diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.i18n.yaml new file mode 100644 index 0000000000..cc9cab8e37 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.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-24-web-plan-mode-projection.md: bff1e6924e514cda9b8d8f00e8bfa2d1fb110437 +2026-07-24-web-plan-mode-projection.zh.md: 039e0e10c580c4128e95b0db5b0bdc0ba3617459 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.md b/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.md new file mode 100644 index 0000000000..bff1e6924e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.md @@ -0,0 +1,52 @@ +# Agent Note: Project plan mode through the web host boundary + +Status: implemented + +English | [中文](2026-07-24-web-plan-mode-projection.zh.md) + +## Problem + +The plan service owned durable state and boundary timing, but the web host contract had no way to discover or select it. A browser could inspect the tail history page for `plan/mode`, yet that page may omit the latest relevant event after pagination or hold no event for the empty-log inactive state. It also cannot reveal a selection waiting for the next model-request boundary. A client-only toggle would therefore drift from resumed sessions, exit-tool transitions, and selections made by another surface. + +The host does not mount plan mode for every product composition. The wire must distinguish an unavailable capability from a supported session whose committed state is inactive. Switching mode is also independent of cancelling an in-progress request: the existing service intentionally applies the latest selection at the next boundary. + +## Decision + +The session RPC domain exposes `session.planMode({ sessionId })` and `session.setPlanMode({ sessionId, active })`. Their shared value is `null | { active: boolean, pending?: boolean }`. `null` means the optional `ctx.planMode` service is absent; `{ active: false }` means the service is available and inactive. Both methods resume a cold session through the same host-owned path as history and prompt before reading or changing state. + +The host adapter delegates selection and folding to `ctx.planMode`; it does not append events or duplicate boundary logic. `active` is the last committed logged value. When present, `pending` is the selected target value awaiting a model-request boundary; its presence, rather than its boolean value, identifies pending intent. Re-selecting the committed value can therefore return `pending: false` while cancelling a pending entry. The boundary then removes that intent without logging a redundant state event. The RPC does not cancel a running request, so a selection made during generation leaves that request unchanged and shapes the next one. + +The browser session object queries the complete state after history opens and on reconnect. A failed plan query is fail-soft: history remains usable and the last known capability state is retained. A reconnect generation fence prevents a superseded query from overwriting the newer result. A separate local event-version fence prevents a query or selection response from overwriting a `plan/mode` commit that overtook it on the mux stream; an early commit remains private until a successful query confirms capability presence. Successful selections otherwise update the snapshot only from the host-confirmed response, while business and transport failures leave the prior state intact. + +Committed `plan/mode` session events remain the live notification. When the host advertised the capability, a valid event replaces `active` and clears `pending`. The object layer ignores malformed events and does not infer capability from a raw event alone. This keeps full-state reads authoritative while preserving the existing logged event stream as the commit signal. + +## State and timing + +| Starting state | Selection | Immediate RPC state | Next request boundary | +|---|---|---|---| +| Inactive | Plan | `{ active: false, pending: true }` | Logs `plan/mode: true`; snapshot becomes active | +| Active | Default | `{ active: true, pending: false }` | Logs `plan/mode: false`; snapshot becomes inactive | +| Inactive with pending Plan | Default | `{ active: false, pending: false }` | No state event is needed | +| Capability absent | Either | `null` | No plan behavior is introduced | + +Stopping generation remains a separate session operation. A pending selection survives cancellation and applies when the next prompt or continuation reaches the service boundary. + +## Alternatives considered + +**Fold only the currently loaded history page.** Rejected because message-boundary pagination can omit the latest mode event, and a page cannot represent pending intent or distinguish empty-log inactive from capability absence. + +**Keep an optimistic browser boolean.** Rejected because tool-approved exit, another client, resume, and append failure can all disagree with the speculative value. The browser displays only the state returned by the owner. + +**Add a dedicated plan control frame.** Rejected because committed state already has the logged `plan/mode` event. A full-state unary query covers open and reconnect without adding a second live event vocabulary. + +**Expose generic named collaboration modes.** Rejected by the plan-specific state decision: ACP may keep a generic adapter vocabulary, but the product currently owns one concrete boolean domain. + +## Verification + +- API schemas reject invalid request and state shapes, and both fetch directions dispatch the two methods. +- Host runtime tests cover capability absence, real-service pending and cancellation state, cold-session errors, and shared RPC semantics. +- Client object tests cover open, selection success, business and transport failure, committed live events, malformed and unavailable events, fail-soft queries, reconnect refresh, superseded-query fencing, and mux commits overtaking unary responses. + +## Consequences + +Web UI packages can discover plan mode without importing its host implementation and can display boundary-pending state without duplicating the plan service. Other clients may use the same optional projection. The contract deliberately does not combine switching with stop, invent generic mode identifiers, or make plan capability mandatory for every host composition. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.zh.md new file mode 100644 index 0000000000..039e0e10c5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.zh.md @@ -0,0 +1,52 @@ +# Agent Note: 通过 Web 宿主边界投影 plan mode + +Status: implemented + +[English](2026-07-24-web-plan-mode-projection.md) | 中文 + +## 问题 + +plan 服务拥有持久状态和边界时序,但 Web 宿主契约无法发现或选择 plan mode。浏览器可以检查尾部历史页中的 `plan/mode`,但分页后,该页面可能遗漏最近的相关事件;在空日志、未激活状态下,也可能完全没有对应事件。它同样无法体现一项等待下一次模型请求边界生效的选择。因此,只在客户端维护切换状态会与恢复后的会话、退出工具触发的状态转换,以及其他界面作出的选择发生偏差。 + +并非每一种产品组合都会在宿主中挂载 plan mode。协议必须区分不可用的功能与已支持但提交状态为未激活的会话。切换模式也与取消正在执行的请求互不影响:现有服务会按设计在下一个边界应用最近一次选择。 + +## 决策 + +会话 RPC 域公开 `session.planMode({ sessionId })` 和 `session.setPlanMode({ sessionId, active })`。两者返回相同的值类型:`null | { active: boolean, pending?: boolean }`。`null` 表示可选的 `ctx.planMode` 服务不存在;`{ active: false }` 表示该服务可用,但当前未激活。读取或修改状态前,这两个方法都会通过宿主用于历史记录与提示词请求的同一路径恢复冷会话。 + +宿主适配器把选择与折叠工作交给 `ctx.planMode`,不会自行追加事件或重复实现边界逻辑。`active` 是最近一次已提交并记录到日志的值。`pending` 存在时,其值是等待模型请求边界生效的所选目标;表示存在待生效意图的是该字段是否存在,而不是其布尔值。因此,重新选择已提交值时,可以在取消待生效的进入选择的同时返回 `pending: false`。随后,边界会移除该意图,且不会记录多余的状态事件。该 RPC 不会取消正在执行的请求,因此生成期间作出的选择不会改变本次请求,只会影响下一次请求。 + +浏览器会话对象在历史记录加载完成后以及重连时查询完整状态。plan 查询失败不会阻断其他功能:历史记录仍可使用,并保留最近一次已知的功能状态。重连使用代际围栏,避免已被取代的查询覆盖较新的结果。另一道独立的本地事件版本围栏会阻止查询或选择响应覆盖 mux 流中已抢先到达的 `plan/mode` 提交;提前到达的提交会保持为内部状态,直到查询成功并确认该功能存在。除上述情况外,选择成功后,只有宿主确认的响应才会更新快照;业务错误和传输失败都会保留先前状态。 + +已提交的 `plan/mode` 会话事件仍作为实时通知。当宿主已公布该功能时,有效事件会替换 `active` 并清除 `pending`。对象层会忽略格式错误的事件,也不会仅凭一条原始事件推断功能是否可用。这样既以完整状态读取为真源,又保留现有的日志事件流作为提交信号。 + +## 状态与时序 + +| 起始状态 | 选择 | RPC 即时状态 | 下一请求边界 | +|---|---|---|---| +| 未激活 | Plan | `{ active: false, pending: true }` | 记录 `plan/mode: true`;快照变为已激活 | +| 已激活 | Default | `{ active: true, pending: false }` | 记录 `plan/mode: false`;快照变为未激活 | +| 未激活,Plan 待生效 | Default | `{ active: false, pending: false }` | 无需记录状态事件 | +| 功能不存在 | 任一选择 | `null` | 不会引入 plan 行为 | + +停止生成仍是单独的会话操作。待生效的选择会在取消后保留,并在下一条提示词或 continuation 到达服务边界时应用。 + +## 考虑过的替代方案 + +**仅折叠当前加载的历史页。** 不予采纳,因为按消息边界分页时,页面可能遗漏最近的模式事件;单个页面也无法表达待生效的意图,或区分空日志的未激活状态与功能不存在。 + +**在浏览器中维护乐观布尔值。** 不予采纳,因为工具审批通过的退出、其他客户端、恢复以及追加失败都可能与推测值不一致。浏览器只显示归属服务返回的状态。 + +**增加专用的 plan 控制帧。** 不予采纳,因为已提交状态已有记录到日志的 `plan/mode` 事件。完整状态的单次查询足以覆盖打开与重连场景,无需增加第二套实时事件词汇。 + +**公开通用具名协作模式。** 根据 plan 专用状态决策,不予采纳。ACP 可以保留通用适配器词汇,但产品目前只拥有一个具体的布尔值领域。 + +## 验证 + +- API schema 拒绝无效的请求与状态结构,两个 fetch 方向均可分派这两个方法。 +- 宿主运行时测试覆盖功能不存在、真实服务的待生效与取消状态、冷会话错误,以及共享 RPC 语义。 +- 客户端对象测试覆盖打开、选择成功、业务错误与传输失败、已提交的实时事件、格式错误及功能不可用时的事件、查询失败时的容错、重连刷新、针对已被取代查询的围栏保护,以及 mux 提交抢先于 unary 响应到达的情况。 + +## 后果 + +Web UI 包(package)无需导入 plan mode 的宿主实现即可发现该功能,也能显示边界处的待生效状态,而不必重复实现 plan 服务。其他客户端也可以使用同一个可选投影。该契约有意不把模式切换与停止操作合并,不发明通用模式标识符,也不要求每一种宿主组合都必须提供 plan 功能。 diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index c7e1ed5c68..58cd6d2cfc 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -7,7 +7,7 @@ export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PlanModeState, ToolEventView, } 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 7585ca9fba..3f409a6eef 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -522,6 +522,8 @@ export function createFixtureApi(): ApiProxy { } return ok(request, { accepted: true as const }) }, + planMode: request => ok(request, null), + setPlanMode: request => ok(request, null), }, host: { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }), @@ -633,6 +635,8 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.history': return this.api.sessions.history(request) case 'session.prompt': return this.api.sessions.prompt(request) case 'session.cancel': return this.api.sessions.cancel(request) + case 'session.planMode': return this.api.sessions.planMode(request) + case 'session.setPlanMode': return this.api.sessions.setPlanMode(request) case 'host.describe': return this.api.host.describe(request) } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index b017d1c9e2..f646bdf8b4 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -15,7 +15,7 @@ import { WebApiClient } from './web-api-client.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PlanModeState, ToolEventView, ToolCallView, ToolResultView, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index af5743bf9a..8472406da1 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -2,7 +2,7 @@ // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { - HostFrame, IApiClient, MuxFrame, RpcRequest, RpcResponse, SessionId, + HostFrame, IApiClient, MuxFrame, PlanModeState, RpcRequest, RpcResponse, SessionId, } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' @@ -49,6 +49,10 @@ export class FakeApiClient implements IApiClient { onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onPlanMode: (payload: unknown) => Promise> = + () => Promise.resolve(ok(null)) + onSetPlanMode: (payload: unknown) => Promise> = + () => Promise.resolve(ok(null)) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) @@ -65,6 +69,8 @@ export class FakeApiClient implements IApiClient { this.record('session.history', payload, this.onHistory(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), + planMode: (payload: unknown) => this.record('session.planMode', payload, this.onPlanMode(payload)), + setPlanMode: (payload: unknown) => this.record('session.setPlanMode', payload, this.onSetPlanMode(payload)), } readonly host: IApiClient['host'] = { diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index ac32955c36..0b03949e46 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -48,6 +48,16 @@ describe('createFixtureApi', () => { expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material }) + it('reports plan mode as an unavailable optional fixture capability', async () => { + const client = new FixtureApiClient() + expect((await client.sessions.planMode({ sessionId: sid('fx-alpha') })).result).toEqual({ + ok: true, value: null, + }) + expect((await client.sessions.setPlanMode({ sessionId: sid('fx-alpha'), active: true })).result).toEqual({ + ok: true, value: null, + }) + }) + it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => { const api = createFixtureApi() const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 })) diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 4fd5d15905..9c59627959 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -6,6 +6,10 @@ Client cordis boot + core services: SlotsService (Service wrapper over SlotCore `SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. +## Plan-mode projection + +Each opened `Session` queries the optional plan capability independently of paginated history and exposes `planMode: null | { active, pending? }` in its `ConversationSnapshot`. `null` hides consumers that require the capability. A successful selection replaces the snapshot with the host-confirmed committed and pending state; failures retain the previous state. Logged live `plan/mode` events commit `active` and clear `pending`, while reconnect re-queries the full state. A failed capability query never makes an otherwise usable conversation fail to open. + ## Model Experience None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request. diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 1e06fad70d..fa343be02b 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -4,7 +4,9 @@ // string here (narrow to real brands when convenient). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { RpcError, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { + PlanModeState, RpcError, SessionId, ToolCallView, ToolResultView, +} from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' /** Assistant content blocks sorted by what the UI cares about @@ -149,6 +151,8 @@ export interface ConversationSnapshot { partial: PartialAssistant | null runningCalls: readonly RunningToolCall[] pending: readonly PendingInteraction[] + /** Optional host plan capability; null hides plan controls. */ + planMode: PlanModeState | null running: boolean /** Set after host/session-removed; the UI grays out and disables input. */ removed: boolean diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index c394141d85..9803bf757a 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -6,7 +6,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, + HistoryEntry, IApiClient, MuxFrame, PlanModeState, RpcError, RpcId, RpcResult, SessionId, ToolEventView, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): @@ -54,6 +54,13 @@ export class Session implements ObservableSnapshot { * Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */ private frozenNodes: ConversationNode[] = [] private pending = new Map() + private planMode: PlanModeState | null = null + /** Whether a successful query established capability presence or absence. */ + private planCapabilityKnown = false + /** Monotonic local fence for committed plan events observed on the mux stream. */ + private planEventVersion = 0 + /** Latest valid commit, held until the initial capability query resolves. */ + private latestLivePlanMode: PlanModeState | null = null // Revision counters + caches backing the snapshot's reference-stability contract (§A.9.4/§C.2, // audit S5): buildSnapshot reuses the previous array when the revision is unchanged, so // React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every @@ -128,6 +135,29 @@ export class Session implements ObservableSnapshot { return result } + /** + * Select plan mode for the next model-request boundary. A successful + * response updates the snapshot immediately with the host-confirmed pending + * state; failures retain the prior state for the caller to report. + * + * @param active Whether plan mode should be selected. + * @returns The host-confirmed state, or null when plan mode is unavailable. + */ + async setPlanMode(active: boolean): Promise> { + const planEventVersion = this.planEventVersion + let result: RpcResult + try { + result = (await this.api.sessions.setPlanMode({ sessionId: this.sessionId, active })).result + } catch (error) { + result = transportError(error) + } + if (result.ok) { + this.applyPlanResponse(result.value, planEventVersion) + this.notifier.notifyNow() + } + 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() @@ -334,6 +364,7 @@ export class Session implements ObservableSnapshot { if (result.ok) this.installWindow(result.value.events, result.value.hasMore) } this.openState = 'open' + await this.refreshPlanMode(generation) } catch (error) { if (generation !== this.openGeneration) return this.openState = 'error' @@ -370,6 +401,55 @@ export class Session implements ObservableSnapshot { this.views.push(view) this.foldAdapter.append(event, view) this.applyEventSideEffects(event, view) + this.applyLivePlanMode(event) + } + + /** + * Refresh the optional plan capability without failing an otherwise valid + * conversation open. History remains usable when this independent control + * query fails; reconnect retries it. + */ + private async refreshPlanMode(generation: number): Promise { + const planEventVersion = this.planEventVersion + try { + const { result } = await this.api.sessions.planMode({ sessionId: this.sessionId }) + if (generation !== this.openGeneration) return + if (result.ok) this.applyPlanResponse(result.value, planEventVersion) + else console.error('[web-runtime] plan-mode query failed:', result.error) + } catch (error) { + if (generation !== this.openGeneration) return + console.error('[web-runtime] plan-mode query failed:', error) + } + } + + /** Apply a committed live plan event only when the host advertised the capability. */ + private applyLivePlanMode(event: SessionEvent): void { + const candidate = event as unknown as { type: string; data: unknown } + if (candidate.type !== 'plan/mode') return + if (typeof candidate.data !== 'object' || candidate.data === null) return + const data = candidate.data as { active?: unknown } + if (typeof data.active !== 'boolean') return + this.planEventVersion++ + this.latestLivePlanMode = { active: data.active } + if (this.planCapabilityKnown && this.planMode !== null) { + this.planMode = this.latestLivePlanMode + } + } + + /** + * Apply a unary plan snapshot unless a newer mux commit crossed the request. + * A successful null response establishes absence and never promotes a raw + * event into a capability. + */ + private applyPlanResponse(value: PlanModeState | null, requestVersion: number): void { + this.planCapabilityKnown = true + if (value === null) { + this.planMode = null + return + } + this.planMode = requestVersion === this.planEventVersion + ? value + : this.latestLivePlanMode ?? value } /** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop; @@ -531,6 +611,7 @@ export class Session implements ObservableSnapshot { partial: this.partial?.toPartial() ?? null, runningCalls: this.callsCache.value, pending: this.pendingCache.value, + planMode: this.planMode, running: this.running, removed: this.removed, openState: this.openState, diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 25f12c2b70..86b9a8d1e1 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -2,7 +2,8 @@ // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { - ClientResponse, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, + ClientResponse, HostFrame, IApiClient, MuxFrame, PlanModeState, RpcError, RpcReceipt, RpcRequest, + RpcResponse, SessionId, } from '@deepseek-ai/dsh-client-connection/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' @@ -52,6 +53,10 @@ export class FakeApiClient implements IApiClient { onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onPlanMode: (payload: unknown) => Promise> = + () => Promise.resolve(ok(null)) + onSetPlanMode: (payload: unknown) => Promise> = + () => Promise.resolve(ok(null)) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) @@ -68,6 +73,8 @@ export class FakeApiClient implements IApiClient { this.record('session.history', payload, this.onHistory(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), + planMode: (payload: unknown) => this.record('session.planMode', payload, this.onPlanMode(payload)), + setPlanMode: (payload: unknown) => this.record('session.setPlanMode', payload, this.onSetPlanMode(payload)), } readonly host: IApiClient['host'] = { diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index b980a674fe..3cc536c0d5 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -83,6 +83,115 @@ describe('open', () => { }) }) +describe('plan mode projection', () => { + it('loads the optional capability and applies a host-confirmed pending selection', async () => { + const { api, session } = makeSession() + api.onPlanMode = () => Promise.resolve(ok({ active: false })) + await session.open() + expect(session.getSnapshot().planMode).toEqual({ active: false }) + expect(api.callsOf('session.planMode')).toEqual([{ sessionId: SID }]) + + api.onSetPlanMode = () => Promise.resolve(ok({ active: false, pending: true })) + const result = await session.setPlanMode(true) + expect(result).toEqual({ ok: true, value: { active: false, pending: true } }) + expect(api.callsOf('session.setPlanMode')).toEqual([{ sessionId: SID, active: true }]) + expect(session.getSnapshot().planMode).toEqual({ active: false, pending: true }) + }) + + it('retains the prior state when a selection fails at the business or transport layer', async () => { + const { api, session } = makeSession() + api.onPlanMode = () => Promise.resolve(ok({ active: true })) + await session.open() + api.onSetPlanMode = () => Promise.resolve(err({ + code: 'internal', message: 'selection failed', details: {}, + })) + expect((await session.setPlanMode(false)).ok).toBe(false) + expect(session.getSnapshot().planMode).toEqual({ active: true }) + + api.onSetPlanMode = () => Promise.reject(new Error('wire down')) + expect((await session.setPlanMode(false)).ok).toBe(false) + expect(session.getSnapshot().planMode).toEqual({ active: true }) + }) + + it('commits a live plan event, clears pending, and ignores malformed or unavailable projections', async () => { + const available = makeSession() + available.api.onPlanMode = () => Promise.resolve(ok({ active: false, pending: true })) + await available.session.open() + available.session.handleMuxEnvelope('rp1' as never, { + type: 'session/event', + sessionId: SID, + event: at(0, { type: 'plan/mode', data: { active: 'yes' } }), + }) + expect(available.session.getSnapshot().planMode).toEqual({ active: false, pending: true }) + available.session.handleMuxEnvelope('rp2' as never, { + type: 'session/event', + sessionId: SID, + event: at(1, { type: 'plan/mode', data: { active: true } }), + }) + expect(available.session.getSnapshot().planMode).toEqual({ active: true }) + + const unavailable = makeSession() + await unavailable.session.open() + unavailable.session.handleMuxEnvelope('rp3' as never, { + type: 'session/event', + sessionId: SID, + event: at(0, { type: 'plan/mode', data: { active: true } }), + }) + expect(unavailable.session.getSnapshot().planMode).toBeNull() + }) + + it('keeps a mux commit that overtakes the initial query or a selection response', async () => { + const initial = makeSession() + const initialQuery = deferred>>() + initial.api.onPlanMode = () => initialQuery.promise + const opening = initial.session.open() + await vi.waitFor(() => { + expect(initial.api.callsOf('session.planMode')).toHaveLength(1) + }) + initial.session.handleMuxEnvelope('rp-overtake-open' as never, { + type: 'session/event', + sessionId: SID, + event: at(0, { type: 'plan/mode', data: { active: true } }), + }) + expect(initial.session.getSnapshot().planMode).toBeNull() + initialQuery.resolve(ok({ active: false })) + await opening + expect(initial.session.getSnapshot().planMode).toEqual({ active: true }) + + const selection = makeSession() + selection.api.onPlanMode = () => Promise.resolve(ok({ active: false })) + await selection.session.open() + const selectionResponse = deferred>>() + selection.api.onSetPlanMode = () => selectionResponse.promise + const selecting = selection.session.setPlanMode(true) + selection.session.handleMuxEnvelope('rp-overtake-set' as never, { + type: 'session/event', + sessionId: SID, + event: at(0, { type: 'plan/mode', data: { active: true } }), + }) + selectionResponse.resolve(ok({ active: false, pending: true })) + await selecting + expect(selection.session.getSnapshot().planMode).toEqual({ active: true }) + }) + + it('keeps history usable when the independent capability query fails', async () => { + const business = makeSession() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + business.api.onPlanMode = () => Promise.resolve(err({ + code: 'internal', message: 'query failed', details: {}, + })) + await business.session.open() + expect(business.session.getSnapshot()).toMatchObject({ openState: 'open', planMode: null }) + + const transport = makeSession() + transport.api.onPlanMode = () => Promise.reject(new Error('query wire down')) + await transport.session.open() + expect(transport.session.getSnapshot()).toMatchObject({ openState: 'open', planMode: null }) + expect(errorSpy).toHaveBeenCalledTimes(2) + errorSpy.mockRestore() + }) +}) + describe('live event path', () => { async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) { const { api, session } = makeSession() @@ -598,6 +707,22 @@ describe('resync', () => { expect(cold.api.calls).toEqual([]) // never opened: no traffic }) + it('refreshes plan state and drops a superseded open query result', async () => { + const { api, session } = makeSession() + const stale = deferred>>() + api.onPlanMode = () => stale.promise + const opening = session.open() + await vi.waitFor(() => { + expect(api.callsOf('session.planMode')).toHaveLength(1) + }) + api.onPlanMode = () => Promise.resolve(ok({ active: true })) + const resynced = session.resync() + stale.resolve(ok({ active: false })) + await Promise.all([opening, resynced]) + expect(api.callsOf('session.planMode')).toHaveLength(2) + expect(session.getSnapshot().planMode).toEqual({ active: true }) + }) + it('re-mints a replayed requested frame as a fresh wait with the same key (old reference superseded)', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) 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 2686a59ac2..f89acae6ab 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 @@ -28,7 +28,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, planMode: null, } } diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 69cb2cfa09..0fdd21b94a 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -40,7 +40,7 @@ function snapshotWith(nodes: ToolResultNode[]): 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, planMode: null, } as ConversationSnapshot } diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 90c2f3090c..668268f3d8 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -30,7 +30,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, planMode: null, } } 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 c59b1c7527..73c301165a 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -25,7 +25,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, planMode: null, } as ConversationSnapshot } diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index b91fe229c3..326031e71c 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -29,7 +29,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, planMode: null, } as ConversationSnapshot } diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 1badfe65e8..774d771eba 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -10,6 +10,8 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. +Plan mode uses two unary methods instead of deriving current state from a history page: `session.planMode` returns the committed state plus any boundary-pending selection, and `session.setPlanMode` records a selection and returns the same authoritative shape. Both return `null` when the optional host service is absent; `null` is capability absence, while `{ active: false }` is a supported inactive session. Committed changes still arrive through the raw logged `plan/mode` session event. + ## Carrier layer (`/client` + root) `AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless. diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index c2fbb0d189..c318024c2f 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -19,7 +19,7 @@ export interface ApiProxy { } // ---- Domain interfaces and payload entities ---- -export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts' +export type { HistoryEntry, PlanModeState, SessionsApi, SessionSummary } from './sessions.ts' export type { HostApi } from './host.ts' export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { ApprovalResponsePayload } from './approvals.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index b37cc062ff..2f8ebd00f6 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -15,6 +15,8 @@ export interface RpcMethodMap { 'session.history': SessionsApi['history'] 'session.prompt': SessionsApi['prompt'] 'session.cancel': SessionsApi['cancel'] + 'session.planMode': SessionsApi['planMode'] + 'session.setPlanMode': SessionsApi['setPlanMode'] 'host.describe': HostApi['describe'] } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 3edf0e6014..7ae09aa116 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -9,7 +9,7 @@ import { z } from 'zod' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import type { HistoryEntry, SessionSummary } from './sessions.ts' +import type { HistoryEntry, PlanModeState, SessionSummary } from './sessions.ts' import type { ToolEventView } from './events.ts' /** SessionId: one brand cast after shape validation (the only cast point in this domain). */ @@ -108,3 +108,28 @@ export const sessionCancelRequestSchema = z.object({ export const sessionCancelValueSchema = z.object({ accepted: z.literal(true), }) satisfies z.ZodType>> + +/** Plan state shared by the read and selection responses. */ +export const planModeStateSchema = z.object({ + active: z.boolean(), + pending: z.boolean().optional(), +}) satisfies z.ZodType> + +/** session.planMode request payload. */ +export const sessionPlanModeRequestSchema = z.object({ + sessionId: sessionIdSchema, +}) satisfies z.ZodType>> + +/** session.planMode response value; null means the optional service is absent. */ +export const sessionPlanModeValueSchema = + planModeStateSchema.nullable() satisfies z.ZodType>> + +/** session.setPlanMode request payload. */ +export const sessionSetPlanModeRequestSchema = z.object({ + sessionId: sessionIdSchema, + active: z.boolean(), +}) satisfies z.ZodType>> + +/** session.setPlanMode response value; null means the optional service is absent. */ +export const sessionSetPlanModeValueSchema = + planModeStateSchema.nullable() satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 393303f817..48a04230ab 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -44,6 +44,16 @@ export interface SessionSummary { cwd?: string } +/** + * Plan collaboration state exposed to clients. `active` is the logged state + * shaping the current request; `pending`, when present, is the user's + * next-boundary selection. + */ +export interface PlanModeState { + active: boolean + pending?: boolean +} + /** Session-domain unary methods (the map keys session.* of RpcMethodMap). */ export interface SessionsApi { /** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */ @@ -70,4 +80,18 @@ export interface SessionsApi { /** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */ cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise> + + /** + * Reads plan collaboration state. `null` means the host did not compose the + * optional plan-mode service; it is distinct from inactive state. + */ + planMode(request: RpcRequest<{ sessionId: SessionId }>): Promise> + + /** + * Selects plan collaboration state for the next model-request boundary. + * The returned state exposes the still-committed value and pending target; + * `null` means plan mode is unavailable on this host. + */ + setPlanMode(request: RpcRequest<{ sessionId: SessionId; active: boolean }>): + Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 901cf7bd2a..80554f3112 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -19,7 +19,9 @@ import { sessionCreateValueSchema, sessionHistoryValueSchema, sessionListValueSchema, + sessionPlanModeValueSchema, sessionPromptValueSchema, + sessionSetPlanModeValueSchema, } from '../api/sessions.schema.ts' /** @@ -44,6 +46,8 @@ export interface IApiClient { history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise>> cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise>> + planMode(payload: RequestPayload<'session.planMode'>, signal?: AbortSignal): Promise>> + setPlanMode(payload: RequestPayload<'session.setPlanMode'>, signal?: AbortSignal): Promise>> } host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> @@ -66,6 +70,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('session.history', payload, signal), prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal), cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal), + planMode: (payload, signal) => this.callUnary('session.planMode', payload, signal), + setPlanMode: (payload, signal) => this.callUnary('session.setPlanMode', payload, signal), } readonly host: IApiClient['host'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 03b9f6500f..f49fea7ac2 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -19,7 +19,9 @@ import { sessionCreateRequestSchema, sessionHistoryRequestSchema, sessionListRequestSchema, + sessionPlanModeRequestSchema, sessionPromptRequestSchema, + sessionSetPlanModeRequestSchema, } from '../api/sessions.schema.ts' import { hostDescribeRequestSchema } from '../api/host.schema.ts' @@ -43,6 +45,8 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) }, 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, + 'session.planMode': { schema: sessionPlanModeRequestSchema, invoke: (api, r) => api.sessions.planMode(r) }, + 'session.setPlanMode': { schema: sessionSetPlanModeRequestSchema, invoke: (api, r) => api.sessions.setPlanMode(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, } diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 25af7e2f75..c8d134ea08 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -31,6 +31,8 @@ function scriptedApi(overrides: { history: r => ok(r, { events: [], hasMore: false }), prompt: r => ok(r, { accepted: true as const }), cancel: r => ok(r, { accepted: true as const }), + planMode: r => ok(r, null), + setPlanMode: r => ok(r, null), ...overrides.sessions, }, host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), ...overrides.host }, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index d097daecef..7464d19d16 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -36,6 +36,12 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async cancel(request) { return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, + async planMode(request) { + return { rpcId: request.rpcId, result: { ok: true, value: null } } + }, + async setPlanMode(request) { + return { rpcId: request.rpcId, result: { ok: true, value: null } } + }, }, host: { async describe(request) { @@ -75,11 +81,13 @@ describe('unary round trip (handler ⇄ client, no network)', () => { if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found') }) - it('covers create/prompt/cancel/describe passthrough', async () => { + it('covers create/prompt/cancel/plan/describe passthrough', async () => { const c = client() expect((await c.sessions.create({})).result.ok).toBe(true) expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true) expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true) + expect((await c.sessions.planMode({ sessionId: 's' as never })).result).toEqual({ ok: true, value: null }) + expect((await c.sessions.setPlanMode({ sessionId: 's' as never, active: true })).result).toEqual({ ok: true, value: null }) expect((await c.host.describe({})).result.ok).toBe(true) }) }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 0c3eb2b320..656be7b36a 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -9,7 +9,8 @@ import { contentBlockSchema, sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema, sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema, sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionPromptRequestSchema, - sessionPromptValueSchema, sessionSummarySchema, + sessionPlanModeRequestSchema, sessionPlanModeValueSchema, sessionPromptValueSchema, + sessionSetPlanModeRequestSchema, sessionSetPlanModeValueSchema, sessionSummarySchema, } from '../src/api/sessions.schema.ts' import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts' @@ -106,6 +107,13 @@ describe('sessions domain schemas', () => { expect(sessionPromptValueSchema.parse({ accepted: true }).accepted).toBe(true) expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true) + expect(sessionPlanModeRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') + expect(sessionPlanModeValueSchema.parse({ active: false, pending: true })).toEqual({ active: false, pending: true }) + expect(sessionPlanModeValueSchema.parse(null)).toBeNull() + expect(sessionSetPlanModeRequestSchema.parse({ sessionId: 's1', active: true }).active).toBe(true) + expect(sessionSetPlanModeValueSchema.parse({ active: true })).toEqual({ active: true }) + expect(() => sessionSetPlanModeRequestSchema.parse({ sessionId: 's1', active: 'yes' })).toThrow() + expect(() => sessionPlanModeValueSchema.parse({ active: 'yes' })).toThrow() expect(contentBlockSchema.parse({ type: 'text', text: 'x', extra: 1 })).toMatchObject({ extra: 1 }) }) }) diff --git a/packages/host/runtime/README.md b/packages/host/runtime/README.md index f7cf7d9de8..42b3a4c64d 100644 --- a/packages/host/runtime/README.md +++ b/packages/host/runtime/README.md @@ -18,7 +18,7 @@ Which plugins mount and with what defaults is decided only here — shells must ## ApiProxy implementation notes -Unary methods take the narrow `RpcRequest

` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. The host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position. +Unary methods take the narrow `RpcRequest

` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). `planMode` and `setPlanMode` use the same resume path, project the optional `ctx.planMode` service, and return `null` when it is not mounted. The mux stream replays a `session/subscribed` baseline per attached session and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. The host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position. ## Model Experience diff --git a/packages/host/runtime/package.json b/packages/host/runtime/package.json index 94a544fb74..de56c51185 100644 --- a/packages/host/runtime/package.json +++ b/packages/host/runtime/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 2d674912cd..f035879f5a 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -21,6 +21,9 @@ import type { AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest, } from '@deepseek-ai/dsh-user-interaction' import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' +// Type-only optional edge: resolves ctx.get('planMode') without requiring the +// product assembly to mount plan mode. +import type {} from '@deepseek-ai/dsh-plan-mode' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 @@ -458,6 +461,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro agent.cancel() return Promise.resolve(ok(request, { accepted: true as const })) }, + + async planMode(request) { + const found = await agentFor(request.payload.sessionId) + if ('error' in found) return err(request, found.error) + const planMode = ctx.get('planMode') + return ok(request, planMode?.get(found.agent) ?? null) + }, + + async setPlanMode(request) { + const found = await agentFor(request.payload.sessionId) + if ('error' in found) return err(request, found.error) + const planMode = ctx.get('planMode') + if (planMode === undefined) return ok(request, null) + planMode.set(found.agent, request.payload.active) + return ok(request, planMode.get(found.agent)) + }, }, host: { diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index c30e07b50b..712b10b576 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -13,6 +13,7 @@ import type { Config as SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-t import type { HostFrame, MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import PlanModeService from '@deepseek-ai/dsh-plan-mode' import { bootHost, startHost, type HostHandle, type RunningHost } from '../src/index.ts' /** Scripted adapter: each model call consumes the next chunk list; 'hang' streams then waits for abort. */ @@ -233,6 +234,44 @@ describe('sessions.create / list', () => { }) }) +describe('sessions.planMode / setPlanMode', () => { + it('reports the optional service absence without conflating it with inactive mode', async () => { + const { api } = await boot() + const { sessionId } = expectOk(await api.sessions.create(request({}))) + expect(expectOk(await api.sessions.planMode(request({ sessionId })))).toBeNull() + expect(expectOk(await api.sessions.setPlanMode(request({ sessionId, active: true })))).toBeNull() + }) + + it('projects committed and pending state from the real plan service', async () => { + const running = await boot() + await running.ctx.plugin(PlanModeService, { section: 'Plan before acting.' }) + const { sessionId } = expectOk(await running.api.sessions.create(request({}))) + + expect(expectOk(await running.api.sessions.planMode(request({ sessionId })))).toEqual({ + active: false, + }) + expect(expectOk(await running.api.sessions.setPlanMode(request({ sessionId, active: true })))).toEqual({ + active: false, + pending: true, + }) + expect(expectOk(await running.api.sessions.setPlanMode(request({ sessionId, active: false })))).toEqual({ + active: false, + pending: false, + }) + }) + + it('returns the normal session-not-found error for both methods', async () => { + const { api } = await boot() + const sessionId = 'missing-plan-session' as SessionId + expect((await api.sessions.planMode(request({ sessionId }))).result).toMatchObject({ + ok: false, error: { code: 'session-not-found' }, + }) + expect((await api.sessions.setPlanMode(request({ sessionId, active: true }))).result).toMatchObject({ + ok: false, error: { code: 'session-not-found' }, + }) + }) +}) + describe('sessions.prompt / cancel', () => { it.each([ { name: 'host default', config: true, target: '5 words', maxTokens: 64 }, diff --git a/packages/host/runtime/tsconfig.json b/packages/host/runtime/tsconfig.json index aee28b5371..68046c712a 100644 --- a/packages/host/runtime/tsconfig.json +++ b/packages/host/runtime/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../llm/llm-deepseek" }, + { + "path": "../../plan/plan-mode" + }, { "path": "../../core/session" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e0d5cd798..fd04f77d39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2106,6 +2106,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-plan-mode': + specifier: workspace:^ + version: link:../../plan/plan-mode '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session