feat(web): project plan mode through host API
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../llm/llm-deepseek"
|
||||
},
|
||||
{
|
||||
"path": "../../plan/plan-mode"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user