From 40599db7c85fb030b2086bd4da81c32c3170ac41 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 27 Oct 2026 12:11:22 +0800 Subject: [PATCH] test(agent): verify retry control identity across SDK recovery --- apps/web/tests/live-interactions.e2e.ts | 6 +++--- docs/architecture.i18n.yaml | 4 ++-- docs/architecture.md | 4 ++-- docs/architecture.zh.md | 4 ++-- docs/persistence-catalog.i18n.yaml | 12 ++++++------ docs/persistence-catalog.md | 13 +++++++++---- docs/persistence-catalog.zh.md | 13 +++++++++---- .../historical-formats/README.i18n.yaml | 4 ++-- .../historical-formats/README.md | 2 +- .../historical-formats/README.zh.md | 2 +- docs/persistence-schema.json | 2 +- .../tests/empty-prompt.host.spec.ts | 7 ++++--- scripts/smoke-python-runtime.py | 8 ++++++++ 13 files changed, 50 insertions(+), 31 deletions(-) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index bde84fe2d1..394da150c3 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -478,9 +478,9 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { const userMessages = sessionEvents.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user' ? [event.data] : []) expect(userMessages.map(message => message.content)).toEqual([[{ type: 'text', text: PROMPT }]]) - expect(sessionEvents.filter(event => event.type === 'agent/retry')).toEqual([ - expect.objectContaining({ data: expect.objectContaining({ turn: 2, step: 1, source: expect.objectContaining({ kind: 'retry' }) }) }), - ]) + const retries = sessionEvents.filter(event => event.type === 'agent/retry') + expect(retries).toHaveLength(1) + expect(retries[0]?.data).toMatchObject({ turn: 2, step: 1, source: { kind: 'retry' } }) expect(sessionEvents.filter(event => event.type === 'turn/start')).toHaveLength(2) expect(sessionEvents.filter(event => event.type === 'turn/end')).toHaveLength(2) expect(await resume.count()).toBe(0) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 5dfa2c2995..ca357a4a9c 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -24,8 +24,8 @@ en: 693407efb233f961 zh: 37b9dd0677d4405c /deepseek-harness-architecture/turn-flow: - en: 3770eacd9c3bae28 - zh: bd7b8a0374ba0ff8 + en: d67fe476bec779ea + zh: 774a0913a8fb03d0 /deepseek-harness-architecture/session-log: en: 8a7f19b95d426c99 zh: d5bb0538404e20ea diff --git a/docs/architecture.md b/docs/architecture.md index 064026d4d2..1ffee7bf2f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -107,9 +107,9 @@ turn/end Factory setup follows SessionStore entry and `session/created`, preceding `agent/created`; closing events commit before removal. -One inbox feeds the driver; injected context waits for a waking message. AgentLoop’s durable `inbox` projection exposes pending input without live Agents. +`inbox` persists pending input; injected context waits for waking input. Retry controls record `agent/retry` without changing turn settlement. -Before assembly, the loop snapshots Session model selection in `AssembleContext.modelSelection` and `agent/pre-step.modelSelection`; retries retain it; omitted reasoning effort uses adapter defaults. `agent/pre-step` listeners accept, rewrite, or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, the deprecated `agent/request` hook adjusts controls without changing the provider, model, or effort selection, and `prepareCall()` binds the adapter before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Empty retry-source controls record `agent/retry` instead of entering model history; they retain ordinary inbox and turn lifecycle. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The system prompt travels as `system/message` history: empty text clears it, capable routes append updates, and other routes consolidate it at the first system node. [Agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) owns prompt admission and replacement details. +Before assembly, the loop snapshots Session model selection in `AssembleContext.modelSelection` and `agent/pre-step.modelSelection`; retries retain it; omitted reasoning effort uses adapter defaults. `agent/pre-step` listeners accept, rewrite, or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, the deprecated `agent/request` hook adjusts controls without changing the provider, model, or effort selection, and `prepareCall()` binds the adapter before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The system prompt travels as `system/message` history: empty text clears it, capable routes append updates, and other routes consolidate it at the first system node. [Agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) owns prompt admission and replacement details. The loop sends immutable requests with live cancellation and reuses freeze evidence for frozen identities; [agent-loop](../packages/core/agent-loop/README.md) owns construction and cancellation causes. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index e836ab7452..42297aca67 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -111,9 +111,9 @@ turn/end 工厂 setup 在 Session 进入 SessionStore 并完成 `session/created` 之后、`agent/created` 之前运行;收尾事件在移除之前提交。 -输入通过同一个 inbox 到达驱动器;注入的上下文等待一条唤醒消息。AgentLoop 的持久 `inbox` 投影使待处理输入在没有活跃 Agent 时仍可读取。 +`inbox` 持久化待消费输入;注入上下文等待唤醒输入。重试控制输入记录 `agent/retry`,不改变轮次结算。 -循环在组装前将 Session 模型选择快照到 `AssembleContext.modelSelection` 与 `agent/pre-step.modelSelection`;重试保留该选择,省略推理强度时使用适配器默认值。`agent/pre-step` 监听器接纳、改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,已弃用的 `agent/request` hook 在不改变提供方、模型或推理强度选择的前提下调整控制参数,`prepareCall()` 绑定适配器,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。系统提示词通过 `system/message` 历史传递:空文本清除提示词,具备能力的路由追加更新,其他路由将其归并到首个系统节点。[Agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation) 说明提示词准入与替换的细节。 空的 retry 来源控制输入记录 `agent/retry`,不进入模型历史;inbox 与轮次生命周期仍按普通流程处理。 +循环在组装前将 Session 模型选择快照到 `AssembleContext.modelSelection` 与 `agent/pre-step.modelSelection`;重试保留该选择,省略推理强度时使用适配器默认值。`agent/pre-step` 监听器接纳、改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,已弃用的 `agent/request` hook 在不改变提供方、模型或推理强度选择的前提下调整控制参数,`prepareCall()` 绑定适配器,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。系统提示词通过 `system/message` 历史传递:空文本清除提示词,具备能力的路由追加更新,其他路由将其归并到首个系统节点。[Agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation) 说明提示词准入与替换的细节。 循环发送不可变请求,同时保持取消有效,仅对完全冻结的对象复用冻结证据;[agent-loop](../packages/core/agent-loop/README.zh.md) 负责请求构建与取消原因。 diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 3214b08f10..546008ca82 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -21,8 +21,8 @@ en: 9188862058c6986b zh: 9188862058c6986b /session-persistence-event-catalog/events/agent/agent-inbox-spliced-log-only: - en: 9ae614c041a27169 - zh: 8d669dd582b88f79 + en: b647f3f80ec8b407 + zh: d5e43856cfd85ddb /session-persistence-event-catalog/events/agent/agent-retry-log-only: en: 559e0484e8ae8904 zh: 8967a336e5aef50c @@ -144,8 +144,8 @@ en: 9edf4ef31e60e880 zh: 9edf4ef31e60e880 /session-persistence-event-catalog/events/model/model-selection-log-only: - en: 404c54b08c088438 - zh: eb86ed4e7d0e9228 + en: 608081c311c1bf13 + zh: bcd14effec5f6c78 /session-persistence-event-catalog/events/permission: en: 19917bc5438e60af zh: 19917bc5438e60af @@ -1545,8 +1545,8 @@ en: 930d9c64fe39857d zh: 65cd07dc3c7789db /session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start: - en: 4edbaf2329a66619 - zh: 7d29428252d9d84f + en: 473f9e6ab2b776dc + zh: ce701e3ff6ab63ab /session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream: en: 8e86cd838e3e4a49 zh: f5fec2a8adf1f3cc diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 70e16e7ce7..f3cfff9e7f 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -186,7 +186,7 @@ Sources: [`packages/core/session/src/types.ts:439`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:119`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:124`](../packages/core/agent/src/types.ts) @@ -194,7 +194,12 @@ Source: [`packages/core/agent/src/types.ts:119`](../packages/core/agent/src/type ```ts persistence-catalog /** An admitted retry control, correlated with its durable inbox input; never enters model history. */ -'agent/retry': { turn: number; step: number; inputId: MessageId; source: Extract } +'agent/retry': { + turn: number + step: number + inputId: MessageId + source: Extract +} ``` Types: [MessageSource](subsystems/core.md) @@ -714,7 +719,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src 'model/selection': ModelSelection ``` -Source: [`packages/core/agent/src/types.ts:130`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:135`](../packages/core/agent/src/types.ts) ### `permission/*` @@ -6468,7 +6473,7 @@ Sources: [`packages/core/agent/src/types.ts:113`](../packages/core/agent/src/typ SHA-256: `c66c6c3b411abf46c1f9b99e0019c10c1b6d13ee716a4b5e88f7e1b65feb3039` -Sources: [`packages/core/agent/src/types.ts:119`](../packages/core/agent/src/types.ts) +Sources: [`packages/core/agent/src/types.ts:124`](../packages/core/agent/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 47f1c30ed9..05543778cb 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -188,7 +188,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/agent/src/types.ts:119`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:124`](../packages/core/agent/src/types.ts) @@ -196,7 +196,12 @@ export type SessionEvent = { ```ts persistence-catalog /** An admitted retry control, correlated with its durable inbox input; never enters model history. */ -'agent/retry': { turn: number; step: number; inputId: MessageId; source: Extract } +'agent/retry': { + turn: number + step: number + inputId: MessageId + source: Extract +} ``` 类型:[MessageSource](subsystems/core.zh.md) @@ -716,7 +721,7 @@ export type SessionEvent = { 'model/selection': ModelSelection ``` -来源:[`packages/core/agent/src/types.ts:130`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:135`](../packages/core/agent/src/types.ts) ### `permission/*` @@ -6470,7 +6475,7 @@ SHA-256: `2a668a48dec1739752123d67c3bc254412d6743dbe3347365e0ed83a542f2bc3` SHA-256: `c66c6c3b411abf46c1f9b99e0019c10c1b6d13ee716a4b5e88f7e1b65feb3039` -来源:[`packages/core/agent/src/types.ts:119`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:124`](../packages/core/agent/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index 40d842e063..cae6902733 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -15,8 +15,8 @@ en: 14c57f30e676f6e3 zh: b1a0ccf26fab3727 /session-persistence-formats/format-references: - en: e3511a4ac3bc4457 - zh: 828f3c39f80d5370 + en: 83fd4c4276a2600d + zh: 4f3431c960a7f8da /session-persistence-formats/scope-and-evidence: en: b220062139ddac1e zh: 7c4c0efe05dafa9c diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index 74c575b300..da1a2d629e 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 65 / 622 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 66 / 629 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index 12eb600771..8a2460c46c 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 65 / 622 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 66 / 629 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 07e2ce9415..f7630fdb61 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -568,7 +568,7 @@ {"digest":"c589e938cc0e4989df8b82abef6339de621f0dc4fe62c230d145b94996af7f92","names":[],"sources":[]}, {"digest":"c592ce75aab73fcab19c1d7845684c72cf402b78d2e1f2833a58ecf9f3598ed6","names":["packages/util/values/src/index.ts#JsonValue"],"sources":["packages/util/values/src/index.ts:4"]}, {"digest":"c5bbcb3435908af2036df7105883e0a49c30ecc1fae4420aa5c4cf944066eff5","names":[],"sources":[]}, - {"digest":"c66c6c3b411abf46c1f9b99e0019c10c1b6d13ee716a4b5e88f7e1b65feb3039","names":[],"sources":["packages/core/agent/src/types.ts:119"]}, + {"digest":"c66c6c3b411abf46c1f9b99e0019c10c1b6d13ee716a4b5e88f7e1b65feb3039","names":[],"sources":["packages/core/agent/src/types.ts:124"]}, {"digest":"c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262","names":[],"sources":[]}, {"digest":"c8e6f48878cc2a2f0dc59682280d81f407943fb503f32c64bf9ff3d9393c3eff","names":[],"sources":[]}, {"digest":"c99e20441bf15341e9db54385e558e5c03d21dff2eb1ac08ccbf47c2d8747954","names":[],"sources":[]}, diff --git a/packages/api/session-controller/tests/empty-prompt.host.spec.ts b/packages/api/session-controller/tests/empty-prompt.host.spec.ts index 835d7ae442..50d4871ee8 100644 --- a/packages/api/session-controller/tests/empty-prompt.host.spec.ts +++ b/packages/api/session-controller/tests/empty-prompt.host.spec.ts @@ -43,9 +43,10 @@ it('records retry controls without user messages or duplicate requests', async ( const messages = events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user' ? [event.data] : []) expect(messages).toHaveLength(1) const retries = events.flatMap(event => event.type === 'agent/retry' ? [event.data] : []) - expect(retries).toEqual([ - { turn: 2, step: 1, inputId: expect.any(String), source: { kind: 'retry', rpcId: 'continue-1' } }, - { turn: 3, step: 1, inputId: expect.any(String), source: { kind: 'retry', rpcId: 'continue-2' } }, + expect(retries).toHaveLength(2) + expect(retries).toMatchObject([ + { turn: 2, step: 1, source: { kind: 'retry', rpcId: 'continue-1' } }, + { turn: 3, step: 1, source: { kind: 'retry', rpcId: 'continue-2' } }, ]) const controls = events.flatMap(event => event.type === 'agent/inbox/spliced' ? event.data.inserted : []) .filter(message => message.source.kind === 'retry') diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 0ea6f3831c..24bc9f15f1 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -1930,6 +1930,13 @@ def smoke_sdk_scheduler_recovery(base_url: str, executable: Path, update_snapsho retries = [record for record in records if record.get("type") == "agent/retry"] if len(prompts) != 1 or len(retries) != 1: raise AssertionError("scheduler recovery must record one original prompt and one retry control") + retry_id = retries[0]["data"]["inputId"] + queued_retry_ids = [message.get("id") for record in records + if record.get("type") == "agent/inbox/spliced" + for message in record["data"]["inserted"] + if message.get("source", {}).get("kind") == "retry"] + if not isinstance(retry_id, str) or queued_retry_ids != [retry_id]: + raise AssertionError("scheduler recovery lost the retry control's durable inbox identity") calls = [record["data"]["callId"] for record in records if record.get("type") == "tool/call"] if calls != list(RECOVERY_CALL_IDS[:2]): raise AssertionError(f"scheduler recovery dispatched the unstarted call: {calls}") @@ -1946,6 +1953,7 @@ def smoke_sdk_scheduler_recovery(base_url: str, executable: Path, update_snapsho replacements = [ *snapshot_directory_replacements(root, "{{cwd}}"), (RECOVERY_SESSION_ID, "{{parent}}"), + (retry_id, "{{messageId}}"), ] result_value = [{ "session_id": result.session_id,