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 new file mode 100644 index 0000000000..48a75f3449 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.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/feature/2026-07-22-docked-web-goal-bar.md +2026-07-22-docked-web-goal-bar.md: 52a7d223ce3522c5ba977b1126dcd63bd2f6366f +2026-07-22-docked-web-goal-bar.zh.md: e4842a03ccb8a29b35c7af0c03c51b1324b6ab36 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 new file mode 100644 index 0000000000..52a7d223ce --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md @@ -0,0 +1,39 @@ +# Agent Note: Docked web goal bar + +Status: implemented + +English | [中文](2026-07-22-docked-web-goal-bar.zh.md) + +## Problem + +The web UI had no goal surface at all: the goal stack shipped with model tools, the TUI/ACP adapters, and the `/goal` command, but the browser client exposed none of it — no runtime verbs, no indicator. This change introduces the client goal verbs (runtime session methods over RPC) and the first goal UI together. Placement follows the redesign's premise that goal presence belongs to the composer's context: the goal is a property of the work the user is about to prompt, so its indicator docks directly above the message composer as a rounded-top strip tucked under the composer card's top edge. The mock keeps only a sparkle, a phase word ("Ongoing/Paused/Blocked Goal"), the truncated objective, and edit/clear icon actions, with resume appearing only on a paused goal. + +## Decision + +`GoalBar` (`packages/client/ui-goal/src/client/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. 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`. 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 — 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-goal/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 + +- **Put the strip in the session header** — rejected because the redesign's premise is that goal presence belongs to the composer's context; a header strip cannot dock into the composer card. +- **Render a "Loading goal…" placeholder for `undefined`** — rejected: the strip would flash and collapse on every session open, chrome noise for a sub-second state. +- **Include an inline create affordance when no goal is set** — rejected after implementation review: goal creation lives on the `/goal` command, matching the pattern where the model creates goals on request; the bar is a status indicator, not a creation surface. +- **Carry the full verb set (`onPause`/`onComplete`) in `GoalBarActions`** — rejected as speculative generality: no consumer calls them, so the interface carries only the rendered verbs. + +## Consequences + +- Goal presence in the web UI is a composer-docked strip: sparkle, phase label, truncated objective, and edit/clear (plus resume when paused) — the browser client's first goal surface. +- The runtime session exposes the goal verbs over RPC with folded transport errors, and refreshes the snapshot's goal on open and on live goal-change meta (coalesced, guarded against stale reads). +- Objective editing is reachable from the UI for the first time, through `goal.edit` with the runtime-owned ref; pause/complete remain available to other surfaces (`/goal`, model tools). +- `goal === null` renders nothing; the composer carries no persistent create affordance — creation is the `/goal` command's job. 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 new file mode 100644 index 0000000000..e4842a03cc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 停靠式 Web 目标条 + +Status: implemented + +[English](2026-07-22-docked-web-goal-bar.md) | 中文 + +## 问题 + +Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、TUI/ACP 适配器和 `/goal` 命令交付,但浏览器客户端完全不接触它——既没有运行时动词,也没有指示器。本变更同时引入客户端目标动词(基于 RPC 的运行时会话方法)和第一个目标 UI。摆放位置遵循重新设计的前提:目标的存在感属于输入框的上下文——目标是用户即将提交的工作的属性,因此它的指示器停靠在消息输入框正上方,呈现为一条圆角顶部的横条,收进输入框卡片顶边之下。设计稿只保留一个闪光图标、一个阶段词("Ongoing/Paused/Blocked Goal")、截断后的目标内容,以及编辑/清除图标操作,恢复按钮仅在目标暂停时出现。 + +## 决策 + +`GoalBar`(`packages/client/ui-goal/src/client/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 会在目标身份变化时丢弃编辑表单,因此存留的草稿绝不可能覆盖掉替换它的新目标。 + +`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` 触发合并重新拉取——并发触发器共享正在执行的 `goal.get`,读取期间收到的触发器会安排一次合并后的尾随读取,避免彼此独立排序的通知和 GET 响应留下陈旧状态。窗口重放绝不触发重新拉取,且匹配元数据 kind(而不是 goal 键)还能捕获其他客户端写入的清除墓碑。六个变更动词与所有同类会话方法一样,把传输层失败折叠为 `{ ok: false }` 结果;比在拉取途中落地的变更响应更旧的 get 结果会被丢弃。 + +横条的背景色用 `--dsw-alias-interactive-bg-hover`,而不是设计稿里的字面值 `#F5F6F7`:这个半透明的悬浮灰在浅色主题的白色底上正好解析为该值,而在深色模式下能把横条从输入框卡片上衬托出来,静态的浅色 token 在深色模式下会沉进去。所有颜色都是 `--dsw-*` token。 + +## 测试 + +`packages/client/ui-goal/tests/goalbar.spec.tsx` 仅通过 props 固定这些行为:加载中/无目标/已完成时不渲染;active 横条渲染标签和目标内容并触发清除;编辑表单预填内容、拒绝空值、按 Enter 保存、按 Esc 取消,并在目标身份变化时重置;paused 横条触发恢复;blocked 横条暴露原因悬浮提示。组件失败路径用例证明编辑失败时保留草稿,并且编辑/恢复/清除错误持续显示在横条中。skeleton 规格测试分别挂载带与不带 `goalActions` 的 `ConversationRoot`;未定义的情形预置了一个 active 目标,因此隐藏横条的是缺失的挂载门,而不是缺失的目标。运行时会话规格测试固定了折叠错误结果、仅 live 的执行中读取加尾随读取,以及陈旧读取守卫。一个无密钥真实浏览器冒烟测试通过 `boot → RPC → runtime → GoalBar` 启动组装后的应用,并以内联快照记录渲染出的标签、目标内容和操作。 + +## 考虑过的替代方案 + +- **把横条放在会话头部**:不予采纳,因为重新设计的前提是目标的存在感属于输入框的上下文;放在头部的横条无法停靠进输入框卡片。 +- **为 `undefined` 渲染 "Loading goal…" 占位**:不予采纳,每次打开会话横条都会闪现再坍缩,对一个不到一秒的状态来说只是界面噪音。 +- **未设置目标时在横条内提供内联创建入口**:实现评审后不予采纳,创建目标的职责在 `/goal` 命令上,与模型按请求创建目标的模式一致;横条是状态指示器,不是创建入口。 +- **在 `GoalBarActions` 中携带完整动词集合(`onPause`/`onComplete`)**:作为投机性泛化不予采纳,没有消费方调用它们,接口只携带实际渲染的动词。 + +## 后果 + +- Web UI 中目标的存在形式是停靠在输入框上方的横条:闪光图标、阶段标签、截断的目标内容,以及编辑/清除(暂停时另有恢复)——这是浏览器客户端的第一个目标界面。 +- 运行时会话通过 RPC 暴露目标动词并折叠传输层错误,且在打开时和 live 目标变更元数据到达时刷新快照中的目标(合并拉取,带陈旧读取守卫)。 +- 目标内容首次可以从 UI 编辑,经由 `goal.edit`,ref 由运行时持有;暂停/完成对其他界面(`/goal`、模型工具)照常可用。 +- `goal === null` 时不渲染任何内容;输入框不提供常驻的创建入口,创建是 `/goal` 命令的职责。 diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml index c9cebc9db9..c66891f689 100644 --- a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.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-07-25-scriptable-llm-wire-fault-server.md: 0795f71f0eab1a107740aaa8cba6fa04b1fbd306 -2026-07-25-scriptable-llm-wire-fault-server.zh.md: a0e3c98d729adcc74e6d98933ab2beb538f71cb3 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md +2026-07-25-scriptable-llm-wire-fault-server.md: b8e64d92db224c199d0f8c547f0caa588d0ca65e +2026-07-25-scriptable-llm-wire-fault-server.zh.md: 35b27efa99b625fa3c815bbe58fef6a138477e55 diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md index 0795f71f0e..b8e64d92db 100644 --- a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md @@ -12,7 +12,7 @@ Connection refusal, a reset before the first event, clean EOF without `[DONE]`, ## Decision -`@deepseek-ai/dsh-llm-mock-server` is a support package with an importable Node HTTP server and a standalone CLI. It accepts OpenAI-compatible root and `/v1` chat-completions paths, validates an optional bearer token, captures requests, and consumes one explicit behavior per accepted request. Script exhaustion fails loud; repetition requires `repeatLast`. +`@deepseek-ai/dsh-llm-mock-server` is a private support package with an importable Node HTTP server. The repository-local `pnpm run mock:llm` source entry provides a standalone process for manual fault injection; the package exposes no installable binary. It accepts OpenAI-compatible root and `/v1` chat-completions paths, validates an optional bearer token, captures requests, and consumes one explicit behavior per accepted request. Script exhaustion fails loud; repetition requires `repeatLast`. Request behaviors cover socket reset, post-header disconnect, partial disconnect, stall, valid empty completion, clean truncated streams, malformed payloads, representative HTTP failures, complete text/reasoning/tool-call responses, slow streaming, and max-token completion. A true `connection_refused` is a CLI listener-lifecycle phase because a bound request handler cannot refuse its own TCP connection. @@ -32,10 +32,12 @@ Package tests exercise every request behavior, split UTF-8 request decoding, HTT **Use only an in-process `LlmAdapter` mock** — rejected because it bypasses fetch, HTTP status/header parsing, SSE framing, socket termination, and the adapter idle watchdog: the exact boundaries this test infrastructure exists to exercise. +**Expose an installable workspace binary** — rejected because pnpm links dependency binaries before repository build outputs exist, coupling clean installs to a test-only artifact. The repository-local source command supports the same manual fault injection without adding a package installation surface. + **Change retry defaults with the server** — rejected because the server reveals existing semantics rather than deciding policy. Extending recovery to `STREAM_CLOSED` requires a separate decision with its own cost, latency, and duplicate-generation trade-offs. ## Consequences Developers can reproduce fault sequences by changing only provider URL/key configuration, and automated tests can keep socket-level failures deterministic through explicit scripts and seeds. The same wire fixture now exposes gaps between hard resets, clean truncation, and recovered empty completions without splicing attempts or modifying model history. -The server adds a support package, executable build entry, and behavior vocabulary that must remain compatible with both direct tests and CLI examples. Arrival-ordered scripts are intentionally shared across clients, random defaults are stress weights rather than operational truth, and exact connection refusal requires coordinating the client attempt with the pre-listen interval. +The server adds a private support package and behavior vocabulary that must remain compatible with both direct tests and repository-local CLI examples. Arrival-ordered scripts are intentionally shared across clients, random defaults are stress weights rather than operational truth, and exact connection refusal requires coordinating the client attempt with the pre-listen interval. diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md index a0e3c98d72..35b27efa99 100644 --- a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md @@ -12,9 +12,9 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-llm-mock-server` 是一个支持包(package),提供可导入的 Node HTTP 服务器和独立 CLI(命令行界面)。它接受兼容 OpenAI 的根路径和 `/v1` chat-completions 路径,校验可选的 bearer token,捕获请求,并对每个已接受请求消耗一个显式行为。脚本耗尽时快速失败;只有设置 `repeatLast` 才会重复最后一个行为。 +`@deepseek-ai/dsh-llm-mock-server` 是一个私有支持包(package),提供可导入的 Node HTTP 服务器。仓库内的 `pnpm run mock:llm` 源码入口提供一个用于手动故障注入的独立进程;该包不公开可安装的二进制命令。它接受兼容 OpenAI 的根路径和 `/v1` chat-completions 路径,校验可选的 bearer token,捕获请求,并对每个已接受请求消耗一个显式行为。脚本耗尽时快速失败;只有设置 `repeatLast` 才会重复最后一个行为。 -请求行为覆盖 socket 重置、发送 header 后断开、发送部分内容后断开、停滞、合法空完成、正常关闭但被截断的流、畸形 payload、典型 HTTP 故障、完整的文本/推理/工具调用响应、慢速流式输出以及达到 token 上限的完成。真正的 `connection_refused` 由 CLI 的监听器生命周期阶段实现,因为已经绑定端口的请求处理器无法拒绝自身的 TCP 连接。 +请求行为覆盖 socket 重置、发送 header 后断开、发送部分内容后断开、停滞、合法空完成、正常关闭但被截断的流、畸形 payload、典型 HTTP 故障、完整的文本/推理/工具调用响应、慢速流式输出以及达到 token 上限的完成。真正的 `connection_refused` 由 CLI(命令行界面)的监听器生命周期阶段实现,因为已经绑定端口的请求处理器无法拒绝自身的 TCP 连接。 脚本项 `random` 会为每个请求重新执行一次加权选择。服务器公开并记录其无符号 32 位 seed,允许调用方提供相对权重,并内置一套偏重成功结果的压力测试配置,将传输、协议、提供方、超时和语义空结果混合在一起。该配置用于提供可调的测试压力,并非对生产事故发生频率的估算;`connection_refused` 仍不进入请求级随机池。 @@ -32,10 +32,12 @@ Status: implemented **仅使用进程内的 `LlmAdapter` mock**:不予采纳。它会绕过 fetch、HTTP 状态与 header 解析、SSE 分帧、socket 终止以及适配器的空闲看门狗,而这正是这套测试基础设施要覆盖的边界。 +**公开可安装的 workspace 二进制命令**:不予采纳。pnpm 会在仓库构建产物存在之前链接依赖项的二进制命令,从而让干净安装与仅供测试的产物产生耦合。仓库内的源码命令支持相同的手动故障注入,而不会新增包安装接口。 + **随服务器一起修改默认重试策略**:不予采纳。服务器用于揭示既有语义,而非决定策略。是否将恢复能力扩展到 `STREAM_CLOSED`,需要单独决策,并权衡成本、延迟和重复生成风险。 ## 后果 开发者只需修改提供方 URL/key 配置即可复现故障序列;自动化测试则可通过显式脚本和 seed,让 socket 层故障保持确定性。同一套协议 fixture 现在可以暴露硬重置、正常截断与恢复后的空完成之间的差异,而不会拼接多次尝试的内容或修改模型历史。 -服务器新增了一个支持包、可执行构建入口和行为词汇,三者必须同时兼容直接测试与 CLI 示例。按请求到达顺序执行的脚本有意由所有客户端共享;随机模式的默认值代表压力测试权重,而非实际运行规律;精确模拟连接遭拒时,需要让客户端尝试与监听开始前的时间区间协调一致。 +服务器新增了一个私有支持包和一套行为词汇,二者必须同时兼容直接测试与仓库内的 CLI 示例。按请求到达顺序执行的脚本有意由所有客户端共享;随机模式的默认值代表压力测试权重,而非实际运行规律;精确模拟连接遭拒时,需要让客户端尝试与监听开始前的时间区间协调一致。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index b7ca8629cd..b17bc614a8 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -175,6 +175,18 @@ - id: commands name: '@deepseek-ai/dsh-commands' +# Goal service + automatic same-session continuation + the /goal command. +# The GoalService registers the 'goal' session projection unit; the web +# GoalBar reads it through useProjection. +- id: goal + name: '@deepseek-ai/dsh-goal' + +- id: goal-session + name: '@deepseek-ai/dsh-goal-session' + +- id: command-goal + name: '@deepseek-ai/dsh-command-goal' + # Plan mode registers /plan (the first real command on the web surface). # Section text mirrors examples/tui-agent/cordis.yml (the reference # deployment); plan-mode throws at load on an empty section. @@ -326,6 +338,10 @@ - id: ui-subagent name: '@deepseek-ai/dsh-client-ui-subagent' +# Goal surface: GoalBar in the input dock over the goal session projection. +- id: ui-goal + name: '@deepseek-ai/dsh-client-ui-goal' + # Model selection: the /model popupSelect + composer seat over session.models. - id: ui-model name: '@deepseek-ai/dsh-client-ui-model' diff --git a/apps/cli/package.json b/apps/cli/package.json index 8c333c90ab..e31e7378a0 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-goal": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model": "workspace:^", "@deepseek-ai/dsh-client-ui-models": "workspace:^", @@ -43,11 +44,14 @@ "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", + "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 15004d3786..5b9ece6019 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -421,7 +421,7 @@ export interface Config { } ``` -Source: [`packages/goal/goal/src/index.ts:56`](../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:118`](../packages/goal/goal/src/index.ts) ## `@deepseek-ai/dsh-hooks-claude` @@ -2172,6 +2172,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model` ([`packages/client/ui-model/src/index.ts`](../packages/client/ui-model/src/index.ts)) - `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 62f5ddfeb2..967241fbf6 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -524,7 +524,7 @@ Goal mutation accepted by one live agent. The matching context event is already Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/goal/goal/src/types.ts:169`](../../packages/goal/goal/src/types.ts) +Source: [`packages/goal/goal/src/domain.ts:135`](../../packages/goal/goal/src/domain.ts) ## `llm/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 20d4d31ba0..6f32d656af 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -675,7 +675,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:135`](../../packages/goal/goal/src/index.ts) +Source: [`packages/goal/goal/src/index.ts:197`](../../packages/goal/goal/src/index.ts) ## `ctx.httpServer` — `HttpServerService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 16d5f20493..9d66b52bfb 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -29,7 +29,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:169`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 5d943b0341..b0f1111826 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -144,6 +144,7 @@ flowchart TD pkg_client_test_runtime["client-test-runtime"] pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] + pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_model["client-ui-model"] pkg_client_ui_models["client-ui-models"] @@ -443,6 +444,7 @@ flowchart TD pkg_goal --> pkg_llm pkg_goal --> pkg_scope pkg_goal --> pkg_session + pkg_goal --> pkg_session_projection pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_invariants pkg_bash_local --> pkg_subprocess @@ -579,6 +581,13 @@ flowchart TD pkg_permission --> pkg_sandbox_policy pkg_permission --> pkg_session pkg_permission --> pkg_user_approval + pkg_client_ui_goal --> pkg_client_connection + pkg_client_ui_goal --> pkg_client_runtime + pkg_client_ui_goal --> pkg_client_ui_conversation + pkg_client_ui_goal --> pkg_client_ui_primitives + pkg_client_ui_goal --> pkg_client_ui_slots + pkg_client_ui_goal --> pkg_goal + pkg_client_ui_goal --> pkg_invariants pkg_pty_local --> pkg_agent pkg_pty_local --> pkg_invariants pkg_pty_local --> pkg_pty @@ -1007,7 +1016,7 @@ flowchart TD | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | +| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | @@ -1039,6 +1048,7 @@ flowchart TD | [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | diff --git a/knip.json b/knip.json index f9fd9e1dfd..38eb32c223 100644 --- a/knip.json +++ b/knip.json @@ -101,6 +101,16 @@ "tests/**/*.tsx" ] }, + "packages/client/ui-goal": { + "entry": [ + "tests/**/*.spec.tsx" + ], + "project": [ + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.tsx" + ] + }, "packages/client/web-react": { "entry": [ "tests/**/*.spec.tsx" diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 77f5445f6d..978d4c3378 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -12,6 +12,7 @@ export type { CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelTarget, SessionModels, + GoalsApi, GoalRef, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 5f32d07d9d..bcb88ac0f7 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -341,6 +341,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record= 0; i--) { + const event = log[i] as unknown as { + type: string + data?: { source?: { kind?: string; round?: number; change?: FxGoalChange } } + } | undefined + if (event === undefined || event.type !== 'user/message') continue + const source = event.data?.source + if (source?.kind !== 'goal' || source.round !== 0) continue + const change = source.change + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (change === undefined || change.kind !== 'goal/change') continue + if (change.operation === 'clear') return null + return { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt } + } + return null +} + interface StreamConn { push(envelope: RpcRequest): void } @@ -613,6 +672,47 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { for (const frame of projectionFramesOf(id, log, event)) emitMux(frame) } + /** Append one goal/change as its round-zero goal-sourced user message (host GoalService parallel). */ + const appendGoalChange = (id: SessionId, change: FxGoalChange): FxGoalProjection => { + const ref = change.operation === 'clear' ? change.cleared : change.goal + const payload = change.operation === 'clear' + ? { cleared: change.cleared, clearedAt: change.clearedAt } + : { goal: change.goal, roundsStarted: change.roundsStarted, createdAt: change.createdAt, updatedAt: change.updatedAt } + append(id, { + type: 'user/message', surfaceOp: 'append', + data: userMessage( + text(`${JSON.stringify(payload)}`), + { kind: 'goal', goalId: ref.id, revision: ref.revision, round: 0, change } as unknown as MessageSource, + ), + }) + return backscanGoal(logOf(id)) as FxGoalProjection + } + + /** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */ + const fxMutateGoal = ( + request: RpcRequest<{ sessionId: SessionId; ref: { id: string; revision: number } }>, + ref: { id: string; revision: number }, + next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined, + ): Promise> => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const id = request.payload.sessionId + const current = backscanGoal(logOf(id)) + if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) { + return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } }) + } + const goal = next(current) + if (goal === undefined) { + return err(request, { code: 'internal', message: `invalid goal transition from "${current.goal.phase}"`, details: { goalCode: 'GOAL_INVALID_TRANSITION' } }) + } + const projection = appendGoalChange(id, { + kind: 'goal/change', version: 1, + operation: goal.phase === current.goal.phase ? 'edit' : goal.phase === 'paused' ? 'pause' : goal.phase === 'active' ? 'resume' : 'complete', + goal, roundsStarted: current.roundsStarted, createdAt: current.createdAt, updatedAt: Date.now(), + }) + return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } }) + } + /** At most one in-flight replay per session; cancel clears it. */ const replays = new Map; finish(aborted: boolean): void }>() @@ -981,7 +1081,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { commands: [ { name: 'compact', description: 'fixture:压缩当前会话上下文' }, { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } }, - { name: 'goal-fixture', description: 'fixture:目标样本命令', input: { hint: '' } }, + { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '' } }, { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]' } }, ], }) @@ -998,6 +1098,29 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const match = /^\/(\S+)((?:\s.*)?)$/.exec(request.payload.line.trim()) const name = match?.[1] const args = match?.[2] ?? '' + if (name === 'goal') { + // Host parallel: /goal with an objective creates (or reports) the + // current goal; the command lifecycle pair brackets the mutation. + const commandId = `fx-cmd-${logOf(id).length}` as CommandId + append(id, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }) + const objective = args.trim() + const current = backscanGoal(logOf(id)) + let text: string + if (objective === '') { + text = current === null ? 'No goal is set. Usage: /goal ' : `Current goal: ${current.goal.objective}` + } else if (current !== null && current.goal.phase !== 'complete') { + text = `A goal already exists (${current.goal.objective}). Clear it first.` + } else { + const created = appendGoalChange(id, { + kind: 'goal/change', version: 1, operation: 'create', + goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective, phase: 'active', maxGoalRounds: 256 }, + roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(), + }) + text = `Goal created: ${created.goal.objective}` + } + append(id, { type: 'command/done', data: { commandId, kind: 'success', text } }) + return ok(request, { matched: true as const, commandId }) + } // Host parallel: /plan on an idle fixture session commits plan/mode // immediately (the boundary flush covers only a running turn), so the // outcome copy matches the immediate branch of the host handler. @@ -1005,7 +1128,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const outcomes: Record = { compact: 'fixture:已压缩(假动作)', echo: args.trim(), - 'goal-fixture': `fixture:goal 已设置(${id})`, plan: args.trim() === 'off' ? (running ? 'Leaving plan mode (applies from the next step).' : 'Plan mode off.') : (running @@ -1037,6 +1159,62 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }) }, }, + goals: { + // Mutation-only mirror of the host handlers: each verb CAS-checks the + // projected current goal, appends the whole-value change (the mux + // stream and projection frame ride the shared append path), and + // acknowledges with the new ref only. + create: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const id = request.payload.sessionId + const current = backscanGoal(logOf(id)) + if (current !== null && current.goal.phase !== 'complete') { + return err(request, { code: 'internal', message: `goal "${current.goal.id}" already exists`, details: { goalCode: 'GOAL_ALREADY_EXISTS' } }) + } + const projection = appendGoalChange(id, { + kind: 'goal/change', version: 1, operation: 'create', + goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective: request.payload.objective, phase: 'active', maxGoalRounds: request.payload.maxGoalRounds ?? 256 }, + roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(), + }) + return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } }) + }, + edit: request => fxMutateGoal(request, request.payload.ref, current => ({ + ...current.goal, + revision: current.goal.revision + 1, + ...request.payload.objective === undefined ? {} : { objective: request.payload.objective }, + ...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds }, + })), + pause: request => fxMutateGoal(request, request.payload.ref, current => ( + current.goal.phase === 'active' + ? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' } + : undefined + )), + resume: request => fxMutateGoal(request, request.payload.ref, current => ( + current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active' + ? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' } + : undefined + )), + complete: request => fxMutateGoal(request, request.payload.ref, current => ( + current.goal.phase === 'complete' + ? undefined + : { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' } + )), + clear: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const id = request.payload.sessionId + const current = backscanGoal(logOf(id)) + if (current === null || current.goal.id !== request.payload.ref.id || current.goal.revision !== request.payload.ref.revision) { + return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } }) + } + appendGoalChange(id, { + kind: 'goal/change', version: 1, operation: 'clear', + cleared: { id: current.goal.id, revision: current.goal.revision + 1 }, clearedAt: Date.now(), + }) + return ok(request, { cleared: true as const }) + }, + }, events: { async *mux(_request, signal) { const conn = new FxInbox() @@ -1167,6 +1345,12 @@ export class FixtureApiClient extends AbstractApiClient { // The in-memory execute never blocks, so a never-aborting signal is faithful here. case 'command.execute': return this.api.commands.execute(request, new AbortController().signal) case 'skill.list': return this.api.skills.list(request) + case 'goal.create': return this.api.goals.create(request) + case 'goal.edit': return this.api.goals.edit(request) + case 'goal.pause': return this.api.goals.pause(request) + case 'goal.resume': return this.api.goals.resume(request) + case 'goal.complete': return this.api.goals.complete(request) + case 'goal.clear': return this.api.goals.clear(request) } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 1ce30dbdc4..53e5b2bc3f 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -20,6 +20,7 @@ export type { RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, + GoalsApi, GoalRef, } from './api.ts' export { RpcId, AbstractApiClient, transportError } from './api.ts' diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 1dac997ae2..d974c4bf3b 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -127,6 +127,15 @@ export class FakeApiClient implements IApiClient { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } + readonly goals: IApiClient['goals'] = { + create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))), + } + /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ suppressStreamOpen = false diff --git a/packages/client/connection/tests/fixture-commands.spec.ts b/packages/client/connection/tests/fixture-commands.spec.ts index c9bb0249e3..f589024a24 100644 --- a/packages/client/connection/tests/fixture-commands.spec.ts +++ b/packages/client/connection/tests/fixture-commands.spec.ts @@ -23,7 +23,7 @@ describe('createFixtureApi commands/skills', () => { expect(response.rpcId).toBe(request.rpcId) if (!response.result.ok) throw new Error('list failed') const commands = response.result.value.commands - expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal-fixture', 'plan']) + expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'plan']) // input hint rides only the commands declaring it. const echo = commands.find(c => c.name === 'echo') expect(echo?.input?.hint).toBeTruthy() @@ -64,11 +64,11 @@ describe('createFixtureApi commands/skills', () => { it('addresses execute to the session; an unknown session errs', async () => { const api = createFixtureApi() - const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal-fixture ship' }), signal) + const hit = await api.commands.execute(req({ sessionId: sid('fx-alpha'), line: '/goal ship' }), signal) if (!hit.result.ok) throw new Error('execute failed') expect(hit.result.value.matched).toBe(true) - const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal-fixture ship' }), signal) + const missing = await api.commands.execute(req({ sessionId: sid('fx-nope'), line: '/goal ship' }), signal) expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) }) diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 3661acd880..8f51861283 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -73,7 +73,7 @@ describe('createFixtureApi', () => { // and plan-mode are mounted): the empty-log values. expect(empty.result.value).toEqual({ events: [], hasMore: false, - projections: { asOfSeq: -1, values: { todos: null, plan: { active: false, pending: false } } }, + projections: { asOfSeq: -1, values: { goal: null, todos: null, plan: { active: false, pending: false } } }, }) }) @@ -208,7 +208,7 @@ describe('createFixtureApi', () => { const envelopes: RpcRequest[] = [] for await (const envelope of api.events.mux(req({}), abort.signal)) { envelopes.push(envelope) - if (envelopes.length >= 6) abort.abort() + if (envelopes.length >= 7) abort.abort() } return envelopes } @@ -216,14 +216,15 @@ describe('createFixtureApi', () => { const second = await openOnce() expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' }) expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0) - // Projection baseline frames follow the subscribed frame (title + todos + plan units). + // Projection baseline frames follow the subscribed frame (title + todos + plan + goal units). expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' }) expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' }) expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } }) - expect(first[4]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) - expect(second[4]?.rpcId).toBe(first[4]?.rpcId) // stable rpcId across replays (host replay semantics) - expect(first[5]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) - expect(second[5]?.rpcId).toBe(first[5]?.rpcId) + expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null }) + expect(first[5]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) + expect(second[5]?.rpcId).toBe(first[5]?.rpcId) // stable rpcId across replays (host replay semantics) + expect(first[6]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) + expect(second[6]?.rpcId).toBe(first[6]?.rpcId) }) it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { @@ -701,6 +702,29 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId }) if (!moved.result.ok) throw new Error('workspace move failed') expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId]) + // Goal lifecycle over the fixture fold: create → edit → pause → resume → complete → clear; + // every mutation acknowledges with the NEW CAS ref (state rides the projection frames). + const goalCreated = await client.goals.create({ sessionId: id, objective: 'ship it' }) + if (!goalCreated.result.ok) throw new Error('goal create failed') + let ref = goalCreated.result.value.ref + expect(ref.revision).toBe(1) + const edited = await client.goals.edit({ sessionId: id, ref, objective: 'ship it v2' }) + if (!edited.result.ok) throw new Error('goal edit failed') + ref = edited.result.value.ref + const paused = await client.goals.pause({ sessionId: id, ref }) + if (!paused.result.ok) throw new Error('goal pause failed') + ref = paused.result.value.ref + const resumed = await client.goals.resume({ sessionId: id, ref }) + if (!resumed.result.ok) throw new Error('goal resume failed') + ref = resumed.result.value.ref + // A stale ref loses the CAS check. + expect((await client.goals.pause({ sessionId: id, ref: { ...ref, revision: 1 } })).result.ok).toBe(false) + const completed = await client.goals.complete({ sessionId: id, ref }) + if (!completed.result.ok) throw new Error('goal complete failed') + ref = completed.result.value.ref + // complete → complete is an invalid transition. + expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false) + expect((await client.goals.clear({ sessionId: id, ref })).result).toEqual({ ok: true, value: { cleared: true } }) }) it('maps empty, prompt-reject, and workspace-first query scenarios', async () => { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index b18ae87d8e..bf1a4a04b3 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -153,6 +153,15 @@ export class FakeApiClient implements IApiClient { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } + readonly goals: IApiClient['goals'] = { + create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), + clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))), + } + /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ suppressStreamOpen = false diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 7bd4f22b06..412ff9fe59 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -6,12 +6,12 @@ import type { ReactNode } from 'react' import { - IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14, + IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16, + IconThinkOutline14, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowOwnerProps } from '../contract/slots.ts' import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts' import { ToolRow } from './ToolRow.tsx' -import { IconSparkle16 } from './IconSparkle16.tsx' /** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */ const VARIANT_ICONS: Record = { diff --git a/packages/client/ui-conversation/src/client/chat/IconSparkle16.tsx b/packages/client/ui-conversation/src/client/chat/IconSparkle16.tsx deleted file mode 100644 index 61331ba616..0000000000 --- a/packages/client/ui-conversation/src/client/chat/IconSparkle16.tsx +++ /dev/null @@ -1,15 +0,0 @@ -// Local sparkle icon for the Others tool-row variant (figma 43:31850 leading -// glyph is an SF Symbols "sparkles" text glyph — not extractable as vector -// data, so this is a hand-authored three-star approximation). Lives here -// rather than ui-primitives until the exact glyph is exported and adopted -// into the ic_ds_* family. - -export function IconSparkle16({ size = 16, className }: { size?: number; className?: string }) { - return ( - - - - - - ) -} diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml new file mode 100644 index 0000000000..9cda25e2e2 --- /dev/null +++ b/packages/client/ui-goal/README.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 packages/client/ui-goal/README.md +README.md: 476096a43532a0bf514cd191585872ef17f65c50 +README.zh.md: 27bd9a2e735cb4895d30eaf3b08dd939a00436fc diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md new file mode 100644 index 0000000000..476096a435 --- /dev/null +++ b/packages/client/ui-goal/README.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-client-ui-goal + +English | [中文](README.zh.md) + +Goal surface plugin, browser half: the `GoalBar` strip in the `conversation.input.dock` list (order 1, tucked against the composer). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the three mutation verbs (edit / resume / clear over the `goal.*` wire domain); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing. + +The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types. + +## Model Experience + +Indirectly, through the `goal.edit`/`goal.resume`/`goal.clear` RPCs the strip's verbs submit: each accepted mutation appends a model-visible `goal/change` context message to the session (the same durable event the projection folds), so the model sees the updated goal state on its next turn. The strip itself adds no prompt content. + +#### KV Cache effect + +None beyond the goal mutation's own context event, which appends to the log tail like any other message. + +## Known Limitations and Deferred Work + +- **Durable phase only** — the projection value deliberately omits process-local activation (armed/disarmed), so the strip cannot distinguish an active-but-disarmed goal from an armed one; resume re-arms through the RPC side. A host-live-value channel is deferred until a real consumer needs it. +- **No keyless snapshot yet** — the assembled-application transcript (boot → projection → GoalBar) is deferred to the post-review cleanup pass recorded on the landing PR. diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md new file mode 100644 index 0000000000..27bd9a2e73 --- /dev/null +++ b/packages/client/ui-goal/README.zh.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-client-ui-goal + +[English](README.md) | 中文 + +Goal 表面插件(浏览器半件):`conversation.input.dock` 列表中的 `GoalBar` 条带(order 1,紧贴 composer)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带三个变更动词(edit / resume / clear,走 `goal.*` 协议域);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现(RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。 + +`/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。 + +## Model Experience + +间接影响:条带动词提交的 `goal.edit`/`goal.resume`/`goal.clear` RPC 每次被接受后,会向会话追加一条模型可见的 `goal/change` 上下文消息(与投影折叠的正是同一条持久事件),模型在下一轮即可看到更新后的 goal 状态。条带自身不添加任何提示词内容。 + +#### KV Cache effect + +除 goal 变更自身的上下文事件(如同任何消息一样追加在日志尾部)外无额外影响。 + +## Known Limitations and Deferred Work + +- **只反映持久 phase** —— 投影值有意省略进程本地的 activation(armed/disarmed),条带无法区分 active-but-disarmed 与 armed 状态;resume 经 RPC 侧重新武装。host 活值通道待出现真实消费方后再议。 +- **暂缺 keyless 快照** —— 组装应用级 transcript(boot → 投影 → GoalBar)推迟到落地 PR 记录的评审后收口批次。 diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json new file mode 100644 index 0000000000..d734539d73 --- /dev/null +++ b/packages/client/ui-goal/package.json @@ -0,0 +1,70 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-goal", + "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-goal": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ] +} diff --git a/packages/client/ui-goal/src/client/GoalBar.module.css b/packages/client/ui-goal/src/client/GoalBar.module.css new file mode 100644 index 0000000000..fe07bace1b --- /dev/null +++ b/packages/client/ui-goal/src/client/GoalBar.module.css @@ -0,0 +1,120 @@ +/* GoalBar: the goal strip docked above the composer card. The dock mirrors + InputBar's horizontal geometry (32px side padding, 776px centered cap) + plus the mock's 12px inset, so the bar's edges land 12px inside the + composer card's edges in both the capped and the squeezed regimes. The + negative bottom margin eats InputBar's 8px top padding and tucks the + bar's square bottom edge 2px under the composer card's top edge (the + card, later in DOM order, paints over it). All states share one fixed + 38px height so switching between them never resizes the strip. */ + +.dock { + padding: 0 44px; +} + +.bar { + display: flex; + align-items: center; + gap: 6px; + box-sizing: border-box; + max-width: 752px; + height: 38px; + margin: 0 auto -10px; + padding: 0 14px; + border-radius: 14px 14px 0 0; + /* Translucent hover gray doubles as the mock's #F5F6F7 over the white + base and lifts the strip off the composer card in dark mode. */ + background: var(--dsw-alias-interactive-bg-hover); +} + +.sparkle { + display: inline-flex; + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.label { + flex: none; + font-size: 13px; + line-height: 20px; + font-weight: 600; + color: var(--dsw-alias-label-primary); +} + +.objective { + flex: 1; + min-width: 0; + overflow: hidden; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); + text-overflow: ellipsis; + 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 { + flex: 1; + min-width: 0; + height: 26px; + padding: 0 8px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + background: var(--dsw-alias-bg-base); + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-primary); + outline: none; +} + +.objectiveInput:focus { + border-color: var(--dsw-alias-state-business-primary); +} + +.objectiveInput::placeholder { + color: var(--dsw-alias-label-caption); +} + +/* ---- Icon actions ---- */ + +.actions { + display: flex; + align-items: center; + gap: 2px; + flex: none; +} + +.iconBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.iconBtn:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} + +.iconBtn:disabled { + opacity: 0.4; + cursor: default; +} diff --git a/packages/client/ui-goal/src/client/GoalBar.tsx b/packages/client/ui-goal/src/client/GoalBar.tsx new file mode 100644 index 0000000000..76308734fc --- /dev/null +++ b/packages/client/ui-goal/src/client/GoalBar.tsx @@ -0,0 +1,161 @@ +/** + * GoalBar: the goal indicator docked above the message composer (input dock + * strip). A present goal shows a sparkle, a phase label, the truncated + * objective, and icon actions — resume when paused, edit (inline form in the + * same strip), and clear. Goal creation lives on the `/goal` command, not + * here: loading (undefined), no goal (null), and complete goals render + * nothing. Live state arrives as the projected whole snapshot; the verbs are + * the injected face. + */ + +import { useCallback, useEffect, useState } from 'react' +import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client' +import { + IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPlayOutline16, IconSparkle16, IconTrashOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { GoalActionResult, GoalBarActions } from './slots.ts' +import css from './GoalBar.module.css' + +export interface GoalBarProps extends GoalBarActions { + /** Current goal snapshot; undefined = capability absent or loading, null = no goal set. */ + goal: GoalSnapshot | null | undefined +} + +/** Strip labels per visible phase; complete goals render nothing. */ +const PHASE_LABELS = { + active: 'Ongoing Goal', + paused: 'Paused Goal', + blocked: 'Blocked Goal', +} as const + +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(async () => { + const trimmed = draft.trim() + if (trimmed === '') return + 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') void handleEdit() + if (e.key === 'Escape') setEditing(false) + }} + autoFocus + /> + {actionError !== null && {actionError}} +
+ + +
+
+
+ ) + } + + const title = goal.phase === 'blocked' ? goal.blockedReason?.message : undefined + return ( +
+
+ + {PHASE_LABELS[goal.phase]} + {goal.objective} + {actionError !== null && {actionError}} +
+ {goal.phase === 'paused' && ( + + )} + + +
+
+
+ ) +} + +/** Full props of the dock entry: InputZone owner share + session standard kit + injected verbs. */ +export type GoalDockProps = import('@deepseek-ai/dsh-client-ui-slots').PropsRuntime<'conversation.input.dock'> & GoalBarActions + +/** Dock adapter: reads the host-computed 'goal' projection (whole value; absent or null renders nothing). */ +export function GoalDock({ useProjection, onEdit, onResume, onClear }: GoalDockProps) { + const projection = useProjection('goal') + return ( + + ) +} diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts new file mode 100644 index 0000000000..9c9d3f298d --- /dev/null +++ b/packages/client/ui-goal/src/client/index.ts @@ -0,0 +1,82 @@ +/** + * Goal surface plugin, browser half: the GoalBar entry in the + * conversation.input.dock strip. Projection-mode surface — the live goal + * arrives through `useProjection('goal')` (seeded by the history tail page, + * updated by session/projection frames), so this plugin owns no store, no + * refresh chain, and no event listener. The inject face carries only the + * three mutation verbs (edit/resume/clear over the goal.* wire domain); + * their CAS ref reads the session's current projected value at call time. + * Goal creation stays on the /goal host command. + */ +import type { ConnectionHandle, GoalRef, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { RpcResult } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the ui-conversation SlotMap merge (the input.dock entry). +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +// Type-only: the `goal` SessionProjectionMap key merge (single source, the domain's pure outlet). +import type { GoalProjection } from '@deepseek-ai/dsh-goal/client' +import type { GoalActionResult, GoalBarActions } from './slots.ts' +import { GoalDock } from './GoalBar.tsx' + +export { GoalBar, GoalDock } from './GoalBar.tsx' +export type { GoalActionResult, GoalBarActions } from './slots.ts' + +/** Required services: slots for the dock entry, sessions for the projected ref, connection for the wire verbs. */ +export const inject = ['slots', 'sessions', 'connection'] + +/** Map one settled RPC result onto the strip's inline-render shape. */ +function settle(result: RpcResult): GoalActionResult { + if (result.ok) return { ok: true } + return { ok: false, error: { code: result.error.code, message: result.error.message } } +} + +/** + * Client plugin body: the GoalBar dock entry with its mutation verbs. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + const { goals } = (ctx.get('connection') as ConnectionHandle).api + + // Conditional mount: 'conversation.input.dock' is declared by the + // conversation entry; the conversation service being up is the + // registration-safe signal (the TodoDock/QueueDock seam). + ctx.inject(['slots', 'conversation', 'sessions'], (scope: ClientContext) => { + const sessions = scope.sessions + + /** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */ + const refOf = (sessionId: SessionId): GoalRef | undefined => { + const face = sessions.binding(sessionId)?.session.projections.faceOf('goal') + const projection = face?.getSnapshot() as GoalProjection | null | undefined + if (projection == null) return undefined + return { id: projection.goal.id, revision: projection.goal.revision } + } + + const noCurrentGoal: GoalActionResult = { + ok: false, + error: { code: 'no-current-goal', message: 'no current goal to mutate' }, + } + + scope.effect(() => scope.slots.register({ + name: 'conversation.input.dock', + id: 'goal', + order: 1, + inject: (sessionId): GoalBarActions => ({ + onEdit: async (objective) => { + const ref = refOf(sessionId) + if (ref === undefined) return noCurrentGoal + return settle((await goals.edit({ sessionId, ref, objective })).result) + }, + onResume: async () => { + const ref = refOf(sessionId) + if (ref === undefined) return noCurrentGoal + return settle((await goals.resume({ sessionId, ref })).result) + }, + onClear: async () => { + const ref = refOf(sessionId) + if (ref === undefined) return noCurrentGoal + return settle((await goals.clear({ sessionId, ref })).result) + }, + }), + }, GoalDock), 'ui-goal: GoalBar dock registration') + }) +} diff --git a/packages/client/ui-goal/src/client/slots.ts b/packages/client/ui-goal/src/client/slots.ts new file mode 100644 index 0000000000..5791f26373 --- /dev/null +++ b/packages/client/ui-goal/src/client/slots.ts @@ -0,0 +1,26 @@ +/** + * GoalBar's injected face. The target 'conversation.input.dock' slot is + * declared (children table) and typed by ui-conversation; this package only + * contributes the entry, so no SlotMap merge lives here. The live goal value + * is NOT part of this face — it arrives through `useProjection('goal')` + * (the framework standard kit); inject carries only the mutation verbs + * (callbacks from inject, live state from useProjection). + */ + +/** Settled outcome of one goal mutation, rendered inline by the strip. */ +export type GoalActionResult = + | { ok: true } + | { ok: false; error: { code: string; message: string } } + +/** Injected business face of the GoalBar dock entry: the mutation verbs (function properties: the strip destructures them freely). */ +export interface GoalBarActions { + /** + * Replace the current goal's objective (CAS on the projected ref). + * @param objective - replacement objective text. + */ + onEdit: (objective: string) => Promise + /** Resume a paused goal. */ + onResume: () => Promise + /** Clear the current goal (tombstone). */ + onClear: () => Promise +} diff --git a/packages/client/ui-goal/src/css-modules.d.ts b/packages/client/ui-goal/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-goal/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-goal/src/index.ts b/packages/client/ui-goal/src/index.ts new file mode 100644 index 0000000000..780cea398e --- /dev/null +++ b/packages/client/ui-goal/src/index.ts @@ -0,0 +1,9 @@ +/** + * Goal surface plugin, node half. Pure UI plugin: the empty apply exists so + * the plugin appears in the host cordis.yml / Loader; the browser half + * ships via exports["./client"], discovered through the package.json + * dshClient declaration. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-goal/src/invariant.ts b/packages/client/ui-goal/src/invariant.ts new file mode 100644 index 0000000000..2120600664 --- /dev/null +++ b/packages/client/ui-goal/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-goal`. + * @module @deepseek-ai/dsh-client-ui-goal/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-goal' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-goal-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a single GoalBar dock registration whose disposal is + * proven by the HMR-safety spec — the plugin owns no store (state arrives on + * the goal projection), emits no cordis events, and holds no cross-plugin + * mutable state. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx new file mode 100644 index 0000000000..e1eae47529 --- /dev/null +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -0,0 +1,170 @@ +// @vitest-environment jsdom +/** + * ui-goal browser half on a real cordis Context with fake slots/connection/ + * sessions faces: the plugin registers the GoalBar dock entry at + * conversation.input.dock, the inject face's three verbs read the CAS ref + * from the session's CURRENT projected value at call time (no fence — the + * RPC's compare-and-set is the guard), a missing projection short-circuits + * to the no-current-goal error without touching the wire, and RPC errors + * map onto the inline-render result shape. Registration disposal rides the + * plugin fiber (HMR safety). The node half and the invariant companion are + * exercised over the same Context. + */ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { afterEach } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { GoalProjection } from '@deepseek-ai/dsh-goal/client' +import type { GoalBarActions } from '../src/client/slots.ts' +import { apply, inject } from '../src/client/index.ts' +import { GoalDock } from '../src/client/GoalBar.tsx' +import { apply as nodeApply } from '../src/index.ts' + +afterEach(cleanup) + +const sid = (k: string): SessionId => k as SessionId + +function makeProjection(revision = 3): GoalProjection { + return { + goal: { + id: 'g-1' as GoalProjection['goal']['id'], + revision, + objective: 'Ship it', + phase: 'active', + maxGoalRounds: 8, + }, + roundsStarted: 1, + createdAt: 10, + updatedAt: 20, + } +} + +/** Boot the plugin over fake faces; goals verbs record payloads and answer per the script. */ +function bench(options: { projection?: GoalProjection | null | undefined; failWith?: { code: string; message: string } } = {}) { + const ctx = new Context() + const calls: { method: string; payload: unknown }[] = [] + function answer(method: string, value: T) { + return (payload: unknown) => { + calls.push({ method, payload }) + return Promise.resolve({ + result: options.failWith === undefined + ? { ok: true as const, value } + : { ok: false as const, error: { ...options.failWith, details: {} } }, + }) + } + } + const ref = { id: 'g-1', revision: 3 } + ctx.provide('connection', { api: { goals: { + edit: answer('goal.edit', { ref }), + resume: answer('goal.resume', { ref }), + clear: answer('goal.clear', { cleared: true as const }), + } } }) + const entries = new Map GoalBarActions }>() + ctx.provide('slots', { + register(reg: { name: string; id?: string; order?: number; inject?: (sessionId: SessionId) => GoalBarActions }) { + entries.set(reg.name, reg) + return () => { entries.delete(reg.name) } + }, + }) + ctx.provide('conversation', {}) + ctx.provide('sessions', { + binding: (id: SessionId) => ({ + sessionId: id, + session: { projections: { faceOf: (key: string) => ({ + getSnapshot: () => (key === 'goal' ? options.projection : undefined), + subscribe: () => () => {}, + }) } }, + ctx, + }), + }) + const fiber = ctx.plugin({ inject: [...inject], apply }) + return { + ctx, + fiber, + calls, + entry: () => entries.get('conversation.input.dock'), + } +} + +describe('ui-goal browser plugin', () => { + it('registers the GoalBar dock entry with the documented id and order', async () => { + const b = bench() + await b.fiber.await() + expect(b.entry()).toMatchObject({ id: 'goal', order: 1 }) + expect(b.entry()?.inject).toBeTypeOf('function') + }) + + it('verbs read the CAS ref from the current projected value at call time', async () => { + const b = bench({ projection: makeProjection(5) }) + await b.fiber.await() + const verbs = b.entry()!.inject!(sid('s1')) + expect(await verbs.onEdit('New objective')).toEqual({ ok: true }) + expect(await verbs.onResume()).toEqual({ ok: true }) + expect(await verbs.onClear()).toEqual({ ok: true }) + expect(b.calls.map(c => c.method)).toEqual(['goal.edit', 'goal.resume', 'goal.clear']) + const ref = { id: 'g-1', revision: 5 } + expect(b.calls[0]?.payload).toEqual({ sessionId: 's1', ref, objective: 'New objective' }) + expect(b.calls[1]?.payload).toEqual({ sessionId: 's1', ref }) + expect(b.calls[2]?.payload).toEqual({ sessionId: 's1', ref }) + }) + + it('a null or absent projection short-circuits every verb without touching the wire', async () => { + for (const projection of [null, undefined]) { + const b = bench({ projection }) + await b.fiber.await() + const verbs = b.entry()!.inject!(sid('s1')) + for (const result of [await verbs.onEdit('x'), await verbs.onResume(), await verbs.onClear()]) { + expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate' } }) + } + expect(b.calls).toHaveLength(0) + } + }) + + it('maps a settled RPC error onto the inline-render shape', async () => { + const b = bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } }) + await b.fiber.await() + const verbs = b.entry()!.inject!(sid('s1')) + expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision' } }) + }) + + it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => { + const b = bench() + await b.fiber.await() + expect(b.entry()).toBeDefined() + await b.fiber.dispose() + expect(b.entry()).toBeUndefined() + }) +}) + +describe('GoalDock adapter', () => { + it('renders the projected goal snapshot and nothing for absent/null', () => { + const projection = makeProjection() + const useProjection = vi.fn(() => projection) + const actions: GoalBarActions = { + onEdit: () => Promise.resolve({ ok: true }), + onResume: () => Promise.resolve({ ok: true }), + onClear: () => Promise.resolve({ ok: true }), + } + const dockProps = (up: () => GoalProjection | null | undefined) => + ({ useProjection: up, ...actions }) as unknown as Parameters[0] + const shown = render() + expect(shown.getByText('Ship it')).toBeTruthy() + cleanup() + + const empty = render( null)} />) + expect(empty.container.firstChild).toBeNull() + cleanup() + + const absent = render( undefined)} />) + expect(absent.container.firstChild).toBeNull() + }) +}) + +describe('ui-goal node half', () => { + // The invariant companion is mounted by the vitest-wide invariant host on + // every Context this suite creates; its registration is covered there. + it('the node apply is an inert loader seat', () => { + expect(() => { nodeApply() }).not.toThrow() + }) +}) diff --git a/packages/client/ui-goal/tests/goalbar.spec.tsx b/packages/client/ui-goal/tests/goalbar.spec.tsx new file mode 100644 index 0000000000..ece447f8b0 --- /dev/null +++ b/packages/client/ui-goal/tests/goalbar.spec.tsx @@ -0,0 +1,170 @@ +// @vitest-environment jsdom +// GoalBar behavior: the docked strip above the composer — phase labels, +// 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, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client' +import { GoalBar } from '../src/client/GoalBar.tsx' +import type { GoalBarActions } from '../src/client/slots.ts' + +afterEach(cleanup) + +function makeGoal(over: Partial = {}): GoalSnapshot { + return { + id: 'g1' as GoalSnapshot['id'], + revision: 1, + objective: 'Ship the redesign', + phase: 'active', + maxGoalRounds: 4, + ...over, + } +} + +function makeActions() { + return { + 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', () => { + it('renders nothing while loading, absent, or when the goal is complete', () => { + const actions = makeActions() + const loading = render() + expect(loading.container.firstChild).toBeNull() + cleanup() + + const absent = render() + expect(absent.container.firstChild).toBeNull() + cleanup() + + const complete = render() + expect(complete.container.firstChild).toBeNull() + }) + + it('active goal: sparkle, "Ongoing Goal", truncated objective, edit and clear actions', () => { + const actions = makeActions() + render() + expect(screen.getByText('Ongoing Goal')).toBeTruthy() + expect(screen.getByText('Ship the redesign')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Clear goal' })) + expect(actions.onClear).toHaveBeenCalledTimes(1) + }) + + 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' })) + const box = screen.getByRole('textbox', { name: 'Goal objective' }) + expect(box).toHaveProperty('value', 'Ship the redesign') + + fireEvent.change(box, { target: { value: ' ' } }) + expect(screen.getByRole('button', { name: 'Save goal' })).toHaveProperty('disabled', true) + + fireEvent.change(box, { target: { value: 'Ship v2' } }) + fireEvent.keyDown(box, { key: 'Enter' }) + expect(actions.onEdit).toHaveBeenCalledWith('Ship v2') + await waitFor(() => { expect(screen.getByText('Ongoing Goal')).toBeTruthy() }) + }) + + it('Esc cancels the edit without calling onEdit', () => { + const actions = makeActions() + render() + fireEvent.click(screen.getByRole('button', { name: 'Edit goal' })) + fireEvent.keyDown(screen.getByRole('textbox', { name: 'Goal objective' }), { key: 'Escape' }) + expect(actions.onEdit).not.toHaveBeenCalled() + expect(screen.getByText('Ongoing Goal')).toBeTruthy() + }) + + it('the cancel button exits the form and drops the draft (re-edit starts from the objective)', () => { + const actions = makeActions() + render() + fireEvent.click(screen.getByRole('button', { name: 'Edit goal' })) + fireEvent.change(screen.getByRole('textbox', { name: 'Goal objective' }), { target: { value: 'abandoned draft' } }) + fireEvent.click(screen.getByRole('button', { name: 'Cancel edit' })) + expect(actions.onEdit).not.toHaveBeenCalled() + expect(screen.getByText('Ongoing Goal')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Edit goal' })) + expect(screen.getByRole('textbox', { name: 'Goal objective' })).toHaveProperty('value', 'Ship the redesign') + }) + + it('Enter with a blank draft neither saves nor closes the form', () => { + const actions = makeActions() + render() + fireEvent.click(screen.getByRole('button', { name: 'Edit goal' })) + const box = screen.getByRole('textbox', { name: 'Goal objective' }) + fireEvent.change(box, { target: { value: ' ' } }) + fireEvent.keyDown(box, { key: 'Enter' }) + expect(actions.onEdit).not.toHaveBeenCalled() + expect(screen.getByRole('textbox', { name: 'Goal objective' })).toBeTruthy() + }) + + it('paused goal: "Paused Goal" with a resume action before edit', () => { + const actions = makeActions() + render() + expect(screen.getByText('Paused Goal')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Resume goal' })) + expect(actions.onResume).toHaveBeenCalledTimes(1) + }) + + it('a new goal identity drops the edit form (no stale draft over the new goal)', () => { + const actions = makeActions() + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: 'Edit goal' })) + fireEvent.change(screen.getByRole('textbox', { name: 'Goal objective' }), { target: { value: 'stale draft' } }) + + rerender() + expect(screen.queryByRole('textbox')).toBeNull() + expect(screen.getByText('Ongoing Goal')).toBeTruthy() + expect(screen.getByText('New goal')).toBeTruthy() + + rerender() + expect(screen.queryByText('Ongoing Goal')).toBeNull() + }) + + it('blocked goal: "Blocked Goal" with the block reason as the strip tooltip', () => { + const actions = makeActions() + const goal = makeGoal({ phase: 'blocked', blockedReason: { code: 'stalled', message: 'No progress in 3 rounds' } }) + render() + expect(screen.getByText('Blocked Goal')).toBeTruthy() + expect(screen.getByText('Blocked Goal').closest('[title]')?.getAttribute('title')).toBe('No progress in 3 rounds') + }) + + it('blocked goal without a reason carries no tooltip', () => { + const actions = makeActions() + render() + 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' })).toHaveProperty('value', '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-goal/tsconfig.json b/packages/client/ui-goal/tsconfig.json new file mode 100644 index 0000000000..d1d008e92b --- /dev/null +++ b/packages/client/ui-goal/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../connection" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../goal/goal" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-goal/tsdown.config.ts b/packages/client/ui-goal/tsdown.config.ts new file mode 100644 index 0000000000..a01975f78a --- /dev/null +++ b/packages/client/ui-goal/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-goal', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 23b3bcb835..8f5c333778 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -680,3 +680,14 @@ export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => ( /> ) + +/** sparkle_16 (Others tool-row / goal strip leading glyph; hand-authored three-star + * approximation — the figma 43:31850 glyph is an SF Symbols "sparkles" text glyph, + * not extractable as vector data) */ +export const IconSparkle16 = ({ size = 16, className }: IconProps) => ( + + + + + +) diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index c06a2e52fc..487fbb696e 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -14,8 +14,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full P-I set (43 deepsuite + 13 figma extracts)', () => { - expect(iconNames.length).toBe(56) + it('exports the full P-I set (43 deepsuite + 13 figma extracts + the hand-authored sparkle)', () => { + expect(iconNames.length).toBe(57) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 2427ccfa5e..eaef4e6ad2 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -15,12 +15,21 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client.d.ts", + "default": "./lib/types/client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -28,6 +37,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-session-projection": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", @@ -36,10 +46,12 @@ "cordis": "^4.0.0-rc.7" }, "dependencies": { - "schemastery": "^3.17.2" + "schemastery": "^3.17.2", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/goal/goal/src/client.ts b/packages/goal/goal/src/client.ts new file mode 100644 index 0000000000..ffe3de29b7 --- /dev/null +++ b/packages/goal/goal/src/client.ts @@ -0,0 +1,10 @@ +/** + * Client-namespace projection of the goal domain: a pure re-export of the + * package's types outlet. Client code imports ONLY the client namespace + * (repo discipline), so `./client` projects the same single-source content + * `./types` serves to host consumers — zero duplication. + * + * @module @deepseek-ai/dsh-goal/client + */ + +export type * from './types.ts' diff --git a/packages/goal/goal/src/domain.ts b/packages/goal/goal/src/domain.ts new file mode 100644 index 0000000000..d7fb53e685 --- /dev/null +++ b/packages/goal/goal/src/domain.ts @@ -0,0 +1,137 @@ +/** + * Host-side vocabulary of the goal domain: live views, durable change + * payloads, message attribution, replay folds, and the scoped `goal/changed` + * event. Split from ./types.ts (the pure client-safe outlet) because these + * declarations pull dsh-agent, dsh-llm, and cordis into the program — the + * one-program-per-side layout forbids that on client aggregates. + * @module @deepseek-ai/dsh-goal + */ + +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { GoalId, GoalRef, GoalSnapshot } from './types.ts' + +/** Whether this live process may automatically continue an active goal. */ +export type GoalActivation = 'armed' | 'disarmed' + +/** Current goal projection, including values derived from the session log. */ +export interface GoalView extends GoalSnapshot { + /** Highest admitted round number for this goal. */ + readonly roundsStarted: number + /** Epoch milliseconds of the create mutation. */ + readonly createdAt: number + /** Epoch milliseconds of the latest mutation. */ + readonly updatedAt: number + /** Process-local continuation eligibility; never persisted. */ + readonly activation: GoalActivation +} + +/** Goal state-changing verbs recorded in the durable source change. */ +export type GoalOperation = + | 'create' + | 'edit' + | 'pause' + | 'resume' + | 'complete' + | 'block' + | 'clear' + +/** Full-snapshot goal mutation retained in a model-visible context event. */ +export interface GoalSnapshotChangeMeta { + readonly kind: 'goal/change' + readonly version: 1 + readonly operation: Exclude + readonly goal: GoalSnapshot + readonly roundsStarted: number + readonly createdAt: number + readonly updatedAt: number +} + +/** Tombstone retained when the current goal is cleared. */ +export interface GoalClearChangeMeta { + readonly kind: 'goal/change' + readonly version: 1 + readonly operation: 'clear' + readonly cleared: GoalRef + readonly clearedAt: number +} + +/** Durable change union carried by a goal-owned round-zero message source. */ +export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta + +/** Message attribution for durable goal state and continuation rounds. */ +export interface GoalMessageSource { + readonly kind: 'goal' + readonly goalId: GoalId + readonly revision: number + /** Zero for state changes; positive for admitted continuation rounds. */ + readonly round: number + /** Complete durable mutation carried only by round-zero state-change messages. */ + readonly change?: GoalChangeMeta +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + goal: GoalMessageSource + } +} + +/** Pure replay fold of durable goal facts. */ +export interface FoldedGoal { + /** Current goal, absent after a clear or before the first create. */ + readonly goal?: GoalSnapshot + /** Highest admitted round for the current goal. */ + readonly roundsStarted: number + /** Current goal creation time, absent without a current goal. */ + readonly createdAt?: number + /** Current goal mutation time, absent without a current goal. */ + readonly updatedAt?: number + /** Latest mutation ref, including a clear tombstone. */ + readonly lastRef?: GoalRef +} + +/** Input whose omitted round cap is resolved by the service configuration. */ +export interface CreateGoalRequest { + readonly objective: string + readonly maxGoalRounds?: number +} + +/** Fields changed by an edit; at least one must be present. */ +export interface EditGoalRequest { + readonly objective?: string + readonly maxGoalRounds?: number +} + +/** Live notification after one goal mutation has been accepted for logging. */ +export interface GoalChanged { + readonly operation: GoalOperation + readonly ref: GoalRef + /** Absent for a clear tombstone. */ + readonly goal?: GoalView +} + +/** Stable error codes for rejected goal reads and mutations. */ +export type GoalErrorCode = + | 'GOAL_AGENT_NOT_LIVE' + | 'GOAL_NOT_FOUND' + | 'GOAL_ALREADY_EXISTS' + | 'GOAL_STALE_REVISION' + | 'GOAL_INVALID_OBJECTIVE' + | 'GOAL_INVALID_MAX_ROUNDS' + | 'GOAL_INVALID_BLOCK_REASON' + | 'GOAL_INVALID_EDIT' + | 'GOAL_INVALID_TRANSITION' + +declare module 'cordis' { + interface Events { + /** + * Goal mutation accepted by one live agent. The matching context event is + * already appended or queued in that agent's active tool-batch FIFO. + * Listener failures are contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param agent - agent whose session owns the goal. + * @param change - fresh current projection or clear tombstone. + * @mode emit + */ + 'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, agent: Agent, change: GoalChanged): void + } +} diff --git a/packages/goal/goal/src/fold.ts b/packages/goal/goal/src/fold.ts index cfb8994748..2ea83029cf 100644 --- a/packages/goal/goal/src/fold.ts +++ b/packages/goal/goal/src/fold.ts @@ -4,18 +4,15 @@ import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { renderGoalChange } from './render.ts' import { GOAL_CHANGE_VERSION, GoalId } from './runtime.ts' +import type { GoalBlockReason, GoalPhase, GoalRef, GoalSnapshot } from './types.ts' import type { FoldedGoal, - GoalBlockReason, GoalChangeMeta, GoalClearChangeMeta, GoalMessageSource, GoalOperation, - GoalPhase, - GoalRef, - GoalSnapshot, GoalSnapshotChangeMeta, -} from './types.ts' +} from './domain.ts' type UserMessageEvent = Extract diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 7024b2f640..ff7d686a0c 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -7,10 +7,14 @@ import { randomUUID } from 'node:crypto' import { Context, Service } from 'cordis' import z from 'schemastery' +import { z as zod } from 'zod' +import type { ZodType } from 'zod' import { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { Session } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +// Type-only: resolves ctx.sessionProjections for the optional unit child. +import type {} from '@deepseek-ai/dsh-session-projection' import { applyGoalChange, applyGoalEvent, @@ -25,23 +29,31 @@ import { GoalError, GoalId, } from './runtime.ts' +import type { + GoalBlockReason, + GoalPhase, + GoalProjection, + GoalRef, + GoalSnapshot, +} from './types.ts' import type { CreateGoalRequest, EditGoalRequest, GoalActivation, - GoalBlockReason, GoalChangeMeta, GoalChanged, GoalClearChangeMeta, GoalOperation, - GoalPhase, - GoalRef, - GoalSnapshot, GoalSnapshotChangeMeta, GoalView, -} from './types.ts' +} from './domain.ts' -export * from './types.ts' +// The pure payload outlet (./types.ts, ONE home of the `goal` projection-key +// declaration) re-exported onto the package root keeps the module edge in +// the emitted index.d.ts, so aggregate programs consuming the declarations +// still receive the SessionProjectionMap merge. +export type * from './types.ts' +export type * from './domain.ts' export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts' export { decodeGoalChange, foldGoal, goalChangeRef } from './fold.ts' export { renderGoalChange } from './render.ts' @@ -52,6 +64,56 @@ declare module 'cordis' { } } +/** Wire payload schema of the `goal` projection (whole current goal or pre-create/cleared null). */ +const goalProjectionSchema: ZodType = zod.union([ + zod.object({ + goal: zod.object({ + id: zod.string().min(1), + revision: zod.number().int().positive(), + objective: zod.string().min(1), + phase: zod.union([zod.literal('active'), zod.literal('paused'), zod.literal('blocked'), zod.literal('complete')]), + blockedReason: zod.object({ code: zod.string(), message: zod.string() }).optional(), + maxGoalRounds: zod.number().int().positive(), + }), + roundsStarted: zod.number().int().nonnegative(), + createdAt: zod.number(), + updatedAt: zod.number(), + }), + zod.null(), +]) as ZodType + +/** + * Light last-wins fold of the `goal` projection unit. Unlike the strict + * replay fold (fold.ts: transition validation, fail-loud on malformed + * changes, Set-typed state), this transition is projection-grade: the state + * is plain JSON (persisted-cache precondition), any non-goal or malformed + * event returns the same reference (the registry's Object.is gate — the + * title/todos posture), and correctness of the written change is the write + * side's job (GoalService validated it before appending; the package + * invariant rejects a violating stream fail-loud where it is installed). + * @param state - the projection covering all prior events. + * @param event - the next committed session event. + * @returns the next projection (same reference when the event is not a goal change). + */ +export function applyGoalProjection(state: GoalProjection | null, event: SessionEvent): GoalProjection | null { + if (event.type !== 'user/message') return state + const source = event.data.source + if (source.kind !== 'goal' || source.round !== 0) return state + const change = source.change + // Session-log data is a durable boundary: the static type promises the kind, + // but a foreign or corrupted change record must degrade to same-reference, + // never feed the zod parse in the registry drive. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- durable-boundary guard + if (change === undefined || change.kind !== 'goal/change') return state + if (change.operation === 'clear') return null + return { + goal: change.goal, + roundsStarted: change.roundsStarted, + createdAt: change.createdAt, + updatedAt: change.updatedAt, + } +} + /** Deployment defaults for goal creation. */ export interface Config { /** Total rounds used when a create request omits its own cap. */ @@ -150,6 +212,19 @@ export class GoalService extends Service { ctx.on('agent/session-start', (agent) => { this.cache(agent.session).activation = 'disarmed' }) + // The `goal` projection unit: last-wins fold of goal/change whole values + // (see applyGoalProjection). The unit child activates only when a + // projection registry is composed (headless assemblies stay unaffected). + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register<'goal', GoalProjection | null>({ + key: 'goal', + schema: goalProjectionSchema, + init: () => null, + apply: applyGoalProjection, + view: state => state, + stateVersion: 1, + }) + }) } /** diff --git a/packages/goal/goal/src/render.ts b/packages/goal/goal/src/render.ts index d7348473db..c269276d25 100644 --- a/packages/goal/goal/src/render.ts +++ b/packages/goal/goal/src/render.ts @@ -1,7 +1,7 @@ /** Model-visible rendering for durable goal mutations. */ import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { GoalChangeMeta } from './types.ts' +import type { GoalChangeMeta } from './domain.ts' /** * Render a complete goal snapshot or clear tombstone without hidden prose. diff --git a/packages/goal/goal/src/runtime.ts b/packages/goal/goal/src/runtime.ts index 16a9abff72..7c10fa95cd 100644 --- a/packages/goal/goal/src/runtime.ts +++ b/packages/goal/goal/src/runtime.ts @@ -1,7 +1,8 @@ /** Runtime constructors and protocol constants for the goal domain. */ import { HarnessError } from '@deepseek-ai/dsh-llm' -import type { GoalErrorCode, GoalId as GoalIdType } from './types.ts' +import type { GoalId as GoalIdType } from './types.ts' +import type { GoalErrorCode } from './domain.ts' /** Version of the goal change embedded in a round-zero message source. */ export const GOAL_CHANGE_VERSION = 1 diff --git a/packages/goal/goal/src/types.ts b/packages/goal/goal/src/types.ts index c9cc941870..89d85ca5b6 100644 --- a/packages/goal/goal/src/types.ts +++ b/packages/goal/goal/src/types.ts @@ -1,10 +1,16 @@ /** - * Durable and live vocabulary for one same-session goal. + * Pure types of the goal domain: the ONE home of the `goal` projection-key + * declaration plus the durable payload vocabulary it carries, free of this + * package's host-side imports (cordis events, dsh-agent, dsh-llm, the + * service). Two namespace projections serve it — `./types` for host + * consumers, `./client` (the browser half-entry's re-export) for client + * aggregates — with zero content duplication. Host-coupled domain + * vocabulary (message sources, events, fold shapes) lives in ./domain.ts. + * * @module @deepseek-ai/dsh-goal/types */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { Agent } from '@deepseek-ai/dsh-agent' /** Identifies one goal across its durable revisions. */ export type GoalId = Branded<'GoalId'> @@ -44,128 +50,31 @@ export interface GoalSnapshot extends GoalRef { readonly maxGoalRounds: number } -/** Whether this live process may automatically continue an active goal. */ -export type GoalActivation = 'armed' | 'disarmed' - -/** Current goal projection, including values derived from the session log. */ -export interface GoalView extends GoalSnapshot { +/** + * The `goal` projection value: the current durable goal with its replay + * counters, exactly as the latest `goal/change` source carried them. + * Activation is process-local (never persisted) and deliberately absent — + * the projection reflects durable phase only. + */ +export interface GoalProjection { + /** Current durable goal snapshot (the CAS ref for mutations rides on it). */ + readonly goal: GoalSnapshot /** Highest admitted round number for this goal. */ readonly roundsStarted: number /** Epoch milliseconds of the create mutation. */ readonly createdAt: number /** Epoch milliseconds of the latest mutation. */ readonly updatedAt: number - /** Process-local continuation eligibility; never persisted. */ - readonly activation: GoalActivation } -/** Goal state-changing verbs recorded in the durable source change. */ -export type GoalOperation = - | 'create' - | 'edit' - | 'pause' - | 'resume' - | 'complete' - | 'block' - | 'clear' - -/** Full-snapshot goal mutation retained in a model-visible context event. */ -export interface GoalSnapshotChangeMeta { - readonly kind: 'goal/change' - readonly version: 1 - readonly operation: Exclude - readonly goal: GoalSnapshot - readonly roundsStarted: number - readonly createdAt: number - readonly updatedAt: number -} - -/** Tombstone retained when the current goal is cleared. */ -export interface GoalClearChangeMeta { - readonly kind: 'goal/change' - readonly version: 1 - readonly operation: 'clear' - readonly cleared: GoalRef - readonly clearedAt: number -} - -/** Durable change union carried by a goal-owned round-zero message source. */ -export type GoalChangeMeta = GoalSnapshotChangeMeta | GoalClearChangeMeta - -/** Message attribution for durable goal state and continuation rounds. */ -export interface GoalMessageSource { - readonly kind: 'goal' - readonly goalId: GoalId - readonly revision: number - /** Zero for state changes; positive for admitted continuation rounds. */ - readonly round: number - /** Complete durable mutation carried only by round-zero state-change messages. */ - readonly change?: GoalChangeMeta -} - -declare module '@deepseek-ai/dsh-llm' { - interface MessageSourceMap { - goal: GoalMessageSource - } -} - -/** Pure replay fold of durable goal facts. */ -export interface FoldedGoal { - /** Current goal, absent after a clear or before the first create. */ - readonly goal?: GoalSnapshot - /** Highest admitted round for the current goal. */ - readonly roundsStarted: number - /** Current goal creation time, absent without a current goal. */ - readonly createdAt?: number - /** Current goal mutation time, absent without a current goal. */ - readonly updatedAt?: number - /** Latest mutation ref, including a clear tombstone. */ - readonly lastRef?: GoalRef -} - -/** Input whose omitted round cap is resolved by the service configuration. */ -export interface CreateGoalRequest { - readonly objective: string - readonly maxGoalRounds?: number -} - -/** Fields changed by an edit; at least one must be present. */ -export interface EditGoalRequest { - readonly objective?: string - readonly maxGoalRounds?: number -} - -/** Live notification after one goal mutation has been accepted for logging. */ -export interface GoalChanged { - readonly operation: GoalOperation - readonly ref: GoalRef - /** Absent for a clear tombstone. */ - readonly goal?: GoalView -} - -/** Stable error codes for rejected goal reads and mutations. */ -export type GoalErrorCode = - | 'GOAL_AGENT_NOT_LIVE' - | 'GOAL_NOT_FOUND' - | 'GOAL_ALREADY_EXISTS' - | 'GOAL_STALE_REVISION' - | 'GOAL_INVALID_OBJECTIVE' - | 'GOAL_INVALID_MAX_ROUNDS' - | 'GOAL_INVALID_BLOCK_REASON' - | 'GOAL_INVALID_EDIT' - | 'GOAL_INVALID_TRANSITION' - -declare module 'cordis' { - interface Events { +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { /** - * Goal mutation accepted by one live agent. The matching context event is - * already appended or queued in that agent's active tool-batch FIFO. - * Listener failures are contained. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param agent - agent whose session owns the goal. - * @param change - fresh current projection or clear tombstone. - * @mode emit + * The session's current goal (the latest `goal/change` whole value), or + * `null` before the first create and after a clear tombstone. + * Whole-value rule: every goal change carries the complete post-change + * state, so the fold is last-wins. */ - 'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped, agent: Agent, change: GoalChanged): void + goal: GoalProjection | null } } diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts new file mode 100644 index 0000000000..75c01295ce --- /dev/null +++ b/packages/goal/goal/tests/projection.spec.ts @@ -0,0 +1,173 @@ +/** + * The `goal` projection unit: mounting GoalService beside the registry + * serves the current whole goal on the history tail page with a consistent + * asOfSeq; before the first create the value is null; a clear tombstone + * returns it to null; a composition without the goal service has no `goal` + * key; unmounting drops it (HMR safety). Malformed goal-shaped events are + * ignored fail-soft (same-reference return) — strict replay validation + * belongs to the write side and foldGoal, never the projection drive. + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { UserMessage } from '@deepseek-ai/dsh-session' +import SessionStore from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import GoalService, { applyGoalProjection } from '@deepseek-ai/dsh-goal' +import type { GoalRef } from '@deepseek-ai/dsh-goal' + +interface Bench { + ctx: Context + session: Session + agent: Agent + tailValues(): Record + tailAsOfSeq(): number +} + +/** Register a minimal registry-compatible live agent over a store session. */ +function liveAgent(ctx: Context, session: Session): Agent { + const status: AgentStatus = 'idle' + const agent: Agent = { + id: session.id, + options: {}, + session, + ctx, + get status() { return status }, + get acceptsNextStep() { return false }, + send: () => {}, + followup: () => {}, + steer: () => {}, + inject(input: UserMessage) { + session.append('user/message', input, { surfaceOp: 'append' }) + }, + cancel() {}, + whenIdle() { return Promise.resolve() }, + } + ctx.agents.register(agent) + return agent +} + +async function harness(withGoal: boolean): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) + if (withGoal) await ctx.plugin(GoalService) + const session = ctx.sessions.create() + const agent = liveAgent(ctx, session) + return { + ctx, + session, + agent, + tailValues: () => ctx.sessionProjections.snapshot(session).values, + tailAsOfSeq: () => ctx.sessionProjections.snapshot(session).asOfSeq, + } +} + +/** One paginable message so the tail is non-degenerate. */ +function seedMessage(session: Session): void { + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) +} + +describe('goal projection unit', () => { + it('serves null before the first create', async () => { + const bench = await harness(true) + seedMessage(bench.session) + expect(bench.tailValues()).toEqual({ goal: null }) + expect(bench.tailAsOfSeq()).toBe(bench.session.seq - 1) + }) + + it('serves the whole current goal after create and tracks mutations last-wins', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_700_000_000_000) + try { + const bench = await harness(true) + seedMessage(bench.session) + const created = bench.ctx.goals.create(bench.agent, { objective: 'ship the goal bar' }) + const afterCreate = bench.tailValues().goal + expect(afterCreate).toMatchObject({ + goal: { id: created.id, revision: 1, objective: 'ship the goal bar', phase: 'active' }, + roundsStarted: 0, + }) + + const ref: GoalRef = { id: created.id, revision: created.revision } + const paused = bench.ctx.goals.pause(bench.agent, ref) + expect(bench.tailValues().goal).toMatchObject({ + goal: { revision: paused.revision, phase: 'paused' }, + }) + expect(bench.tailAsOfSeq()).toBe(bench.session.seq - 1) + } finally { + vi.useRealTimers() + } + }) + + it('returns to null after a clear tombstone', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_700_000_000_000) + try { + const bench = await harness(true) + seedMessage(bench.session) + const created = bench.ctx.goals.create(bench.agent, { objective: 'temporary' }) + expect(bench.tailValues().goal).not.toBeNull() + bench.ctx.goals.clear(bench.agent, { id: created.id, revision: created.revision }) + expect(bench.tailValues().goal).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('ignores non-goal and malformed goal-shaped events fail-soft (same reference)', () => { + // The package invariant rejects a violating stream loudly wherever it is + // installed — the unit itself must never throw on the projection drive + // (a throwing apply would tear down every registered unit's drive), so + // its transition is exercised directly as the pure function it is. + const user = { type: 'user/message', seq: 0, time: 1, data: createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'user' }, + }) } as never + expect(applyGoalProjection(null, user)).toBeNull() + + const malformed = { type: 'user/message', seq: 1, time: 2, data: createUserMessage({ + content: [{ type: 'text', text: 'broken' }], + source: { kind: 'goal', goalId: 'g-broken', revision: 1, round: 0 } as never, + }) } as never + const state = { goal: { id: 'g1', revision: 1, objective: 'x', phase: 'active', maxGoalRounds: 4 }, roundsStarted: 0, createdAt: 1, updatedAt: 1 } as never + // Same-reference return: the registry's Object.is gate sees no change. + expect(applyGoalProjection(state, malformed)).toBe(state) + expect(applyGoalProjection(null, malformed)).toBeNull() + + // A non-message event (the registry drives EVERY committed event through + // apply): early same-reference return. + const turnStart = { type: 'turn/start', seq: 3, time: 4, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never + expect(applyGoalProjection(state, turnStart)).toBe(state) + + // A round-zero goal source whose change carries a foreign kind: same posture. + const foreignKind = { type: 'user/message', seq: 2, time: 3, data: createUserMessage({ + content: [{ type: 'text', text: 'foreign' }], + source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0, change: { kind: 'not-a-goal-change' } } as never, + }) } as never + expect(applyGoalProjection(state, foreignKind)).toBe(state) + }) + + it('has no goal key when the goal service is not composed', async () => { + const bench = await harness(false) + seedMessage(bench.session) + expect('goal' in (bench.tailValues() ?? {})).toBe(false) + }) + + it('drops the key when the goal fiber unloads (HMR safety)', async () => { + const bench = await harness(false) + seedMessage(bench.session) + const fiber = await bench.ctx.plugin(GoalService) + expect(bench.tailValues()).toEqual({ goal: null }) + await fiber.dispose() + expect('goal' in (bench.tailValues() ?? {})).toBe(false) + }) +}) diff --git a/packages/goal/goal/tsconfig.json b/packages/goal/goal/tsconfig.json index a06b59ed0e..9663f894fe 100644 --- a/packages/goal/goal/tsconfig.json +++ b/packages/goal/goal/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../core/agent" }, + { + "path": "../../session-projection/session-projection" + }, { "path": "../../support/invariants" } diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index a3e0cfac5d..0db9568f7b 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 1bf1645202..56d4df78a3 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -24,7 +24,7 @@ import { // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' import type { - ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning, + ApiProxy, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSummary, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' @@ -32,6 +32,9 @@ import type { import type {} from '@deepseek-ai/dsh-session-projection' // Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column). import type {} from '@deepseek-ai/dsh-session-projection-cache' +// GoalError narrows domain rejections to their stable codes at the wire boundary. +import { GoalError } from '@deepseek-ai/dsh-goal' +import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal' // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-skill' @@ -681,6 +684,38 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return operation } + /** Resolve the goal service; absent = the deployment did not compose @deepseek-ai/dsh-goal. */ + function goalService(): NonNullable>> | { error: RpcError } { + const goals = ctx.get('goals') + if (goals === undefined) { + return { error: { code: 'internal', message: 'goal service is absent: this deployment does not mount @deepseek-ai/dsh-goal in its composition (cordis.yml or explicit assembly)', details: {} } } + } + return goals + } + + /** Map one goal-domain rejection to the wire error (stable GoalError codes ride in details). */ + function goalError(request: RpcRequest, error: unknown): RpcResponse { + const details = error instanceof GoalError ? { goalCode: error.code } : {} + return err(request, { code: 'internal', message: String(error), details }) + } + + /** Resolve a session's agent, apply one goal mutation, and acknowledge with the new CAS ref. */ + async function mutateGoal( + request: RpcRequest<{ sessionId: SessionId }>, + mutation: (goals: NonNullable>>, agent: Agent) => CoreGoalRef, + ): Promise> { + const goals = goalService() + if ('error' in goals) return err(request, goals.error) + const found = await agentFor(request.payload.sessionId) + if ('error' in found) return err(request, found.error) + try { + const ref = mutation(goals, found.agent) + return ok(request, { ref: { id: ref.id, revision: ref.revision } }) + } catch (error: unknown) { + return goalError(request, error) + } + } + return { sessions: { // Attached sessions summarize from memory; persisted-but-unattached (cold) @@ -1138,6 +1173,54 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, + goals: { + // Mutations only — the read side is the 'goal' session projection. + // Every verb resolves the session's agent (agentFor: implicit cold + // resume, the command.* precedent) and acknowledges with the new CAS + // ref; the committed goal/change event carries the whole value to every + // client through the projection frames. + async create(request) { + const { objective, maxGoalRounds } = request.payload + return mutateGoal(request, (goals, agent) => goals.create(agent, { + objective, + ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}), + })) + }, + + async edit(request) { + const { ref, objective, maxGoalRounds } = request.payload + return mutateGoal(request, (goals, agent) => goals.edit(agent, ref, { + ...(objective !== undefined ? { objective } : {}), + ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}), + })) + }, + + async pause(request) { + return mutateGoal(request, (goals, agent) => goals.pause(agent, request.payload.ref)) + }, + + async resume(request) { + return mutateGoal(request, (goals, agent) => goals.resume(agent, request.payload.ref)) + }, + + async complete(request) { + return mutateGoal(request, (goals, agent) => goals.complete(agent, request.payload.ref)) + }, + + async clear(request) { + const goals = goalService() + if ('error' in goals) return err(request, goals.error) + const found = await agentFor(request.payload.sessionId) + if ('error' in found) return err(request, found.error) + try { + goals.clear(found.agent, request.payload.ref) + return ok(request, { cleared: true as const }) + } catch (error: unknown) { + return goalError(request, error) + } + }, + }, + skills: { // Skill lookup never touches the Agent registry: the session address // resolves to a canonical cwd from the host-resident session header, so diff --git a/packages/host/apiproxy/src/api/goals.schema.ts b/packages/host/apiproxy/src/api/goals.schema.ts new file mode 100644 index 0000000000..24615502c3 --- /dev/null +++ b/packages/host/apiproxy/src/api/goals.schema.ts @@ -0,0 +1,79 @@ +/** + * goals domain zod schemas. Mutation-only shapes: every value schema is a + * `{ ref }` acknowledgement (clear: `{ cleared }`) — the current goal state + * travels exclusively on the 'goal' session projection. + */ + +import { z } from 'zod' +import type { Wire } from './rpc.schema.ts' +import type { GoalRef, RequestPayload, ResponseValue } from './index.ts' + +/** GoalRef schema. */ +export const goalRefSchema = z.object({ + id: z.string(), + revision: z.number().int().positive(), +}) as unknown as z.ZodType> + +/** Shared `{ ref }` acknowledgement value of every non-clear mutation. */ +const goalRefValueSchema = z.object({ ref: goalRefSchema }) + +/** goal.create request payload. */ +export const goalCreateRequestSchema = z.object({ + sessionId: z.string(), + objective: z.string().min(1), + maxGoalRounds: z.number().int().positive().optional(), +}) as unknown as z.ZodType>> + +/** goal.create response value. */ +export const goalCreateValueSchema = goalRefValueSchema as unknown as z.ZodType>> + +/** goal.edit request payload. */ +export const goalEditRequestSchema = z.object({ + sessionId: z.string(), + 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. */ +export const goalEditValueSchema = goalRefValueSchema as unknown as z.ZodType>> + +/** goal.pause request payload. */ +export const goalPauseRequestSchema = z.object({ + sessionId: z.string(), + ref: goalRefSchema, +}) as unknown as z.ZodType>> + +/** goal.pause response value. */ +export const goalPauseValueSchema = goalRefValueSchema as unknown as z.ZodType>> + +/** goal.resume request payload. */ +export const goalResumeRequestSchema = z.object({ + sessionId: z.string(), + ref: goalRefSchema, +}) as unknown as z.ZodType>> + +/** goal.resume response value. */ +export const goalResumeValueSchema = goalRefValueSchema as unknown as z.ZodType>> + +/** goal.complete request payload. */ +export const goalCompleteRequestSchema = z.object({ + sessionId: z.string(), + ref: goalRefSchema, +}) as unknown as z.ZodType>> + +/** goal.complete response value. */ +export const goalCompleteValueSchema = goalRefValueSchema as unknown as z.ZodType>> + +/** goal.clear request payload. */ +export const goalClearRequestSchema = z.object({ + sessionId: z.string(), + ref: goalRefSchema, +}) as unknown as z.ZodType>> + +/** goal.clear response value. */ +export const goalClearValueSchema = z.object({ + cleared: z.literal(true), +}) as unknown as z.ZodType>> diff --git a/packages/host/apiproxy/src/api/goals.ts b/packages/host/apiproxy/src/api/goals.ts new file mode 100644 index 0000000000..c585eacdf5 --- /dev/null +++ b/packages/host/apiproxy/src/api/goals.ts @@ -0,0 +1,50 @@ +/** + * goals domain contract. Method signatures are the source of truth: + * unary methods take the RpcRequest

narrow form and the impl echoes rpcId. + * + * Mutations only: the read side is the 'goal' session projection (history + * tail-page projections block + session/projection frames), so there is no + * goal.get and no wire goal view — responses acknowledge with the new CAS + * ref and never feed client state (the committed goal/change event reaches + * every client through the mux stream carrying the same whole value). + */ + +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. */ +export type GoalId = Branded<'GoalId'> + +/** Compare-and-set identity for one exact goal revision. */ +export interface GoalRef { + readonly id: GoalId + readonly revision: number +} + +/** Goal-domain unary methods (every mutation resolves the session's agent and applies one CAS-guarded verb). */ +export interface GoalsApi { + /** Create and arm a goal. */ + create(request: RpcRequest<{ sessionId: SessionId; objective: string; maxGoalRounds?: number }>): + Promise> + + /** Edit objective and/or round cap without changing phase. */ + edit(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef; objective?: string; maxGoalRounds?: number }>): + Promise> + + /** Pause an active goal and disarm automatic continuation. */ + pause(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>): + Promise> + + /** Resume and arm a stopped goal. */ + resume(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>): + Promise> + + /** Mark a current non-complete goal complete and disarm it. */ + complete(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>): + Promise> + + /** Clear the current goal while retaining a durable tombstone and history. */ + clear(request: RpcRequest<{ sessionId: SessionId; ref: GoalRef }>): + Promise> +} diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 5f27f121c4..451655d114 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -10,6 +10,7 @@ import type { WorkspaceApi } from './workspace.ts' import type { CommandsApi } from './commands.ts' import type { SkillsApi } from './skills.ts' import type { EventsApi } from './events.ts' +import type { GoalsApi } from './goals.ts' import type { ClientResponse, RpcReceipt } from './rpc.ts' /** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */ @@ -20,6 +21,7 @@ export interface ApiProxy { commands: CommandsApi skills: SkillsApi events: EventsApi + goals: GoalsApi /** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */ respond(message: ClientResponse): Promise } @@ -34,6 +36,7 @@ export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts' +export type { GoalsApi, GoalId, GoalRef } from './goals.ts' export type { ApprovalResponsePayload } from './approvals.ts' export type { QuestionResponsePayload } from './questions.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 7beabd2696..bedd6f4b1f 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -9,6 +9,7 @@ import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' import type { CommandsApi } from './commands.ts' import type { SkillsApi } from './skills.ts' +import type { GoalsApi } from './goals.ts' import type { RpcResponse } from './rpc.ts' /** @@ -35,6 +36,12 @@ export interface RpcMethodMap { 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] + 'goal.create': GoalsApi['create'] + 'goal.edit': GoalsApi['edit'] + 'goal.pause': GoalsApi['pause'] + 'goal.resume': GoalsApi['resume'] + 'goal.complete': GoalsApi['complete'] + 'goal.clear': GoalsApi['clear'] } /** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */ diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 300cc210b6..1e13645eae 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -43,6 +43,8 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }), z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), + z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }), + z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 52b2f503dd..ce6b8186d3 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -40,6 +40,10 @@ export interface RpcErrorDetailsMap { 'workspace-name-conflict': { name: string } 'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId } 'agent-busy': { reason: string } + /** A known slash command reported a usage/state error; the message is the command's own text. */ + 'command-error': {} + /** A leading-/ prompt named no registered command; the message names the token. */ + 'unknown-command': {} 'internal': {} } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 21feaaf604..81c42b8a56 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -193,9 +193,13 @@ export const sessionPromptRequestSchema = z.object({ content: z.array(contentBlockSchema), }) as unknown as z.ZodType> -/** session.prompt response value. */ +/** session.prompt response value (the command slot appears only when the prompt dispatched a slash command). */ export const sessionPromptValueSchema = z.object({ accepted: z.literal(true), + command: z.object({ + kind: z.literal('success'), + text: z.string().optional(), + }).optional(), }) satisfies z.ZodType>> /** session.cancel request payload. */ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index d1be7af4de..cd112c6546 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -208,9 +208,16 @@ export interface SessionsApi { }>): Promise> - /** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */ + /** + * Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. + * A prompt whose content is exactly one text block starting with '/' is a slash command: the host + * executes it through the command registry (mode-agnostic) and it is never sent to the model. A + * successful command returns ok with the command slot (its success text, when the command produced + * one — carried for future rendering; the state change is the feedback). A usage/state error is an + * RPC error with code command-error; an unrecognized name is an RPC error with code unknown-command. + */ prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>): - Promise> + Promise> /** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */ cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise> diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index ceca9fee7e..fab8166c3f 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -34,6 +34,14 @@ import { } from '../api/workspace.schema.ts' import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' +import { + goalCreateValueSchema, + goalEditValueSchema, + goalPauseValueSchema, + goalResumeValueSchema, + goalCompleteValueSchema, + goalClearValueSchema, +} from '../api/goals.schema.ts' /** * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary @@ -83,6 +91,14 @@ export interface IApiClient { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> host(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> } + goals: { + create(payload: RequestPayload<'goal.create'>, signal?: AbortSignal): Promise>> + edit(payload: RequestPayload<'goal.edit'>, signal?: AbortSignal): Promise>> + pause(payload: RequestPayload<'goal.pause'>, signal?: AbortSignal): Promise>> + resume(payload: RequestPayload<'goal.resume'>, signal?: AbortSignal): Promise>> + complete(payload: RequestPayload<'goal.complete'>, signal?: AbortSignal): Promise>> + clear(payload: RequestPayload<'goal.clear'>, signal?: AbortSignal): Promise>> + } /** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */ respond(message: ClientResponse, signal?: AbortSignal): Promise } @@ -110,6 +126,12 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('skill.list', payload, signal), } + readonly goals: IApiClient['goals'] = { + create: (payload, signal) => this.callUnary('goal.create', payload, signal), + edit: (payload, signal) => this.callUnary('goal.edit', payload, signal), + pause: (payload, signal) => this.callUnary('goal.pause', payload, signal), + resume: (payload, signal) => this.callUnary('goal.resume', payload, signal), + complete: (payload, signal) => this.callUnary('goal.complete', payload, signal), + clear: (payload, signal) => this.callUnary('goal.clear', payload, signal), + } + readonly events: IApiClient['events'] = { mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen), host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen), diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 505239c084..31ed3a8dea 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -35,6 +35,14 @@ import { } from '../api/workspace.schema.ts' import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' +import { + goalCreateRequestSchema, + goalEditRequestSchema, + goalPauseRequestSchema, + goalResumeRequestSchema, + goalCompleteRequestSchema, + goalClearRequestSchema, +} from '../api/goals.schema.ts' /** * Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a @@ -71,6 +79,12 @@ const UNARY_ROUTES: UnaryRoutes = { 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, + 'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) }, + 'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) }, + 'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) }, + 'goal.resume': { schema: goalResumeRequestSchema, invoke: (api, r) => api.goals.resume(r) }, + 'goal.complete': { schema: goalCompleteRequestSchema, invoke: (api, r) => api.goals.complete(r) }, + 'goal.clear': { schema: goalClearRequestSchema, invoke: (api, r) => api.goals.clear(r) }, } /** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */ diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index e7ef6c7332..c9a4e3afb9 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -57,6 +57,7 @@ export class ApiProxyService extends Service implements ApiProxy { readonly workspace: ApiProxy['workspace'] readonly host: ApiProxy['host'] readonly commands: ApiProxy['commands'] + readonly goals: ApiProxy['goals'] readonly skills: ApiProxy['skills'] readonly events: ApiProxy['events'] readonly respond: ApiProxy['respond'] @@ -74,6 +75,7 @@ export class ApiProxyService extends Service implements ApiProxy { this.workspace = api.workspace this.host = api.host this.commands = api.commands + this.goals = api.goals this.skills = api.skills this.events = api.events // createApiProxy returns closures (no `this` capture); bind only satisfies diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 2794aa13d2..3f2ef875f9 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-session' -import type { ApiProxy, HostFrame, MuxFrame, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy' +import type { ApiProxy, GoalRef, HostFrame, MuxFrame, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy' import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' const sid = (id: string): SessionId => id as SessionId @@ -23,9 +23,12 @@ function scriptedApi(overrides: { commands?: Partial skills?: Partial events?: Partial + goals?: Partial respond?: ApiProxy['respond'] } = {}): ApiProxy { async function *empty(): AsyncGenerator> { /* no frames */ } + const err = (r: RpcRequest): Promise> => + Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'internal' as const, message: 'stub', details: {} } } }) return { sessions: { list: r => ok(r, { items: [] }), @@ -66,6 +69,15 @@ function scriptedApi(overrides: { ...overrides.commands, }, skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, + goals: { + create: err, + edit: err, + pause: err, + resume: err, + complete: err, + clear: err, + ...overrides.goals, + }, events: { mux: () => empty(), host: () => empty(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), } @@ -405,6 +417,67 @@ describe('SSE stream path', () => { }) }) +describe('goals unary surface', () => { + const ref: GoalRef = { id: 'goal-1' as GoalRef['id'], revision: 1 } + /** The `{ ref }` acknowledgement every non-clear mutation answers (state travels on the projection). */ + const ack = { ref: { id: 'goal-1' as GoalRef['id'], revision: 2 } } + + it('round-trips every goal method with its own payload and value shape', async () => { + const seen: { method: string; payload: unknown }[] = [] + const record = (method: string, respond: (r: RpcRequest

) => Promise>) => + (r: RpcRequest

): Promise> => { + seen.push({ method, payload: r.payload }) + return respond(r) + } + const api = scriptedApi({ + goals: { + create: record('goal.create', r => ok(r, ack)), + edit: record('goal.edit', r => ok(r, { ref: { ...ack.ref, revision: 3 } })), + pause: record('goal.pause', r => ok(r, ack)), + resume: record('goal.resume', r => ok(r, ack)), + complete: record('goal.complete', r => ok(r, ack)), + clear: record('goal.clear', r => ok(r, { cleared: true as const })), + }, + }) + const c = client(api) + + const created = await c.goals.create({ sessionId: sid('s1'), objective: 'ship it', maxGoalRounds: 4 }) + expect(created.result).toEqual({ ok: true, value: ack }) + const edited = await c.goals.edit({ sessionId: sid('s1'), ref, objective: 'ship v2' }) + expect(edited.result).toEqual({ ok: true, value: { ref: { ...ack.ref, revision: 3 } } }) + expect((await c.goals.pause({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack }) + expect((await c.goals.resume({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack }) + expect((await c.goals.complete({ sessionId: sid('s1'), ref })).result).toEqual({ ok: true, value: ack }) + const cleared = await c.goals.clear({ sessionId: sid('s1'), ref }) + expect(cleared.result).toEqual({ ok: true, value: { cleared: true } }) + + // The handler dispatched each call through its own route row: payload parsed per method. + expect(seen.map(s => s.method)).toEqual(['goal.create', 'goal.edit', 'goal.pause', 'goal.resume', 'goal.complete', 'goal.clear']) + expect(seen[0]?.payload).toEqual({ sessionId: 's1', objective: 'ship it', maxGoalRounds: 4 }) + expect(seen[1]?.payload).toEqual({ sessionId: 's1', ref, objective: 'ship v2' }) + }) + + it('passes business errors through as results, not throws', async () => { + // Default scripted goals impl answers an err result: it must arrive as a result, not a throw. + const failed = await client(scriptedApi()).goals.pause({ sessionId: sid('s1'), ref }) + expect(failed.result.ok).toBe(false) + if (!failed.result.ok) expect(failed.result.error.code).toBe('internal') + }) + + it('rejects an invalid goal payload at the handler as bad-request', async () => { + 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, ack) } } }) + 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) + }) +}) + describe('respond path', () => { it('round-trips a client-response to a receipt', async () => { const seen: unknown[] = [] diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 6c62501c60..3d5fac2a5a 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -135,6 +135,26 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } } }, }, + goals: { + async create(request) { + return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } } + }, + async edit(request) { + return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } } + }, + async pause(request) { + return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } } + }, + async resume(request) { + return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } } + }, + async complete(request) { + return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } } + }, + async clear(request) { + return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } } + }, + }, events: { mux: (_request, signal) => stream(muxFrames, signal), host: (_request, signal) => stream(hostFrames, signal), diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 07c59a12a8..783acc7056 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -28,6 +28,7 @@ import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from ' 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', () => { @@ -63,11 +64,14 @@ describe('rpcErrorSchema', () => { details: { provider: 'p', model: 'm' }, }).code).toBe('model-unavailable') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') + expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') + expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) it('rejects a known code with missing details', () => { expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow() + expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow() }) }) @@ -205,6 +209,11 @@ describe('sessions domain schemas', () => { expect(prompt.mode).toBe('queue') expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow() expect(sessionPromptValueSchema.parse({ accepted: true }).accepted).toBe(true) + // The command slot appears only when the prompt dispatched a slash command. + const dispatched = sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success', text: 'Goal set' } }) + expect(dispatched.command?.text).toBe('Goal set') + expect(sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success' } }).command).toEqual({ kind: 'success' }) + expect(() => sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'failure' } })).toThrow() expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true) expect(contentBlockSchema.parse({ type: 'text', text: 'x', extra: 1 })).toMatchObject({ extra: 1 }) @@ -312,6 +321,15 @@ describe('skills 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/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index fbe8e77719..26a0af3636 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../goal/goal" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/support/llm-mock-server/README.i18n.yaml b/packages/support/llm-mock-server/README.i18n.yaml index 0e14fec455..8806042fae 100644 --- a/packages/support/llm-mock-server/README.i18n.yaml +++ b/packages/support/llm-mock-server/README.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 -README.md: 77a5c5e35fe0b4b1c92968eecea85b6059c889fc -README.zh.md: bf84a1c5f5428e845a6917a82287733d82405144 +# pnpm run verify-translation-pairing --write packages/support/llm-mock-server/README.md +README.md: a535c086bf688ad48b1a3bb19c7b81da21cbad92 +README.zh.md: e013cc47399da7fdde42c10dfe086ffab9105785 diff --git a/packages/support/llm-mock-server/README.md b/packages/support/llm-mock-server/README.md index 77a5c5e35f..a535c086bf 100644 --- a/packages/support/llm-mock-server/README.md +++ b/packages/support/llm-mock-server/README.md @@ -26,7 +26,7 @@ DEEPSEEK_API_KEY=mock-key \ pnpm run demo:headless "test provider recovery" ``` -The built package also exposes `dsh-llm-mock-server`. Stdout is JSONL: a `ready` record carries the `/v1` base URL and random seed, followed by request/result records that name both the scripted behavior and the concrete selected behavior. +The repository script writes JSONL to stdout: a `ready` record carries the `/v1` base URL and random seed, followed by request/result records that name both the scripted behavior and the concrete selected behavior. The private support package exposes no installable binary. ## Behavior script diff --git a/packages/support/llm-mock-server/README.zh.md b/packages/support/llm-mock-server/README.zh.md index bf84a1c5f5..e013cc4739 100644 --- a/packages/support/llm-mock-server/README.zh.md +++ b/packages/support/llm-mock-server/README.zh.md @@ -26,7 +26,7 @@ DEEPSEEK_API_KEY=mock-key \ pnpm run demo:headless "test provider recovery" ``` -构建包还公开 `dsh-llm-mock-server`。Stdout 是 JSONL:`ready` 记录携带 `/v1` base URL 和随机种子,后续请求/结果记录同时命名脚本行为和实际选中的具体行为。 +仓库脚本将 JSONL 写入 stdout:`ready` 记录携带 `/v1` base URL 和随机种子,后续请求/结果记录同时命名脚本行为和实际选中的具体行为。这个私有支持包(package)不公开可安装的二进制命令。 ## 行为脚本 diff --git a/packages/support/llm-mock-server/package.json b/packages/support/llm-mock-server/package.json index 790365407b..4b14cb6d0b 100644 --- a/packages/support/llm-mock-server/package.json +++ b/packages/support/llm-mock-server/package.json @@ -6,9 +6,6 @@ "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", - "bin": { - "dsh-llm-mock-server": "lib/bin.js" - }, "exports": { ".": { "types": "./lib/types/index.d.ts", @@ -18,17 +15,12 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, - "./bin": { - "types": "./lib/types/bin.d.ts", - "default": "./lib/bin.js" - }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", - "lib/bin.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" diff --git a/packages/support/llm-mock-server/src/bin.ts b/packages/support/llm-mock-server/src/bin.ts index e77de74dad..1db33b7f6e 100644 --- a/packages/support/llm-mock-server/src/bin.ts +++ b/packages/support/llm-mock-server/src/bin.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * Standalone process wrapper for the scriptable mock LLM server. - * @module @deepseek-ai/dsh-llm-mock-server/bin + * @module @deepseek-ai/dsh-llm-mock-server/src/bin */ import { setTimeout as delay } from 'node:timers/promises' diff --git a/packages/support/llm-mock-server/tsdown.config.ts b/packages/support/llm-mock-server/tsdown.config.ts index 3dcb19efab..8d0f040df6 100644 --- a/packages/support/llm-mock-server/tsdown.config.ts +++ b/packages/support/llm-mock-server/tsdown.config.ts @@ -10,8 +10,4 @@ export default defineConfig([ entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, }, - { - entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', - fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, - }, ]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90c38fe1a7..ed70444992 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../../packages/client/ui-conversation + '@deepseek-ai/dsh-client-ui-goal': + specifier: workspace:^ + version: link:../../packages/client/ui-goal '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../../packages/client/ui-layout @@ -197,6 +200,9 @@ importers: '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:^ version: link:../../packages/code-runtime/code-runtime-worker + '@deepseek-ai/dsh-command-goal': + specifier: workspace:^ + version: link:../../packages/goal/command-goal '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../packages/ui/commands @@ -212,6 +218,12 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../packages/fs/fs-policy + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../packages/goal/goal + '@deepseek-ai/dsh-goal-session': + specifier: workspace:^ + version: link:../../packages/goal/goal-session '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy @@ -1047,6 +1059,45 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-goal: + devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-layout: devDependencies: '@deepseek-ai/dsh-client-locale': @@ -2507,6 +2558,9 @@ importers: schemastery: specifier: ^3.17.2 version: 3.18.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2529,6 +2583,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) @@ -2758,6 +2815,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../ui/commands + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index b5fc6e3627..5821cd0a01 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -179,37 +179,37 @@ { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalView", - "source": "packages/goal/goal/src/types.ts" + "source": "packages/goal/goal/src/domain.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalSnapshotChangeMeta", - "source": "packages/goal/goal/src/types.ts" + "source": "packages/goal/goal/src/domain.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalClearChangeMeta", - "source": "packages/goal/goal/src/types.ts" + "source": "packages/goal/goal/src/domain.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalMessageSource", - "source": "packages/goal/goal/src/types.ts" + "source": "packages/goal/goal/src/domain.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "CreateGoalRequest", - "source": "packages/goal/goal/src/types.ts" + "source": "packages/goal/goal/src/domain.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "EditGoalRequest", - "source": "packages/goal/goal/src/types.ts" + "source": "packages/goal/goal/src/domain.ts" }, { "doc": "docs/core-data-structures/goal.md", "symbol": "GoalChanged", - "source": "packages/goal/goal/src/types.ts" + "source": "packages/goal/goal/src/domain.ts" }, { "doc": "docs/core-data-structures/commands.md", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index ca2eae115a..023ba64522 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -59,6 +59,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, 'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the host snapshots the target at the next prompt-assembly boundary and owns the model-visible effect.' }, + 'packages/client/ui-goal': { kind: 'indirect', reason: 'The strip verbs route goal.* mutations; the host GoalService owns the model-visible goal/change context message.' }, 'packages/client/ui-plan': { kind: 'indirect', reason: 'The chip dispatches /plan off; dsh-plan-mode owns the model-visible policy, exit tool, and logged state.' }, 'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' }, 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index da239b1e84..12f96ba81d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -48,6 +48,8 @@ "@deepseek-ai/dsh-session-title/client": ["./packages/session-title/session-title/src/client.ts"], "@deepseek-ai/dsh-plan-mode/types": ["./packages/plan/plan-mode/src/types.ts"], "@deepseek-ai/dsh-plan-mode/client": ["./packages/plan/plan-mode/src/client.ts"], + "@deepseek-ai/dsh-goal/types": ["./packages/goal/goal/src/types.ts"], + "@deepseek-ai/dsh-goal/client": ["./packages/goal/goal/src/client.ts"], "@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"], "@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"], "@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 4b53a08a71..f4063d52a6 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -43,6 +43,7 @@ { "path": "./packages/client/ui-command" }, { "path": "./packages/client/ui-skill" }, { "path": "./packages/client/ui-subagent" }, + { "path": "./packages/client/ui-goal" }, { "path": "./packages/client/ui-model" }, { "path": "./packages/client/ui-plan" }, { "path": "./packages/client/ui-question" },