From ab56e0df0a30ab36998b096a9b5922d4b14bef6b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 20:36:26 +0800 Subject: [PATCH] fix(agent-loop): park empty turn batches, keep max-tokens sticky, and settle headless at idle - turn boundary now returns false for an empty admitted batch (claimed input removed before the wake) instead of opening a turn and spending a model call on nothing; the step boundary already had the symmetric guard. - a max-token step stays sticky when steering or injected work continues the turn: a later completed step no longer downgrades the outcome, matching the TurnEndReasonMap contract. - session/queue wire schema accepts the context placement (previously the zod union rejected injected-context snapshots wholesale and the client silently dropped the whole frame); schema tests cover all placements. - headless runs settle at whole-agent idle instead of the first turn/end, honoring the one-shot idle-to-idle contract. - flush JSDoc names the real callers (checkpoint policy, goal-session, teardown, self-flushing consumers); apiproxy zh README loses its stale duplicate history section; ACP note/README record the delivered error rejection and turnless-cancelled behaviors. --- ...-followup-enqueue-and-owned-runs.i18n.yaml | 4 +- ...6-07-30-followup-enqueue-and-owned-runs.md | 2 +- ...7-30-followup-enqueue-and-owned-runs.zh.md | 2 +- apps/cli/src/headless.ts | 76 +++++++++++-------- packages/acp/acp/README.i18n.yaml | 4 +- packages/acp/acp/README.md | 4 +- packages/acp/acp/README.zh.md | 4 +- packages/core/agent-loop/src/agent.ts | 11 ++- .../tests/contract-regressions.spec.ts | 14 ++++ packages/core/agent-loop/tests/loop.spec.ts | 4 +- packages/core/session/src/index.ts | 9 ++- packages/host/apiproxy/README.i18n.yaml | 2 +- packages/host/apiproxy/README.zh.md | 2 - .../host/apiproxy/src/api/events.schema.ts | 2 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 11 +++ 15 files changed, 101 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml index fec50c4b67..2b16888d38 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.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 .agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md -2026-07-30-followup-enqueue-and-owned-runs.md: 12da1d770c9346e79394c194f33e7faae2254cdf -2026-07-30-followup-enqueue-and-owned-runs.zh.md: c7b839d1207151002a0cff1a470245198561b2d8 +2026-07-30-followup-enqueue-and-owned-runs.md: 54f4af75eeb29b06504fa2629b0665f3e5f4c4ee +2026-07-30-followup-enqueue-and-owned-runs.zh.md: 62c0d998a8bf94df8cabfccf23ea8b2a4da92c4d diff --git a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md index 12da1d770c..54f4af75ee 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md +++ b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md @@ -18,7 +18,7 @@ The low-level SDK protocol answers `session/prompt` as soon as enqueue succeeds High-level automation APIs return a `RunResult` only when they explicitly own an activity interval. The TypeScript and Python SDK `run()` methods collect from the submitted message's durable inbox receipt through the next whole-agent `idle`; their `finalResponse` is the last committed assistant message in that interval, not a response causally attributed to the submitted prompt. The one-shot CLI owns the analogous idle-to-idle interval. An isolated child-agent run may report a result because its caller owns the complete child lifecycle and any steering belongs to that run. -ACP must return a protocol `stopReason`. Its bridge serializes one in-flight prompt per ACP session, waits for whole-agent idle, reports `cancelled` only for explicit ACP cancellation or disposal, and otherwise reports the generic `end_turn`. It does not infer token-limit or error attribution for the prompt. +ACP must return a protocol `stopReason`. Its bridge serializes one in-flight prompt per ACP session, waits for whole-agent idle, and otherwise reports the generic `end_turn`. Token-limit endings are not attributed to the prompt: they settle as `end_turn`. A model error on the prompt's correlated turn does reject the prompt immediately (the error is attributed by its owning turn), and a turnless slot (admission discarded the prompt) settles as `cancelled` at idle alongside explicit ACP cancellation or disposal. Goal continuation retains `MessageId` only to recognize its durable queued and admitted goal message. It advances from durable goal state at whole-agent idle, without mapping the message to a turn result. diff --git a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md index c7b839d120..62c0d998a8 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md @@ -18,7 +18,7 @@ Status: implemented 只有明确拥有一个活动区间时,高层自动化 API 才返回 `RunResult`。TypeScript 和 Python SDK 的 `run()` 方法从已提交消息的持久 inbox 回执开始收集,直至整个 agent 下一次进入 `idle`;其 `finalResponse` 是该区间内最后一条已提交的 assistant 消息,而不是按因果关系归属于已提交提示词的响应。单次 CLI(命令行界面)拥有相应的 idle 到 idle 区间。隔离的子 agent 运行可以报告结果,因为调用方拥有完整的子级生命周期,任何 steering 都属于该运行。 -ACP(Agent Client Protocol)必须返回协议规定的 `stopReason`。其桥接层串行处理每个 ACP 会话中唯一一个正在处理的提示词,等待整个 agent 进入 idle,仅在显式 ACP 取消或资源释放时报告 `cancelled`,其他情况均报告通用的 `end_turn`。它不推断 token 上限或错误是否归属于该提示词。 +ACP(Agent Client Protocol)必须返回协议规定的 `stopReason`。其桥接层串行处理每个 ACP 会话中唯一一个正在处理的提示词,等待整个 agent 进入 idle,其他情况均报告通用的 `end_turn`。token 上限的轮次结束不归因于提示词:它们以 `end_turn` 结算。与该提示词关联的轮次上的模型错误会立即以该错误 reject 提示词(错误按其所属轮次归因),而 turnless 槽位(准入已丢弃提示词)会在 idle 时以 `cancelled` 结算,与显式 ACP 取消或资源释放并列。 Goal 续行只保留 `MessageId`,用于识别持久排队和已准入的 goal 消息。它在整个 agent 进入 idle 时根据持久 goal 状态推进,不把消息映射到轮次结果。 diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 99603512d8..aa4323f789 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -31,39 +31,50 @@ async function unwrap(response: RpcResponse, dispose: () => Promise) } /** - * Consume mux frames until the task turn ends, per the cli-demo runOneShot - * correlation precedent: the stream opens immediately before the prompt, so - * its first observed turn/start owns the task. Aggregate text from that turn's - * assistant/message events (last one wins), then finish on its turn/end. + * Consume mux frames until the agent reaches idle, per the one-shot CLI + * idle-to-idle contract: the stream opens immediately before the prompt, and + * its first observed turn/start begins the task. Text is the last committed + * assistant message of the whole interval (steering or injected work may run + * further turns before quiescence), and the outcome reason is the final + * turn/end's kind. Idleness is signalled out of band by the caller's + * `agent/status` subscription; the stream itself carries no status frame. + * @param frames - the mux stream opened before the prompt. + * @param sessionId - the headless session. + * @param idle - resolves when the agent reaches quiescence. + * @returns the aggregated outcome. */ -async function consumeUntilTurnEnd(frames: AsyncIterable>, sessionId: SessionId): Promise { - let targetTurn: number | undefined +async function consumeUntilIdle( + frames: AsyncIterable>, + sessionId: SessionId, + idle: Promise, +): Promise { + let started = false let text = '' - try { - for await (const frame of frames) { - const payload = frame.payload - if (payload.type === 'stream/error') { - process.stderr.write(`dsh: stream error: ${payload.error.message}\n`) - return { text, reason: 'error' } - } - if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue - const event = payload.event - if (targetTurn === undefined) { - if (event.type === 'turn/start') targetTurn = event.data.turn - continue - } - if (event.type === 'assistant/message' && event.data.turn === targetTurn) { - const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('') - if (joined !== '') text = joined - } - if (event.type === 'turn/end' && event.data.turn === targetTurn) { - return { text, reason: event.data.reason.kind } + let reason: string = 'error' + void (async () => { + try { + for await (const frame of frames) { + const payload = frame.payload + if (payload.type === 'stream/error') return + if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue + const event = payload.event + if (event.type === 'turn/start') { + started = true + continue + } + if (!started) continue + if (event.type === 'assistant/message') { + const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('') + if (joined !== '') text = joined + } + if (event.type === 'turn/end') reason = event.data.reason.kind } + } catch (error: unknown) { + process.stderr.write(`dsh: event stream failed: ${String(error)}\n`) } - } catch (error: unknown) { - process.stderr.write(`dsh: event stream failed: ${String(error)}\n`) - } - return { text, reason: 'error' } + })() + await idle + return { text, reason } } /** @@ -105,7 +116,12 @@ export async function runHeadless(task: string): Promise { // to a remote HTTP carrier unchanged. const abort = new AbortController() const frames = api.events.mux({}, abort.signal) - const done = consumeUntilTurnEnd(frames, created.sessionId) + const idle = new Promise((resolve) => { + ctx.on('agent/status', (agent, status) => { + if (agent.id === created.sessionId && status === 'idle') resolve() + }) + }) + const done = consumeUntilIdle(frames, created.sessionId, idle) await unwrap(await api.sessions.prompt({ sessionId: created.sessionId, diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml index 40680ed517..4cc8ade9c0 100644 --- a/packages/acp/acp/README.i18n.yaml +++ b/packages/acp/acp/README.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 packages/acp/acp/README.md -README.md: 4632c00398d0870d3682d8c6241e26de006ad233 -README.zh.md: 1bfd16def64f7973ae4e517dfc96e32478b49aa2 +README.md: a48224fca8aac12e1440e187cfc4b418952d0951 +README.zh.md: 953b7791a0948620706d86a5c30f6629bdd11ae4 diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index 4632c00398..a48224fca8 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -24,7 +24,7 @@ Both fields are optional so another agent/request listener may supply the target | `initialize` | Negotiates the supported version and advertises baseline-only prompts (no image, audio, or embedded-context capability). No session, editor, terminal, filesystem, or MCP capability is advertised. | | `authenticate` | No-op because the server advertises no authentication methods. | | `session/new` | Creates a fresh agent with an absolute primary `cwd`; empty `additionalDirectories` and `mcpServers` are accepted, non-empty values reject. | -| `session/prompt` | Concatenates text blocks, renders baseline resource links as bracketed textual references, rejects empty or beyond-baseline input, permits one in-flight request per session, and waits for the whole agent to become idle. Normal quiescence reports `end_turn`; explicit ACP cancellation or disposal reports `cancelled`. | +| `session/prompt` | Concatenates text blocks, renders baseline resource links as bracketed textual references, rejects empty or beyond-baseline input, permits one in-flight request per session, and waits for the whole agent to become idle. Normal quiescence reports `end_turn`; explicit ACP cancellation, disposal, or a prompt whose admission was discarded (a turnless slot) reports `cancelled`. | | `session/cancel` | Cancels only the addressed agent and settles its pending prompt as `cancelled`; unknown ids are no-ops. | | `session/update` | Emits one `agent_message_chunk` per non-empty text block in a committed `assistant/message`. Raw deltas and non-message events are omitted. | | `session/request_permission` | Offers one-shot allow/reject choices for bridge-owned approval requests carrying a tool call id. Clients may answer automatically. | @@ -37,7 +37,7 @@ Committed-message output intentionally trades token-by-token latency for a clean Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent. -ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. Committed assistant messages stream across the owned activity, and steering or injected work may contribute before idle. Token-limit and model-error turn endings therefore do not become prompt-level ACP stop reasons. +ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. Committed assistant messages stream across the owned activity, and steering or injected work may contribute before idle. Token-limit turn endings therefore do not become prompt-level ACP stop reasons (they settle as `end_turn`); a model error on the correlated turn rejects the prompt immediately. ## Running diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index 1bfd16def6..953b7791a0 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -24,7 +24,7 @@ | `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 | | `authenticate` | 空操作,因为服务器不公布身份验证方法。 | | `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 | -| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入 idle。正常完全停稳时报告 `end_turn`;显式 ACP 取消或资源释放时报告 `cancelled`。 | +| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并等待整个 agent 进入 idle。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(turnless 槽位)时报告 `cancelled`。 | | `session/cancel` | 仅取消指定的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 | | `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 | | `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 | @@ -37,7 +37,7 @@ 客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。 -ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出,agent 进入 idle 前发生的 steering(中途引导)或注入工作也可能参与其中。因此,因 token 上限或模型错误而结束的轮次不会成为提示词级 ACP 停止原因。 +ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。已提交的 assistant 消息会在整个自有活动期间流式输出,agent 进入 idle 前发生的 steering(中途引导)或注入工作也可能参与其中。因此,因 token 上限而结束的轮次不会成为提示词级 ACP 停止原因(它们以 `end_turn` 结算);关联轮次上的模型错误会立即 reject 提示词。 ## 运行 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 8983a13551..2909baeb70 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -234,6 +234,10 @@ export class ReactLoopAgent implements Agent { try { decision = await this.preStep('next-turn', { turn: phase.turn + 1, step: 1 }) if (decision.kind === 'reject') return false + // An empty admitted batch (claimed input removed before the wake, or no + // runtime-context change) parks the driver instead of opening a turn and + // spending a model call on nothing. + if (decision.messages.length === 0) return false signal.throwIfAborted() } catch (error: unknown) { if (signal.aborted) throw error @@ -256,7 +260,12 @@ export class ReactLoopAgent implements Agent { for (const message of decision.messages) { this.session.append('user/message', message, { surfaceOp: 'append' }) } - turnEnds = await this.step(decision.assembly) + // max-tokens is sticky: once any step hits the ceiling, later steps + // that complete normally must not downgrade the turn outcome. + const stepEnd = await this.step(decision.assembly) + // max-tokens stays sticky: a later completed step must not + // downgrade the turn outcome. + if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd } finally { this.session.append('step/end', { turn, step }) } diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index c16b1a1000..8578c26783 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -187,6 +187,20 @@ describe('abort during tool execution ends the turn', () => { .toBeUndefined() }) + it('parks an empty admitted batch instead of opening a turn', async () => { + const adapter = new MockAdapter([textResponse('must not run')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('a-empty-batch'), { provider: 'mock', model: 'mock' }) + send(agent, 'go') + // The wake microtask has not run yet: remove the only pending message so + // the admission batch is empty. + agent.inbox.remove('next-turn', agent.inbox.nextTurn[0]!.id) + await waitForIdle(ctx, agent) + expect(adapter.requests).toHaveLength(0) + expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false) + expect(agent.inbox.nextTurn).toHaveLength(0) + }) + it('parks result context finalized after disposal cancellation without opening another turn', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})]) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 5e2890fb9a..382a134e66 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -967,7 +967,9 @@ describe('agent loop', () => { source: { kind: 'plugin', plugin: 'max-tokens-test' }, }, ]) - expect(reasons).toEqual([{ kind: 'completed' }]) + // A max-token step is sticky: the later completed step must not + // downgrade the turn outcome. + expect(reasons).toEqual([{ kind: 'max-tokens' }]) }) it('a completed step after no max-tokens keeps the turn completed (max-tokens does not leak across turns)', async () => { diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index cbde1d3ee0..2f9420feab 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -928,10 +928,11 @@ export class SessionStore extends Service { /** * Dispatch the awaited `session/flush` durability checkpoint for `session`, * with the carrier captured at {@link enter}. THE flush entry point: the - * store owns the carrier, so callers (the loop's turn-end checkpoint, idle - * injection, teardown drains) must come through here rather than dispatch a - * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the - * scoped-dispatch invariant can pin it. + * store owns the carrier, so callers (the checkpoint policy's per-request + * barrier, goal-session's idle checkpoint, teardown drains, and consumers + * that flush themselves before reading storage) must come through here + * rather than dispatch a raw `ctx.parallel('session/flush', …)` — one owner, + * one spelling, and the scoped-dispatch invariant can pin it. * @param session - the session whose buffered events must reach durable storage. * @returns whether at least one durability listener participated, after every * listener has settled successfully. diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 6235ac3736..12425904a6 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md README.md: 0b8761f57556d0076f45e2d5ab735acd092cb253 -README.zh.md: 094ce99900eeb788536cd98b7fc464cbc57fd0b9 +README.zh.md: 983bad4e3cc3d8c2ed8664d1088b2c7a2f700ef4 diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 094ce99900..983bad4e3c 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -12,8 +12,6 @@ 首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本;单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。 -`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent(智能体),然后按追加来源的消息边界分页。`maxMessages` 统计以追加方式进入 surface 的 `user/message`、`assistant/message` 和 `steering/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。 - `session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 334b1af9c9..13ead7d08d 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -54,7 +54,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [ sessionId: sessionIdSchema, items: z.array(z.object({ id: messageIdSchema, - placement: z.union([z.literal('queued'), z.literal('steering')]), + placement: z.union([z.literal('queued'), z.literal('steering'), z.literal('context')]), message: messageSchema, })), }), diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 723bd7285c..63e53310f7 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -455,6 +455,17 @@ describe('events frame schemas', () => { } }) + it('accepts every queue placement and rejects unknown placements', () => { + const item = (placement: string) => ({ type: 'session/queue', sessionId: 's', items: [{ + id: 'm', placement, + message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } }, + }] }) + for (const placement of ['queued', 'steering', 'context']) { + expect(() => muxFrameSchema.parse(item(placement))).not.toThrow() + } + expect(() => muxFrameSchema.parse(item('bogus'))).toThrow() + }) + it('rejects a queue snapshot with malformed items', () => { expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow() expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', role: 'user', content: [], source: { kind: 'user' } }] })).toThrow()