diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml index 8fc7c8966f..3b0e42df8c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.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/architecture/2026-07-28-launcher-owned-resume-identity.md -2026-07-28-launcher-owned-resume-identity.md: e11431716305ff18e951ca45921c71dcd105974c -2026-07-28-launcher-owned-resume-identity.zh.md: e4078023cb1519288f0367f8973788f361aaa0bd +2026-07-28-launcher-owned-resume-identity.md: 167c9e848a9101c9d1e93cf3af968b00279db32b +2026-07-28-launcher-owned-resume-identity.zh.md: 218b69581e65e8566ff1047603bb71c9537d3486 diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md index e114317163..167c9e848a 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md @@ -10,7 +10,7 @@ Two facts a launcher owns were shipped as deployment config keys on the TUI app Routing them through YAML made them silently droppable. `@cordisjs/plugin-include` applies a targeted patch by replacing whole top-level keys (`target[key] = value`), so a personal `~/.dsh/config.yaml` patching the `tui-agent` entry's `config` replaces the shipped block entirely. A user overlay written to change provider and model therefore deleted every resume key it did not restate, and nothing reported it: absent `resumeCommand` legitimately means "no fallback configured". -Both failures were live in one real overlay. The exit hint stopped printing, because the overlay omitted `resumeCommand`. Worse, the overlay carried `resumeSessionId: !!js process.env.RESUME_SESSION_ID` — a stale line from before [the env-var bridge was removed](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md) — which overwrote the shipped `!!js "typeof resumeSessionId === 'string' ? …"` intake with a read of a variable nothing sets. `dsh --resume ` then started a *fresh* session and said nothing, reproduced directly: the banner showed a newly minted id, not the requested one. The [`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note had recorded this silent resume as an unexplained pre-existing defect; the overlay's shallow replacement is the cause. +Both failures were live in one real overlay. The exit hint stopped printing, because the overlay omitted `resumeCommand`. Worse, the overlay carried `resumeSessionId: !!js process.env.RESUME_SESSION_ID` — a stale line from before [the env-var bridge was removed](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md) — which overwrote the shipped `!!js "typeof resumeSessionId === 'string' ? …"` intake with a read of a variable nothing sets. `dsh --resume ` then started a *fresh* session and said nothing, reproduced directly: the banner showed a newly minted id, not the requested one. The [`dsh experimental-meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note had recorded this silent resume as an unexplained pre-existing defect; the overlay's shallow replacement is the cause. A config key cannot express these facts safely, because the deployment is not the authority on them. @@ -25,7 +25,7 @@ Both sit beside the existing `tuiResumeHost` host capability, which set the prec Identity belongs to `agent-loop` because that is the plugin which creates configured agents, and because a patch replaces a row's whole `config`: an overlay repointing the agent row's model route would erase a launcher-set identity key. See [the shared-base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md). -`apps/cli` mints or selects the id and builds the line from the invocation it is reproducing, sharing one `resumeArgs` helper with the `/resume` execve handoff so the printed command and the in-place handoff cannot diverge. The line now names `--config` when one was passed, and reproduces `dsh meta --resume ` in meta mode — closing the mode-aware hint deferred by the `dsh meta` note, where a copied hint previously only worked from the checkout. +`apps/cli` mints or selects the id and builds the line from the invocation it is reproducing, sharing one `resumeArgs` helper with the `/resume` execve handoff so the printed command and the in-place handoff cannot diverge. The line names `--config` when one was passed. Resume always re-enters the default surface through `dsh --resume `; `dsh experimental-meta` accepts no options and always starts fresh. **`ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin.** Config `!!js` expressions evaluate as `with (entry.ctx) { eval(expr) }` (`vendor/loader/src/config/utils.ts`), so a bare identifier resolves against the entry's context and nothing else reaches it. The slot therefore cannot be removed while the app bundle is mounted from YAML; what changes is that it is now internal launcher↔app plumbing instead of a documented key a config author must wire correctly. diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md index e4078023cb..218b69581e 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md @@ -10,7 +10,7 @@ Status: implemented 把它们经由 YAML 传递,使其可被静默丢弃。`@cordisjs/plugin-include` 施加定向补丁的方式是替换整个顶层键(`target[key] = value`),因此一份对 `tui-agent` 条目的 `config` 打补丁的个人 `~/.dsh/config.yaml`,会把交付时的整块内容整体替换掉。于是,一份为改动 provider 和 model 而写的用户 overlay,会删掉它未重述的每一个 resume 键,且没有任何东西报告这一点:缺失 `resumeCommand` 合法地意味着「未配置回退」。 -两处失效在同一份真实的 overlay 中同时存在。退出提示不再打印,因为该 overlay 省略了 `resumeCommand`。更糟的是,该 overlay 带着 `resumeSessionId: !!js process.env.RESUME_SESSION_ID`——一行来自 [env 变量桥被移除](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md)之前的陈旧代码——它用一次对某个无人设置的变量的读取,覆盖掉了交付时的 `!!js "typeof resumeSessionId === 'string' ? …"` 入口。此后 `dsh --resume ` 会开启一个*全新*会话且什么都不说,并被直接复现:banner 显示的是一个新铸造的 id,而非所请求的那个。[`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note 曾把这次静默的 resume 记为一处无法解释的既有缺陷;而 overlay 的浅层替换正是其成因。 +两处失效在同一份真实的 overlay 中同时存在。退出提示不再打印,因为该 overlay 省略了 `resumeCommand`。更糟的是,该 overlay 带着 `resumeSessionId: !!js process.env.RESUME_SESSION_ID`——一行来自 [env 变量桥被移除](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md)之前的陈旧代码——它用一次对某个无人设置的变量的读取,覆盖掉了交付时的 `!!js "typeof resumeSessionId === 'string' ? …"` 入口。此后 `dsh --resume ` 会开启一个*全新*会话且什么都不说,并被直接复现:banner 显示的是一个新铸造的 id,而非所请求的那个。[`dsh experimental-meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note 曾把这次静默的 resume 记为一处无法解释的既有缺陷;而 overlay 的浅层替换正是其成因。 一个配置键无法安全地表达这些事实,因为部署方并非它们的权威。 @@ -25,7 +25,7 @@ Status: implemented 身份归属于 `agent-loop`,因为它才是创建所配置 agent 的插件;也因为 patch 会整体替换配置项的 `config`:重新指向 agent 配置项模型路由的 overlay 会抹掉启动器设置的身份键。参见[共享 base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md)。 -`apps/cli` 铸造或选定 id,并依据它所复现的那次调用构建该行,与 `/resume` 的 execve 移交共用同一个 `resumeArgs` 助手,从而使打印出的命令与原地移交不会分歧。该行现在会在传入了 `--config` 时命名它,并在 meta 模式下复现 `dsh meta --resume `——从而收口了 `dsh meta` note 所推迟的随 mode 变化的提示,在那里被复制的提示此前只有在检出目录中才有效。 +`apps/cli` 铸造或选定 id,并依据它所复现的那次调用构建该行,与 `/resume` 的 execve 移交共用同一个 `resumeArgs` 助手,从而使打印出的命令与原地移交不会分歧。该行会在传入了 `--config` 时将其写入命令。恢复始终通过 `dsh --resume ` 重新进入默认界面;`dsh experimental-meta` 不接受任何选项,并且总是启动新会话。 **`ctx.provide` 是从启动器 argv 进入被 Loader 挂载的插件的唯一通道。** 配置的 `!!js` 表达式会以 `with (entry.ctx) { eval(expr) }`(`vendor/loader/src/config/utils.ts`)求值,因此一个裸标识符会针对该条目的上下文解析,别无它物可达。于是只要应用 bundle 仍从 YAML 挂载,这个槽位就无法被移除;变化之处在于它现在是启动器↔应用之间的内部管线,而不再是一个配置作者必须正确接线的、有文档记载的键。 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-28-dsh-guided-skill-session-commands.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml index c60a7f0127..f50a69e7a8 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.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-28-dsh-guided-skill-session-commands.md -2026-07-28-dsh-guided-skill-session-commands.md: a11807dca9ad1640857cd95a8b528df691b8a27e -2026-07-28-dsh-guided-skill-session-commands.zh.md: e48420ed77496d8c336031473364f9008fccced7 +2026-07-28-dsh-guided-skill-session-commands.md: 9d5341880e92d88781278238f21380919c962820 +2026-07-28-dsh-guided-skill-session-commands.zh.md: 90c47d64173161fa4ef1f975de37b9cd42e02c99 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md index a11807dca9..9d5341880e 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md @@ -1,4 +1,4 @@ -# Agent Note: `dsh migrate`/`dsh upgrade` seed the first turn with a skill +# Agent Note: `dsh migrate`/`dsh experimental-upgrade` seed the first turn with a skill Status: implemented @@ -10,7 +10,7 @@ Two recurring flows begin with the user manually invoking one skill and answerin ## Decision -`dsh migrate` and `dsh upgrade` boot the ordinary TUI as a fresh session whose first turn auto-invokes a bundled skill (`dsh-migrate`, `dsh-upgrade`), exactly as if the user typed `/skill:` and pressed Enter. +`dsh migrate` and `dsh experimental-upgrade` boot the ordinary TUI as a fresh session whose first turn auto-invokes a bundled skill (`dsh-migrate`, `dsh-upgrade`), exactly as if the user typed `/skill:` and pressed Enter. The seed reuses the existing TUI skill path, not a new one. `createTuiChat` already has `invokeSkill(name, instructions)` — the code a typed `/skill:` runs, including the "Unknown skill" notice. The launcher passes the skill name to the TUI through a new boot-context slot `INITIAL_SKILL_KEY` (`tuiInitialSkill`), mirroring `CONFIGURED_AGENT_IDENTITIES_KEY`/`TUI_GOODBYE_MESSAGE_KEY`: `ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin. The TUI's `apply()` reads the slot and folds it into `config.initialSkill`; after `ui.start()` succeeds, `createTuiChat` fires `invokeSkill(config.initialSkill, '')` once when set. @@ -26,7 +26,7 @@ The `dsh-migrate` skill is bundled under `skills/` (shipped through `DSH_BUNDLED `packages/ui/tui/tests/tui.spec.ts` gains two fake-terminal cases in the existing skill describe block: `config.initialSkill` set delivers the rendered skill body as the first turn with no user input, and an unknown initial skill reports a notice without sending. `runSkillSession` itself is composition inside the module's `v8 ignore` block, like `runTui`/`runMeta`. -No keyless PTY snapshot: per the maintainer's scope call for this change, unit coverage plus interactive verification suffices, and the seed rides the already-snapshotted `/skill:` render path. Both commands were verified interactively in tmux from a scratch cwd: `dsh migrate` loaded `dsh-migrate` and asked which source agent; `dsh upgrade` loaded `dsh-upgrade`, which pulled in `dsh-customize` and began checkout discovery. +No keyless PTY snapshot: per the maintainer's scope call for this change, unit coverage plus interactive verification suffices, and the seed rides the already-snapshotted `/skill:` render path. Both commands were verified interactively in tmux from a scratch cwd: `dsh migrate` loaded `dsh-migrate` and asked which source agent; `dsh experimental-upgrade` loaded `dsh-upgrade`, which pulled in `dsh-customize` and began checkout discovery. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md index e48420ed77..90c47d6417 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md @@ -1,4 +1,4 @@ -# Agent Note:`dsh migrate`/`dsh upgrade` 以 skill 播种首轮 +# Agent Note:`dsh migrate`/`dsh experimental-upgrade` 以 skill 播种首轮 Status: implemented @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`dsh migrate` 与 `dsh upgrade` 以全新会话启动普通 TUI,其首轮自动调用一个内置 skill(`dsh-migrate`、`dsh-upgrade`),效果等同于用户键入 `/skill:` 并回车。 +`dsh migrate` 与 `dsh experimental-upgrade` 以全新会话启动普通 TUI,其首轮自动调用一个内置 skill(`dsh-migrate`、`dsh-upgrade`),效果等同于用户键入 `/skill:` 并回车。 播种复用现有的 TUI skill 路径,而非新增一条。`createTuiChat` 已有 `invokeSkill(name, instructions)`——即键入 `/skill:` 所走的代码,包含“未知 skill”通知。启动器通过一个新的启动上下文槽 `INITIAL_SKILL_KEY`(`tuiInitialSkill`)把 skill 名称传给 TUI,与 `CONFIGURED_AGENT_IDENTITIES_KEY`/`TUI_GOODBYE_MESSAGE_KEY` 一致:`ctx.provide` 是从启动器 argv 进入 Loader 挂载插件的唯一通道。TUI 的 `apply()` 读取该槽并折叠进 `config.initialSkill`;`ui.start()` 成功后,`createTuiChat` 在其被设置时调用一次 `invokeSkill(config.initialSkill, '')`。 @@ -26,7 +26,7 @@ Status: implemented `packages/ui/tui/tests/tui.spec.ts` 在既有 skill describe 块中新增两个伪终端用例:设置 `config.initialSkill` 时无需用户输入即把渲染后的 skill 正文作为首轮投递;未知的初始 skill 以通知形式报告且不发送。`runSkillSession` 本身是模块 `v8 ignore` 块内的组装,与 `runTui`/`runMeta` 相同。 -无 keyless PTY 快照:依据维护者对本次改动的范围裁定,单元覆盖加交互式验证已足够,且播种走的是已有快照的 `/skill:` 渲染路径。两个命令均已在 tmux 中从临时 cwd 交互式验证:`dsh migrate` 加载 `dsh-migrate` 并询问源 agent;`dsh upgrade` 加载 `dsh-upgrade`,后者引入 `dsh-customize` 并开始 checkout 发现。 +无 keyless PTY 快照:依据维护者对本次改动的范围裁定,单元覆盖加交互式验证已足够,且播种走的是已有快照的 `/skill:` 渲染路径。两个命令均已在 tmux 中从临时 cwd 交互式验证:`dsh migrate` 加载 `dsh-migrate` 并询问源 agent;`dsh experimental-upgrade` 加载 `dsh-upgrade`,后者引入 `dsh-customize` 并开始 checkout 发现。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml index 1a7c941216..9f62d0d4ea 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.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-28-dsh-meta-source-workspace.md -2026-07-28-dsh-meta-source-workspace.md: d65e0e6ff092b63931dd58c52fa76fb76a071dff -2026-07-28-dsh-meta-source-workspace.zh.md: 72a0c65e6eeda7d63dccc2306a3d7625cdb54362 +2026-07-28-dsh-meta-source-workspace.md: be414ddbf63dd95791f9ca956b2f345b1fc8c685 +2026-07-28-dsh-meta-source-workspace.zh.md: 86fc135db290766cb6fc2abefb194120416bfd9d diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md index d65e0e6ff0..be414ddbf6 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md @@ -1,4 +1,4 @@ -# Agent Note: `dsh meta` boots the TUI over the harness checkout +# Agent Note: `dsh experimental-meta` boots the TUI over the harness checkout Status: implemented @@ -10,40 +10,32 @@ English | [中文](2026-07-28-dsh-meta-source-workspace.zh.md) ## Decision -`dsh meta` boots the ordinary TUI with the harness checkout as the workspace, from any directory. +`dsh experimental-meta` boots the ordinary TUI with the harness checkout as the workspace, from any directory. The target is `SOURCE_ROOT` in `apps/cli/src/tui.ts` — `fileURLToPath(new URL('../../..', import.meta.url))`, three hops up from `apps/cli/{src,lib}` — the same constant the `harness:source` prompt section already names, so the workspace and the path advertised to the model cannot drift. It follows the launcher's real path, so a PATH symlink through `current` resolves to whichever staging worktree is active. -The mechanism is one `process.chdir(workspace)` inside `runTui`, guarded by a new optional third parameter that only `runMeta` passes. The cwd *is* the workspace seam in the shipped tree: `examples/tui-agent/cordis.yml` derives the session cwd (`!!js process.cwd()`), the `./.sessions` persistence root, and the HMR watch root (`root: ['.']`) from it, so one chdir moves all three together and meta sessions land in the checkout's gitignored `.sessions/`. It runs after both `.env` layers are loaded — the bin's invoking-directory load and the personal one — so the ambient > project > personal precedence is untouched. `DEFAULT_CONFIG` and `SOURCE_ROOT` are absolute and TUI mode passes no snapshot mode, so config resolution is chdir-independent. +The mechanism is one `process.chdir(workspace)` inside `runTui`, guarded by an optional third parameter that only the `experimental-meta` dispatch passes. The cwd *is* the workspace seam in the shipped tree: `examples/tui-agent/cordis.yml` derives the session cwd (`!!js process.cwd()`), the `./.sessions` persistence root, and the HMR watch root (`root: ['.']`) from it, so one chdir moves all three together and meta sessions land in the checkout's gitignored `.sessions/`. It runs after both `.env` layers are loaded — the bin's invoking-directory load and the personal one — so the ambient > project > personal precedence is untouched. `DEFAULT_CONFIG` and `SOURCE_ROOT` are absolute and TUI mode passes no snapshot mode, so config resolution is chdir-independent. -`meta` accepts only `--resume `. `--config` would boot a foreign tree against the harness workspace, which is the `--config` case rather than this one; `-p` is not interactive. Both fail loud, as does an empty `--resume=` — matching the default surface, where a swallowed empty id would silently start a fresh session. - -**`meta` does not redeclare `--resume`.** Commander parses an option a subcommand shares with its parent into `program.opts()` and leaves the subcommand's own options object empty, so redeclaring it silently dropped the id (found by probing the adapter, not by review). The action reads `program.opts()`, which also accepts the flag on either side of the subcommand; `--help` still lists it among the parent's options. +`experimental-meta` always starts a fresh session and accepts no options. `--config` would boot a foreign tree against the harness workspace, which is the default surface's `--config` case rather than this command; `-p` is not interactive, and resume re-enters the persisted session's own workspace through `dsh --resume `. Any leaked default-surface option fails loud. ## Testing -`apps/cli/tests/args.spec.ts` extends its two existing cases rather than adding a file: routing for `meta`, `meta --resume `, and `--resume meta` (pinning the shared-option behavior above), and exit-1 for `meta --resume=`, `meta --config`, and `meta -p`. `runMeta` itself is composition inside the module's existing `v8 ignore` block, like `runTui`. +`apps/cli/tests/args.spec.ts` pins routing for `experimental-meta`, rejection of every leaked default-surface option, and rejection of the former `meta` name. The dispatch itself is composition inside `bin.ts`'s existing `v8 ignore` block. -There is no keyless PTY smoke for this mode. The smoke harness gives each run a temp cwd, but `dsh meta` deliberately chdirs to the real checkout, so a smoke would write `.sessions/` into the live tree mid-test. Covering it properly needs an injectable target directory — a test-only seam this note declines to add for a one-line chdir. +There is no keyless PTY smoke for this mode. The smoke harness gives each run a temp cwd, but `dsh experimental-meta` deliberately chdirs to the real checkout, so a smoke would write `.sessions/` into the live tree mid-test. Covering it properly needs an injectable target directory — a test-only seam this note declines to add for a one-line chdir. The mode was verified interactively instead. Launched from `$HOME`, a `pwd` tool call reports the checkout, git resolves to its branch, the session log lands under the checkout's `.sessions/` (leaving `~/.sessions` untouched and the tree free of unignored residue), and plain `dsh` from another directory still uses the invoking one. -`dsh meta --resume ` once started a *fresh* session instead of resuming — a pre-existing defect on the default surface, not one this mode introduced. [Launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) found the cause and fixed it: a personal overlay had replaced the whole `tui-agent` config block, overwriting the shipped `resumeSessionId` intake with a read of an unset environment variable, so a valid id was silently ignored. Session identity is now a launcher-owned context slot that no config key can displace, and `meta` routes through it. - ## Alternatives considered **Thread an explicit workspace through `boot` and the config tree.** Avoids mutating process-wide state, but the shipped config reads the cwd in three places (`!!js process.cwd()`, `persistenceRoot`, HMR `root`), so each would need its own new plumbing and config key to stay consistent. `chdir` before boot expresses "this is the workspace" once, at the seam that already means it. -**A `--meta` flag on the default surface.** Rejected: the default surface is option-only so that subcommands do not collide with a positional, and a flag that silently relocates the workspace reads as a modifier of the current directory rather than a different target. `meta` alongside `web` matches the existing shape. +**An `--experimental-meta` flag on the default surface.** Rejected: the default surface is option-only so that subcommands do not collide with a positional, and a flag that silently relocates the workspace reads as a modifier of the current directory rather than a different target. `experimental-meta` alongside `web` matches the existing shape. **Resolve `~/.dsh/source/current` instead of the launcher's own path.** Rejected: it would diverge from the `harness:source` prompt path whenever a non-installed checkout's `bin/dsh` is invoked directly, telling the model one source root while working in another. -**Make the printed resume hint mode-aware.** Deferred here as a known cost, then delivered by [launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md): the exit line became a launcher-provided context slot, so meta mode prints `dsh meta --resume ` and a copied hint works from any directory. It previously came from static config as `dsh --resume {session}` and only worked when re-run from the checkout. - ## Consequences -Starting a session on dsh's own source is `dsh meta` from anywhere, and the workspace is guaranteed to be the same checkout the model is told about. Meta sessions are isolated in the checkout's `.sessions/`, so `dsh meta --resume` sees only other meta sessions — intended, since a session's logged cwd belongs to its workspace. - -The resume hint was this mode's original cost and is now resolved. [Launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) made both the printed line and the in-place `/resume` handoff reproduce the mode as `dsh meta --resume ` from one shared argv helper, so a copied hint works from any directory and the handoff no longer depends implicitly on `execve` preserving the process cwd. +Starting a session on dsh's own source is `dsh experimental-meta` from anywhere, and the workspace is guaranteed to be the same checkout the model is told about. The command always starts fresh; an ordinary `dsh --resume ` later restores the session and enters its persisted workspace. `runTui` gains an optional third parameter, so the workspace override is visible at the one function that owns TUI composition rather than hidden in a second copy of it. diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md index 72a0c65e6e..86fc135db2 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md @@ -1,4 +1,4 @@ -# Agent Note:`dsh meta` 以 harness 检出为 workspace 启动 TUI +# Agent Note:`dsh experimental-meta` 以 harness 检出为 workspace 启动 TUI Status: implemented @@ -10,40 +10,32 @@ Status: implemented ## Decision -`dsh meta` 在任意目录下都以 harness 检出为 workspace 启动普通 TUI。 +`dsh experimental-meta` 在任意目录下都以 harness 检出为 workspace 启动普通 TUI。 目标是 `apps/cli/src/tui.ts` 中的 `SOURCE_ROOT`——`fileURLToPath(new URL('../../..', import.meta.url))`,从 `apps/cli/{src,lib}` 向上三级——与 `harness:source` 提示词段所用的常量完全相同,因此 workspace 与告知模型的路径不可能发生偏离。它跟随启动器的真实路径,所以经由 `current` 的 PATH 符号链接会解析到当前生效的那个 staging 工作树。 -机制是 `runTui` 内的一次 `process.chdir(workspace)`,由一个新的可选第三参数把守,只有 `runMeta` 会传入。在已交付的配置树中,cwd *就是* workspace 的接缝:`examples/tui-agent/cordis.yml` 由它派生出会话 cwd(`!!js process.cwd()`)、`./.sessions` 持久化根目录以及 HMR 监视根目录(`root: ['.']`),因此一次 chdir 会让三者一并移动,meta 会话则落在检出目录中被 gitignore 的 `.sessions/` 内。它在两层 `.env` 都加载之后执行——bin 对调用目录的加载与个人层加载——因此“环境中已有的值 > 项目 > 个人”的优先级不受影响。`DEFAULT_CONFIG` 与 `SOURCE_ROOT` 都是绝对路径,且 TUI 模式不传 snapshot mode,所以配置解析与 chdir 无关。 +机制是 `runTui` 内的一次 `process.chdir(workspace)`,由一个可选第三参数把守,只有 `experimental-meta` 分派会传入。在已交付的配置树中,cwd *就是* workspace 的接缝:`examples/tui-agent/cordis.yml` 由它派生出会话 cwd(`!!js process.cwd()`)、`./.sessions` 持久化根目录以及 HMR 监视根目录(`root: ['.']`),因此一次 chdir 会让三者一并移动,meta 会话则落在检出目录中被 gitignore 的 `.sessions/` 内。它在两层 `.env` 都加载之后执行——bin 对调用目录的加载与个人层加载——因此“环境中已有的值 > 项目 > 个人”的优先级不受影响。`DEFAULT_CONFIG` 与 `SOURCE_ROOT` 都是绝对路径,且 TUI 模式不传 snapshot mode,所以配置解析与 chdir 无关。 -`meta` 只接受 `--resume `。`--config` 会以 harness workspace 启动其他配置树,那属于 `--config` 的场景而非本场景;`-p` 并非交互式。两者都会明确报错,空的 `--resume=` 亦然——与默认界面一致,在那里被吞掉的空 id 会静默开启一个新会话。 - -**`meta` 不重新声明 `--resume`。** 对于子命令与父命令共享的选项,Commander 会将其解析进 `program.opts()`,而把子命令自身的 options 对象留空;因此重新声明会静默丢弃该 id(这是通过实测适配器发现的,而非评审发现)。action 读取 `program.opts()`,这同时也允许该标志出现在子命令的任意一侧;`--help` 仍会在父命令的选项中列出它。 +`experimental-meta` 始终启动新会话,且不接受任何选项。`--config` 会针对 harness workspace 启动其他配置树,那是默认界面的 `--config` 场景,而不是该命令的场景;`-p` 并非交互式,恢复则通过 `dsh --resume ` 重新进入已持久化会话自身的 workspace。任何泄漏的默认界面选项都会明确报错。 ## Testing -`apps/cli/tests/args.spec.ts` 扩展其已有的两个用例而非新增文件:`meta`、`meta --resume ` 与 `--resume meta` 的路由(钉住上述共享选项行为),以及 `meta --resume=`、`meta --config`、`meta -p` 的退出码 1。`runMeta` 自身与 `runTui` 一样,属于该模块既有 `v8 ignore` 块内的组合代码。 +`apps/cli/tests/args.spec.ts` 钉住 `experimental-meta` 的路由、对每个泄漏的默认界面选项的拒绝,以及对旧名称 `meta` 的拒绝。该分派本身是 `bin.ts` 既有 `v8 ignore` 块内的组合代码。 -该 mode 没有 keyless PTY 冒烟测试。冒烟框架会为每次运行提供临时 cwd,但 `dsh meta` 刻意 chdir 到真实检出目录,因此冒烟测试会在测试中途把 `.sessions/` 写入实际工作树。要正确覆盖它需要一个可注入的目标目录——为了一行 chdir 而引入的测试专用 seam,本 note 不予采纳。 +该 mode 没有 keyless PTY 冒烟测试。冒烟框架会为每次运行提供临时 cwd,但 `dsh experimental-meta` 刻意 chdir 到真实检出目录,因此冒烟测试会在测试中途把 `.sessions/` 写入实际工作树。要正确覆盖它需要一个可注入的目标目录——为了一行 chdir 而引入的测试专用 seam,本 note 不予采纳。 取而代之的是交互式验证。从 `$HOME` 启动后,`pwd` 工具调用报告的是该检出目录,git 解析到其分支,会话日志落在该检出的 `.sessions/` 下(`~/.sessions` 未被触及,工作树也没有未被忽略的残留),并且从其他目录运行的普通 `dsh` 仍使用调用目录。 -`dsh meta --resume <有效 id>` 曾经开启一个*新*会话而非恢复——这是默认界面上既已存在的缺陷,并非本 mode 引入。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 查明了原因并将其修复:一个个人 overlay 替换了整个 `tui-agent` 配置块,用对一个未设置的环境变量的读取覆盖了已交付的 `resumeSessionId` 入口,因此有效的 id 会被静默忽略。会话标识如今是一个启动器拥有的上下文槽位,没有任何配置键能取代它,而 `meta` 经由它进行路由。 - ## Alternatives considered **通过 `boot` 与配置树显式传递 workspace。** 这可避免修改进程级状态,但已交付的配置在三处读取 cwd(`!!js process.cwd()`、`persistenceRoot`、HMR `root`),每一处都需要各自新增管线与配置键才能保持一致。启动前 chdir 只在本就表达该含义的接缝上表达一次“这就是 workspace”。 -**在默认界面上加一个 `--meta` 标志。** 拒绝:默认界面是纯选项形式,以免子命令与位置参数冲突;而一个会静默改变 workspace 的标志读起来像是对当前目录的修饰,而非另一个目标。`meta` 与 `web` 并列符合既有形态。 +**在默认界面上加一个 `--experimental-meta` 标志。** 拒绝:默认界面是纯选项形式,以免子命令与位置参数冲突;而一个会静默改变 workspace 的标志读起来像是对当前目录的修饰,而非另一个目标。`experimental-meta` 与 `web` 并列符合既有形态。 **解析 `~/.dsh/source/current` 而非启动器自身路径。** 拒绝:当直接调用某个非安装检出的 `bin/dsh` 时,它会与 `harness:source` 提示词路径产生偏离——告知模型一个源码根目录,却在另一个目录中工作。 -**让打印的恢复提示随 mode 变化。** 在此作为已知代价推迟,随后由 [由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 交付:退出行变成了一个启动器提供的上下文槽位,因此 meta 模式打印 `dsh meta --resume `,被复制的提示在任意目录下都有效。它此前来自静态配置,固定为 `dsh --resume {session}`,且只有在检出目录中重新运行才有效。 - ## Consequences -在 dsh 自身源码上开启会话变成了在任意位置执行 `dsh meta`,且该 workspace 必然就是告知模型的那个检出目录。meta 会话被隔离在检出目录的 `.sessions/` 内,因此 `dsh meta --resume` 只能看到其他 meta 会话——这是预期行为,因为会话记录的 cwd 属于它的 workspace。 - -恢复提示曾是本 mode 的原初代价,如今已解决。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 让打印的行与原地 `/resume` 移交都从同一个共享的 argv 辅助函数将该 mode 复现为 `dsh meta --resume `,因此被复制的提示在任意目录下都有效,且移交不再隐式依赖于 `execve` 保留进程 cwd。 +在 dsh 自身源码上开启会话变成了在任意位置执行 `dsh experimental-meta`,且该 workspace 必然就是告知模型的那个检出目录。该命令始终启动新会话;之后,普通的 `dsh --resume ` 会恢复该会话并进入其已持久化的 workspace。 `runTui` 新增一个可选第三参数,因此 workspace 覆盖是在拥有 TUI 组合逻辑的那唯一一个函数上可见的,而不是隐藏在它的第二份副本中。 diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml index f2b2b5b22e..c72f343373 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.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-28-skill-invocation-policy.md -2026-07-28-skill-invocation-policy.md: f74b0bcfddb1699c48279b4d8b153cabf764b140 -2026-07-28-skill-invocation-policy.zh.md: 1a7117a382be224c5371964dd4ad3e916d4e0917 +2026-07-28-skill-invocation-policy.md: e639db39c0e971ea6988ef6e9801ab71f8d1337f +2026-07-28-skill-invocation-policy.zh.md: 7e68f2cf28fbaa734dff477441fc8ab73e7367b7 diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md index f74b0bcfdd..e639db39c0 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md @@ -18,7 +18,7 @@ The local parser also exposed an internal camel-case spelling as frontmatter. Su The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. It maps `disable-model-invocation` to the inverse positive field and fills both positive fields from their defaults even when neither key is present. A camel-case external spelling or non-boolean invocation value drops the entire skill from discovery with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias. Invocation data fails closed because ignoring it would default to permission and could expose the skill on a disabled surface, while wrong-typed optional `whenToUse` and `metadata` values are omitted because they do not decide invocation. -The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The launcher-seeded initial skill used by guided `dsh migrate` and `dsh upgrade` sessions follows this same TUI path and must remain user-invocable. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added. +The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The launcher-seeded initial skill used by guided `dsh migrate` and `dsh experimental-upgrade` sessions follows this same TUI path and must remain user-invocable. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added. These rules permit all four combinations: diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md index 1a7117a382..7e68f2cf28 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md @@ -18,7 +18,7 @@ skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会 本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`,与 Claude skills 实际支持的布尔写法一致。它将 `disable-model-invocation` 映射为相反的正向字段,即使两个键都不存在,也会根据默认值填充两个正向字段。若使用外部驼峰式拼写或提供非布尔调用值,发现流程会丢弃整个 skill,并给出有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。调用数据校验遵循失败时默认拒绝原则,因为忽略这类数据会默认授予权限,可能使 skill 暴露在已禁用的接口上;与之不同,类型错误的可选 `whenToUse` 和 `metadata` 值会被省略,因为它们不参与调用判定。 -面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill peer 变成运行时导入。由 launcher 预置、供引导式 `dsh migrate` 和 `dsh upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。 +面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill peer 变成运行时导入。由 launcher 预置、供引导式 `dsh migrate` 和 `dsh experimental-upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。 这些规则允许以下四种组合: 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/README.i18n.yaml b/README.i18n.yaml index b492ed9c37..75f61c7f43 100644 --- a/README.i18n.yaml +++ b/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 README.md -README.md: b447c9634189353854e8be9d0bf597a8b0c7e371 -README.zh.md: f8bbbc36bc670403c0b9a40977f32f598e77ee46 +README.md: fb956dce51838438fb508db7ea9ebdf9e0b3a50b +README.zh.md: aa80b744465d7d253a54fffeead7262a1fdf69eb diff --git a/README.md b/README.md index b447c96341..fb956dce51 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,12 @@ It uses an architecture where **everything is a plugin**. ## Install -Install `dsh` with one command: +Clone the repository, then run the installer: ```sh -curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh +git clone +cd deepseek-harness +scripts/install.sh ``` The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key. diff --git a/README.zh.md b/README.zh.md index f8bbbc36bc..aa80b74446 100644 --- a/README.zh.md +++ b/README.zh.md @@ -16,10 +16,12 @@ DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 ## 安装 -使用一条命令安装 `dsh`: +克隆仓库,然后运行安装器: ```sh -curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh +git clone +cd deepseek-harness +scripts/install.sh ``` 安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 9d93c662d1..8fa53f8051 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: e2567bebea80b154c01d1cad8094008818e5fd13 -README.zh.md: a3b7f433040ce0ff02848e3741f407dffe8f8cb8 +README.md: 7ef24dc6290af5aafe3eb53609b090fcbfb83ce8 +README.zh.md: b19d4979351940e925db3b7a616a5c87689770a1 diff --git a/apps/cli/README.md b/apps/cli/README.md index e2567bebea..7ef24dc629 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -3,21 +3,21 @@ English | [中文](README.zh.md) -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`, `--dump-config`, `--dump-default-config`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is an option-less guided-session entry, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `upgrade`, `web`, `meta` — rejects a leaked `--config`/`-p`/`--resume`/dump flag rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`, `--dump-config`, `--dump-default-config`), whose `experimental-meta` subcommand is the same TUI over this checkout, whose `experimental-upgrade` subcommand is an option-less guided-session entry, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `experimental-upgrade`, `web`, `experimental-meta` — rejects a leaked `--config`/`-p`/`--resume`/dump flag rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. The TUI surface: - boots `base.cordis.yml` plus `tui.cordis.yml` through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); `--config ` applies a patch-list overlay instead of the personal overlay, while `--config-replace ` boots that file as the complete tree; - resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session; -- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below); +- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh experimental-meta` is the sole exception, below); - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`. - presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context. - registers bare `/compact`: while the agent is idle, it summarizes useful older history even below automatic pressure, rejects arguments, and reports success only after the standalone replacement bracket is durable. A prompt submitted during compaction keeps its queue identity and starts after that checkpoint; injected context remains visible. -`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after the environment is settled, so precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume ` to resume a persisted session. +`dsh experimental-meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after the environment is settled, so precedence is unchanged while the session cwd and HMR watch root move together. Experimental meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume ` to resume a persisted session. -`dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. +`dsh experimental-upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. The command takes no options — `--config`, `-p`, and `--resume` fail loud — and seeds only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. `dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and the `--config` or personal overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index a3b7f43304..b19d497935 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -3,21 +3,21 @@ [English](README.md) | 中文 -Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`、`--dump-config`、`--dump-default-config`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`upgrade`、`web`、`meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`/dump 标志,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`、`--dump-config`、`--dump-default-config`),`experimental-meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`experimental-upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`experimental-upgrade`、`web`、`experimental-meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`/dump 标志,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 TUI 界面: - 通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 启动 `base.cordis.yml` 与 `tui.cordis.yml`;`--config ` 应用一个补丁列表覆盖并替代个人覆盖,而 `--config-replace ` 将指定文件作为完整配置树启动; - 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话; -- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文); +- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh experimental-meta` 是唯一例外,见下文); - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`。 - 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。 - 注册裸 `/compact`:agent 空闲时,即使未达到自动压力,也会摘要有效的较早历史;该命令拒绝参数,并只在独立替换标记对持久化后报告成功。压缩(compaction)期间提交的提示词保留其队列身份,并在该检查点之后启动;注入的上下文仍保持可见。 -`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume `。 +`dsh experimental-meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Experimental meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume `。 -`dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 +`dsh experimental-upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。该命令不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 `dsh --dump-config` 和 `dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include` 的 `applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr,与 Loader 启动时的警告一致。由启动器持有的启动上下文值(会话身份、CLI 标志补丁)是每次调用的事实,位于配置树之外,不会出现。dump 标志会拒绝仅用于启动的标志(`-p`、`--resume`、`--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config`。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index ac74b9468d..2c05e966bc 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -2,8 +2,9 @@ * Commander adapter for the `dsh` command-line entry: the one place argv is * parsed and routed to a mode. `bin.ts` switches on the returned discriminant * and dynamic-imports that mode's module. One program: the default (no - * subcommand) is the TUI/headless surface with option-only flags; `meta` and - * `web` are real subcommands. Commander owns `--help`/`--version` and parse + * subcommand) is the TUI/headless surface with option-only flags; + * `experimental-meta` and `web` are real subcommands. Commander owns + * `--help`/`--version` and parse * errors — it prints and exits at the point of failure (a domain failure routes through * `command.error`), so this returns only a resolved mode. * @module @deepseek-ai/dsh/args @@ -51,8 +52,8 @@ interface MetaInvocation { } /** - * Guided fresh-session entry: `dsh upgrade` seeds the first turn with the - * `dsh-upgrade` skill. It always mints a + * Guided fresh-session entry: `dsh experimental-upgrade` seeds the first turn + * with the `dsh-upgrade` skill. It always mints a * fresh session in the invoking directory and takes no options — `--resume`, * `--config`, and `-p` are rejected as mistyped, so there is nothing to carry. */ @@ -248,14 +249,15 @@ Examples: } // Registration order is the rendered help order, so daily use comes first - // and the harness-development surfaces (`web --dev`, `meta`) come last. - // `upgrade` is a guided fresh-session entry: it takes no options and always - // mints a fresh session, so nothing is left to carry. + // and the harness-development surfaces (`web --dev`, `experimental-meta`) + // come last. `experimental-upgrade` is a guided fresh-session entry: it + // takes no options and always mints a fresh session, so nothing is left to + // carry. program - .command('upgrade') + .command('experimental-upgrade') .description('update this dsh installation to the latest version') .action(() => { - rejectParentOptions('upgrade') + rejectParentOptions('experimental-upgrade') resolved = { mode: 'upgrade' } }) @@ -283,10 +285,10 @@ Examples: }) program - .command('meta') + .command('experimental-meta') .description('work on the dsh source that runs this command, from any directory') .action(() => { - rejectParentOptions('meta') + rejectParentOptions('experimental-meta') resolved = { mode: 'meta' } }) diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 0ac25ac91f..15e7d6f77b 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -8,10 +8,11 @@ * from it, so `dsh` acts on whatever project it is launched in. Session storage * is the exception — it lives under the Harness home so `/resume` reaches every * workspace, and an in-place resume enters the selected session's own directory. - * `dsh meta` is the one exception — it makes this harness checkout the - * workspace. `dsh upgrade` is a fresh session whose first turn auto-invokes a - * bundled skill. After boot, the agent's system prompt is told the path to this - * harness checkout so it can find its own source. + * `dsh experimental-meta` is the one exception — it makes this harness + * checkout the workspace. `dsh experimental-upgrade` is a fresh session whose + * first turn auto-invokes a bundled skill. After boot, the agent's system + * prompt is told the path to this harness checkout so it can find its own + * source. * @module @deepseek-ai/dsh/tui */ @@ -70,7 +71,7 @@ const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.d // The harness checkout root: three hops up from apps/cli/{src,lib}, resolved // from this bin's location so it holds however `dsh` is launched (a PATH // symlink, an arbitrary cwd). The agent is told where its own source lives. -/** The harness checkout used as the `dsh meta` workspace and source prompt path. */ +/** The harness checkout used as the `dsh experimental-meta` workspace and source prompt path. */ export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) /* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers; @@ -87,10 +88,11 @@ export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) * {@link CONFIGURED_AGENT_IDENTITIES_KEY}, so no config key selects the session * and an overlay replacing the agent row cannot drop it. * @param workspace - a directory to make the workspace instead of the invoking - * one, or `undefined` to keep the cwd. Only `dsh meta` passes it. + * one, or `undefined` to keep the cwd. Only `dsh experimental-meta` passes it. * @param initialSkill - a bundled skill to auto-invoke as a fresh session's - * first turn, or `undefined`. Set only by `dsh upgrade` and ignored on a resume, - * so it never re-fires; reaches the app through {@link INITIAL_SKILL_KEY}. + * first turn, or `undefined`. Set only by `dsh experimental-upgrade` and + * ignored on a resume, so it never re-fires; reaches the app through + * {@link INITIAL_SKILL_KEY}. * @param configReplace - a config path to boot as the ENTIRE tree, bypassing the * shared base, the TUI overlay, and the personal overlay alike, or `undefined` * to compose them; already parsed from `--config-replace`. @@ -138,8 +140,9 @@ export async function runTui( const entry = process.argv[1] const execve = process.execve?.bind(process) const app: { current?: Context } = {} - // Resume always enters the default surface because meta rejects parent - // options, including `--resume`. The resumed session already persists its cwd. + // Resume always enters the default surface because experimental-meta rejects + // parent options, including `--resume`. The resumed session already persists + // its cwd. const resumeArgs = (sessionId: string): string[] => [ `--resume=${sessionId}`, // Both config flags must survive the handoff: resuming into a different diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index cda7818e63..9da69fb91c 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -24,13 +24,13 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => { + it('routes each mode by its shape: default TUI, -p headless, experimental and web subcommands', () => { expect(parse([])).toEqual({ mode: 'tui' }) expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.yml' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) - expect(parse(['meta'])).toEqual({ mode: 'meta' }) + expect(parse(['experimental-meta'])).toEqual({ mode: 'meta' }) // Bare `web` carries no host/port: the shipped Web overlay owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) expect(parse(['web', '--config', 'web.yml'])).toEqual({ mode: 'web', dev: false, config: 'web.yml' }) @@ -39,7 +39,7 @@ describe('parseDshArgs', () => { expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) // Guided fresh-session entries carry nothing: bare mode discriminant only. - expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' }) + expect(parse(['experimental-upgrade'])).toEqual({ mode: 'upgrade' }) // --trusted-host is variadic and repeatable; authorities pass through unvalidated. expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) @@ -64,8 +64,8 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1) expect(exitCode(['web', '--dump-default-config', '--config', 'w.yml'])).toBe(1) // A leaked dump flag on a subcommand that has none is a mistyped invocation. - expect(exitCode(['meta', '--dump-config'])).toBe(1) - expect(exitCode(['upgrade', '--dump-config'])).toBe(1) + expect(exitCode(['experimental-meta', '--dump-config'])).toBe(1) + expect(exitCode(['experimental-upgrade', '--dump-config'])).toBe(1) }) it('exits nonzero instead of silently starting fresh or dropping inputs', () => { @@ -88,17 +88,20 @@ describe('parseDshArgs', () => { expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1) // Same rule for each subcommand that shares no option with the default // surface, so a leaked flag is a typo, not something to ignore. - // `meta` fixes its own config tree and always starts fresh, so every - // default-surface option is rejected. - expect(exitCode(['meta', '--resume', 's'])).toBe(1) - expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['meta', '--config-replace', 'tree.yml'])).toBe(1) - expect(exitCode(['meta', '-p', 'task'])).toBe(1) - // `upgrade` takes no options: any leaked default-surface flag is a - // mistyped invocation, not a silently-dropped input. - expect(exitCode(['upgrade', '--resume', 's'])).toBe(1) - expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1) + // `experimental-meta` fixes its own config tree and always starts fresh, + // so every default-surface option is rejected. + expect(exitCode(['experimental-meta', '--resume', 's'])).toBe(1) + expect(exitCode(['experimental-meta', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['experimental-meta', '--config-replace', 'tree.yml'])).toBe(1) + expect(exitCode(['experimental-meta', '-p', 'task'])).toBe(1) + // `experimental-upgrade` takes no options: any leaked default-surface flag + // is a mistyped invocation, not a silently-dropped input. + expect(exitCode(['experimental-upgrade', '--resume', 's'])).toBe(1) + expect(exitCode(['experimental-upgrade', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['-p', 'task', 'experimental-upgrade'])).toBe(1) + // The pre-release command names have no compatibility aliases. + expect(exitCode(['meta'])).toBe(1) + expect(exitCode(['upgrade'])).toBe(1) }) it('exits 0 for --help (disclosing web) and --version', () => { diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 87e19a6915..d6fe3d0942 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -147,14 +147,21 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2) await expect.poll(() => page.getByRole('columnheader').count(), { timeout: 10_000 }).toBe(0) await page.locator('tr[data-kind="tool"]').first().click() - await expect.poll(() => page.getByRole('complementary', { name: 'Event details' }).count(), { timeout: 10_000 }).toBe(1) + const details = page.getByRole('complementary', { name: 'Event details' }) + await expect.poll(() => details.count(), { timeout: 10_000 }).toBe(1) + await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) + const darkSummarySurfaces = await details.getByRole('heading', { name: 'Payload' }).evaluate(heading => ({ + heading: getComputedStyle(heading).backgroundColor, + panel: getComputedStyle(heading.closest('[aria-label="Event details"]')!).backgroundColor, + })) + expect(darkSummarySurfaces.heading).toBe(darkSummarySurfaces.panel) + await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') }) await page.getByRole('tab', { name: 'Result' }).click() await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) - await page.getByRole('complementary', { name: 'Event details' }) - .getByRole('button', { name: 'Close details' }).click() + await details.getByRole('button', { name: 'Close details' }).click() }, 60_000) it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => { 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/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/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/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/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/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/apps/web/vite.config.ts b/apps/web/vite.config.ts index 7043de4911..1e8eb07210 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ { find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') }, + { find: /^@deepseek-ai\/dsh-client-schema-form$/, replacement: src('../../packages/client/schema-form/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') }, ], }, diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 10d5c44c83..e177c05253 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2010,8 +2010,8 @@ export interface Config extends TuiConfig { /** * Skill name auto-invoked as this session's first user turn, exactly as if * the user typed `/skill:`. Set only by a launcher for a fresh - * skill-guided session (`dsh migrate`/`dsh upgrade`); absent leaves the first - * turn to the user. + * skill-guided session (`dsh migrate`/`dsh experimental-upgrade`); absent + * leaves the first turn to the user. */ initialSkill?: string } diff --git a/docs/module-graph.md b/docs/module-graph.md index eb2f7d887d..f0f58a18c1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -316,6 +316,7 @@ flowchart TD pkg_client_ui_settings --> pkg_client_ui_primitives pkg_client_ui_settings --> pkg_client_ui_slots pkg_client_ui_settings --> pkg_invariants + pkg_client_ui_trajectory --> pkg_client_runtime pkg_client_ui_trajectory --> pkg_client_ui_primitives pkg_client_ui_trajectory --> pkg_invariants pkg_credentials --> pkg_brand @@ -1082,7 +1083,7 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | 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/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f8ac552846..505d693bb6 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: b61a70fb079eb6a1bc2a67b682a337ffdf708b79 +README.zh.md: 0bb1740b166cfacc2bc79fe2f49793796f66c365 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 68115f8f9b..b61a70fb07 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -6,13 +6,13 @@ 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. 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..0bb1740b16 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -6,11 +6,11 @@ 压缩(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 则被拆入视图组件自身。 -已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `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/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/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; 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/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/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/package.json b/packages/client/ui-trajectory/package.json index fc853d19d1..8da6559866 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation" ], "platform": "web" @@ -37,6 +38,7 @@ "diff": "^9.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "cordis": "^4.0.0-rc.7", diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index c7b7948845..86cc8dfd8b 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -321,6 +321,13 @@ width: 76px; } +.kindSlot :global([role='tooltip']) { + border: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-layer-2); + box-shadow: var(--dsw-shadow-lv2); + color: var(--dsw-alias-label-primary); +} + .content { padding-left: 4px !important; color: var(--dsw-alias-label-primary); @@ -828,6 +835,7 @@ flex: 1; min-height: 0; overflow: auto; + scrollbar-gutter: stable; } .detailBodySummary { @@ -989,7 +997,7 @@ margin: 0; padding: 0 0 3px 14px; color: var(--dsw-alias-label-secondary); - background: var(--dsw-alias-bg-base); + background: var(--dsw-alias-bg-layer-1); font: var(--dsw-font-xs-strong-13); user-select: none; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 53db32efca..4f88bf0bca 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1906,7 +1906,10 @@ export function TrajectoryTable({ - + (null) + const rootRef = useRef(null) + const trackRef = useRef(null) const [draft, setDraft] = useState(null) const [hover, setHover] = useState(null) const [viewport, setViewport] = useState(null) @@ -183,16 +191,48 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ } as CSSProperties const committed = model === null || range === null ? null - : rangeFraction(range, domainStart, domainDuration) + : rangeFraction(range, domainStart, domainDuration, model.start, model.end) const draftFraction = model === null || draft === null ? null - : rangeFraction(draft, domainStart, domainDuration) + : rangeFraction(draft, domainStart, domainDuration, model.start, model.end) const visibleRange = draftFraction ?? committed const activeRange = draft ?? range + useEffect(() => { + const root = rootRef.current + if (root === null) return + const onWheel = (event: globalThis.WheelEvent): void => { + event.preventDefault() + const track = trackRef.current + if (track === null || model === null) return + setAnimateViewport(false) + const rect = track.getBoundingClientRect() + const anchorFraction = + clampFraction((event.clientX - rect.left) / Math.max(1, rect.width)) + const nextDuration = Math.min( + fullDuration, + Math.max( + Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration), + domainDuration * Math.exp(event.deltaY * 0.0015), + ), + ) + if (nextDuration >= fullDuration * 0.999) { + setViewport(null) + return + } + const anchorTime = domainStart + anchorFraction * domainDuration + const nextStart = Math.min( + Math.max(anchorTime - anchorFraction * nextDuration, model.start), + model.end - nextDuration, + ) + setViewport({ start: nextStart, end: nextStart + nextDuration }) + } + root.addEventListener('wheel', onWheel, { passive: false }) + return () => { root.removeEventListener('wheel', onWheel) } + }, [domainDuration, domainStart, fullDuration, mode, model]) if (model === null) { return ( -
+
@@ -339,36 +379,12 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({ setHover(null) } - const onWheel = (event: WheelEvent) => { - event.preventDefault() - setAnimateViewport(false) - const rect = event.currentTarget.getBoundingClientRect() - const anchorFraction = - clampFraction((event.clientX - rect.left) / Math.max(1, rect.width)) - const nextDuration = Math.min( - fullDuration, - Math.max( - Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration), - domainDuration * Math.exp(event.deltaY * 0.0015), - ), - ) - if (nextDuration >= fullDuration * 0.999) { - setViewport(null) - return - } - const anchorTime = domainStart + anchorFraction * domainDuration - const nextStart = Math.min( - Math.max(anchorTime - anchorFraction * nextDuration, model.start), - model.end - nextDuration, - ) - setViewport({ start: nextStart, end: nextStart + nextDuration }) - } - return ( -
+
{ event.preventDefault() setAnimateViewport(false) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css index 6e3dd27916..aa38650411 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css @@ -149,7 +149,7 @@ font: var(--dsw-font-xxs-12); } -.action:hover:not(:disabled) { +.action:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); } @@ -159,11 +159,6 @@ outline-offset: 1px; } -.action:disabled { - color: var(--dsw-alias-label-dimmed); - cursor: not-allowed; -} - .actionIcon { color: var(--dsw-alias-label-tertiary); font: 14px/14px var(--ds-font-family-code); diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx index 09d213c36e..5db9f56e80 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx @@ -8,18 +8,14 @@ export interface TrajectoryToolbarProps { actualDuration: boolean /** Select recorded-duration or equal-width blocks. */ onActualDurationChange: (actualDuration: boolean) => void - /** Whether recorded timing retains idle gaps between user turns. */ + /** Whether recorded timing retains idle gaps between operations. */ actualTime: boolean /** Select complete wall-clock timing or idle-compressed timing. */ onActualTimeChange: (actualTime: boolean) => void - /** Number of turns containing more than one row. */ - collapsibleTurns: number /** Whether every collapsible turn is currently folded. */ allTurnsCollapsed: boolean /** Fold or expand every collapsible turn. */ onToggleAllTurns: () => void - /** Number of assistant messages followed by tool calls. */ - collapsibleAssistants: number /** Whether every collapsible assistant's tool calls are currently folded. */ allAssistantsCollapsed: boolean /** Fold or expand tool calls under every collapsible assistant. */ @@ -40,10 +36,8 @@ export function TrajectoryToolbar({ onActualDurationChange, actualTime, onActualTimeChange, - collapsibleTurns, allTurnsCollapsed, onToggleAllTurns, - collapsibleAssistants, allAssistantsCollapsed, onToggleAllAssistants, searchQuery, @@ -91,7 +85,6 @@ export function TrajectoryToolbar({ aria-label={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'} aria-pressed={allTurnsCollapsed} title={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'} - disabled={collapsibleTurns === 0} onClick={onToggleAllTurns} >