From 5e68d812d187ed7a2b12ca581fd3e4c01e464afd Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 31 Jul 2026 05:20:10 -0700 Subject: [PATCH 1/4] fix(web): let context injection cards fit content --- apps/web/tests/seeded-history.e2e.ts | 21 +++++++++++++++++++ .../client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../chat/ContextInjectionRow.module.css | 4 ++-- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 9acb0fa136..6a4c079953 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -357,6 +357,27 @@ describe('web e2e: seeded history renders through cold resume', () => { await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE) }, 60_000) + it.skipIf(MODE === 'record')('fits short injected context without a scrollport', async () => { + const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) + if (agent === undefined) throw new Error('seeded session did not attach an agent') + agent.inject(createUserMessage({ + content: [{ type: 'text', text: 'Short injected context.' }], + source: { kind: 'plugin', plugin: 'fixture' }, + })) + + const disclosures = page.getByRole('button', { name: 'Context injection' }) + await expect.poll(() => disclosures.count(), { timeout: 10_000 }).toBe(2) + const disclosure = disclosures.nth(1) + await disclosure.click() + await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') + + const body = page.locator('[data-context-injection-body]') + const bodyBox = await body.boundingBox() + if (bodyBox === null) throw new Error('short context disclosure geometry is not measurable') + expect(bodyBox.height).toBeLessThan(141) + expect(await body.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(false) + }) + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { // No replay fixture was installed and the llm seam is open — any stray // stream would have failed the turn loudly. Cleanliness pins the wire. diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f8ac552846..c60362a691 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 68115f8f9b9225d1f6b0e9cc93394d042c2befaa -README.zh.md: 65a3b334ff316a37dfb8506396eae8c17cccb40d +README.md: 3aa24316993fce620454556ecdf9b2c30e470470 +README.zh.md: b219bf292bee3813cb0f53d6f1feb7745417db2d diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 68115f8f9b..3aa2431699 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -12,7 +12,7 @@ The view ring IS a slot: the conversation registration declares the `'conversati Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. -Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded 141px scrollport shows bounded inline JSON for both `content` and `source`, and no tool state, summary, or keyed toolview dispatch is synthesized ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). +Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 65a3b334ff..b219bf292b 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -10,7 +10,7 @@ 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包(package)自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 -已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开后的 141px 滚动区会以内联 JSON 的形式有界展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 +已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 diff --git a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css index 7ffdfca5bd..e603931a27 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ContextInjectionRow.module.css @@ -1,4 +1,4 @@ -/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px clipped code block. */ +/* Figma 10:2482: 24px Tool calls header, 4px gap, 141px code block cap. */ .root { min-width: 0; @@ -15,7 +15,7 @@ .body { box-sizing: border-box; width: calc(100% - 22px); - height: 141px; + max-height: 141px; margin: 4px 0 0 22px; overflow: auto; padding: 10px 16px 12px 12px; From 32a0e871b7fd51ba79ecc42368c6ceea6f814081 Mon Sep 17 00:00:00 2001 From: kingwl Date: Fri, 31 Jul 2026 20:51:05 +0800 Subject: [PATCH 2/4] fix(web): preserve queue on stop --- ...6-07-31-web-stop-preserves-queue.i18n.yaml | 6 ++ .../2026-07-31-web-stop-preserves-queue.md | 37 ++++++++++++ .../2026-07-31-web-stop-preserves-queue.zh.md | 37 ++++++++++++ ...-29-addressable-queue-operations.i18n.yaml | 4 +- ...2026-07-29-addressable-queue-operations.md | 6 +- ...6-07-29-addressable-queue-operations.zh.md | 6 +- ...-06-20-public-agent-stop-surface.i18n.yaml | 6 +- .../2026-06-20-public-agent-stop-surface.md | 12 ++-- ...2026-06-20-public-agent-stop-surface.zh.md | 12 ++-- apps/web/tests/queue-actions.e2e.ts | 56 ++++++++++++++----- .../queue-actions/preserved.expected.md | 44 +++++++++++++++ .../runtime/src/client/contract/session.ts | 3 +- .../runtime/src/client/sessions/session.ts | 3 +- .../ui-conversation/src/client/service.ts | 4 +- packages/core/agent-loop/tests/cancel.spec.ts | 43 ++++++++++++-- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 2 +- packages/host/apiproxy/src/api/sessions.ts | 2 +- 20 files changed, 241 insertions(+), 50 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.zh.md create mode 100644 apps/web/tests/snapshots/queue-actions/preserved.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.i18n.yaml new file mode 100644 index 0000000000..c20d347fc9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md +2026-07-31-web-stop-preserves-queue.md: 943e95d6951a28929c4f8ce4d0b6e17224b08ede +2026-07-31-web-stop-preserves-queue.zh.md: bbadd8adf8fd5bb0604ef87d322e48ce4c2ed759 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md new file mode 100644 index 0000000000..943e95d695 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.md @@ -0,0 +1,37 @@ +# Agent Note: Web stop preserves pending Queue + +Status: implemented + +English | [中文](2026-07-31-web-stop-preserves-queue.zh.md) + +## Problem + +The Web stop button reached `session.cancel`, which mapped to broad `agent.cancel({ kind: 'user' })`. During an active turn, ordinary composer submissions are already accepted as independently addressable Queue occurrences. Broad cancellation discarded every occurrence when the user intended to stop only the current generation, conflating turn interruption with the Queue's explicit delete operation. + +The browser cannot repair that loss by resending visible rows. It does not own their live `InboxItemId`, wake policy, or claim race, and a resend can duplicate work that the Host has already claimed. + +## Decision + +`session.cancel` is the Web Host API's active-turn stop. It calls `agent.cancel({ kind: 'user' }, { keepInbox: true })`, preserving pending inbox work while cooperatively aborting the current turn. The underlying option preserves queued and steering entries; the Web Queue projection continues to expose only queued entries. + +The AgentLoop starts no concurrent replacement turn. It closes and flushes the interrupted turn, reaches cancellation quiescence, and then claims the next waking queued occurrence through its existing FIFO driver. That claim emits `agent/inbox/dequeue`, so the Host's authoritative `session/queue` snapshot retires the claimed row and leaves the remaining tail visible. The browser neither resends nor promotes any row. Work that ignores cancellation delays this handoff until it settles. + +This mapping changes only the Host `session.cancel` endpoint used by Web clients. The default `Agent.cancel()` contract remains broad, ACP and TUI retain their existing cancellation policies, and `AgentHandle.dispose()` still clears pending work during teardown. Queue row removal remains the explicit Web action for discarding one pending occurrence. + +## Alternatives considered + +**Keep broad cancellation for the stop button.** Rejected because stopping one generation should not destroy independently queued user intent; the Queue already owns explicit deletion. + +**Resend the next row from the browser after cancellation.** Rejected because the Host owns occurrence identity and claim order. Client resubmission can duplicate work, reorder the FIFO, or race an authoritative dequeue. + +**Start the next turn before cancelled work reaches quiescence.** Rejected because two turns would concurrently mutate one session log and share Agent-owned resources. Cooperative cancellation waits truthfully for the active work to settle. + +**Add a wire option for broad versus preserving cancellation.** Rejected until the Web product has a separate “stop and clear Queue” interaction. The existing stop button has one policy, while per-row delete already supplies the current discard control. + +## Verification + +AgentLoop coverage holds an active model stream, queues two waking turns, cancels with `keepInbox`, and pins the aborted-then-completed turn reasons, FIFO user-message order, absence of discard events, and eventual idle state. The keyless Web scenario drives the built composition over HTTP/SSE: it stops one hung turn, observes the next queued occurrence start while the tail remains visible, stops that turn, and observes the final queued occurrence complete. Its accessibility snapshot pins the intermediate preserved-Queue state. + +## Consequences + +Web stop preserves accepted queued intent and advances it automatically after truthful cancellation settlement. Queue rows may remain visible while uncooperative active work winds down, and external steering preserved by the same inbox option can enter the next admitted turn even though Web does not render steering in QueueDock. A future bulk-clear interaction requires an explicit product action rather than overloading stop. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.zh.md new file mode 100644 index 0000000000..bbadd8adf8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-web-stop-preserves-queue.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Web 停止操作保留待处理 Queue + +Status: implemented + +[English](2026-07-31-web-stop-preserves-queue.md) | 中文 + +## 问题 + +Web 停止按钮调用 `session.cancel`,后者映射到广义 `agent.cancel({ kind: 'user' })`。在活动轮次期间,普通 composer 提交已经被接纳为可独立寻址的 Queue 入队项。用户只想停止当前生成时,广义取消却会丢弃所有入队项,混淆了轮次中断与 Queue 的显式删除操作。 + +浏览器无法通过重发可见行修复这一损失。它不拥有这些行的实时 `InboxItemId`、唤醒策略或认领竞态;重发还可能重复 Host 已认领的工作。 + +## 决策 + +`session.cancel` 是 Web Host API 的活动轮次停止操作。它调用 `agent.cancel({ kind: 'user' }, { keepInbox: true })`,在协作式中止当前轮次的同时保留待处理 inbox 工作。底层选项会保留 queued 和 steering 入队项;Web Queue 投影继续只暴露 queued 入队项。 + +AgentLoop 不会启动并发的替代轮次。它会关闭并 flush 被中断的轮次,达到取消的完全停稳,然后通过现有 FIFO 驱动器认领下一个可唤醒的 queued 入队项。该认领会发出 `agent/inbox/dequeue`,因此 Host 的权威 `session/queue` 快照会退役已认领行,并使剩余队尾保持可见。浏览器既不重发,也不提升任何行。忽略取消的工作会延迟这一交接,直到该工作结算。 + +该映射只更改 Web 客户端使用的 Host `session.cancel` 端点。`Agent.cancel()` 默认契约仍为广义取消,ACP 和 TUI 保留既有取消策略,`AgentHandle.dispose()` 在拆卸期间仍会清除待处理工作。移除 Queue 行仍是用于丢弃单个待处理入队项的显式 Web 操作。 + +## 考虑过的替代方案 + +**停止按钮继续使用广义取消。** 之所以否决:停止一次生成不应销毁已独立排队的用户意图;Queue 已拥有显式删除操作。 + +**取消后由浏览器重发下一行。** 之所以否决:Host 拥有入队项标识和认领顺序。客户端重新提交可能重复工作、重排 FIFO,或与权威出队产生竞态。 + +**被取消工作达到完全停稳之前启动下一轮次。** 之所以否决:两个轮次会并发修改同一会话日志,并共享 Agent 拥有的资源。协作式取消会如实等待活动工作结算。 + +**为广义取消与保留式取消添加协议选项。** 之所以否决:在 Web 产品提供独立的「停止并清空 Queue」交互之前,不需要此选项。现有停止按钮只有一项策略,而逐行删除已提供当前的丢弃控件。 + +## 验证 + +AgentLoop 覆盖会保持一个活动模型流,将两个可唤醒轮次排队,使用 `keepInbox` 取消,并固定验证先中止、后完成的轮次原因,FIFO 用户消息顺序,不存在 discard 事件,以及最终空闲状态。无密钥 Web 场景通过 HTTP/SSE 驱动已组装组合:它停止一个卡住的轮次,观察队尾保持可见时下一个 queued 入队项开始,再停止该轮次,并观察最后一个 queued 入队项完成。其可访问性快照固定了中间的 Queue 保留状态。 + +## 后果 + +Web 停止会保留已接纳的排队意图,并在取消如实结算后自动推进。不配合取消的活动工作收尾时,Queue 行可能仍保持可见;由同一 inbox 选项保留的外部 steering 可以进入下一个已接纳轮次,尽管 Web 不会在 QueueDock 中渲染 steering。未来的批量清空交互需要显式的产品操作,而不是过载停止。 diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml index d9b4a04671..5fcb6367ea 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md -2026-07-29-addressable-queue-operations.md: 7a08b889c958e583dc430d33a1855fe3725f3d48 -2026-07-29-addressable-queue-operations.zh.md: 701b028c7494fd7cb608d05a5d170c9075b155d7 +2026-07-29-addressable-queue-operations.md: 57527730d0f43a3a6c7801806fb9cc136daa5f14 +2026-07-29-addressable-queue-operations.zh.md: 65617e492c16e41d19ba296c7d117f8a2771a67d diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md index 7a08b889c9..57527730d0 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md @@ -18,7 +18,7 @@ The Web queue rendered pending messages but could not edit or delete one row. `M **Queue addresses require a live Agent.** `session.updateQueue` queries only the mounted Agent registry and never resumes a cold session: an `InboxItemId` is process-local and cannot name work after restart or disposal. A missing Agent and a driver-claimed occurrence both return `queue-item-not-found`. -**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock hides while empty, renders one pending occurrence directly, and defaults two or more occurrences to a collapsed `" 条排队消息"` header that expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Visible rows expose edit and delete, but no send-now control. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence. +**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock hides while empty, renders one pending occurrence directly, and defaults two or more occurrences to a collapsed `" 条排队消息"` header that expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Visible rows expose edit and delete, but no send-now control. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence. The Web stop action preserves pending Queue work; AgentLoop claims the next waking occurrence only after the interrupted turn reaches quiescence, and its dequeue event retires that row without a browser resend. ## Alternatives considered @@ -34,10 +34,10 @@ The Web queue rendered pending messages but could not edit or delete one row. `M ## Verification -AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, synchronous re-entrant mutation order, reconnect, cold-Agent rejection, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, single-row presentation, default multi-row collapse, interaction-forced visibility, reset after emptying, expansion, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. Keyless browser scenarios capture the default collapsed header before expanding the queue and driving its exposed edit and delete actions through the built Web composition and real HTTP/SSE wire. +AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, synchronous re-entrant mutation order, reconnect, cold-Agent rejection, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, single-row presentation, default multi-row collapse, interaction-forced visibility, reset after emptying, expansion, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. A keyless browser scenario captures the default collapsed header, drives edit and delete through the built Web composition and real HTTP/SSE wire, then stops consecutive active turns to prove the preserved FIFO advances without clearing its tail. ## Consequences -Queued work gains precise row operations without becoming durable session history. Occurrence identity is a live process-local capability and disappears at claim, cancellation, disposal, or restart; reconnect recovers only queued items still held by the live Agent. Editing excludes mixed content until an editor can preserve every block, while pending steering remains outside this operation surface. +Queued work gains precise row operations without becoming durable session history. Occurrence identity is a live process-local capability and disappears at claim, broad cancellation, disposal, or restart; the Web stop action preserves it until a later claim, while reconnect recovers only queued items still held by the live Agent. Editing excludes mixed content until an editor can preserve every block, while pending steering remains outside this operation surface. The protocol now carries full queue snapshots on each change. Queues are expected to remain short, so deterministic recovery and multi-client convergence are preferred over an incremental mutation protocol. diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md index 701b028c74..65617e492c 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md @@ -18,7 +18,7 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行 **Queue 寻址要求 Agent 存活。** `session.updateQueue` 只查询已挂载的 Agent 注册表,绝不恢复冷会话:`InboxItemId` 属于进程本地标识,无法在重启或资源释放后继续指向工作。Agent 缺失和单次入队项已被驱动器认领这两种情况都返回 `queue-item-not-found`。 -**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `" 条排队消息"` 表头。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。可见行暴露编辑和删除操作,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。 +**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `" 条排队消息"` 表头。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。可见行暴露编辑和删除操作,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。Web 停止操作会保留待处理 Queue 工作;只有在被中断轮次达到完全停稳后,AgentLoop 才认领下一个可唤醒入队项,其出队事件会退役该行,无需浏览器重发。 ## 考虑过的替代方案 @@ -34,10 +34,10 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行 ## 验证 -AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、同步可重入变更顺序、重连、拒绝冷 Agent、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、单行呈现、多行默认收起、交互期间强制保持可见、清空后重置、展开、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会先捕获默认收起的表头,再展开队列,并通过构建后的 Web 组合和真实 HTTP/SSE 协议操作其公开的编辑和删除。 +AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、同步可重入变更顺序、重连、拒绝冷 Agent、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、单行呈现、多行默认收起、交互期间强制保持可见、清空后重置、展开、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。一个无密钥浏览器场景会捕获默认收起的表头,通过构建后的 Web 组合和真实 HTTP/SSE 协议执行编辑和删除,随后连续停止活动轮次,证明保留的 FIFO 会继续推进且不清空队尾。 ## 后果 -queued 工作获得精确的行操作,但不会因此成为持久会话历史。单次入队标识是进程本地的实时寻址凭据,会在认领、取消、dispose 或重启时消失;重连只能恢复仍由活跃 Agent 持有的 queued 项。编辑会排除混合内容,直至编辑器能够保留每个块;待处理 steering 则不属于此操作接口。 +queued 工作获得精确的行操作,但不会因此成为持久会话历史。单次入队标识是进程本地的实时寻址凭据,会在认领、广义取消、dispose 或重启时消失;Web 停止操作会将其保留到后续认领,而重连只能恢复仍由活跃 Agent 持有的 queued 项。编辑会排除混合内容,直至编辑器能够保留每个块;待处理 steering 则不属于此操作接口。 现在,协议会在每次变更时携带完整队列快照。队列预期保持较短,因此系统优先选择确定性恢复和多客户端收敛,而非增量变更协议。 diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml index f6cc14538d..86f2224878 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-20-public-agent-stop-surface.md: e22c4389df18f3c9ca96763fc097eabefcc5b761 -2026-06-20-public-agent-stop-surface.zh.md: e2647b498a8c906579b4fd2b50f94d1c326fe784 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md +2026-06-20-public-agent-stop-surface.md: 7e8f6f691c999fd78c9b4133eaeac40f2d1c1ba9 +2026-06-20-public-agent-stop-surface.zh.md: 041d666eb817450add2d7a1746f7f5b81223568a diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md index e22c4389df..7e8f6f691c 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.md @@ -8,15 +8,15 @@ English | [中文](2026-06-20-public-agent-stop-surface.zh.md) ## Problem -The public `Agent` handle exposed two overlapping ways to stop in-flight work: step-only `abort()` and queue-aware `cancel()`. The former preserved queued input while the latter clears queued and steering work and aborts the active turn. In production, ACP uses `cancel()` for `session/cancel`, while lifecycle owners tear down agents through `AgentHandle.dispose()`. No production caller needs a bare step-only abort. +The public `Agent` handle exposed two overlapping ways to stop in-flight work: step-only `abort()` and queue-aware `cancel()`. The former preserved queued input while the latter originally only exposed its broad default, which clears queued and steering work while aborting the active turn. `cancel(cause, { keepInbox: true })` now covers the production Web stop policy without exposing the private turn holder; ACP retains broad cancellation, while lifecycle owners tear down agents through `AgentHandle.dispose()`. No production caller needs a bare step-only abort. -The behavioral distinction is real, but no shipping code needs the narrower operation. AgentLoop instead owns one private cancellation holder for the whole turn. `cancel(cause?)` carries a typed `user` or `parent` cause, defaults to `user`, and drops pending input; disposal remains a separate lifecycle interruption. The complete ownership and propagation contract lives in the [explicit turn cancellation RFC](../architecture/2026-07-16-explicit-turn-cancellation.md). +The behavioral distinction is real, but no shipping code needs a separate narrower verb. AgentLoop owns one private cancellation holder for the whole turn. `cancel(cause, options?)` carries an explicit typed `user` or `parent` cause; its broad default drops pending input, while `keepInbox` preserves pending work for later turns. Disposal remains a separate lifecycle interruption. The complete ownership and propagation contract lives in the [explicit turn cancellation RFC](../architecture/2026-07-16-explicit-turn-cancellation.md). -The extra surface area made the loop carry a public verb that is mostly a teardown internal: `abort()` had to be documented as distinct from queue-aware cancellation even though a UI cancellation almost always wants the broader operation. +The extra surface area made the loop carry a public verb that was mostly a teardown internal. An options-bearing `cancel()` expresses caller policy without exposing a second holder-shaped operation. ## Decision -`cancel()` is the only public *stop* primitive on `Agent`. Lifecycle owners use `AgentHandle.dispose()` to stop and unregister an agent; non-owners use `cancel()` to abandon current and queued work. The implementation keeps a private turn cancellation holder, but it is not part of the plugin-facing `Agent` contract. +`cancel()` is the only public *stop* primitive on `Agent`. Lifecycle owners use `AgentHandle.dispose()` to stop and unregister an agent; non-owners use broad `cancel()` to abandon current and queued work or `keepInbox` to abort the active turn while retaining pending work. The implementation keeps a private turn cancellation holder, but it is not part of the plugin-facing `Agent` contract. The [Web stop decision](../bug-fix/2026-07-31-web-stop-preserves-queue.md) is the production `keepInbox` consumer. `whenIdle()` is **retained** as the public quiescence-observation primitive (resolve once the agent settles out of `running`, resolve immediately when already idle, await the loop exit when disposed). It is not a stop verb; it is how a non-owner observes the stop *completing* without disposing the agent. Its live consumers are ACP and agent tests that await settlement through this public seam (`packages/acp/acp/tests`, `packages/core/agent-loop/tests`); the production ACP bridge owns its agents and tears them down through `AgentHandle.dispose()`, so `packages/acp/acp/src` itself has no `whenIdle()` call. @@ -28,11 +28,11 @@ Public `abort()` is absent, and the disposer remains async and waits for the loo ## Verification -`Agent` exposes no public `abort()` while `cancel()`, `whenIdle()`, and `steer()` remain; ACP cancellation calls `cancel()`; teardown awaits quiescence through handle disposal, with `whenIdle()` resolving on quiescence for non-owner observers; and the suites cover cancellation and disposal as the two supported stop paths. +`Agent` exposes no public `abort()` while `cancel()`, `whenIdle()`, and `steer()` remain; ACP cancellation calls broad `cancel()`, Web stop calls `cancel(..., { keepInbox: true })`, and teardown awaits quiescence through handle disposal. `whenIdle()` resolves on quiescence for non-owner observers, and the suites cover cancellation and disposal as the two supported stop paths. ## Consequences -A future plugin cannot abort only the current model/tool step while preserving queued prompts through the public interface. If that use case becomes real, it should return with a named consumer and a narrower contract. Today it is latent generality that keeps a private loop mechanic public. +A plugin can abort the active turn while preserving queued prompts through `keepInbox`, but it cannot abort only one model/tool step while leaving that turn running. A step-only use case would need a named consumer and a narrower contract; exposing the private loop mechanic remains unjustified. ## Related diff --git a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md index e2647b498a..041d666eb8 100644 --- a/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md +++ b/.agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.zh.md @@ -8,15 +8,15 @@ Status: implemented ## 问题 -公共 `Agent` handle 暴露了两种相互重叠的在途工作停止方式:仅针对步骤的 `abort()` 和感知队列的 `cancel()`。前者保留已排队输入,后者则清除已排队和 steering(中途引导)工作,并中止活动轮次。在生产中,ACP(Agent Client Protocol)对 `session/cancel` 使用 `cancel()`,生命周期拥有者则通过 `AgentHandle.dispose()` 拆除 agent(智能体)。没有生产调用方需要一个裸的、仅针对步骤的 abort。 +公共 `Agent` handle 暴露了两种相互重叠的在途工作停止方式:仅针对步骤的 `abort()` 和感知队列的 `cancel()`。前者保留已排队输入,后者原本只暴露广义默认行为,该行为会清除已排队和 steering(中途引导)工作,同时中止活动轮次。`cancel(cause, { keepInbox: true })` 现在无需暴露私有轮次 holder 即可覆盖生产环境的 Web 停止策略;ACP 保留广义取消,生命周期拥有者则通过 `AgentHandle.dispose()` 拆除 agent(智能体)。没有生产调用方需要一个裸的、仅针对步骤的 abort。 -行为差异确实存在,但已发布代码不需要较窄的操作。AgentLoop 改为为整个轮次拥有一个私有取消 holder。`cancel(cause?)` 携带类型化的 `user` 或 `parent` 原因,默认为 `user`,并丢弃待处理输入;释放仍是单独的生命周期中断。完整的归属与传播契约位于[显式轮次取消 Agent Note(agent 决策记录)](../architecture/2026-07-16-explicit-turn-cancellation.md)。 +行为差异确实存在,但已发布代码不需要独立的更窄动词。AgentLoop 为整个轮次拥有一个私有取消 holder。`cancel(cause, options?)` 携带显式且类型化的 `user` 或 `parent` 原因;其广义默认行为丢弃待处理输入,`keepInbox` 则为后续轮次保留待处理工作。资源释放仍是单独的生命周期中断。完整的归属与传播契约位于[显式轮次取消 Agent Note(agent 决策记录)](../architecture/2026-07-16-explicit-turn-cancellation.md)。 -多余的公开接口使得循环不得不承载一个本质上属于内部拆卸的公开动词:`abort()` 必须被文档描述为有别于队列感知的取消,尽管 UI 取消几乎总是需要更广泛的操作。 +多余的公开接口使循环承载了一个本质上属于内部拆卸的公开动词。带选项的 `cancel()` 可以表达调用方策略,而无需暴露第二个 holder 形态的操作。 ## 决策 -`cancel()` 是 `Agent` 上唯一的公共*停止*原语。生命周期拥有者使用 `AgentHandle.dispose()` 停止并注销 agent;非拥有者使用 `cancel()` 放弃当前和已排队工作。实现保留一个私有轮次取消 holder,但它不属于面向插件的 `Agent` 契约。 +`cancel()` 是 `Agent` 上唯一的公共*停止*原语。生命周期拥有者使用 `AgentHandle.dispose()` 停止并注销 agent;非拥有者使用广义 `cancel()` 放弃当前和已排队工作,或使用 `keepInbox` 中止活动轮次并保留待处理工作。实现保留一个私有轮次取消 holder,但它不属于面向插件的 `Agent` 契约。[Web 停止决策](../bug-fix/2026-07-31-web-stop-preserves-queue.md)是生产环境中的 `keepInbox` 消费方。 `whenIdle()` **保留**为公开的完全停稳观测原语(agent 从 `running` 状态稳定后 resolve,已处于 idle 时立即 resolve,dispose 后等待循环退出)。它不是停止动词;它是非所有者在不 dispose agent 的前提下观测停止*完成*的方式。它的活跃消费方是 ACP 和通过此公开 seam 等待结算的 agent 测试(`packages/acp/acp/tests`、`packages/core/agent-loop/tests`);生产环境的 ACP 桥接层拥有其 agent 并通过 `AgentHandle.dispose()` 销毁它们,因此 `packages/acp/acp/src` 本身没有 `whenIdle()` 调用。 @@ -28,11 +28,11 @@ Status: implemented ## 验证 -`Agent` 不再暴露公开的 `abort()`,而 `cancel()`、`whenIdle()` 和 `steer()` 保留;ACP 取消调用 `cancel()`;拆卸通过 handle disposal 等待完全停稳,`whenIdle()` 在完全停稳时为非所有者观测者 resolve;测试套件覆盖取消和 disposal 作为两条受支持的停止路径。 +`Agent` 不再暴露公开的 `abort()`,而 `cancel()`、`whenIdle()` 和 `steer()` 保留;ACP 取消调用广义 `cancel()`,Web 停止调用 `cancel(..., { keepInbox: true })`,拆卸则通过 handle disposal 等待完全停稳。`whenIdle()` 在完全停稳时为非所有者观测者 resolve;测试套件覆盖取消和资源释放这两条受支持的停止路径。 ## 后果 -未来的插件无法通过公开接口仅中止当前模型/工具步骤而保留队列中的提示词。如果该用例变为现实需求,它应当带着一个具名消费方和更窄的契约回归。目前它是将私有循环机制保持公开的潜在泛化。 +插件可以通过 `keepInbox` 在保留已排队提示词的同时中止活动轮次,但不能只中止某一个模型/工具步骤而让该轮次继续运行。仅步骤用例需要具名消费方和更窄契约;暴露私有循环机制仍缺乏正当理由。 ## 相关 diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 57b8afb4ff..006b2816cd 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -1,16 +1,16 @@ // Keyless browser coverage for pending queue actions through the shipped Web -// composition and real HTTP/SSE wire. A replay override parks the active turn -// so two ordinary follow-ups remain addressable while the page edits one and -// removes one. The queue uses an existing recorded model -// call; this scenario owns only the user-visible mid-turn golden. +// composition and real HTTP/SSE wire. Replay overrides park consecutive turns +// so the page can edit and remove exact occurrences, then stop the active turn +// while proving the preserved Queue advances in FIFO order. import { existsSync } from 'node:fs' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterEach, describe, expect, it, onTestFailed } from 'vitest' +import { deriveReplayScript, parseSessionLog, type ReplayEntry } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -22,6 +22,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.m const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) const COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md') const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md') +const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const MODE = webSnapshotMode() @@ -29,6 +30,12 @@ const ACTIVE_PROMPT = 'Reply with a one-sentence description of event sourcing, const REMOVE = 'Queue item to remove' const EDIT = 'Queue item to edit' const EDITED = 'Edited queue item' +const TAIL = 'Queue item preserved after stop' + +/** Durable turn-end classifications observed by the scenario. */ +function turnEndReasons(events: readonly SessionEvent[]): string[] { + return events.flatMap(event => event.type === 'turn/end' ? [event.data.reason.kind] : []) +} describe('web e2e: queue row actions', () => { let scaffold: WebScaffold | undefined @@ -52,13 +59,19 @@ describe('web e2e: queue row actions', () => { if (failures.length > 1) throw new AggregateError(failures, 'queue-actions teardown failed') }) - it.skipIf(MODE === 'record')('edits and removes exact pending occurrences', async () => { + it.skipIf(MODE === 'record')('edits and removes exact occurrences and preserves Queue across stop', async () => { overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queue-actions-')) const readyFile = join(overrideDir, '.hang-ready') + const nextReadyFile = join(overrideDir, '.next-hang-ready') const overridePath = join(overrideDir, 'replay.override.json') - await writeFile(overridePath, JSON.stringify({ - patches: [{ at: 0, entry: { kind: 'hang', readyFile } }], - })) + const recorded = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8'))) + expect(recorded).toHaveLength(1) + const replay: ReplayEntry[] = [ + { kind: 'hang', readyFile }, + { kind: 'hang', readyFile: nextReadyFile }, + recorded[0]!, + ] + await writeFile(overridePath, JSON.stringify(replay)) const sessionEvents: SessionEvent[] = [] scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath }) @@ -135,17 +148,34 @@ describe('web e2e: queue row actions', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - const editedRow = page.getByText(EDITED, { exact: true }).locator('..') - await editedRow.getByRole('button', { name: 'Remove queued message' }).click() - await expect.poll(() => page.getByText(EDITED, { exact: true }).count()).toBe(0) + await input.fill(TAIL) + await input.press('Enter') + await expect.poll( + () => page.getByRole('button', { name: 'Remove queued message' }).count(), + { timeout: 10_000 }, + ).toBe(2) + + await page.getByRole('button', { name: 'Stop generating' }).click() + await expect.poll(() => existsSync(nextReadyFile), { timeout: 15_000 }).toBe(true) + await page.getByText(TAIL, { exact: true }).waitFor() + await expect.poll(() => page.getByRole('button', { name: 'Remove queued message' }).count()) + .toBe(1) + + const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE) + await page.getByRole('button', { name: 'Stop generating' }).click() await settled + expect(turnEndReasons(sessionEvents)).toEqual(['aborted', 'aborted', 'completed']) + expect(sessionEvents.filter(event => event.type === 'user/message' && event.data.source.kind === 'user')) + .toHaveLength(3) + await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0) }, 120_000) it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => { await assertFixtureInventory( SNAPSHOT_DIR, - ['collapsed.expected.md', 'editing.expected.md', 'ui.expected.md'], + ['collapsed.expected.md', 'editing.expected.md', 'preserved.expected.md', 'ui.expected.md'], ) }) }) diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md new file mode 100644 index 0000000000..008fa0fb66 --- /dev/null +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -0,0 +1,44 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with a one-sentence description" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- button "Context injection": + - img + - img + - text: Context injection +- paragraph: partial +- text: Stopped +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Edited queue item {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- paragraph: partial +- status: Deep diving... +- list: + - listitem: + - text: Queue item preserved after stop + - button "Edit queued message": + - img + - button "Remove queued message": + - img +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Full access"': Full access +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Stop generating" +- text: 1 turns · 1 steps Input 0 tok · Output 0 tok diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts index 82bde108b4..f2c7d4183d 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/client/runtime/src/client/contract/session.ts @@ -46,7 +46,8 @@ export interface ISession { */ updateQueue(itemId: InboxItemId, action: QueueAction): Promise> /** - * Cancel the running turn. + * Cancel the running turn. Pending queued work remains and resumes in FIFO + * order after the Host reaches cancellation quiescence. * @returns acceptance, or the business error. */ cancel(): Promise> diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 02922dbbab..53a69215ac 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -248,7 +248,8 @@ export class Session implements SessionFace { } /** - * Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot). + * Stop the active turn while the Host preserves pending inbox work; failures + * land in promptError (same error-strip display slot). * @returns the cancel result. */ async cancel(): Promise> { diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index bbbb13f3ca..9a7d8fd5bb 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -38,7 +38,7 @@ export interface IConversation { */ updateQueue(itemId: QueueItemId, action: QueueAction): Promise /** - * Cancel the scoped session's in-flight turn. + * Cancel the scoped session's in-flight turn while preserving its pending Queue. * @returns completion; failures reject as in send. */ cancel(): Promise @@ -86,7 +86,7 @@ export class ConversationService extends Service implements IConversation { } } - /** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */ + /** Cancel the scoped session's in-flight turn while preserving Queue (failures land in promptError and reject, as in send). */ async cancel(): Promise { const session = this.scopedSession('cancel') const result = await session.cancel() diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 1636998094..ec55e2c76b 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -1,9 +1,9 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' /** - * Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the broad verb — it - * clears queued + steering work, aborts the active turn, and drops work not yet claimed by the - * driver without leaking cancellation into a replacement prompt. The suite covers every landing - * window plus signal reset and `whenIdle()` quiescence. + * Tests for the queue-aware `Agent.cancel()` primitive. The default clears + * queued and steering work, while `keepInbox` preserves pending input and + * resumes waking turns after the active turn reaches quiescence. The suite + * covers every landing window plus signal reset and `whenIdle()` quiescence. * @module dsh-agent-loop/tests/cancel */ @@ -302,6 +302,41 @@ describe('Agent.cancel()', () => { expect(adapter.requests).toHaveLength(1) }) + it('cancel({ keepInbox: true }) aborts the active turn and drains the queued tail in FIFO order', async () => { + const adapter = new MockAdapter([ + 'hang', + textResponse('second reply'), + textResponse('third reply'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('keep-inbox-running'), { provider: 'mock', model: 'mock' }) + const reasons: TurnEndReason[] = [] + const discards: unknown[] = [] + ctx.on('session/event', (session, event) => { + if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason) + }) + ctx.on('agent/inbox/discard', (subject, items) => { + if (subject === agent) discards.push(items) + }) + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + send(agent, 'queued second') + send(agent, 'queued third') + const idle = agent.whenIdle() + agent.cancel({ kind: 'user' }, { keepInbox: true }) + await idle + + expect(discards).toEqual([]) + expect(userTexts(agent)).toEqual(['active', 'queued second', 'queued third']) + expect(reasons).toEqual([ + { kind: 'aborted' }, + { kind: 'completed' }, + { kind: 'completed' }, + ]) + expect(adapter.requests).toHaveLength(3) + }) + it('cancel from an assistant/message observer skips execution but balances replay', async () => { const adapter = new MockAdapter([ toolCallResponse('c1', 'danger', {}), diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 0962dd7205..27a1434e60 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 5af2739ee419d32c93578b37bebc6808431fd003 -README.zh.md: c2729c379bd3c132c94d96134c04a0976a3aca9b +README.md: 3c5a83a468b0cf9e596b8b13fafe40c409576fc5 +README.zh.md: f8533564575bf6b716f3fa7241ce47b8d4dd435f diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 5af2739ee4..3c5a83a468 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -20,7 +20,7 @@ Session titles ride the generic projection pair like every other domain — the Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. -Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. +Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking occurrence in FIFO order. The browser never resends or promotes that occurrence. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. Queue operations query only an attached Agent and never resume a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index c2729c379b..f853356457 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -20,7 +20,7 @@ 会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 -待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 +待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。`session.cancel` 仅中止活动轮次,并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一个可唤醒入队项。浏览器绝不重发或提升该入队项。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。队列操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 90bbd2c745..4e506ed262 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1587,7 +1587,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: { sessionId }, })) } - agent.cancel({ kind: 'user' }) + agent.cancel({ kind: 'user' }, { keepInbox: true }) return Promise.resolve(ok(request, { accepted: true as const })) }, }, diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index e6f93c0dae..c95d1f0832 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -279,7 +279,7 @@ export interface SessionsApi { updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>): Promise> - /** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */ + /** Stops the active turn, preserving pending inbox work that resumes in FIFO order after cancellation settles. */ cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise> } From f38d37be112ff9cf177eeb911b70965d61434cfd Mon Sep 17 00:00:00 2001 From: kingwl Date: Fri, 31 Jul 2026 21:06:03 +0800 Subject: [PATCH 3/4] test: refresh translation prompt snapshot --- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index e27a4f5838..d6aa4708ea 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\n感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。\n\n“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the active checkout after installation and after each update, then start the Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe full build produces the library and client bundles plus the frontend dist. The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI and Web UI both include Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\n感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。\n\n“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the active checkout after installation and after each update, then start the Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe full build produces the library and client bundles plus the frontend dist. The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI and Web UI both include Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\n感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。\n\n“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n完整构建会生成库与客户端 bundle,以及前端 dist。上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 与 Web UI 均包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\n感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。\n\n“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n完整构建会生成库与客户端 bundle,以及前端 dist。上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 与 Web UI 均包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", From 07b0efc49e3e10f0ac20164d6fa3ca0688ddb0ff Mon Sep 17 00:00:00 2001 From: kingwl Date: Fri, 31 Jul 2026 21:08:57 +0800 Subject: [PATCH 4/4] fix(web): hide session lineage in header --- apps/web/tests/scaffold.ts | 2 +- .../snapshots/code-mode-round/ui.expected.md | 3 +- .../cordis-tool-round/ui.expected.md | 3 +- .../snapshots/fresh-round-trip/ui.expected.md | 3 +- .../lifecycle-chrome/reloaded.expected.md | 3 +- .../live-interactions/cancel.expected.md | 3 +- .../live-interactions/error-auth.expected.md | 3 +- .../live-interactions/loading.expected.md | 3 +- .../live-interactions/retry.expected.md | 3 +- .../snapshots/message-actions/ui.expected.md | 3 +- .../plan-review/approved.expected.md | 3 +- .../question-composer/answered.expected.md | 3 +- .../queue-actions/collapsed.expected.md | 3 +- .../queue-actions/editing.expected.md | 3 +- .../snapshots/queue-actions/ui.expected.md | 3 +- .../seeded-history/command-row.expected.md | 3 +- .../snapshots/seeded-history/ui.expected.md | 3 +- .../snapshots/steering/mid-steer.expected.md | 3 +- .../snapshots/steering/settled.expected.md | 3 +- .../snapshots/web-search-round/ui.expected.md | 3 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 2 - .../src/client/contract/slots.ts | 5 +- .../ui-conversation/src/client/locales.ts | 2 - .../skeleton/ConversationRoot.module.css | 49 +++---------------- .../client/skeleton/ConversationSession.tsx | 40 ++------------- .../tests/apply-inject.spec.tsx | 7 +-- .../tests/assembly-surfaces.spec.tsx | 12 ++--- .../ui-conversation/tests/skeleton.spec.tsx | 9 ++-- .../client/ui-trajectory/tests/views.spec.tsx | 8 --- 32 files changed, 46 insertions(+), 155 deletions(-) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index ea39e2d29b..249ff619cb 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -496,7 +496,7 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id * volatility collapse to stable tokens. */ function normalizeAria(snapshot: string, workspaceCwd: string): string { - // The header breadcrumb renders the workspace's basename, not the full + // The session heading renders the workspace's basename, not the full // path, so both spellings must collapse to the token. const base = workspaceCwd.split('/').pop()! return snapshot diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 183bd366a0..9b539fc432 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - 'button "Using ONE run_code program: run" [disabled]' + - 'heading "Using ONE run_code program: run" [level=1]' - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 636b1e6d28..6445b0fd8b 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Use only Cordis tools. First" [disabled] + - heading "Use only Cordis tools. First" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 3f7fa52b2e..32b0d9de50 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Use the bash tool to" [disabled] + - heading "Use the bash tool to" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index f58e5b77f2..35921f50ce 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Reply with the single word" [disabled] + - heading "Reply with the single word" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 3333237798..2354cc2b66 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Reply with a one-sentence description" [disabled] + - heading "Reply with a one-sentence description" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index e6a93f2463..05efb17f29 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Reply with a one-sentence description" [disabled] + - heading "Reply with a one-sentence description" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index b442b4345a..ecabb944c2 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Reply with a one-sentence description" [disabled] + - heading "Reply with a one-sentence description" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index f34dddd7dd..4b57ca7a98 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Reply with a one-sentence description" [disabled] + - heading "Reply with a one-sentence description" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 613e9a3605..257318c27f 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Use the read tool twice" [disabled] + - heading "Use the read tool twice" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index c971a6b2e4..c7f5c6d664 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - 'button "Plan a small change: add" [disabled]' + - 'heading "Plan a small change: add" [level=1]' - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index fec84d06db..d916f82f4b 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Use the ask_user_question tool to" [disabled] + - heading "Use the ask_user_question tool to" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index 829f21a70f..052df599d7 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Reply with a one-sentence description" [disabled] + - heading "Reply with a one-sentence description" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 169dde2c51..98b234ec69 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Reply with a one-sentence description" [disabled] + - heading "Reply with a one-sentence description" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index e2c91f7584..664aaa7041 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Reply with a one-sentence description" [disabled] + - heading "Reply with a one-sentence description" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index 0173726c38..ceeb45dd8a 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Use the read tool twice" [disabled] + - heading "Use the read tool twice" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 0062b6cfab..3cee5a52de 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Use the read tool twice" [disabled] + - heading "Use the read tool twice" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 27b40ef442..671e39a351 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Use the ask_user_question tool to" [disabled] + - heading "Use the ask_user_question tool to" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 2ab2f5970d..ba04efc20f 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Use the ask_user_question tool to" [disabled] + - heading "Use the ask_user_question tool to" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 37d53a0df6..b8eb6a1156 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -1,6 +1,5 @@ - banner: - - navigation "Session hierarchy": - - button "Use web_search to search exactly" [disabled] + - heading "Use web_search to search exactly" [level=1] - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f8ac552846..a000429d50 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 68115f8f9b9225d1f6b0e9cc93394d042c2befaa -README.zh.md: 65a3b334ff316a37dfb8506396eae8c17cccb40d +README.md: abbc20aba27fbd8af911d810ad7976d29cb8ad4f +README.zh.md: 600b06140c342bca984787c42eca66f601904a65 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 68115f8f9b..abbc20aba2 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -6,7 +6,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders. -The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. +The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 65a3b334ff..600b06140c 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -6,7 +6,7 @@ 压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包(package)自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 489ad34e3f..c67431e409 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -165,7 +165,6 @@ export function apply(ctx: Context): void { // the resident parent keeps Hero and composer layout identity stable. slots.register({ name: 'conversation.session', - locale: NS, children: { 'conversation.view': { kind: 'list', scope: 'session' } }, store: chatStore, inject: (sessionId: SessionId, _actions: BoundActions): ConversationSessionInjected => ({ @@ -175,7 +174,6 @@ export function apply(ctx: Context): void { version: () => slots.getVersion('conversation.view'), }, bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write), - open: (id) => { sessions.open(id) }, }), }, ConversationSession) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index b909b6e979..555a6b8e4f 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react' import type { InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' -import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' @@ -244,8 +244,6 @@ export interface ConversationSessionInjected { } /** Bind the input machine's draft persistence mirror to the session store. */ bindDraftMirror: (write: (text: string) => void) => () => void - /** Select a real Session through the runtime navigation owner. */ - open: (sessionId: SessionId) => void } /** @@ -355,7 +353,6 @@ export type ConversationSessionSlotProps = & PropsRenderSlots<'conversation.view'> & PropsStore & ConversationSessionInjected - & PropsLocale<'conversation'> /** The pending approval carrier the owner dispatches into the composer chain. */ export type ApprovalWait = PendingWait<'approval'> diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 76560ca9e4..1bda57660d 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -30,7 +30,6 @@ export const zh = { 'access.confirm.enable': '启用 Full access', 'hero.headline': '开始构建吧', 'hero.chooseWorkspace': '选择工作区', - 'session.hierarchy': '会话层级', 'details.title': '详情', 'details.close': '关闭详情', 'details.empty': '点击消息流中的工具行查看详情', @@ -130,7 +129,6 @@ export const en = { 'access.confirm.enable': 'Enable Full access', 'hero.headline': 'Let\'s start building', 'hero.chooseWorkspace': 'Choose workspace', - 'session.hierarchy': 'Session hierarchy', 'details.title': 'Details', 'details.close': 'Close details', 'details.empty': 'Click a tool row in the message flow to view its details', diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 38e9e03d3c..1f32d844a6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -1,4 +1,4 @@ -/* Conversation column skeleton: header (breadcrumb row + tabs) over the view +/* Conversation column skeleton: header (session title + tabs) over the view area, composer InputBar at the bottom. Column width/squeeze is layout's; this fills its cell. Figma: Header 39:27730 (83px two-row), tabs 13px with a 3px active bar. */ @@ -23,59 +23,24 @@ display: none; } -.crumbRow { +.titleRow { display: flex; align-items: center; - justify-content: space-between; min-height: 32px; } -.crumbs { - display: flex; - align-items: center; - gap: 4px; +.sessionTitle { min-width: 0; + max-width: 100%; overflow: hidden; - white-space: nowrap; -} - -.crumbSeg { - display: inline-flex; - align-items: center; - gap: 4px; - min-width: 0; -} - -.crumbSep { - /* figma: "/" separators are 14px caption gray (75:7903), one tint lighter than crumb text. */ - color: var(--dsw-alias-label-caption); - font-size: 14px; - line-height: 20px; -} - -.crumb { - max-width: 220px; - overflow: hidden; + margin: 0; padding: 4px 8px; - border: none; - border-radius: 12px; - background: transparent; font-size: 14px; line-height: 20px; - color: var(--dsw-alias-label-tertiary); - text-overflow: ellipsis; - white-space: nowrap; - cursor: pointer; -} - -.crumb:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); -} - -.crumbCurrent { font-weight: 500; color: var(--dsw-alias-label-primary); - cursor: default; + text-overflow: ellipsis; + white-space: nowrap; } /* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */ diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 677f33f46a..6b696e9e9d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -2,35 +2,21 @@ import { useEffect, useSyncExternalStore, type ReactNode } from 'react' import clsx from 'clsx' -import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' -import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSessionSlotProps } from '../contract/slots.ts' import css from './ConversationRoot.module.css' /** Full props composed from the strict session slot contract. */ export type ConversationSessionProps = ConversationSessionSlotProps -function deriveAncestry(list: SessionListState, id: SessionId): readonly SessionSummary[] { - const chain: SessionSummary[] = [] - let cursor: SessionId | undefined = id - while (cursor !== undefined) { - const summary: SessionSummary | undefined = list.byId[cursor] - if (summary === undefined || chain.includes(summary)) break - chain.unshift(summary) - cursor = summary.parentId - } - return chain -} - export function ConversationSession({ sessionId, useSession, useSessions, useInput, inputActions, useStore, actions, - renderSlot, views, bindDraftMirror, open, wrapActiveBody, t, + renderSlot, views, bindDraftMirror, wrapActiveBody, }: ConversationSessionProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() const activeId = useStore(s => s.view) ?? 'chat' const active = tabs.find(view => view.id === activeId) ?? tabs[0] - const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual) + const title = useSessions(s => s.byId[sessionId]?.displayTitle ?? sessionId) const composerPhase = useSession(s => s.composerPhase) const blank = useSession(s => s.blank) const inputState = useInput(s => s) @@ -69,26 +55,8 @@ export function ConversationSession({ > {!hideChrome && ( <> -
- +
+

{title}

{tabs.length > 1 && (
diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 2b170048f0..cfbb0fdcbe 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -228,12 +228,9 @@ describe('conversation slot inject surface', () => { await b.runtime.dispose() }) - it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => { + it('routes workspace switching through the runtime owner, carrying the draft', async () => { const b = await bench() - const { injected } = b.conversationSurface(ROOT) const resident = b.residentSurface(ROOT) - injected.open(ROOT) - expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] }) // Same-session connect (the picked workspace resolves to this session): // no draft movement, plain re-open. b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT)) @@ -241,7 +238,7 @@ describe('conversation slot inject surface', () => { actions.setDraft('carry me') void resident.selectWorkspace('workspace-1' as never) await vi.waitFor(() => { - expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(2) + expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(1) }) expect(b.runtime.workspaces.calls).toContainEqual({ method: 'connectWorkspace', args: ['workspace-1'] }) expect(state.getSnapshot().draft).toBe('carry me') diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 3ddaef873e..8e66dd7541 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -20,7 +20,7 @@ * suite only proves the assembled wiring. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, waitFor, within } from '@testing-library/react' +import { cleanup, fireEvent, waitFor } from '@testing-library/react' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' @@ -256,16 +256,14 @@ describe('prompt rejection through the assembled composer', () => { }) describe('title projection across assembled surfaces', () => { - it('one summary update re-labels the breadcrumb and document.title consumers together', async () => { + it('one summary update re-labels the current-session heading', async () => { const runtime = await bench([]) const view = runtime.renderRoot() - // The strict session header breadcrumb reads useSessions ancestry. - const crumb = within(view.container.querySelector('[aria-label="会话层级"]') as HTMLElement) - expect(crumb.getByText('S')).toBeTruthy() + expect(view.getByRole('heading', { name: 'S', level: 1 })).toBeTruthy() await runtime.sessions.updateSummary(SID, { displayTitle: '修订标题', title: '修订标题' }) - await waitFor(() => { expect(crumb.getByText('修订标题')).toBeTruthy() }) - expect(crumb.queryByText('S')).toBeNull() + await waitFor(() => { expect(view.getByRole('heading', { name: '修订标题', level: 1 })).toBeTruthy() }) + expect(view.queryByRole('heading', { name: 'S', level: 1 })).toBeNull() await runtime.dispose() }) }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 0470618ada..35afd759f1 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -111,7 +111,6 @@ function mount( const useInput = bindSnapshotSelector(wiring.state) const inputActions = wiring.actions const stop = vi.fn() - const open = vi.fn() const slotCalls: string[] = [] let pickerOwner: unknown const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => { @@ -140,8 +139,6 @@ function mount( version: () => 1, }} bindDraftMirror={write => wiring.bindMirror(write)} - open={open} - t={t} {...owner} /> ) @@ -203,7 +200,7 @@ function mount( } const view = render() return { - view, chat, sink, open, retargetWorkspace, session, slotCalls, + view, chat, sink, retargetWorkspace, session, slotCalls, pickerOwner: () => pickerOwner, rerender: () => { view.rerender() }, } @@ -218,8 +215,8 @@ describe('ConversationRoot resident composer', () => { expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised') fireEvent.keyDown(box, { key: 'Enter' }) expect(b.sink).toHaveBeenCalledWith('ordinary revised') - fireEvent.click(b.view.getByRole('button', { name: 'Root' })) - expect(b.open).toHaveBeenCalledWith(sid('root')) + expect(b.view.getByRole('heading', { name: 'Child', level: 1 })).toBeTruthy() + expect(b.view.queryByText('Root')).toBeNull() }) it('active phase: fixed header outside the scrollport; sticky composer seat inside it', () => { diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 4459d0c0e8..39504fe66a 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -23,7 +23,6 @@ import type { import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client' import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' -import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' import type { TrajectoryTurnModel } from '../src/client/layout.ts' @@ -36,11 +35,6 @@ import { deriveTrajectoryTimeline } from '../src/client/timeline.ts' const SID = 's1' as SessionId -// Stub of the conversation package's standard locale seat (this spec mounts -// its ConversationSession chrome); answers from the zh dictionary and falls -// back to the key like the real chain. -const tConversation: ConversationSessionProps['t'] = - key => (conversationZh as Record)[key] ?? key afterEach(cleanup) // The chat store persists under its declared key; clear so one case's active // view cannot rehydrate into the next. @@ -219,7 +213,6 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES return render( children(SID)} useSession={useSession} useSessions={emptySessions()} @@ -236,7 +229,6 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never} inputActions={{ setDraft: vi.fn(), submit: vi.fn() }} bindDraftMirror={() => () => {}} - open={vi.fn()} />, ) }