feat(web): project plan mode through host API

This commit is contained in:
fz
2026-07-24 12:09:04 +08:00
parent bc7a89b81f
commit bc63b5fe00
34 changed files with 515 additions and 16 deletions
@@ -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
@@ -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.
@@ -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 功能。
+1 -1
View File
@@ -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 {
@@ -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)
}
}
@@ -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,
+7 -1
View File
@@ -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<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onPlanMode: (payload: unknown) => Promise<RpcResponse<PlanModeState | null>> =
() => Promise.resolve(ok(null))
onSetPlanMode: (payload: unknown) => Promise<RpcResponse<PlanModeState | null>> =
() => Promise.resolve(ok(null))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => 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'] = {
@@ -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 }))
+4
View File
@@ -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.
@@ -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
@@ -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<ConversationSnapshot> {
* Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
private frozenNodes: ConversationNode[] = []
private pending = new Map<string, PendingInteraction>()
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<ConversationSnapshot> {
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<RpcResult<PlanModeState | null>> {
const planEventVersion = this.planEventVersion
let result: RpcResult<PlanModeState | null>
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<void> {
if (this.openState === 'open') return Promise.resolve()
@@ -334,6 +364,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
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<ConversationSnapshot> {
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<void> {
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<ConversationSnapshot> {
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,
+8 -1
View File
@@ -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<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onPlanMode: (payload: unknown) => Promise<RpcResponse<PlanModeState | null>> =
() => Promise.resolve(ok(null))
onSetPlanMode: (payload: unknown) => Promise<RpcResponse<PlanModeState | null>> =
() => Promise.resolve(ok(null))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
() => 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'] = {
@@ -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<Awaited<ReturnType<FakeApiClient['onPlanMode']>>>()
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<Awaited<ReturnType<FakeApiClient['onSetPlanMode']>>>()
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<Awaited<ReturnType<FakeApiClient['onPlanMode']>>>()
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'))
@@ -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,
}
}
@@ -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
}
@@ -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,
}
}
@@ -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
}
@@ -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
}
+2
View File
@@ -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.
+1 -1
View File
@@ -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'
@@ -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']
}
@@ -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<Wire<ResponseValue<'session.cancel'>>>
/** Plan state shared by the read and selection responses. */
export const planModeStateSchema = z.object({
active: z.boolean(),
pending: z.boolean().optional(),
}) satisfies z.ZodType<Wire<PlanModeState>>
/** session.planMode request payload. */
export const sessionPlanModeRequestSchema = z.object({
sessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'session.planMode'>>>
/** session.planMode response value; null means the optional service is absent. */
export const sessionPlanModeValueSchema =
planModeStateSchema.nullable() satisfies z.ZodType<Wire<ResponseValue<'session.planMode'>>>
/** session.setPlanMode request payload. */
export const sessionSetPlanModeRequestSchema = z.object({
sessionId: sessionIdSchema,
active: z.boolean(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.setPlanMode'>>>
/** session.setPlanMode response value; null means the optional service is absent. */
export const sessionSetPlanModeValueSchema =
planModeStateSchema.nullable() satisfies z.ZodType<Wire<ResponseValue<'session.setPlanMode'>>>
@@ -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<RpcResponse<{ accepted: true }>>
/**
* 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<RpcResponse<PlanModeState | null>>
/**
* 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<RpcResponse<PlanModeState | null>>
}
@@ -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<RpcResponse<ResponseValue<'session.history'>>>
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
planMode(payload: RequestPayload<'session.planMode'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.planMode'>>>
setPlanMode(payload: RequestPayload<'session.setPlanMode'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.setPlanMode'>>>
}
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
@@ -66,6 +70,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.history': sessionHistoryValueSchema,
'session.prompt': sessionPromptValueSchema,
'session.cancel': sessionCancelValueSchema,
'session.planMode': sessionPlanModeValueSchema,
'session.setPlanMode': sessionSetPlanModeValueSchema,
'host.describe': hostDescribeValueSchema,
}
@@ -247,6 +253,8 @@ export abstract class AbstractApiClient implements IApiClient {
history: (payload, signal) => 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'] = {
@@ -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) },
}
@@ -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 },
@@ -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)
})
})
@@ -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 })
})
})
+1 -1
View File
@@ -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<P>` 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<P>` 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
+1
View File
@@ -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:^",
+19
View File
@@ -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: {
@@ -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 },
+3
View File
@@ -20,6 +20,9 @@
{
"path": "../../llm/llm-deepseek"
},
{
"path": "../../plan/plan-mode"
},
{
"path": "../../core/session"
},
+3
View File
@@ -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