mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
import type { Context } from '@deepseek-ai/cordis'
|
|
import { describe, expect, it } from 'vitest'
|
|
import type {
|
|
ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition,
|
|
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
import { registerTrajectoryAssistantDefinition } from '../src/client/trajectory-assistant-definition.ts'
|
|
import { registerTrajectoryCompactionDefinitions } from '../src/client/trajectory-compaction-definition.ts'
|
|
import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
|
|
import { registerTrajectoryMessageDefinitions } from '../src/client/trajectory-message-definitions.ts'
|
|
import { registerTrajectoryRequestHeaderDefinition } from '../src/client/trajectory-request-header-definition.ts'
|
|
import { trajectoryViewDefinition } from '../src/client/trajectory-snapshot-builder.ts'
|
|
import { registerTrajectoryToolDefinition } from '../src/client/trajectory-tool-definition.ts'
|
|
|
|
const DEFINITIONS: ConversationNodeDefinition[] = []
|
|
const registrationContext = {
|
|
uiConversation: {
|
|
events: {
|
|
register: (definition: ConversationNodeDefinition) => {
|
|
DEFINITIONS.push(definition)
|
|
return () => {}
|
|
},
|
|
},
|
|
},
|
|
} as unknown as Context
|
|
|
|
registerTrajectoryMessageDefinitions(registrationContext)
|
|
registerTrajectoryRequestHeaderDefinition(registrationContext)
|
|
registerTrajectoryAssistantDefinition(registrationContext)
|
|
registerTrajectoryToolDefinition(registrationContext)
|
|
registerTrajectoryCompactionDefinitions(registrationContext)
|
|
|
|
class TestEventDefinitions {
|
|
entries(): readonly ConversationNodeDefinition[] {
|
|
return DEFINITIONS
|
|
}
|
|
|
|
fallbackEntry(): undefined {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
class TestViewDefinitions {
|
|
entries(): readonly ConversationViewDefinition[] {
|
|
return [trajectoryViewDefinition]
|
|
}
|
|
}
|
|
|
|
function at(
|
|
seq: number,
|
|
type: string,
|
|
data: unknown,
|
|
extra: Record<string, unknown> = {},
|
|
): ConversationEventInput {
|
|
return {
|
|
event: {
|
|
seq,
|
|
time: 1_700_000_000_000 + seq,
|
|
type,
|
|
data,
|
|
...extra,
|
|
} as unknown as ConversationEventInput['event'],
|
|
}
|
|
}
|
|
|
|
function assembler(events: readonly ConversationEventInput[]): ConversationNodeAssembler {
|
|
const value = new ConversationNodeAssembler(
|
|
new TestEventDefinitions(),
|
|
new TestViewDefinitions(),
|
|
)
|
|
value.replaceWindow(events, false)
|
|
value.flush()
|
|
return value
|
|
}
|
|
|
|
function snapshot(value: ConversationNodeAssembler): TrajectorySnapshot {
|
|
const current = value.get('trajectory')
|
|
if (current === undefined) throw new Error('trajectory view was not registered')
|
|
return current
|
|
}
|
|
|
|
function assistantMessage(id: string, text: string) {
|
|
return {
|
|
id,
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text }],
|
|
source: { kind: 'model', provider: 'test', model: 'test' },
|
|
}
|
|
}
|
|
|
|
describe('Trajectory conversation Definitions', () => {
|
|
it('assembles streaming usage, preserves retry facts, and materializes interruption', () => {
|
|
const value = assembler([
|
|
at(1, 'turn/start', { turn: 1 }),
|
|
at(2, 'step/start', { turn: 1, step: 1 }),
|
|
at(3, 'assistant/chunk', {
|
|
turn: 1,
|
|
step: 1,
|
|
chunk: { type: 'text-delta', index: 0, text: 'first attempt' },
|
|
}),
|
|
at(4, 'assistant/chunk', {
|
|
turn: 1,
|
|
step: 1,
|
|
chunk: { type: 'usage', usage: { inputTokens: 10, outputTokens: 3 } },
|
|
}),
|
|
])
|
|
|
|
expect(snapshot(value).partial?.blocks).toEqual([{ kind: 'text', text: 'first attempt' }])
|
|
expect(snapshot(value).requests).toMatchObject([{
|
|
purpose: 'assistant',
|
|
status: 'running',
|
|
usage: { inputTokens: 10, outputTokens: 3 },
|
|
}])
|
|
|
|
value.append(at(5, 'llm/retry', {
|
|
retryId: 'retry-1',
|
|
turn: 1,
|
|
step: 1,
|
|
provider: 'test',
|
|
mode: 'normal',
|
|
policyKey: 'test-normal',
|
|
retry: 1,
|
|
maxRetries: 2,
|
|
delayMs: 25,
|
|
failure: { code: 'TRANSPORT', message: 'temporary failure' },
|
|
}))
|
|
value.append(at(6, 'assistant/chunk', {
|
|
turn: 1,
|
|
step: 1,
|
|
chunk: { type: 'text-delta', index: 0, text: 'second attempt' },
|
|
}))
|
|
value.append(at(7, 'step/end', { turn: 1, step: 1 }))
|
|
value.flush()
|
|
|
|
const settled = snapshot(value)
|
|
expect(settled.partial).toBeNull()
|
|
expect(settled.eventNodes).toMatchObject([{
|
|
kind: 'assistant',
|
|
seq: 6.1,
|
|
interrupted: true,
|
|
blocks: [{ kind: 'text', text: 'second attempt' }],
|
|
}])
|
|
expect(settled.requests).toMatchObject([{
|
|
purpose: 'assistant',
|
|
status: 'error',
|
|
error: 'temporary failure',
|
|
errorCode: 'TRANSPORT',
|
|
retry: 1,
|
|
maxRetries: 2,
|
|
retryDelayMs: 25,
|
|
usage: { inputTokens: 10, outputTokens: 3 },
|
|
}])
|
|
})
|
|
|
|
it('classifies a cancellation-finalized prefix as an interrupted request result', () => {
|
|
const current = snapshot(assembler([
|
|
at(1, 'turn/start', { turn: 1 }),
|
|
at(2, 'step/start', { turn: 1, step: 1 }),
|
|
at(3, 'assistant/message', {
|
|
turn: 1,
|
|
step: 1,
|
|
message: assistantMessage('interrupted-message', 'cut short'),
|
|
interrupted: true,
|
|
}),
|
|
at(4, 'step/end', { turn: 1, step: 1 }),
|
|
at(5, 'turn/end', {
|
|
turn: 1,
|
|
reason: { kind: 'aborted', reason: { kind: 'user' } },
|
|
}),
|
|
]))
|
|
|
|
expect(current.eventNodes).toMatchObject([{
|
|
kind: 'assistant',
|
|
seq: 3,
|
|
messageId: 'interrupted-message',
|
|
interrupted: true,
|
|
blocks: [{ kind: 'text', text: 'cut short' }],
|
|
}])
|
|
expect(current.requests).toMatchObject([{
|
|
purpose: 'assistant',
|
|
resultSeq: 3,
|
|
status: 'error',
|
|
provenance: { provider: 'test', model: 'test' },
|
|
}])
|
|
})
|
|
|
|
it('keeps parallel interrupted roots and nests Code Dispatch results', () => {
|
|
const current = snapshot(assembler([
|
|
at(1, 'turn/start', { turn: 1 }),
|
|
at(2, 'step/start', { turn: 1, step: 1 }),
|
|
at(3, 'tool/call', {
|
|
turn: 1, step: 1, callId: 'root-a', name: 'code', arguments: '{}',
|
|
}),
|
|
at(4, 'tool/call', {
|
|
turn: 1, step: 1, callId: 'root-b', name: 'parallel', arguments: '{}',
|
|
}),
|
|
at(5, 'tool/code-dispatch-start', {
|
|
rootCallId: 'root-a',
|
|
parentCallId: 'root-a',
|
|
subCallId: 'child',
|
|
name: 'read',
|
|
arguments: { path: 'README.md' },
|
|
}),
|
|
at(6, 'tool/code-dispatch', {
|
|
rootCallId: 'root-a',
|
|
parentCallId: 'root-a',
|
|
subCallId: 'child',
|
|
name: 'read',
|
|
arguments: { path: 'README.md' },
|
|
content: [{ type: 'text', text: 'contents' }],
|
|
}),
|
|
at(7, 'step/end', { turn: 1, step: 1 }),
|
|
]))
|
|
|
|
const tools = current.eventNodes.filter(node => node.kind === 'tool-result')
|
|
expect(tools.map(node => node.callId).sort()).toEqual(['root-a', 'root-b'])
|
|
expect(tools.find(node => node.callId === 'root-a')?.subCalls).toMatchObject([{
|
|
kind: 'tool-result',
|
|
callId: 'child',
|
|
call: { name: 'read' },
|
|
}])
|
|
})
|
|
|
|
it('assembles compaction lifecycle, checkpoint replacement, and orphan interruption', () => {
|
|
const current = snapshot(assembler([
|
|
at(1, 'compaction/start', { compactionId: 'complete', turn: null }),
|
|
at(2, 'compaction/summary', {
|
|
compactionId: 'complete',
|
|
turn: null,
|
|
summary: 'summary',
|
|
provider: 'test',
|
|
model: 'test',
|
|
maxTokens: 100,
|
|
usage: { inputTokens: 20, outputTokens: 5 },
|
|
}),
|
|
at(3, 'user/message', {
|
|
id: 'checkpoint',
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'summary checkpoint' }],
|
|
source: { kind: 'plugin', plugin: 'compact', compactionId: 'complete' },
|
|
}),
|
|
at(4, 'compaction/end', { compactionId: 'complete', turn: null }),
|
|
at(5, 'compaction/start', { compactionId: 'orphan', turn: null }),
|
|
at(6, 'session/end-seed', {}),
|
|
]))
|
|
|
|
expect(current.requests).toMatchObject([
|
|
{
|
|
purpose: 'compaction',
|
|
startSeq: 1,
|
|
status: 'complete',
|
|
resultSeq: 2,
|
|
replacementSeq: 3,
|
|
summary: 'summary',
|
|
},
|
|
{
|
|
purpose: 'compaction',
|
|
startSeq: 5,
|
|
status: 'error',
|
|
completedAt: 1_700_000_000_006,
|
|
},
|
|
])
|
|
})
|
|
|
|
it('classifies claimed inbox input as steering and consumes one inherited prompt change', () => {
|
|
const value = assembler([
|
|
at(1, 'turn/start', { turn: 1 }),
|
|
at(2, 'request/header', {
|
|
reason: 'initial',
|
|
header: {
|
|
config: { provider: 'test', model: 'test' },
|
|
system: 'system prompt',
|
|
tools: [],
|
|
},
|
|
}),
|
|
at(3, 'step/start', { turn: 1, step: 1 }),
|
|
at(4, 'assistant/message', {
|
|
turn: 1,
|
|
step: 1,
|
|
message: assistantMessage('assistant-1', 'first'),
|
|
}),
|
|
at(5, 'step/end', { turn: 1, step: 1 }),
|
|
at(6, 'agent/inbox/spliced', {
|
|
target: 'next-step', start: 0, removedCount: 0, inserted: [{ id: 'm1' }],
|
|
}),
|
|
at(7, 'agent/inbox/spliced', {
|
|
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
|
}),
|
|
at(8, 'step/start', { turn: 1, step: 2 }),
|
|
])
|
|
value.append(at(9, 'user/message', {
|
|
id: 'm1',
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'steer here' }],
|
|
source: { kind: 'user' },
|
|
}))
|
|
value.flush()
|
|
|
|
const steering = snapshot(value)
|
|
expect(steering.eventNodes.find(node => node.seq === 9)?.kind).toBe('steering')
|
|
expect(steering.eventLocations.get(9)).toMatchObject({
|
|
kind: 'step',
|
|
turn: { turn: 1 },
|
|
step: { step: 2 },
|
|
})
|
|
|
|
value.append(at(10, 'assistant/message', {
|
|
turn: 1,
|
|
step: 2,
|
|
message: assistantMessage('assistant-2', 'second'),
|
|
}))
|
|
value.flush()
|
|
const current = snapshot(value)
|
|
|
|
expect(current.requests.map(request => request.purpose === 'assistant'
|
|
? request.prompt?.system
|
|
: undefined)).toEqual(['system prompt', 'system prompt'])
|
|
expect(current.requests.map(request => request.purpose === 'assistant'
|
|
? request.promptChange?.kind
|
|
: undefined)).toEqual(['initial', undefined])
|
|
})
|
|
})
|