diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml index d65f6c1601..e192f9f3ee 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.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 -2026-07-22-docked-web-goal-bar.md: b2ae08f1c0f0ceaf5d726ff3f2560f37a0e1762c -2026-07-22-docked-web-goal-bar.zh.md: b3f2c603dca05cb98b2a0bb7b6d78e6011246018 +2026-07-22-docked-web-goal-bar.md: 01b84efa66bfbf35b81798d8e775327cd366f92c +2026-07-22-docked-web-goal-bar.zh.md: 5cdbfe7335c6f6045591bff96d66f2f4e5740b91 diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md index b2ae08f1c0..01b84efa66 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md @@ -12,17 +12,17 @@ The web UI had no goal surface at all: the goal stack shipped with model tools, `GoalBar` (`packages/client/ui-conversation/src/client/skeleton/GoalBar.tsx`) is a new props-driven, self-contained component; `ConversationRoot` mounts it immediately before the composer `InputBar`. The strip's CSS mirrors the composer's horizontal geometry (32px side padding, 776px centered cap) plus the mock's 12px inset, and a -10px bottom margin eats InputBar's 8px top padding and tucks its square bottom edge 2px under the composer card's top edge. All strip states share one fixed 38px height so switching between them never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome. -Visibility drives the label and actions: active shows "Ongoing Goal" with edit/clear; paused shows "Paused Goal" and adds a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. Clear calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. An effect keyed on the goal's id drops the edit form when the goal's identity changes, so a surviving draft can never be written over the goal that replaced it. +Visibility drives the label and actions: active shows "Ongoing Goal" with edit/clear; paused shows "Paused Goal" and adds a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. An effect keyed on the goal's id drops the edit form when the goal's identity changes, so a surviving draft can never be written over the goal that replaced it. -`GoalBarActions` lives in the contract layer (`contract/slots.ts`, next to the `ConversationInjected.goalActions` slot it feeds) and carries exactly the rendered verbs: `onEdit`/`onResume`/`onClear`. `apply.ts` wires them to the runtime session methods; the runtime session resolves the current goal's compare-and-set ref internally, so the UI passes no ref. +`GoalBarActions` lives in the contract layer (`contract/slots.ts`, next to the `ConversationInjected.goalActions` slot it feeds) and carries exactly the rendered verbs: `onEdit`/`onResume`/`onClear`. Each callback asynchronously returns an explicit success/failure result so `GoalBar` owns its transitions and error display. `apply.ts` wires them to the runtime session methods; the runtime session resolves the current goal's compare-and-set ref internally, so the UI passes no ref. -The runtime session gains the goal surface the strip (and future UI) needs: `fetchGoal` populates the snapshot on open, and a live `context/message` carrying `goal/change` meta triggers a coalesced refetch — window replays never refetch, and matching the meta kind (rather than a goal key) also catches clear tombstones written by other clients. The six mutation verbs fold transport failures into `{ ok: false }` results like every sibling session method, and a get result older than a mutation response that landed mid-flight is dropped. +The runtime session gains the goal surface the strip (and future UI) needs: `fetchGoal` populates the snapshot on open, and a live `context/message` carrying `goal/change` meta triggers a coalesced refetch — concurrent triggers share the in-flight `goal.get`, while a trigger received during that read schedules one coalesced trailing read so independently ordered notifications and GET responses cannot leave stale state. Window replays never refetch, and matching the meta kind (rather than a goal key) also catches clear tombstones written by other clients. The six mutation verbs fold transport failures into `{ ok: false }` results like every sibling session method, and a get result older than a mutation response that landed mid-flight is dropped. The strip's background is `--dsw-alias-interactive-bg-hover` rather than the mock's literal `#F5F6F7`: the translucent hover gray resolves to that value over the white light-theme base and lifts the strip off the composer card in dark mode, where a static light token would sink. All colors are `--dsw-*` tokens. ## Testing -`packages/client/ui-conversation/tests/goalbar.spec.tsx` pins the behavior through props alone: loading/absent/complete render nothing, the active strip renders label/objective and fires clear, the edit form prefills, rejects empty, saves on Enter, cancels on Esc, and resets when the goal's identity changes, the paused strip fires resume, and the blocked strip exposes the reason tooltip. The skeleton specs mount `ConversationRoot` with and without `goalActions`; the undefined case is seeded with an active goal, so the missing gate — not the missing goal — is what hides the strip. Runtime session specs pin the folded-error results, the live-only coalesced refetch, and the stale-read guard. +`packages/client/ui-conversation/tests/goalbar.spec.tsx` pins the behavior through props alone: loading/absent/complete render nothing, the active strip renders label/objective and fires clear, the edit form prefills, rejects empty, saves on Enter, cancels on Esc, and resets when the goal's identity changes, the paused strip fires resume, and the blocked strip exposes the reason tooltip. Component failure-path cases prove that a failed edit preserves its draft and that edit/resume/clear errors remain visible in the bar. The skeleton specs mount `ConversationRoot` with and without `goalActions`; the undefined case is seeded with an active goal, so the missing gate — not the missing goal — is what hides the strip. Runtime session specs pin the folded-error results, the live-only in-flight-plus-trailing refetch, and the stale-read guard. A keyless real-browser smoke boots the assembled application through `boot → RPC → runtime → GoalBar` and records an inline snapshot of the rendered label, objective, and actions. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md index b3f2c603dc..5cdbfe7335 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md @@ -12,17 +12,17 @@ Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、T `GoalBar`(`packages/client/ui-conversation/src/client/skeleton/GoalBar.tsx`)是一个新的、由 props 驱动的自包含组件;`ConversationRoot` 将它挂载在输入框 `InputBar` 紧上方。横条的 CSS 对齐输入框的水平几何(两侧 32px 内边距、776px 居中上限),再加上设计稿的 12px 内缩,并用 -10px 的下外边距吃掉 InputBar 的 8px 上内边距,使它方形的底边收进输入框卡片顶边之下 2px。横条的所有状态共享固定的 38px 高度,状态切换不会引起尺寸变化。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。 -可见性决定标签和操作:active 状态显示 "Ongoing Goal" 并提供编辑/清除;paused 状态显示 "Paused Goal",并增加一个恢复图标按钮;blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上,不在横条里。铅笔图标把横条切换为内联编辑表单,预填当前目标内容:Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存,Esc 取消,目标内容全为空白字符时保存按钮保持禁用。清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。一个以目标 id 为键的 effect 会在目标身份变化时丢弃编辑表单,因此存留的草稿绝不可能覆盖掉替换它的新目标。 +可见性决定标签和操作:active 状态显示 "Ongoing Goal" 并提供编辑/清除;paused 状态显示 "Paused Goal",并增加一个恢复图标按钮;blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上,不在横条里。铅笔图标把横条切换为内联编辑表单,预填当前目标内容:Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存,Esc 取消,目标内容全为空白字符时保存按钮保持禁用。编辑成功后表单才会关闭;编辑失败时保留草稿,并在横条中显示错误。恢复和清除失败也显示在横条中。除此之外,清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。一个以目标 id 为键的 effect 会在目标身份变化时丢弃编辑表单,因此存留的草稿绝不可能覆盖掉替换它的新目标。 -`GoalBarActions` 位于 contract 层(`contract/slots.ts`,紧挨它所喂给的 `ConversationInjected.goalActions` 槽位),只携带实际渲染的动词:`onEdit`/`onResume`/`onClear`。`apply.ts` 把它们接到运行时会话方法上;运行时会话在内部解析当前目标的 compare-and-set ref,因此 UI 不传 ref。 +`GoalBarActions` 位于 contract 层(`contract/slots.ts`,紧挨它所喂给的 `ConversationInjected.goalActions` 槽位),只携带实际渲染的动词:`onEdit`/`onResume`/`onClear`。每个回调都会异步返回显式成功/失败结果,因此 `GoalBar` 自行负责界面转换和错误显示。`apply.ts` 把它们接到运行时会话方法上;运行时会话在内部解析当前目标的 compare-and-set ref,因此 UI 不传 ref。 -运行时会话获得了横条(以及未来 UI)所需的目标表面:`fetchGoal` 在打开时填充快照;携带 `goal/change` 元数据的 live `context/message` 触发一次合并后的重新拉取——窗口重放绝不触发重新拉取,且匹配元数据 kind(而不是 goal 键)还能捕获其他客户端写入的清除墓碑。六个变更动词与所有同类会话方法一样,把传输层失败折叠为 `{ ok: false }` 结果;比在拉取途中落地的变更响应更旧的 get 结果会被丢弃。 +运行时会话获得了横条(以及未来 UI)所需的目标表面:`fetchGoal` 在打开时填充快照;携带 `goal/change` 元数据的 live `context/message` 触发合并重新拉取——并发触发器共享正在执行的 `goal.get`,读取期间收到的触发器会安排一次合并后的尾随读取,避免彼此独立排序的通知和 GET 响应留下陈旧状态。窗口重放绝不触发重新拉取,且匹配元数据 kind(而不是 goal 键)还能捕获其他客户端写入的清除墓碑。六个变更动词与所有同类会话方法一样,把传输层失败折叠为 `{ ok: false }` 结果;比在拉取途中落地的变更响应更旧的 get 结果会被丢弃。 横条的背景色用 `--dsw-alias-interactive-bg-hover`,而不是设计稿里的字面值 `#F5F6F7`:这个半透明的悬浮灰在浅色主题的白色底上正好解析为该值,而在深色模式下能把横条从输入框卡片上衬托出来,静态的浅色 token 在深色模式下会沉进去。所有颜色都是 `--dsw-*` token。 ## 测试 -`packages/client/ui-conversation/tests/goalbar.spec.tsx` 仅通过 props 固定这些行为:加载中/无目标/已完成时不渲染;active 横条渲染标签和目标内容并触发清除;编辑表单预填内容、拒绝空值、按 Enter 保存、按 Esc 取消,并在目标身份变化时重置;paused 横条触发恢复;blocked 横条暴露原因悬浮提示。skeleton 规格测试分别挂载带与不带 `goalActions` 的 `ConversationRoot`;未定义的情形预置了一个 active 目标,因此隐藏横条的是缺失的挂载门,而不是缺失的目标。运行时会话规格测试固定了折叠错误结果、仅 live 的合并重新拉取,以及陈旧读取守卫。 +`packages/client/ui-conversation/tests/goalbar.spec.tsx` 仅通过 props 固定这些行为:加载中/无目标/已完成时不渲染;active 横条渲染标签和目标内容并触发清除;编辑表单预填内容、拒绝空值、按 Enter 保存、按 Esc 取消,并在目标身份变化时重置;paused 横条触发恢复;blocked 横条暴露原因悬浮提示。组件失败路径用例证明编辑失败时保留草稿,并且编辑/恢复/清除错误持续显示在横条中。skeleton 规格测试分别挂载带与不带 `goalActions` 的 `ConversationRoot`;未定义的情形预置了一个 active 目标,因此隐藏横条的是缺失的挂载门,而不是缺失的目标。运行时会话规格测试固定了折叠错误结果、仅 live 的执行中读取加尾随读取,以及陈旧读取守卫。一个无密钥真实浏览器冒烟测试通过 `boot → RPC → runtime → GoalBar` 启动组装后的应用,并以内联快照记录渲染出的标签、目标内容和操作。 ## 考虑过的替代方案 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 291e871e63..ecf3713bdc 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -1,9 +1,9 @@ // Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins // registry surface + __DSH_BOOT__ injection + built shell dist in a real // chromium. First describe: manifest injection + fail-loud half. Second -// describe: the settled success pass — five REAL tsdown bundles (the -// infrastructure four + layout) load through the DI chain in ?fixture mode -// and the three-column frame appears in one flip. The full conversation +// describe: the settled success pass — all eight REAL tsdown bundles load +// through the DI chain in ?fixture mode and the three-column frame appears +// in one flip. The full conversation // round lands in smoke-real under the W5 real-host standard. import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' @@ -17,13 +17,16 @@ import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './suppo const bundlePath = (dir: string): string => fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url)) -/** id ↔ bundle table for the success pass (immediately four + layout). */ +/** id ↔ bundle table for the success pass. */ const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [ { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true }, { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, ] /** Manifest served by the fake registry: one live bundle row, one missing row. */ @@ -90,7 +93,7 @@ describe('web boot chain (keyless, real carrier)', () => { }) }) -describe('web boot chain success pass (keyless, five real bundles, ?fixture)', () => { +describe('web boot chain success pass (keyless, eight real bundles, ?fixture)', () => { const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir))) let server: Awaited> let browser: Browser @@ -141,6 +144,30 @@ describe('web boot chain success pass (keyless, five real bundles, ?fixture)', ( expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout') }) + it('renders the goal bar through the assembled boot, RPC, runtime, and conversation path', async () => { + const tree = page.getByRole('tree', { name: 'Sessions' }) + await tree.getByRole('treeitem').filter({ hasText: '3 sessions' }).click() + await tree.locator('[role="treeitem"][aria-selected]').first().click() + const bar = page.locator('[data-goal-bar]') + await bar.waitFor({ timeout: 10_000 }) + const snapshot = { + actions: await bar.locator('button').evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-label'))), + text: (await bar.locator('span').allTextContents()).filter(text => text !== ''), + } + expect(snapshot).toMatchInlineSnapshot(` + { + "actions": [ + "Edit goal", + "Clear goal", + ], + "text": [ + "Ongoing Goal", + "Ship the fixture goal bar", + ], + } + `) + }) + it('stayed clean: no page errors across the whole load chain', () => { expect(pageErrors).toEqual([]) }) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c49776d60d..0d57ab3c3e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -611,7 +611,7 @@ clear(agent: Agent, ref: GoalRef): GoalRef Types: [Agent](../core-data-structures/core.md) · [CreateGoalRequest](../core-data-structures/goal.md) · [EditGoalRequest](../core-data-structures/goal.md) · [GoalBlockReason](../core-data-structures/goal.md) · [GoalRef](../core-data-structures/goal.md) · [GoalView](../core-data-structures/goal.md) -Source: [`packages/goal/goal/src/index.ts:147`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:138`](../../packages/goal/goal/src/index.ts) ## `ctx.invariants` — `InvariantService` diff --git a/missions/plan.md b/missions/plan.md index 07f6421f90..e1e3a0f0c1 100644 --- a/missions/plan.md +++ b/missions/plan.md @@ -15,7 +15,7 @@ DO NOT read AGENTS.md / CLAUDE.md in this project !! 2. 对话流:输入框、流式输入、Markdown 展示,tool 显示,发送排队,数据走 SSE/WebSocket 不确定 3. 设置页:Provider 配置/APIKEY 配置、模型列表 -最新调研结论,在 +最新调研结论,在 - missions/ui-product.md - missions/ui-tech.md diff --git a/missions/tasks/20260719-2039-rpc-vs-jsonrpc/findings.md b/missions/tasks/20260719-2039-rpc-vs-jsonrpc/findings.md index a1ebd0d1a9..6bc1cfe6bd 100644 --- a/missions/tasks/20260719-2039-rpc-vs-jsonrpc/findings.md +++ b/missions/tasks/20260719-2039-rpc-vs-jsonrpc/findings.md @@ -84,4 +84,3 @@ 5. **【反面自查,已通过】wire 命名一致性**:对方无 convention 导致 `session/prompt`(斜杠方法名)与 `session.event`(点号通知名)在同一包内互相打架。我方已拍板机械推导 convention(方法点号、Frame 斜杠),此坑已提前规避——无动作,仅记录佐证。 SSE 手工解帧的多字节 UTF-8 跨 chunk 测试(维度 5)并入 client 实现测试计划,不单列为契约建议。 - diff --git a/missions/tasks/20260720-0300-web-dev-2-onboarding/audit.md b/missions/tasks/20260720-0300-web-dev-2-onboarding/audit.md index 1c66aa0617..dd6ccd3521 100644 --- a/missions/tasks/20260720-0300-web-dev-2-onboarding/audit.md +++ b/missions/tasks/20260720-0300-web-dev-2-onboarding/audit.md @@ -87,4 +87,3 @@ | — | doc-mismatch 汇总 | A2(双向校验)、A6(since 载体)、S1(§D.3 缝合)、S5(§A.9 引用稳定)、S6(哨兵类型)+ 笔记 §7 的 8 条 | 转 RFC/文档线(rfc-consolidation) | **总评**:架构分层(契约/载体/装配/对象层/hook/组件六层)与依赖纪律执行得好,OO 归属经两次纠偏后基本正位(批 4 抽查组件零越界属实);系统性弱点集中在两条线——**错误与异常路径的兑现度**(A1/A2/R1/R3/S2:正常路径精心设计,出错路径要么吞要么没人走过)和**时序竞态**(S1/S3/S4/C2:fixture 同步理想时序掩盖了慢网/重连窗口)。建议修复顺序:R1+S2 两个立即小修 → 「打开/重连时序」批(S1/S3/S4/C2)→「载体错误通道」批(A1/A2/A4/R3)→ impl step2(R4 优先)→ 性能与杂项。每批修完跑 verify-session + verify-session-real 并按发现补用例。 - diff --git a/missions/tasks/20260720-0337-test-design/design.md b/missions/tasks/20260720-0337-test-design/design.md index 80fa2daf05..d549061b70 100644 --- a/missions/tasks/20260720-0337-test-design/design.md +++ b/missions/tasks/20260720-0337-test-design/design.md @@ -276,4 +276,3 @@ agent 纪律沿用既有惯例:playwright 验收 agent 自己跑(chromium he 2. **`toFetchHandler` 用 `node:crypto` randomUUID**:换 `globalThis.crypto.randomUUID()` 即浏览器可跑(C.1 前提,一行)。 3. **webserver `RunningWebServer.port` 回显 `options.port` 而非实际监听端口**:port=0(随机端口)时返回 0。测试想用随机端口避免冲突就会撞上;建议改读 `server.address().port`。 4. **`AbortSignal.timeout` 不可被 vi fake timers 控制**:D.1 已定短真值策略,写 T4 时勿踩。 - diff --git a/missions/tasks/20260720-1652-components-research/survey.md b/missions/tasks/20260720-1652-components-research/survey.md index f888a33d3f..19af97f69d 100644 --- a/missions/tasks/20260720-1652-components-research/survey.md +++ b/missions/tasks/20260720-1652-components-research/survey.md @@ -167,4 +167,3 @@ Vendored from a pinned upstream commit. - **不 vendor `sessionMessageList`/`assistantMessage` 等业务包装**:触发条件=web-ui 消息列表需要滚动锚定/分支切换等具体交互时;返工点=届时读上游对应实现抄交互逻辑(作参考不作源);预埋=本报告已记其路径与行数。 - **不跟上游 pnpm patch 体系整体走**:触发条件=vendor md 后流式公式出现崩溃/丢内容 case;返工点=把上游 5 个 patch 中 micromark 系 3 个移植成我们的 patchedDependencies;预埋=patch 文件路径 `common/pnpm-patches/`(上游),本报告 4.1 已标记。 - **不做 CSS Modules 化改写**:触发条件=全局 class 与未来第三方样式实际冲突;返工点=对冲突组件加 layer/scope 包裹而非改写源码;预埋=全部外来 CSS 保持 `.ds-*` 前缀不动。 - diff --git a/missions/tasks/20260721-1330-vscode-extension-research/report.md b/missions/tasks/20260721-1330-vscode-extension-research/report.md index 5d4e5a5e27..a47cb67d96 100644 --- a/missions/tasks/20260721-1330-vscode-extension-research/report.md +++ b/missions/tasks/20260721-1330-vscode-extension-research/report.md @@ -277,4 +277,3 @@ VS Code 坚持扩展不碰 DOM 的三个真实动因:**① 进程故障隔离* ### 5.3 一句话总结 VS Code 用三层物理隔离买「不可信生态的安全与秩序」,我们不需要买安全,但它为「秩序」发明的那套纪律——契约单源、声明先于行为、参数化优先、id 命名空间、disposable 到底、主题 token 化、跨界即异步即可序列化——与进程隔离零耦合,全部适用于同进程 React 插件系统,且大多与 cordis 现有约定(effect/register-disposer/显式 seam)天然同构。 - diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index a1a2b40aa0..7e6046a243 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -10,7 +10,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, - ToolCallView, ToolEventView, ToolResultView, + ToolCallView, ToolEventView, ToolResultView, GoalView, } from './api.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' import { AbstractApiClient, RpcId } from './api.ts' @@ -243,6 +243,11 @@ export function createFixtureApi(): ApiProxy { { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' }, ] const logs = new Map([[sid('fx-alpha'), buildAlphaLog()]]) + const fixtureGoal: GoalView = { + id: 'fx-goal-1' as GoalView['id'], revision: 1, objective: 'Ship the fixture goal bar', + phase: 'active', maxGoalRounds: 4, roundsStarted: 1, createdAt: 1, updatedAt: 2, + activation: 'armed', + } const nextTurn = new Map([[sid('fx-alpha'), 60]]) let nextSession = 1 let nextRpc = 1 @@ -424,7 +429,7 @@ export function createFixtureApi(): ApiProxy { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }), }, goals: { - get: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }), + get: request => ok(request, { goal: request.payload.sessionId === sid('fx-alpha') ? fixtureGoal : null }), create: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }), edit: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }), pause: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }), diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index c50921a44d..b4c224eefb 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -308,6 +308,16 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true) + const goal = await client.goals.get({ sessionId: sid('fx-alpha') }) + expect(goal.result).toMatchObject({ ok: true, value: { goal: { objective: 'Ship the fixture goal bar' } } }) + expect((await client.goals.get({ sessionId: id })).result).toEqual({ ok: true, value: { goal: null } }) + const ref = { id: 'fx-goal-1' as never, revision: 1 } + expect((await client.goals.create({ sessionId: id, objective: 'x' })).result.ok).toBe(false) + expect((await client.goals.edit({ sessionId: id, ref, objective: 'x' })).result.ok).toBe(false) + expect((await client.goals.pause({ sessionId: id, ref })).result.ok).toBe(false) + expect((await client.goals.resume({ sessionId: id, ref })).result.ok).toBe(false) + expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false) + expect((await client.goals.clear({ sessionId: id, ref })).result.ok).toBe(false) }) it('fires onOpen at stream-iteration start and taps server-request full forms', async () => { diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index e1cd766f2f..4c3f0d6936 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -63,8 +63,9 @@ export class Session implements ObservableSnapshot { private lastAgentError: string | null = null /** Current goal projection; undefined = not yet fetched, null = no goal set. */ private goal: GoalView | null | undefined = undefined - /** Coalesced goal refetch (the open() idiom): live goal-change events share one in-flight get. */ + /** Coalesced goal refetch; a trigger received in flight schedules one trailing read. */ private goalFetch: Promise | null = null + private goalFetchPending = false /** Bumped on every local goal write; a get result older than the latest write is stale and * dropped (a mutation response that landed mid-fetch is always newer than the get's read). */ private goalWriteRev = 0 @@ -127,17 +128,27 @@ export class Session implements ObservableSnapshot { return result } - /** Fetch the current goal, coalesced: concurrent triggers share the in-flight get (identity-guarded - * like openPromise — a superseded fetch must not null out the one that replaced it). */ + /** Fetch the current goal, coalesced: concurrent triggers share the in-flight get. */ private fetchGoal(): Promise { - if (this.goalFetch !== null) return this.goalFetch - const promise = this.doFetchGoal().finally(() => { - if (this.goalFetch === promise) this.goalFetch = null - }) + if (this.goalFetch !== null) { + this.goalFetchPending = true + return this.goalFetch + } + const promise = this.drainGoalFetches().finally(() => { this.goalFetch = null }) this.goalFetch = promise return promise } + /** Drain the current read plus one coalesced trailing read for triggers received in flight. */ + private async drainGoalFetches(): Promise { + do { + this.goalFetchPending = false + await this.doFetchGoal() + // A goal-change callback can set the flag while doFetchGoal is suspended. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } while (this.goalFetchPending) + } + /** The get behind fetchGoal: folds transport failures (fail-soft like loadOlder, logged) and * drops the result when a mutation response landed mid-flight (write revision moved on). */ private async doFetchGoal(): Promise { @@ -153,18 +164,13 @@ export class Session implements ObservableSnapshot { } } - /** - * Create a goal for this session. - * @param objective - the goal's objective text. - * @param maxGoalRounds - optional cap on admitted goal rounds (host default when absent). - * @returns the created goal view; transport failures fold into a failed result, never a rejection. - */ - async createGoal(objective: string, maxGoalRounds?: number): Promise> { + /** Execute a goal mutation and publish its successful projection. */ + private async updateGoal( + request: () => Promise<{ result: RpcResult<{ goal: GoalView }> }>, + ): Promise> { let result: RpcResult<{ goal: GoalView }> try { - result = (await this.api.goals.create({ - sessionId: this.sessionId, objective, ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}), - })).result + result = (await request()).result } catch (error) { result = transportError(error) } @@ -176,6 +182,18 @@ export class Session implements ObservableSnapshot { return result } + /** + * Create a goal for this session. + * @param objective - the goal's objective text. + * @param maxGoalRounds - optional cap on admitted goal rounds (host default when absent). + * @returns the created goal view; transport failures fold into a failed result, never a rejection. + */ + async createGoal(objective: string, maxGoalRounds?: number): Promise> { + return this.updateGoal(() => this.api.goals.create({ + sessionId: this.sessionId, objective, ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}), + })) + } + /** * Edit this session's goal objective or round cap (CAS with the locally held revision). * @param objective - replacement objective text; absent leaves it unchanged. @@ -186,23 +204,22 @@ export class Session implements ObservableSnapshot { if (this.goal === null || this.goal === undefined) { return { ok: false, error: { code: 'internal', message: 'No goal to edit', details: {} } } } - let result: RpcResult<{ goal: GoalView }> - try { - result = (await this.api.goals.edit({ - sessionId: this.sessionId, - ref: { id: this.goal.id, revision: this.goal.revision }, - ...(objective !== undefined ? { objective } : {}), - ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}), - })).result - } catch (error) { - result = transportError(error) + const goal = this.goal + return this.updateGoal(() => this.api.goals.edit({ + sessionId: this.sessionId, + ref: { id: goal.id, revision: goal.revision }, + ...(objective !== undefined ? { objective } : {}), + ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}), + })) + } + + /** Apply a phase transition to the current goal. */ + private transitionGoal(operation: 'pause' | 'resume' | 'complete'): Promise> { + if (this.goal === null || this.goal === undefined) { + return Promise.resolve({ ok: false, error: { code: 'internal', message: `No goal to ${operation}`, details: {} } }) } - if (result.ok) { - this.goal = result.value.goal - this.goalWriteRev++ - this.notifier.markDirty() - } - return result + const ref = { id: this.goal.id, revision: this.goal.revision } + return this.updateGoal(() => this.api.goals[operation]({ sessionId: this.sessionId, ref })) } /** @@ -210,23 +227,7 @@ export class Session implements ObservableSnapshot { * @returns the updated goal view; fails without a current goal, and transport failures fold into a failed result. */ async pauseGoal(): Promise> { - if (this.goal === null || this.goal === undefined) { - return { ok: false, error: { code: 'internal', message: 'No goal to pause', details: {} } } - } - let result: RpcResult<{ goal: GoalView }> - try { - result = (await this.api.goals.pause({ - sessionId: this.sessionId, ref: { id: this.goal.id, revision: this.goal.revision }, - })).result - } catch (error) { - result = transportError(error) - } - if (result.ok) { - this.goal = result.value.goal - this.goalWriteRev++ - this.notifier.markDirty() - } - return result + return this.transitionGoal('pause') } /** @@ -234,23 +235,7 @@ export class Session implements ObservableSnapshot { * @returns the updated goal view; fails without a current goal, and transport failures fold into a failed result. */ async resumeGoal(): Promise> { - if (this.goal === null || this.goal === undefined) { - return { ok: false, error: { code: 'internal', message: 'No goal to resume', details: {} } } - } - let result: RpcResult<{ goal: GoalView }> - try { - result = (await this.api.goals.resume({ - sessionId: this.sessionId, ref: { id: this.goal.id, revision: this.goal.revision }, - })).result - } catch (error) { - result = transportError(error) - } - if (result.ok) { - this.goal = result.value.goal - this.goalWriteRev++ - this.notifier.markDirty() - } - return result + return this.transitionGoal('resume') } /** @@ -258,23 +243,7 @@ export class Session implements ObservableSnapshot { * @returns the updated goal view; fails without a current goal, and transport failures fold into a failed result. */ async completeGoal(): Promise> { - if (this.goal === null || this.goal === undefined) { - return { ok: false, error: { code: 'internal', message: 'No goal to complete', details: {} } } - } - let result: RpcResult<{ goal: GoalView }> - try { - result = (await this.api.goals.complete({ - sessionId: this.sessionId, ref: { id: this.goal.id, revision: this.goal.revision }, - })).result - } catch (error) { - result = transportError(error) - } - if (result.ok) { - this.goal = result.value.goal - this.goalWriteRev++ - this.notifier.markDirty() - } - return result + return this.transitionGoal('complete') } /** diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 6469986f0d..56da757241 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -647,6 +647,12 @@ describe('goal session methods', () => { expect(session.getSnapshot().goal).toEqual(goal) }) + it('createGoal forwards an explicit round cap', async () => { + const { api, session } = makeSession() + await session.createGoal('bounded', 7) + expect(api.callsOf('goal.create')).toEqual([{ sessionId: SID, objective: 'bounded', maxGoalRounds: 7 }]) + }) + it('editGoal sends the current ref and updates snapshot', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) @@ -661,6 +667,18 @@ describe('goal session methods', () => { expect(session.getSnapshot().goal).toEqual(edited) }) + it('editGoal can replace only the round cap', async () => { + const { api, session } = makeSession() + const goal = makeGoal() + api.goals.create = () => Promise.resolve(ok({ goal })) + await session.createGoal('test-goal') + const edited = makeGoal({ revision: 2, maxGoalRounds: 9 }) + const edit = vi.fn(() => Promise.resolve(ok({ goal: edited }))) + api.goals.edit = edit + await session.editGoal(undefined, 9) + expect(edit).toHaveBeenCalledWith({ sessionId: SID, ref: { id: goal.id, revision: 1 }, maxGoalRounds: 9 }) + }) + it('editGoal returns an error when no goal exists', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) @@ -670,6 +688,15 @@ describe('goal session methods', () => { expect((r as { error: { code: string } }).error.code).toBe('internal') }) + it('phase mutations and clear return an error when no goal exists', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + for (const mutate of [() => session.pauseGoal(), () => session.resumeGoal(), () => session.completeGoal(), () => session.clearGoal()]) { + expect((await mutate()).ok).toBe(false) + } + }) + it('pauseGoal pauses and updates snapshot', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) @@ -748,6 +775,14 @@ describe('goal session methods', () => { expect(session.getSnapshot().goal).toEqual(goal) }) + it('keeps the goal unresolved when the eager fetch returns an RPC error', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse([]) + api.goals.get = () => Promise.resolve(err({ code: 'internal', message: 'unavailable', details: {} })) + await session.open() + expect(session.getSnapshot().goal).toBeUndefined() + }) + const goalChangeEvent = (seq: number, operation: string): SessionEvent => at(seq, { type: 'context/message', surfaceOp: 'append', @@ -773,7 +808,7 @@ describe('goal session methods', () => { }, }) - it('live goal-change meta triggers one coalesced refetch; window replays never refetch', async () => { + it('live goal-change meta coalesces to one in-flight read plus one trailing read; window replays never refetch', async () => { const { api, session } = makeSession() // The history window replays goal-change meta (a snapshot change AND a clear tombstone). api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), goalChangeEvent(6, 'create'), goalClearEvent(7)]) @@ -791,12 +826,14 @@ describe('goal session methods', () => { }) expect(refetches).toBe(0) - // Two live goal events (change + clear tombstone) coalesce into a single refetch. + // Two live goal events (change + clear tombstone) share the in-flight read, then the + // second trigger schedules a trailing read so an independently ordered GET cannot win. session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: goalChangeEvent(9, 'edit') }) session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: goalClearEvent(10) }) expect(refetches).toBe(1) const goal = makeGoal({ revision: 3 }) gate.resolve(ok({ goal })) + await vi.waitFor(() => { expect(refetches).toBe(2) }) await vi.waitFor(() => { expect(session.getSnapshot().goal).toEqual(goal) }) }) @@ -837,6 +874,11 @@ describe('goal session methods', () => { const paused = await session.pauseGoal() expect(paused).toMatchObject({ ok: false, error: { code: 'internal', message: 'pause wire down' } }) expect(session.getSnapshot().goal).toEqual(goal) // local state untouched + + api.goals.clear = () => Promise.reject(new Error('clear wire down')) + const cleared = await session.clearGoal() + expect(cleared).toMatchObject({ ok: false, error: { code: 'internal', message: 'clear wire down' } }) + expect(session.getSnapshot().goal).toEqual(goal) }) it('a goal.get transport rejection on a live refetch is logged and swallowed', async () => { diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index dbd096da2e..1a76322bac 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -154,9 +154,9 @@ export function apply(ctx: Context): void { return createElement(Fragment, null, ...children) }, goalActions: { - onEdit: (objective) => { void session.editGoal(objective) }, - onResume: () => { void session.resumeGoal() }, - onClear: () => { void session.clearGoal() }, + onEdit: objective => session.editGoal(objective), + onResume: () => session.resumeGoal(), + onClear: () => session.clearGoal(), } satisfies GoalBarActions, } return injected diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 545e647958..04b217823e 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -13,14 +13,17 @@ import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-w import type { ConvOwnerProps, DetailsOwnerProps, EmptyOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client' import type { SelectionTarget, ViewEntry, ViewId } from './views.ts' +/** Result shape the goal strip needs to retain drafts and surface action failures. */ +export type GoalActionResult = { ok: true } | { ok: false; error: { code: string; message: string } } + /** Goal strip callbacks (the docked GoalBar's verb set). State is read from useSession. */ export interface GoalBarActions { /** Replace the current goal's objective. */ - onEdit(objective: string): void + onEdit(objective: string): Promise /** Resume a paused goal. */ - onResume(): void + onResume(): Promise /** Clear the current goal (tombstone). */ - onClear(): void + onClear(): Promise } /** Injected share of the conversation slot (assembled by apply's inject factory). */ diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index ffc11d5744..33c72a221a 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -22,7 +22,7 @@ export type { } from './contract/toolview.ts' export type { ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps, - EmptyStateInjected, EmptyStateSlotProps, GoalBarActions, + EmptyStateInjected, EmptyStateSlotProps, GoalActionResult, GoalBarActions, } from './contract/slots.ts' export { ConversationRoot } from './skeleton/ConversationRoot.tsx' diff --git a/packages/client/ui-conversation/src/client/skeleton/GoalBar.module.css b/packages/client/ui-conversation/src/client/skeleton/GoalBar.module.css index 45ce02ac49..fe07bace1b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/GoalBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/GoalBar.module.css @@ -51,6 +51,17 @@ white-space: nowrap; } +.error { + flex: 1; + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-state-error-primary); + font-size: 12px; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + /* ---- Inline edit form ---- */ .objectiveInput { diff --git a/packages/client/ui-conversation/src/client/skeleton/GoalBar.tsx b/packages/client/ui-conversation/src/client/skeleton/GoalBar.tsx index 992ee67a7e..de26bcf037 100644 --- a/packages/client/ui-conversation/src/client/skeleton/GoalBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/GoalBar.tsx @@ -12,7 +12,7 @@ import type { GoalView } from '@deepseek-ai/dsh-client-runtime/client' import { IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPlayOutline16, IconSparkle16, IconTrashOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { GoalBarActions } from '../contract/slots.ts' +import type { GoalActionResult, GoalBarActions } from '../contract/slots.ts' import css from './GoalBar.module.css' export interface GoalBarProps extends GoalBarActions { @@ -30,27 +30,45 @@ const PHASE_LABELS = { export function GoalBar({ goal, onEdit, onResume, onClear }: GoalBarProps) { const [editing, setEditing] = useState(false) const [draft, setDraft] = useState('') + const [pending, setPending] = useState(false) + const [actionError, setActionError] = useState(null) // A new goal identity (cleared/completed/replaced externally) invalidates the local edit // state: without the reset a surviving draft's Enter would write over the NEW goal. const goalId = goal?.id useEffect(() => { setEditing(false) + setActionError(null) }, [goalId]) - const handleEdit = useCallback(() => { + const handleEdit = useCallback(async () => { const trimmed = draft.trim() if (trimmed === '') return - onEdit(trimmed) - setEditing(false) + setPending(true) + setActionError(null) + const result = await onEdit(trimmed) + setPending(false) + if (result.ok) { + setEditing(false) + } else { + setActionError(`${result.error.message}(${result.error.code})`) + } }, [draft, onEdit]) + const runAction = useCallback(async (action: () => Promise) => { + setPending(true) + setActionError(null) + const result = await action() + setPending(false) + if (!result.ok) setActionError(`${result.error.message}(${result.error.code})`) + }, []) + // Loading, absent, and complete goals have no strip at all. if (goal === undefined || goal === null || goal.phase === 'complete') return null if (editing) { return ( -
+
setDraft(e.target.value)} onKeyDown={e => { - if (e.key === 'Enter') handleEdit() + if (e.key === 'Enter') void handleEdit() if (e.key === 'Escape') setEditing(false) }} autoFocus /> + {actionError !== null && {actionError}}
)} -
diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index b681ea2b8a..8c7a68ad5b 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -63,6 +63,9 @@ async function bench() { () => Promise.resolve({ ok: true, value: { accepted: true } })), cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>( () => Promise.resolve({ ok: true, value: { accepted: true } })), + editGoal: vi.fn(() => Promise.resolve({ ok: true as const, value: { goal: {} } })), + resumeGoal: vi.fn(() => Promise.resolve({ ok: true as const, value: { goal: {} } })), + clearGoal: vi.fn(() => Promise.resolve({ ok: true as const, value: { cleared: true as const } })), } sessionFake.useSelector = bindSnapshotSelector(sessionFake as never) const scopes = new Map() @@ -166,6 +169,19 @@ describe('conversation slot inject surface', () => { await new Promise(r => setTimeout(r, 0)) }) + it('goal actions return the runtime mutation results', async () => { + const b = await bench() + const injected = b.entryOf('conversation').options.inject(b.binding) as { + goalActions: import('@deepseek-ai/dsh-client-ui-conversation/client').GoalBarActions + } + expect((await injected.goalActions.onEdit('updated')).ok).toBe(true) + expect((await injected.goalActions.onResume()).ok).toBe(true) + expect((await injected.goalActions.onClear()).ok).toBe(true) + expect(b.sessionFake.editGoal).toHaveBeenCalledWith('updated') + expect(b.sessionFake.resumeGoal).toHaveBeenCalledTimes(1) + expect(b.sessionFake.clearGoal).toHaveBeenCalledTimes(1) + }) + it('view actions forward: openDetails writes selection through the scoped service, loadOlder hits the session', async () => { const b = await bench() const injected = b.entryOf('conversation').options.inject(b.binding) as { diff --git a/packages/client/ui-conversation/tests/goalbar.spec.tsx b/packages/client/ui-conversation/tests/goalbar.spec.tsx index f20a6de3a2..40f5815d8d 100644 --- a/packages/client/ui-conversation/tests/goalbar.spec.tsx +++ b/packages/client/ui-conversation/tests/goalbar.spec.tsx @@ -3,7 +3,7 @@ // inline edit form, and resume/clear icon actions — driven purely through // props, no wire. Loading, absent, and complete goals render nothing. -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { GoalView } from '@deepseek-ai/dsh-client-runtime/client' import { GoalBar } from '../src/client/skeleton/GoalBar.tsx' @@ -26,12 +26,12 @@ function makeGoal(over: Partial = {}): GoalView { } } -function makeActions(): { [K in keyof GoalBarActions]: ReturnType> } { +function makeActions() { return { - onEdit: vi.fn(), - onResume: vi.fn(), - onClear: vi.fn(), - } + onEdit: vi.fn(() => Promise.resolve({ ok: true })), + onResume: vi.fn(() => Promise.resolve({ ok: true })), + onClear: vi.fn(() => Promise.resolve({ ok: true })), + } satisfies GoalBarActions } describe('GoalBar', () => { @@ -58,7 +58,7 @@ describe('GoalBar', () => { expect(actions.onClear).toHaveBeenCalledTimes(1) }) - it('edit swaps the strip for a prefilled form; Enter saves, empty stays disabled', () => { + it('edit swaps the strip for a prefilled form; Enter saves, empty stays disabled', async () => { const actions = makeActions() render() fireEvent.click(screen.getByRole('button', { name: 'Edit goal' })) @@ -71,7 +71,7 @@ describe('GoalBar', () => { fireEvent.change(box, { target: { value: 'Ship v2' } }) fireEvent.keyDown(box, { key: 'Enter' }) expect(actions.onEdit).toHaveBeenCalledWith('Ship v2') - expect(screen.getByText('Ongoing Goal')).toBeTruthy() + await waitFor(() => { expect(screen.getByText('Ongoing Goal')).toBeTruthy() }) }) it('Esc cancels the edit without calling onEdit', () => { @@ -144,4 +144,31 @@ describe('GoalBar', () => { expect(screen.getByText('Blocked Goal')).toBeTruthy() expect(screen.getByText('Blocked Goal').closest('[title]')).toBeNull() }) + + it('keeps the edit draft open and reports a failed save', async () => { + const actions = makeActions() + actions.onEdit.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'stale revision' } }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Edit goal' })) + const box = screen.getByRole('textbox', { name: 'Goal objective' }) + fireEvent.change(box, { target: { value: 'retry this draft' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save goal' })) + + expect((await screen.findByRole('alert')).textContent).toBe('stale revision(agent-busy)') + expect((screen.getByRole('textbox', { name: 'Goal objective' }) as HTMLInputElement).value).toBe('retry this draft') + }) + + it('reports resume and clear failures without hiding the goal', async () => { + const actions = makeActions() + actions.onResume.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'resume failed' } }) + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: 'Resume goal' })) + expect((await screen.findByRole('alert')).textContent).toBe('resume failed(internal)') + + actions.onClear.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'clear failed' } }) + rerender() + fireEvent.click(screen.getByRole('button', { name: 'Clear goal' })) + expect((await screen.findByRole('alert')).textContent).toBe('clear failed(agent-busy)') + expect(screen.getByText('Ship the redesign')).toBeTruthy() + }) }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 62974352d0..ddd962c8ee 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -172,9 +172,9 @@ describe('ConversationRoot', () => { actions={{ openView: vi.fn() as (v: never) => void, open: vi.fn() }} renderView={() => null} goalActions={{ - onEdit: vi.fn(), - onResume: vi.fn(), - onClear: vi.fn(), + onEdit: vi.fn(() => Promise.resolve({ ok: true as const })), + onResume: vi.fn(() => Promise.resolve({ ok: true as const })), + onClear: vi.fn(() => Promise.resolve({ ok: true as const })), }} />) expect(screen.getByText('Ongoing Goal')).toBeTruthy() diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 6778343d7c..65d978eeb1 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -67,15 +67,6 @@ export interface ResolvedConfig { /** * One accepted mutation waiting to enter or be observed in the session log. * - * TODO(pending-fifo): The pending FIFO + `applied` flag + `sameChange` match + - * `observedSeq` tracking implements a distributed-transaction-style reconciliation - * protocol over what is purely synchronous, process-local state. In `commit()` there - * is no yield point between `agent.inject()` and the `pending.applied` guard (L509), - * so the guard is always taken — the flag is never `true` at that point. The same - * outcome is achievable by folding the injected event directly after `inject()` - * instead of deferring to `sync()`. This would eliminate `PendingGoalChange`, - * `GoalCache.pending`, `GoalCache.observedSeq`, `sameChange()`, the `applied` flag, - * the `catch` rollback, and the reconciliation branch in `sync()` (~40 lines). */ interface PendingGoalChange { readonly change: GoalChangeMeta diff --git a/packages/host/apiproxy/src/api/goals.schema.ts b/packages/host/apiproxy/src/api/goals.schema.ts index 2ec087190a..dc08f95a2c 100644 --- a/packages/host/apiproxy/src/api/goals.schema.ts +++ b/packages/host/apiproxy/src/api/goals.schema.ts @@ -60,6 +60,8 @@ export const goalEditRequestSchema = z.object({ ref: goalRefSchema, objective: z.string().min(1).optional(), maxGoalRounds: z.number().int().positive().optional(), +}).refine(value => value.objective !== undefined || value.maxGoalRounds !== undefined, { + message: 'goal.edit requires objective or maxGoalRounds', }) as unknown as z.ZodType>> /** goal.edit response value. */ diff --git a/packages/host/apiproxy/src/api/goals.ts b/packages/host/apiproxy/src/api/goals.ts index b1a2dd9a77..90e552963d 100644 --- a/packages/host/apiproxy/src/api/goals.ts +++ b/packages/host/apiproxy/src/api/goals.ts @@ -4,6 +4,7 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RpcRequest, RpcResponse } from './rpc.ts' /** Identifies one goal across its durable revisions. */ @@ -60,29 +61,29 @@ export interface EditGoalRequest { /** Goal-domain unary methods. */ export interface GoalsApi { /** Read the current goal for one session. Returns null when no goal is current. */ - get(request: RpcRequest<{ sessionId: string }>): Promise> + get(request: RpcRequest<{ sessionId: SessionId }>): Promise> /** Create and arm a goal. */ - create(request: RpcRequest<{ sessionId: string; objective: string; maxGoalRounds?: number }>): + create(request: RpcRequest<{ sessionId: SessionId; objective: string; maxGoalRounds?: number }>): Promise> /** Edit objective and/or round cap without changing phase. */ - edit(request: RpcRequest<{ sessionId: string; ref: GoalRef; objective?: string; maxGoalRounds?: number }>): + edit(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef; objective?: string; maxGoalRounds?: number }>): Promise> /** Pause an active goal and disarm automatic continuation. */ - pause(request: RpcRequest<{ sessionId: string; ref: GoalRef }>): + pause(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>): Promise> /** Resume and arm a stopped goal. */ - resume(request: RpcRequest<{ sessionId: string; ref: GoalRef }>): + resume(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>): Promise> /** Mark a current non-complete goal complete and disarm it. */ - complete(request: RpcRequest<{ sessionId: string; ref: GoalRef }>): + complete(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>): Promise> /** Clear the current goal while retaining a durable tombstone and history. */ - clear(request: RpcRequest<{ sessionId: string; ref: GoalRef }>): + clear(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>): Promise> } diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 6b651bc386..de90fd909f 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -425,6 +425,13 @@ describe('goals unary surface', () => { const response = await client(scriptedApi()).goals.create({ sessionId: sid('s1'), objective: '' }) expect(response.result.ok).toBe(false) if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') + + let editCalls = 0 + const api = scriptedApi({ goals: { edit: (r) => { editCalls++; return ok(r, { goal: view }) } } }) + const emptyEdit = await client(api).goals.edit({ sessionId: sid('s1'), ref }) + expect(emptyEdit.result.ok).toBe(false) + if (!emptyEdit.result.ok) expect(emptyEdit.result.error.code).toBe('bad-request') + expect(editCalls).toBe(0) }) }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 681d4c6794..45f9945cef 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -15,6 +15,7 @@ import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/h import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts' import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts' import { askUserQuestionAnswerSchema, questionResponsePayloadSchema } from '../src/api/questions.schema.ts' +import { goalEditRequestSchema } from '../src/api/goals.schema.ts' describe('RpcId', () => { it('brands a raw string at zero runtime cost', () => { @@ -126,6 +127,15 @@ describe('host domain schemas', () => { }) }) +describe('goals domain schemas', () => { + it('requires at least one replacement field for goal.edit', () => { + const ref = { id: 'g1', revision: 1 } + expect(goalEditRequestSchema.parse({ sessionId: 's1', ref, objective: 'updated' }).objective).toBe('updated') + expect(goalEditRequestSchema.parse({ sessionId: 's1', ref, maxGoalRounds: 3 }).maxGoalRounds).toBe(3) + expect(() => goalEditRequestSchema.parse({ sessionId: 's1', ref })).toThrow() + }) +}) + describe('events frame schemas', () => { it('accepts every mux frame branch', () => { const frames = [ diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 744e2e6ad9..89a986fcf1 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -15,6 +15,7 @@ import type {} from '@deepseek-ai/dsh-commands' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api' import type { GoalView } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { GoalView as CoreGoalView } from '@deepseek-ai/dsh-goal' import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -222,7 +223,7 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name: } /** Project a server-side GoalView into the wire GoalView shape. */ -function goalView(g: import('@deepseek-ai/dsh-goal').GoalView): GoalView { +function goalView(g: CoreGoalView): GoalView { return { id: g.id, revision: g.revision, @@ -296,6 +297,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } + /** Resolve a session, apply one goal mutation, and map domain failures to the wire result. */ + async function mutateGoal( + request: RpcRequest<{ sessionId: SessionId }>, + mutation: (agent: Agent) => CoreGoalView, + ): Promise> { + const found = await agentFor(request.payload.sessionId) + if ('error' in found) return err(request, found.error) + try { + return ok(request, { goal: goalView(mutation(found.agent)) }) + } catch (error: unknown) { + return err(request, { code: 'internal', message: String(error), details: {} }) + } + } + return { sessions: { // Attached sessions summarize from memory; persisted-but-unattached (cold) @@ -482,81 +497,43 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro goals: { async get(request) { const { sessionId } = request.payload - const found = await agentFor(sessionId as SessionId) + const found = await agentFor(sessionId) if ('error' in found) return err(request, found.error) const goal = ctx.goals.get(found.agent) return ok(request, { goal: goal ? goalView(goal) : null }) }, async create(request) { - const { sessionId, objective, maxGoalRounds } = request.payload - const found = await agentFor(sessionId as SessionId) - if ('error' in found) return err(request, found.error) - try { - const goal = ctx.goals.create(found.agent, { - objective, - ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}), - }) - return ok(request, { goal: goalView(goal) }) - } catch (error: unknown) { - return err(request, { code: 'internal', message: String(error), details: {} }) - } + const { objective, maxGoalRounds } = request.payload + return mutateGoal(request, agent => ctx.goals.create(agent, { + objective, + ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}), + })) }, async edit(request) { - const { sessionId, ref, objective, maxGoalRounds } = request.payload - const found = await agentFor(sessionId as SessionId) - if ('error' in found) return err(request, found.error) - try { - const goal = ctx.goals.edit(found.agent, ref, { - ...(objective !== undefined ? { objective } : {}), - ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}), - }) - return ok(request, { goal: goalView(goal) }) - } catch (error: unknown) { - return err(request, { code: 'internal', message: String(error), details: {} }) - } + const { ref, objective, maxGoalRounds } = request.payload + return mutateGoal(request, agent => ctx.goals.edit(agent, ref, { + ...(objective !== undefined ? { objective } : {}), + ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}), + })) }, async pause(request) { - const { sessionId, ref } = request.payload - const found = await agentFor(sessionId as SessionId) - if ('error' in found) return err(request, found.error) - try { - const goal = ctx.goals.pause(found.agent, ref) - return ok(request, { goal: goalView(goal) }) - } catch (error: unknown) { - return err(request, { code: 'internal', message: String(error), details: {} }) - } + return mutateGoal(request, agent => ctx.goals.pause(agent, request.payload.ref)) }, async resume(request) { - const { sessionId, ref } = request.payload - const found = await agentFor(sessionId as SessionId) - if ('error' in found) return err(request, found.error) - try { - const goal = ctx.goals.resume(found.agent, ref) - return ok(request, { goal: goalView(goal) }) - } catch (error: unknown) { - return err(request, { code: 'internal', message: String(error), details: {} }) - } + return mutateGoal(request, agent => ctx.goals.resume(agent, request.payload.ref)) }, async complete(request) { - const { sessionId, ref } = request.payload - const found = await agentFor(sessionId as SessionId) - if ('error' in found) return err(request, found.error) - try { - const goal = ctx.goals.complete(found.agent, ref) - return ok(request, { goal: goalView(goal) }) - } catch (error: unknown) { - return err(request, { code: 'internal', message: String(error), details: {} }) - } + return mutateGoal(request, agent => ctx.goals.complete(agent, request.payload.ref)) }, async clear(request) { const { sessionId, ref } = request.payload - const found = await agentFor(sessionId as SessionId) + const found = await agentFor(sessionId) if ('error' in found) return err(request, found.error) try { ctx.goals.clear(found.agent, ref) diff --git a/packages/host/runtime/tests/api-proxy-command.spec.ts b/packages/host/runtime/tests/api-proxy-command.spec.ts index baa0c35ca3..8012de3e6a 100644 --- a/packages/host/runtime/tests/api-proxy-command.spec.ts +++ b/packages/host/runtime/tests/api-proxy-command.spec.ts @@ -325,7 +325,7 @@ describe('goals RPC surface', () => { it('an unservable session id is an RPC error on every goal method', async () => { const test = await harness() const api = createApiProxy(test.ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) - const missing = 'no-such-session' + const missing = SessionId('no-such-session') const ref = { id: 'goal-x' as GoalView['id'], revision: 1 } const attempts = [ diff --git a/vitest.config.ts b/vitest.config.ts index 957d6e053a..2f3f80220e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -97,16 +97,6 @@ export default defineConfig({ 'packages/client/ui-trajectory/src/*', 'packages/client/web-react/src/*', 'packages/host/webserver/src/*', - // GUI goal UI (goal-ui): goal RPC wire glue and the docked GoalBar — - // core dispatch paths are unit-covered, the remaining branches are - // transport/presenter tails. TODO(gui): cover and remove. - 'packages/client/ui-conversation/src/client/skeleton/GoalBar.tsx', - 'packages/client/ui-conversation/src/client/apply.ts', - 'packages/client/runtime/src/client/sessions/session.ts', - 'packages/client/connection/src/client/fixture.ts', - 'packages/host/apiproxy/src/fetch/client.ts', - 'packages/host/apiproxy/src/fetch/handler.ts', - 'packages/host/runtime/src/api-proxy.ts', ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), ...windowsCoverageExclusions, ],