From 8e88b17c9f8d09577783be8ceca6bc14125372df Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 31 Jul 2026 19:40:59 +0800 Subject: [PATCH] fix pre-step lifecycle regressions --- ...n-pressure-and-overflow-recovery.i18n.yaml | 2 +- ...ction-pressure-and-overflow-recovery.zh.md | 2 +- ...send-and-coalesced-user-messages.i18n.yaml | 2 +- ...ied-send-and-coalesced-user-messages.zh.md | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/cordis-catalog/events.md | 20 +- docs/cordis-catalog/services.md | 2 +- .../bash/tool-bash/tests/integration.spec.ts | 18 +- .../tests/compact-loop-repro.spec.ts | 30 ++- .../context/time-context/src/invariant.ts | 14 +- .../time-context/tests/invariant.spec.ts | 5 +- .../time-context/tests/time-context.spec.ts | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 8 - packages/core/agent-loop/src/agent.ts | 1 + .../tests/contract-regressions.spec.ts | 3 +- packages/core/agent-loop/tests/loop.spec.ts | 5 +- .../tests/request-reconstruction.spec.ts | 12 +- packages/core/agent/src/inbox.ts | 2 +- .../agent-spine-demo/tests/agent-core.spec.ts | 26 ++- packages/goal/goal-session/src/index.ts | 67 +++++-- .../goal-session/tests/goal-session.spec.ts | 17 +- .../hooks/hooks-claude/tests/bridge.spec.ts | 10 +- .../hooks/hooks-codex/tests/coverage-cases.ts | 8 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 188 +++++++++--------- .../llm-deepseek/tests/dynamic-config.spec.ts | 3 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 36 ++-- .../llm-pi-ai/tests/dynamic-config.spec.ts | 4 +- packages/plan/plan-mode/README.i18n.yaml | 4 +- packages/plan/plan-mode/README.md | 2 +- packages/plan/plan-mode/README.zh.md | 2 +- packages/plan/plan-mode/src/index.ts | 37 ++-- .../plan/plan-mode/tests/integration.spec.ts | 26 ++- .../plan/plan-mode/tests/plan-mode.spec.ts | 20 +- .../tests/subagent-inprocess.spec.ts | 5 +- packages/ui/tui/tests/tui.spec.ts | 1 - 37 files changed, 331 insertions(+), 265 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml index 7852bcf11e..3ad7f81ef6 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md 2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 6fbd5e2c9d57da3f25c72c652ca50eb45b84323c -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 5f42a9926abc490274fbc0a180e46719166e441c +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 5aef054b80fb5d14ab7a09d6c696acdaefde6155 diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md index 5f42a9926a..5aef054b80 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md @@ -58,4 +58,4 @@ Compact-basic 会在每个拟议请求之前包装 `agent/pre-step`。在续步 代价是在共享 pre-step waterfall 中执行压力工作,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分不可分割的非工具节点,或修复非可剪枝剩余部分仍然过大的工具单元。若可移除的文本工具结果是主要体积,可选剪枝器仍可修复原本不可分割的工具配对。 -[已领取 pre-step 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md)取代了本记录原先的 post-step 触发方式。服务拆分、独立 token meter、平衡范围契约、日志记录锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。 +[已领取 pre-step 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.md)取代了本记录原先的 post-step 触发方式。服务拆分、独立 token meter、平衡范围契约、日志记录锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index deb1001f22..a3fc44c0dd 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md 2026-07-22-unified-send-and-coalesced-user-messages.md: 4bc4303f636ea7ff268d0b3f2d0a65dfba954883 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 4dc725d5d03f1871c7fb5115577e3283bb395add +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 02c3bcafa246d52da0ef3b8f0dfd956091eedfb6 diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 4dc725d5d0..02c3bcafa2 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -43,7 +43,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` 投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。`Agent` 契约仍是接口,因此其他实现和对象字面量形式的测试替身只需实现同一个最小结构接口。goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。空闲注入会保持待处理,不打开轮次也不运行模型;后续会唤醒的投递在 pre-step 将其放入进入步骤的批次时,它才成为 `user/message`。 -`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可领取的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算完全停稳。每次插入与退出都会发布对应的实时通知,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理消息的表示方式,使持久 splice 与实时事件保持可关联,既无需维护第二个 steering 包装层,也避免数据发生分歧。后续的[已领取 pre-step inbox 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md)决策保留通过 `MessageId` 寻址的实时队列变更,并把单消息生命周期通知与持久的整体队列 splice 投影分离。 +`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可领取的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算完全停稳。每次插入与退出都会发布对应的实时通知,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理消息的表示方式,使持久 splice 与实时事件保持可关联,既无需维护第二个 steering 包装层,也避免数据发生分歧。后续的[已领取 pre-step inbox 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.md)决策保留通过 `MessageId` 寻址的实时队列变更,并把单消息生命周期通知与持久的整体队列 splice 投影分离。 ## 相关 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 7e113833bc..2825174e30 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 38f889b32a75d945876c1174b1673ff741960c10 -architecture.zh.md: 919b7cf4efb5f562b2d3c27335126ed101771211 +architecture.md: d2e094be43f22abb6d829cd6484c09e8f34130b0 +architecture.zh.md: f9588b52ac220d509d340270bed36036ef8f8b03 diff --git a/docs/architecture.md b/docs/architecture.md index 38f889b32a..d2e094be43 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,7 +121,7 @@ Each step assembles ordered prompt sections, tool schemas, and variables; unknow `agent/pre-step` receives the exclusive batch already removed from the inbox and finalizes whether the loop enters the proposed step. Its `PreStepContext` carries the exact upcoming turn and step plus the cancellation signal. `{ kind: 'reject' }` opens no step; `{ kind: 'enter', messages }` supplies the complete batch appended after `step/start`. A tool continuation with no newly claimed inbox input submits an empty batch so listeners can still contribute current-step context. Waterfall rewrites settle only in the final returned `messages` value. -Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize one retry turn between failed-step and turn close; cancellation wins. Adapter-owned `retryPolicy` makes normal mode bounded; always mode delegates specialized recovery before retrying until success or cancellation ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). +Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize a same-step retry before the step closes; cancellation wins, and the retry reuses that step's frozen prompt assembly. Adapter-owned `retryPolicy` makes normal mode bounded; always mode delegates specialized recovery before retrying until success or cancellation ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). ### Failure Boundaries diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 919b7cf4ef..f9588b52ac 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -121,7 +121,7 @@ idle inject: `agent/pre-step` 接收已经从 inbox 删除的独占批次,并最终决定循环是否进入拟议步骤。它的 `PreStepContext` 携带准确的 turn、step 与取消 signal。`{ kind: 'reject' }` 不会打开步骤;`{ kind: 'enter', messages }` 提供在 `step/start` 后追加的完整批次。当工具 continuation 没有新领取的 inbox 输入时,批次为空,listener 仍可为当前步骤贡献上下文。waterfall 的全部改写只在最终返回的 `messages` 中一次性结算。 -裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以在失败步骤与轮次关闭之间授权一个重试轮次;取消优先。适配器拥有的 `retryPolicy` 使 normal mode 保持有界;always mode 先委托专门恢复,再持续重试直至成功或取消([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。 +裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以在步骤关闭前授权一次同步骤重试;取消优先,且重试会复用该步骤已冻结的 prompt assembly。适配器拥有的 `retryPolicy` 使 normal mode 保持有界;always mode 先委托专门恢复,再持续重试直至成功或取消([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。 ### 失败边界 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index f24e268b63..6bc6e27824 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:171`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:166`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:281`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) ### `agent/inbox/claimed` — emit @@ -151,9 +151,9 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p 'agent/pre-step'(this: Scoped, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [PreStepDecision](../core-data-structures/core.md) · [PreStepContext](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) +Types: [Agent](../core-data-structures/core.md) · [PreStepContext](../core-data-structures/core.md) · [PreStepDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:216`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -177,7 +177,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:242`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -200,7 +200,7 @@ Handle one failed model-request attempt before the loop retries or closes its st Types: [Agent](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [RequestFailureContext](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:254`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:258`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -222,7 +222,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:221`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -243,7 +243,7 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running` Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:190`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:185`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -269,7 +269,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:269`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a157512c43..6238a9e497 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -220,7 +220,7 @@ Source: [`packages/core/agent/src/index.ts:216`](../../packages/core/agent/src/i ## `ctx.approval` — `ApprovalService` -Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session. It exposes deterministic policy changes to the model through prompt and pre-step notices. +Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session. It exposes deterministic policy changes to the model through prompt-submission notices. ```ts cordis-catalog /** diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 5616afcdef..bb63781665 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -172,7 +172,7 @@ describe('bash tool through the agent loop', () => { expect(resultText(toolResult)).toContain('[exit code: 9]') }) - it('background: start ack → completion notice as user/message → task_output collects it', async () => { + it('background: start ack → pending completion notice → task_output collects it', async () => { // The task id is deterministic (a fresh LocalTaskService counts per kind from 1), // so the script can name `bash-1` without threading a generated id. const adapter = new MockAdapter([ @@ -192,20 +192,22 @@ describe('bash tool through the agent loop', () => { expect(resultText(firstResult)).toBe('started background task bash-1') // The task settles on its own; the tool-tasks notice listener injects a - // durable plugin-sourced user/message into the owning agent's session - // (settlement may race turn end, so poll for it). + // pending next-step message without waking the idle agent. const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> => e.type === 'user/message' && e.data.source.kind === 'plugin' - await pollUntil(() => events(agent).some(isNotice)) - const notice = events(agent).find(isNotice)! - expect(notice.data.content.some( + await pollUntil(() => agent.inbox.nextStep.some(message => message.source.kind === 'plugin')) + const pendingNotice = agent.inbox.nextStep.find(message => message.source.kind === 'plugin')! + expect(pendingNotice.content.some( block => block.type === 'text' && block.text.includes('background task bash-1 (bash: echo bg-ok) finished'), )).toBe(true) - expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' }) + expect(pendingNotice.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' }) - // The next turn collects the output through the generic task tool. + // The next turn first admits that notice as user/message, then collects + // the output through the generic task tool. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'collect it' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) + const notice = events(agent).find(isNotice)! + expect(notice.data).toEqual(pendingNotice) const readResult = findEvent(events(agent), 'tool/result', 'last') expect(readResult.data.message.content[0].isError).toBe(false) expect(resultText(readResult)).toContain('bg-ok') diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 84f51375b3..b7146a2a23 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -306,7 +306,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () describe('context-overflow recovery across the real loop and compact-basic', () => { it.each(['thrown', 'in-band'] as const)( - 'force-compacts a %s overflow between failed and retry steps', + 'force-compacts a %s overflow within the retried step', async (delivery) => { const ctx = new Context() const adapter = new OverflowRecoveryAdapter(delivery) @@ -347,18 +347,12 @@ describe('context-overflow recovery across the real loop and compact-basic', () expect(retry).not.toContain('OLD HISTORY SENTINEL') const events = [...agent.session.events] - const failedStepEnd = events.find(event => + const stepStart = events.find(event => + event.type === 'step/start' && event.data.turn === 3 && event.data.step === 1, + )! + const stepEnd = events.find(event => event.type === 'step/end' && event.data.turn === 3 && event.data.step === 1, )! - const failedEnd = events.find(event => - event.type === 'turn/end' && event.data.turn === 3, - )! - const retryStart = events.find(event => - event.type === 'turn/start' && event.data.turn === 4, - )! - const retryStep = events.find(event => - event.type === 'step/start' && event.data.turn === 4 && event.data.step === 1, - )! const compaction = events.filter(event => event.type === 'compact/start' || event.type === 'compact/summary' @@ -369,11 +363,13 @@ describe('context-overflow recovery across the real loop and compact-basic', () 'compact/summary', 'compact/end', ]) - expect(retryStart.seq).toBeGreaterThan(failedEnd.seq) expect(compaction.every(event => - event.seq > failedStepEnd.seq && event.seq < failedEnd.seq, + event.seq > stepStart.seq && event.seq < stepEnd.seq, )).toBe(true) - expect(retryStep.seq).toBeGreaterThan(retryStart.seq) + expect(events.filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn)) + .toEqual([3]) + expect(events.filter(event => event.type === 'step/start' && event.data.turn === 3)) + .toHaveLength(1) expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, @@ -413,9 +409,9 @@ describe('context-overflow recovery across the real loop and compact-basic', () expect(adapter.conversationRequests).toHaveLength(3) expect(adapter.summaryRequests).toHaveLength(1) expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data)) - .toEqual([expect.objectContaining({ turn: 4, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })]) - expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-3).map(event => event.data.turn)) - .toEqual([3, 4, 5]) + .toEqual([expect.objectContaining({ turn: 3, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })]) + expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn)) + .toEqual([3]) expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index d561b69308..ec1fa015ed 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -18,16 +18,20 @@ export const name = 'time-context-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Derive the next request boundary at which a time-context reading may append. */ +/** Derive the entered step boundary at which a time-context reading may append. */ function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } { for (const event of history.slice().reverse()) { switch (event.type) { - case 'step/end': - return { turn: event.data.turn, step: event.data.step + 1 } - case 'turn/start': - return { turn: event.data.turn, step: 1 } case 'step/start': + return { turn: event.data.turn, step: event.data.step } + case 'turn/start': + case 'step/end': case 'turn/end': + case 'request/header': + case 'assistant/chunk': + case 'assistant/message': + case 'tool/call': + case 'tool/result': fail('time-context reading must be appended at a prompt boundary') break default: diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index 3a161fb0ff..059b809e52 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -58,6 +58,7 @@ function preparing(turn: number, step: number): Session { session.append('step/start', { turn, step: priorStep }) session.append('step/end', { turn, step: priorStep }) } + session.append('step/start', { turn, step }) return session } @@ -88,6 +89,7 @@ describe('time-context invariants', () => { await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('time-invariant-late-valid')) session.append('turn/start', { turn: 1 }) + session.append('step/start', { turn: 1, step: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'prepare' }], source: { kind: 'user' }, @@ -103,6 +105,7 @@ describe('time-context invariants', () => { await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('time-invariant-late-invalid')) session.append('turn/start', { turn: 1 }) + session.append('step/start', { turn: 1, step: 1 }) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'prepare' }], source: { kind: 'user' }, @@ -132,7 +135,7 @@ describe('time-context invariants', () => { it('rejects a reading outside a prompt boundary', async () => { const ctx = await setup() const ended = preparing(1, 1) - ended.append('step/start', { turn: 1, step: 1 }) + ended.append('step/end', { turn: 1, step: 1 }) expect(() => { ctx.emit('session/event', ended, event(reading())) }).toThrow(/at a prompt boundary/) expect(() => { ctx.emit('session/event', new Session(SessionId('time-invariant-empty')), event(reading())) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 38ea62f2ed..6e9caaf71e 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -17,7 +17,7 @@ const SIGNAL = new AbortController().signal beforeEach(() => { process.env['TZ'] = 'UTC' - vi.useFakeTimers() + vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(BASE) }) @@ -405,7 +405,7 @@ describe('real agent-loop request history', () => { expect(contexts).toHaveLength(adapter.requests.length) expect(starts).toHaveLength(adapter.requests.length) for (let index = 0; index < contexts.length; index += 1) { - expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq) + expect(contexts[index]!.seq).toBeGreaterThan(starts[index]!.seq) } expect(contexts.every(event => event.data.source.kind === 'plugin' && event.data.source.plugin === 'time-context' diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 07f406227a..86a0042f7c 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2059,14 +2059,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PreparedReferencedMessage', declaration: 'export interface PreparedReferencedMessage {\n content: ContentBlock[];\n additionalContext?: UserMessage;\n}', }, - { - name: 'PreStepContext', - declaration: 'export interface PreStepContext {\n readonly turn: number;\n readonly step: number;\n readonly signal: AbortSignal;\n}', - }, - { - name: 'PreStepDecision', - declaration: 'export type PreStepDecision = {\n kind: \'reject\';\n} | {\n kind: \'enter\';\n messages: UserMessage[];\n};', - }, { name: 'PresetOption', declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index d78ffbe41f..b11912e2ce 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -217,6 +217,7 @@ export class ReactLoopAgent implements Agent { await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal) signal.throwIfAborted() } + if (turnEnds && this.inbox.nextStep.length === 0) break decision = await this.preStep('next-step', { turn, step: phase.step + 1 }) if (decision.kind === 'reject') { turnEnds = { kind: 'blocked' } diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index d2e3659701..28c36a24a8 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -292,7 +292,6 @@ describe('abort during tool execution ends the turn', () => { { type: 'finish', reason: { kind: 'tool-calls' } }, ] satisfies StreamChunk[], textResponse('later turn'), - textResponse('context accepted'), ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' }) @@ -338,7 +337,7 @@ describe('abort during tool execution ends the turn', () => { ? [event.data.content] : [])[0]) .toEqual([{ type: 'text', text: 'new turn context' }]) - expect(JSON.stringify(adapter.requests[2]?.messages)).toContain('new turn context') + expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context') }) }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index d8aa8dddcd..384b0a3ab0 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -313,11 +313,12 @@ describe('agent loop', () => { const steering = agent.session.events.find(e => e.type === 'user/message' && JSON.stringify(e.data.content).includes('change of plans')) expect(steering).toBeDefined() - // Steering enters history before the second step's request derives it. + // The entered batch is appended after the second step opens and before its + // request derives history. const steeringSeq = steering!.seq const secondStepStart = agent.session.events.filter(e => e.type === 'step/start')[1] expect(secondStepStart).toBeDefined() - expect(steeringSeq).toBeLessThan(secondStepStart!.seq) + expect(steeringSeq).toBeGreaterThan(secondStepStart!.seq) // the second model request saw the steering content const secondRequest = adapter.requests[1] diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 72524eaa6c..4d83315d35 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -590,14 +590,18 @@ describe('request stability across the loop', () => { adapter.requests.forEach((request, index) => { const stepStart = stepStarts[index]! - // Messages: the derivation over the log prefix strictly before this - // step's step/start — rebuilt here through a completely fresh Session. - const rebuilt = new Session(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, stepStart.seq))) + const firstChunk = events.find(e => + e.type === 'assistant/chunk' + && e.data.turn === stepStart.data.turn + && e.data.step === stepStart.data.step, + )! + // Messages: the entered batch is logged after step/start, so rebuild the + // complete dispatch prefix through a completely fresh Session. + const rebuilt = new Session(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, firstChunk.seq))) expect(structuredClone(request.messages)).toEqual(rebuilt.deriveMessages()) // Header: the latest request/header snapshot up to this step's dispatch // (its header event sits between step/start and the first chunk). - const firstChunk = events.find(e => e.type === 'assistant/chunk' && e.seq > stepStart.seq)! const header = foldRequestHeader(events.slice(0, firstChunk.seq))! expect(request.model).toBe(header.config.model) expect(request.reasoningEffort).toBe(header.config.reasoningEffort) diff --git a/packages/core/agent/src/inbox.ts b/packages/core/agent/src/inbox.ts index 01bb24dd45..68479ae0d5 100644 --- a/packages/core/agent/src/inbox.ts +++ b/packages/core/agent/src/inbox.ts @@ -156,7 +156,7 @@ export class Inbox { inbox.length - actualStart, ) if (actualDeleteCount === 0 && inserted.length === 0) return [] - const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' : undefined + const outcome = discardRemoved && actualDeleteCount > 0 ? 'canceled' as const : undefined const splice = { target, start: actualStart, diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 4c372f743c..ed65593e21 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -335,7 +335,7 @@ describe('dsh-agent-spine-demo bundle', () => { try { await mkdir(join(root, '.git'), { recursive: true }) await writeFile(join(root, 'AGENTS.md'), 'bundled project rule') - const adapter = new MockAdapter([textResponse('ok')]) + const adapter = new MockAdapter([textResponse('first'), textResponse('ok')]) const ctx = await mount({ workspaceContext: { maxBytes: 65536 } }) await ctx.plugin(LocalFileSystem, { cwd: '/' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -349,9 +349,12 @@ describe('dsh-agent-spine-demo bundle', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) - const sentText = adapter.requests[0]?.messages.map(messageText).join('\n') - expect(sentText).toContain('hi') - expect(sentText).toContain('bundled project rule') + expect(adapter.requests).toHaveLength(2) + const firstRequestText = adapter.requests[0]?.messages.map(messageText).join('\n') + const secondRequestText = adapter.requests[1]?.messages.map(messageText).join('\n') + expect(firstRequestText).toContain('hi') + expect(firstRequestText).not.toContain('bundled project rule') + expect(secondRequestText).toContain('bundled project rule') expect(adapter.requests[0]?.system).toContain('You are an AI agent powered by the DeepSeek Harness SDK.') expect(adapter.requests[0]?.system).not.toContain('bundled project rule') await handle.dispose() @@ -585,12 +588,12 @@ describe('dsh-agent-spine-demo bundle', () => { }).toThrow('agent-spine-demo: dshHome and skills.local.dshHome must resolve to the same directory') }) - it('places workspace instructions before the skill catalog in the session prefix', async () => { + it('delivers workspace instructions after the first-step skill catalog', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-agent-spine-demo-prefix-order-')) try { await mkdir(join(root, '.git'), { recursive: true }) await writeFile(join(root, 'AGENTS.md'), 'workspace rule before skills') - const adapter = new MockAdapter([textResponse('ok')]) + const adapter = new MockAdapter([textResponse('first'), textResponse('ok')]) const ctx = await mount({ workspaceContext: { maxBytes: 65536 } }) await ctx.plugin(LocalFileSystem, { cwd: '/' }) ctx.llm.registerAdapter(['mock'], adapter) @@ -609,8 +612,15 @@ describe('dsh-agent-spine-demo bundle', () => { handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })) await waitForIdle(ctx, handle.agent) - expect(messageText(adapter.requests[0]?.messages[1])).toContain('workspace rule before skills') - expect(messageText(adapter.requests[0]?.messages[2])).toContain('prefix-order-skill') + expect(adapter.requests).toHaveLength(2) + const firstCatalogIndex = adapter.requests[0]!.messages.findIndex( + message => messageText(message).includes('prefix-order-skill'), + ) + const workspaceIndex = adapter.requests[1]!.messages.findIndex( + message => messageText(message).includes('workspace rule before skills'), + ) + expect(firstCatalogIndex).toBeGreaterThanOrEqual(0) + expect(workspaceIndex).toBeGreaterThanOrEqual(0) await handle.dispose() await ctx.fiber.dispose() } finally { diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index 14a34693e8..c006fc8b55 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -10,7 +10,7 @@ import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import type { GoalMessageSource, GoalRef, GoalView } from '@deepseek-ai/dsh-goal' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session' import { renderGoalRoundPrompt } from './prompt.ts' export { renderGoalRoundPrompt } from './prompt.ts' @@ -25,11 +25,11 @@ interface RoundIdentity { readonly round: number } -/** One queued or admitted goal message retained until whole-agent quiescence. */ +/** One queued, claimed, or admitted goal message retained until whole-agent quiescence. */ interface RoundAttempt extends RoundIdentity { readonly messageId: MessageId readonly content: ContentBlock[] - phase: 'queued' | 'admitted' + phase: 'queued' | 'claimed' | 'admitted' cancelled: boolean stale: boolean } @@ -123,6 +123,16 @@ export function apply(ctx: Context): void { } } + /** Preserve claimed step context when this driver drops only its own round. */ + function restoreOtherClaimed(agent: Agent, messages: UserMessage[], messageId: MessageId): void { + const retained = messages.filter(message => message.id !== messageId) + for (const message of retained.toReversed()) { + if (agent.inbox.nextStep.some(candidate => candidate.id === message.id) + || agent.inbox.nextTurn.some(candidate => candidate.id === message.id)) continue + agent.inbox.prepend('next-step', message) + } + } + /** Process admitted work at quiescence, then reserve at most one next round. */ async function drive(state: DriverState): Promise { const { agent } = state @@ -144,7 +154,7 @@ export function apply(ctx: Context): void { const attempt = state.attempt if (attempt !== undefined) { - if (attempt.phase === 'queued') return + if (attempt.phase === 'queued' || attempt.phase === 'claimed') return state.attempt = undefined state.needsCheckpoint = true state.requested = true @@ -252,7 +262,7 @@ export function apply(ctx: Context): void { state.competingQueued = false const attempt = state.attempt const goal = currentGoal(state) - if ((attempt?.phase === 'queued' || attempt?.cancelled) + if ((attempt?.phase === 'queued' || attempt?.phase === 'claimed' || attempt?.cancelled) && goal?.phase === 'active' && goal.activation === 'armed') { state.attempt = undefined try { @@ -271,21 +281,34 @@ export function apply(ctx: Context): void { requestDrive(state) }) + ctx.on('agent/inbox/inserted', (agent, { message }) => { + if (!agent.inbox.nextTurn.some(candidate => candidate.id === message.id)) return + const state = stateFor(agent) + const attempt = state.attempt + if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) return + state.competingQueued = true + if (attempt?.phase === 'queued') attempt.stale = true + }) + ctx.on('agent/inbox/claimed', (agent, { message }) => { + const state = stateFor(agent) + const attempt = state.attempt + if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) { + attempt.phase = 'claimed' + } + }) + ctx.on('agent/inbox/discarded', (agent, { message }) => { + const state = stateFor(agent) + const attempt = state.attempt + if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) { + attempt.cancelled = true + } + }) + ctx.on('session/event', (session: Session, event: SessionEvent) => { const agent = ctx.agents.get(session.id) if (agent === undefined || agent.session !== session) return const state = stateFor(agent) switch (event.type) { - case 'agent/inbox/spliced': { - if (event.data.target !== 'next-turn') return - const attempt = state.attempt - for (const message of event.data.inserted) { - if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) continue - state.competingQueued = true - if (attempt?.phase === 'queued') attempt.stale = true - } - return - } case 'user/message': if (state.attempt !== undefined && event.data.id === state.attempt.messageId) { state.attempt.phase = 'admitted' @@ -314,7 +337,7 @@ export function apply(ctx: Context): void { const attempt = state.attempt const goal = currentGoal(state) return ctx.fiber.state === FiberState.ACTIVE - && !state.stopping && attempt !== undefined && attempt.phase === 'queued' + && !state.stopping && attempt !== undefined && attempt.phase === 'claimed' && !attempt.stale && sameQueued(content, source, attempt) && goal !== undefined && goal.id === source.goalId && goal.revision === source.revision && goal.phase === 'active' && goal.activation === 'armed' @@ -340,6 +363,7 @@ export function apply(ctx: Context): void { attempt.stale = true state.attempt = undefined } + restoreOtherClaimed(agent, messages, submitted.id) requestDrive(state) return { kind: 'reject' } } @@ -353,13 +377,16 @@ export function apply(ctx: Context): void { // starve every later drive pass. Clear it and let the driver // reschedule the round. const attempt = state.attempt - if (attempt !== undefined && sameRound(source, attempt) && attempt.phase === 'queued') { + if (attempt !== undefined && sameRound(source, attempt) && attempt.phase === 'claimed') { state.attempt = undefined requestDrive(state) } throw error } - if (signal.aborted) return decision + if (signal.aborted) { + if (decision.kind === 'enter') restoreOtherClaimed(agent, decision.messages, submitted.id) + return decision + } if (decision.kind === 'reject') { const attempt = state.attempt if (attempt !== undefined && sameRound(source, attempt)) state.attempt = undefined @@ -386,6 +413,7 @@ export function apply(ctx: Context): void { attempt.stale = true state.attempt = undefined } + restoreOtherClaimed(agent, decision.messages, submitted.id) requestDrive(state) return { kind: 'reject' } } @@ -409,7 +437,8 @@ export function apply(ctx: Context): void { const attempt = state.attempt if (attempt !== undefined) { attempt.stale = true - if (attempt.phase === 'admitted' && state.agent.status === 'running') { + if ((attempt.phase === 'claimed' || attempt.phase === 'admitted') + && state.agent.status === 'running') { state.agent.cancel({ kind: 'parent' }) waits.push(state.agent.whenIdle()) } diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index eb4ebd3f8c..e1dd02dd32 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -113,6 +113,17 @@ function onInboxMessage( }) } +/** Observe one claimed message at its exclusive pre-step ownership transfer. */ +function onClaimedMessage( + ctx: Context, + agent: Agent, + listener: (message: UserMessage) => void, +): () => void { + return ctx.on('agent/inbox/claimed', (subject, { message }) => { + if (subject === agent) listener(message) + }) +} + /** Await a stable goal projection selected by the caller. */ async function waitForGoal( ctx: Context, @@ -273,8 +284,8 @@ describe('same-session goal driving', () => { it('pauses and drops a reserved round when cancellation lands before pre-step', async () => { const test = await harness([]) - const cancel = onInboxMessage(test.ctx, test.agent, (message) => { - if (message.source.kind === 'goal') { + const cancel = onClaimedMessage(test.ctx, test.agent, (message) => { + if (message.source.kind === 'goal' && message.source.round > 0) { cancel() test.agent.cancel({ kind: 'user' }) } @@ -616,7 +627,7 @@ describe('same-session goal driving', () => { it('fails an initial pre-step read closed even when the first disarm attempt throws', async () => { const test = await harness([textResponse('retry after containment')]) let armed = true - onInboxMessage(test.ctx, test.agent, (message) => { + onClaimedMessage(test.ctx, test.agent, (message) => { if (message.source.kind !== 'goal' || message.source.round <= 0 || !armed) return armed = false vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index ecf1386f9e..3557fc1f87 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -262,11 +262,11 @@ describe('hooks-claude bridge — SessionStart', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - // session-start fires async (detached .then → agent.inject); wait for the - // injected user/message to actually land before sending, rather than a - // fixed sleep that flakes under load. - await waitFor(() => events(agent).some(e => e.type === 'user/message' - && e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs')))) + // session-start fires async (detached .then → agent.inject); injection now + // enters the next-step inbox directly and becomes a user/message only after + // step entry, so synchronize on the pending inbox item before sending. + await waitFor(() => agent.inbox.nextStep.some(message => + message.content.some(block => block.type === 'text' && block.text.includes('project uses tabs')))) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 0ab178b6a9..32bb2aed4e 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -207,8 +207,8 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - await waitFor(() => events(agent).some(e => e.type === 'user/message' - && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx')))) + await waitFor(() => agent.inbox.nextStep.some(message => + message.content.some(block => block.type === 'text' && block.text.includes('start-ctx')))) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('start-ctx') }) @@ -547,8 +547,8 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) - await waitFor(() => events(agent).some(e => e.type === 'user/message' - && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble')))) + await waitFor(() => agent.inbox.nextStep.some(message => + message.content.some(block => block.type === 'text' && block.text.includes('session preamble')))) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble') }) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index ec4a271f15..9e47a061c4 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -2,8 +2,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, - errorChain, - LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId, @@ -206,18 +204,22 @@ describe('DeepSeekAdapter against a mock server', () => { }) }) - it('rejects a per-request effort before I/O when thinking is disabled', async () => { + it('reports a per-request effort failure before I/O when thinking is disabled', async () => { const server = await mockServer([]) const ctx = await harness(server.url, { thinking: 'disabled' }) - await expect(assemble(ctx, { + const result = await assemble(ctx, { model: 'deepseek-v4-flash', reasoningEffort: ReasoningEffortId('high'), messages: [createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'plugin', plugin: 'test' }, })], - })).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + }) + expect(result.finish).toMatchObject({ + kind: 'error', + failure: { code: 'UNSUPPORTED_REASONING_EFFORT' }, + }) expect(server.requests).toHaveLength(0) }) @@ -250,23 +252,22 @@ describe('DeepSeekAdapter against a mock server', () => { [400, 'INVALID_REQUEST'], [500, 'SERVER'], [503, 'SERVER'], - ])('maps HTTP %d to LlmError code %s with the body message', async (status, code) => { + ])('maps HTTP %d to failure code %s with the body message', async (status, code) => { const behavior: Behavior = { kind: 'http-error', status, body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }), } - const server = await mockServer([behavior, behavior]) + const server = await mockServer([behavior]) const ctx = await harness(server.url) - await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) - .rejects.toThrow(`failed with ${status}`) - await expect( - assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - .catch((error: unknown) => (error as LlmError).code), - ).resolves.toBe(code) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { message: `failed with ${status}`, code, status }, + }) }) - it('classifies a thrown HTTP context-window rejection with the canonical code', async () => { + it('classifies an HTTP context-window failure with the canonical code', async () => { const server = await mockServer([{ kind: 'http-error', status: 400, @@ -279,9 +280,11 @@ describe('DeepSeekAdapter against a mock server', () => { }), }]) const ctx = await harness(server.url) - const code = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - .catch((error: unknown) => (error as LlmError).code) - expect(code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ + kind: 'error', + failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE }, + }) }) it('retains status, Retry-After seconds, and provider request id as structured facts', async () => { @@ -292,19 +295,16 @@ describe('DeepSeekAdapter against a mock server', () => { headers: { 'retry-after': '2', 'x-request-id': 'req-429' }, }]) const ctx = await harness(server.url) - let thrown: unknown - try { - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - } catch (error: unknown) { - thrown = error - } - expect(thrown).toBeInstanceOf(LlmError) - expect((thrown as LlmError).failure).toEqual({ - message: 'slow down', - code: 'RATE_LIMIT', - status: 429, - providerRetryAfterMs: 2_000, - requestId: ProviderRequestId('req-429'), + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { + message: 'slow down', + code: 'RATE_LIMIT', + status: 429, + providerRetryAfterMs: 2_000, + requestId: ProviderRequestId('req-429'), + }, }) }) @@ -322,16 +322,17 @@ describe('DeepSeekAdapter against a mock server', () => { }, }]) const ctx = await harness(server.url) - await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toMatchObject({ - failure: { - message: 'come back later', - code: 'SERVER', - status: 503, - providerRetryAfterMs: 3_000, - requestId: ProviderRequestId('deepseek-503'), - }, - }) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { + message: 'come back later', + code: 'SERVER', + status: 503, + providerRetryAfterMs: 3_000, + requestId: ProviderRequestId('deepseek-503'), + }, + }) } finally { dateNow.mockRestore() } @@ -352,13 +353,11 @@ describe('DeepSeekAdapter against a mock server', () => { headers: { 'retry-after': value }, }]) const ctx = await harness(server.url) - let thrown: LlmError | undefined - try { - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - } catch (error: unknown) { - if (error instanceof LlmError) thrown = error - } - expect(thrown?.failure).toEqual({ message: 'retry later', code: 'RATE_LIMIT', status: 429 }) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toEqual({ + kind: 'error', + failure: { message: 'retry later', code: 'RATE_LIMIT', status: 429 }, + }) } }) @@ -379,53 +378,50 @@ describe('DeepSeekAdapter against a mock server', () => { it('keeps the status-line message for JSON error bodies without a message', async () => { const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }]) const ctx = await harness(server.url) - await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) - .rejects.toThrow(/HTTP 500/) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish.kind).toBe('error') + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.code).toBe('SERVER') + expect(result.finish.failure.message).toMatch(/HTTP 500/) }) it('keeps the status-line message for non-JSON error bodies', async () => { const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }]) const ctx = await harness(server.url) - await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) - .rejects.toThrow(/HTTP 502/) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish.kind).toBe('error') + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.code).toBe('SERVER') + expect(result.finish.failure.message).toMatch(/HTTP 502/) }) it('maps unusual statuses to HTTP_', () => { expect(httpErrorCode(418)).toBe('HTTP_418') }) - it('wraps a transport failure in TRANSPORT with the fetch cause chain in the message', async () => { - // Port 1 is reserved/unbound: fetch rejects with `TypeError: fetch failed` - // whose actionable detail (ECONNREFUSED) lives on `cause`. + it('reports a transport failure with the endpoint in the message', async () => { + // Port 1 is reserved/unbound, so the service normalizes the fetch failure. const ctx = await harness('http://127.0.0.1:1') - let caught: unknown - try { - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) - } catch (error: unknown) { - caught = error - } - expect(caught).toBeInstanceOf(LlmError) - const llmError = caught as LlmError - expect(llmError.code).toBe('TRANSPORT') - expect(llmError.message).toContain('http://127.0.0.1:1') - expect(llmError.cause).toBeInstanceOf(TypeError) - // The chain renderer reaches the transport diagnosis through the cause. - expect(errorChain(llmError)).toMatch(/ECONNREFUSED|EADDRNOTAVAIL|bad port/) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ + kind: 'error', + failure: { + code: 'TRANSPORT', + message: 'DeepSeek API request to http://127.0.0.1:1 failed', + }, + }) }) - it('classifies an aborted request without losing the transport rejection', async () => { + it('classifies an aborted request as an aborted finish', async () => { const controller = new AbortController() controller.abort() const ctx = await harness('http://127.0.0.1:1') - let caught: unknown - try { - await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], signal: controller.signal }) - } catch (error: unknown) { - caught = error - } - expect(caught).toBeInstanceOf(LlmError) - expect(caught).toMatchObject({ code: 'ABORTED' }) - expect((caught as LlmError).cause).toMatchObject({ name: 'AbortError' }) + const result = await assemble(ctx, { + model: 'deepseek-v4-flash', + messages: [], + signal: controller.signal, + }) + expect(result.finish).toMatchObject({ kind: 'aborted', failure: { code: 'ABORTED' } }) }) it('throws EMPTY_RESPONSE when the response has no body', async () => { @@ -443,20 +439,17 @@ describe('DeepSeekAdapter against a mock server', () => { } }) - it('classifies an abrupt body close as TRANSPORT and retains its cause', async () => { + it('classifies an abrupt body close as TRANSPORT', async () => { const server = await mockServer([{ kind: 'close-early', events: ['{"choices":[{"delta":{"content":"par"}}]}'], }]) const ctx = await harness(server.url) - let caught: unknown - try { - await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - } catch (error: unknown) { - caught = error - } - expect(caught).toMatchObject({ code: 'TRANSPORT' }) - expect(errorChain(caught)).toMatch(/terminated|socket|without \[DONE\]/) + const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish.kind).toBe('error') + if (result.finish.kind !== 'error') throw new Error('expected an error finish') + expect(result.finish.failure.code).toBe('TRANSPORT') + expect(result.finish.failure.message).toMatch(/^DeepSeek API stream from .* failed$/) }) it('aborts mid-stream via the request signal', async () => { @@ -478,7 +471,13 @@ describe('DeepSeekAdapter against a mock server', () => { })() setTimeout(() => { controller.abort() }, 30) - await expect(pending).rejects.toMatchObject({ code: 'ABORTED' }) + const chunks = await pending + expect(chunks).toHaveLength(1) + expect(chunks[0]?.type).toBe('finish') + if (chunks[0]?.type !== 'finish') throw new Error('expected a finish chunk') + expect(chunks[0].reason.kind).toBe('aborted') + if (chunks[0].reason.kind !== 'aborted') throw new Error('expected an aborted finish') + expect(chunks[0].reason.failure.code).toBe('ABORTED') }) it('maps connection failures to TRANSPORT without losing the cause', async () => { @@ -858,12 +857,15 @@ describe('plugin registration and config', () => { // only the request itself needs a key. expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) - await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) + const first = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) // The guidance leads with the credential store — the path that keeps the // secret out of configuration files — and mentions a literal key last. - await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s) + const second = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(second.finish.kind).toBe('error') + if (second.finish.kind !== 'error') throw new Error('expected an error finish') + expect(second.finish.failure.message) + .toMatch(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s) }) it('reads the ambient variable when no credentials seam is mounted', async () => { @@ -883,8 +885,8 @@ describe('plugin registration and config', () => { const ctx = new Context() await ctx.plugin(LlmService) await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) - await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) }) it('prefers explicit config over env for key and base URL', async () => { diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 25cf4f293b..11df9e1d81 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -96,7 +96,8 @@ describe('request-level dynamic configuration', () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await boot(dir, { baseURL: server.url }) - await expect(prompt(ctx)).rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) + const keyless = await prompt(ctx) + expect(keyless.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) await ctx.credentials.set(KEY_REF, 'sk-arrived') await prompt(ctx) expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived') diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index a0826b3571..6f6e6c7ba6 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -85,7 +85,7 @@ describe('PiAiAdapter provider routing', () => { }) }) - it('uses a dynamic request effort and rejects unsupported efforts before network I/O', async () => { + it('uses a dynamic request effort and reports unsupported efforts before network I/O', async () => { const server = await mockServer([{ events: textEvents }, { events: textEvents }]) const ctx = await harness(server.url, { reasoning: 'max' }) @@ -104,11 +104,15 @@ describe('PiAiAdapter provider routing', () => { expect(server.requests[1]).toMatchObject({ thinking: { type: 'disabled' } }) expect(server.requests[1]).not.toHaveProperty('reasoning_effort') - await expect(assemble(ctx, { + const unsupported = await assemble(ctx, { model: 'deepseek-v4-flash', reasoningEffort: ReasoningEffortId('xhigh'), messages: [], - })).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + }) + expect(unsupported.finish).toMatchObject({ + kind: 'error', + failure: { code: 'UNSUPPORTED_REASONING_EFFORT' }, + }) expect(server.requests).toHaveLength(2) }) @@ -125,19 +129,19 @@ describe('PiAiAdapter provider routing', () => { expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }]) }) - it('rejects stop sequences rather than silently ignoring them', async () => { + it('reports unsupported stop sequences rather than silently ignoring them', async () => { const server = await mockServer([]) const ctx = await harness(server.url) - await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [], stop: ['END'] })) - .rejects.toMatchObject({ code: 'UNSUPPORTED_OPTION' }) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], stop: ['END'] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'UNSUPPORTED_OPTION' } }) expect(server.requests).toEqual([]) }) - it('rejects unknown catalog models before network I/O', async () => { + it('reports unknown catalog models before network I/O', async () => { const server = await mockServer([]) const ctx = await harness(server.url) - await expect(assemble(ctx, { model: 'not-in-the-catalog', messages: [] })) - .rejects.toMatchObject({ code: 'UNKNOWN_MODEL' }) + const result = await assemble(ctx, { model: 'not-in-the-catalog', messages: [] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'UNKNOWN_MODEL' } }) expect(server.requests).toEqual([]) }) @@ -237,8 +241,8 @@ describe('PiAiAdapter provider routing', () => { const server = await mockServer([{ events: textEvents, delayMs: 200 }]) const ctx = await harness(server.url, { streamIdleTimeoutMs: 20 }) - await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toMatchObject({ code: 'TIMEOUT' }) + const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'TIMEOUT' } }) await Promise.race([ server.responseClosed, new Promise((_resolve, reject) => { @@ -393,10 +397,12 @@ describe('provider profile lifecycle', () => { vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' }) - await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' }) - await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })) - .rejects.toThrow(/provider route "deepseek".*PI_CUSTOM_REF_KEY/s) + const first = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } }) + const second = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }) + expect(second.finish.kind).toBe('error') + if (second.finish.kind !== 'error') throw new Error('expected an error finish') + expect(second.finish.failure.message).toMatch(/provider route "deepseek".*PI_CUSTOM_REF_KEY/s) expect(server.requests).toHaveLength(0) }) diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index d13234f8db..94c81b8cae 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -105,8 +105,8 @@ describe('request-level dynamic profiles', () => { // composition route stays. await ctx.settings.replace(NS, {}) expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai']) - await expect(assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })) - .rejects.toMatchObject({ code: 'NO_ADAPTER' }) + const removed = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }) + expect(removed.finish).toMatchObject({ kind: 'error', failure: { code: 'NO_ADAPTER' } }) }) it('rotates the per-request credential referenced by apiKeyEnv', async () => { diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index 1c932256a0..3771e8aca0 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md -README.md: c23dd0c42d8b1d1975b30b08e5be8758a325a9c7 -README.zh.md: 88640381d736244ad56aa802cee6ed70c6c75c20 +README.md: cbd0f5a042a4ce67e10eb990189ad87329aaf9fb +README.zh.md: b0fc2709e4665814fc10e950f39c62feec17c6b0 diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index c23dd0c42d..cbd0f5a042 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -8,7 +8,7 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, direc `plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`. -`ctx.planMode.set(agent, active)` commits immediately when the agent is idle — no boundary would arrive until the next prompt, so the standalone `plan/mode` event lands at once — and holds a pending selection for the next in-turn request boundary while the agent is running; it returns which of the two happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's mid-turn selection. Initial and continuation pre-step boundaries plus request-recovery retries are covered; a changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths). +`ctx.planMode.set(agent, active)` commits immediately when the agent is idle — no boundary would arrive until the next prompt, so the standalone `plan/mode` event lands at once — and holds a pending selection for the next accepted in-turn pre-step while the agent is running; it returns which of the two happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's mid-turn selection. Initial and continuation pre-step boundaries are covered; a same-step request-recovery retry reuses its frozen assembly and leaves the selection pending for the next pre-step. A changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths). ## Model and human surfaces diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 88640381d7..b0fc2709e4 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -8,7 +8,7 @@ `plan/mode`(`{ active: boolean }`)是一个仅写日志、整值替换的 `SessionEventMap` 成员。`foldPlanMode(events)` 返回最后记录的值,如果没有则返回 `false`,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复 plan 状态。UI 通过 `session/event` 观察已提交的切换。 -`ctx.planMode.set(agent, active)` 在 agent 空闲时立即提交——下一个 prompt 之前不会有任何边界到来,因此独立的 `plan/mode` 事件当场落账——在 agent 运行中则持有待生效选择、等下一个轮内请求边界;返回值说明发生了哪种(`committed`/`queued`)、一次 `cancelled` 反转或 `noop`。`get(agent)` 返回 `{ active, pending? }`,将塑造当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 边界以及请求恢复重试都在覆盖范围内;当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。 +`ctx.planMode.set(agent, active)` 在 agent 空闲时立即提交——下一个 prompt 之前不会有任何边界到来,因此独立的 `plan/mode` 事件当场落账——在 agent 运行中则持有待生效选择、等下一个被接受的轮内 pre-step;返回值说明发生了哪种(`committed`/`queued`)、一次 `cancelled` 反转或 `noop`。`get(agent)` 返回 `{ active, pending? }`,将塑造当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 边界都在覆盖范围内;同一步骤的请求恢复重试会复用已冻结的 assembly,并将该选择保留到下一个 pre-step。当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。 ## 模型与人类界面 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index eb6023705c..7c3a6a1189 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -8,9 +8,9 @@ * * The state in force is folded from the session log (`plan/mode`, last one * wins), so resume and fork restore it without a live mirror. User selections - * are held as pending intent until an in-turn step boundary because every - * session event is turn-enclosed. The service flushes on `step/start` before - * the affected request assembly, including retry turns. + * are held as pending intent until an in-turn step boundary. The service + * flushes from `agent/pre-step` before the affected request assembly; + * same-step request retries reuse their assembly. * * The exit tool remains registered while plan mode is inactive so crossing a * boundary changes only the prompt section, not the request tool catalog. @@ -196,34 +196,31 @@ export class PlanModeService extends Service { super(ctx, 'planMode') this.section = resolveConfig(config).section let disposed = false - - // Pre-step runs before the turn opens, so the turn-enclosed mode event - // commits from the immediately following step/start observer. Request - // assembly happens afterward. A failed append remains pending for a later - // boundary, and policy cannot block the turn. - ctx.on('session/event', (session, event) => { - if (disposed || event.type !== 'step/start') return - try { - this.onBoundary(session) - } catch (error) { - ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error) - } - }, { prepend: true }) + // Pre-step is outside Session.append publication, so its log-only mode + // event can land between turns or inside an open turn without re-entering + // the session. A failed append remains pending for a later boundary, and + // policy cannot block the step. ctx.on('agent/pre-step', async ( agent, _messages, - _signal, + { signal }, next, ): Promise => { const decision = await next() const pending = this.pendingIntents.get(agent.session) - if (decision.kind === 'reject' || pending?.narrate !== true) return decision + if (decision.kind === 'reject' || signal.aborted || pending === undefined) return decision const narration = this.narration(agent.session, pending.active) - return narration === undefined + try { + this.onBoundary(agent.session) + } catch (error) { + ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error) + return decision + } + return !pending.narrate || narration === undefined ? decision : { ...decision, messages: [...decision.messages, narration] } }) - ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close boundary lifetime') + ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close service lifetime') ctx.systemPrompt.section({ name: 'plan:policy', diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 51b98bf727..6e614a36a0 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -13,7 +13,7 @@ const PLAN_CONFIG = { section: 'Test plan mode instructions.' } /** * Full-loop integration: a scripted mock model drives the REAL plan-mode plugin - * through the agent loop — the pending-intent flush at the request boundary, the + * through the agent loop — the pending-intent flush at the step boundary, the * assembly the soft layer shapes (the exit tool + mode section), and the * `request/header` snapshots every transition leaves. * Only the model is mocked; the loop, the session log, and the plugin are @@ -127,12 +127,16 @@ describe('plan mode through the agent loop', () => { expect(second.data.header.system).toContain('plan mode') }) - it('a mode flip at error settlement shapes the retry before its assembly', async () => { + it('a mode flip at error settlement waits until the step after a same-step retry', async () => { const failedRequest = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'temporarily unavailable', code: 'SERVER', status: 503 } }, }] satisfies StreamChunk[] - const adapter = new MockAdapter([failedRequest, textResponse('Recovered in plan mode.')]) + const adapter = new MockAdapter([ + failedRequest, + textResponse('Recovered with the original step assembly.'), + textResponse('Entered plan mode on the next step.'), + ]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' }) ctx.on('agent/request-error', async (subject, _context, _signal, next) => { @@ -147,16 +151,26 @@ describe('plan mode through the agent loop', () => { expect(adapter.requests).toHaveLength(2) expect(adapter.requests[0]?.system).not.toContain(PLAN_CONFIG.section) - expect(adapter.requests[1]?.system).toContain(PLAN_CONFIG.section) + expect(adapter.requests[1]?.system).not.toContain(PLAN_CONFIG.section) expect(adapter.requests[1]?.tools).toEqual(adapter.requests[0]?.tools) + expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) + expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) + + const nextIdle = waitForIdle(ctx, agent) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue with the plan' }], source: { kind: 'user' } })) + await nextIdle + + expect(adapter.requests).toHaveLength(3) + expect(adapter.requests[2]?.system).toContain(PLAN_CONFIG.section) + expect(adapter.requests[2]?.tools).toEqual(adapter.requests[0]?.tools) const log = agent.session.events const planMode = findEvent(log, 'plan/mode') const firstEnd = log.find(event => event.type === 'step/end' && event.data.turn === 1 && event.data.step === 1) - const retryStart = log.find(event => event.type === 'step/start' + const nextStart = log.find(event => event.type === 'step/start' && event.data.turn === 2 && event.data.step === 1) expect(firstEnd?.seq).toBeLessThan(planMode.seq) - expect(planMode.seq).toBeLessThan(retryStart?.seq ?? 0) + expect(planMode.seq).toBeLessThan(nextStart?.seq ?? 0) expect(findEvent(log, 'request/header', 'last').data.header.system).toContain(PLAN_CONFIG.section) const notice = log.find(event => event.type === 'user/message' && event.data.source.kind === 'plugin') expect(notice?.type === 'user/message' && notice.data.content).toEqual([ diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index de0080bee5..aecb0f77bf 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -249,22 +249,17 @@ describe('ctx.planMode: get/set', () => { }) describe('the boundary flush', () => { - it('does not flush during pre-step and commits from the following step/start', async () => { + it('flushes from pre-step before the following step/start', async () => { const ctx = await setup() const agent = await agentWithSession(ctx) openTurn(agent.session) ctx.planMode.set(agent, true) - // Pre-step only composes narration. The pending intent survives until the - // turn-enclosed step/start event commits it before request assembly. await boundary(ctx, agent, 'pre-step') - expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) - expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) - await boundary(ctx, agent, 'step-start') expect(foldPlanMode(agent.session.events)).toBe(true) expect(ctx.planMode.get(agent)).toEqual({ active: true }) }) - it('removes the step/start flush when the plugin fiber is disposed', async () => { + it('removes the pre-step flush when the plugin fiber is disposed', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) @@ -273,8 +268,7 @@ describe('the boundary flush', () => { openTurn(agent.session) ctx.planMode.set(agent, true) await fiber.dispose() - const event = agent.session.append('step/start', { turn: 1, step: 1 }) - ctx.emit('session/event', agent.session, event) + await boundary(ctx, agent, 'pre-step') expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(false) }) @@ -366,7 +360,7 @@ describe('the boundary flush', () => { expect(ctx.planMode.get(agent).pending).toBeUndefined() }) - it('pre-step never appends, so a broken backend surfaces only at step/start', async () => { + it('contains a pre-step append failure and keeps the intent pending', async () => { const ctx = await setup() const warn = vi.fn() ctx.logger.warn = warn as never @@ -379,8 +373,6 @@ describe('the boundary flush', () => { return (original as (...args: unknown[]) => unknown)(type, ...rest) }) as unknown) as typeof agent.session.append await boundary(ctx, agent, 'pre-step') - expect(warn).not.toHaveBeenCalled() - await boundary(ctx, agent, 'step-start') expect(warn).toHaveBeenCalledOnce() expect(ctx.planMode.get(agent)).toEqual({ active: false, pending: true }) }) @@ -602,7 +594,7 @@ describe('/plan', () => { .toEqual({ kind: 'success', text: 'Plan mode entry cancelled.' }) expect(ctx.planMode.get(entering)).toEqual({ active: false, pending: false }) expect(enteringSteer).not.toHaveBeenCalled() - await boundary(ctx, entering, 'step/end') + await boundary(ctx, entering, 'step-start') expect(ctx.planMode.get(entering)).toEqual({ active: false }) expect(entering.session.events.some(event => event.type === 'plan/mode')).toBe(false) @@ -616,7 +608,7 @@ describe('/plan', () => { expect((await ctx.commands.execute(active, '/plan off', signal))?.result) .toEqual({ kind: 'success', text: 'Leaving plan mode (applies from the next step).' }) expect(activeSteer).not.toHaveBeenCalled() - await boundary(ctx, active, 'step/end') + await boundary(ctx, active, 'step-start') expect(ctx.planMode.get(active)).toEqual({ active: false }) }) diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 0697af4b72..e84edbc369 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -189,7 +189,10 @@ describe('startInProcessRun', () => { expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' }) const child = parent.ctx.agents.get(signalled.id) const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end') - expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' }) + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ + kind: 'aborted', + reason: { kind: 'parent' }, + }) await signalled.dispose() const disposed = await startInProcessRun(request(parent), {}) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 38e735f5db..e84f7dd4b4 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1783,7 +1783,6 @@ describe('pi-tui chat lifecycle and transcript', () => { 0, result.agent.inbox.nextStep.length, [], - 'canceled', ) await tick() expect(result.terminal.output).not.toContain('queued')