From 923535fa7a3a33aafdf79eb671914e26b1e4664d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 22 Jul 2026 20:51:44 +0800 Subject: [PATCH] feat(gui): goal wire domain, client goal state, and docked goal bar - apiproxy goals RPC domain (get/create/edit/pause/resume/complete/clear) with CAS refs, zod schemas, and fetch client/handler wiring - client runtime session goal state: live goal/change meta triggers a coalesced refetch; mutations fold transport errors into RpcResult - web GoalBar: docked strip above the composer (sparkle, phase label, truncated objective, inline edit, clear; resume when paused); creation stays on the /goal command - GoalBarActions in the ui-conversation contract layer; IconSparkle16 moves to ui-primitives icons --- .../2026-07-22-docked-web-goal-bar.i18n.yaml | 6 + .../feature/2026-07-22-docked-web-goal-bar.md | 39 +++ .../2026-07-22-docked-web-goal-bar.zh.md | 39 +++ packages/client/connection/src/client/api.ts | 1 + .../client/connection/src/client/fixture.ts | 16 ++ .../client/connection/src/client/index.ts | 1 + packages/client/connection/tests/fake-api.ts | 10 + packages/client/runtime/src/client/index.ts | 1 + .../src/client/sessions/conversation.ts | 5 +- .../runtime/src/client/sessions/session.ts | 193 ++++++++++++++- packages/client/runtime/tests/fake-api.ts | 10 + packages/client/runtime/tests/session.spec.ts | 231 ++++++++++++++++++ .../ui-conversation/src/client/apply.ts | 7 +- .../src/client/chat/GenericToolCard.tsx | 3 +- .../src/client/chat/IconSparkle16.tsx | 15 -- .../src/client/contract/slots.ts | 12 + .../ui-conversation/src/client/index.ts | 2 +- .../src/client/skeleton/ConversationRoot.tsx | 10 +- .../src/client/skeleton/GoalBar.module.css | 109 +++++++++ .../src/client/skeleton/GoalBar.tsx | 122 +++++++++ .../tests/apply-inject.spec.tsx | 2 +- .../tests/chat-stats-bash-sample.spec.tsx | 1 + .../ui-conversation/tests/chat-view.spec.tsx | 1 + .../tests/gate-branch-tails.spec.tsx | 2 +- .../ui-conversation/tests/goalbar.spec.tsx | 116 +++++++++ .../tests/skeleton-branches.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 86 +++++++ .../client/ui-primitives/src/icons/index.tsx | 11 + .../client/ui-primitives/tests/icons.spec.tsx | 4 +- packages/goal/goal-session/src/index.ts | 5 + packages/goal/goal/src/index.ts | 14 +- .../host/apiproxy/src/api/goals.schema.ts | 112 +++++++++ packages/host/apiproxy/src/api/goals.ts | 88 +++++++ packages/host/apiproxy/src/api/index.ts | 3 + packages/host/apiproxy/src/api/rpc-map.ts | 8 + packages/host/apiproxy/src/fetch/client.ts | 35 +++ packages/host/apiproxy/src/fetch/handler.ts | 16 ++ .../apiproxy/tests/client-handler.spec.ts | 13 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 23 ++ pnpm-lock.yaml | 12 + 40 files changed, 1358 insertions(+), 28 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md create mode 100644 .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md delete mode 100644 packages/client/ui-conversation/src/client/chat/IconSparkle16.tsx create mode 100644 packages/client/ui-conversation/src/client/skeleton/GoalBar.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/GoalBar.tsx create mode 100644 packages/client/ui-conversation/tests/goalbar.spec.tsx create mode 100644 packages/host/apiproxy/src/api/goals.schema.ts create mode 100644 packages/host/apiproxy/src/api/goals.ts 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..d65f6c1601 --- /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 +2026-07-22-docked-web-goal-bar.md: b2ae08f1c0f0ceaf5d726ff3f2560f37a0e1762c +2026-07-22-docked-web-goal-bar.zh.md: b3f2c603dca05cb98b2a0bb7b6d78e6011246018 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..b2ae08f1c0 --- /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-conversation/src/client/skeleton/GoalBar.tsx`) is a new props-driven, self-contained component; `ConversationRoot` mounts it immediately before the composer `InputBar`. The strip's CSS mirrors the composer's horizontal geometry (32px side padding, 776px centered cap) plus the mock's 12px inset, and a -10px bottom margin eats InputBar's 8px top padding and tucks its square bottom edge 2px under the composer card's top edge. All strip states share one fixed 38px height so switching between them never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome. + +Visibility drives the label and actions: active shows "Ongoing Goal" with edit/clear; paused shows "Paused Goal" and adds a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. Clear calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. An effect keyed on the goal's id drops the edit form when the goal's identity changes, so a surviving draft can never be written over the goal that replaced it. + +`GoalBarActions` lives in the contract layer (`contract/slots.ts`, next to the `ConversationInjected.goalActions` slot it feeds) and carries exactly the rendered verbs: `onEdit`/`onResume`/`onClear`. `apply.ts` wires them to the runtime session methods; the runtime session resolves the current goal's compare-and-set ref internally, so the UI passes no ref. + +The runtime session gains the goal surface the strip (and future UI) needs: `fetchGoal` populates the snapshot on open, and a live `context/message` carrying `goal/change` meta triggers a coalesced refetch — window replays never refetch, and matching the meta kind (rather than a goal key) also catches clear tombstones written by other clients. The six mutation verbs fold transport failures into `{ ok: false }` results like every sibling session method, and a get result older than a mutation response that landed mid-flight is dropped. + +The strip's background is `--dsw-alias-interactive-bg-hover` rather than the mock's literal `#F5F6F7`: the translucent hover gray resolves to that value over the white light-theme base and lifts the strip off the composer card in dark mode, where a static light token would sink. All colors are `--dsw-*` tokens. + +## Testing + +`packages/client/ui-conversation/tests/goalbar.spec.tsx` pins the behavior through props alone: loading/absent/complete render nothing, the active strip renders label/objective and fires clear, the edit form prefills, rejects empty, saves on Enter, cancels on Esc, and resets when the goal's identity changes, the paused strip fires resume, and the blocked strip exposes the reason tooltip. The skeleton specs mount `ConversationRoot` with and without `goalActions`; the undefined case is seeded with an active goal, so the missing gate — not the missing goal — is what hides the strip. Runtime session specs pin the folded-error results, the live-only coalesced refetch, and the stale-read guard. + +## 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..b3f2c603dc --- /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-conversation/src/client/skeleton/GoalBar.tsx`)是一个新的、由 props 驱动的自包含组件;`ConversationRoot` 将它挂载在输入框 `InputBar` 紧上方。横条的 CSS 对齐输入框的水平几何(两侧 32px 内边距、776px 居中上限),再加上设计稿的 12px 内缩,并用 -10px 的下外边距吃掉 InputBar 的 8px 上内边距,使它方形的底边收进输入框卡片顶边之下 2px。横条的所有状态共享固定的 38px 高度,状态切换不会引起尺寸变化。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。 + +可见性决定标签和操作:active 状态显示 "Ongoing Goal" 并提供编辑/清除;paused 状态显示 "Paused Goal",并增加一个恢复图标按钮;blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上,不在横条里。铅笔图标把横条切换为内联编辑表单,预填当前目标内容:Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存,Esc 取消,目标内容全为空白字符时保存按钮保持禁用。清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。一个以目标 id 为键的 effect 会在目标身份变化时丢弃编辑表单,因此存留的草稿绝不可能覆盖掉替换它的新目标。 + +`GoalBarActions` 位于 contract 层(`contract/slots.ts`,紧挨它所喂给的 `ConversationInjected.goalActions` 槽位),只携带实际渲染的动词:`onEdit`/`onResume`/`onClear`。`apply.ts` 把它们接到运行时会话方法上;运行时会话在内部解析当前目标的 compare-and-set ref,因此 UI 不传 ref。 + +运行时会话获得了横条(以及未来 UI)所需的目标表面:`fetchGoal` 在打开时填充快照;携带 `goal/change` 元数据的 live `context/message` 触发一次合并后的重新拉取——窗口重放绝不触发重新拉取,且匹配元数据 kind(而不是 goal 键)还能捕获其他客户端写入的清除墓碑。六个变更动词与所有同类会话方法一样,把传输层失败折叠为 `{ ok: false }` 结果;比在拉取途中落地的变更响应更旧的 get 结果会被丢弃。 + +横条的背景色用 `--dsw-alias-interactive-bg-hover`,而不是设计稿里的字面值 `#F5F6F7`:这个半透明的悬浮灰在浅色主题的白色底上正好解析为该值,而在深色模式下能把横条从输入框卡片上衬托出来,静态的浅色 token 在深色模式下会沉进去。所有颜色都是 `--dsw-*` token。 + +## 测试 + +`packages/client/ui-conversation/tests/goalbar.spec.tsx` 仅通过 props 固定这些行为:加载中/无目标/已完成时不渲染;active 横条渲染标签和目标内容并触发清除;编辑表单预填内容、拒绝空值、按 Enter 保存、按 Esc 取消,并在目标身份变化时重置;paused 横条触发恢复;blocked 横条暴露原因悬浮提示。skeleton 规格测试分别挂载带与不带 `goalActions` 的 `ConversationRoot`;未定义的情形预置了一个 active 目标,因此隐藏横条的是缺失的挂载门,而不是缺失的目标。运行时会话规格测试固定了折叠错误结果、仅 live 的合并重新拉取,以及陈旧读取守卫。 + +## 考虑过的替代方案 + +- **把横条放在会话头部**:不予采纳,因为重新设计的前提是目标的存在感属于输入框的上下文;放在头部的横条无法停靠进输入框卡片。 +- **为 `undefined` 渲染 "Loading goal…" 占位**:不予采纳,每次打开会话横条都会闪现再坍缩,对一个不到一秒的状态来说只是界面噪音。 +- **未设置目标时在横条内提供内联创建入口**:实现评审后不予采纳,创建目标的职责在 `/goal` 命令上,与模型按请求创建目标的模式一致;横条是状态指示器,不是创建入口。 +- **在 `GoalBarActions` 中携带完整动词集合(`onPause`/`onComplete`)**:作为投机性泛化不予采纳,没有消费方调用它们,接口只携带实际渲染的动词。 + +## 后果 + +- Web UI 中目标的存在形式是停靠在输入框上方的横条:闪光图标、阶段标签、截断的目标内容,以及编辑/清除(暂停时另有恢复)——这是浏览器客户端的第一个目标界面。 +- 运行时会话通过 RPC 暴露目标动词并折叠传输层错误,且在打开时和 live 目标变更元数据到达时刷新快照中的目标(合并拉取,带陈旧读取守卫)。 +- 目标内容首次可以从 UI 编辑,经由 `goal.edit`,ref 由运行时持有;暂停/完成对其他界面(`/goal`、模型工具)照常可用。 +- `goal === null` 时不渲染任何内容;输入框不提供常驻的创建入口,创建是 `/goal` 命令的职责。 diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 9ea6ba6dfe..3a8fa4e9de 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -8,6 +8,7 @@ export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + GoalsApi, GoalView, GoalRef, GoalPhase, GoalBlockReason, } 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 7d4a93e888..a1a2b40aa0 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -423,6 +423,15 @@ export function createFixtureApi(): ApiProxy { host: { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }), }, + goals: { + get: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }), + create: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }), + edit: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }), + pause: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }), + resume: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }), + complete: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }), + clear: request => err(request, { code: 'internal', message: 'fixture: goals not implemented', details: {} }), + }, events: { async *mux(_request, signal) { const conn = new FxInbox() @@ -514,6 +523,13 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.prompt': return this.api.sessions.prompt(request) case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) + case 'goal.get': return this.api.goals.get(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 026fc1bf5f..1d9cf168ee 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, GoalView, GoalRef, GoalPhase, GoalBlockReason, } from './api.ts' export { RpcId, AbstractApiClient, resultOf, transportError } from './api.ts' diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index af5743bf9a..b974581c55 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -71,6 +71,16 @@ export class FakeApiClient implements IApiClient { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), } + readonly goals: IApiClient['goals'] = { + get: payload => this.record('goal.get', payload, Promise.resolve(ok({ goal: null }))), + create: payload => this.record('goal.create', payload, Promise.resolve(ok({ goal: null as unknown as never }))), + edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ goal: null as unknown as never }))), + pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ goal: null as unknown as never }))), + resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ goal: null as unknown as never }))), + complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ goal: null as unknown as never }))), + 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/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 6c012fa410..99aee47147 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -28,6 +28,7 @@ export type { ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +export type { GoalView, GoalRef, GoalPhase, GoalBlockReason } from '@deepseek-ai/dsh-client-connection/client' // ---- Narrowed aliases (the single narrowing point of the slot type chain: // ui-slots/web-react stay generic and dependency-inverted; the client-tree diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 18c5501972..6283b9aed5 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -4,7 +4,7 @@ // string here (narrow to real brands when convenient). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { GoalView, RpcError, RpcId, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' /** Assistant content blocks sorted by what the UI cares about * (text body / collapsible reasoning / tool-call card head / other fallback). */ @@ -162,4 +162,7 @@ export interface ConversationSnapshot { loadingOlder: boolean promptError: PromptError | null lastAgentError: string | null + /** Current goal projection (fetched on open / refreshed when a live context/message carries + * goal/change meta). undefined = not yet loaded. */ + goal: GoalView | null | undefined } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 0934118bd1..e1cd766f2f 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -5,7 +5,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client' +import type { GoalView, HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView } from '@deepseek-ai/dsh-client-connection/client' import { transportError } from '@deepseek-ai/dsh-client-connection/client' import type { ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' @@ -61,6 +61,13 @@ export class Session implements ObservableSnapshot { private removed = false private promptError: PromptError | null = null private lastAgentError: string | null = null + /** Current goal projection; undefined = not yet fetched, null = no goal set. */ + private goal: GoalView | null | undefined = undefined + /** Coalesced goal refetch (the open() idiom): live goal-change events share one in-flight get. */ + private goalFetch: Promise | null = null + /** Bumped on every local goal write; a get result older than the latest write is stale and + * dropped (a mutation response that landed mid-fetch is always newer than the get's read). */ + private goalWriteRev = 0 /** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */ private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = [] /** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */ @@ -120,6 +127,180 @@ export class Session implements ObservableSnapshot { return result } + /** Fetch the current goal, coalesced: concurrent triggers share the in-flight get (identity-guarded + * like openPromise — a superseded fetch must not null out the one that replaced it). */ + private fetchGoal(): Promise { + if (this.goalFetch !== null) return this.goalFetch + const promise = this.doFetchGoal().finally(() => { + if (this.goalFetch === promise) this.goalFetch = null + }) + this.goalFetch = promise + return promise + } + + /** The get behind fetchGoal: folds transport failures (fail-soft like loadOlder, logged) and + * drops the result when a mutation response landed mid-flight (write revision moved on). */ + private async doFetchGoal(): Promise { + const writeRev = this.goalWriteRev + try { + const { result } = await this.api.goals.get({ sessionId: this.sessionId }) + if (!result.ok) return + if (writeRev !== this.goalWriteRev) return + this.goal = result.value.goal + this.notifier.markDirty() + } catch (error) { + console.error('[web-runtime] goal fetch failed:', error) + } + } + + /** + * Create a goal for this session. + * @param objective - the goal's objective text. + * @param maxGoalRounds - optional cap on admitted goal rounds (host default when absent). + * @returns the created goal view; transport failures fold into a failed result, never a rejection. + */ + async createGoal(objective: string, maxGoalRounds?: number): Promise> { + let result: RpcResult<{ goal: GoalView }> + try { + result = (await this.api.goals.create({ + sessionId: this.sessionId, objective, ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}), + })).result + } catch (error) { + result = transportError(error) + } + if (result.ok) { + this.goal = result.value.goal + this.goalWriteRev++ + this.notifier.markDirty() + } + return result + } + + /** + * Edit this session's goal objective or round cap (CAS with the locally held revision). + * @param objective - replacement objective text; absent leaves it unchanged. + * @param maxGoalRounds - replacement round cap; absent leaves it unchanged. + * @returns the updated goal view; fails without a current goal, and transport failures fold into a failed result. + */ + async editGoal(objective?: string, maxGoalRounds?: number): Promise> { + if (this.goal === null || this.goal === undefined) { + return { ok: false, error: { code: 'internal', message: 'No goal to edit', details: {} } } + } + let result: RpcResult<{ goal: GoalView }> + try { + result = (await this.api.goals.edit({ + sessionId: this.sessionId, + ref: { id: this.goal.id, revision: this.goal.revision }, + ...(objective !== undefined ? { objective } : {}), + ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}), + })).result + } catch (error) { + result = transportError(error) + } + if (result.ok) { + this.goal = result.value.goal + this.goalWriteRev++ + this.notifier.markDirty() + } + return result + } + + /** + * Pause the current goal (CAS with the locally held revision). + * @returns the updated goal view; fails without a current goal, and transport failures fold into a failed result. + */ + async pauseGoal(): Promise> { + if (this.goal === null || this.goal === undefined) { + return { ok: false, error: { code: 'internal', message: 'No goal to pause', details: {} } } + } + let result: RpcResult<{ goal: GoalView }> + try { + result = (await this.api.goals.pause({ + sessionId: this.sessionId, ref: { id: this.goal.id, revision: this.goal.revision }, + })).result + } catch (error) { + result = transportError(error) + } + if (result.ok) { + this.goal = result.value.goal + this.goalWriteRev++ + this.notifier.markDirty() + } + return result + } + + /** + * Resume a paused/blocked goal (CAS with the locally held revision). + * @returns the updated goal view; fails without a current goal, and transport failures fold into a failed result. + */ + async resumeGoal(): Promise> { + if (this.goal === null || this.goal === undefined) { + return { ok: false, error: { code: 'internal', message: 'No goal to resume', details: {} } } + } + let result: RpcResult<{ goal: GoalView }> + try { + result = (await this.api.goals.resume({ + sessionId: this.sessionId, ref: { id: this.goal.id, revision: this.goal.revision }, + })).result + } catch (error) { + result = transportError(error) + } + if (result.ok) { + this.goal = result.value.goal + this.goalWriteRev++ + this.notifier.markDirty() + } + return result + } + + /** + * Complete the current goal (CAS with the locally held revision). + * @returns the updated goal view; fails without a current goal, and transport failures fold into a failed result. + */ + async completeGoal(): Promise> { + if (this.goal === null || this.goal === undefined) { + return { ok: false, error: { code: 'internal', message: 'No goal to complete', details: {} } } + } + let result: RpcResult<{ goal: GoalView }> + try { + result = (await this.api.goals.complete({ + sessionId: this.sessionId, ref: { id: this.goal.id, revision: this.goal.revision }, + })).result + } catch (error) { + result = transportError(error) + } + if (result.ok) { + this.goal = result.value.goal + this.goalWriteRev++ + this.notifier.markDirty() + } + return result + } + + /** + * Clear the current goal (tombstone; CAS with the locally held revision). + * @returns a cleared marker; fails without a current goal, and transport failures fold into a failed result. + */ + async clearGoal(): Promise> { + if (this.goal === null || this.goal === undefined) { + return { ok: false, error: { code: 'internal', message: 'No goal to clear', details: {} } } + } + let result: RpcResult<{ cleared: true }> + try { + result = (await this.api.goals.clear({ + sessionId: this.sessionId, ref: { id: this.goal.id, revision: this.goal.revision }, + })).result + } catch (error) { + result = transportError(error) + } + if (result.ok) { + this.goal = null + this.goalWriteRev++ + this.notifier.markDirty() + } + return result + } + /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */ open(): Promise { if (this.openState === 'open') return Promise.resolve() @@ -317,6 +498,8 @@ export class Session implements ObservableSnapshot { if (result.ok) this.installWindow(result.value.events, result.value.hasMore) } this.openState = 'open' + // Fetch the current goal eagerly after the window lands. + void this.fetchGoal() } catch (error) { if (generation !== this.openGeneration) return this.openState = 'error' @@ -353,6 +536,13 @@ export class Session implements ObservableSnapshot { this.views.push(view) this.foldAdapter.append(event, view) this.applyEventSideEffects(event, view) + // Goal mutations surface as goal/change meta on a context/message (clear tombstones carry no + // goal key, so match the meta kind). LIVE events only: window rebuilds replay the same meta + // and would storm goal.get on open/resync/loadOlder; the refetch coalesces via goalFetch. + if (event.type === 'context/message' + && (event.data.meta as { kind?: unknown } | undefined)?.kind === 'goal/change') { + void this.fetchGoal() + } } /** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop; @@ -522,6 +712,7 @@ export class Session implements ObservableSnapshot { loadingOlder: this.loadingOlder, promptError: this.promptError, lastAgentError: this.lastAgentError, + goal: this.goal, } } } diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index c13ef09fcb..e48141328a 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -74,6 +74,16 @@ export class FakeApiClient implements IApiClient { describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), } + readonly goals: IApiClient['goals'] = { + get: payload => this.record('goal.get', payload, Promise.resolve(ok({ goal: null }))), + create: payload => this.record('goal.create', payload, Promise.resolve(ok({ goal: null as unknown as never }))), + edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ goal: null as unknown as never }))), + pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ goal: null as unknown as never }))), + resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ goal: null as unknown as never }))), + complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ goal: null as unknown as never }))), + 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/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 149f73c1fc..6469986f0d 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -7,6 +7,7 @@ */ import { describe, expect, it, vi } from 'vitest' +import type { GoalView } from '@deepseek-ai/dsh-client-connection/client' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { Session } from '../src/client/sessions/session.ts' @@ -623,3 +624,233 @@ describe('reference stability (the memo contract)', () => { expect(resolved.pending).toBe(after.pending) }) }) + +describe('goal session methods', () => { + const GID = 'g-1' as never + function makeGoal(overrides: Partial = {}): GoalView { + return { + id: GID, revision: 1, objective: 'test-goal', phase: 'active', + maxGoalRounds: 256, roundsStarted: 0, createdAt: 100, updatedAt: 100, + activation: 'armed', + ...overrides, + } + } + + it('createGoal calls the API and updates snapshot.goal', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const goal = makeGoal() + api.goals.create = () => Promise.resolve(ok({ goal })) + const r = await session.createGoal('test-goal') + expect(r).toEqual({ ok: true, value: { goal } }) + expect(session.getSnapshot().goal).toEqual(goal) + }) + + it('editGoal sends the current ref and updates snapshot', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const goal = makeGoal() + api.goals.create = () => Promise.resolve(ok({ goal })) + await session.createGoal('test-goal') + const edited = makeGoal({ revision: 2, objective: 'edited' }) + api.goals.edit = () => Promise.resolve(ok({ goal: edited })) + const r = await session.editGoal('edited') + expect(r).toEqual({ ok: true, value: { goal: edited } }) + expect(session.getSnapshot().goal).toEqual(edited) + }) + + it('editGoal returns an error when no goal exists', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const r = await session.editGoal('nothing') + expect(r.ok).toBe(false) + expect((r as { error: { code: string } }).error.code).toBe('internal') + }) + + it('pauseGoal pauses and updates snapshot', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const goal = makeGoal() + api.goals.create = () => Promise.resolve(ok({ goal })) + await session.createGoal('test-goal') + const paused = makeGoal({ revision: 2, phase: 'paused', activation: 'disarmed' }) + api.goals.pause = () => Promise.resolve(ok({ goal: paused })) + const r = await session.pauseGoal() + expect(r).toEqual({ ok: true, value: { goal: paused } }) + expect(session.getSnapshot().goal).toEqual(paused) + }) + + it('resumeGoal resumes a paused goal', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const goal = makeGoal({ phase: 'paused', activation: 'disarmed' }) + api.goals.create = () => Promise.resolve(ok({ goal })) + await session.createGoal('test-goal') + const resumed = makeGoal({ revision: 2, phase: 'active', activation: 'armed' }) + api.goals.resume = () => Promise.resolve(ok({ goal: resumed })) + const r = await session.resumeGoal() + expect(r).toEqual({ ok: true, value: { goal: resumed } }) + expect(session.getSnapshot().goal).toEqual(resumed) + }) + + it('completeGoal marks the goal complete', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const goal = makeGoal() + api.goals.create = () => Promise.resolve(ok({ goal })) + await session.createGoal('test-goal') + const completed = makeGoal({ revision: 2, phase: 'complete', activation: 'disarmed' }) + api.goals.complete = () => Promise.resolve(ok({ goal: completed })) + const r = await session.completeGoal() + expect(r).toEqual({ ok: true, value: { goal: completed } }) + expect(session.getSnapshot().goal).toEqual(completed) + }) + + it('clearGoal removes the goal from snapshot', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const goal = makeGoal() + api.goals.create = () => Promise.resolve(ok({ goal })) + await session.createGoal('test-goal') + api.goals.clear = () => Promise.resolve(ok({ cleared: true as const })) + const r = await session.clearGoal() + expect(r).toEqual({ ok: true, value: { cleared: true } }) + expect(session.getSnapshot().goal).toBeNull() + }) + + it('error responses from the API do not mutate local state', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const goal = makeGoal() + api.goals.create = () => Promise.resolve(ok({ goal })) + await session.createGoal('test-goal') + const before = session.getSnapshot().goal + api.goals.pause = () => Promise.resolve(err({ code: 'agent-busy', message: 'stale revision', details: { reason: 'stale revision' } })) + const r = await session.pauseGoal() + expect(r.ok).toBe(false) + expect(session.getSnapshot().goal).toBe(before) + }) + + it('fetchGoal runs on session open and populates goal', async () => { + const goal = makeGoal() + const { api, session } = makeSession() + api.onHistory = () => histResponse([]) + api.goals.get = () => Promise.resolve(ok({ goal })) + await session.open() + expect(session.getSnapshot().goal).toEqual(goal) + }) + + const goalChangeEvent = (seq: number, operation: string): SessionEvent => + at(seq, { + type: 'context/message', surfaceOp: 'append', + data: { + content: [{ type: 'text', text: `goal ${operation}` }], + source: { kind: 'goal', goalId: 'g-1', revision: 1, round: 0 }, + meta: { + kind: 'goal/change', version: 1, operation, + goal: { id: 'g-1', revision: 1, objective: 'x', phase: 'active', maxGoalRounds: 256 }, + roundsStarted: 0, createdAt: 100, updatedAt: 100, + }, + }, + }) + + // Clear tombstones carry no goal key — the trigger matches the meta kind, not a goal field. + const goalClearEvent = (seq: number): SessionEvent => + at(seq, { + type: 'context/message', surfaceOp: 'append', + data: { + content: [{ type: 'text', text: 'goal cleared' }], + source: { kind: 'goal', goalId: 'g-1', revision: 2, round: 0 }, + meta: { kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'g-1', revision: 2 }, clearedAt: 100 }, + }, + }) + + it('live goal-change meta triggers one coalesced refetch; window replays never refetch', async () => { + const { api, session } = makeSession() + // The history window replays goal-change meta (a snapshot change AND a clear tombstone). + api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), goalChangeEvent(6, 'create'), goalClearEvent(7)]) + await session.open() + await Promise.resolve() + expect(api.callsOf('goal.get')).toHaveLength(1) // the eager open fetch only — no replay storm + + // A plain live context message is not a goal change. + let refetches = 0 + const gate = deferred>>() + api.goals.get = () => { refetches++; return gate.promise } // replacement bypasses record(): count locally + session.handleMuxEnvelope('r0' as never, { + type: 'session/event', sessionId: SID, + event: at(8, { type: 'context/message', surfaceOp: 'append', data: { content: [{ type: 'text', text: 'plain' }], source: { kind: 'system' } } }), + }) + expect(refetches).toBe(0) + + // Two live goal events (change + clear tombstone) coalesce into a single refetch. + session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: goalChangeEvent(9, 'edit') }) + session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: goalClearEvent(10) }) + expect(refetches).toBe(1) + const goal = makeGoal({ revision: 3 }) + gate.resolve(ok({ goal })) + await vi.waitFor(() => { expect(session.getSnapshot().goal).toEqual(goal) }) + }) + + it('drops a goal.get result older than a mutation response that landed mid-flight', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const goal = makeGoal() + api.goals.create = () => Promise.resolve(ok({ goal })) + await session.createGoal('test-goal') + + let refetches = 0 + const gate = deferred>>() + api.goals.get = () => { refetches++; return gate.promise } // replacement bypasses record(): count locally + session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: goalChangeEvent(6, 'edit') }) + expect(refetches).toBe(1) // the live refetch is parked on the gate + const completed = makeGoal({ revision: 2, phase: 'complete', activation: 'disarmed' }) + api.goals.complete = () => Promise.resolve(ok({ goal: completed })) + await session.completeGoal() // newer write lands while the get is in flight + gate.resolve(ok({ goal: makeGoal({ objective: 'stale-read' }) })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(session.getSnapshot().goal).toEqual(completed) // the stale read never overwrote it + }) + + it('folds transport rejections from goal mutations into { ok: false } without rejecting', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + api.goals.create = () => Promise.reject(new Error('goal wire down')) + const created = await session.createGoal('test-goal') + expect(created).toMatchObject({ ok: false, error: { code: 'internal', message: 'goal wire down' } }) + expect(session.getSnapshot().goal).toBeNull() + + const goal = makeGoal() + api.goals.create = () => Promise.resolve(ok({ goal })) + await session.createGoal('test-goal') + api.goals.pause = () => Promise.reject(new Error('pause wire down')) + const paused = await session.pauseGoal() + expect(paused).toMatchObject({ ok: false, error: { code: 'internal', message: 'pause wire down' } }) + expect(session.getSnapshot().goal).toEqual(goal) // local state untouched + }) + + it('a goal.get transport rejection on a live refetch is logged and swallowed', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + api.goals.get = () => Promise.reject(new Error('get wire down')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: goalChangeEvent(6, 'create') }) + await vi.waitFor(() => { expect(errorSpy).toHaveBeenCalled() }) + expect(session.getSnapshot().goal).toBeNull() // fail-soft: state untouched + } finally { + errorSpy.mockRestore() + } + }) +}) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index a600789a20..dbd096da2e 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -15,7 +15,7 @@ import type { import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client' import type { I18nService } from '@deepseek-ai/dsh-client-i18n/client' import type { ConvViewProps, SelectionTarget, ViewEntry, ViewId } from './contract/views.ts' -import type { ConversationInjected, DetailsInjected, EmptyStateInjected } from './contract/slots.ts' +import type { ConversationInjected, DetailsInjected, EmptyStateInjected, GoalBarActions } from './contract/slots.ts' import { ConversationService } from './service.ts' import { ToolViewRegistry } from './toolviews/registry.ts' import { childSessionScope, registerChat } from './chat/register.ts' @@ -153,6 +153,11 @@ export function apply(ctx: Context): void { } return createElement(Fragment, null, ...children) }, + goalActions: { + onEdit: (objective) => { void session.editGoal(objective) }, + onResume: () => { void session.resumeGoal() }, + onClear: () => { void session.clearGoal() }, + } satisfies GoalBarActions, } return injected } diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 32793fb804..6557c469d7 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -4,12 +4,11 @@ import type { ReactNode } from 'react' import { - IconApiOutline14, IconBrowseOutline16, IconSearchOutline16, IconThinkOutline14, + IconApiOutline14, IconBrowseOutline16, IconSearchOutline16, IconSparkle16, IconThinkOutline14, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolViewProps } from '../contract/toolview.ts' import { toolRowModel, type ToolCallBlock, type ToolRowVariant } from '../contract/tool-call-model.ts' import { ToolRow } from './ToolRow.tsx' -import { IconSparkle16 } from './IconSparkle16.tsx' /** Variant leading icons (figma table). */ 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-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 276ae1ec30..545e647958 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -13,6 +13,16 @@ import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-w import type { ConvOwnerProps, DetailsOwnerProps, EmptyOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client' import type { SelectionTarget, ViewEntry, ViewId } from './views.ts' +/** Goal strip callbacks (the docked GoalBar's verb set). State is read from useSession. */ +export interface GoalBarActions { + /** Replace the current goal's objective. */ + onEdit(objective: string): void + /** Resume a paused goal. */ + onResume(): void + /** Clear the current goal (tombstone). */ + onClear(): void +} + /** Injected share of the conversation slot (assembled by apply's inject factory). */ export interface ConversationInjected { /** Breadcrumb chain (root ancestor first, self last; ancestry(list) feed). */ @@ -38,6 +48,8 @@ export interface ConversationInjected { } /** Renders the active view's body (the owner closes over ConvViewProps assembly). */ renderView: (entry: ViewEntry) => ReactNode + /** Goal callbacks (undefined = goal feature not available). State is read from useSession. */ + goalActions?: GoalBarActions } /** Full conversation-slot component props: owner share & standard share & injected share. */ diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 17e9ed88a8..ffc11d5744 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -22,7 +22,7 @@ export type { } from './contract/toolview.ts' export type { ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps, - EmptyStateInjected, EmptyStateSlotProps, + EmptyStateInjected, EmptyStateSlotProps, GoalBarActions, } from './contract/slots.ts' export { ConversationRoot } from './skeleton/ConversationRoot.tsx' diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 540b76bda7..5b7c799057 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -10,6 +10,8 @@ import clsx from 'clsx' import type { ConversationSlotProps } from '../contract/slots.ts' import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' +import { GoalBar } from './GoalBar.tsx' +import type { GoalBarProps } from './GoalBar.tsx' import css from './ConversationRoot.module.css' /** @@ -20,7 +22,7 @@ import css from './ConversationRoot.module.css' export type ConversationRootProps = ConversationSlotProps export function ConversationRoot({ - sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView, + sessionId, useSession, useAncestry, views, useActiveView, composer, actions, renderView, goalActions, }: ConversationRootProps) { useSyncExternalStore(views.subscribe, views.version) const list = views.list() @@ -33,6 +35,7 @@ export function ConversationRoot({ const removed = useSession(s => (s as { removed: boolean }).removed) const promptError = useSession(s => (s as { promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null }).promptError) const turns = useSession(s => countTurns(s as { nodes: readonly { kind: string }[] })) + const goal = useSession(s => (s as { goal: unknown }).goal) const error: InputBarError | null = promptError === null ? null @@ -87,6 +90,11 @@ export function ConversationRoot({ {active !== undefined && renderView(active)} + {/* GoalBar docks directly above the composer (its CSS mirrors the + composer's horizontal geometry and tucks under the card's top edge). */} + {goalActions !== undefined && ( + + )} { + setEditing(false) + }, [goalId]) + + const handleEdit = useCallback(() => { + const trimmed = draft.trim() + if (trimmed === '') return + onEdit(trimmed) + setEditing(false) + }, [draft, onEdit]) + + // Loading, absent, and complete goals have no strip at all. + if (goal === undefined || goal === null || goal.phase === 'complete') return null + + if (editing) { + return ( +
+
+ setDraft(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') handleEdit() + if (e.key === 'Escape') setEditing(false) + }} + autoFocus + /> +
+ + +
+
+
+ ) + } + + const title = goal.phase === 'blocked' ? goal.blockedReason?.message : undefined + return ( +
+
+ + {PHASE_LABELS[goal.phase]} + {goal.objective} +
+ {goal.phase === 'paused' && ( + + )} + + +
+
+
+ ) +} diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 2c0166c7de..b681ea2b8a 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -39,7 +39,7 @@ function snapshotBase(): ConversationSnapshot { return { sessionId: ROOT, nodes: [], foldDegraded: false, partial: null, runningCalls: [], pending: [], running: false, removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, + hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, goal: undefined, } as ConversationSnapshot } diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 63b76c86df..90006f8885 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -31,6 +31,7 @@ function snapshotBase(): ConversationSnapshot { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], pending: [], running: false, removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, + goal: undefined, } } diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index af4f80c0b4..e2716308cb 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -25,6 +25,7 @@ function snapshotBase(): ConversationSnapshot { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], pending: [], running: false, removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, + goal: undefined, } } diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 3ff148f5d6..03f84b8086 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -27,7 +27,7 @@ function snapshotBase(): ConversationSnapshot { return { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], pending: [], running: false, removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, + hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, goal: undefined, } as ConversationSnapshot } diff --git a/packages/client/ui-conversation/tests/goalbar.spec.tsx b/packages/client/ui-conversation/tests/goalbar.spec.tsx new file mode 100644 index 0000000000..c928da5558 --- /dev/null +++ b/packages/client/ui-conversation/tests/goalbar.spec.tsx @@ -0,0 +1,116 @@ +// @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 } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { GoalView } from '@deepseek-ai/dsh-client-runtime/client' +import { GoalBar } from '../src/client/skeleton/GoalBar.tsx' +import type { GoalBarActions } from '../src/client/contract/slots.ts' + +afterEach(cleanup) + +function makeGoal(over: Partial = {}): GoalView { + return { + id: 'g1' as GoalView['id'], + revision: 1, + objective: 'Ship the redesign', + phase: 'active', + maxGoalRounds: 4, + roundsStarted: 1, + createdAt: 1, + updatedAt: 2, + activation: 'armed', + ...over, + } +} + +function makeActions(): { [K in keyof GoalBarActions]: ReturnType> } { + return { + onEdit: vi.fn(), + onResume: vi.fn(), + onClear: vi.fn(), + } +} + +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', () => { + const actions = makeActions() + render() + fireEvent.click(screen.getByRole('button', { name: 'Edit goal' })) + const box = screen.getByRole('textbox', { name: 'Goal objective' }) + expect((box as HTMLInputElement).value).toBe('Ship the redesign') + + fireEvent.change(box, { target: { value: ' ' } }) + expect((screen.getByRole('button', { name: 'Save goal' }) as HTMLButtonElement).disabled).toBe(true) + + fireEvent.change(box, { target: { value: 'Ship v2' } }) + fireEvent.keyDown(box, { key: 'Enter' }) + expect(actions.onEdit).toHaveBeenCalledWith('Ship v2') + 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('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') + }) +}) diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index 25808e17c5..7e39d31654 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -20,7 +20,7 @@ function snapshotBase(): ConversationSnapshot { return { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], pending: [], running: false, removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, + hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, goal: undefined, } as ConversationSnapshot } diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index ab23a64389..62974352d0 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -133,6 +133,92 @@ describe('ConversationRoot', () => { fireEvent.keyDown(box, { key: 'Enter' }) expect(send).toHaveBeenCalledWith('queue') }) + + it('renders GoalBar when goalActions and an active goal are provided', () => { + const goal = { + id: 'g1' as never, + revision: 1, + objective: 'test-objective', + phase: 'active' as const, + maxGoalRounds: 256, + roundsStarted: 0, + createdAt: 100, + updatedAt: 100, + activation: 'armed' as const, + } + const store = createSnapshotStore({ + nodes: [], runningCalls: [], running: false, removed: false, promptError: null, goal, + }) + const useSession = bindSnapshotSelector(store) as unknown as UseSession + const activeStore = createSnapshotStore('chat') + const views = [view('chat', 'Chat')] + render( + []} + views={{ + list: () => views, + subscribe: () => () => {}, + version: () => 1, + }} + useActiveView={() => activeStore.useSelector(s => s) as ViewId | undefined} + composer={{ + useDraft: () => '', + setDraft: () => {}, + send: () => {}, + stop: () => {}, + }} + actions={{ openView: vi.fn() as (v: never) => void, open: vi.fn() }} + renderView={() => null} + goalActions={{ + onEdit: vi.fn(), + onResume: vi.fn(), + onClear: vi.fn(), + }} + />) + expect(screen.getByText('Ongoing Goal')).toBeTruthy() + expect(screen.getByText('test-objective')).toBeTruthy() + }) + + it('hides GoalBar when goalActions is undefined (even with an active goal in the store)', () => { + const goal = { + id: 'g1' as never, + revision: 1, + objective: 'test-objective', + phase: 'active' as const, + maxGoalRounds: 256, + roundsStarted: 0, + createdAt: 100, + updatedAt: 100, + activation: 'armed' as const, + } + const store = createSnapshotStore({ + nodes: [], runningCalls: [], running: false, removed: false, promptError: null, goal, + }) + const useSession = bindSnapshotSelector(store) as unknown as UseSession + render( + []} + views={{ + list: () => [], + subscribe: () => () => {}, + version: () => 1, + }} + useActiveView={() => 'chat' as ViewId} + composer={{ + useDraft: () => '', + setDraft: () => {}, + send: () => {}, + stop: () => {}, + }} + actions={{ openView: vi.fn() as (v: never) => void, open: vi.fn() }} + renderView={() => null} + />) + expect(screen.queryByText('Ongoing Goal')).toBeNull() + }) }) describe('DetailsPanel', () => { diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 4f35833fb6..6ac07cbd9c 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -573,3 +573,14 @@ export const IconTreeCorner8x10 = ({ size = 10, 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 ee396af4f5..8ef7a5bd8a 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 + 6 figma extracts)', () => { - expect(iconNames.length).toBe(49) + it('exports the full P-I set (43 deepsuite + 6 figma extracts + 1 hand-authored)', () => { + expect(iconNames.length).toBe(50) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => { diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index bcb3fc2a75..38591d72f1 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -147,6 +147,11 @@ export function apply(ctx: Context): void { ctx.goals.block(state.agent, ref, { code: outcome.code, message: outcome.message }) return case 'disarm': + // TODO(disarm-reason): `GoalRoundOutcome.disarm.reason` carries a + // specific string (durability-failed/disposed/interrupted) that is + // produced in `classifyGoalRound()` but discarded here. Either log it + // or remove it from the variant to stop dead payload being carried + // through the result type. ctx.goals.disarm(state.agent) return /* v8 ignore next 2 -- GoalRoundOutcome is closed and every member is handled above */ diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 7391c69e93..6778343d7c 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -64,7 +64,19 @@ export interface ResolvedConfig { defaultMaxGoalRounds: number } -/** One accepted mutation waiting to enter or be observed in the session log. */ +/** + * One accepted mutation waiting to enter or be observed in the session log. + * + * TODO(pending-fifo): The pending FIFO + `applied` flag + `sameChange` match + + * `observedSeq` tracking implements a distributed-transaction-style reconciliation + * protocol over what is purely synchronous, process-local state. In `commit()` there + * is no yield point between `agent.inject()` and the `pending.applied` guard (L509), + * so the guard is always taken — the flag is never `true` at that point. The same + * outcome is achievable by folding the injected event directly after `inject()` + * instead of deferring to `sync()`. This would eliminate `PendingGoalChange`, + * `GoalCache.pending`, `GoalCache.observedSeq`, `sameChange()`, the `applied` flag, + * the `catch` rollback, and the reconciliation branch in `sync()` (~40 lines). + */ interface PendingGoalChange { readonly change: GoalChangeMeta readonly activation: GoalActivation 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..2ec087190a --- /dev/null +++ b/packages/host/apiproxy/src/api/goals.schema.ts @@ -0,0 +1,112 @@ +/** + * goals domain zod schemas. + */ + +import { z } from 'zod' +import type { Wire } from './rpc.schema.ts' +import type { GoalRef, GoalView, 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> + +/** Goal block reason schema. */ +export const goalBlockReasonSchema = z.object({ + code: z.string(), + message: z.string(), +}) + +/** GoalView schema. */ +export const goalViewSchema = z.object({ + id: z.string(), + revision: z.number().int().positive(), + objective: z.string(), + phase: z.union([z.literal('active'), z.literal('paused'), z.literal('blocked'), z.literal('complete')]), + blockedReason: goalBlockReasonSchema.optional(), + maxGoalRounds: z.number().int().positive(), + roundsStarted: z.number().int().nonnegative(), + createdAt: z.number(), + updatedAt: z.number(), + activation: z.union([z.literal('armed'), z.literal('disarmed')]), +}) as unknown as z.ZodType> + +/** goal.get request payload. */ +export const goalGetRequestSchema = z.object({ + sessionId: z.string(), +}) as unknown as z.ZodType>> + +/** goal.get response value. */ +export const goalGetValueSchema = z.object({ + goal: goalViewSchema.nullable(), +}) as unknown as z.ZodType>> + +/** 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 = z.object({ + goal: goalViewSchema, +}) 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(), +}) as unknown as z.ZodType>> + +/** goal.edit response value. */ +export const goalEditValueSchema = z.object({ + goal: goalViewSchema, +}) 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 = z.object({ + goal: goalViewSchema, +}) 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 = z.object({ + goal: goalViewSchema, +}) 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 = z.object({ + goal: goalViewSchema, +}) 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..b1a2dd9a77 --- /dev/null +++ b/packages/host/apiproxy/src/api/goals.ts @@ -0,0 +1,88 @@ +/** + * goals domain contract. Method signatures are the source of truth: + * unary methods take the RpcRequest

narrow form and the impl echoes rpcId. + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' +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 +} + +/** Durable continuation phase. */ +export type GoalPhase = + | 'active' + | 'paused' + | 'blocked' + | 'complete' + +/** Machine-routable and human-readable explanation for a blocked goal. */ +export interface GoalBlockReason { + readonly code: string + readonly message: string +} + +/** 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 { + readonly id: GoalId + readonly revision: number + readonly objective: string + readonly phase: GoalPhase + readonly blockedReason?: GoalBlockReason + readonly maxGoalRounds: number + readonly roundsStarted: number + readonly createdAt: number + readonly updatedAt: number + readonly activation: GoalActivation +} + +/** 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 +} + +/** Goal-domain unary methods. */ +export interface GoalsApi { + /** Read the current goal for one session. Returns null when no goal is current. */ + get(request: RpcRequest<{ sessionId: string }>): Promise> + + /** Create and arm a goal. */ + create(request: RpcRequest<{ sessionId: string; objective: string; maxGoalRounds?: number }>): + Promise> + + /** Edit objective and/or round cap without changing phase. */ + edit(request: RpcRequest<{ sessionId: string; ref: GoalRef; objective?: string; maxGoalRounds?: number }>): + Promise> + + /** Pause an active goal and disarm automatic continuation. */ + pause(request: RpcRequest<{ sessionId: string; ref: GoalRef }>): + Promise> + + /** Resume and arm a stopped goal. */ + resume(request: RpcRequest<{ sessionId: string; ref: GoalRef }>): + Promise> + + /** Mark a current non-complete goal complete and disarm it. */ + complete(request: RpcRequest<{ sessionId: string; ref: GoalRef }>): + Promise> + + /** Clear the current goal while retaining a durable tombstone and history. */ + clear(request: RpcRequest<{ sessionId: string; ref: GoalRef }>): + Promise> +} diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index c51c785a7c..805d8ffa5e 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -7,6 +7,7 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.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. */ @@ -14,6 +15,7 @@ export interface ApiProxy { sessions: SessionsApi host: HostApi 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 } @@ -22,6 +24,7 @@ export interface ApiProxy { export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts' export type { HostApi } from './host.ts' export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts' +export type { GoalsApi, GoalView, GoalRef, GoalPhase, GoalBlockReason, CreateGoalRequest, EditGoalRequest } 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 b37cc062ff..22c831ad33 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -6,6 +6,7 @@ import type { SessionsApi } from './sessions.ts' import type { HostApi } from './host.ts' +import type { GoalsApi } from './goals.ts' import type { RpcResponse } from './rpc.ts' /** Method name → method signature. Signatures are the single source of truth; payload/value types are always derived from here. */ @@ -16,6 +17,13 @@ export interface RpcMethodMap { 'session.prompt': SessionsApi['prompt'] 'session.cancel': SessionsApi['cancel'] 'host.describe': HostApi['describe'] + 'goal.get': GoalsApi['get'] + '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/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 901cf7bd2a..31312bc261 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -21,6 +21,15 @@ import { sessionListValueSchema, sessionPromptValueSchema, } from '../api/sessions.schema.ts' +import { + goalGetValueSchema, + 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 @@ -52,6 +61,15 @@ export interface IApiClient { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> host(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> } + goals: { + get(payload: RequestPayload<'goal.get'>, signal?: AbortSignal): Promise>> + 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 } @@ -67,6 +85,13 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('host.describe', payload, signal), } + readonly goals: IApiClient['goals'] = { + get: (payload, signal) => this.callUnary('goal.get', payload, signal), + 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 03b9f6500f..ad2d55750b 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -22,6 +22,15 @@ import { sessionPromptRequestSchema, } from '../api/sessions.schema.ts' import { hostDescribeRequestSchema } from '../api/host.schema.ts' +import { + goalGetRequestSchema, + 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 @@ -44,6 +53,13 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, + 'goal.get': { schema: goalGetRequestSchema, invoke: (api, r) => api.goals.get(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/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 25af7e2f75..9552a93b4f 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -21,9 +21,12 @@ function scriptedApi(overrides: { sessions?: Partial host?: 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: [] }), @@ -34,6 +37,16 @@ function scriptedApi(overrides: { ...overrides.sessions, }, host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), ...overrides.host }, + goals: { + get: err, + 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 })), } diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index d097daecef..f41f0f6b14 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -42,6 +42,29 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } } }, }, + goals: { + async get(request) { + return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } } + }, + 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/pnpm-lock.yaml b/pnpm-lock.yaml index 773b05f87d..d39785a1d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1920,6 +1920,12 @@ importers: '@deepseek-ai/dsh-client-ui-trajectory': specifier: workspace:^ version: link:../../client/ui-trajectory + '@deepseek-ai/dsh-command-goal': + specifier: workspace:^ + version: link:../../goal/command-goal + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../ui/commands '@deepseek-ai/dsh-compact-basic': specifier: workspace:^ version: link:../../compact/compact-basic @@ -1929,6 +1935,12 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../fs/fs-policy + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal + '@deepseek-ai/dsh-goal-session': + specifier: workspace:^ + version: link:../../goal/goal-session '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../apiproxy