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:
_Kerman
2026-08-03 10:56:09 +08:00
605 changed files with 25740 additions and 2265 deletions

View File

@@ -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()

View File

@@ -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', () => {

View File

@@ -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)

View File

@@ -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',