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
+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"
},