fix(web): fence plan mode projection races

This commit is contained in:
fz
2026-07-24 13:16:26 +08:00
parent bc63b5fe00
commit ced6ab9d14
13 changed files with 119 additions and 32 deletions

View File

@@ -10,7 +10,7 @@ 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.
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. A present `pending` target must differ from `active`; a net-zero service cleanup intent projects as `{ active }`, and the wire schema rejects equal values. Both methods 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)

View File

@@ -113,7 +113,10 @@ export const sessionCancelValueSchema = z.object({
export const planModeStateSchema = z.object({
active: z.boolean(),
pending: z.boolean().optional(),
}) satisfies z.ZodType<Wire<PlanModeState>>
}).refine(
state => state.pending === undefined || state.pending !== state.active,
{ message: 'pending must differ from active when present', path: ['pending'] },
) satisfies z.ZodType<Wire<PlanModeState>>
/** session.planMode request payload. */
export const sessionPlanModeRequestSchema = z.object({

View File

@@ -47,7 +47,7 @@ export interface SessionSummary {
/**
* 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.
* next-boundary selection and differs from `active`.
*/
export interface PlanModeState {
active: boolean

View File

@@ -114,6 +114,8 @@ describe('sessions domain schemas', () => {
expect(sessionSetPlanModeValueSchema.parse({ active: true })).toEqual({ active: true })
expect(() => sessionSetPlanModeRequestSchema.parse({ sessionId: 's1', active: 'yes' })).toThrow()
expect(() => sessionPlanModeValueSchema.parse({ active: 'yes' })).toThrow()
expect(() => sessionPlanModeValueSchema.parse({ active: false, pending: false })).toThrow()
expect(() => sessionSetPlanModeValueSchema.parse({ active: true, pending: true })).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). `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.
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, canonicalize a net-zero cleanup intent by omitting `pending`, and return `null` when the service 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

@@ -12,7 +12,8 @@ import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type {
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
ApiProxy, HistoryEntry, HostFrame, MuxFrame, PlanModeState, QuestionResponsePayload, SessionSummary,
ToolEventView,
} from '@deepseek-ai/dsh-host-apiproxy/api'
import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -65,6 +66,16 @@ function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
return { rpcId: request.rpcId, result: { ok: true, value } }
}
/**
* Project the service's boundary-cleanup intent into canonical wire state.
* A target equal to the committed value has no user-visible pending effect.
*/
function projectPlanModeState(state: PlanModeState): PlanModeState {
return state.pending !== undefined && state.pending === state.active
? { active: state.active }
: state
}
/** Wrap an error result echoing the request's rpcId. */
function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
return { rpcId: request.rpcId, result: { ok: false, error } }
@@ -466,7 +477,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
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)
return ok(request, planMode === undefined ? null : projectPlanModeState(planMode.get(found.agent)))
},
async setPlanMode(request) {
@@ -475,7 +486,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
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))
return ok(request, projectPlanModeState(planMode.get(found.agent)))
},
},

View File

@@ -256,8 +256,8 @@ describe('sessions.planMode / setPlanMode', () => {
})
expect(expectOk(await running.api.sessions.setPlanMode(request({ sessionId, active: false })))).toEqual({
active: false,
pending: false,
})
expect(expectOk(await running.api.sessions.planMode(request({ sessionId })))).toEqual({ active: false })
})
it('returns the normal session-not-found error for both methods', async () => {