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

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 {

View File

@@ -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)
}
}

View File

@@ -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,

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'] = {

View File

@@ -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 }))

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.

View File

@@ -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

View File

@@ -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,

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'] = {

View File

@@ -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'))

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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
}

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.

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'

View File

@@ -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']
}

View File

@@ -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'>>>

View File

@@ -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>>
}

View File

@@ -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'] = {

View File

@@ -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) },
}

View File

@@ -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 },

View File

@@ -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)
})
})

View File

@@ -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 })
})
})

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

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:^",

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: {

View File

@@ -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 },

View File

@@ -20,6 +20,9 @@
{
"path": "../../llm/llm-deepseek"
},
{
"path": "../../plan/plan-mode"
},
{
"path": "../../core/session"
},