Merge branch 'codex/web-plan-wire' into codex/web-plan-mode

This commit is contained in:
fz
2026-07-24 16:34:56 +08:00
14 changed files with 252 additions and 21 deletions

View File

@@ -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: 9c894a30a0c456d2d6216baa23220db8d68fdda3
2026-07-24-web-plan-mode-projection.zh.md: 9f8876c4f222059066ff1bff42aebc37acb5683c
2026-07-24-web-plan-mode-projection.md: 57bf397fd6b8b3fcefc04ba3faebbc6d8648bf5a
2026-07-24-web-plan-mode-projection.zh.md: 9d868c8494abe988955d328e5482fefff9c6ceec

View File

@@ -12,12 +12,14 @@ The host does not mount plan mode for every product composition. The wire must d
## Decision
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 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. `session.prompt` additionally accepts an optional `planMode` target, letting a client bind the selected target to the prompt it submits.
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 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.
Prompt admission waits for the latest selector request and follows any newer overlapping selection that supersedes the one it was awaiting. The latest outcome is retained after settlement so an already-completed failure cannot be missed. That failure rejects the prompt locally, letting the composer restore its draft instead of sending under an uncertain mode. After selection succeeds, the browser attaches the confirmed `pending ?? active` target to `session.prompt`. The host applies that target immediately before the synchronous `send` or `steer` admission, with no await where another request could interleave; unavailable plan capability fails closed, and a synchronous admission rejection restores the preceding target. Model generation begins only after admission and remains independently cancellable.
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
@@ -44,9 +46,9 @@ Stopping generation remains a separate session operation. A pending selection su
## Verification
- 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.
- Host runtime tests cover capability absence, real-service pending state, canonical net-zero cancellation, cold-session errors, atomic prompt admission, admission rollback, and shared RPC semantics.
- Client object tests cover open, selection success, prompt waiting and failure containment, 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
Web UI packages can discover plan mode without importing its host implementation and can display boundary-pending state without duplicating the plan service. Other clients may use the same optional projection. The contract deliberately does not combine switching with stop, invent generic mode identifiers, or make plan capability mandatory for every host composition.
Web UI packages can discover plan mode without importing its host implementation, display boundary-pending state without duplicating the plan service, and bind one submitted prompt to the selected target. Other clients may use the same optional projection. The contract deliberately does not combine switching with stop, invent generic mode identifiers, or make plan capability mandatory for every host composition.

View File

@@ -12,12 +12,14 @@ plan 服务拥有持久状态和边界时序,但 Web 宿主契约无法发现
## 决策
会话 RPC 域公开 `session.planMode({ sessionId })``session.setPlanMode({ sessionId, active })`。两者返回相同的值类型:`null | { active: boolean, pending?: boolean }``null` 表示可选的 `ctx.planMode` 服务不存在;`{ active: false }` 表示该服务可用,但当前未激活。读取或修改状态前,这两个方法都会通过宿主用于历史记录与提示词请求的同一路径恢复冷会话。
会话 RPC 域公开 `session.planMode({ sessionId })``session.setPlanMode({ sessionId, active })`。两者返回相同的值类型:`null | { active: boolean, pending?: boolean }``null` 表示可选的 `ctx.planMode` 服务不存在;`{ active: false }` 表示该服务可用,但当前未激活。读取或修改状态前,这两个方法都会通过宿主用于历史记录与提示词请求的同一路径恢复冷会话。`session.prompt` 还接受可选的 `planMode` 目标,使客户端可以把所选目标绑定到其提交的提示词。
宿主适配器把选择与折叠工作交给 `ctx.planMode`,不会自行追加事件或重复实现边界逻辑。`active` 是最近一次已提交并记录到日志的值。`pending` 存在时,其值是等待模型请求边界生效的所选目标,且必定不同于 `active`;表示用户可见的待生效转换的是该字段是否存在,而不是其布尔值。重新选择已提交值时,服务内部可能仍留有一项清理意图,但适配器会把这一无净变化状态规范化为 `{ active }`。随后,边界会移除该意图,且不会记录多余的状态事件。协议 schema 会拒绝 `active``pending` 相等的值。该 RPC 不会取消正在执行的请求,因此生成期间作出的选择不会改变本次请求,只会影响下一次请求。
浏览器会话对象在历史记录加载完成后以及重连时查询完整状态。plan 查询失败不会阻断其他功能:历史记录仍可使用,并保留最近一次已知的功能状态。重连使用代际围栏,避免已被取代的打开流程覆盖较新的结果。一个单调递增的 plan 请求围栏同时覆盖查询和选择,因此较早的 unary 响应无法替换较新请求的结果。另一道独立的本地事件版本围栏会阻止当前查询或选择响应覆盖 mux 流中已抢先到达的 `plan/mode` 提交;提前到达的提交会保持为内部状态,直到查询成功并确认该功能存在。除上述情况外,选择成功后,只有宿主确认的响应才会更新快照;业务错误和传输失败都会保留先前状态。
提示词准入会等待最近一次选择器请求;若有更新的重叠选择取代原先等待的请求,准入也会转而跟随该选择。请求完成后仍会保留最新结果,因此不会漏掉已经完成的失败结果。该失败会在本地拒绝提示词,使输入区可以恢复草稿,而不会在模式不确定时发送。选择成功后,浏览器会把已确认的 `pending ?? active` 目标附加到 `session.prompt`。宿主会在同步 `send``steer` 准入之前立即应用该目标;期间不会执行 await因此其他请求无法插入。plan 功能不可用时默认拒绝,同步准入遭拒时则会恢复先前目标。模型生成仅在准入完成后开始,并仍可独立取消。
已提交的 `plan/mode` 会话事件仍作为实时通知。当宿主已公布该功能时,有效事件会替换 `active` 并清除 `pending`。追加路径和历史替换窗口都会按序号采用最新的有效 plan 事件,因此即使缓冲的触发帧在回放时已与窗口重叠,缺口回补仍会应用恢复出的提交。对象层会忽略格式错误的事件,也不会仅凭一条原始事件推断功能是否可用。这样既以完整状态读取为真源,又保留现有的日志事件流作为提交信号。
## 状态与时序
@@ -44,9 +46,9 @@ plan 服务拥有持久状态和边界时序,但 Web 宿主契约无法发现
## 验证
- API schema 拒绝无效的请求与状态结构,包括 `active``pending` 相等的情况;两个 fetch 方向均可分派这两个方法。
- 宿主运行时测试覆盖功能不存在、真实服务的待生效状态、取消时无净变化状态的规范化、冷会话错误,以及共享 RPC 语义。
- 客户端对象测试覆盖打开、选择成功、重叠选择请求的响应顺序、业务错误与传输失败、已提交的实时事件、经缺口回补的提交、格式错误及功能不可用时的事件、查询失败时的容错、重连刷新、针对已被取代查询的围栏保护,以及 mux 提交抢先于 unary 响应到达的情况。
- 宿主运行时测试覆盖功能不存在、真实服务的待生效状态、取消时无净变化状态的规范化、冷会话错误、原子化提示词准入、准入回滚,以及共享 RPC 语义。
- 客户端对象测试覆盖打开、选择成功、提示词等待与失败隔离、重叠选择请求的响应顺序、业务错误与传输失败、已提交的实时事件、经缺口回补的提交、格式错误及功能不可用时的事件、查询失败时的容错、重连刷新、针对已被取代查询的围栏保护,以及 mux 提交抢先于 unary 响应到达的情况。
## 后果
Web UI 包package无需导入 plan mode 的宿主实现即可发现该功能,也能显示边界处的待生效状态,而不必重复实现 plan 服务。其他客户端也可以使用同一个可选投影。该契约有意不把模式切换与停止操作合并,不发明通用模式标识符,也不要求每一种宿主组合都必须提供 plan 功能。
Web UI 包package无需导入 plan mode 的宿主实现即可发现该功能,也能在不重复实现 plan 服务的情况下显示边界处的待生效状态,并把一次提示词提交绑定到所选目标。其他客户端也可以使用同一个可选投影。该契约有意不把模式切换与停止操作合并,不发明通用模式标识符,也不要求每一种宿主组合都必须提供 plan 功能。

View File

@@ -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 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.
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. Prompt admission waits for the latest selector request, follows a newer overlapping request when one supersedes it, stops on that latest selection's failure, and sends the resulting `pending ?? active` target with the prompt so the host cannot accept the message under another mode. 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

View File

@@ -65,6 +65,11 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private planRequestVersion = 0
/** Latest valid commit, held until the initial capability query resolves. */
private latestLivePlanMode: PlanModeState | null = null
/**
* Latest selector mutation, retained after settlement so prompt admission
* cannot miss a failure that completed before it began waiting.
*/
private planSelection: Promise<RpcResult<PlanModeState | null>> | 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
@@ -108,9 +113,30 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.promptError = null
this.lastAgentError = null
this.notifier.markDirty()
while (this.planSelection !== null) {
const pending = this.planSelection
const selection = await pending
// A newer selection owns the target and its outcome, even when this
// superseded request fails after the replacement has already settled.
if (this.planSelection !== pending) continue
if (!selection.ok) {
this.promptError = { op: 'send', error: selection.error }
this.notifier.markDirty()
return selection
}
break
}
const planTarget = this.planMode === null
? undefined
: this.planMode.pending ?? this.planMode.active
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
result = (await this.api.sessions.prompt({
sessionId: this.sessionId,
mode,
content,
...(planTarget === undefined ? {} : { planMode: planTarget }),
})).result
} catch (error) {
result = transportError(error)
}
@@ -147,7 +173,14 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* @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>> {
setPlanMode(active: boolean): Promise<RpcResult<PlanModeState | null>> {
const selection = this.selectPlanMode(active)
this.planSelection = selection
return selection
}
/** Run one selector mutation; {@link setPlanMode} retains its latest outcome for prompt admission. */
private async selectPlanMode(active: boolean): Promise<RpcResult<PlanModeState | null>> {
const planEventVersion = this.planEventVersion
const planRequestVersion = ++this.planRequestVersion
let result: RpcResult<PlanModeState | null>

View File

@@ -384,6 +384,100 @@ describe('prompt and cancel errors', () => {
expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } })
})
it('waits for the current selector target and admits it with the prompt', async () => {
const { api, session } = makeSession()
api.onPlanMode = () => Promise.resolve(ok({ active: false }))
await session.open()
const selected = deferred<Awaited<ReturnType<FakeApiClient['onSetPlanMode']>>>()
api.onSetPlanMode = () => selected.promise
const selecting = session.setPlanMode(true)
const prompting = session.prompt([{ type: 'text', text: 'plan this' }], 'queue')
await Promise.resolve()
expect(api.callsOf('session.prompt')).toEqual([])
selected.resolve(ok({ active: false, pending: true }))
await selecting
expect((await prompting).ok).toBe(true)
expect(api.callsOf('session.prompt')).toEqual([{
sessionId: SID,
mode: 'queue',
content: [{ type: 'text', text: 'plan this' }],
planMode: true,
}])
})
it('does not admit a prompt when the selector request fails', async () => {
const { api, session } = makeSession()
api.onPlanMode = () => Promise.resolve(ok({ active: false }))
await session.open()
const selected = deferred<Awaited<ReturnType<FakeApiClient['onSetPlanMode']>>>()
api.onSetPlanMode = () => selected.promise
const selecting = session.setPlanMode(true)
const prompting = session.prompt([{ type: 'text', text: 'do not send' }], 'queue')
selected.resolve(err({ code: 'internal', message: 'selection failed', details: {} }))
await selecting
expect((await prompting).ok).toBe(false)
expect(api.callsOf('session.prompt')).toEqual([])
expect(session.getSnapshot().promptError).toMatchObject({
op: 'send',
error: { code: 'internal', message: 'selection failed' },
})
})
it('ignores a superseded selector failure and admits the latest successful target', async () => {
const { api, session } = makeSession()
api.onPlanMode = () => Promise.resolve(ok({ active: false }))
await session.open()
const older = deferred<Awaited<ReturnType<FakeApiClient['onSetPlanMode']>>>()
const newer = deferred<Awaited<ReturnType<FakeApiClient['onSetPlanMode']>>>()
let call = 0
api.onSetPlanMode = () => ++call === 1 ? older.promise : newer.promise
const selectPlan = session.setPlanMode(true)
const prompting = session.prompt([{ type: 'text', text: 'use latest' }], 'queue')
const selectDefault = session.setPlanMode(false)
newer.resolve(ok({ active: false }))
await selectDefault
older.resolve(err({ code: 'internal', message: 'stale failure', details: {} }))
await selectPlan
expect((await prompting).ok).toBe(true)
expect(api.callsOf('session.prompt')).toEqual([{
sessionId: SID,
mode: 'queue',
content: [{ type: 'text', text: 'use latest' }],
planMode: false,
}])
})
it('retains the latest selector failure until prompt admission observes it', async () => {
const { api, session } = makeSession()
api.onPlanMode = () => Promise.resolve(ok({ active: false }))
await session.open()
const older = deferred<Awaited<ReturnType<FakeApiClient['onSetPlanMode']>>>()
const newer = deferred<Awaited<ReturnType<FakeApiClient['onSetPlanMode']>>>()
let call = 0
api.onSetPlanMode = () => ++call === 1 ? older.promise : newer.promise
const selectPlan = session.setPlanMode(true)
const prompting = session.prompt([{ type: 'text', text: 'must stay local' }], 'queue')
const selectDefault = session.setPlanMode(false)
newer.resolve(err({ code: 'internal', message: 'latest failure', details: {} }))
await selectDefault
older.resolve(ok({ active: false, pending: true }))
await selectPlan
expect((await prompting).ok).toBe(false)
expect(api.callsOf('session.prompt')).toEqual([])
expect(session.getSnapshot().promptError).toMatchObject({
op: 'send',
error: { code: 'internal', message: 'latest failure' },
})
})
it('lands cancel failures in promptError with op=stop', async () => {
const { api, session } = makeSession()
api.onCancel = () => Promise.reject(new Error('cancel transport down'))

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. 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.
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. `session.prompt` may carry a `planMode` target so the host records that selection immediately before accepting the prompt; it fails when the capability is absent and restores the prior target after a synchronous prompt rejection.
## Carrier layer (`/client` + root)

View File

@@ -92,6 +92,7 @@ export const sessionPromptRequestSchema = z.object({
sessionId: sessionIdSchema,
mode: z.union([z.literal('queue'), z.literal('steer')]),
content: z.array(contentBlockSchema),
planMode: z.boolean().optional(),
}) as unknown as z.ZodType<RequestPayload<'session.prompt'>>
/** session.prompt response value. */

View File

@@ -74,8 +74,17 @@ export interface SessionsApi {
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean }>>
/** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
/**
* Sends a message. `content` is core's ContentBlock[] verbatim and `mode`
* maps 1:1 — queue→send, steer→steer. An optional `planMode` target is
* admitted atomically with the prompt.
*/
prompt(request: RpcRequest<{
sessionId: SessionId
mode: 'queue' | 'steer'
content: ContentBlock[]
planMode?: boolean
}>):
Promise<RpcResponse<{ accepted: true }>>
/** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */

View File

@@ -84,7 +84,12 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
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.prompt({
sessionId: 's' as never,
mode: 'queue',
content: [{ type: 'text', text: 'x' }],
planMode: true,
})).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 })

View File

@@ -101,9 +101,14 @@ describe('sessions domain schemas', () => {
expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3)
expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow()
expect(sessionHistoryValueSchema.parse({ events: [], hasMore: false }).hasMore).toBe(false)
const prompt = sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'queue', content: [{ type: 'text', text: 'hi' }] })
expect(prompt.mode).toBe('queue')
const prompt = sessionPromptRequestSchema.parse({
sessionId: 's1', mode: 'queue', content: [{ type: 'text', text: 'hi' }], planMode: true,
})
expect(prompt).toMatchObject({ mode: 'queue', planMode: true })
expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow()
expect(() => sessionPromptRequestSchema.parse({
sessionId: 's1', mode: 'queue', content: [], planMode: 'plan',
})).toThrow()
expect(sessionPromptValueSchema.parse({ accepted: true }).accepted).toBe(true)
expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true)

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, 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.
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. A prompt carrying `planMode` sets that target and admits the message without an intervening await; missing capability fails closed, while synchronous admission failure restores the preceding target. 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

@@ -443,16 +443,30 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
async prompt(request) {
const { sessionId, mode, content } = request.payload
const { sessionId, mode, content, planMode: planTarget } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const agent = found.agent
const planMode = ctx.get('planMode')
if (planTarget !== undefined && planMode === undefined) {
return err(request, {
code: 'internal',
message: 'prompt requested plan mode, but this host does not provide it',
details: {},
})
}
// No await separates selection from admission: another unary request
// cannot interleave a different target between these two operations.
const priorPlanState = planTarget === undefined ? undefined : planMode?.get(agent)
const priorPlanTarget = priorPlanState?.pending ?? priorPlanState?.active
if (planTarget !== undefined) planMode?.set(agent, planTarget)
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
try {
if (mode === 'steer') agent.steer(content, { source })
else agent.send(content, { source })
} catch (error: unknown) {
if (priorPlanTarget !== undefined) planMode?.set(agent, priorPlanTarget)
// A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached.
return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
}

View File

@@ -273,6 +273,72 @@ describe('sessions.planMode / setPlanMode', () => {
})
describe('sessions.prompt / cancel', () => {
it('admits a prompt and its plan target through one host operation', async () => {
const running = await boot([textResponse('planned')])
await running.ctx.plugin(PlanModeService, { section: 'Plan before acting.' })
const { sessionId } = expectOk(await running.api.sessions.create(request({})))
const agent = running.ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(running.ctx, agent)
expectOk(await running.api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'plan this' }],
planMode: true,
})))
await idle
const planEvent = agent.session.events.find(event => event.type === 'plan/mode')
const userEvent = agent.session.events.find(event => event.type === 'user/message')
const header = agent.session.events.find(event => event.type === 'request/header')
expect(planEvent?.type === 'plan/mode' && planEvent.data.active).toBe(true)
expect(planEvent?.seq).toBeLessThan(userEvent?.seq ?? Number.POSITIVE_INFINITY)
expect(header?.type === 'request/header' && header.data.header.system).toContain('Plan before acting.')
})
it('rolls back the plan target when prompt admission is rejected', async () => {
const running = await boot()
await running.ctx.plugin(PlanModeService, { section: 'Plan before acting.' })
const { sessionId } = expectOk(await running.api.sessions.create(request({})))
const agent = running.ctx.agents.get(sessionId) as Agent
const send = vi.spyOn(agent, 'send').mockImplementation(() => {
throw new Error('closed for admission')
})
try {
const response = await running.api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'plan this' }],
planMode: true,
}))
expect(response.result).toMatchObject({
ok: false,
error: { code: 'agent-busy', details: { reason: 'Error: closed for admission' } },
})
expect(expectOk(await running.api.sessions.planMode(request({ sessionId })))).toEqual({
active: false,
})
} finally {
send.mockRestore()
}
})
it('fails closed when a prompt targets unavailable plan mode', async () => {
const running = await boot()
const { sessionId } = expectOk(await running.api.sessions.create(request({})))
const response = await running.api.sessions.prompt(request({
sessionId,
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'must not run ambiguously' }],
planMode: true,
}))
expect(response.result).toMatchObject({
ok: false,
error: { code: 'internal', message: 'prompt requested plan mode, but this host does not provide it' },
})
expect((running.ctx.agents.get(sessionId) as Agent).session.events).toEqual([])
})
it.each([
{ name: 'host default', config: true, target: '5 words', maxTokens: 64 },
{