From ced6ab9d14d689dd13eb7b6c19ed41d82b0c6cd6 Mon Sep 17 00:00:00 2001 From: fz Date: Fri, 24 Jul 2026 13:16:26 +0800 Subject: [PATCH] fix(web): fence plan mode projection races --- ...6-07-24-web-plan-mode-projection.i18n.yaml | 4 +- .../2026-07-24-web-plan-mode-projection.md | 14 +++--- .../2026-07-24-web-plan-mode-projection.zh.md | 14 +++--- packages/client/runtime/README.md | 2 +- .../runtime/src/client/sessions/session.ts | 42 +++++++++++++++--- packages/client/runtime/tests/session.spec.ts | 43 +++++++++++++++++++ packages/host/apiproxy/README.md | 2 +- .../host/apiproxy/src/api/sessions.schema.ts | 5 ++- packages/host/apiproxy/src/api/sessions.ts | 2 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 2 + packages/host/runtime/README.md | 2 +- packages/host/runtime/src/api-proxy.ts | 17 ++++++-- .../host/runtime/tests/host-runtime.spec.ts | 2 +- 13 files changed, 119 insertions(+), 32 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.i18n.yaml index cc9cab8e37..387a5f16a8 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.i18n.yaml @@ -2,5 +2,5 @@ # 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 +2026-07-24-web-plan-mode-projection.md: 9c894a30a0c456d2d6216baa23220db8d68fdda3 +2026-07-24-web-plan-mode-projection.zh.md: 9f8876c4f222059066ff1bff42aebc37acb5683c diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.md b/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.md index bff1e6924e..9c894a30a0 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.md @@ -14,11 +14,11 @@ The host does not mount plan mode for every product composition. The wire must d 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 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 and differs from `active`; its presence, rather than its boolean value, identifies a user-visible pending transition. Re-selecting the committed value can leave an internal cleanup intent in the service, but the adapter canonicalizes that net-zero state to `{ active }`. The boundary then removes the intent without logging a redundant state event. The wire schema rejects equal `active` and `pending` values. 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. +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 open from overwriting the newer result. One monotonic plan-request fence covers both queries and selections, so an older unary response cannot replace the result of a newer request. A separate local event-version fence prevents a current 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. +Committed `plan/mode` session events remain the live notification. When the host advertised the capability, a valid event replaces `active` and clears `pending`. Both the append path and a history replacement window observe the newest valid plan event by sequence, so gap repair applies a recovered commit even when the buffered triggering frame becomes replay overlap. 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 @@ -26,7 +26,7 @@ Committed `plan/mode` session events remain the live notification. When the host |---|---|---|---| | 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 | +| Inactive with pending Plan | Default | `{ active: 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. @@ -43,9 +43,9 @@ Stopping generation remains a separate session operation. A pending selection su ## 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. +- API schemas reject invalid request and state shapes, including equal committed and pending values, and both fetch directions dispatch the two methods. +- Host runtime tests cover capability absence, real-service pending state, canonical net-zero cancellation, cold-session errors, and shared RPC semantics. +- Client object tests cover open, selection success, overlapping selection response order, business and transport failure, committed live events, gap-repaired commits, malformed and unavailable events, fail-soft queries, reconnect refresh, superseded-query fencing, and mux commits overtaking unary responses. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.zh.md index 039e0e10c5..9f8876c4f2 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-plan-mode-projection.zh.md @@ -14,11 +14,11 @@ plan 服务拥有持久状态和边界时序,但 Web 宿主契约无法发现 会话 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 不会取消正在执行的请求,因此生成期间作出的选择不会改变本次请求,只会影响下一次请求。 +宿主适配器把选择与折叠工作交给 `ctx.planMode`,不会自行追加事件或重复实现边界逻辑。`active` 是最近一次已提交并记录到日志的值。`pending` 存在时,其值是等待模型请求边界生效的所选目标,且必定不同于 `active`;表示用户可见的待生效转换的是该字段是否存在,而不是其布尔值。重新选择已提交值时,服务内部可能仍留有一项清理意图,但适配器会把这一无净变化状态规范化为 `{ active }`。随后,边界会移除该意图,且不会记录多余的状态事件。协议 schema 会拒绝 `active` 与 `pending` 相等的值。该 RPC 不会取消正在执行的请求,因此生成期间作出的选择不会改变本次请求,只会影响下一次请求。 -浏览器会话对象在历史记录加载完成后以及重连时查询完整状态。plan 查询失败不会阻断其他功能:历史记录仍可使用,并保留最近一次已知的功能状态。重连使用代际围栏,避免已被取代的查询覆盖较新的结果。另一道独立的本地事件版本围栏会阻止查询或选择响应覆盖 mux 流中已抢先到达的 `plan/mode` 提交;提前到达的提交会保持为内部状态,直到查询成功并确认该功能存在。除上述情况外,选择成功后,只有宿主确认的响应才会更新快照;业务错误和传输失败都会保留先前状态。 +浏览器会话对象在历史记录加载完成后以及重连时查询完整状态。plan 查询失败不会阻断其他功能:历史记录仍可使用,并保留最近一次已知的功能状态。重连使用代际围栏,避免已被取代的打开流程覆盖较新的结果。一个单调递增的 plan 请求围栏同时覆盖查询和选择,因此较早的 unary 响应无法替换较新请求的结果。另一道独立的本地事件版本围栏会阻止当前查询或选择响应覆盖 mux 流中已抢先到达的 `plan/mode` 提交;提前到达的提交会保持为内部状态,直到查询成功并确认该功能存在。除上述情况外,选择成功后,只有宿主确认的响应才会更新快照;业务错误和传输失败都会保留先前状态。 -已提交的 `plan/mode` 会话事件仍作为实时通知。当宿主已公布该功能时,有效事件会替换 `active` 并清除 `pending`。对象层会忽略格式错误的事件,也不会仅凭一条原始事件推断功能是否可用。这样既以完整状态读取为真源,又保留现有的日志事件流作为提交信号。 +已提交的 `plan/mode` 会话事件仍作为实时通知。当宿主已公布该功能时,有效事件会替换 `active` 并清除 `pending`。追加路径和历史替换窗口都会按序号采用最新的有效 plan 事件,因此即使缓冲的触发帧在回放时已与窗口重叠,缺口回补仍会应用恢复出的提交。对象层会忽略格式错误的事件,也不会仅凭一条原始事件推断功能是否可用。这样既以完整状态读取为真源,又保留现有的日志事件流作为提交信号。 ## 状态与时序 @@ -26,7 +26,7 @@ plan 服务拥有持久状态和边界时序,但 Web 宿主契约无法发现 |---|---|---|---| | 未激活 | Plan | `{ active: false, pending: true }` | 记录 `plan/mode: true`;快照变为已激活 | | 已激活 | Default | `{ active: true, pending: false }` | 记录 `plan/mode: false`;快照变为未激活 | -| 未激活,Plan 待生效 | Default | `{ active: false, pending: false }` | 无需记录状态事件 | +| 未激活,Plan 待生效 | Default | `{ active: false }` | 无需记录状态事件 | | 功能不存在 | 任一选择 | `null` | 不会引入 plan 行为 | 停止生成仍是单独的会话操作。待生效的选择会在取消后保留,并在下一条提示词或 continuation 到达服务边界时应用。 @@ -43,9 +43,9 @@ plan 服务拥有持久状态和边界时序,但 Web 宿主契约无法发现 ## 验证 -- API schema 拒绝无效的请求与状态结构,两个 fetch 方向均可分派这两个方法。 -- 宿主运行时测试覆盖功能不存在、真实服务的待生效与取消状态、冷会话错误,以及共享 RPC 语义。 -- 客户端对象测试覆盖打开、选择成功、业务错误与传输失败、已提交的实时事件、格式错误及功能不可用时的事件、查询失败时的容错、重连刷新、针对已被取代查询的围栏保护,以及 mux 提交抢先于 unary 响应到达的情况。 +- API schema 拒绝无效的请求与状态结构,包括 `active` 与 `pending` 相等的情况;两个 fetch 方向均可分派这两个方法。 +- 宿主运行时测试覆盖功能不存在、真实服务的待生效状态、取消时无净变化状态的规范化、冷会话错误,以及共享 RPC 语义。 +- 客户端对象测试覆盖打开、选择成功、重叠选择请求的响应顺序、业务错误与传输失败、已提交的实时事件、经缺口回补的提交、格式错误及功能不可用时的事件、查询失败时的容错、重连刷新、针对已被取代查询的围栏保护,以及 mux 提交抢先于 unary 响应到达的情况。 ## 后果 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 9c59627959..7d81886cee 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -8,7 +8,7 @@ Client cordis boot + core services: SlotsService (Service wrapper over SlotCore ## 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. +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 present `pending` differs from `active`. A successful selection replaces the snapshot with the host-confirmed committed and pending state; failures retain the previous state. A shared request fence drops stale plan query and selection responses, while an event-version fence preserves a commit that overtakes a current unary request. Logged live `plan/mode` events commit `active` and clear `pending`; replacement history windows also fold their latest plan event so gap repair cannot miss a recovered commit. Reconnect re-queries the full state, and a failed capability query never makes an otherwise usable conversation fail to open. ## Model Experience diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 9803bf757a..4015d16e07 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -59,6 +59,10 @@ export class Session implements ObservableSnapshot { private planCapabilityKnown = false /** Monotonic local fence for committed plan events observed on the mux stream. */ private planEventVersion = 0 + /** Highest plan event seq observed through an append or replacement window. */ + private latestPlanEventSeq: number | null = null + /** Monotonic fence shared by plan queries and selections; only the latest response may land. */ + private planRequestVersion = 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, @@ -145,6 +149,7 @@ export class Session implements ObservableSnapshot { */ async setPlanMode(active: boolean): Promise> { const planEventVersion = this.planEventVersion + const planRequestVersion = ++this.planRequestVersion let result: RpcResult try { result = (await this.api.sessions.setPlanMode({ sessionId: this.sessionId, active })).result @@ -152,8 +157,9 @@ export class Session implements ObservableSnapshot { result = transportError(error) } if (result.ok) { - this.applyPlanResponse(result.value, planEventVersion) - this.notifier.notifyNow() + if (this.applyPlanResponse(result.value, planEventVersion, planRequestVersion)) { + this.notifier.notifyNow() + } } return result } @@ -387,6 +393,7 @@ export class Session implements ObservableSnapshot { this.hasMore = hasMore this.foldAdapter.reset(this.events, this.baseSeq, this.views) this.rebuildDerivedFromWindow() + this.applyLatestWindowPlanMode() const buffered = this.liveBuffer this.liveBuffer = [] for (const item of buffered) this.appendLive(item.event, item.view) @@ -411,10 +418,11 @@ export class Session implements ObservableSnapshot { */ private async refreshPlanMode(generation: number): Promise { const planEventVersion = this.planEventVersion + const planRequestVersion = ++this.planRequestVersion try { const { result } = await this.api.sessions.planMode({ sessionId: this.sessionId }) if (generation !== this.openGeneration) return - if (result.ok) this.applyPlanResponse(result.value, planEventVersion) + if (result.ok) this.applyPlanResponse(result.value, planEventVersion, planRequestVersion) else console.error('[web-runtime] plan-mode query failed:', result.error) } catch (error) { if (generation !== this.openGeneration) return @@ -429,6 +437,8 @@ export class Session implements ObservableSnapshot { if (typeof candidate.data !== 'object' || candidate.data === null) return const data = candidate.data as { active?: unknown } if (typeof data.active !== 'boolean') return + if (this.latestPlanEventSeq !== null && event.seq <= this.latestPlanEventSeq) return + this.latestPlanEventSeq = event.seq this.planEventVersion++ this.latestLivePlanMode = { active: data.active } if (this.planCapabilityKnown && this.planMode !== null) { @@ -436,20 +446,38 @@ export class Session implements ObservableSnapshot { } } + /** Replacement windows bypass appendLive, so fold their newest valid plan commit explicitly. */ + private applyLatestWindowPlanMode(): void { + for (let i = this.events.length - 1; i >= 0; i--) { + const before = this.latestPlanEventSeq + this.applyLivePlanMode(this.events[i] as SessionEvent) + if (this.latestPlanEventSeq !== before) return + } + } + /** - * Apply a unary plan snapshot unless a newer mux commit crossed the request. + * Apply the latest unary plan snapshot unless a newer request or mux commit + * crossed it. * A successful null response establishes absence and never promotes a raw * event into a capability. + * + * @returns Whether this response was current and applied. */ - private applyPlanResponse(value: PlanModeState | null, requestVersion: number): void { + private applyPlanResponse( + value: PlanModeState | null, + requestEventVersion: number, + requestVersion: number, + ): boolean { + if (requestVersion !== this.planRequestVersion) return false this.planCapabilityKnown = true if (value === null) { this.planMode = null - return + return true } - this.planMode = requestVersion === this.planEventVersion + this.planMode = requestEventVersion === this.planEventVersion ? value : this.latestLivePlanMode ?? value + return true } /** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop; diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 3cc536c0d5..8feb32ec23 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -96,6 +96,29 @@ describe('plan mode projection', () => { 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 }) + + api.onSetPlanMode = () => Promise.resolve(ok({ active: false })) + await session.setPlanMode(false) + expect(session.getSnapshot().planMode).toEqual({ active: false }) + }) + + it('drops an older overlapping selection response after the newer selection lands', async () => { + const { api, session } = makeSession() + api.onPlanMode = () => Promise.resolve(ok({ active: false })) + await session.open() + const older = deferred>>() + const newer = deferred>>() + let call = 0 + api.onSetPlanMode = () => ++call === 1 ? older.promise : newer.promise + + const selectPlan = session.setPlanMode(true) + const selectDefault = session.setPlanMode(false) + newer.resolve(ok({ active: false })) + await selectDefault + older.resolve(ok({ active: false, pending: true })) + await selectPlan + + expect(session.getSnapshot().planMode).toEqual({ active: false }) }) it('retains the prior state when a selection fails at the business or transport layer', async () => { @@ -174,6 +197,26 @@ describe('plan mode projection', () => { expect(selection.session.getSnapshot().planMode).toEqual({ active: true }) }) + it('applies a plan commit recovered through a gap-repair replacement window', async () => { + const { api, session } = makeSession() + const initial = plainTurn(0, 0, 'a', 'b') + api.onHistory = () => histResponse(initial) + api.onPlanMode = () => Promise.resolve(ok({ active: false, pending: true })) + await session.open() + + const planCommit = at(6, { type: 'plan/mode', data: { active: true } }) + const later = ev.turnStart(7, 1) + api.onHistory = () => histResponse([...initial, planCommit, later]) + session.handleMuxEnvelope('rp-gap' as never, { + type: 'session/event', sessionId: SID, event: later, + }) + + await vi.waitFor(() => { + expect(api.callsOf('session.history')).toHaveLength(2) + expect(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(() => {}) diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 774d771eba..77532d15d0 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -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) diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 7ae09aa116..90e874e842 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -113,7 +113,10 @@ export const sessionCancelValueSchema = z.object({ export const planModeStateSchema = z.object({ active: z.boolean(), pending: z.boolean().optional(), -}) satisfies z.ZodType> +}).refine( + state => state.pending === undefined || state.pending !== state.active, + { message: 'pending must differ from active when present', path: ['pending'] }, +) satisfies z.ZodType> /** session.planMode request payload. */ export const sessionPlanModeRequestSchema = z.object({ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 48a04230ab..a5c643f51b 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -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 diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 656be7b36a..2dd3fb6645 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -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 }) }) }) diff --git a/packages/host/runtime/README.md b/packages/host/runtime/README.md index 42b3a4c64d..8558b63680 100644 --- a/packages/host/runtime/README.md +++ b/packages/host/runtime/README.md @@ -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

` 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

` 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 diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index f035879f5a..70f853cf41 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -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(request: RpcRequest, value: T): RpcResponse { 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(request: RpcRequest, error: RpcError): RpcResponse { 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))) }, }, diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index 712b10b576..d99763c6e5 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -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 () => {