fix: complete immutable message migration

This commit is contained in:
_Kerman
2026-07-28 14:15:23 +08:00
parent f5ec71f5b1
commit 350c296cff
27 changed files with 231 additions and 60 deletions

File diff suppressed because one or more lines are too long

View File

@@ -3056,6 +3056,8 @@ describe('dynamic nested workspace context injection', () => {
expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule')
expect(blocksText(workspaceContextOf(result)?.content)).not.toContain('downstream context')
expect(result.additionalContexts?.[1]).toEqual({
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'downstream context' }],
source: { kind: 'plugin', plugin: 'downstream' },
})

View File

@@ -338,8 +338,10 @@ describe('Agent.cancel()', () => {
const result = agent.session.events.find(event => event.type === 'tool/result')
expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1')
expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({
callId: 'c1',
isError: true,
message: {
source: { kind: 'tool', callId: 'c1' },
content: [{ type: 'tool-result', toolCallId: 'c1', isError: true }],
},
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})

View File

@@ -744,9 +744,24 @@ describe('agent loop', () => {
expect(steps).toBe(2)
expect(adapter.requests).toHaveLength(2)
expect(adapter.requests[1]!.messages).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'go' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'first half' }], provenance: { provider: 'mock', model: 'mock' } },
{ role: 'user', content: [{ type: 'text', text: 'continue after truncation' }] },
{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
},
{
id: expect.any(String) as unknown,
role: 'assistant',
content: [{ type: 'text', text: 'first half' }],
source: { kind: 'model', provider: 'mock', model: 'mock' },
},
{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'continue after truncation' }],
source: { kind: 'plugin', plugin: 'max-tokens-test' },
},
])
expect(reasons).toEqual([{ kind: 'max-tokens' }])
})
@@ -799,13 +814,26 @@ describe('agent loop', () => {
expect(executions).toBe(0)
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
expect(agent.session.deriveMessages()).toEqual([{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
}])
expect(reasons).toEqual([{ kind: 'max-tokens' }])
// Empty content still needs an assistant/message to carry usage; derivation
// skips that host so it does not create a spurious assistant turn.
const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message')
expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data).toEqual({
turn: 1, step: 1, content: [], provenance: { provider: 'mock', model: 'mock' }, usage: { inputTokens: 10, outputTokens: 5 },
turn: 1,
step: 1,
message: {
id: expect.any(String) as unknown,
role: 'assistant',
content: [],
source: { kind: 'model', provider: 'mock', model: 'mock' },
},
usage: { inputTokens: 10, outputTokens: 5 },
})
})
@@ -839,11 +867,20 @@ describe('agent loop', () => {
expect(assistant.type === 'assistant/message' && assistant.data).toEqual({
turn: 1,
step: 1,
content: [],
provenance: { provider: 'mock', model: 'mock' },
message: {
id: expect.any(String) as unknown,
role: 'assistant',
content: [],
source: { kind: 'model', provider: 'mock', model: 'mock' },
},
})
expect(assistant.sourceEventSeqs?.length).toBeGreaterThan(0)
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
expect(agent.session.deriveMessages()).toEqual([{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
}])
})
it('appends an empty completion anchor for a normal stop with no usage', async () => {
@@ -864,11 +901,20 @@ describe('agent loop', () => {
expect(assistant.type === 'assistant/message' && assistant.data).toEqual({
turn: 1,
step: 1,
content: [],
provenance: { provider: 'mock', model: 'mock' },
message: {
id: expect.any(String) as unknown,
role: 'assistant',
content: [],
source: { kind: 'model', provider: 'mock', model: 'mock' },
},
})
expect(assistant.sourceEventSeqs?.length).toBe(1)
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
expect(agent.session.deriveMessages()).toEqual([{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
}])
})
it('keeps safe max-tokens assistant content while dropping truncated tool calls', async () => {
@@ -889,8 +935,18 @@ describe('agent loop', () => {
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
expect(agent.session.deriveMessages()).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'go' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'partial text' }], provenance: { provider: 'mock', model: 'mock' } },
{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
},
{
id: expect.any(String) as unknown,
role: 'assistant',
content: [{ type: 'text', text: 'partial text' }],
source: { kind: 'model', provider: 'mock', model: 'mock' },
},
])
})

View File

@@ -95,7 +95,12 @@ describe('SessionStore.fork', () => {
expect(child.events).toEqual(source.events.slice(0, firstBoundary + 1))
expect(child.header.seedLength).toBe(firstBoundary + 1)
expect(child.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'first' }] }])
expect(child.deriveMessages()).toEqual([{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'first' }],
source: { kind: 'user' },
}])
})
it('accepts every turn/end reason as an explicit fork boundary', async () => {

View File

@@ -998,6 +998,8 @@ describe('the run_code dispatch bridge', () => {
expect(result.isError).toBe(true)
expect(result.additionalContexts).toEqual([{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'nested context' }],
source: { kind: 'plugin', plugin: 'test' },
}])

View File

@@ -385,7 +385,12 @@ describe('ToolRegistry', () => {
value: { text: 'policy value' },
content: [{ type: 'text', text: 'render:policy value' }],
meta: { projected: 'policy value' },
additionalContexts: [{ content: [{ type: 'text', text: 'value context' }], source: { kind: 'plugin', plugin: 'test' } }],
additionalContexts: [{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'value context' }],
source: { kind: 'plugin', plugin: 'test' },
}],
})
})
@@ -518,7 +523,12 @@ describe('ToolRegistry', () => {
error: { message: 'wrapped failure' },
content: [{ type: 'text', text: 'wrapper content' }],
meta: { wrapped: true },
additionalContexts: [{ content: [{ type: 'text', text: 'wrapper context' }], source: { kind: 'plugin', plugin: 'test' } }],
additionalContexts: [{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'wrapper context' }],
source: { kind: 'plugin', plugin: 'test' },
}],
})
})
@@ -1820,6 +1830,8 @@ describe('ToolRegistry', () => {
callId: CallId('around-context'), name: 'echo', arguments: {},
})
expect(result.additionalContexts).toEqual([{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'from around dispatch' }],
source: { kind: 'plugin', plugin: 'test' },
}])

View File

@@ -367,7 +367,12 @@ describe('dsh-agent-spine-demo bundle', () => {
handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
await waitForIdle(ctx, handle.agent)
expect(adapter.requests[0]?.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])
expect(adapter.requests[0]?.messages).toEqual([{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'user' },
}])
await handle.dispose()
await ctx.fiber.dispose()
} finally {

View File

@@ -126,7 +126,7 @@ describe('GoalService creation and replay', () => {
if (change === undefined) throw new Error('expected decoded goal change')
expect(change).toMatchObject({ operation: 'create', goal: { id: goal.id } })
expect(context.data.content).toEqual(renderGoalChange(change))
expect(session.deriveMessages()).toEqual([{ role: 'user', content: context.data.content }])
expect(session.deriveMessages()).toEqual([context.data])
expect(foldGoal(session.events)).toMatchObject({ goal: { id: goal.id }, roundsStarted: 0 })
vi.useRealTimers()
})

View File

@@ -265,8 +265,8 @@ describe('session/queued frames', () => {
const liveFrames = (await liveCollected).filter(f => f.type === 'session/queued')
expect(liveFrames).toEqual([
{ type: 'session/queued', sessionId: agent.id, content: queued.content, source: { kind: 'user' }, steering: false },
{ type: 'session/queued', sessionId: agent.id, content: steering.content, source: { kind: 'user' }, steering: true },
{ type: 'session/queued', sessionId: agent.id, message: queued, steering: false },
{ type: 'session/queued', sessionId: agent.id, message: steering, steering: true },
])
// A fresh mux connection replays the still-pending entries as its baseline.
@@ -308,6 +308,6 @@ describe('session/queued frames', () => {
api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 2, abort)
const remaining = frames.filter(f => f.type === 'session/queued')
expect(remaining).toHaveLength(1)
expect(remaining[0]).toMatchObject({ content: survivor.content })
expect(remaining[0]).toMatchObject({ message: survivor })
})
})

View File

@@ -16,7 +16,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -111,7 +111,12 @@ describe('mux live view computation', () => {
const events = frames.filter(f => f.type === 'session/event')
const byCall = new Map(events
.filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result')
.map(f => [`${f.event.type}:${(f.event.data as { callId: string }).callId}`, f]))
.map(f => [
`${f.event.type}:${f.event.type === 'tool/call'
? f.event.data.callId
: (f.event.data as SessionEvent<'tool/result'>['data']).message.source.callId}`,
f,
]))
expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
@@ -189,7 +194,12 @@ describe('mux live view computation', () => {
const entries = response.result.value.events
const byKey = new Map(entries
.filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result')
.map(entry => [`${entry.event.type}:${(entry.event.data as { callId: string }).callId}`, entry]))
.map(entry => [
`${entry.event.type}:${entry.event.type === 'tool/call'
? entry.event.data.callId
: (entry.event.data as SessionEvent<'tool/result'>['data']).message.source.callId}`,
entry,
]))
expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)

View File

@@ -319,8 +319,8 @@ describe('events frame schemas', () => {
{ type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
{ type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
{ type: 'session/queued', sessionId: 's', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' }, steering: false },
{ type: 'session/queued', sessionId: 's', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' }, steering: true },
{ type: 'session/queued', sessionId: 's', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, steering: false },
{ type: 'session/queued', sessionId: 's', message: { id: 'm2', role: 'user', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } }, steering: true },
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
]
for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
@@ -340,9 +340,9 @@ describe('events frame schemas', () => {
})
it('rejects a queued frame missing its members', () => {
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: 'x', source: { kind: 'user' } })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: [], source: { kind: 'user' } })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', content: [], source: {}, steering: false })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: 'x', steering: false })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } } })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: {} }, steering: false })).toThrow()
})
it('accepts every host frame branch', () => {

View File

@@ -364,7 +364,7 @@ describe('toPiContext', () => {
it.each([
['provider', { ...validReplay, provider: 'openai' }],
['model', { ...validReplay, model: 'deepseek-v4-pro' }],
])('rejects replay metadata whose %s differs from assistant provenance', (field, replayState) => {
])('rejects replay metadata whose %s differs from assistant source', (field, replayState) => {
try {
toPiContext({
provider: 'deepseek',
@@ -382,7 +382,7 @@ describe('toPiContext', () => {
} catch (error: unknown) {
expect(error).toBeInstanceOf(LlmError)
expect((error as LlmError).code).toBe('INVALID_REPLAY_STATE')
expect((error as Error).message).toContain(`${field} does not match assistant provenance`)
expect((error as Error).message).toContain(`${field} does not match assistant source`)
}
})

View File

@@ -216,9 +216,10 @@ describe('provider-routed retry policy', () => {
expect(agent.session.events.filter(item => item.type === 'step/start').map(item => item.data))
.toEqual([{ turn: 1, step: 1 }, { turn: 2, step: 1 }])
expect(agent.session.deriveMessages().at(-1)).toEqual({
id: expect.any(String) as unknown,
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
provenance: { provider: 'mock', model: 'mock' },
source: { kind: 'model', provider: 'mock', model: 'mock' },
})
})
@@ -296,7 +297,7 @@ describe('provider-routed retry policy', () => {
expect(agent.session.deriveMessages().at(-1)).toMatchObject({
role: 'assistant',
content: [{ type: 'text', text: 'recovered' }],
provenance: { provider: 'mock', model: 'mock' },
source: { kind: 'model', provider: 'mock', model: 'mock' },
})
})

View File

@@ -128,8 +128,8 @@ export function createAssistantMessage(
role: 'assistant',
content: input.content,
source: {
...input.source,
kind: 'model',
...input.source,
},
})
}

View File

@@ -527,6 +527,8 @@ describe('/plan', () => {
})
expect(ctx.planMode.get(messageAgent)).toEqual({ active: false, pending: true })
expect(messageSteer).toHaveBeenCalledExactlyOnceWith({
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'draft the migration' }],
source: { kind: 'user' },
})

View File

@@ -1,4 +1,4 @@
import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
import { MessageId, createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises'
@@ -1291,9 +1291,18 @@ describe('SessionPersistenceJsonl: edge cases', () => {
it('rejects non-JSON event data: BigInt, function, circular, Map, undefined property', async () => {
const m = meta('serial')
await ctx.sessionPersistence.create(m)
const bad = (extra: unknown) => [{ type: 'user/message', seq: 0, time: 1, data: createUserMessage({
content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra,
}) }] as unknown as SessionEvent[]
const bad = (extra: unknown) => [{
type: 'user/message',
seq: 0,
time: 1,
data: {
id: MessageId('invalid-json'),
role: 'user',
content: [{ type: 'text', text: 'x' }],
source: { kind: 'user' },
extra,
},
}] as unknown as SessionEvent[]
await expect(ctx.sessionPersistence.append(m.id, bad(1n))).rejects.toThrow(/non-JSON-serializable/)
await expect(ctx.sessionPersistence.append(m.id, bad(() => 0))).rejects.toThrow(/non-JSON-serializable/)
await expect(ctx.sessionPersistence.append(m.id, bad(Symbol('s')))).rejects.toThrow(/non-JSON-serializable/)

View File

@@ -11,7 +11,7 @@
import { describe, expect, it } from 'vitest'
import { SESSION_FORMAT_VERSION, Session, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, SurfaceEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
import { createUserMessage, CallId , createMessage } from '@deepseek-ai/dsh-llm'
import { CallId, MessageId, createMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import type { SessionPersistence } from '../src/index.ts'
/** A backend under test plus its teardown. */
@@ -34,13 +34,16 @@ export function meta(id: string, cwd?: string): SessionHeader {
export function oneTurnLog(): SessionEvent[] {
return [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'user/message', seq: 1, time: 2, data: createUserMessage({
{ type: 'user/message', seq: 1, time: 2, data: freezeMessage({
id: MessageId('one-turn-user'),
role: 'user',
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), surfaceOp: 'append' },
{ type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 3, time: 4, data: {
turn: 1, step: 1,
message: createMessage({
message: freezeMessage({
id: MessageId('one-turn-assistant'),
role: 'assistant',
content: [{ type: 'text', text: 'hello' }],
source: {
@@ -201,7 +204,11 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
])
const synthetic = loaded.events.find(e => e.type === 'tool/result')
expect(synthetic?.type === 'tool/result' && synthetic.data).toMatchObject({
callId: CallId('call-x'), isError: true, error: { code: TOOL_NOT_STARTED },
message: {
source: { kind: 'tool', callId: CallId('call-x') },
content: [{ type: 'tool-result', toolCallId: CallId('call-x'), isError: true }],
},
error: { code: TOOL_NOT_STARTED },
})
// The synthetic result carries the SAME callId as the orphaned tool-call,
// so deriveMessages() pairs them — no provider-invalid dangling call.
@@ -365,9 +372,18 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
const mi = meta(`s5-${i}`)
await persistence.create(mi)
const events = [
{ type: 'user/message', seq: 0, time: 1, data: createUserMessage({
content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: bad,
}) },
{
type: 'user/message',
seq: 0,
time: 1,
data: {
id: MessageId(`invalid-json-${i}`),
role: 'user',
content: [{ type: 'text', text: 'x' }],
source: { kind: 'user' },
extra: bad,
},
},
] as unknown as SessionEvent[]
await expect(persistence.append(mi.id, events)).rejects.toThrow(/losslessly JSON-serializable/)
}

View File

@@ -45,6 +45,7 @@ import {
materializeSessionResultFilters,
} from './filters.ts'
import * as tracing from './tracing.ts'
import { snapshotEvent } from './snapshot.ts'
export type * from './types.ts'
export { SessionSearchCursor } from './cursor.ts'
@@ -146,7 +147,7 @@ export abstract class SessionQueryService extends Service {
new Session(sessionId, loaded.events, loaded.header)
return {
session: structuredClone(loaded.header),
events: loaded.events.map(event => structuredClone(event)),
events: loaded.events.map(snapshotEvent),
}
}
@@ -330,10 +331,13 @@ export abstract class SessionQueryService extends Service {
}
const startSeq = Math.max(0, seq - before)
const endSeq = Math.min(loaded.events.length - 1, seq + after)
const targetSnapshot = snapshotEvent(target)
const events = loaded.events.slice(startSeq, endSeq + 1)
.map(event => event === target ? targetSnapshot : snapshotEvent(event))
return {
session: loaded.header,
target,
events: loaded.events.slice(startSeq, endSeq + 1),
session: structuredClone(loaded.header),
target: targetSnapshot,
events,
startSeq,
endSeq,
}

View File

@@ -0,0 +1,27 @@
/** Detached session-query snapshots that preserve message immutability. */
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
/**
* Clone one event while retaining the invariant that every identified message is frozen.
* @param event - source event from one corpus observation.
* @returns a detached event whose message value, if any, is deeply frozen.
*/
export function snapshotEvent<T extends SessionEvent>(event: T): T {
const snapshot = structuredClone(event)
switch (snapshot.type) {
case 'user/message':
deepFreeze(snapshot.data)
break
case 'assistant/message':
case 'tool/result':
case 'steering/message':
deepFreeze(snapshot.data.message)
break
default:
// SessionEventMap is merge-extensible; plugin-owned log-only events carry no core message.
break
}
return snapshot
}

View File

@@ -10,6 +10,7 @@ import type {
SessionLineageTrace,
SessionRecord,
} from './types.ts'
import { snapshotEvent } from './snapshot.ts'
interface EventLogAnalysis {
records: SessionEventRecord[]
@@ -51,7 +52,7 @@ export function currentSurfaceEvents(
'SESSION_QUERY_INVALID_SURFACE',
)
}
return structuredClone(event)
return snapshotEvent(event)
})
}

View File

@@ -150,7 +150,9 @@ describe('dsh-tool-skill', () => {
expect(prefix).toEqual([
{
id: expect.any(String) as unknown,
role: 'user',
source: { kind: 'plugin', plugin: 'dsh-tool-skill' },
content: [{
type: 'text',
text: [
@@ -167,7 +169,12 @@ describe('dsh-tool-skill', () => {
].join('\n'),
}],
},
{ role: 'user', content: [{ type: 'text', text: 'later contribution' }] },
{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'later contribution' }],
source: { kind: 'plugin', plugin: 'later-contribution' },
},
])
const rendered = JSON.stringify(prefix[0])
expect(rendered).not.toContain('whenToUse')

View File

@@ -230,7 +230,7 @@ describe('in-process structured output', () => {
const child = ctx.agents.get(run.id)!
const results = child.session.events.filter(e => e.type === 'tool/result')
expect(results.length).toBe(2)
expect((results[0]!.data as { isError?: boolean }).isError).toBe(true)
expect(results[0]!.data.message.content[0].isError).toBe(true)
await run.dispose()
})

View File

@@ -439,9 +439,12 @@ export function unknownToolCallIds(rawLog: string): string[] {
if (record.type !== 'tool/result') return []
const data = record.data
if (data === null || typeof data !== 'object') return []
const { source, error } = data as { source?: unknown; error?: unknown }
const { message, error } = data as { message?: unknown; error?: unknown }
if (error === null || typeof error !== 'object') return []
if ((error as { code?: unknown }).code !== 'UNKNOWN_TOOL') return []
const source = typeof message === 'object' && message !== null
? (message as { source?: unknown }).source
: undefined
const callId = typeof source === 'object' && source !== null
? (source as { callId?: unknown }).callId
: undefined

View File

@@ -432,8 +432,8 @@ describe('tool-schema snapshots', () => {
describe('unknownToolCallIds', () => {
it('returns structured UNKNOWN_TOOL call ids and ignores other results', () => {
const log = [
'{"type":"tool/result","data":{"callId":"missing","error":{"code":"UNKNOWN_TOOL"}}}',
'{"type":"tool/result","data":{"callId":"failed","error":{"code":"EXECUTION_FAILED"}}}',
'{"type":"tool/result","data":{"message":{"source":{"kind":"tool","callId":"missing"}},"error":{"code":"UNKNOWN_TOOL"}}}',
'{"type":"tool/result","data":{"message":{"source":{"kind":"tool","callId":"failed"}},"error":{"code":"EXECUTION_FAILED"}}}',
'{"type":"tool/result","data":null}',
'{"type":"tool/result","data":"invalid"}',
'{"type":"tool/result","data":{"error":null}}',
@@ -446,7 +446,7 @@ describe('unknownToolCallIds', () => {
})
it('returns no failures for ordinary tool results', () => {
expect(unknownToolCallIds('{"type":"tool/result","data":{"callId":"ok"}}\n')).toEqual([])
expect(unknownToolCallIds('{"type":"tool/result","data":{"message":{"source":{"kind":"tool","callId":"ok"}}}}\n')).toEqual([])
})
})

View File

@@ -457,6 +457,8 @@ describe('completion notices', () => {
await tick()
expect(inject).toHaveBeenCalledTimes(1)
expect(inject).toHaveBeenCalledWith({
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'background task bash-1 (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with task_output.' }],
source: { kind: 'plugin', plugin: 'tool-tasks' },
})
@@ -479,6 +481,8 @@ describe('completion notices', () => {
expect(inject).toHaveBeenNthCalledWith(
1,
{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'background task subagent-1\n[notice truncated]\nDone; task_output.' }],
source: { kind: 'plugin', plugin: 'tool-tasks' },
},

View File

@@ -2365,7 +2365,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'source background' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'source background' }],
source: { kind: 'user' },
}),
surfaceOp: 'append',
},
{