refactor(client): name the compile face in every client test filename
A test file under packages/client now says which face it covers:
`*.client.spec.{ts,tsx}` and its `*.client.{ts,tsx}` helpers belong to the
Client aggregate, `*.host.spec.ts` to the host aggregate. The carrier's four
node-half specs take the Host suffix.
The two suffixes are mutually exclusive, so each aggregate excludes the
other's and both keep one broad test glob: `exclude` wins over `include`, and
`packages/client/**` no longer has to be excluded wholesale from the host
program with per-file `files` entries carved back out of it. A Host-face spec
that reaches only Host source therefore needs no cross-face project
reference, which the split-project rule rejects.
vitest still discovers every file through `**/*.spec.{ts,tsx}`.
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/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 = {
|
||||
conversationEvents: {
|
||||
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'],
|
||||
view: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
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.snapshot('trajectory') as TrajectorySnapshot | undefined
|
||||
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',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
retryDelayMs: 25,
|
||||
usage: { inputTokens: 10, outputTokens: 3 },
|
||||
}])
|
||||
})
|
||||
|
||||
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, 'compact/start', { compactionId: 'complete', turn: null }),
|
||||
at(2, 'compact/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, 'compact/end', { compactionId: 'complete', turn: null }),
|
||||
at(5, 'compact/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])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user