mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix: complete immutable message migration
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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' },
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' },
|
||||
}])
|
||||
|
||||
@@ -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' },
|
||||
}])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -128,8 +128,8 @@ export function createAssistantMessage(
|
||||
role: 'assistant',
|
||||
content: input.content,
|
||||
source: {
|
||||
...input.source,
|
||||
kind: 'model',
|
||||
...input.source,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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/)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
27
packages/session-query/session-query/src/snapshot.ts
Normal file
27
packages/session-query/session-query/src/snapshot.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user