fix: enforce message snapshot invariants
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/*` 事件。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user