fix: enforce message snapshot invariants

This commit is contained in:
_Kerman
2026-07-28 15:33:00 +08:00
parent 0a3d38bb08
commit b1af35145b
34 changed files with 417 additions and 129 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 packages/core/agent-loop/README.md
README.md: c12140f27aed400b0f7b4246700473e877d37632
README.zh.md: 6394cd86f5f3241be07ef711c76079624bce1bfe
README.md: 99fdc73e2dcb09ed7d47f021598dea8720cada5d
README.zh.md: fe9db9a625ea289e7e9364a182061dee1a0e773c

View File

@@ -54,7 +54,7 @@ Configured agents start automatically. A model call requires both `provider` and
The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. Every inbox enqueue publishes `agent/inbox/enqueue` with the resolved queued-or-steering placement; taking it publishes `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. Every inbox enqueue publishes `agent/inbox/enqueue` with the resolved queued-or-steering placement; taking it publishes `agent/inbox/dequeue` with the same placement, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
### Loop lifecycle (`agent.ts`)

View File

@@ -54,7 +54,7 @@ interface Config {
实体 `ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()``inject()` 会暂存到同一个 outbox接纳获准后会开启轮次记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering中途引导及与其一同暂存的上下文则继续待处理以供重试或之后获准的提示词使用。窗口之外steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。每次 inbox 入队都会发布 `agent/inbox/enqueue`,并携带解析出的 queued 或 steering 路由归类;取走它会发布 `agent/inbox/dequeue``cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()``inject()` 会暂存到同一个 outbox接纳获准后会开启轮次记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering中途引导及与其一同暂存的上下文则继续待处理以供重试或之后获准的提示词使用。窗口之外steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。每次 inbox 入队都会发布 `agent/inbox/enqueue`,并携带解析出的 queued 或 steering 路由归类;取走它会发布 `agent/inbox/dequeue`,并携带相同的路由归类`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`
### 循环生命周期(`agent.ts`

View File

@@ -294,7 +294,7 @@ export class ReactLoopAgent implements Agent {
// Published only after the abort owner and pending done are installed: a
// dequeue listener that cancels or disposes must find live cancellation
// and quiescence ownership, not the previous activity's settled state.
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', message)
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', message, 'queued')
}
/**
@@ -639,7 +639,7 @@ export class ReactLoopAgent implements Agent {
for (const item of this.outbox.splice(0, limit)) {
if (item.steering) {
steered = true
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.message)
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.message, 'steering')
this.session.append(
'steering/message',
{ turn, message: item.message },

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 packages/core/agent/README.md
README.md: 44b2f81f7630834f8992f30e707956d86beac20c
README.zh.md: bec0524ebc575d382c1a70871b4cb252830499b5
README.md: 9fe207d940ddc80153131c3d76c992804bb83773
README.zh.md: c7975fe5e0368aa46253c47c15f04e12a24a3b7b

View File

@@ -58,7 +58,7 @@ Turn and step boundaries and the model token stream are durable `session/event`
The handle every plugin programs against:
- `agent.send(message, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `message` is an already identified `UserMessage`; callers normally create it with `createUserMessage()` before routing begins. `SendOptions` owns only the `target` and `wakeup` policy. The agent detaches and freezes the complete value without minting or replacing its identity. The message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry it so callers can correlate a queued item with its lifecycle; enqueue also carries the resolved `queued | steering` placement so listeners never reconstruct acceptance-time routing from later state. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
- `agent.send(message, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `message` is an already identified `UserMessage`; callers normally create it with `createUserMessage()` before routing begins. `SendOptions` owns only the `target` and `wakeup` policy. The agent detaches and freezes the complete value without minting or replacing its identity. The message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry it so callers can correlate a queued item with its lifecycle; enqueue and dequeue also carry the resolved `queued | steering` placement so repeated message identities retire from the correct FIFO. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
- `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
- `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it.
- `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event.

View File

@@ -58,7 +58,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
每个插件面向的 handle
- `agent.send(message, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`message` 是已有标识的 `UserMessage`;调用方通常会在开始路由前使用 `createUserMessage()` 创建它。`SendOptions` 只持有 `target``wakeup` 策略。agent 会将完整值与输入分离并冻结,但不会生成或替换其标识。该消息的 `agent/inbox/enqueue`/`dequeue`/`discard` 事件会携带其 id调用方可据此把排队项与其生命周期关联入队事件还会携带解析出的 `queued | steering` 路由归类,使监听器无需从后续状态重建接收时的路由`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'``wakeup: true` 提交 steering中途引导`target: 'next-step'``wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。
- `agent.send(message, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`message` 是已有标识的 `UserMessage`;调用方通常会在开始路由前使用 `createUserMessage()` 创建它。`SendOptions` 只持有 `target``wakeup` 策略。agent 会将完整值与输入分离并冻结,但不会生成或替换其标识。该消息的 `agent/inbox/enqueue`/`dequeue`/`discard` 事件会携带其 id调用方可据此把排队项与其生命周期关联入队与出队事件还会携带解析出的 `queued | steering` 路由归类,使重复出现的消息标识能在正确的 FIFO 中完成结算`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'``wakeup: true` 提交 steering中途引导`target: 'next-step'``wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。
- `agent.followup(input)``send()``next-turn`wakeup 预设:排队一个普通后续轮次并唤醒驱动器。
- `agent.steer(input)``next-step`wakeup 预设:提示词接纳期间或轮次打开时,为下一个安全边界暂存 steering且不分发 `agent/prompt-submit`;该接收窗口之外则委托给会唤醒的后续轮次。接纳失败会保留暂存的 steering以供重试或之后获准的提示词使用而取消或 dispose 可能丢弃它。
- `agent.inject(input)``next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。

View File

@@ -248,11 +248,18 @@ declare module 'cordis' {
* boundary, or steering drained between steps. Fires after the item leaves
* its FIFO and before it becomes a durable message.
* @param agent - the agent whose inbox item was claimed.
* @param message - the claimed message (matching the `id` from its `agent/inbox/enqueue`).
* @param message - the claimed message.
* @param placement - the FIFO that claimed this occurrence; together with
* `message.id`, it matches the earliest outstanding enqueue in that FIFO.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, message: UserMessage): void
'agent/inbox/dequeue'(
this: Scoped<Agent>,
agent: Agent,
message: UserMessage,
placement: InboxPlacement,
): void
/**
* Pending inbox items were dropped without delivering them, so every
* enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR

View File

@@ -60,7 +60,7 @@ describe('agent inbox invariants', () => {
expect(() => {
ctx.emit(at, 'agent/inbox/enqueue', agent, info(), 'queued')
ctx.emit(at, 'agent/inbox/enqueue', agent, info(), 'steering')
ctx.emit(at, 'agent/inbox/dequeue', agent, info())
ctx.emit(at, 'agent/inbox/dequeue', agent, info(), 'queued')
ctx.emit(at, 'agent/inbox/discard', agent, [info()])
}).not.toThrow()
})
@@ -68,7 +68,7 @@ describe('agent inbox invariants', () => {
it('rejects a dequeue with no outstanding item', async () => {
const ctx = await setup()
const agent = mockAgent('i2')
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info()) })
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info(), 'queued') })
.toThrow(/without a matching prior enqueue/)
})

View File

@@ -49,7 +49,7 @@ describe('scoped-dispatch invariants', () => {
'agent/disposed': [agent],
'agent/status': [agent, 'idle'],
'agent/inbox/enqueue': [agent, message, 'queued'],
'agent/inbox/dequeue': [agent, message],
'agent/inbox/dequeue': [agent, message, 'queued'],
'agent/inbox/discard': [agent, []],
'agent/cancel-requested': [agent, { kind: 'user' }],
'agent/session-start': [agent, 'startup'],

View File

@@ -146,6 +146,33 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
return deepFreeze(record as unknown as SessionHeader)
}
/**
* Detach one event while preserving deep immutability for its identified message.
* @param event - event imported across a query or persistence boundary.
* @returns a detached event snapshot with a validated, deeply frozen message.
*/
export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
const snapshot = structuredClone(event)
assertMessageEventShape(
snapshot,
`session event at seq ${snapshot.seq}`,
)
switch (snapshot.type) {
case 'user/message':
deepFreeze(snapshot.data)
break
case 'assistant/message':
case 'tool/result':
case 'steering/message':
deepFreeze(snapshot.data.message)
break
default:
// SessionEventMap is merge-extensible; plugin-owned events carry no core message.
break
}
return snapshot
}
/** Validate the fixed event envelope after one-pass JSON materialization. */
function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
const event = value
@@ -166,13 +193,14 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
assertCurrentTurnEndShape(event, index)
}
/** Reject obsolete request headers and pre-unification message shapes at the seed/load boundary. */
/** Reject obsolete request headers and malformed messages at the seed/load boundary. */
function assertCurrentLlmShape(event: Record<string, unknown>, index: number): void {
const data = event['data']
if (typeof data !== 'object' || data === null) return
const record = data as Record<string, unknown>
const record = typeof data === 'object' && data !== null
? data as Record<string, unknown>
: undefined
if (event['type'] === 'request/header') {
const header = record['header']
const header = record?.['header']
const config = typeof header === 'object' && header !== null ? (header as Record<string, unknown>)['config'] : undefined
if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`)
const reasoningEffort = (config as Record<string, unknown>)['reasoningEffort']
@@ -184,11 +212,60 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
const type = event['type']
if (type !== 'user/message' && type !== 'assistant/message'
&& type !== 'tool/result' && type !== 'steering/message') return
const message = type === 'user/message' ? record : record['message']
assertMessageEventShape(event, `seed ${type} at index ${index}`)
}
/** Validate only the event-specific invariants needed to safely replay a message. */
function assertMessageEventShape(event: Record<string, unknown>, subject: string): void {
const type = event['type']
if (type !== 'user/message' && type !== 'assistant/message'
&& type !== 'tool/result' && type !== 'steering/message') return
const data = event['data']
const record = typeof data === 'object' && data !== null
? data as Record<string, unknown>
: undefined
const message = type === 'user/message' ? record : record?.['message']
if (typeof message !== 'object' || message === null
|| typeof (message as Record<string, unknown>)['id'] !== 'string'
|| (message as Record<string, unknown>)['id'] === '') {
throw new Error(`seed ${type} at index ${index} lacks an identified message`)
throw new Error(`${subject} lacks an identified message`)
}
const messageRecord = message as Record<string, unknown>
const expectedRole = type === 'assistant/message' ? 'assistant' : 'user'
if (messageRecord['role'] !== expectedRole) {
throw new Error(`${subject} message must have role "${expectedRole}"`)
}
const source = messageRecord['source']
if (typeof source !== 'object' || source === null
|| typeof (source as Record<string, unknown>)['kind'] !== 'string'
|| (source as Record<string, unknown>)['kind'] === '') {
throw new Error(`${subject} message has invalid source`)
}
if (!Array.isArray(messageRecord['content'])) {
throw new Error(`${subject} message has invalid content`)
}
const sourceRecord = source as Record<string, unknown>
if (type === 'assistant/message') {
if (sourceRecord['kind'] !== 'model' || !hasProviderModel(sourceRecord)) {
throw new Error(`${subject} message must have model source`)
}
return
}
if (type !== 'tool/result') return
if (sourceRecord['kind'] !== 'tool'
|| typeof sourceRecord['callId'] !== 'string'
|| sourceRecord['callId'] === '') {
throw new Error(`${subject} message must have tool source`)
}
const content = messageRecord['content'] as unknown[]
const block = content[0]
if (content.length !== 1 || typeof block !== 'object' || block === null
|| (block as Record<string, unknown>)['type'] !== 'tool-result'
|| !Array.isArray((block as Record<string, unknown>)['content'])) {
throw new Error(`${subject} message must contain one tool-result block`)
}
if ((block as Record<string, unknown>)['toolCallId'] !== sourceRecord['callId']) {
throw new Error(`${subject} message has mismatched tool call ids`)
}
}

View File

@@ -7,6 +7,7 @@ import SessionStore, {
Session,
SessionEvent,
SessionId,
snapshotSessionEvent,
} from '@deepseek-ai/dsh-session'
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
@@ -220,6 +221,156 @@ describe('Session', () => {
.toEqual([unrelatedPrimitiveData])
})
it('rejects event-specific malformed message shapes on seed/load', () => {
const user = {
id: 'user',
role: 'user',
content: [{ type: 'text', text: 'content' }],
source: { kind: 'user' },
}
const assistant = {
id: 'assistant',
role: 'assistant',
content: [{ type: 'text', text: 'content' }],
source: { kind: 'model', provider: 'mock', model: 'mock' },
}
const tool = {
id: 'tool',
role: 'user',
content: [{
type: 'tool-result',
toolCallId: 'call',
content: [{ type: 'text', text: 'result' }],
}],
source: { kind: 'tool', callId: 'call' },
}
const invalid = [
{
name: 'message record',
event: {
type: 'user/message', seq: 0, time: 1, surfaceOp: 'append',
data: null,
},
message: 'lacks an identified message',
},
{
name: 'user role',
event: {
type: 'user/message', seq: 0, time: 1, surfaceOp: 'append',
data: { ...user, role: 'assistant' },
},
message: 'message must have role "user"',
},
{
name: 'source',
event: {
type: 'user/message', seq: 0, time: 1, surfaceOp: 'append',
data: { ...user, source: null },
},
message: 'message has invalid source',
},
{
name: 'assistant source',
event: {
type: 'assistant/message', seq: 0, time: 1, surfaceOp: 'append',
data: {
turn: 1,
step: 1,
message: { ...assistant, source: { kind: 'user' } },
},
},
message: 'message must have model source',
},
{
name: 'content block',
event: {
type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append',
data: {
turn: 1,
message: { ...user, content: 'not-an-array' },
},
},
message: 'message has invalid content',
},
{
name: 'tool source',
event: {
type: 'tool/result', seq: 0, time: 1, surfaceOp: 'append',
data: {
turn: 1,
step: 1,
message: { ...tool, source: { kind: 'user' } },
},
},
message: 'message must have tool source',
},
{
name: 'tool tuple',
event: {
type: 'tool/result', seq: 0, time: 1, surfaceOp: 'append',
data: {
turn: 1,
step: 1,
message: { ...tool, content: [{ type: 'text', text: 'not a result' }] },
},
},
message: 'message must contain one tool-result block',
},
{
name: 'tool correlation',
event: {
type: 'tool/result', seq: 0, time: 1, surfaceOp: 'append',
data: {
turn: 1,
step: 1,
message: {
...tool,
source: { kind: 'tool', callId: 'other-call' },
},
},
},
message: 'message has mismatched tool call ids',
},
] as const
for (const { name, event, message } of invalid) {
expect(
() => new Session(SessionId(`invalid-${name}`), [event as unknown as SessionEvent]),
name,
).toThrow(message)
}
})
it('snapshots message events without validating plugin-owned block details', () => {
const boundary = snapshotSessionEvent({
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
})
expect(boundary).toEqual({
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
})
const extended = snapshotSessionEvent({
type: 'user/message',
seq: 0,
time: 1,
surfaceOp: 'append',
data: {
id: 'extended-message',
role: 'user',
content: [{ type: 'plugin-block', value: 1 }],
source: { kind: 'plugin-source', value: 1 },
},
} as unknown as SessionEvent)
expect(extended.type === 'user/message' && extended.data.content)
.toEqual([{ type: 'plugin-block', value: 1 }])
})
it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => {
const valid = {
type: 'request/header',