diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml index 320476d704..0b66ec2f1d 100644 --- a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.i18n.yaml @@ -6,17 +6,17 @@ en: 327aaa1cdc63b1f5 zh: 1147b4a23e51772c /agent-note-manual-generation-continuation/problem: - en: 027e01e4586da32f - zh: dce2c6858ef709c1 + en: f118844946afdbdc + zh: f1fe37d042f88b24 /agent-note-manual-generation-continuation/decision: - en: dc5f066e9c0bbb23 - zh: 6e54f59a34d8fca5 + en: 32e75f0c2a35bfec + zh: 343fedcd8f53a6a8 /agent-note-manual-generation-continuation/alternatives-considered: - en: b32c1050bc0ca9de - zh: 5e950f842f944818 + en: 58bb948d210d42ea + zh: 5dc920d222d71218 /agent-note-manual-generation-continuation/consequences: - en: c825acfaae8f049d - zh: e4768dab9d8edde3 + en: e86aee3a7da7649f + zh: d58e8cc94e5008bf /agent-note-manual-generation-continuation/verification: - en: bd59f8845a358dec - zh: cfcd30ca521e0111 + en: e31a5bd5826c689f + zh: 02ac700146246664 diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md index 8060f1bde4..ab91eb5f7b 100644 --- a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md +++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.md @@ -6,28 +6,28 @@ English | [中文](2026-10-26-manual-generation-continuation.zh.md) ## Problem -Users need another turn after a model request fails. A browser page can omit the failed turn's start; streaming must not repeatedly copy loaded history. +Users need another request after a model failure without inserting an artificial instruction. Browser paging can omit the failed turn's start, and streaming must not repeatedly copy history. ## Decision -The play action submits localized ordinary user text through the existing composer, retaining admission, logging, failure drafts, and telemetry. No resume RPC, AgentLoop change, or Session event format is introduced. +The play action submits `content: []` through the existing Session prompt API. AgentLoop retains the identified inbox input and starts a new balanced turn, but omits the empty ordinary input from model-visible `user/message` events. The original failed turn remains closed. -The fixed-size Host projection `conversationContinuation` recovers from complete Session history. Eligibility requires admitted ordinary input and a failed attempt, independently of the browser's 500-message page. +The Session API owns the fixed-size `conversationContinuation` projection and shares its value with the browser. Admission requires an eligible failure, true idle ownership, and no pending human input. An accepted empty input carries the admitted work into its next turn, preserving eligibility if that request also fails. -The six-code manual allowlist remains independent of [automatic retry policy](../../../../packages/llm/llm-retry/README.md). Pending [injected context](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) in `next-step` accompanies the new waking message; pending ordinary user submissions prevent duplicates. +The six-code manual allowlist stays independent of provider retry policy. Non-waking injected context accompanies the request. Durable inbox receipts retain request-id deduplication after the empty input is claimed; no new Remote operation or Session event format is required. ## Alternatives considered -**Window scanning or entries caching.** Each revision can materialize a fresh window; caching cannot recover unloaded facts. +**Localized or hidden continuation text.** Adds model-visible instructions and changes retained history. -**Use provider retryableCodes.** Deployment retry policies could expose authentication or other unsuitable errors as UI actions. +**A separate resume operation.** The existing prompt and inbox already carry identified control input and own wakeup. -**Add a resume RPC.** Ordinary submission already owns wakeup, admission, error reporting, and history. +**Keep the failed turn waiting for a click.** Changes failure settlement, idle status, cancellation, and model-selection semantics. ## Consequences -Host activation requires `sessionProjections`; custom compositions follow the [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.md). Fixed-size state avoids Client history scans without introducing a second submission lifecycle. +Ordinary empty-draft Enter remains inert. The explicit action reuses submission state and error reporting without an echo or generated draft. Pre-step rejection and cleared batches still prevent model calls. ## Verification -Tests cover checkpoint recovery, long-turn paging, injected context, refusal and retained drafts, and zero Client event-window reads. The recorded Web scenario exercises failure followed by continuation. +Unit and assembled scenarios cover repeated failure, request deduplication, injected context, balanced turns, retained tools, one original user message, and zero Client event-window reads. diff --git a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md index c7c0d9ee13..21ff2cf50a 100644 --- a/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md +++ b/.agents/notes/implemented/feature/2026-10-26-manual-generation-continuation.zh.md @@ -6,28 +6,28 @@ Status: implemented ## 问题 -模型请求失败后,用户需要发起下一轮。浏览器分页可能不含失败轮次的起点;流式输出也不能反复复制已加载历史。 +模型请求失败后,用户需要再次请求,但不应插入人为构造的指令。浏览器分页可能缺少失败轮次起点,流式输出也不能反复复制历史。 ## 决策 -播放按钮通过现有 composer 提交本地化的普通用户文本,沿用准入、日志、失败草稿和遥测语义。不引入 resume RPC,不修改 AgentLoop 或 Session 事件格式。 +播放按钮通过现有 Session prompt API 提交 `content: []`。AgentLoop 保留带身份的 inbox 输入并启动正常闭合的新轮次,但不将空普通输入写成模型可见的 `user/message`。原失败轮次保持闭合。 -固定大小的 Host 投影 `conversationContinuation` 从完整 Session 历史恢复。资格要求已准入的普通用户输入和失败尝试,不依赖浏览器的 500 条消息分页。 +Session API 拥有固定大小的 `conversationContinuation` 投影,向浏览器发布相同的值。准入要求失败符合条件、持有真正的空闲状态且没有待消费的用户输入。已接受的空输入将原先准入的工作带入新轮次,因此请求再次失败后仍保留继续资格。 -六类手动继续白名单独立于[自动重试策略](../../../../packages/llm/llm-retry/README.zh.md)。`next-step` 中待处理的[注入上下文](../architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md) 随新的唤醒消息进入请求;待提交的普通用户消息仍阻止重复提交。 +六码手动白名单独立于提供方重试策略。非唤醒的注入上下文随请求进入。空输入被领取后,持久 inbox 回执仍支持 request-id 去重;不需要新增 Remote 操作或 Session 事件格式。 ## 考虑过的替代方案 -**窗口扫描或 entries 缓存。** 每个 revision 都可能物化新窗口;缓存无法恢复尚未加载的事实。 +**本地化或隐藏的继续文本。** 增加模型可见指令并改变保留历史。 -**采用提供方的 retryableCodes。** 部署重试策略可能把认证等不适合的错误也变成界面动作。 +**单独的 resume 操作。** 现有 prompt 和 inbox 已可承载带身份的控制输入并拥有唤醒流程。 -**新增 resume RPC。** 普通提交已经拥有唤醒、准入、错误报告和历史。 +**保持失败轮次等待点击。** 改变失败结算、空闲状态、取消和模型选择语义。 -## 影响 +## 后果 -Host 激活需要 `sessionProjections`;自定义组合按[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.zh.md) 调整。固定大小的状态避免 Client 历史扫描,也无需第二套提交生命周期。 +普通空草稿回车仍不执行。显式继续沿用提交状态与错误报告,不创建消息回显或生成草稿。pre-step 拒绝和被清空的批次仍阻止模型调用。 ## 验证 -测试覆盖检查点恢复、长轮次分页、注入上下文、拒绝与草稿保留,以及不读取 Client 事件窗口。已录制的 Web 场景验证失败后的继续流程。 +单元和组装场景覆盖重复失败、请求去重、注入上下文、轮次闭合、工具结果保留、仅一条原始用户消息,以及客户端不读取完整事件窗口。 diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index d1e90d0080..eda193847c 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -414,7 +414,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { expect(tripwire.warnings).toEqual([]) }, 120_000) - it.skipIf(MODE === 'record')('continues after transient retries exhaust through a localized user message', async () => { + it.skipIf(MODE === 'record')('continues after transient retries exhaust without another user message', async () => { // A whole-script replacement: three throw entries cover the first request // plus both budgeted retries (patches cannot reach past the one-call // derived script). The scenario-owned policy keeps exhaustion fast and @@ -477,7 +477,9 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { expect(sessionEvents.some(event => event.type === 'user/message' && event.data.source.kind === 'plan-mode')).toBe(true) const userMessages = sessionEvents.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user' ? [event.data.content] : []) - expect(userMessages).toEqual([[{ type: 'text', text: PROMPT }], [{ type: 'text', text: 'continue' }]]) + expect(userMessages).toEqual([[{ type: 'text', text: PROMPT }]]) + 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) await page.setViewportSize({ width: 1440, height: 900 }) await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'continued.expected.md'), diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index c8543cdc36..d1e20ef6b6 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -19,12 +19,12 @@ zh: 70d87a12c43e042a /deepseek-harness-architecture/core-packages: en: 20ef882e44b06a10 - zh: c10c3934ed5570e9 + zh: 0d69c1500650868f /deepseek-harness-architecture/events: en: 693407efb233f961 zh: 37b9dd0677d4405c /deepseek-harness-architecture/turn-flow: - en: 1a79a70b62c04cdb + en: 22964b8327adc3f6 zh: c86d765e21430405 /deepseek-harness-architecture/session-log: en: 8a7f19b95d426c99 diff --git a/docs/architecture.md b/docs/architecture.md index e6bede8d0e..ed35ab94af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,6 +119,8 @@ Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-ex [`ctx.tools.execute()`](subsystems/tools.md) receives raw batches and concurrency limits. +An identified empty ordinary input starts a new turn from committed history without creating a model-visible user message. It remains logged in the inbox, passes normal pre-step admission, and closes through the same turn and step lifecycle. + ## Session log The session log is the source of the context the model sees. `deriveMessages()` projects model history from it. Each `assistant/message` embeds the exact compact timed stream that produced its assembled content; `assistant/attempt` retains settled failed, retried, cancelled, and stream-error attempts without adding model history. Fork, resume, transcripts, telemetry, and persistence all derive from these durable settlements, while live UI incrementality comes from `agent/assistant-stream`; a hard process loss before settlement leaves no durable attempt stream ([decision](../.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 8863c8047c..64c3be1ddd 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -66,6 +66,8 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共 +带身份的空普通输入基于已提交历史启动新轮次,不生成模型可见的用户消息。它仍记录在 inbox 中,经过正常 pre-step 准入,并通过相同的轮次和步骤生命周期闭合。 + ## 事件 事件就是扩展点,而选对事件域是大多数改动的第一个决定。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 43726adfb5..be0d89caf2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -4713,7 +4713,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-client-ui-brand-official` | — | [`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts) | | `@deepseek-ai/dsh-client-ui-chat` | — | [`packages/client/ui-chat/src/index.ts`](../packages/client/ui-chat/src/index.ts) | | `@deepseek-ai/dsh-client-ui-commands` | — | [`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts) | -| `@deepseek-ai/dsh-client-ui-conversation` | `sessionProjections` | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) | | `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) | | `@deepseek-ai/dsh-client-ui-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) | | `@deepseek-ai/dsh-client-ui-deepseek-model-label-1017` | — | [`packages/client/ui-deepseek-model-label-1017/src/index.ts`](../packages/client/ui-deepseek-model-label-1017/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 470c40d5b0..23165f5f85 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -4715,7 +4715,7 @@ export interface Config { | `@deepseek-ai/dsh-client-ui-brand-official` | — | [`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts) | | `@deepseek-ai/dsh-client-ui-chat` | — | [`packages/client/ui-chat/src/index.ts`](../packages/client/ui-chat/src/index.ts) | | `@deepseek-ai/dsh-client-ui-commands` | — | [`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts) | -| `@deepseek-ai/dsh-client-ui-conversation` | `sessionProjections` | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) | | `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) | | `@deepseek-ai/dsh-client-ui-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) | | `@deepseek-ai/dsh-client-ui-deepseek-model-label-1017` | — | [`packages/client/ui-deepseek-model-label-1017/src/index.ts`](../packages/client/ui-deepseek-model-label-1017/src/index.ts) | diff --git a/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.i18n.yaml deleted file mode 100644 index 50094c2b2c..0000000000 --- a/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.i18n.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.md -/: - en: cd293f657bb1ab7a - zh: 7d942448b488e22c -/provide-session-projections-for-conversation: - en: 6ec06cc57348d333 - zh: 86665b791e3b2e02 -/provide-session-projections-for-conversation/change: - en: b0048aafcb91d8a5 - zh: c913215a68c5ff5b -/provide-session-projections-for-conversation/migration: - en: e06d674bafc37abe - zh: b68409bfbc936d19 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.md deleted file mode 100644 index 52fa806c20..0000000000 --- a/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -kind: upgrade-guide -description: "Custom Conversation compositions must provide sessionProjections to activate the Host plugin." ---- -# Provide Session projections for Conversation - -English | [中文](guide.zh.md) - -## Change - -The Host entry of `@deepseek-ai/dsh-client-ui-conversation` requires the `sessionProjections` service to publish manual continuation eligibility. Custom compositions that mounted Conversation without a projection provider must add one. The shipped Web and Desktop compositions already provide it. Session logs do not require migration. - -## Migration - -1. Check the custom profile's bundles and `cordis.patch.yml` for an existing `@deepseek-ai/dsh-session-projection` entry. Keep that provider when present. -2. If the composition has none, add this row to the profile's `cordis.patch.yml`: - - ```yaml - - id: session-projection - name: '@deepseek-ai/dsh-session-projection' - ``` - -3. Restart the profile and verify that the Conversation Host entry activates. After a supported model-request failure, an empty, idle ordinary composer must offer the play action. - -See the [Conversation reference](../../../../packages/client/ui-conversation/README.md#shell-and-standard-props) for the supported failures and submission behavior. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.zh.md deleted file mode 100644 index 1277f31bf5..0000000000 --- a/docs/upgrade-guide/v0.2.1-rc.3/conversation-continuation-projection/guide.zh.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -kind: upgrade-guide -description: "自定义 Conversation 组合必须提供 sessionProjections 才能激活 Host 插件。" ---- -# 为 Conversation 提供 Session 投影 - -[English](guide.md) | 中文 - -## 变更 - -`@deepseek-ai/dsh-client-ui-conversation` 的 Host 入口需要 `sessionProjections` 服务来发布手动继续资格。挂载 Conversation 却没有投影提供方的自定义组合必须补上提供方。随产品提供的 Web 和 Desktop 组合已经提供它。Session 日志不需要迁移。 - -## 迁移 - -1. 检查自定义 profile 的 bundle 和 `cordis.patch.yml` 中是否已有 `@deepseek-ai/dsh-session-projection` entry;存在时保留该提供方。 -2. 如果组合中没有该提供方,在 profile 的 `cordis.patch.yml` 中添加以下条目: - - ```yaml - - id: session-projection - name: '@deepseek-ai/dsh-session-projection' - ``` - -3. 重启 profile 并确认 Conversation Host 入口已激活。支持的模型请求失败后,空草稿且空闲的普通 composer 应提供播放按钮。 - -支持的失败类型和提交行为见 [Conversation 参考](../../../../packages/client/ui-conversation/README.zh.md#shell-and-standard-props)。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.i18n.yaml new file mode 100644 index 0000000000..cbda06d0c8 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.md +/: + en: 7a194a68f49ac09a + zh: 4d7bdff82c571c62 +/observe-empty-prompt-admission-through-the-inbox: + en: 450277479989809e + zh: 4b01f252faeae19c +/observe-empty-prompt-admission-through-the-inbox/change: + en: e1c081d5321f3256 + zh: a5dd5585fe4300b3 +/observe-empty-prompt-admission-through-the-inbox/migration: + en: 36e80226df9dcf54 + zh: 81a9c0bd5c667470 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.md new file mode 100644 index 0000000000..16876dd26c --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Empty ordinary Agent inputs start requests without producing user/message events." +--- +# Observe empty prompt admission through the inbox + +English | [中文](guide.zh.md) + +## Change + +An ordinary Agent input with `content: []` starts a new turn from committed history without appending an empty `user/message`. The identified input remains in durable inbox events. Custom consumers that wait for every accepted input to produce a user message must observe its inbox receipt instead. Session logs need no migration; existing recorded empty user messages keep their historical interpretation. + +The Web Session prompt endpoint accepts an empty array only for an eligible failed, idle ordinary Session in queue mode. Whitespace-only text parts remain invalid. + +## Migration + +1. Keep ordinary text and attachment inputs unchanged. Use an empty array only when requesting another model call without new user text. +2. Match acceptance by the input identity in `agent/inbox/spliced`, then observe the normal turn/status lifecycle. Do not wait for an empty `user/message`. +3. Verify that continuation adds a balanced turn without adding a user message. The shipped TypeScript and Python SDK clients already observe inbox receipts. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.zh.md new file mode 100644 index 0000000000..9c879cc2aa --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/empty-prompt-continuation/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "空的普通 Agent 输入发起请求,但不产生 user/message 事件。" +--- +# 通过 inbox 观察空 prompt 准入 + +[English](guide.md) | 中文 + +## 变更 + +普通 Agent 输入的 `content: []` 基于已提交历史启动新轮次,不再追加空的 `user/message`。带身份的输入仍保留在持久 inbox 事件中。若自定义消费者等待每条已接受输入产生用户消息,应改为观察对应 inbox 回执。Session 日志无需迁移,历史空用户消息的解释保持不变。 + +Web Session prompt 端点仅在普通 Session 空闲、最新失败符合继续条件且使用 queue 模式时接受空数组。仅含空白的文本块仍无效。 + +## 迁移 + +1. 普通文本和附件输入保持不变。仅在需要再次请求模型而不添加用户文本时使用空数组。 +2. 通过 `agent/inbox/spliced` 中的输入身份匹配准入,然后观察正常轮次与状态生命周期。不要等待空的 `user/message`。 +3. 验证继续操作增加正常闭合的轮次,但不增加用户消息。内置 TypeScript 和 Python SDK 客户端已观察 inbox 回执。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 010c41bf94..78f64d3acc 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -24,8 +24,8 @@ en: 954923654eebe2dc zh: 6bd9a7d2ea965aed /session-controller/configuration: - en: c92590fbaf75e45e - zh: fcac67017bb0e3ab + en: 2a91d9f904e90756 + zh: 8affb15ccc571829 /session-controller/model-experience: en: 30b917bf822bb4df zh: b0b023b5b526c317 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 6fd7b24561..1b11d04106 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -111,6 +111,8 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a Blockquotes travel as ordinary prompt text without quote metadata. Local submission echoes and durable messages use the same text rendering rules; queue edits replace content while retaining prompt correlation. +An empty `prompt.content` array requests a new turn only for an eligible failed, idle ordinary Session in queue mode. Pending human input prevents admission; non-waking injected context is retained. `conversationContinuation` supplies the shared six-code manual policy, independently of provider retry configuration. The input is logged through the inbox for request-id deduplication, but creates no model-visible user message. Whitespace-only text parts remain invalid. + ## Model Experience None, as invoked Agent commands own any model-visible effect. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 8729f555ba..cb8a1ba818 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -111,6 +111,8 @@ GUI 模型选择要求确切提供方/模型对出现在可用目录中;不 Blockquote 作为普通 prompt 文本传递,不携带 quote 元数据。本地提交回显与持久消息使用同一文本渲染规则;队列编辑替换 content,同时保留 prompt 关联信息。 +空的 `prompt.content` 数组仅在普通 Session 空闲、最新失败符合继续条件且使用 queue 模式时请求新轮次。待消费的用户输入阻止准入,非唤醒的注入上下文保留。`conversationContinuation` 提供共用的六码手动策略,独立于提供方重试配置。输入通过 inbox 持久记录,用于 request-id 去重,但不生成模型可见的用户消息。仅含空白的文本块仍无效。 + ## 模型体验 无;任何模型可见效果都由被调用的 Agent 命令负责。 diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 5309872331..0b62ff724a 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -1,5 +1,8 @@ /** Session commands whose activation policy is explicit at each Remote method. */ +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' +import { continuationProjectionDefinition, type ContinuationState } from './continuation.ts' + import { modelAvailable } from './catalog.ts' import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' @@ -83,6 +86,13 @@ function latestCompletedPrefixBoundary(events: readonly SessionEvent[]): Session /** Implements Session business commands delegated by the Session Controller Remote service. */ export class SessionCommandController { + private continuation!: ProjectionHandle + + /** Register complete-history empty-prompt eligibility. @returns when command admission is ready. */ + async initialize(): Promise { + this.continuation = await this.ctx.sessionProjections.register(continuationProjectionDefinition) + } + /** * @param ctx - Host context carrying Agent, model, attachment, title, and Workspace services. * @param agents - sole owner of create, resume, and Agent composition. @@ -304,6 +314,22 @@ export class SessionCommandController { async prompt(request: SessionPromptRequest, source: MessageSource): Promise { const agent = await this.resolveAgent(request.sessionId) if (hasPromptRequest(agent, request.requestId)) return { accepted: true } + if (request.content.length === 0) { + try { + return await agent.runMaintenance(async () => { + const inbox = agent.inbox + if (request.mode !== 'queue' || this.continuation.stateOf(agent.session).ending === null + || inbox.nextTurn.length > 0 || inbox.nextStep.some(message => message.source.kind === 'user')) { + throw new RemoteError('session/agent-busy', 'generation continuation is unavailable', {}) + } + agent.followup(createUserMessage({ content: [], source })) + return { accepted: true as const } + }) + } catch (error: unknown) { + if (remoteErrorOf(error) !== undefined) throw error + throw new RemoteError('session/agent-busy', 'generation continuation was not accepted', { reason: String(error) }) + } + } const admission = resolvePromptFileReceipts( request.content, receiptId => this.ctx.fileUploads.resolve(agent, receiptId), @@ -547,6 +573,9 @@ function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean { if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents().some((event) => { + if (event.type === 'agent/inbox/spliced') { + return event.data.inserted.some(message => message.content.length === 0 && matches(message)) + } if (event.type !== 'user/message') return false const source = event.data.source return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId diff --git a/packages/client/ui-conversation/src/host/continuation.ts b/packages/api/session-controller/src/continuation.ts similarity index 65% rename from packages/client/ui-conversation/src/host/continuation.ts rename to packages/api/session-controller/src/continuation.ts index ef2239338e..8673cfc731 100644 --- a/packages/client/ui-conversation/src/host/continuation.ts +++ b/packages/api/session-controller/src/continuation.ts @@ -1,5 +1,5 @@ /** Constant-size continuation eligibility over the complete durable Session. */ -import type {} from '../continuation-types.ts' +import type {} from './types.ts' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import { brandNumber } from '@deepseek-ai/dsh-brand' @@ -15,27 +15,35 @@ const stateSchema = z.object({ admitted: z.boolean(), failedAttempt: z.boolean(), ending: endingSchema, + continuing: z.boolean(), }) -type ContinuationState = z.infer +/** Recoverable manual-continuation facts, including an accepted empty input awaiting its turn. */ +export type ContinuationState = z.infer -const EMPTY: ContinuationState = { admitted: false, failedAttempt: false, ending: null } +const EMPTY: ContinuationState = { admitted: false, failedAttempt: false, ending: null, continuing: false } -/** Eligibility requires admitted human input and a failed settlement in the last request step. */ +/** Eligibility requires admitted human work and a failed settlement in the last request step. */ export const continuationProjectionDefinition = { key: 'conversationContinuation', - stateVersion: 1, + stateVersion: 2, stateSchema, init: (): ContinuationState => EMPTY, apply: (state, event) => { switch (event.type) { + case 'agent/inbox/spliced': + if (state.continuing && event.data.target === 'next-turn' && event.data.outcome === 'canceled' + && event.data.start === 0 && (event.data.removedCount ?? 0) > 0) return { ...state, continuing: false } + return state.ending !== null && !state.continuing && event.data.target === 'next-turn' + && event.data.inserted.some(message => message.source.kind === 'user' && message.content.length === 0) + ? { ...state, continuing: true } : state case 'turn/start': - return EMPTY + return state.continuing ? { ...EMPTY, admitted: true } : EMPTY case 'step/start': case 'assistant/message': return state.failedAttempt ? { ...state, failedAttempt: false } : state case 'user/message': - return !state.admitted && event.surfaceOp === 'append' && event.data.source.kind === 'user' + return !state.admitted && event.surfaceOp === 'append' && event.data.source.kind === 'user' && event.data.content.length > 0 ? { ...state, admitted: true } : state case 'assistant/attempt': return state.failedAttempt ? state : { ...state, failedAttempt: true } diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 94d08c77e3..f87c4f0b0f 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -192,6 +192,8 @@ export class SessionController extends TypertRemoteService { this.agents = new ApiSessionAgentController(ctx) this.agents.initialize() this.commands = new SessionCommandController(ctx, this.agents, process.cwd()) + await this.commands.initialize() + ctx.fiber.assertActive() ctx.effect(() => ctx.fileUploads.registerAgentResolver(async (sessionId) => { const result = await this.agents.resolveAgent(sessionId) if ('error' in result) throw result.error @@ -464,7 +466,7 @@ export class SessionController extends TypertRemoteService { @Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise { signal.throwIfAborted() - if (!hasPromptContent(request.content)) { + if (request.content.length > 0 && !hasPromptContent(request.content)) { throw new RemoteError('gateway/bad-request', 'prompt content must include non-whitespace text or an attachment', {}) } const clientTimeZone = request.clientTimeZone === undefined diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index e3e2ea375a..798ca0e04c 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -8,7 +8,7 @@ import type { import type { Branded } from '@deepseek-ai/dsh-brand' import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand' import type { TextBlock } from '@deepseek-ai/dsh-llm' -import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session/types' +import type { SessionId, SessionSeq, SessionSeqCursor } from '@deepseek-ai/dsh-session/types' import type { SessionProjectionMap, SessionProjectionSummaryExcluded, SessionProjectionSummaryValues } from '@deepseek-ai/dsh-session-projection/types' import type { Principal } from '@deepseek-ai/dsh-typert-protocol' import type { JsonValue } from '@deepseek-ai/dsh-util-values' @@ -16,6 +16,8 @@ import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { + /** Eligible latest failed turn/end for an empty prompt, or null. */ + conversationContinuation: SessionSeq | null /** Persisted facts used to summarize a Session without activating it. */ sessionListMetadata: SessionListMetadata /** @@ -317,11 +319,11 @@ export interface SessionForkValue { /** Session prompt request. */ export interface SessionPromptRequest { - /** Client-minted identity persisted on the exact accepted user message. */ + /** Client-minted identity persisted on the accepted inbox input, including empty continuation. */ readonly requestId: SessionRequestId readonly sessionId: SessionId readonly mode: 'queue' | 'steer' - /** At least one non-whitespace text part or attachment. */ + /** Non-whitespace text or attachments; an empty array continues an eligible failed idle Session. */ readonly content: readonly PromptContentPart[] readonly clientTimeZone?: string } diff --git a/packages/client/ui-conversation/tests/continuation-fixture.ts b/packages/api/session-controller/tests/continuation-fixture.ts similarity index 100% rename from packages/client/ui-conversation/tests/continuation-fixture.ts rename to packages/api/session-controller/tests/continuation-fixture.ts diff --git a/packages/client/ui-conversation/tests/continuation.host.spec.ts b/packages/api/session-controller/tests/continuation.host.spec.ts similarity index 78% rename from packages/client/ui-conversation/tests/continuation.host.spec.ts rename to packages/api/session-controller/tests/continuation.host.spec.ts index 1b0dd1c7b3..f87b29809f 100644 --- a/packages/client/ui-conversation/tests/continuation.host.spec.ts +++ b/packages/api/session-controller/tests/continuation.host.spec.ts @@ -5,9 +5,9 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session/types' import { expect, it, onTestFinished, vi } from 'vitest' -import * as plugin from '../src/index.ts' -import { continuationProjectionDefinition as definition } from '../src/host/continuation.ts' -import { paginate } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' + +import { continuationProjectionDefinition as definition } from '../src/continuation.ts' +import { paginate } from '../src/history.ts' import { continuationTurn as turn } from './continuation-fixture.ts' const failed = (code: string): TurnEndReason => ({ kind: 'error', error: { code, message: code } }) @@ -86,7 +86,7 @@ it('publishes a complete-log result independently of the visible page and releas onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - const fiber = await ctx.plugin(plugin) + const fiber = await ctx.sessionProjections.register(definition) const session = ctx.sessions.create(SessionId('continued')) expect(ctx.sessionProjections.snapshot(session).values.conversationContinuation).toBeNull() const history: SessionEvent[] = [] @@ -111,7 +111,7 @@ it('publishes a complete-log result independently of the visible page and releas expect(ctx.sessionProjections.snapshot(session).values.conversationContinuation).toBe(ending) expect(changed.mock.calls.map(([, key, value]) => [key, value])).toEqual([['conversationContinuation', null], ['conversationContinuation', ending]]) expect(ctx.sessionProjections.checkpoint(session).conversationContinuation) - .toEqual({ ver: 1, seq: ending, val: { admitted: false, failedAttempt: false, ending } }) + .toEqual({ ver: 2, seq: ending, val: { admitted: false, failedAttempt: false, ending, continuing: false } }) session.append('turn/start', { turn: 2 }) expect(ctx.sessionProjections.snapshot(session).values.conversationContinuation).toBeNull() expect(changed).toHaveBeenCalledTimes(3) @@ -124,10 +124,10 @@ it('restores valid, absent and outdated checkpoints through the registry', async onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(plugin) + await ctx.sessionProjections.register(definition) const session = ctx.sessions.create(SessionId('restored')) const log = events(failed('SERVER')) - const checkpoint = { conversationContinuation: { ver: 1, seq: SessionSeq(2), val: fold(log.slice(0, 3)) } } + const checkpoint = { conversationContinuation: { ver: 2, seq: SessionSeq(2), val: fold(log.slice(0, 3)) } } const restore = (rows: typeof checkpoint | Record, tail: readonly SessionEvent[], base: number) => ctx.sessionProjections.restore(rows, tail, SessionLogOffset(base), session.header, session.inheritedEventCount) expect(restore(checkpoint, log.slice(3), 3).snapshot.values.conversationContinuation).toBe(5) @@ -137,3 +137,32 @@ it('restores valid, absent and outdated checkpoints through the registry', async expect(() => restore(outdated, log.slice(3), 3)).toThrow('re-read from seq 0') expect(() => restore({}, log.slice(3), 3)).toThrow('re-read from seq 0') }) + +it('carries admitted work through an empty continuation and retires a canceled queued control', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const handle = await ctx.sessionProjections.register(definition) + const session = ctx.sessions.create(SessionId('empty-repeat')) + for (const event of events(failed('SERVER'))) { + if (event.type === 'user/message') session.append(event.type, event.data, { surfaceOp: 'append' }) + else if (event.type === 'turn/start') session.append(event.type, event.data) + else if (event.type === 'step/start' || event.type === 'step/end') session.append(event.type, event.data) + else if (event.type === 'assistant/attempt') session.append(event.type, event.data) + else if (event.type === 'turn/end') session.append(event.type, event.data) + } + const input = createUserMessage({ content: [], source: { kind: 'user' } }) + session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [input] }) + expect(handle.stateOf(session).continuing).toBe(true) + session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' }) + expect(handle.stateOf(session).continuing).toBe(false) + session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [input] }) + session.append('turn/start', { turn: 2 }) + expect(handle.stateOf(session).admitted).toBe(true) + session.append('step/start', { turn: 2, step: 1 }) + session.append('assistant/attempt', { turn: 2, step: 1, stream: [] }) + session.append('step/end', { turn: 2, step: 1 }) + const ending = session.append('turn/end', { turn: 2, reason: failed('SERVER') }).seq + expect(handle.stateOf(session).ending).toBe(ending) +}) diff --git a/packages/api/session-controller/tests/empty-prompt.host.spec.ts b/packages/api/session-controller/tests/empty-prompt.host.spec.ts new file mode 100644 index 0000000000..5f8d913f40 --- /dev/null +++ b/packages/api/session-controller/tests/empty-prompt.host.spec.ts @@ -0,0 +1,82 @@ +/** Empty prompt admission over the real AgentLoop and the Session Remote owner. */ +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { createUserMessage, LlmError } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { expect, it, onTestFinished } from 'vitest' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' +import { createSessionTestRemote } from './test-remote.ts' + +async function setup() { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + const fail = (): never => { throw new LlmError('busy', 'SERVER') } + const adapter = new MockAdapter([fail, fail, textResponse('done')]) + ctx.effect(() => ctx.llm.registerAdapter(['mock'], adapter)) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: '/workspace' }) + const agent = await ctx.agentLoop.create(SessionId('empty-prompt'), { provider: 'mock', model: 'mock' }) + const request = (id: string, mode: SessionPromptRequest['mode'] = 'queue'): SessionPromptRequest => ({ + sessionId: agent.id, requestId: id as SessionRequestId, content: [], mode, + }) + return { ctx, remote, agent, adapter, request } +} + +it('admits repeated empty prompts after failures without extra user messages or duplicate requests', async () => { + const { remote, agent, adapter, request } = await setup() + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'original task' }], source: { kind: 'user' } })) + await agent.whenIdle() + const first = request('continue-1') + expect((await remote.prompt(first)).ok).toBe(true) + await agent.whenIdle() + expect(adapter.requests).toHaveLength(2) + expect((await remote.prompt(first)).ok).toBe(true) + await agent.whenIdle() + expect(adapter.requests).toHaveLength(2) + expect((await remote.prompt(request('continue-2'))).ok).toBe(true) + await agent.whenIdle() + expect(adapter.requests).toHaveLength(3) + const events = agent.session.snapshotEvents() + expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(1) + expect(events.flatMap(event => event.type === 'turn/end' ? [event.data.reason.kind] : [])).toEqual(['error', 'error', 'completed']) + expect(events.filter(event => event.type === 'turn/start')).toHaveLength(3) + expect((await remote.prompt(request('after-completion'))).ok).toBe(false) +}) + +it('refuses empty fresh sessions, steering, queued human input and maintenance without waking work', async () => { + const { remote, agent, adapter, request } = await setup() + expect((await remote.prompt(request('empty-fresh'))).ok).toBe(false) + expect(adapter.requests).toHaveLength(0) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'original task' }], source: { kind: 'user' } })) + await agent.whenIdle() + expect((await remote.prompt(request('empty-steer', 'steer'))).ok).toBe(false) + const pending = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } }) + agent.inbox.append('next-turn', pending) + expect((await remote.prompt(request('empty-queued'))).ok).toBe(false) + agent.inbox.clear() + agent.inject(pending) + expect((await remote.prompt(request('empty-steering'))).ok).toBe(false) + agent.inbox.clear() + await agent.runMaintenance(async () => { + expect((await remote.prompt(request('empty-maintenance'))).ok).toBe(false) + }) + expect(adapter.requests).toHaveLength(1) +}) + +it('consumes injected context with the empty prompt', async () => { + const { ctx, remote, agent, adapter, request } = await setup() + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'original task' }], source: { kind: 'user' } })) + await agent.whenIdle() + agent.inject(createUserMessage({ content: [{ type: 'text', text: 'approval context' }], source: { kind: 'user-approval' } })) + agent.model.select({ provider: 'mock', model: 'changed' }) + expect((await remote.prompt(request('empty-context'))).ok).toBe(true) + await agent.whenIdle() + expect(adapter.requests).toHaveLength(2) + expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('approval context') + expect(adapter.requests[1]!.model).toBe('changed') + expect(agent.inbox.nextStep).toHaveLength(0) + expect(ctx.sessionProjections.snapshot(agent.session).values.conversationContinuation).not.toBeNull() +}) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 560c05514f..5139baf644 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -924,7 +924,6 @@ describe('sessions.prompt synchronous rejection', () => { }) const initialEvents = session.snapshotEvents() const rejectedContent: readonly SessionPromptRequest['content'][] = [ - [], [{ type: 'text', text: '' }], [{ type: 'text', text: ' \t\n' }, { type: 'text', text: '' }], ] diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index acadf82099..168fd5a0c7 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -25,7 +25,8 @@ "src/list.ts", "src/media-references.ts", "src/archived-session-gate.ts", - "src/skill-catalog.ts" + "src/skill-catalog.ts", + "src/continuation.ts" ], "references": [ { @@ -126,6 +127,9 @@ }, { "path": "../../session/working-directory" + }, + { + "path": "../../util/brand" } ] } diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2b4fd0c5db..6ee1b33613 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -21,26 +21,26 @@ en: cc4dafd1aae02468 zh: be6b67724b7ac4a3 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: ac170c9895e17840 - zh: c7603d365134ce89 + en: 8f3eb5f46b48df30 + zh: a1a1da12767f4e92 /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 /deepseek-ai-dsh-client-ui-conversation/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 -/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-message: - en: 30f3836663c881ca - zh: ee3e1d1df8603fad -/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-message/what-the-model-sees: - en: d566958ff2f23584 - zh: f6ebea015ba61872 -/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-message/token-effect: - en: 0ad06a5163a62c50 - zh: d25b93fcb2783566 -/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-message/kv-cache-effect: - en: 326e23f5913e3fc0 - zh: 865969505814e0af +/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-request: + en: 6a7ae99570c9c007 + zh: 26e86063e97d82c7 +/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-request/what-the-model-sees: + en: b1376c710fa71ce3 + zh: e751e1a490c36bd8 +/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-request/token-effect: + en: a5bbf55d50568fa2 + zh: 6c43af211055db93 +/deepseek-ai-dsh-client-ui-conversation/model-experience/manual-continuation-request/kv-cache-effect: + en: bbb4b8186c85f62d + zh: 222397b869a60df2 /deepseek-ai-dsh-client-ui-conversation/known-limitations-and-deferred-work: en: d76daa90b6d6f600 zh: 37a9f7d402b2552b diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index eadcdf2467..4a408c5950 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -76,9 +76,9 @@ The composer measures its expanded control groups after size, content, visibilit The context meter follows the [statistics-control radius](../../../docs/ui-radius.md#radius-scale). -An empty, idle ordinary Session composer offers a play action after `RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`, `EMPTY_RESPONSE`, or `STREAM_CLOSED` ends its latest turn. The failed turn must include admitted ordinary user input and a settled failed model attempt. Clicking submits a normal user message in the current interface language; drafts and attachments keep the Send action. Continued requests retain committed history and use the existing submission lifecycle. +An empty, idle ordinary Session composer offers a play action after `RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`, `EMPTY_RESPONSE`, or `STREAM_CLOSED` ends its latest turn. The failed turn must include admitted ordinary user input and a settled failed model attempt. Clicking submits `content: []` through the existing Session prompt API; drafts and attachments keep the Send action. A new balanced turn uses committed history without adding a user message. Refusal or transport failure leaves the draft empty and reports the error through the ordinary submission lifecycle. -The Host requires `sessionProjections` and publishes `conversationContinuation`; eligibility is restored from Session history independently of the browser's loaded history pages. +The Session API owns `conversationContinuation`, shared by Host admission and browser presentation. It restores eligibility from complete Session history and preserves it across repeated failed empty continuations, independently of loaded browser pages. The composer uses the shared [text-input focus policy](../ui-primitives/README.md#use-this-package) on mount, Session switching, and unlock. Identified phones and tablets require tapping the input; desktop devices retain automatic focus. On these mobile devices, a submission that empties the focused draft moves focus to the non-text composer card, dismissing the keyboard while retaining its navigation position. Rejected submissions, retained newer draft text, and another control already holding focus preserve focus. The [Lexical patch](../../../patches/lexical@0.49.0.patch) keeps caret scrolling in the same coordinate system as DOM geometry when a mobile keyboard pans the page. @@ -189,19 +189,19 @@ The selector must be a pure function of the owner currency. Its non-null return ## Model Experience -### Manual continuation message +### Manual continuation request #### What the model sees -The play action submits one ordinary user message using the interface language selected at the click: Chinese `继续`, English/French/Portuguese `continue`, Japanese `続けて`, Spanish `continúa`, German `weiter`, or Russian `продолжай`. The admitted message is stored in the Session log and included in subsequent model history; the action does not reconstruct text from the failed attempt. +The play action starts a new request from committed Session history. Its empty input is a durable inbox command, not a model-visible user message; it adds no localized or hidden continuation instruction. Pending injected context follows ordinary admission. #### Token effect -Each continuation admitted into model history adds its short localized text and the ordinary user-message overhead. The exact token count depends on the model tokenizer. A submission refused or failed before admission adds no continuation text to model history. +The action adds no continuation text or empty user-message overhead. The request still incurs the ordinary cost of retained history, current prompt sections, tool declarations, and any newly admitted context. #### KV Cache effect -The continuation message extends retained history without replacing earlier messages or prompt sections. It preserves an already-reusable prefix; provider cache availability, reuse policy, and eviction remain outside this package's control. +The control input does not alter model history. Prefix reuse still depends on the current model, prompt, tools, injected context, and provider cache policy. ## Known Limitations and Deferred Work diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 3573e9c7b4..85df62d84b 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -76,9 +76,9 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS 上下文占用率入口遵循[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。 -普通 Session 的输入框在空草稿且空闲时,若最新一轮以 `RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT`、`EMPTY_RESPONSE` 或 `STREAM_CLOSED` 结束,会提供播放图标按钮。失败轮次必须包含已进入模型历史的普通用户输入和已落盘的失败模型请求尝试。点击后按当前界面语言提交一条普通用户消息;存在草稿或附件时保留发送按钮。继续请求保留已提交历史,并沿用现有提交流程。 +空闲且草稿为空的普通 Session,在最新轮次以 `RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT`、`EMPTY_RESPONSE` 或 `STREAM_CLOSED` 结束后显示播放按钮。失败工作必须有已准入的普通用户输入和失败请求尝试。点击通过现有 Session prompt API 提交 `content: []`;已有草稿或附件仍使用发送按钮。新轮次正常闭合,使用已提交历史而不新增用户消息。请求被拒绝或传输失败时,草稿保持为空,错误沿用普通提交流程展示。 -Host 需要 `sessionProjections`,并发布 `conversationContinuation`;继续资格从 Session 历史恢复,不受浏览器已加载历史分页的影响。 +Session API 拥有 `conversationContinuation`,Host 准入和浏览器展示共用该投影。它从完整 Session 历史恢复资格,并在空输入继续再次失败时保留资格,不依赖浏览器已加载的历史分页。 composer 在挂载、切换 Session 和解除锁定时使用共享的[文字输入框聚焦策略](../ui-primitives/README.zh.md#use-this-package)。识别出的手机和平板需要点击输入框;桌面设备保留自动聚焦。在这些移动设备上,提交清空正在聚焦的草稿后,焦点移到不接受文字输入的 composer 卡片,收起键盘并保留键盘导航位置。被拒绝的提交、保留的新草稿文字,以及已经聚焦的其他控件均保留原有焦点。移动端键盘平移页面时,[Lexical 补丁](../../../patches/lexical@0.49.0.patch)让光标滚动计算与 DOM 几何使用同一坐标系。 @@ -189,19 +189,19 @@ selector 必须是 owner currency 的纯函数。非 null 返回值作为 `match ## 模型体验 -### 手动继续消息 +### 手动继续请求 #### 模型看到什么 -播放按钮按点击时选中的界面语言提交一条普通用户消息:中文为 `继续`,英语、法语和葡萄牙语为 `continue`,日语为 `続けて`,西班牙语为 `continúa`,德语为 `weiter`,俄语为 `продолжай`。已准入的消息写入 Session 日志并进入后续模型历史;该操作不从失败尝试中重建文本。 +播放按钮基于已提交的 Session 历史发起新请求。空输入作为持久 inbox 控制记录,不成为模型可见的用户消息;不会添加本地化或隐藏的继续指令。待注入上下文遵循普通准入流程。 #### Token 影响 -每条进入模型历史的继续消息增加其本地化短文本和普通用户消息的封装开销。准确 token 数取决于模型 tokenizer。在准入前被拒绝或失败的提交不会向模型历史添加继续文本。 +该操作不增加继续文本或空用户消息的封装开销。请求仍承担保留历史、当前提示词、工具声明以及新准入上下文的正常输入成本。 #### KV Cache 影响 -继续消息在保留历史后追加,不替换更早的消息或 prompt section。它保留原本可复用的前缀;提供方缓存的可用性、复用策略和淘汰规则不由本包控制。 +控制输入不改变模型历史。前缀复用仍取决于当前模型、提示词、工具、注入上下文及提供方缓存策略。 ## 已知限制与暂缓事项 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index c4b2317d94..d38d3d7ebe 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -47,9 +47,7 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3", - "@deepseek-ai/dsh-brand": "workspace:*" + "@deepseek-ai/schemastery": "workspace:~" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" @@ -98,7 +96,9 @@ "@deepseek-ai/dsh-client-shortcuts": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", "@deepseek-ai/dsh-message-quotes": "workspace:*", - "@deepseek-ai/dsh-session-projection": "workspace:*" + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-brand": "workspace:*", + "zod": "4.4.3" }, "files": [ "locale/*.json", diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index 82f030c775..9cc7e4b566 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -352,6 +352,8 @@ export type InputEvent = /** Enter submission with the current clipboard projection, its reference chips, its quotes, and its attachments. */ | { readonly type: 'enter' + /** Explicit continuation may submit an empty draft through the default sink. */ + readonly allowEmpty?: boolean readonly mode: InputSubmitMode readonly draft: string readonly occurrences: readonly Occurrence[] diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 068fb0026e..4d701b2af8 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -530,7 +530,7 @@ export interface ComposerBarInjected { toggleCommandMenu: ((selection: EditSelection) => void) | undefined stop: (() => void) | undefined /** - * Submit a localized continuation message for the displayed failure. + * Submit empty continuation input for the displayed failure. * @param ending - sequence of the displayed failed turn/end. */ continueGeneration: ((ending: SessionSeq) => void) | undefined diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 65d7edc6b2..d4f06accc5 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -1,6 +1,5 @@ /** Browser Conversation assemble core, React adapter, shell, input, and text projection plugin. */ export type {} from './user-text/session-reference-rule.ts' -export type {} from '../continuation-types.ts' /** Declaration-merged node payloads; IDs also identify the corresponding keyed renderer. */ export interface UserTextDataMap { diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 6306216b1c..e82acfdd35 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -388,7 +388,7 @@ export class SessionInputShell implements SessionInput { * (adjudicating/submitting/sending) force-closes the transient layers: the * popup dismisses and the menu tracks frozen. */ - submit(mode: InputSubmitMode = 'queue', source?: 'click' | 'enter'): void { + submit(mode: InputSubmitMode = 'queue', source?: 'click' | 'enter', allowEmpty = false): void { if (this.disposed) return const timestamp = Date.now() let state: MessageSubmissionState | undefined @@ -409,6 +409,7 @@ export class SessionInputShell implements SessionInput { } this.dispatchRun({ type: 'enter', + ...allowEmpty ? { allowEmpty: true } : {}, mode, draft: this.projection.clipboardText, occurrences: [...this.projection.occurrences], @@ -726,6 +727,10 @@ export class SessionInputShell implements SessionInput { }) const send = (text: string): void => { if (this.dead(attempt)) return + if (text === '' && draft !== '' && attachmentIds.length === 0) { + this.settleSink(attempt, Promise.resolve({ kind: 'success' })) + return + } this.settleSink(attempt, this.deps.defaultSink(text, attachmentIds, mode, attempt.signal, () => { this.acceptSink(attempt) })) } if (!(prompt instanceof Promise)) { send(prompt); return } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 36ea1c6b4b..d1454d29d7 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -25,7 +25,6 @@ import type { ComposerBlocks } from '../contract/composer-blocks.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' import type { PopupDismissFace } from './facade.ts' import { SessionInputShell } from './facade.ts' -import type {} from '../../continuation-types.ts' import { reportMessageSubmission } from './submission-analytics.ts' import { readConversationDraft } from '../stores.ts' @@ -239,7 +238,7 @@ export class InputHub implements SessionInputResolver { } /** - * Submit localized continuation text through the ordinary composer. Stale + * Submit empty continuation input through the ordinary composer. Stale * failures, active work, and drafts keep their current input unchanged. * @param id - retained ordinary Session identity. * @param ending - failure the displayed button addresses. @@ -256,8 +255,7 @@ export class InputHub implements SessionInputResolver { || input.draft.trim() !== '' || input.attachmentIds.length > 0 || (inbox?.['next-turn'].length ?? 0) > 0 || this.conversation().blocks.storeFor(id).getSnapshot() !== undefined) return - shell.setDraft(this.t('input.continueMessage')) - shell.submit('queue', 'click') + shell.submit('queue', 'click', true) } /** @@ -304,7 +302,9 @@ export class InputHub implements SessionInputResolver { signal: AbortSignal, accept: () => void, ): Promise { - if (text === '' && attachmentIds.length === 0) return Promise.resolve({ kind: 'success' }) + if (text === '' && attachmentIds.length === 0) { + return session.prompt([], mode, signal).then(result => result.ok ? { kind: 'success' } : { kind: 'error' }) + } return this.conversation().sendSession(session, text, attachmentIds, mode, signal, accept) } diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 16fe7dd034..8545f964e5 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -144,7 +144,7 @@ export class SubmitMachine { return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(draft, this.claim.token) }] } const trimmed = draft.trim() - if (trimmed === '' && attachmentIds.length === 0) return [] + if (trimmed === '' && attachmentIds.length === 0 && ev.allowEmpty !== true) return [] const attempt = this.beginAttempt(mode, draft, occurrences, quotes, attachmentIds, submission) if (trimmed.startsWith('/')) { this.phase = 'adjudicating' diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 0ace610330..0598cee7d9 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -29,8 +29,6 @@ export const zh = { 'input.stop': '停止生成', 'input.send': '发送消息', 'input.continue': '继续生成', - // Model-visible text, durably logged when admitted as an ordinary user message. - 'input.continueMessage': '继续', 'input.sending': '正在发送消息', 'input.send.queue': '排队发送', 'input.send.steer': '插话发送', @@ -416,7 +414,6 @@ export const en = { 'input.stop': 'Stop generating', 'input.send': 'Send message', 'input.continue': 'Continue generating', - 'input.continueMessage': 'continue', 'input.sending': 'Sending message', 'input.send.queue': 'Queue message', 'input.send.steer': 'Steer message', @@ -801,7 +798,6 @@ export const ja = { 'input.stop': '生成を停止', 'input.send': 'メッセージを送信', 'input.continue': '生成を続ける', - 'input.continueMessage': '続けて', 'input.sending': 'メッセージを送信中', 'input.send.queue': 'メッセージをキューに追加', 'input.send.steer': '実行中の指示にメッセージを追加', @@ -1184,7 +1180,6 @@ export const es = { 'input.stop': 'Detener generación', 'input.send': 'Enviar mensaje', 'input.continue': 'Continuar generando', - 'input.continueMessage': 'continúa', 'input.sending': 'Enviando mensaje', 'input.send.queue': 'mensaje de cola', 'input.send.steer': 'Redirigir mensaje', @@ -1569,7 +1564,6 @@ export const fr = { 'input.stop': 'Arrêter la génération', 'input.send': 'Envoyer le message', 'input.continue': 'Continuer la génération', - 'input.continueMessage': 'continue', 'input.sending': 'Envoi du message', 'input.send.queue': "Message de file d'attente", 'input.send.steer': 'Guider avec ce message', @@ -1954,7 +1948,6 @@ export const de = { 'input.stop': 'Generierung stoppen', 'input.send': 'Nachricht senden', 'input.continue': 'Generierung fortsetzen', - 'input.continueMessage': 'weiter', 'input.sending': 'Nachricht wird gesendet', 'input.send.queue': 'Warteschlangennachricht', 'input.send.steer': 'Mit Nachricht lenken', @@ -2339,7 +2332,6 @@ export const ru = { 'input.stop': 'Остановить генерацию', 'input.send': 'Отправить сообщение', 'input.continue': 'Продолжить генерацию', - 'input.continueMessage': 'продолжай', 'input.sending': 'Отправка сообщения', 'input.send.queue': 'Сообщение в очереди', 'input.send.steer': 'Направить сообщение', @@ -2724,7 +2716,6 @@ export const pt = { 'input.stop': 'Parar geração', 'input.send': 'Enviar mensagem', 'input.continue': 'Continuar gerando', - 'input.continueMessage': 'continue', 'input.sending': 'Enviando mensagem', 'input.send.queue': 'Mensagem de fila', 'input.send.steer': 'Orientar com mensagem', diff --git a/packages/client/ui-conversation/src/continuation-types.ts b/packages/client/ui-conversation/src/continuation-types.ts deleted file mode 100644 index 9acece7469..0000000000 --- a/packages/client/ui-conversation/src/continuation-types.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Host-derived eligibility for an explicit generation-continuation message. */ -import type { SessionSeq } from '@deepseek-ai/dsh-session/types' - -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionMap { - /** Eligible latest turn/end, or null while no settled request can be continued. */ - conversationContinuation: SessionSeq | null - } -} diff --git a/packages/client/ui-conversation/src/index.ts b/packages/client/ui-conversation/src/index.ts index 6c7515a448..6ef779d42b 100644 --- a/packages/client/ui-conversation/src/index.ts +++ b/packages/client/ui-conversation/src/index.ts @@ -1,6 +1,5 @@ -/** Host continuation eligibility and browser conversation preferences. */ +/** Browser conversation preferences. */ import type {} from '@deepseek-ai/dsh-settings' -export type {} from './continuation-types.ts' import type { Volatile, Context } from '@deepseek-ai/cordis' import type { BusyEnterBehavior } from './submission-settings.ts' @@ -8,7 +7,6 @@ import z from '@deepseek-ai/schemastery' import { BUSY_ENTER_FIELD } from './submission-settings.ts' import { ConversationSettingsFields } from './submission-settings.ts' -import { continuationProjectionDefinition } from './host/continuation.ts' export { BUSY_ENTER_BEHAVIORS, BUSY_ENTER_FIELD, CONVERSATION_SETTINGS_NAMESPACE, @@ -26,14 +24,12 @@ export const Config = z.object({ [BUSY_ENTER_FIELD]: ConversationSettingsFields[BUSY_ENTER_FIELD].volatile(), }) -/** Required registry for the durable generation-continuation projection. */ -export const inject = ['sessionProjections'] +/** Settings integration is optional and does not block activation. */ +export const inject = [] -/** Register generation-continuation eligibility and browser preferences. +/** Register browser conversation preferences. * @param ctx Plugin context used for optional settings presentation. - * @returns when continuation state is registered and available to Session carriers. */ -export async function apply(ctx: Context): Promise { +export function apply(ctx: Context): void { ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) }) - await ctx.sessionProjections.register(continuationProjectionDefinition) } diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 8f2b967e06..a1cf51452a 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -29,7 +29,7 @@ import { createUserMessage, LlmAttemptId } from '@deepseek-ai/dsh-llm' import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionEventWindow } from '@deepseek-ai/dsh-api-session-controller/client' import type { InboxWireState } from '@deepseek-ai/dsh-agent/types' -import { continuationTurn } from './continuation-fixture.ts' +import { continuationTurn } from '../../../api/session-controller/tests/continuation-fixture.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { @@ -165,7 +165,7 @@ async function bench(renderQuote = false) { } describe('Conversation inject API', () => { - it.each([['zh', '继续'], ['en', 'continue']])('submits one continuation in the current %s locale', async (locale, message) => { + it.each(['zh', 'en'])('submits empty continuation input in the %s locale', async (locale) => { const b = await bench() onTestFinished(() => b.runtime.dispose()) await b.runtime.sessions.replaceEvents(ROOT, continuationTurn({ kind: 'error', error: { code: 'SERVER', message: 'busy' } })) @@ -181,7 +181,7 @@ describe('Conversation inject API', () => { composer.continueGeneration!(SessionSeq(5)) expect(composer.keyboard!.snapshot.phase).toBe('sending') await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledOnce() }) - expect(b.sessionFake.prompt.mock.calls[0]?.[0]).toEqual([{ type: 'text', text: message }]) + expect(b.sessionFake.prompt.mock.calls[0]?.[0]).toEqual([]) expect(b.sessionFake.prompt.mock.calls[0]?.[1]).toBe('queue') pending.resolve({ ok: true, value: { accepted: true } }) await vi.waitFor(() => { expect(composer.keyboard!.snapshot.phase).toBe('plain') }) @@ -322,7 +322,7 @@ describe('Conversation inject API', () => { }, ) - it.each(['refused', 'failed'] as const)('retains a visible editable continuation draft when submission is %s', async (outcome) => { + it.each(['refused', 'failed'] as const)('keeps continuation input empty and exposes submission failure when %s', async (outcome) => { const b = await bench() onTestFinished(() => b.runtime.dispose()) await b.runtime.sessions.setProjection(ROOT, 'conversationContinuation', SessionSeq(5)) @@ -335,11 +335,11 @@ describe('Conversation inject API', () => { else b.sessionFake.prompt.mockRejectedValueOnce(new Error('connection lost')) composer.continueGeneration!(SessionSeq(5)) await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledOnce(); expect(composer.keyboard!.snapshot.phase).toBe('plain') }) - expect(composer.keyboard!.snapshot.draft).toBe('继续') + expect(composer.keyboard!.snapshot.draft).toBe('') if (outcome === 'refused') expect(b.runtime.sessions.binding(ROOT)!.session.getSnapshot().promptError?.error.message).toBe('request refused') else expect(composer.hooks.notices.getSnapshot()).toMatchObject({ level: 'error', text: 'connection lost' }) composer.continueGeneration!(SessionSeq(5)) - expect(b.sessionFake.prompt).toHaveBeenCalledOnce() + await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledTimes(2) }) }) it('renders literal quote text through the actual Factory and keyed node registration', async () => { diff --git a/packages/client/ui-conversation/tests/config.host.spec.ts b/packages/client/ui-conversation/tests/config.host.spec.ts index 402063cedc..25420eff00 100644 --- a/packages/client/ui-conversation/tests/config.host.spec.ts +++ b/packages/client/ui-conversation/tests/config.host.spec.ts @@ -1,6 +1,6 @@ import SessionStore from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { Context, FiberState } from '@deepseek-ai/cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, onTestFinished } from 'vitest' import * as HostPlugin from '../src/index.ts' import { liveConfig, omitsGeneratedPage } from '../../../settings/settings/tests/live-config.ts' @@ -32,15 +32,3 @@ it('keeps its own instance off the generated Settings pages', () => omitsGenerat await ctx.plugin(SessionProjectionRegistry) return ctx.plugin(HostPlugin) })) - -it('waits for the required projection registry before activation', async () => { - const ctx = new Context() - onTestFinished(() => ctx.fiber.dispose()) - await ctx.plugin(SessionStore) - const fiber = ctx.plugin(HostPlugin) - await ctx.fiber.await() - expect(fiber.state).toBe(FiberState.PENDING) - await ctx.plugin(SessionProjectionRegistry) - await fiber - expect(fiber.state).toBe(FiberState.ACTIVE) -}) diff --git a/packages/client/ui-conversation/tsconfig.host.json b/packages/client/ui-conversation/tsconfig.host.json index cc278423fc..ec30b21bfe 100644 --- a/packages/client/ui-conversation/tsconfig.host.json +++ b/packages/client/ui-conversation/tsconfig.host.json @@ -7,9 +7,7 @@ }, "files": [ "src/submission-settings.ts", - "src/index.ts", - "src/continuation-types.ts", - "src/host/continuation.ts" + "src/index.ts" ], "references": [ { @@ -20,15 +18,6 @@ }, { "path": "../../settings/settings" - }, - { - "path": "../../core/session" - }, - { - "path": "../../session/session-projection" - }, - { - "path": "../../util/brand" } ] } diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 33c53afffa..2a3ecdf694 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -51,8 +51,8 @@ en: eea56903c4ff82f5 zh: c0d5abb5767780ed /deepseek-ai-dsh-agent-loop/further-exploration: - en: 7ac519f8e635acbc - zh: 975e514915dc6c9f + en: 687e42aa91c15be5 + zh: 0e2d860391a9ce89 /deepseek-ai-dsh-agent-loop/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 28b862ff9d..9beebb6543 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -147,6 +147,8 @@ The package-level contract is enough for most consumers; read these when you nee ----- +Empty ordinary input remains an identified inbox command, so it can wake a new turn while adding no `user/message` to the model surface. It passes normal pre-step admission; a rejected or removed input does not authorize a model request. Turn and step endings retain their normal success, failure, and cancellation handling. + ## Model Experience ### Complete conversation request diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index f872d4289e..8bbbd145c5 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -147,6 +147,8 @@ loop 在发布工厂前等待 inbox 与轮次边界注册完成,并将句柄 ----- +空的普通输入仍是带身份的 inbox 控制记录,可以唤醒新轮次,但不向模型消息面添加 `user/message`。它经过正常 pre-step 准入;被拒绝或移除的输入不授权模型请求。轮次和步骤结束沿用正常的成功、失败与取消处理。 + ## 模型体验 ### 完整对话请求 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 03c4c0c2bf..619cca96ba 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -460,7 +460,7 @@ export class ReactLoopAgent implements Agent { this.session.append('user/message', context, { surfaceOp: 'append' }) contextAdmitted = true } - } else { + } else if (message.source.kind !== 'user' || message.content.length > 0) { this.session.append('user/message', message, { surfaceOp: 'append' }) } } diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index 5287cb4deb..16c07fcf4e 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -1,5 +1,5 @@ import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -32,6 +32,31 @@ function fail(message: string, code: string): () => never { } describe('agent/request-error', () => { + it('continues repeated failures through empty followups with balanced turns and one admitted user message', async () => { + const adapter = new MockAdapter([fail('busy', 'SERVER'), fail('still busy', 'SERVER'), textResponse('done')]) + const ctx = await harness(adapter) + onTestFinished(() => ctx.fiber.dispose()) + const agent = await ctx.agentLoop.create(SessionId('empty-continuation'), { provider: 'mock', model: 'mock' }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'original task' }], source: { kind: 'user' } })) + await agent.whenIdle() + for (let index = 0; index < 2; index++) { + agent.followup(createUserMessage({ content: [], source: { kind: 'user' } })) + await agent.whenIdle() + } + expect(adapter.requests).toHaveLength(3) + for (const request of adapter.requests) { + expect(request.messages.filter(message => message.role === 'user').map(message => message.content)) + .toEqual([[{ type: 'text', text: 'original task' }]]) + } + const events = agent.session.snapshotEvents() + expect(events.filter(event => event.type === 'user/message')).toHaveLength(1) + expect(events.flatMap(event => event.type === 'turn/start' || event.type === 'turn/end' + ? [`${event.type}:${event.data.turn}`] : [])) + .toEqual(['turn/start:1', 'turn/end:1', 'turn/start:2', 'turn/end:2', 'turn/start:3', 'turn/end:3']) + expect(events.flatMap(event => event.type === 'turn/end' ? [event.data.reason.kind] : [])) + .toEqual(['error', 'error', 'completed']) + }) + it('does not offer middleware failures to request recovery', async () => { const adapter = new MockAdapter([textResponse('unused')]) const ctx = await harness(adapter) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 53a219df1a..3aeeb9a406 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -60,8 +60,8 @@ en: e48eae6c7de88cf8 zh: 1f5b37bdb0e53788 /deepseek-ai-dsh-agent/further-exploration: - en: 4ef57cfafa344581 - zh: 73575876bfdc7a18 + en: d9d9bda2357ef857 + zh: 5fa1368d5f8912e2 /deepseek-ai-dsh-agent/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index a7433347f8..2f7c830c7c 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -151,6 +151,8 @@ The package-level contract is enough for most consumers; read these when you nee ----- +An ordinary `followup` with `content: []` is an explicit request to start a new turn from committed history. It retains the identified inbox receipt and normal lifecycle without creating a model-visible user message. A pre-step rejection or a decision rewritten to an empty batch still prevents the request. + ## Model Experience ### User, steering, and injected messages diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index a92b8338e0..1eb86a7a5e 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -151,6 +151,8 @@ await handle.agent.whenIdle() ----- +普通 `followup` 的 `content: []` 明确请求基于已提交历史启动新轮次。它保留带身份的 inbox 回执和正常生命周期,不生成模型可见的用户消息。pre-step 拒绝或改写为空批次的决策仍会阻止请求。 + ## 模型体验 ### 用户、steering 与注入消息 diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index 2b1698e39c..58d883721c 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -232,6 +232,7 @@ declare module './types.ts' { /** * Route identified input to an inbox boundary and optionally wake the driver. + * Empty ordinary input drives work without becoming a model-visible message. * Waking input submitted after active cancellation is queued for the next * turn and runs when the aborted activity converges to idle; a `disposed` * cancel leaves it parked. A wake submitted while already idle always opens @@ -245,7 +246,8 @@ declare module './types.ts' { /** * Queue an ordinary follow-up turn and wake the driver. The item becomes the - * sole ordinary message of its own turn. + * sole ordinary message of its own turn. An empty ordinary input starts a + * turn from committed history without appending a model-visible user message. * @param message - identified prompt content and the source that supplied it. */ followup(message: UserMessage): void diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 489c15f56e..a8056e3e2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4001,15 +4001,9 @@ importers: packages/client/ui-conversation: dependencies: - '@deepseek-ai/dsh-brand': - specifier: workspace:* - version: link:../../util/brand '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - zod: - specifier: 4.4.3 - version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ @@ -4029,6 +4023,9 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:* version: link:../../attachment/attachment + '@deepseek-ai/dsh-brand': + specifier: workspace:* + version: link:../../util/brand '@deepseek-ai/dsh-client-file-upload': specifier: workspace:* version: link:../file-upload @@ -4143,6 +4140,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + zod: + specifier: 4.4.3 + version: 4.4.3 packages/client/ui-deepseek-model-label-1017: devDependencies: diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 3141b7bb24..fe17f6f952 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -113,7 +113,6 @@ RESTART_SECOND_SESSION_ID = "process-two" RESUME_PROMPT = "Continue the first Python SDK session after restarting the runtime." RESUME_TEXT = "RESUMED_PROCESS_OK" RECOVERY_PROMPT = "Run three todo updates; the scheduler will reject the second call." -RECOVERY_CONTINUE_PROMPT = "Continue after the scheduler failure without repeating any tools." RECOVERY_TEXT = "SCHEDULER_RECOVERY_OK" RECOVERY_SESSION_ID = "scheduler-recovery" RECOVERY_CALL_IDS = ("scheduler-complete", "scheduler-fail", "scheduler-unstarted") @@ -348,7 +347,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: block.get("text") for block in latest.get("content", []) if isinstance(block, dict) and block.get("type") == "text" - and block.get("text") in {RECOVERY_PROMPT, RECOVERY_CONTINUE_PROMPT} + and block.get("text") == RECOVERY_PROMPT ), None) if recovery_prompt == RECOVERY_PROMPT: assert_advertised_tool(body, "todo_write") @@ -358,7 +357,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: chunks.extend({**chunk, "index": index} for chunk in call[1:-2]) chunks.extend(call[-2:]) return chunks - if recovery_prompt == RECOVERY_CONTINUE_PROMPT: + if any(block.get("tool_use_id") == RECOVERY_CALL_IDS[-1] + for message in messages for block in message.get("content", []) + if isinstance(block, dict) and block.get("type") == "tool_result"): results = [ block for message in messages for block in message.get("content", []) @@ -1909,7 +1910,7 @@ def smoke_sdk_scheduler_recovery(base_url: str, executable: Path, update_snapsho with connect() as harness: failed = harness.run(RECOVERY_PROMPT, session_id=RECOVERY_SESSION_ID) - continued = harness.run(RECOVERY_CONTINUE_PROMPT, session_id=RECOVERY_SESSION_ID) + continued = harness.run([], session_id=RECOVERY_SESSION_ID) results = [failed, continued] if [result.finish_reason for result in results] != ["error", "completed"]: outcomes = [ diff --git a/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/requests.json b/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/requests.json index f83fee366e..41cd02a726 100644 --- a/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/requests.json +++ b/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/requests.json @@ -97,10 +97,6 @@ } ], "is_error": true - }, - { - "type": "text", - "text": "Continue after the scheduler failure without repeating any tools." } ] } diff --git a/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/result.json b/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/result.json index 46704a4ab7..7172a0edd1 100644 --- a/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/result.json +++ b/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/result.json @@ -57,7 +57,6 @@ "turn/start", "agent/inbox/spliced", "step/start", - "user/message", "assistant/message", "step/end", "turn/end" @@ -71,7 +70,6 @@ "session.event", "session.event", "session.event", - "session.event", "session.status" ] } diff --git a/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/session.v4.jsonl b/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/session.v4.jsonl index c679ff36b7..37ccfaa185 100644 --- a/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/session.v4.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/scheduler-recovery/session.v4.jsonl @@ -21,11 +21,10 @@ {"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-scheduler-unstarted-19","role":"tool","toolCallId":"scheduler-unstarted","isError":true,"source":{"kind":"tool","callId":"scheduler-unstarted"},"content":[{"type":"text","text":"The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed."}]},"error":{"name":"ToolNotStartedError","code":"TOOL_NOT_STARTED"}},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"error","error":{"message":"Snapshot scheduler preparation failed","code":"UNKNOWN"}}}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SCHEDULER_RECOVERY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["SCHEDULER_RECOVERY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SCHEDULER_RECOVERY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 1a687411fc..ddd859bd38 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -485,11 +485,23 @@ function modelFromSession(log: string): { provider: string; model: string } { function turnActions(log: string): TurnAction[] { const actions: TurnAction[] = [] let current: TurnAction | undefined + let emptyQueued = false for (const record of records(log)) { + if (record.type === 'agent/inbox/spliced') { + const data = record.data as JsonObject | undefined + if (data?.target === 'next-turn' && Array.isArray(data.inserted)) { + emptyQueued ||= data.inserted.some(value => { + const message = value as JsonObject + return (message.source as JsonObject | undefined)?.kind === 'user' + && Array.isArray(message.content) && message.content.length === 0 + }) + } + } if (record.type === 'turn/start') { const data = record.data as JsonObject | undefined if (typeof data?.turn !== 'number') throw new Error('SDK snapshot turn/start has no turn') - current = { turn: data.turn } + current = { turn: data.turn, ...emptyQueued ? { content: [] } : {} } + emptyQueued = false continue } if (record.type === 'user/message' && current !== undefined && current.content === undefined) { @@ -768,7 +780,8 @@ async function runScenario(scenario: CorpusScenario): Promise<{ session = await harness.resumeSession(sessionId) expect(session.id).toBe(sessionId) } - const result = await session.run(materializeInput(action.content, scenario, cwd, liveSessions), { + const input = materializeInput(action.content, scenario, cwd, liveSessions) + const result = await session.run(input, { onNotification: (notification) => { notifications.push(notification) observe(notification) @@ -1117,6 +1130,8 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { } } }) } if (scenario.name === 'tool-scheduler-recovery') { + expect(records(ordered[0]!.content).filter(event => event.type === 'user/message' + && (event.data['source'] as { kind: string }).kind === 'user')).toHaveLength(1) expect(results).toHaveLength(2) const events = results.flatMap(result => result.events) expect(results[1]?.finalResponse, JSON.stringify(events.filter(event => event.type === 'turn/end'))) diff --git a/snapshots/sdk/tool-scheduler-recovery/notifications.expected.jsonl b/snapshots/sdk/tool-scheduler-recovery/notifications.expected.jsonl index 885df800f9..cfe4388d42 100644 --- a/snapshots/sdk/tool-scheduler-recovery/notifications.expected.jsonl +++ b/snapshots/sdk/tool-scheduler-recovery/notifications.expected.jsonl @@ -19,13 +19,12 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":1}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":21,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"Snapshot scheduler preparation failed","code":"UNKNOWN"}}}}}} {"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":22,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":22,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}} {"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":23,"time":0,"data":{"turn":2}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":24,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":2,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":26,"time":0,"data":{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SCHEDULER_RECOVERY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":64,"outputTokens":28},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["SCHEDULER_RECOVERY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SCHEDULER_RECOVERY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":28}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":2,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":26,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SCHEDULER_RECOVERY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":64,"outputTokens":28},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["SCHEDULER_RECOVERY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SCHEDULER_RECOVERY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":28}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":27,"time":0,"data":{"turn":2,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":28,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}}} {"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} diff --git a/snapshots/sdk/tool-scheduler-recovery/session.v4.jsonl b/snapshots/sdk/tool-scheduler-recovery/session.v4.jsonl index 1eb28fe0c1..31eb0f7d38 100644 --- a/snapshots/sdk/tool-scheduler-recovery/session.v4.jsonl +++ b/snapshots/sdk/tool-scheduler-recovery/session.v4.jsonl @@ -21,11 +21,10 @@ {"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-scheduler-unstarted-19","role":"tool","toolCallId":"scheduler-unstarted","isError":true,"source":{"kind":"tool","callId":"scheduler-unstarted"},"content":[{"type":"text","text":"The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed."}]},"error":{"name":"ToolNotStartedError","code":"TOOL_NOT_STARTED"}},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"error","error":{"message":"Snapshot scheduler preparation failed","code":"UNKNOWN"}}}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"}]}} {"type":"turn/start","data":{"turn":2}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Continue after the scheduler failure without repeating any tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SCHEDULER_RECOVERY_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":64,"outputTokens":28},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["SCHEDULER_RECOVERY_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SCHEDULER_RECOVERY_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":28}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/live-interactions/continued.expected.md b/snapshots/web/live-interactions/continued.expected.md index 1eb97847c2..0b4bcd0205 100644 --- a/snapshots/web/live-interactions/continued.expected.md +++ b/snapshots/web/live-interactions/continued.expected.md @@ -19,7 +19,7 @@ - status: - text: This turn failedupstream 503 - code: SERVER -- text: plan Plan mode on. Use /plan off to leave. plan Plan mode off. continue {{clock}} +- text: plan Plan mode on. Use /plan off to leave. plan Plan mode off. - button "Copy" - status: Completed - button "Completed in {{duration}}"