Merge remote-tracking branch 'origin/master' into worktree/web-background-tasks-display-258f7e
# Conflicts: # docs/cordis-catalog/services.md # docs/subsystems/lsp.i18n.yaml # docs/subsystems/tasks.md # docs/subsystems/tasks.zh.md # packages/client/README.i18n.yaml # packages/client/runtime/README.i18n.yaml # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/tsconfig.json # packages/tasks/tasks/README.i18n.yaml # tsconfig.base.json
This commit is contained in:
@@ -19,6 +19,7 @@ function bench(options: {
|
||||
childStatus?: 'idle' | 'running'
|
||||
entries?: object[]
|
||||
followupError?: Error
|
||||
interruptError?: Error
|
||||
listError?: Error
|
||||
/** Persistence forgets the child entirely (the vanished-mid-read race). */
|
||||
storedChild?: false
|
||||
@@ -53,6 +54,12 @@ function bench(options: {
|
||||
) => options.followupError === undefined
|
||||
? Promise.resolve('message-1')
|
||||
: Promise.reject(options.followupError))
|
||||
const interrupt = vi.fn((
|
||||
_targetSessionId: SessionId,
|
||||
_authority: { kind: 'user'; parentSessionId: SessionId },
|
||||
) => {
|
||||
if (options.interruptError !== undefined) throw options.interruptError
|
||||
})
|
||||
const childHeader = {
|
||||
version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT,
|
||||
} satisfies SessionHeader
|
||||
@@ -72,7 +79,7 @@ function bench(options: {
|
||||
})
|
||||
const ctx = new Context()
|
||||
ctx.provide('agents', { get: getAgent })
|
||||
ctx.provide('subagents', { listChildren, followup })
|
||||
ctx.provide('subagents', { listChildren, followup, interrupt })
|
||||
ctx.provide('sessions', {
|
||||
get: (id: SessionId) => options.liveChild === true && id === CHILD
|
||||
? { id: CHILD, header: childHeader, events: childEvents }
|
||||
@@ -90,7 +97,7 @@ function bench(options: {
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp',
|
||||
})
|
||||
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent }
|
||||
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent }
|
||||
}
|
||||
|
||||
describe('subagent gateway', () => {
|
||||
@@ -309,4 +316,48 @@ describe('subagent gateway', () => {
|
||||
error: { code: 'internal', message: 'subagent prompt failed' },
|
||||
})
|
||||
})
|
||||
|
||||
it('interrupts through the core primitive alone while the parent Agent is offline', async () => {
|
||||
const { api, interrupt, getAgent, listChildren, inspect } = bench({ parentLive: false })
|
||||
const response = await api.subagents.interrupt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
|
||||
}))
|
||||
expect(response.rpcId).toBe('subagent-rpc')
|
||||
expect(response.result).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(interrupt).toHaveBeenCalledExactlyOnceWith(CHILD, { kind: 'user', parentSessionId: PARENT })
|
||||
// No parent-registry, catalog, or history dependency: this is what keeps a
|
||||
// live child interruptible after its parent Agent went offline.
|
||||
expect(getAgent).not.toHaveBeenCalled()
|
||||
expect(listChildren).not.toHaveBeenCalled()
|
||||
expect(inspect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps interrupt authorization rejection without touching other services', async () => {
|
||||
const { api, listChildren } = bench({
|
||||
interruptError: new SubagentError('secret lineage', 'UNAUTHORIZED'),
|
||||
})
|
||||
const response = await api.subagents.interrupt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
|
||||
}))
|
||||
expect(response.result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'subagent-unauthorized',
|
||||
message: 'subagent does not belong to this parent',
|
||||
details: { childSessionId: CHILD },
|
||||
},
|
||||
})
|
||||
expect(listChildren).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides unexpected interrupt failures behind the internal code', async () => {
|
||||
const { api } = bench({ interruptError: new Error('secret activation state') })
|
||||
const response = await api.subagents.interrupt(request({
|
||||
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
|
||||
}))
|
||||
expect(response.result).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'subagent interrupt failed', details: {} },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,6 +63,7 @@ function scriptedApi(overrides: {
|
||||
list: r => ok(r, { entries: [], parentAvailable: false }),
|
||||
history: r => ok(r, { events: [], hasMore: false }),
|
||||
prompt: r => ok(r, { messageId: 'message-1' as never }),
|
||||
interrupt: r => ok(r, { accepted: true as const }),
|
||||
...overrides.subagents,
|
||||
},
|
||||
host: {
|
||||
@@ -248,6 +249,32 @@ describe('unary round trip', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trips subagent.interrupt and rejects a one-shot or incomplete address', async () => {
|
||||
const interrupt = vi.fn((r: RpcRequest<unknown>) => ok(r, { accepted: true as const }))
|
||||
const api = scriptedApi({ subagents: { interrupt } })
|
||||
const c = client(api)
|
||||
|
||||
const accepted = await c.subagents.interrupt({
|
||||
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'continuable',
|
||||
})
|
||||
expect(accepted.result).toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(interrupt).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The wire schema owns the mode fence: a one-shot address never reaches the impl.
|
||||
const oneShot = await c.subagents.interrupt({
|
||||
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'one-shot',
|
||||
} as never)
|
||||
expect(oneShot.result.ok).toBe(false)
|
||||
if (!oneShot.result.ok) expect(oneShot.result.error.code).toBe('bad-request')
|
||||
|
||||
const incomplete = await c.subagents.interrupt({
|
||||
parentSessionId: sid('parent'), mode: 'continuable',
|
||||
} as never)
|
||||
expect(incomplete.result.ok).toBe(false)
|
||||
if (!incomplete.result.ok) expect(incomplete.result.error.code).toBe('bad-request')
|
||||
expect(interrupt).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects a method/path mismatch as bad-request', async () => {
|
||||
const handler = toFetchHandler(scriptedApi())
|
||||
const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} }
|
||||
|
||||
@@ -128,6 +128,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
result: { ok: true, value: { messageId: 'message-1' as never } },
|
||||
}
|
||||
},
|
||||
async interrupt(request) {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
|
||||
},
|
||||
},
|
||||
host: {
|
||||
async describe(request) {
|
||||
@@ -433,6 +436,11 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
|
||||
mode: 'continuable',
|
||||
content: [],
|
||||
})).result).toEqual({ ok: true, value: { messageId: 'message-1' } })
|
||||
expect((await c.subagents.interrupt({
|
||||
parentSessionId: 'parent' as never,
|
||||
childSessionId: 'child' as never,
|
||||
mode: 'continuable',
|
||||
})).result).toEqual({ ok: true, value: { accepted: true } })
|
||||
})
|
||||
|
||||
it('keeps caller and connection aborts on command.execute', async () => {
|
||||
|
||||
Reference in New Issue
Block a user