refactor: identify and freeze messages at creation
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
README.md: 95b67fc5977a73d0b7fbf8d37d27eccfcd981338
|
||||
README.zh.md: 47c97256fb14a21adef2a10589d0d7fa22ab2646
|
||||
# pnpm run verify-translation-pairing --write packages/core/session/README.md
|
||||
README.md: 39e239181bda751aca6bc2316474dc960f652b35
|
||||
README.zh.md: 6d62d56d499f5cd3ad5d6450b6efff83a9988b51
|
||||
|
||||
@@ -39,7 +39,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.
|
||||
@@ -66,9 +66,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`)
|
||||
|
||||
@@ -101,7 +101,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
|
||||
|
||||
|
||||
@@ -39,7 +39,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` 是按追加失效的缓存冻结快照;已接受事件保持深度冻结。
|
||||
@@ -66,9 +66,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`)
|
||||
|
||||
@@ -101,7 +101,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 影响
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -165,7 +166,7 @@ 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 pre-unification message shapes 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
|
||||
@@ -180,8 +181,14 @@ 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
|
||||
const message = type === 'user/message' ? record : record['message']
|
||||
if (typeof message !== 'object' || message === null
|
||||
|| typeof (message as Record<string, unknown>)['id'] !== 'string'
|
||||
|| (message as Record<string, unknown>)['id'] === '') {
|
||||
throw new Error(`seed ${type} at index ${index} lacks an identified message`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,7 +525,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]
|
||||
@@ -531,10 +538,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.
|
||||
*/
|
||||
@@ -546,30 +552,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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -224,8 +224,8 @@ function assertToolResultRewrite(
|
||||
}
|
||||
const originalRest = { ...original.data } as Record<string, unknown>
|
||||
const replacementRest = { ...event.data } as Record<string, unknown>
|
||||
delete originalRest['content']
|
||||
delete replacementRest['content']
|
||||
originalRest['message'] = { ...original.data.message, content: null }
|
||||
replacementRest['message'] = { ...event.data.message, content: null }
|
||||
if (!isDeepEqualJson(originalRest, replacementRest)) {
|
||||
throw new Error('tool/result surface replacement may change only content')
|
||||
}
|
||||
|
||||
@@ -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[] }
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -17,19 +17,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'> {
|
||||
@@ -189,23 +189,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)
|
||||
|
||||
@@ -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/)
|
||||
// Merge-extensible session events use the same default enclosure branch.
|
||||
const appendUnknown = outside.append.bind(outside) as (type: string, data: unknown) => unknown
|
||||
@@ -145,10 +162,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()
|
||||
@@ -174,9 +197,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/)
|
||||
})
|
||||
|
||||
@@ -187,9 +212,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/)
|
||||
})
|
||||
|
||||
@@ -208,9 +235,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' } })
|
||||
@@ -218,7 +247,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],
|
||||
@@ -240,16 +275,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],
|
||||
@@ -264,9 +307,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 })
|
||||
@@ -294,9 +339,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/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' } })
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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,
|
||||
@@ -22,16 +22,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 +77,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 +88,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 +134,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 +153,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 +169,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 +204,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,
|
||||
@@ -223,10 +251,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 +316,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 +354,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 +365,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 +420,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 +440,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 +489,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 +525,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 +551,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 +572,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 +613,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 +631,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 +643,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 +659,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 +878,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 +896,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 +1116,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 +1145,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 +1234,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 +1248,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 +1267,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 +1476,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…
|
||||
|
||||
@@ -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: [],
|
||||
@@ -145,7 +174,15 @@ describe('foldSurface tool-result rewrites', () => {
|
||||
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 +215,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 +259,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 +295,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 +306,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 +326,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 +340,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 +383,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 +413,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 +431,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 +460,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 +486,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 +577,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 +606,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 +644,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 +665,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 +687,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 +710,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 +737,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 +765,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 +786,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 +830,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 +843,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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user