Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml # .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml # .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl # packages/acp/acp/README.i18n.yaml # packages/acp/acp/src/index.ts # packages/client/connection/src/client/index.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/queue-store.spec.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-goal/README.i18n.yaml # packages/compact/compact-basic/src/index.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/tmux-context/tests/tmux-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/session/README.i18n.yaml # packages/core/session/README.md # packages/core/session/README.zh.md # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/goal/tool-goal/README.i18n.yaml # packages/goal/tool-goal/README.md # packages/goal/tool-goal/README.zh.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/api/events.schema.ts # packages/host/apiproxy/src/api/events.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/tests/api-proxy-commands.spec.ts # packages/host/apiproxy/tests/api-proxy-projections.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-persistence/session-checkpoint-policy/src/index.ts # packages/session-persistence/session-persistence/tests/coordinator-contract.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/subagent/subagent-inprocess/README.i18n.yaml # packages/subagent/subagent-inprocess/README.md # packages/subagent/subagent-inprocess/README.zh.md # packages/subagent/subagent-inprocess/src/index.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/support/acp-snapshot/src/harness.ts # packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
@@ -547,7 +547,10 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
const prepared = this.prepare(ownerCtx, options.sessionId, options.agentOptions ?? {}, session, options.signal)
|
||||
const published = (async () => {
|
||||
try {
|
||||
await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId)
|
||||
const setupCommit = await raceAbort(
|
||||
options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId,
|
||||
)
|
||||
setupCommit?.commit()
|
||||
return prepared.publish('startup')
|
||||
} catch (error: unknown) {
|
||||
await prepared.dispose()
|
||||
@@ -606,7 +609,8 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
})
|
||||
const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal)
|
||||
try {
|
||||
await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id)
|
||||
const setupCommit = await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id)
|
||||
setupCommit?.commit()
|
||||
return prepared.publish('resume')
|
||||
} catch (error: unknown) {
|
||||
await prepared.dispose()
|
||||
|
||||
@@ -103,6 +103,106 @@ describe('addressable inbox operations', () => {
|
||||
.toEqual(['first', 'edited'])
|
||||
expect(agent.inbox.splice('next-turn', 0, 1, [])).toEqual([])
|
||||
})
|
||||
|
||||
it('strictly transfers a queued occurrence into the open turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('queue-to-steer'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<{ kind: 'allow' }>()
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
|
||||
const enqueued: InboxItem[] = []
|
||||
const discarded: InboxItem[] = []
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject === agent) enqueued.push(item)
|
||||
})
|
||||
ctx.on('agent/inbox/discard', (subject, items) => {
|
||||
if (subject === agent) discarded.push(...items)
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'open the turn')
|
||||
const receipt = agent.steer(createUserMessage({
|
||||
content: [{ type: 'text', text: 'steer this message' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await entered.promise
|
||||
const queued = enqueued.find(item => inboxText(item) === 'steer this message')!
|
||||
|
||||
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied')
|
||||
const steering = enqueued.find(item => item.placement === 'steering')!
|
||||
expect(steering.id).not.toBe(queued.id)
|
||||
expect(steering.message).toBe(queued.message)
|
||||
expect(discarded).toEqual([queued])
|
||||
|
||||
decision.resolve({ kind: 'allow' })
|
||||
await idle
|
||||
expect(agent.session.events.flatMap(event =>
|
||||
event.type === 'steering/message' ? [event.data.message] : [],
|
||||
)).toEqual([queued.message])
|
||||
expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 1, step: 1 })
|
||||
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('not-found')
|
||||
})
|
||||
|
||||
it('keeps a queued occurrence when the next-step window is closed', () => {
|
||||
const ctx = new Context()
|
||||
const session = new Session(SessionId('queue-to-steer-closed'))
|
||||
const agent = new ReactLoopAgent(ctx, session.id, {}, session)
|
||||
const enqueued: InboxItem[] = []
|
||||
const discarded: InboxItem[] = []
|
||||
ctx.on('agent/inbox/enqueue', (_subject, item) => { enqueued.push(item) })
|
||||
ctx.on('agent/inbox/discard', (_subject, items) => { discarded.push(...items) })
|
||||
|
||||
agent.send(
|
||||
createUserMessage({ content: [{ type: 'text', text: 'stay queued' }], source: { kind: 'user' } }),
|
||||
{ target: 'next-turn', wakeup: false },
|
||||
)
|
||||
const queued = enqueued[0]!
|
||||
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('steer-unavailable')
|
||||
expect(discarded).toEqual([])
|
||||
expect(agent.updateInbox(queued.id, { kind: 'remove' })).toBe('applied')
|
||||
})
|
||||
|
||||
it('accounts for both occurrences when steering enqueue cancels reentrantly', async () => {
|
||||
const adapter = new MockAdapter([textResponse('unused')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('queue-to-steer-cancel'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const decision = Promise.withResolvers<{ kind: 'allow' }>()
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
entered.resolve(undefined)
|
||||
return decision.promise
|
||||
})
|
||||
|
||||
const enqueued: InboxItem[] = []
|
||||
const discarded: InboxItem[] = []
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject !== agent) return
|
||||
enqueued.push(item)
|
||||
if (item.placement === 'steering') agent.cancel({ kind: 'user' })
|
||||
})
|
||||
ctx.on('agent/inbox/discard', (subject, items) => {
|
||||
if (subject === agent) discarded.push(...items)
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'open the turn')
|
||||
await entered.promise
|
||||
send(agent, 'cancel during conversion')
|
||||
const queued = enqueued.find(item => inboxText(item) === 'cancel during conversion')!
|
||||
|
||||
expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied')
|
||||
const steering = enqueued.find(item => item.placement === 'steering')!
|
||||
expect(discarded).toEqual([steering, queued])
|
||||
|
||||
decision.resolve({ kind: 'allow' })
|
||||
await idle
|
||||
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assistant replay provenance', () => {
|
||||
|
||||
@@ -291,6 +291,13 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
setupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
order.push('setup:end')
|
||||
return {
|
||||
commit: () => {
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
order.push('setup:commit')
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -304,6 +311,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
expect(order).toEqual([
|
||||
'setup:start',
|
||||
'setup:end',
|
||||
'setup:commit',
|
||||
'session/created',
|
||||
'setup-listener:session/created',
|
||||
'agent/created',
|
||||
@@ -359,6 +367,33 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume setup commit rejection publishes nothing and releases the identity', async () => {
|
||||
const sessionId = SessionId('resume-setup-commit-reject')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
await expect(ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: () => ({
|
||||
commit: () => { throw new Error('resume setup commit failed') },
|
||||
}),
|
||||
})).rejects.toThrow('resume setup commit failed')
|
||||
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
const retry = await ctx.agents.resume({
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
await retry.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('owner unload aborts resume setup and cannot publish after the callback settles', async () => {
|
||||
const sessionId = SessionId('resume-setup-owner-unload')
|
||||
const root = await persistSession(sessionId)
|
||||
|
||||
@@ -264,6 +264,13 @@ describe('agent scope lifecycle', () => {
|
||||
setupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
order.push('setup:end')
|
||||
return {
|
||||
commit: () => {
|
||||
expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('atomic'))).toBeUndefined()
|
||||
order.push('setup:commit')
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
await setupStarted.promise
|
||||
@@ -276,6 +283,7 @@ describe('agent scope lifecycle', () => {
|
||||
expect(order).toEqual([
|
||||
'setup:start',
|
||||
'setup:end',
|
||||
'setup:commit',
|
||||
'session/created',
|
||||
'setup-listener:session/created',
|
||||
'agent/created',
|
||||
|
||||
Reference in New Issue
Block a user