Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress

# Conflicts:
#	.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml
#	docs/core-data-structures/session.i18n.yaml
#	examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl
#	examples/acp-agent/tests/snapshots/fs-write/session.jsonl
#	examples/acp-agent/tests/snapshots/todo-write/session.jsonl
#	examples/headless-agent/tests/todo-write.e2e.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
#	packages/todo/tool-todo/README.i18n.yaml
This commit is contained in:
Chinesezjc
2026-07-28 20:41:55 +08:00
391 changed files with 6129 additions and 3065 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/session/README.md
README.md: f54dc3048fb9ee765a420f387dce9a729c8a85ef
README.zh.md: a3737a12ebe5c86d77ceb6776359a84fb7f0d84f
README.md: 40516d12180de9c30efd40fdffa873da20ddacb3
README.zh.md: 43842643a3434c741f219f7b6c26622cddfae8e7

View File

@@ -38,7 +38,7 @@ The store pairs announced creation with disposal, publishes post-commit append n
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, complete replacement coverage, and content-only single-result `tool/result` rewrites, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
- `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. Assistant projections preserve provider/model provenance and adapter-private replay state. A surface rewrite rebuilds the projection; there is no raw-log fallback.
- `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over the complete identified, frozen messages stored by those entries. Assistant messages preserve provider/model provenance and adapter-private replay state in their model source. A surface rewrite rebuilds the projection; there is no raw-log fallback.
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and request checks.
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
- `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen.
@@ -65,9 +65,9 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
A `user/message` renders its `content` verbatim as a user-role message whether it is a direct human prompt, a synthetic injection, or an admitted goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. Turn execution remains enclosed by `turn/start` and `turn/end`, while an idle injection may append and flush a `user/message` between turns without running the model.
A `user/message` stores the complete `UserMessage` directly, including the identity created before routing or prompt admission. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an admitted goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message`, `tool/result`, and `steering/message` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`, while an idle injection may append and flush a `user/message` between turns without running the model.
`tool/result` persists the model-facing content, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message. This preserves the existing event shape and does not change `SESSION_FORMAT_VERSION`.
`tool/result` persists one identified user-role tool-result message, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message.
### Session event vocabulary (`types.ts`)
@@ -100,7 +100,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
#### What the model sees
The model receives projections of `user/message`, `assistant/message`, `tool/result`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
The model receives the complete messages from `user/message`, `assistant/message`, `tool/result`, and `steering/message` surface entries verbatim. Their identities, roles, sources, and content blocks are the same values established at creation; projections do not mint identities. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
#### Token effect

View File

@@ -38,7 +38,7 @@
普通类(不是 Cordis 服务)。通过 `ctx.sessions.create()` 创建。
- `session.append(type, data, opts?)` 会为持久数据和 surface 元数据制作快照并冻结它们,校验标记形态、溯源信息、替换覆盖完整性,以及仅修改内容的单个 `tool/result` 重写,随后同步提交,再在彼此独立的失败收容下通知观察者。对已附加会话的重入追加会被拒绝,运行时检查也覆盖扩宽后的联合类型和已加载日志。
- `session.deriveMessages()` 对每个新的 surface 条目只做一次增量投影,并返回一个新数组,数组元素引用共享的冻结消息。assistant 投影保留提供方模型溯源信息及适配器私有回放状态。surface 重写会重建投影;不存在原始日志回退。
- `session.deriveMessages()` 对每个新的 surface 条目只做一次增量投影,并返回一个新数组,其中包含这些条目存储的完整、带标识且冻结消息。assistant 消息会在其模型来源中保留提供方模型溯源信息及适配器私有回放状态。surface 重写会重建投影;不存在原始日志回退。
- `session.deriveEventMessage(event)` 是重建和请求检查使用的规范逐事件投影。
- `session.surface` 暴露只读 `SessionSurface` 视图,由会话唯一的增量 surface 管理器所有;每次提交重写,`replaceGeneration` 都会变化。
- `session.events` 是按追加失效的缓存冻结快照;已接受事件保持深度冻结。
@@ -65,9 +65,9 @@
`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial``resume``change``foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。
`user/message`将其 `content` 原样呈现为 user-role 消息,无论它是直接人类提示词、合成注入,还是已准入的 Goal Round带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。轮次执行仍由 `turn/start``turn/end` 包围,而空闲注入可以在轮次之间追加并刷新一条 `user/message`,无需运行模型。
`user/message`直接存储完整的 `UserMessage`,其中包括路由或提示词准入前创建的标识。无论它是直接人类提示词、合成注入,还是已准入的 Goal Round,都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message``tool/result` 和 steering中途引导对应的 `steering/message` 也会存储完整的消息值。轮次执行仍由 `turn/start``turn/end` 包围,而空闲注入可以在轮次之间追加并刷新一条 `user/message`,无需运行模型。
`tool/result` 持久保存面向模型的内容、可选内部失败标识和可选呈现元数据。工具成功时的规范 `value` 和便于人类阅读的规范失败消息只存在于执行本地;渲染后的错误内容是回放权威消息。这样会保留现有事件形态,且不改变 `SESSION_FORMAT_VERSION`
`tool/result` 持久保存一条带标识、user-role 的工具结果消息,以及可选内部失败标识和可选呈现元数据。工具成功时的规范 `value` 和便于人类阅读的规范失败消息只存在于执行本地;渲染后的错误内容是回放权威消息。
### 会话事件词汇(`types.ts`
@@ -100,7 +100,7 @@
#### 模型看到的内容
模型会原样接收 `user/message``assistant/message``tool/result``steering/message` surface 条目的投影:每个投影都是一条 user-role 或 assistant-role 消息,其内容块保持不变。提示词封装只改变面向人的呈现;其前缀上下文和请求分隔符已经位于事件内容中。工具调用包含在 assistant 消息内。分片、边界、用量、hook 记录、todo 记录以及其他仅日志事件不会添加消息。
模型会原样接收 `user/message``assistant/message``tool/result``steering/message` surface 条目中的完整消息。其标识、角色、来源和内容块都与创建时确定的值相同;投影不会生成标识。提示词封装只改变面向人的呈现;其前缀上下文和请求分隔符已经位于事件内容中。工具调用包含在 assistant 消息内。分片、边界、用量、hook 记录、todo 记录以及其他仅日志事件不会添加消息。
#### Token 影响

View File

@@ -20,6 +20,7 @@ import type { SessionSurface } from './surface.ts'
import { foldRequestHeader } from './request-header.ts'
export * from './types.ts'
export type { AssistantMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
export { isJsonValue, snapshotJsonValue } from './json.ts'
export type { JsonValue } from './json.ts'
export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
@@ -145,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
@@ -165,13 +193,14 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
assertCurrentTurnEndShape(event, index)
}
/** Reject pre-provider request headers and assistant messages 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']
@@ -180,8 +209,63 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`)
}
}
if (event['type'] === 'assistant/message' && !hasProviderModel(record['provenance'])) {
throw new Error(`seed assistant/message at index ${index} lacks provider/model provenance`)
const type = event['type']
if (type !== 'user/message' && type !== 'assistant/message'
&& type !== 'tool/result' && type !== 'steering/message') return
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(`${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`)
}
}
@@ -517,7 +601,7 @@ export class Session {
// A surface node is one of the five message-producing types, but an
// empty-content assistant/message (a max-tokens step that hosts only
// usage) derives to null and must not enter the transcript.
if (msg) this.derived.push(deepFreeze(msg))
if (msg) this.derived.push(msg)
}
this.derivedNodes = nodes.length
return [...this.derived]
@@ -530,10 +614,9 @@ export class Session {
* The per-node pure function {@link deriveMessages} folds over the surface;
* an external reconstructor (or the dev invariant) folds the same function
* over a log prefix's surface to rebuild the exact messages any request was
* built from (the reconstructability Agent Note). The returned message wrapper is
* fresh; its content reuses the logged event's already deep-frozen durable
* data, so changing the wrapper cannot rewrite the log and changing content
* throws.
* built from (the reconstructability Agent Note). The returned message is
* the already frozen message nested in the event wrapper and shared by
* delivery, durable history, and model requests.
* @param event - the event to project.
* @returns the derived message, or null when the event produces none.
*/
@@ -545,30 +628,28 @@ export class Session {
switch (event.type) {
// Ordinary prompts, injected context, and mid-turn steering project
// identically in user role: the event's model-facing content stays
// verbatim. The message's `source` and steering's `turn` are log-only. Do NOT
// verbatim. Steering's `turn` is log-only. Do NOT
// re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is
// caller-owned — a producer bakes it into `content`, as workspace-context
// does with `<system-reminder>` — or, if reintroduced, must be driven by
// the event `meta` map and a dedicated renderer, keeping this projection a
// verbatim pass-through. See the deferred design note in
// ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md
case 'user/message':
case 'user/message': {
return event.data
}
case 'steering/message': {
return { role: 'user', content: event.data.content }
return event.data.message
}
case 'assistant/message': {
// Skip an empty-content assistant/message: it exists only to host a
// max-tokens step's usage and must not inject a content-less assistant
// turn into the provider transcript.
if (event.data.content.length === 0) return null
return { role: 'assistant', content: event.data.content, provenance: event.data.provenance }
if (event.data.message.content.length === 0) return null
return event.data.message
}
case 'tool/result': {
const { callId, content, isError } = event.data
return {
role: 'user',
content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
}
return event.data.message
}
default:
// A non-surface event (boundary, chunk, log-only record) projects to

View File

@@ -134,11 +134,12 @@ function validateEvent(
break
}
requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step, fail)
const syntheticNotStarted = event.data.isError && event.data.error?.code === TOOL_NOT_STARTED
if (!trace.pendingCalls.has(event.data.callId) && !syntheticNotStarted) {
fail(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
const callId = event.data.message.source.callId
const syntheticNotStarted = event.data.message.content[0].isError === true && event.data.error?.code === TOOL_NOT_STARTED
if (!trace.pendingCalls.has(callId) && !syntheticNotStarted) {
fail(`tool/result for ${callId} with no prior tool/call in this step`)
}
pendingCalls = { kind: 'delete', callId: event.data.callId }
pendingCalls = { kind: 'delete', callId }
break
}
case 'user/message':

View File

@@ -5,7 +5,8 @@
* @module @deepseek-ai/dsh-session/repair
*/
import type { CallId } from '@deepseek-ai/dsh-llm'
import { MessageId, freezeMessage, type CallId } from '@deepseek-ai/dsh-llm'
import type { ToolResultMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from './types.ts'
/** Recovery code for an assistant tool request that never reached a recorded call start. */
@@ -51,7 +52,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
case 'assistant/message':
// The assistant message carries the tool-call blocks; each is pending
// until a tool/result event with the same callId is logged.
for (const block of event.data.content) {
for (const block of event.data.message.content) {
if (block.type === 'tool-call') pendingCalls.set(block.id, { step: event.data.step })
}
break
@@ -65,7 +66,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
}
break
case 'tool/result':
pendingCalls.delete(event.data.callId)
pendingCalls.delete(event.data.message.source.callId)
break
// Other event types do not move the turn/step boundary cursor.
default:
@@ -89,6 +90,22 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
// and Map insertion order preserves their transcript order.
for (const [callId, { step, callSeq }] of pendingCalls) {
const started = callSeq !== undefined
const message: ToolResultMessage = freezeMessage({
id: MessageId(`interrupted-tool-result-${callId}-${seq}`),
role: 'user',
source: { kind: 'tool', callId },
content: [{
type: 'tool-result',
toolCallId: callId,
isError: true,
content: [{
type: 'text',
text: started
? 'The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.'
: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
}],
}],
})
closers.push({
type: 'tool/result',
seq: seq++,
@@ -96,14 +113,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
data: {
turn: openTurn,
step,
callId,
content: [{
type: 'text',
text: started
? 'The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.'
: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
}],
isError: true,
message,
error: started
? { name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN }
: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },

View File

@@ -224,8 +224,16 @@ function assertToolResultRewrite(
}
const originalRest = { ...original.data } as Record<string, unknown>
const replacementRest = { ...event.data } as Record<string, unknown>
delete originalRest['content']
delete replacementRest['content']
const originalResult = original.data.message.content[0]
const replacementResult = event.data.message.content[0]
originalRest['message'] = {
...original.data.message,
content: [{ ...originalResult, content: null }],
}
replacementRest['message'] = {
...event.data.message,
content: [{ ...replacementResult, content: null }],
}
if (!isDeepEqualJson(originalRest, replacementRest)) {
throw new Error('tool/result surface replacement may change only content')
}

View File

@@ -1,5 +1,16 @@
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, LlmFailure, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
import type {
AssistantMessage,
CallId,
LlmCallConfig,
LlmFailure,
MessageSource,
StreamChunk,
TokenUsage,
ToolResultMessage,
ToolSchema,
UserMessage,
} from '@deepseek-ai/dsh-llm'
import type { JsonValue } from './json.ts'
/** Identifies one session in the store (and its persistence artifacts). */
@@ -166,20 +177,6 @@ export interface EpochHeader {
*/
export type RequestHeaderReason = 'initial' | 'resume' | 'change'
/**
* Shared payload for user, injected-context, and steering messages. A
* direct human prompt, a synthetic `agent.inject()` context, and mid-turn
* steering all project into the model transcript as verbatim user-role content;
* they are told apart by `source` (a non-`user` kind marks injected context),
* not by event type.
*/
export interface UserMessageData {
/** Exact model-facing blocks. */
content: ContentBlock[]
/** Producer provenance. */
source: MessageSource
}
/**
* The merge-extensible, append-only source of truth for an agent interaction.
* Message history is derived from this log. Every event is lossless JSON and
@@ -210,7 +207,7 @@ export interface SessionEventMap {
* project their `content` verbatim; `source` tells them apart. An idle
* injection may append this event between turns without running the model.
*/
'user/message': UserMessageData
'user/message': UserMessage
/** Raw stream chunk — token-level replay fidelity. */
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
/**
@@ -219,7 +216,7 @@ export interface SessionEventMap {
* the model output and its accounting travel together (there is no separate
* usage record). `usage` is absent when the adapter reported none.
*/
'assistant/message': { turn: number; step: number; content: ContentBlock[]; provenance: AssistantProvenance; usage?: TokenUsage }
'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage }
/**
* The model requested one tool invocation: `name` with the raw `arguments`
* JSON string exactly as the model produced it (unparsed). `callId` pairs the
@@ -240,14 +237,12 @@ export interface SessionEventMap {
'tool/result': {
turn: number
step: number
callId: CallId
content: ContentBlock[]
isError: boolean
message: ToolResultMessage
error?: { name: string; code: string }
meta?: JsonValue
}
/** Steering content injected between steps of a running turn. */
'steering/message': UserMessageData & { turn: number }
'steering/message': { turn: number; message: UserMessage }
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
'todo/write': { todos: TodoItem[] }
/**

View File

@@ -1,3 +1,4 @@
import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
/**
* Derived-message cache contract against a scratch oracle: project new nodes
* once, rebuild on surface replacements, return fresh arrays over shared
@@ -8,7 +9,9 @@ import { describe, expect, it } from 'vitest'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
function userText(session: Session, text: string): void {
session.append('user/message', { content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
/** From-scratch oracle: replay the log into a fresh session and derive. */
@@ -23,9 +26,30 @@ describe('derived-message cache', () => {
userText(session, 'one')
expect(session.deriveMessages()).toEqual(scratch(session))
userText(session, 'two')
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'reply' }] }, { surfaceOp: 'append' })
session.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'reply' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
expect(session.deriveMessages()).toEqual(scratch(session))
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 2, content: [], usage: { inputTokens: 1, outputTokens: 0 } }, { surfaceOp: 'append' })
session.append('assistant/message', {
turn: 1, step: 2,
message: createMessage({
role: 'assistant',
content: [],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
usage: { inputTokens: 1, outputTokens: 0 },
}, { surfaceOp: 'append' })
expect(session.deriveMessages()).toEqual(scratch(session))
})
@@ -38,9 +62,9 @@ describe('derived-message cache', () => {
expect(beforeReplace).toHaveLength(2)
const nodes = session.surface.nodes
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
}), { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
expect(session.deriveMessages()).toHaveLength(1)
expect(session.deriveMessages()).toEqual(scratch(session))
@@ -67,7 +91,9 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
it('projects one appended event exactly as the full derivation projects its node', () => {
const session = new Session(SessionId('per-event'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const event = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
// Full and per-event derivation share one projection.
expect(session.deriveEventMessage(event)).toEqual(session.deriveMessages().at(-1))
})
@@ -75,7 +101,9 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
it('reuses the logged event\'s already frozen content', () => {
const session = new Session(SessionId('per-event-clone'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const event = session.append('user/message', { content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const event = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const message = session.deriveEventMessage(event)!
expect(message.content).toBe(event.data.content)
expect(Object.isFrozen(message.content)).toBe(true)
@@ -89,7 +117,17 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const boundary = session.append('step/start', { turn: 1, step: 1 })
expect(session.deriveEventMessage(boundary)).toBeNull()
const empty = session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append' })
const empty = session.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
expect(session.deriveEventMessage(empty)).toBeNull()
})
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId , createMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
@@ -23,19 +23,19 @@ function appendClosedTurn(
reason: TurnEndReason = { kind: 'completed' },
): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason })
}
function appendOpenTurn(session: Session, turn: number): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `open ${turn}` }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
}
function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/message'> {
@@ -116,7 +116,12 @@ describe('SessionStore.fork', () => {
expect(child.events).toEqual(source.events.slice(0, firstBoundary + 1))
expect(child.header.seedLength).toBe(firstBoundary + 1)
expect(child.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'first' }] }])
expect(child.deriveMessages()).toEqual([{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'first' }],
source: { kind: 'user' },
}])
})
it('accepts every turn/end reason as an explicit fork boundary', async () => {
@@ -210,23 +215,42 @@ describe('SessionStore.fork', () => {
}],
['user/message', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'open' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'open' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
return lastSeq(session)
}],
['assistant/message', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'partial' }] }, { surfaceOp: 'append' })
session.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'partial' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
return lastSeq(session)
}],
['tool/call', (session) => {
const callId = CallId('call-open')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
return lastSeq(session)

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope'
import { CallId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId, createMessage, createToolResultMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TOOL_NOT_STARTED } from '@deepseek-ai/dsh-session'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
@@ -36,17 +36,32 @@ describe('session-log invariants', () => {
const session = ctx.sessions.create()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
session.append('assistant/message', {
provenance: { provider: 'mock', model: 'mock' },
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }],
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false }, { surfaceOp: 'append' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c1'),
content: [],
isError: false,
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
@@ -118,14 +133,16 @@ describe('session-log invariants', () => {
.toThrow(/expected turn 2, got 3/)
const outside = (await setup()).ctx.sessions.create()
expect(() => outside.append('user/message', {
expect(() => outside.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'idle context' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })).not.toThrow()
}), { surfaceOp: 'append' })).not.toThrow()
expect(() => outside.append('steering/message', {
turn: 1,
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
message: createUserMessage({
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
// The owning plugin decides whether a merge-extensible event is log-only.
const appendUnknown = outside.append.bind(outside) as (type: string, data: unknown) => unknown
@@ -149,10 +166,16 @@ describe('session-log invariants', () => {
.toThrow(/while step 1 is still open/)
expect(() => nested.append('step/end', { turn: 1, step: 2 })).toThrow(/open is turn 1\/step 1/)
expect(() => nested.append('assistant/message', {
provenance: { provider: 'mock', model: 'mock' },
turn: 1,
step: 2,
content: [],
message: createMessage({
role: 'assistant',
content: [],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step 1/)
const skipped = (await setup()).ctx.sessions.create()
@@ -178,9 +201,11 @@ describe('session-log invariants', () => {
expect(() => tool.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('ghost'),
content: [],
isError: false,
message: createToolResultMessage({
callId: CallId('ghost'),
content: [],
isError: false,
}),
}, { surfaceOp: 'append' })).toThrow(/no prior tool\/call/)
})
@@ -191,9 +216,11 @@ describe('session-log invariants', () => {
expect(() => session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('closed'),
content: [],
isError: false,
message: createToolResultMessage({
callId: CallId('closed'),
content: [],
isError: false,
}),
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step null/)
})
@@ -212,9 +239,11 @@ describe('session-log invariants', () => {
const original = session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
content: [{ type: 'text', text: 'original' }],
isError: false,
message: createToolResultMessage({
callId: CallId('rewrite'),
content: [{ type: 'text', text: 'original' }],
isError: false,
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -222,7 +251,13 @@ describe('session-log invariants', () => {
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: 'pruned' }],
message: freezeMessage({
...original.data.message,
content: [{
...original.data.message.content[0],
content: [{ type: 'text', text: 'pruned' }],
}],
}),
}, {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
@@ -244,16 +279,24 @@ describe('session-log invariants', () => {
const original = session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
content: [{ type: 'text', text: 'original' }],
isError: false,
message: createToolResultMessage({
callId: CallId('rewrite'),
content: [{ type: 'text', text: 'original' }],
isError: false,
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(() => session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: 'pruned' }],
message: freezeMessage({
...original.data.message,
content: [{
...original.data.message.content[0],
content: [{ type: 'text', text: 'pruned' }],
}],
}),
}, {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
@@ -268,9 +311,11 @@ describe('session-log invariants', () => {
repaired.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('crashed'),
content: [],
isError: true,
message: createToolResultMessage({
callId: CallId('crashed'),
content: [],
isError: true,
}),
error: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
}, { surfaceOp: 'append' })
repaired.append('step/end', { turn: 1, step: 1 })
@@ -298,9 +343,11 @@ describe('session-log invariants', () => {
expect(() => session.append('tool/result', {
turn: 1,
step: 2,
callId: CallId('c1'),
content: [],
isError: false,
message: createToolResultMessage({
callId: CallId('c1'),
content: [],
isError: false,
}),
}, { surfaceOp: 'append' })).toThrow(/no prior tool\/call in this step/)
})

View File

@@ -9,7 +9,7 @@
import { describe, expect, it } from 'vitest'
import fc from 'fast-check'
import { CallId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventMap, SessionEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
@@ -27,11 +27,45 @@ const textContentArb = fc.array(
// A message-producing event (these DO affect derived history). Each carries an
// explicit `surfaceOp: 'append'` intent — the marker the real loop passes.
const messageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
textContentArb.map((content): Appendable => ({ type: 'user/message', data: { content, source: { kind: 'user' } }, intent: { surfaceOp: 'append' } })),
textContentArb.map((content): Appendable => ({ type: 'assistant/message', data: { turn: 1, step: 1, content, provenance: { provider: 'mock', model: 'mock' } }, intent: { surfaceOp: 'append' } })),
textContentArb.map((content): Appendable => ({ type: 'assistant/message', data: { turn: 1, step: 1, content, provenance: { provider: 'mock', model: 'mock' }, usage: { inputTokens: 1, outputTokens: 1 } }, intent: { surfaceOp: 'append' } })),
textContentArb.map((content): Appendable => ({ type: 'user/message', data: createUserMessage({
content, source: { kind: 'user' },
}), intent: { surfaceOp: 'append' } })),
textContentArb.map((content): Appendable => ({
type: 'assistant/message',
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content,
source: { kind: 'model', provider: 'mock', model: 'mock' },
}),
},
intent: { surfaceOp: 'append' },
})),
textContentArb.map((content): Appendable => ({
type: 'assistant/message',
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content,
source: { kind: 'model', provider: 'mock', model: 'mock' },
}),
usage: { inputTokens: 1, outputTokens: 1 },
},
intent: { surfaceOp: 'append' },
})),
fc.record({ id: fc.string({ minLength: 1 }), content: textContentArb, isError: fc.boolean() })
.map((r): Appendable => ({ type: 'tool/result', data: { turn: 1, step: 1, callId: CallId(r.id), content: r.content, isError: r.isError }, intent: { surfaceOp: 'append' } })),
.map((r): Appendable => ({ type: 'tool/result', data: {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId(r.id),
content: r.content,
isError: r.isError,
}),
}, intent: { surfaceOp: 'append' } })),
)
// A non-message event (trace/replay data — must NOT affect derived history).

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import { CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
@@ -51,10 +51,20 @@ describe('interruptedTurnClosers', () => {
const events: SessionEvent[] = [
userTurnStart(2, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 2, step: 1, content: [
{ type: 'text', text: 'calling a tool' },
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
], provenance: { provider: 'mock', model: 'mock' } } },
{ type: 'assistant/message', seq: 2, time: 2, data: {
turn: 2, step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'text', text: 'calling a tool' },
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
} },
]
const closers = interruptedTurnClosers(events)
// tool/result (for the orphaned call) → step/end → turn/end, contiguous seqs.
@@ -62,9 +72,15 @@ describe('interruptedTurnClosers', () => {
expect(closers.map(e => e.seq)).toEqual([3, 4, 5])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data).toMatchObject({
turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: TOOL_NOT_STARTED },
turn: 2,
step: 1,
message: {
source: { callId: CallId('call-1') },
content: [{ isError: true }],
},
error: { code: TOOL_NOT_STARTED },
})
expect(result.type === 'tool/result' && result.data.content).toEqual([{
expect(result.type === 'tool/result' && result.data.message.content[0].content).toEqual([{
type: 'text', text: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
}])
})
@@ -73,10 +89,27 @@ describe('interruptedTurnClosers', () => {
const events: SessionEvent[] = [
userTurnStart(2, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 2, step: 1, content: [
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
], provenance: { provider: 'mock', model: 'mock' } } },
{ type: 'tool/result', seq: 3, time: 3, data: { turn: 2, step: 1, callId: CallId('call-1'), content: [{ type: 'text', text: 'ok' }], isError: false } },
{ type: 'assistant/message', seq: 2, time: 2, data: {
turn: 2, step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
} },
{ type: 'tool/result', seq: 3, time: 3, data: {
turn: 2, step: 1,
message: createToolResultMessage({
callId: CallId('call-1'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
} },
]
// The call is answered, so only the open step + turn need closing.
const closers = interruptedTurnClosers(events)
@@ -87,9 +120,19 @@ describe('interruptedTurnClosers', () => {
const events: SessionEvent[] = [
userTurnStart(2, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 2, step: 1, content: [
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
], provenance: { provider: 'mock', model: 'mock' } } },
{ type: 'assistant/message', seq: 2, time: 2, data: {
turn: 2, step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
} },
{ type: 'step/end', seq: 3, time: 3, data: { turn: 2, step: 1 } },
]
@@ -104,48 +147,102 @@ describe('interruptedTurnClosers', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [
{ type: 'tool-call', id: CallId('old-call'), name: 'bash', arguments: '{}' },
], provenance: { provider: 'mock', model: 'mock' } } },
{ type: 'tool/result', seq: 3, time: 3, data: { turn: 1, step: 1, callId: CallId('old-call'), content: [], isError: false } },
{ type: 'assistant/message', seq: 2, time: 2, data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('old-call'), name: 'bash', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
} },
{ type: 'tool/result', seq: 3, time: 3, data: {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('old-call'),
content: [],
isError: false,
}),
} },
{ type: 'step/end', seq: 4, time: 4, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 5, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
userTurnStart(2, 6),
{ type: 'step/start', seq: 7, time: 7, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 8, time: 8, data: { turn: 2, step: 1, content: [
{ type: 'tool-call', id: CallId('new-call'), name: 'bash', arguments: '{}' },
], provenance: { provider: 'mock', model: 'mock' } } },
{ type: 'assistant/message', seq: 8, time: 8, data: {
turn: 2, step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('new-call'), name: 'bash', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
} },
]
const closers = interruptedTurnClosers(events)
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data.callId).toBe('new-call')
expect(result.type === 'tool/result' && result.data.message.source.callId).toBe('new-call')
})
it('synthesizes a result for each of multiple unanswered calls, in log order', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [
{ type: 'tool-call', id: CallId('call-a'), name: 'bash', arguments: '{}' },
{ type: 'tool-call', id: CallId('call-b'), name: 'bash', arguments: '{}' },
], provenance: { provider: 'mock', model: 'mock' } } },
{ type: 'assistant/message', seq: 2, time: 2, data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('call-a'), name: 'bash', arguments: '{}' },
{ type: 'tool-call', id: CallId('call-b'), name: 'bash', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
} },
// call-a got answered before the crash; call-b did not.
{ type: 'tool/result', seq: 3, time: 3, data: { turn: 1, step: 1, callId: CallId('call-a'), content: [], isError: false } },
{ type: 'tool/result', seq: 3, time: 3, data: {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('call-a'),
content: [],
isError: false,
}),
} },
]
const closers = interruptedTurnClosers(events)
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data.callId).toBe('call-b')
expect(result.type === 'tool/result' && result.data.message.source.callId).toBe('call-b')
})
it('synthesized tool/result carries surfaceOp and sourceEventSeqs when tool/call was logged', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
], provenance: { provider: 'mock', model: 'mock' } } },
{ type: 'assistant/message', seq: 2, time: 2, data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
} },
{ type: 'tool/call', seq: 3, time: 3, data: { turn: 1, step: 1, callId: CallId('call-1'), name: 'bash', arguments: '{}' } },
]
const closers = interruptedTurnClosers(events)
@@ -156,11 +253,11 @@ describe('interruptedTurnClosers', () => {
expect(result.type === 'tool/result' && result.data.error).toEqual({
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
})
if (result.type !== 'tool/result' || result.data.content[0]?.type !== 'text') {
if (result.type !== 'tool/result' || result.data.message.content[0].content[0]?.type !== 'text') {
throw new Error('expected a text tool result')
}
expect(result.data.content[0].text).toContain('retry only if the operation is read-only or idempotent')
expect(result.data.content[0].text).toContain('first verify external state or ask the user')
expect(result.data.message.content[0].content[0].text).toContain('retry only if the operation is read-only or idempotent')
expect(result.data.message.content[0].content[0].text).toContain('first verify external state or ask the user')
})
it('handles tool/call without a matching assistant/message entry gracefully', () => {

View File

@@ -3,8 +3,8 @@
import { describe, expect, it } from 'vitest'
import { Session, SessionId, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session'
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
const CONFIG = { provider: 'mock', model: 'm' }
@@ -55,7 +55,9 @@ describe('foldRequestHeader', () => {
const session = new Session(SessionId('fold'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('request/header', { header: { config: CONFIG, system: 'first' }, reason: 'initial' })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('request/header', { header: { config: { provider: 'mock', model: 'other' }, tools: [] }, reason: 'change' })
expect(foldRequestHeader(session.events)).toEqual({ config: { provider: 'mock', model: 'other' } })
})

View File

@@ -1,12 +1,13 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import SessionStore, {
findLastMessageTurnEnd,
SESSION_FORMAT_VERSION,
Session,
SessionEvent,
SessionId,
snapshotSessionEvent,
} from '@deepseek-ai/dsh-session'
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
@@ -22,16 +23,32 @@ describe('Session', () => {
it('derives message history from the event log', () => {
const session = new Session(SessionId('s1'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
session.append('assistant/message', {
turn: 1, step: 1,
content: [
{ type: 'text', text: 'let me check' },
{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' },
],
message: createMessage({
role: 'assistant',
content: [
{ type: 'text', text: 'let me check' },
{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c1'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const messages = session.deriveMessages()
@@ -61,10 +78,10 @@ describe('Session', () => {
turn: 1,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } },
})
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'before' }],
source: { kind: 'plugin', plugin: 'before' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
@@ -72,19 +89,19 @@ describe('Session', () => {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'bounded prompt' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
const messageEnd = session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
session.append('turn/start', {
turn: 3,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } },
})
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'after' }],
source: { kind: 'plugin', plugin: 'after' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd)
@@ -118,14 +135,16 @@ describe('Session', () => {
it('renders injected-context and steering messages as plain user content', () => {
const session = new Session(SessionId('s2'))
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'file changed: a.ts' }],
source: { kind: 'plugin', plugin: 'watcher' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
session.append('steering/message', {
turn: 1,
content: [{ type: 'text', text: 'focus on tests' }],
source: { kind: 'user' },
message: createUserMessage({
content: [{ type: 'text', text: 'focus on tests' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })
const [contextMessage, steeringMessage] = session.deriveMessages()
@@ -135,17 +154,15 @@ describe('Session', () => {
expect(steeringMessage!.content).toEqual([{ type: 'text', text: 'focus on tests' }])
})
it('keeps context source durable in the event while hiding it from the projection', () => {
it('keeps the exact identified context message in durable history and projection', () => {
const session = new Session(SessionId('s2-raw'))
session.append('user/message', {
const message = createUserMessage({
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
source: { kind: 'plugin', plugin: 'workspace-context' },
}, { surfaceOp: 'append' })
})
session.append('user/message', message, { surfaceOp: 'append' })
expect(session.deriveMessages()).toEqual([{
role: 'user',
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
}])
expect(session.deriveMessages()).toEqual([message])
const event = session.events[0]
expect(event?.type === 'user/message' && event.data.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
})
@@ -153,8 +170,20 @@ describe('Session', () => {
it('replays identically from a seeded event log', () => {
const original = new Session(SessionId('s3'))
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
original.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' })
original.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
original.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'a' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const replayed = new Session(SessionId('s3-replay'), [...original.events])
@@ -176,7 +205,7 @@ describe('Session', () => {
surfaceOp: 'append',
} as unknown as SessionEvent
expect(() => new Session(SessionId('old-assistant'), [assistantMessage]))
.toThrow('seed assistant/message at index 0 lacks provider/model provenance')
.toThrow('seed assistant/message at index 0 lacks an identified message')
const malformedHeader = {
type: 'request/header', seq: 0, time: 1,
@@ -192,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',
@@ -223,10 +402,16 @@ describe('Session', () => {
it('isolates the log from mutation through a derived message (append-only contract)', () => {
const session = new Session(SessionId('s4'))
session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('tool/result', {
turn: 1, step: 1, callId: CallId('c1'),
content: [{ type: 'text', text: 'tool out' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c1'),
content: [{ type: 'text', text: 'tool out' }],
isError: false,
}),
}, { surfaceOp: 'append' })
const before = structuredClone(session.events)
@@ -282,7 +467,9 @@ describe('Session', () => {
// A widened SessionEventType bypasses the overload's conditional requirement,
// so the runtime guard must still reject the missing surface marker.
const widenedType = 'user/message' as SessionEventType
expect(() => session.append(widenedType, { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
expect(() => session.append(widenedType, createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
})))
.toThrow(/surface-eligible and requires a surfaceOp marker/)
// The rejected append never entered the log (only turn/start is present).
expect(session.events).toHaveLength(1)
@@ -318,7 +505,9 @@ describe('Session', () => {
// compile time; a raw seed must be rejected at runtime to match.
const markerlessSeed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } } },
{ type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({
content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const },
}) },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
@@ -327,7 +516,9 @@ describe('Session', () => {
it('accepts a well-formed contiguous serializable seed', () => {
const goodSeed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
{ type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({
content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const },
}), surfaceOp: 'append' as const },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
const session = new Session(SessionId('seed-ok'), goodSeed)
@@ -380,7 +571,9 @@ describe('Session', () => {
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}),
surfaceOp: { op: 'replace', start: 1n, end: 2 },
}] as unknown as SessionEvent[]
@@ -398,7 +591,9 @@ describe('Session', () => {
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}),
surfaceOp: new ReplaceOp(),
}] as unknown as SessionEvent[]
@@ -445,13 +640,17 @@ describe('Session', () => {
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
}),
surfaceOp: 'append',
}, {
type: 'user/message',
seq: 1,
time: 2,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}),
surfaceOp,
sourceEventSeqs: [0],
}] as unknown as SessionEvent[]
@@ -477,13 +676,17 @@ describe('Session', () => {
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
}),
surfaceOp: 'append',
}, {
type: 'user/message',
seq: 1,
time: 2,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}),
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
}] as unknown as SessionEvent[]
@@ -499,7 +702,11 @@ describe('Session', () => {
it('snapshots the seed: mutating the original after construction does not affect session.events', () => {
const seed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
{ type: 'user/message' as const, seq: 1, time: 2, data: {
id: MessageId('seed-input'),
role: 'user' as const,
content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const },
}, surfaceOp: 'append' as const },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
const session = new Session(SessionId('seed-snapshot'), seed)
@@ -516,7 +723,12 @@ describe('Session', () => {
it('snapshots append data: mutating the passed object after append does not affect session.events', () => {
const session = new Session(SessionId('append-snapshot'))
const data = { content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const } }
const data = {
id: MessageId('append-input'),
role: 'user' as const,
content: [{ type: 'text' as const, text: 'original' }],
source: { kind: 'user' as const },
}
const event = session.append('user/message', data, { surfaceOp: 'append' })
// Mutate the caller's object after append returns. A shared reference would
// make session.events diverge from the value that passed validation.
@@ -552,7 +764,9 @@ describe('Session', () => {
expect(() => session.append(
'user/message',
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}),
{ surfaceOp: { op: 'replace', start: 1n, end: 2 } } as never,
)).toThrow(/non-JSON-serializable surface metadata/)
expect(session.events).toEqual([])
@@ -568,7 +782,9 @@ describe('Session', () => {
expect(() => session.append(
'user/message',
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}),
{ surfaceOp: new ReplaceOp() },
)).toThrow(/non-JSON-serializable surface metadata/)
expect(session.events).toEqual([])
@@ -578,7 +794,9 @@ describe('Session', () => {
const session = new Session(SessionId('append-unstable-metadata'))
const source = session.append(
'user/message',
{ content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
}),
{ surfaceOp: 'append' },
)
let reads = 0
@@ -592,7 +810,9 @@ describe('Session', () => {
const event = session.append(
'user/message',
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}),
{ surfaceOp, sourceEventSeqs: [0] } as never,
)
@@ -809,7 +1029,9 @@ describe('SessionStore', () => {
// but cannot suppress the durable event feed.
expect(Reflect.set(session, 'onAppend', undefined)).toBe(true)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'x' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
expect(events).toHaveLength(2)
expect(events[1]![0]).toBe(session)
expect(events[1]![1].type).toBe('user/message')
@@ -825,7 +1047,9 @@ describe('SessionStore', () => {
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
a.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] })
expect(forked.deriveMessages()).toEqual(a.deriveMessages())
})
@@ -1043,7 +1267,9 @@ describe('SessionStore', () => {
await fiber.dispose()
expect(ctx.sessions.get(SessionId('scoped'))).toBeUndefined()
session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'late' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
expect(observed).toBe(0)
})
@@ -1070,7 +1296,9 @@ describe('SessionStore', () => {
const session = ctx.sessions.create(SessionId('fixed'))
expect(ctx.sessions.get(SessionId('fixed'))).toBe(session)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
expect(events.at(-1)?.type).toBe('user/message')
})
@@ -1157,10 +1385,10 @@ describe('SessionStore', () => {
const session = ctx.sessions.create(SessionId('surface-dispatch-veto'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'source' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
const surface = session.surface
let reject = true
ctx.on('internal/dispatch', (_mode, name) => {
@@ -1171,10 +1399,16 @@ describe('SessionStore', () => {
})
expect(() => session.append('assistant/message', {
provenance: { provider: 'mock', model: 'mock' },
turn: 1,
step: 1,
content: [{ type: 'text', text: 'replacement' }],
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'replacement' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, {
surfaceOp: { op: 'replace', start: 2, end: 2 },
sourceEventSeqs: [2],
@@ -1184,10 +1418,10 @@ describe('SessionStore', () => {
expect(surface.nodes).toEqual([2])
expect(surface.replaceGeneration).toBe(0)
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'next' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
expect(surface.nodes).toEqual([2, 3])
expect(surface.replaceGeneration).toBe(0)
})
@@ -1393,7 +1627,9 @@ describe('todo/write event', () => {
it('is NOT a surface event: it produces no derived message and joins no surface node', () => {
const session = new Session(SessionId('t3'))
session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const before = session.deriveMessages().length
session.append('todo/write', { todos: [{ content: 'a task', status: 'pending' }] })
// The todo event must not add a message to the derived history…

View File

@@ -7,14 +7,33 @@ import {
isSurfaceEligibleType,
isSurfaceEvent,
} from '@deepseek-ai/dsh-session'
import { CallId } from '@deepseek-ai/dsh-llm'
import {
createMessage,
createToolResultMessage,
createUserMessage,
freezeMessage,
CallId,
MessageId,
} from '@deepseek-ai/dsh-llm'
/** Build a minimal session with turn boundaries and a single user message. */
function surfaceSession(): Session {
const s = new Session(SessionId('ss'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'hi' }] }, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'hi' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return s
}
@@ -24,7 +43,9 @@ function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent {
type: 'user/message',
seq,
time: seq,
data: { content: [], source: { kind: 'user' } },
data: createUserMessage({
content: [], source: { kind: 'user' },
}),
surfaceOp: 'append',
...sourceEventSeqs === undefined ? {} : { sourceEventSeqs },
} as unknown as SessionEvent
@@ -43,9 +64,11 @@ function toolResultEvent(
data: {
turn: 1,
step: 1,
callId: CallId(callId),
content: [{ type: 'text', text: `result ${seq}` }],
isError: false,
message: createToolResultMessage({
callId: CallId(callId),
content: [{ type: 'text', text: `result ${seq}` }],
isError: false,
}),
},
surfaceOp,
...sourceEventSeqs === undefined ? {} : { sourceEventSeqs },
@@ -82,10 +105,16 @@ describe('foldSurface provenance', () => {
seq: 0,
time: 0,
data: {
provenance: { provider: 'mock', model: 'mock' },
turn: 1,
step: 1,
content: [],
message: createMessage({
role: 'assistant',
content: [],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
surfaceOp: 'append',
sourceEventSeqs: [],
@@ -142,10 +171,42 @@ describe('foldSurface tool-result rewrites', () => {
expect(() => foldSurface(events)).toThrow(/may change only content/)
})
it.each([
['toolCallId', { toolCallId: CallId('changed') }],
['isError', { isError: true }],
] as const)('rejects a replacement that changes the result block %s', (_field, patch) => {
const original = toolResultEvent(0, 'original')
const data = original.data as Extract<SessionEvent, { type: 'tool/result' }>['data']
const result = data.message.content[0]
const replacement = {
...original,
seq: 1,
time: 1,
data: {
...data,
message: freezeMessage({
...data.message,
content: [{ ...result, ...patch }] as [typeof result],
}),
},
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
} as SessionEvent
expect(() => foldSurface([original, replacement])).toThrow(/may change only content/)
})
it('compares array-valued rest fields structurally (meta arrays: equal accepted, drifted rejected)', () => {
const withMeta = (seq: number, meta: unknown, surfaceOp: SurfaceEvent['surfaceOp'] = 'append', sourceEventSeqs?: number[]): SessionEvent => {
const event = toolResultEvent(seq, 'c-meta', surfaceOp, sourceEventSeqs)
return { ...event, data: { ...(event.data as object), meta } } as SessionEvent
const data = event.data as Extract<SessionEvent, { type: 'tool/result' }>['data']
return {
...event,
data: {
...data,
message: freezeMessage({ ...data.message, id: MessageId('meta-message') }),
meta,
},
} as SessionEvent
}
// Structurally equal arrays (fresh references) pass the rest-field equality.
expect(() => foldSurface([
@@ -178,10 +239,34 @@ describe('foldSurface tool-result rewrites', () => {
describe('SurfaceManager', () => {
it('shares ordered entries and nested replacement ranges with foldSurface', () => {
const s = new Session(SessionId('shared-fold'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'summary' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 2, content: [{ type: 'text', text: 'summary 2' }] }, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2, 1] })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
s.append('assistant/message', {
turn: 1, step: 2,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary 2' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2, 1] })
const folded = foldSurface(s.events)
expect(folded.nodes).toEqual(s.surface.nodes)
@@ -198,8 +283,20 @@ describe('SurfaceManager', () => {
it('does not retain fold-only replacement history in incremental state', () => {
const s = new Session(SessionId('incremental-state'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'b' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'b' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
expect(s.surface.nodes).toEqual([1])
const manager = s.surface as unknown as { _state: object }
@@ -222,7 +319,9 @@ describe('SurfaceManager', () => {
it('leaves incremental state unchanged when candidate validation fails', () => {
const s = new Session(SessionId('atomic-validation'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const surface = s.surface
const nodes = surface.nodes
@@ -231,7 +330,17 @@ describe('SurfaceManager', () => {
expect(() => s.append(
'assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'invalid' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'invalid' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 0, end: 0 } },
)).toThrow(/missing 0/)
@@ -241,7 +350,9 @@ describe('SurfaceManager', () => {
expect(surface.replaceGeneration).toBe(0)
expect(surface.nodes).toEqual(foldSurface(s.events).nodes)
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
expect(surface.nodes).toBe(nodes)
expect(surface.nodes).toEqual([0, 1])
expect(surface.replaceGeneration).toBe(0)
@@ -253,7 +364,9 @@ describe('SurfaceManager', () => {
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' },
}),
}
expect(() => foldSurface([malformed]))
@@ -294,14 +407,28 @@ describe('SurfaceManager', () => {
it('picks up new events incrementally (delta processing)', () => {
const s = surfaceSession()
expect(s.surface.nodes.length).toBe(2)
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
s.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c1'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
expect(s.surface.nodes.length).toBe(3)
expect(s.surface.nodes[2]!).toBe(4) // seq 4: after turn/end at seq 3
})
it('replays identically from a seeded log with surface markers', () => {
const original = surfaceSession()
original.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
original.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c1'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
const replayed = new Session(SessionId('replay'), [...original.events])
expect(replayed.surface.nodes).toEqual([1, 2, 4])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
@@ -310,7 +437,17 @@ describe('SurfaceManager', () => {
it('rebuild with replace operation splices out shadowed nodes', () => {
const s = surfaceSession()
s.append('assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 2, step: 1, content: [{ type: 'text', text: 'summary' }] },
{
turn: 2, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] },
)
expect(s.surface.nodes).toEqual([4])
@@ -318,12 +455,28 @@ describe('SurfaceManager', () => {
it('replace with both ends at real nodes splices only the range', () => {
const s = new Session(SessionId('range'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
s.append('user/message', { content: [{ type: 'text', text: 'c' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 1
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'c' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 2
// Replace seq 0 through 1 inclusive: shadow a and b, keep c.
s.append('assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'summary' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 0, end: 1 }, sourceEventSeqs: [0, 1] },
) // seq 3
expect(s.surface.nodes).toEqual([3, 2])
@@ -331,11 +484,25 @@ describe('SurfaceManager', () => {
it('single-node replacement (start === end)', () => {
const s = new Session(SessionId('single'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 1
// Replace only seq 1 (single node).
s.append('assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'x' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] },
) // seq 2
expect(s.surface.nodes).toEqual([0, 2])
@@ -343,38 +510,88 @@ describe('SurfaceManager', () => {
it('throws when replace start is not found', () => {
const s = new Session(SessionId('bad-start'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
expect(() => s.append('assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'y' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [0] },
)).toThrow(/surface replace: start seq 5 not found/)
})
it('throws when replace end is not found', () => {
const s = new Session(SessionId('bad-end'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
expect(() => s.append('assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'y' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 0, end: 99 }, sourceEventSeqs: [0] },
)).toThrow(/surface replace: end seq 99 not found/)
})
it('throws when start is after end', () => {
const s = new Session(SessionId('reversed'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 1
// start=1, end=0 would be reversed order.
expect(() => s.append('assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'y' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 1, end: 0 }, sourceEventSeqs: [1, 0] },
)).toThrow(/start seq 1.*after end seq 0/)
})
it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => {
const s = new Session(SessionId('immutable'))
s.append('user/message', { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const sources = [0]
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] }, { surfaceOp: 'append', sourceEventSeqs: sources })
s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'h' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append', sourceEventSeqs: sources })
// Mutate caller's array after append.
sources.push(1)
sources[0] = 99
@@ -384,12 +601,28 @@ describe('SurfaceManager', () => {
it('replace starting at non-head position preserves surrounding order', () => {
const s = new Session(SessionId('mid-replace'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
s.append('user/message', { content: [{ type: 'text', text: 'c' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 1
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'c' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 2
// Replace the middle node (seq 1) only, keeping seq 0 and seq 2.
s.append('assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'x' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] },
) // seq 3
expect(s.surface.nodes).toEqual([0, 3, 2])
@@ -397,9 +630,21 @@ describe('SurfaceManager', () => {
it('surfaceOp replace object is snapshot so caller mutation is isolated', () => {
const s = new Session(SessionId('immutable-op'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const op = { op: 'replace' as const, start: 0, end: 0 }
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: op, sourceEventSeqs: [0] })
s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 's' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: op, sourceEventSeqs: [0] })
// Mutate caller's object after append.
op.start = 99
const logged = s.events[1]! as SurfaceEvent
@@ -423,8 +668,20 @@ describe('deriveMessages with surface', () => {
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'i' } })
s.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'hi' }] }, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'hi' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Chunks and boundaries are NOT in the surface, so only 2 messages.
expect(s.deriveMessages()).toHaveLength(2)
@@ -432,8 +689,20 @@ describe('deriveMessages with surface', () => {
it('deriveMessages via surface respects replace (shadowed nodes are excluded)', () => {
const s = new Session(SessionId('compacted'))
s.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'compacted' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'compacted' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
// Only the compaction node is visible.
const messages = s.deriveMessages()
expect(messages).toHaveLength(1)
@@ -442,8 +711,16 @@ describe('deriveMessages with surface', () => {
it('injected-context and steering/message appear on surface', () => {
const s = new Session(SessionId('ctx'))
s.append('user/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' })
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' },
}), { surfaceOp: 'append' })
s.append('steering/message', {
turn: 1,
message: createUserMessage({
content: [{ type: 'text', text: 'focus' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })
const messages = s.deriveMessages()
expect(messages).toHaveLength(2)
expect(messages[0]!.content).toEqual([{ type: 'text', text: 'file changed' }])
@@ -457,7 +734,17 @@ describe('Session.append surface opts', () => {
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
const event = s.append('assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'h' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: 'append', sourceEventSeqs: [0, 1] },
)
expect(event.sourceEventSeqs).toEqual([0, 1])
@@ -474,7 +761,17 @@ describe('Session.append surface opts', () => {
const seed: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 3, data: { turn: 1, step: 1, content: [], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: 'append' },
{ type: 'assistant/message', seq: 2, time: 3, data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, surfaceOp: 'append' },
{ type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
]
@@ -492,7 +789,17 @@ describe('Session.append surface opts', () => {
it('surfaceOp primitives are not cloned (they are immutable)', () => {
const s = new Session(SessionId('prim'))
const event = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append' })
const event = s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
// The string 'append' is a primitive — identity-preserving is fine.
expect(event.surfaceOp).toBe('append')
})
@@ -503,7 +810,9 @@ describe('Session.append surface opts', () => {
// SurfaceEvent — it would otherwise be silently dropped from the surface.
const noMarker: SessionEvent = {
type: 'user/message', seq: 0, time: 1,
data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}),
}
expect(isSurfaceEvent(noMarker)).toBe(false)
// A non-surface type is rejected too (the type gate).
@@ -545,7 +854,9 @@ describe('surface type guards', () => {
type: 'user/message',
seq: 0,
time: 0,
data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}),
}
expect(isSurfaceEligibleType(markerless.type)).toBe(true)
expect(isSurfaceEvent(markerless)).toBe(false)
@@ -556,16 +867,20 @@ describe('SurfaceManager.replaceGeneration', () => {
it('folds the pending log delta on access and counts replaces', () => {
const s = new Session(SessionId('gen'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('user/message', { content: [{ type: 'text', text: 'two' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'one' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'two' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
// Read the generation FIRST — before nodes — so the getter itself folds
// the pending delta rather than piggybacking on a nodes read.
expect(s.surface.replaceGeneration).toBe(0)
const nodes = s.surface.nodes
s.append('user/message', {
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
}), { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
expect(s.surface.replaceGeneration).toBe(1)
})
})