| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350 |
- 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 roots, raw Tool facts, and nested 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, 'tool/result', {
- turn: 1,
- step: 1,
- message: {
- id: 'result-root-a',
- role: 'user',
- source: { kind: 'tool', callId: 'root-a' },
- content: [{
- type: 'tool-result',
- toolCallId: 'root-a',
- content: [{ type: 'text', text: 'root failed' }],
- isError: true,
- }],
- },
- error: { name: 'ToolError', code: 'failed' },
- meta: { presentation: 'raw' },
- }, { surfaceOp: 'append' }),
- at(8, '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')).toMatchObject({
- kind: 'tool-result',
- callId: 'root-a',
- call: { name: 'code', argsRaw: '{}' },
- content: [{ type: 'text', text: 'root failed' }],
- isError: true,
- error: { name: 'ToolError', code: 'failed' },
- meta: { presentation: 'raw' },
- subCalls: [{
- kind: 'tool-result', callId: 'child', parentCallId: 'root-a', call: { name: 'read' },
- }],
- })
- expect(tools.find(node => node.callId === 'root-b')).toMatchObject({
- isError: true,
- error: { name: 'Interrupted', code: 'interrupted' },
- })
- })
- 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])
- })
- })
|