Merge remote-tracking branch 'origin/master' into worktree/drop-create-by-name
# Conflicts: # .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md # apps/cli/reference/README.i18n.yaml # docs/config-catalog.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-approval.spec.ts # packages/host/apiproxy/tests/api-proxy-blank.spec.ts # packages/host/apiproxy/tests/api-proxy-cold.spec.ts # packages/host/apiproxy/tests/api-proxy-commands.spec.ts # packages/host/apiproxy/tests/api-proxy-config.spec.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/api-proxy-projections.spec.ts # packages/host/apiproxy/tests/api-proxy-question.spec.ts # packages/host/apiproxy/tests/api-proxy-rename.spec.ts # packages/host/apiproxy/tests/api-proxy-search.spec.ts # packages/host/apiproxy/tests/api-proxy-subagents.spec.ts # packages/host/apiproxy/tests/api-proxy-view.spec.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/todo/tool-todo/tests/projection.spec.ts # scripts/hero-composer-dom-continuity.mjs
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 }
|
||||
@@ -84,13 +91,13 @@ function bench(options: {
|
||||
locate: () => undefined,
|
||||
})
|
||||
// The gateway's own projection push feed subscribes at construction; the
|
||||
// no-op disposer keeps that seam quiet while these tests pin history reads.
|
||||
// no-op disposer keeps that feed quiet while these tests pin history reads.
|
||||
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
|
||||
ctx.provide('userInteraction', { registerProvider: () => () => {} })
|
||||
const api = createApiProxy(ctx, {
|
||||
provider: 'p', model: 'm', cwd: '/tmp',
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/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: {} },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user