refactor: identify and freeze messages at creation

This commit is contained in:
_Kerman
2026-07-28 13:55:59 +08:00
parent c49c0ba497
commit fbf87e660c
345 changed files with 5220 additions and 2901 deletions

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import LlmService, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type { Config } from '@deepseek-ai/dsh-llm-deepseek'
@@ -29,7 +29,10 @@ afterEach(async () => {
})
function ask(text: string): Message[] {
return [{ role: 'user', content: [{ type: 'text', text }] }]
return [createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'test' },
})]
}
function textOf(result: AssembledResult): string {
@@ -101,15 +104,18 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
reasoningEffort: ReasoningEffortId(effort),
messages: [
...ask('What is the weather in Paris right now? Use the get_weather tool.'),
{ role: 'assistant', content: first.message.content },
{
role: 'user',
createMessage({
role: 'assistant', content: first.message.content,
source: { kind: 'plugin', plugin: 'test' },
}),
createUserMessage({
content: [{
type: 'tool-result',
toolCallId: CallId(call!.id),
content: [{ type: 'text', text: 'Sunny, 22°C' }],
}],
},
source: { kind: 'plugin', plugin: 'test' },
}),
],
tools: [weatherTool],
maxTokens: 2000,

View File

@@ -2,7 +2,7 @@ import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, {
import LlmService, { createUserMessage,
CONTEXT_WINDOW_EXCEEDED_CODE,
errorChain,
LlmError,
@@ -112,7 +112,10 @@ describe('DeepSeekAdapter against a mock server', () => {
const result = await assemble(ctx, {
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
messages: [createUserMessage({
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'plugin', plugin: 'test' },
})],
})
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
expect(result.finish).toEqual({ kind: 'stop' })
@@ -142,7 +145,10 @@ describe('DeepSeekAdapter against a mock server', () => {
for await (const chunk of ctx.llm.stream({
provider: 'deepseek',
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
messages: [createUserMessage({
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'plugin', plugin: 'test' },
})],
})) {
kinds.push(chunk.type)
}
@@ -155,7 +161,10 @@ describe('DeepSeekAdapter against a mock server', () => {
await assemble(ctx, {
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
messages: [createUserMessage({
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'plugin', plugin: 'test' },
})],
sessionId: SessionId('child-session'),
})
@@ -168,7 +177,10 @@ describe('DeepSeekAdapter against a mock server', () => {
await assemble(ctx, {
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
messages: [createUserMessage({
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'plugin', plugin: 'test' },
})],
purpose: 'compaction',
})
@@ -185,17 +197,26 @@ describe('DeepSeekAdapter against a mock server', () => {
await assemble(ctx,{
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
messages: [createUserMessage({
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'plugin', plugin: 'test' },
})],
})
await assemble(ctx,{
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('off'),
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi again' }] }],
messages: [createUserMessage({
content: [{ type: 'text', text: 'hi again' }],
source: { kind: 'plugin', plugin: 'test' },
})],
})
await assemble(ctx,{
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('max'),
messages: [{ role: 'user', content: [{ type: 'text', text: 'one more time' }] }],
messages: [createUserMessage({
content: [{ type: 'text', text: 'one more time' }],
source: { kind: 'plugin', plugin: 'test' },
})],
})
expect(server.requests[0]).toMatchObject({
thinking: { type: 'enabled' },
@@ -217,7 +238,10 @@ describe('DeepSeekAdapter against a mock server', () => {
await assemble(ctx,{
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
messages: [createUserMessage({
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'plugin', plugin: 'test' },
})],
})
expect(server.requests[0]).toMatchObject({
thinking: { type: 'disabled' },
@@ -239,7 +263,10 @@ describe('DeepSeekAdapter against a mock server', () => {
await expect(assemble(ctx, {
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('high'),
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
messages: [createUserMessage({
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'plugin', plugin: 'test' },
})],
})).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
expect(server.requests).toHaveLength(0)
})
@@ -258,7 +285,10 @@ describe('DeepSeekAdapter against a mock server', () => {
provider: 'deepseek',
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId(effort),
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
messages: [createUserMessage({
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'plugin', plugin: 'test' },
})],
})
await expect(async () => {
for await (const _chunk of stream) { /* drain */ }

View File

@@ -20,14 +20,12 @@ export async function assemble(ctx: Context, options: Omit<GenerateOptions, 'pro
const request = { provider: 'deepseek', ...options }
for await (const chunk of ctx.llm.stream(request)) assembler.push(chunk)
return {
message: {
...assembler.message(),
provenance: {
provider: request.provider,
model: request.model,
...assembler.replayState === undefined ? {} : { replayState: assembler.replayState },
},
},
message: assembler.message({
kind: 'model',
provider: request.provider,
model: request.model,
...assembler.replayState === undefined ? {} : { replayState: assembler.replayState },
}),
...assembler.usage !== undefined ? { usage: assembler.usage } : {},
finish: assembler.finish,
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { serializeMessages, serializeRequest } from '../src/serialize.ts'
@@ -10,27 +10,34 @@ function request(overrides: Partial<GenerateOptions> = {}): GenerateOptions {
describe('serializeMessages', () => {
it('maps user text to string content', () => {
const wire = serializeMessages([
{ role: 'user', content: [{ type: 'text', text: 'hello ' }, { type: 'text', text: 'world' }] },
createUserMessage({
content: [{ type: 'text', text: 'hello ' }, { type: 'text', text: 'world' }],
source: { kind: 'plugin', plugin: 'test' },
}),
])
expect(wire).toEqual([{ role: 'user', content: 'hello world' }])
})
it('maps system-role messages in history', () => {
const wire = serializeMessages([
{ role: 'system', content: [{ type: 'text', text: 'be brief' }] },
createMessage({
role: 'system', content: [{ type: 'text', text: 'be brief' }],
source: { kind: 'plugin', plugin: 'test' },
}),
])
expect(wire).toEqual([{ role: 'system', content: 'be brief' }])
})
it('maps plain assistant text without reasoning_content', () => {
const wire = serializeMessages([
{
createMessage({
role: 'assistant',
content: [
{ type: 'reasoning', text: 'thinking…' },
{ type: 'text', text: 'answer' },
],
},
source: { kind: 'plugin', plugin: 'test' },
}),
])
// Tool-call-free turn: reasoning is dropped (ignored by the API anyway).
expect(wire).toEqual([{ role: 'assistant', content: 'answer' }])
@@ -38,13 +45,14 @@ describe('serializeMessages', () => {
it('passes reasoning_content back on tool-call turns (official passback rule)', () => {
const wire = serializeMessages([
{
createMessage({
role: 'assistant',
content: [
{ type: 'reasoning', text: 'I should check the weather.' },
{ type: 'tool-call', id: CallId('call-1'), name: 'get_weather', arguments: '{"city":"Paris"}' },
],
},
source: { kind: 'plugin', plugin: 'test' },
}),
])
expect(wire).toEqual([{
role: 'assistant',
@@ -58,13 +66,14 @@ describe('serializeMessages', () => {
it('serializes parallel tool calls in order', () => {
const wire = serializeMessages([
{
createMessage({
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('a'), name: 'one', arguments: '{}' },
{ type: 'tool-call', id: CallId('b'), name: 'two', arguments: '{}' },
],
},
source: { kind: 'plugin', plugin: 'test' },
}),
])
const assistant = wire[0] as { tool_calls: { id: string }[] }
expect(assistant.tool_calls.map(call => call.id)).toEqual(['a', 'b'])
@@ -72,37 +81,37 @@ describe('serializeMessages', () => {
it('turns tool results into role:tool messages', () => {
const wire = serializeMessages([
{
role: 'user',
createUserMessage({
content: [{
type: 'tool-result',
toolCallId: CallId('call-1'),
content: [{ type: 'text', text: 'Sunny 22C' }],
}],
},
source: { kind: 'plugin', plugin: 'test' },
}),
])
expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: 'Sunny 22C' }])
})
it('sends a sentinel for empty tool-result content', () => {
const wire = serializeMessages([
{
role: 'user',
createUserMessage({
content: [{ type: 'tool-result', toolCallId: CallId('call-1'), content: [] }],
},
source: { kind: 'plugin', plugin: 'test' },
}),
])
expect(wire).toEqual([{ role: 'tool', tool_call_id: 'call-1', content: '(no output)' }])
})
it('splits mixed user text + tool results into separate wire messages', () => {
const wire = serializeMessages([
{
role: 'user',
createUserMessage({
content: [
{ type: 'text', text: 'context note' },
{ type: 'tool-result', toolCallId: CallId('call-1'), content: [{ type: 'text', text: 'ok' }] },
],
},
source: { kind: 'plugin', plugin: 'test' },
}),
])
expect(wire).toEqual([
{ role: 'user', content: 'context note' },
@@ -112,25 +121,31 @@ describe('serializeMessages', () => {
it('skips plugin-added block types (merge-extensible ContentBlockMap)', () => {
const wire = serializeMessages([
{
role: 'user',
createUserMessage({
content: [
{ type: 'chart', data: 'x' } as unknown as ContentBlock,
{ type: 'text', text: 'see chart' },
],
},
source: { kind: 'plugin', plugin: 'test' },
}),
])
expect(wire).toEqual([{ role: 'user', content: 'see chart' }])
})
it('emits an empty user message rather than dropping block-less messages', () => {
const wire = serializeMessages([{ role: 'user', content: [] }])
const wire = serializeMessages([createUserMessage({
content: [],
source: { kind: 'plugin', plugin: 'test' },
})])
expect(wire).toEqual([{ role: 'user', content: '' }])
})
})
describe('serializeRequest', () => {
const history: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]
const history: Message[] = [createUserMessage({
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'plugin', plugin: 'test' },
})]
it('always streams with usage and maps the basics', () => {
const wire = serializeRequest(request({ messages: history }))
@@ -246,7 +261,10 @@ describe('review fixes: assistant content shapes', () => {
// Aborted/empty assistant turns: no text, no calls → "". The earlier
// null shape was live-falsified: the API 400s a null-content assistant
// message without tool_calls ("content or tool_calls must be set").
const wire = serializeMessages([{ role: 'assistant', content: [] }])
const wire = serializeMessages([createMessage({
role: 'assistant', content: [],
source: { kind: 'plugin', plugin: 'test' },
})])
expect(wire).toEqual([{ role: 'assistant', content: '' }])
})
@@ -255,15 +273,19 @@ describe('review fixes: assistant content shapes', () => {
// greeting did, live). The passback rule keeps reasoning_content off
// plain turns, and content must still be SET — a null here poisoned the
// session log and bricked every later turn of that session.
const wire = serializeMessages([{ role: 'assistant', content: [{ type: 'reasoning', text: '你好!有什么我可以帮你的吗?' }] }])
const wire = serializeMessages([createMessage({
role: 'assistant', content: [{ type: 'reasoning', text: '你好!有什么我可以帮你的吗?' }],
source: { kind: 'plugin', plugin: 'test' },
})])
expect(wire).toEqual([{ role: 'assistant', content: '' }])
})
it('serializes tool-call turns with empty string content, not null', () => {
const wire = serializeMessages([{
const wire = serializeMessages([createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('c'), name: 'f', arguments: '{}' }],
}])
source: { kind: 'plugin', plugin: 'test' },
})])
expect(wire[0]).toMatchObject({ content: '' })
})
})

View File

@@ -123,6 +123,7 @@ function readReplayState(value: unknown): PiAiReplayState {
/** Convert provider-neutral blocks without trusting them as same-model replay. */
function foreignAssistant(message: Message): AssistantMessage {
const source = message.source.kind === 'model' ? message.source : undefined
const content: AssistantMessage['content'] = []
for (const block of message.content) {
switch (block.type) {
@@ -143,10 +144,10 @@ function foreignAssistant(message: Message): AssistantMessage {
role: 'assistant',
content,
// Deliberately never equals a catalog API: absent replay state is foreign
// even if provenance names the same provider/model as this request.
// even if source names the same provider/model as this request.
api: 'dsh-foreign',
provider: message.provenance?.provider ?? 'dsh-foreign',
model: message.provenance?.model ?? 'dsh-foreign',
provider: source?.provider ?? 'dsh-foreign',
model: source?.model ?? 'dsh-foreign',
usage: emptyPiUsage(),
stopReason: content.some(piece => piece.type === 'toolCall') ? 'toolUse' : 'stop',
timestamp: 0,
@@ -156,9 +157,10 @@ function foreignAssistant(message: Message): AssistantMessage {
/** Recombine durable Harness content with validated pi-ai replay metadata. */
function replayedAssistant(message: Message, rawState: unknown): AssistantMessage {
const state = readReplayState(rawState)
const provenance = message.provenance
if (state.provider !== provenance?.provider) return invalidReplay('provider does not match assistant provenance')
if (state.model !== provenance.model) return invalidReplay('model does not match assistant provenance')
const source = message.source
if (source.kind !== 'model') return invalidReplay('assistant message lacks model source')
if (state.provider !== source.provider) return invalidReplay('provider does not match assistant source')
if (state.model !== source.model) return invalidReplay('model does not match assistant source')
if (state.blocks.length !== message.content.length) return invalidReplay('block count does not match assistant content')
const content: AssistantMessage['content'] = message.content.map((block, index) => {
const replay = state.blocks[index]
@@ -202,10 +204,10 @@ function replayedAssistant(message: Message, rawState: unknown): AssistantMessag
/**
* Convert one durable Harness assistant message into pi-ai history.
* @param message - assistant content with optional adapter-owned replay metadata.
* @param message - assistant content with required source and optional adapter-owned replay metadata.
* @returns a native pi-ai assistant message reconstructed from durable content.
*/
export function toPiAssistant(message: Message): AssistantMessage {
const replayState = message.provenance?.replayState
const replayState = message.source.kind === 'model' ? message.source.replayState : undefined
return replayState === undefined ? foreignAssistant(message) : replayedAssistant(message, replayState)
}

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import LlmService, { createUserMessage, CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import type { PiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai'
@@ -38,7 +38,10 @@ afterEach(async () => {
})
function ask(text: string): Message[] {
return [{ role: 'user', content: [{ type: 'text', text }] }]
return [createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'test' },
})]
}
function textOf(result: AssembledResult): string {
@@ -122,14 +125,14 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () =>
messages: [
...ask('What is the weather in Paris right now? Use the get_weather tool.'),
first.message,
{
role: 'user',
createUserMessage({
content: [{
type: 'tool-result',
toolCallId: CallId(call!.id),
content: [{ type: 'text', text: 'Sunny, 22°C' }],
}],
},
source: { kind: 'plugin', plugin: 'test' },
}),
],
tools: [weatherTool],
maxTokens: 2000,

View File

@@ -2,7 +2,7 @@ import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm'
import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
@@ -103,7 +103,10 @@ describe('PiAiAdapter provider routing', () => {
const ctx = await harness(server.url)
const result = await assemble(ctx, {
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
messages: [createUserMessage({
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'plugin', plugin: 'test' },
})],
})
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
expect(result.finish).toEqual({ kind: 'stop' })

View File

@@ -20,14 +20,12 @@ export async function assemble(ctx: Context, options: Omit<GenerateOptions, 'pro
const request = { provider: 'deepseek', ...options }
for await (const chunk of ctx.llm.stream(request)) assembler.push(chunk)
return {
message: {
...assembler.message(),
provenance: {
provider: request.provider,
model: request.model,
...assembler.replayState === undefined ? {} : { replayState: assembler.replayState },
},
},
message: assembler.message({
kind: 'model',
provider: request.provider,
model: request.model,
...assembler.replayState === undefined ? {} : { replayState: assembler.replayState },
}),
...assembler.usage !== undefined ? { usage: assembler.usage } : {},
finish: assembler.finish,
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmError , createMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai'
import { toPiContext } from '../src/context.ts'
@@ -47,7 +47,10 @@ describe('toPiContext', () => {
provider: 'deepseek',
model: 'deepseek-v4-flash',
system: 'be helpful',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
messages: [createUserMessage({
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'plugin', plugin: 'test' },
})],
tools: [{ name: 'f', description: 'F', parameters: { type: 'object', properties: {} } }],
})
expect(context.systemPrompt).toBe('be helpful')
@@ -67,14 +70,15 @@ describe('toPiContext', () => {
const context = toPiContext({
provider: 'deepseek',
model: 'm',
messages: [{
messages: [createMessage({
role: 'assistant',
content: [
{ type: 'reasoning', text: 'hmm' },
{ type: 'text', text: 'calling' },
{ type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '{"a":1}' },
],
}],
source: { kind: 'plugin', plugin: 'test' },
})],
})
const message = context.messages[0] as AssistantMessage
expect(message.role).toBe('assistant')
@@ -90,7 +94,10 @@ describe('toPiContext', () => {
const context = toPiContext({
provider: 'deepseek',
model: 'm',
messages: [{ role: 'assistant', content: [{ type: 'text', text: 'done' }] }],
messages: [createMessage({
role: 'assistant', content: [{ type: 'text', text: 'done' }],
source: { kind: 'plugin', plugin: 'test' },
})],
})
expect((context.messages[0] as AssistantMessage).stopReason).toBe('stop')
})
@@ -99,10 +106,11 @@ describe('toPiContext', () => {
const context = toPiContext({
provider: 'deepseek',
model: 'm',
messages: [{
messages: [createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '{broken' }],
}],
source: { kind: 'plugin', plugin: 'test' },
})],
})
const message = context.messages[0] as AssistantMessage
expect(message.content[0]).toEqual({ type: 'toolCall', id: 'c1', name: 'f', arguments: {} })
@@ -112,10 +120,11 @@ describe('toPiContext', () => {
const context = toPiContext({
provider: 'deepseek',
model: 'm',
messages: [{
messages: [createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '[1,2]' }],
}],
source: { kind: 'plugin', plugin: 'test' },
})],
})
expect((context.messages[0] as AssistantMessage).content[0]).toMatchObject({ arguments: {} })
})
@@ -125,14 +134,15 @@ describe('toPiContext', () => {
provider: 'deepseek',
model: 'm',
messages: [
{
createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('c1'), name: 'get_weather', arguments: '{}' }],
},
{
role: 'user',
source: { kind: 'plugin', plugin: 'test' },
}),
createUserMessage({
content: [{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'Sunny' }] }],
},
source: { kind: 'plugin', plugin: 'test' },
}),
],
})
expect(context.messages[1]).toEqual({
@@ -149,10 +159,10 @@ describe('toPiContext', () => {
const context = toPiContext({
provider: 'deepseek',
model: 'm',
messages: [{
role: 'user',
messages: [createUserMessage({
content: [{ type: 'tool-result', toolCallId: CallId('zz'), content: [], isError: true }],
}],
source: { kind: 'plugin', plugin: 'test' },
})],
})
expect(context.messages[0]).toMatchObject({
role: 'toolResult',
@@ -167,14 +177,17 @@ describe('toPiContext', () => {
provider: 'deepseek',
model: 'm',
messages: [
{ role: 'system', content: [{ type: 'text', text: 'rule' }] },
{
role: 'user',
createMessage({
role: 'system', content: [{ type: 'text', text: 'rule' }],
source: { kind: 'plugin', plugin: 'test' },
}),
createUserMessage({
content: [
{ type: 'text', text: 'note' },
{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'ok' }] },
],
},
source: { kind: 'plugin', plugin: 'test' },
}),
],
})
expect(context.messages.map(message => message.role)).toEqual(['user', 'user', 'toolResult'])
@@ -184,13 +197,14 @@ describe('toPiContext', () => {
const context = toPiContext({
provider: 'deepseek',
model: 'm',
messages: [{
messages: [createMessage({
role: 'assistant',
content: [
{ type: 'chart', data: 'x' } as unknown as ContentBlock,
{ type: 'text', text: 'visible' },
],
}],
source: { kind: 'plugin', plugin: 'test' },
})],
})
expect((context.messages[0] as AssistantMessage).content).toEqual([{ type: 'text', text: 'visible' }])
})
@@ -212,15 +226,18 @@ describe('toPiContext', () => {
const context = toPiContext({
provider: 'anthropic',
model: 'claude-next',
messages: [{
messages: [createMessage({
role: 'assistant',
content: [
{ type: 'reasoning', text: 'private reasoning' },
{ type: 'text', text: 'calling' },
{ type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '{"a":1}' },
],
provenance: { provider: 'openai', model: 'gpt-5', replayState: state },
}],
source: {
kind: 'model',
...{ provider: 'openai', model: 'gpt-5', replayState: state },
},
})],
})
expect(context.messages[0]).toMatchObject({
@@ -250,15 +267,18 @@ describe('toPiContext', () => {
const context = toPiContext({
provider: 'deepseek',
model: 'new-model',
messages: [{
messages: [createMessage({
role: 'assistant',
content: [
{ type: 'reasoning', text: 'private reasoning' },
{ type: 'text', text: 'calling' },
{ type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '{"a":1}' },
],
provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state },
}],
source: {
kind: 'model',
...{ provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state },
},
})],
})
expect(context.messages[0]).toMatchObject({
@@ -278,15 +298,18 @@ describe('toPiContext', () => {
toPiContext({
provider: 'deepseek',
model: 'm',
messages: [{
messages: [createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
provenance: {
provider: 'deepseek',
model: 'old',
replayState: { kind: 'pi-ai', version: 2 },
source: {
kind: 'model',
...{
provider: 'deepseek',
model: 'old',
replayState: { kind: 'pi-ai', version: 2 },
},
},
}],
})],
})
expect.fail('expected invalid replay state')
} catch (error: unknown) {
@@ -301,11 +324,14 @@ describe('toPiContext', () => {
expect(() => toPiContext({
provider: 'deepseek',
model: 'm',
messages: [{
messages: [createMessage({
role: 'assistant',
content: [{ type: 'reasoning', text: 'done' }],
provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state },
}],
source: {
kind: 'model',
...{ provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state },
},
})],
})).toThrow(/block 0 does not match assistant content/)
})
@@ -314,11 +340,14 @@ describe('toPiContext', () => {
expect(() => toPiContext({
provider: 'deepseek',
model: 'm',
messages: [{
messages: [createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state },
}],
source: {
kind: 'model',
...{ provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state },
},
})],
})).toThrow(/block count does not match assistant content/)
})
@@ -340,11 +369,14 @@ describe('toPiContext', () => {
toPiContext({
provider: 'deepseek',
model: 'next-model',
messages: [{
messages: [createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState },
}],
source: {
kind: 'model',
...{ provider: 'deepseek', model: 'deepseek-v4-flash', replayState },
},
})],
})
expect.fail('expected invalid replay state')
} catch (error: unknown) {
@@ -376,11 +408,14 @@ describe('toPiContext', () => {
expect(() => toPiContext({
provider: 'deepseek',
model: 'm',
messages: [{
messages: [createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState },
}],
source: {
kind: 'model',
...{ provider: 'deepseek', model: 'deepseek-v4-flash', replayState },
},
})],
})).toThrow(message)
})
})

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import LlmService, { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import type { PiAiReplayState } from '../src/replay.ts'
@@ -58,7 +58,10 @@ afterEach(async () => {
})
function ask(text: string): Message[] {
return [{ role: 'user', content: [{ type: 'text', text }] }]
return [createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'test' },
})]
}
function textOf(result: AssembledResult): string {
@@ -76,7 +79,9 @@ function expectFinish(result: AssembledResult, expected: 'stop' | 'tool-calls'):
}
function expectNativeReplay(result: AssembledResult, profile: ProviderCase): PiAiReplayState {
const replayState = result.message.provenance?.replayState
const replayState = result.message.source.kind === 'model'
? result.message.source.replayState
: undefined
expect(replayState).toMatchObject({
kind: 'pi-ai',
version: 1,
@@ -141,14 +146,14 @@ for (const profile of providerCases) {
messages: [
...prompt,
first.message,
{
role: 'user',
createUserMessage({
content: [{
type: 'tool-result',
toolCallId: CallId(call!.id),
content: [{ type: 'text', text: 'The code blue means ocean.' }],
}],
},
source: { kind: 'plugin', plugin: 'test' },
}),
],
tools: [lookupTool],
maxTokens: 2048,

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import { ProviderRequestId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, ProviderRequestId , createMessage } from '@deepseek-ai/dsh-llm'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant'
@@ -223,8 +223,14 @@ describe('llm-retry invariants', () => {
reset.append('assistant/message', {
turn: 2,
step: 1,
content: [{ type: 'text', text: 'success' }],
provenance: { provider: 'mock', model: 'mock' },
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'success' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
reset.append('step/end', { turn: 2, step: 1 })
reset.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
@@ -241,18 +247,18 @@ describe('llm-retry invariants', () => {
await ctx.plugin(SessionStore)
const missingEnd = ctx.sessions.create(SessionId('retry-invariant-missing-end'))
missingEnd.append('user/message', {
missingEnd.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'idle context' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
appendRetryTurn(missingEnd, 2)
const nonFailureEnd = ctx.sessions.create(SessionId('retry-invariant-non-failure-end'))
nonFailureEnd.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
nonFailureEnd.append('user/message', {
nonFailureEnd.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'idle context' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
appendRetryTurn(nonFailureEnd, 2)
const missingStart = ctx.sessions.create(SessionId('retry-invariant-missing-start'))

View File

@@ -8,7 +8,7 @@ import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import LlmService, { LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
import LlmService, { createUserMessage, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -105,7 +105,7 @@ describe('real Loader composition', () => {
const adapter = new TransientOnceAdapter()
loaded.llm.registerAdapter(['mock'], adapter)
const agent = loaded.agentLoop.create(SessionId('loader-retry'), { provider: 'mock', model: 'mock' })
agent.followup({ content: [{ type: 'text', text: 'recover' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'recover' }], source: { kind: 'user' } }))
await agent.whenIdle()
expect(adapter.requests).toBe(2)

View File

@@ -1,7 +1,7 @@
import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Fiber } from 'cordis'
import LlmService, { CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
import LlmService, { createUserMessage, CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
import type {
AlwaysRetryPolicyConfig,
BackoffConfig,
@@ -190,7 +190,7 @@ describe('provider-routed retry policy', () => {
})
const scheduled = waitForRetry(context, agent, 1)
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
const event = await scheduled
expect(event.data).toEqual({
@@ -235,7 +235,7 @@ describe('provider-routed retry policy', () => {
const agent = context.agentLoop.create(SessionId('retry-empty-response'), { provider: 'mock', model: 'mock' })
const scheduled = waitForRetry(context, agent, 1)
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
const event = await scheduled
expect(event.data.failure).toEqual({
message: 'model returned a completed response with no content',
@@ -277,7 +277,7 @@ describe('provider-routed retry policy', () => {
const agent = context.agentLoop.create(SessionId('retry-partial'), { provider: 'mock', model: 'mock' })
const scheduled = waitForRetry(context, agent, 1)
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await scheduled
const idle = waitForIdle(context, agent)
await vi.advanceTimersByTimeAsync(500)
@@ -316,7 +316,7 @@ describe('provider-routed retry policy', () => {
const agent = context.agentLoop.create(SessionId('retry-exhausted'), { provider: 'mock', model: 'mock' })
const first = waitForRetry(context, agent, 1)
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
expect((await first).data.delayMs).toBe(450)
const second = waitForRetry(context, agent, 2)
@@ -347,7 +347,7 @@ describe('provider-routed retry policy', () => {
const agent = context.agentLoop.create(SessionId('retry-zero-delay'), { provider: 'mock', model: 'mock' })
const scheduled = waitForRetry(context, agent, 1)
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
expect((await scheduled).data.delayMs).toBe(0)
const idle = waitForIdle(context, agent)
@@ -367,7 +367,7 @@ describe('provider-routed retry policy', () => {
}) }))
const acceptedAgent = context.agentLoop.create(SessionId('retry-after-accepted'), { provider: 'mock', model: 'mock' })
const scheduled = waitForRetry(context, acceptedAgent, 1)
acceptedAgent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
acceptedAgent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
expect((await scheduled).data.delayMs).toBe(2_000)
const acceptedIdle = waitForIdle(context, acceptedAgent)
await vi.advanceTimersByTimeAsync(2_000)
@@ -381,7 +381,7 @@ describe('provider-routed retry policy', () => {
;({ ctx: context } = await harness(rejected))
const rejectedAgent = context.agentLoop.create(SessionId('retry-after-rejected'), { provider: 'mock', model: 'mock' })
const rejectedIdle = waitForIdle(context, rejectedAgent)
rejectedAgent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
rejectedAgent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await rejectedIdle
expect(rejected.requests).toHaveLength(1)
expect(rejectedAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
@@ -404,7 +404,7 @@ describe('provider-routed retry policy', () => {
})
const scheduled = waitForRetry(context, agent, 1)
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
expect((await scheduled).data.delayMs).toBe(3)
const idle = waitForIdle(context, agent)
await vi.advanceTimersByTimeAsync(3)
@@ -419,7 +419,7 @@ describe('provider-routed retry policy', () => {
;({ ctx: context } = await harness(adapter))
const agent = context.agentLoop.create(SessionId('retry-auth'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(context, agent)
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await idle
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
@@ -437,7 +437,7 @@ describe('provider-routed retry policy', () => {
})
const idle = waitForIdle(context, agent)
agent.followup({ content: [{ type: 'text', text: 'missing route' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'missing route' }], source: { kind: 'user' } }))
await idle
expect(adapter.requests).toHaveLength(0)
@@ -464,7 +464,7 @@ describe('provider-routed retry policy', () => {
model: 'mock',
})
const normalIdle = waitForIdle(context, normalAgent)
normalAgent.followup({ content: [{ type: 'text', text: 'normal' }], source: { kind: 'user' } })
normalAgent.followup(createUserMessage({ content: [{ type: 'text', text: 'normal' }], source: { kind: 'user' } }))
await normalIdle
expect(normalAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
@@ -473,7 +473,7 @@ describe('provider-routed retry policy', () => {
model: 'mock',
})
const scheduled = waitForRetry(context, alwaysAgent, 1)
alwaysAgent.followup({ content: [{ type: 'text', text: 'always' }], source: { kind: 'user' } })
alwaysAgent.followup(createUserMessage({ content: [{ type: 'text', text: 'always' }], source: { kind: 'user' } }))
expect((await scheduled).data).toMatchObject({
provider: 'other',
mode: 'always',
@@ -507,7 +507,7 @@ describe('provider-routed retry policy', () => {
})
const scheduled = waitForRetry(context, agent, 1)
agent.followup({ content: [{ type: 'text', text: 'reroute' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'reroute' }], source: { kind: 'user' } }))
expect((await scheduled).data).toMatchObject({ provider: 'other', mode: 'always' })
const idle = waitForIdle(context, agent)
await vi.advanceTimersByTimeAsync(1)
@@ -544,10 +544,10 @@ describe('provider-routed retry policy', () => {
})
const idle = waitForIdle(context, agent)
agent.followup({
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'switch provider after failure' }],
source: { kind: 'user' },
})
}))
await vi.runAllTimersAsync()
await idle
@@ -591,10 +591,10 @@ describe('provider-routed retry policy', () => {
model: 'mock',
})
const scheduled = waitForRetry(context, agent, 1)
agent.followup({
agent.followup(createUserMessage({
content: [{ type: 'text', text: 'replace while in flight' }],
source: { kind: 'user' },
})
}))
await entered.promise
mounted.disposeAdapter()
@@ -659,7 +659,7 @@ describe('provider-routed retry policy', () => {
})
const idle = waitForIdle(context, agent)
agent.followup({ content: [{ type: 'text', text: 'keep trying' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'keep trying' }], source: { kind: 'user' } }))
await vi.runAllTimersAsync()
await idle
@@ -696,7 +696,7 @@ describe('provider-routed retry policy', () => {
})
const scheduled = waitForRetry(context, agent, 1)
agent.followup({ content: [{ type: 'text', text: 'safe input' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'safe input' }], source: { kind: 'user' } }))
await scheduled
const idle = waitForIdle(context, agent)
await vi.advanceTimersByTimeAsync(1)
@@ -725,7 +725,7 @@ describe('provider-routed retry policy', () => {
})
const idle = waitForIdle(context, agent)
agent.followup({ content: [{ type: 'text', text: 'recover' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'recover' }], source: { kind: 'user' } }))
await idle
expect(adapter.requests).toHaveLength(2)
@@ -752,7 +752,7 @@ describe('provider-routed retry policy', () => {
})
const scheduled = waitForRetry(context, agent, 1)
agent.followup({ content: [{ type: 'text', text: 'recover' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'recover' }], source: { kind: 'user' } }))
await scheduled
const idle = waitForIdle(context, agent)
await vi.advanceTimersByTimeAsync(1)
@@ -771,7 +771,7 @@ describe('provider-routed retry policy', () => {
context = mounted.ctx
const agent = context.agentLoop.create(SessionId('retry-hmr'), { provider: 'mock', model: 'mock' })
const scheduled = waitForRetry(context, agent, 1)
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await scheduled
const idle = waitForIdle(context, agent)
@@ -802,7 +802,7 @@ describe('provider-routed retry policy', () => {
model: 'mock',
})
const idle = waitForIdle(context, agent).then(() => { order.push('idle') })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await entered.promise
const disposing = mounted.retryFiber.dispose().then(() => { order.push('disposed') })
@@ -842,7 +842,7 @@ describe('provider-routed retry policy', () => {
model: 'mock',
})
const idle = waitForIdle(context, agent).then(() => { order.push('idle') })
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await entered.promise
agent.cancel({ kind: 'user' })
@@ -882,7 +882,7 @@ describe('provider-routed retry policy', () => {
})
const idle = waitForIdle(context, agent)
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await entered.promise
let timer: ReturnType<typeof setTimeout> | undefined
const outcome = await Promise.race([
@@ -929,7 +929,7 @@ describe('provider-routed retry policy', () => {
model: 'mock',
})
const idle = waitForIdle(context, agent)
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await captured.promise
await mounted.retryFiber.dispose()
@@ -950,7 +950,7 @@ describe('provider-routed retry policy', () => {
;({ ctx: context } = await harness(adapter, { mock: alwaysConfig() }))
const agent = context.agentLoop.create(SessionId('retry-cancel'), { provider: 'mock', model: 'mock' })
const scheduled = waitForRetry(context, agent, 1)
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await scheduled
const idle = waitForIdle(context, agent)
agent.cancel({ kind: 'user' })
@@ -984,7 +984,7 @@ describe('provider-routed retry policy', () => {
const agent = context.agentLoop.create(SessionId('retry-pre-cancel'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(context, agent)
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await idle
expect(adapter.requests).toHaveLength(1)
@@ -1008,7 +1008,7 @@ describe('provider-routed retry policy', () => {
})
const idle = waitForIdle(context, agent)
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await idle
expect(adapter.requests).toHaveLength(1)

View File

@@ -1,3 +1,4 @@
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { afterEach, describe, expect, it } from 'vitest'
@@ -66,7 +67,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
function sendAndWait(ctx: Context, agent: Agent): Promise<void> {
const idle = waitForIdle(ctx, agent)
agent.followup({ content: [{ type: 'text', text: 'recover through the provider boundary' }], source: { kind: 'user' } })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'recover through the provider boundary' }], source: { kind: 'user' } }))
return idle
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
README.md: 2328188e420df6de60f024982a31d37a858a303e
README.zh.md: 586767a9e790fa68d27673836700fd789ce6a180
README.md: 7c34d5621d6ac644aaac17169f38709147ac1dd5
README.zh.md: 1d2475272640162beab425ac91fc93dd27b53b63

View File

@@ -38,9 +38,11 @@ Reasoning identifiers are opaque adapter-owned strings rather than a core enum.
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerRetryPolicy()` to supply provider-owned recovery configuration, `providerInfo()` and asynchronous `listModels()` to expose selector metadata, then implement `resolveModel()` when exact identity, capacity, or selectable reasoning efforts are available; an asynchronous resolver must honor its optional cancellation signal. The defaults use bounded normal retry policy, use the route and model ids as names, advertise no models, and return no capacity or reasoning metadata.
- Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, logging, or routing. A wrapper that retries after emitting a chunk has no durable attempt boundary; shipped agent retry policy therefore uses `agent/request-error` instead.
### Content-block vocabulary (`types.ts`)
### Messages (`message.ts`) and content blocks (`types.ts`)
Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. Assistant messages produced by the loop also carry provider/model provenance and optional adapter-private replay state. Before dispatch, `LlmService` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. The core block set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it.
`Message` is the shared immutable value used by delivery, durable history, and model requests. Every message has a required `MessageId`, role, content, and typed source from creation onward. `createMessage(input)` mints the identity and returns a detached deep-frozen value; `createUserMessage({ content, source })` fixes the user role; `createAssistantMessage({ content, source })` fixes the assistant role and model source kind; `createToolResultMessage({ callId, content, isError })` fixes the user role and couples the tool source to its result block; `freezeMessage(message)` imports an identity that already exists and never replaces it. Message rewrites preserve the identity and produce another frozen value.
Message content is an array of typed blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. Assistant messages use a model source carrying provider/model provenance and optional adapter-private replay state. Before dispatch, `LlmService` retains that state only when the historical provider route and target provider route are currently owned by the exact same adapter instance; the adapter then decides whether it can restore or convert the state across models/providers. The core block set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it.
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
@@ -55,7 +57,7 @@ Every product adapter sends application identity on provider HTTP requests. `att
### Classes
- `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`.
- `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history.
- `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and can create an identified, frozen assistant message from them. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks for history.
- `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams.
- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) matches its frozen serializable `failure.code`. The payload may also retain validated status, `Retry-After`, and branded provider request id facts; policy remains outside the error.
- `errorChain(value)` — renders a thrown value with its full `cause` chain and AggregateError members for diagnostic surfaces (UI notices, logger lines, durable `turn/end` messages), so transport wrappers like undici's `TypeError: fetch failed` surface the underlying `ECONNREFUSED`/DNS/TLS detail instead of masking it. Rendering only — route on `code`, never by parsing the result.

View File

@@ -38,9 +38,11 @@
- 继承 `LlmAdapter` 并调用 `ctx.llm.registerAdapter(providers, adapter)`,添加一条或多条提供方路由。`GenerateOptions.provider` 选择适配器;`GenerateOptions.model` 属于适配器,可以动态解析。覆盖 `providerRetryPolicy()` 以提供由提供方持有的恢复配置,覆盖 `providerInfo()` 和异步 `listModels()` 以公开 selector 元数据;精确身份、容量或可选推理强度可用时,实现 `resolveModel()`;异步解析器必须响应其可选的取消 signal。默认实现使用有界的 normal 重试策略,将路由和模型 id 用作名称,不公布模型,也不返回容量或推理元数据。
- 包装 `llm/stream` 时,通过 `ctx.on()` waterfall listener 实现缓存、日志或路由。发出 chunk 后重试的包装层没有持久尝试边界;因此已发布 agent 重试策略改用 `agent/request-error`
### 内容块词汇`types.ts`
### 消息(`message.ts`)与内容块(`types.ts`
消息是类型化内容块数组:`text``reasoning``tool-call``tool-result`。联合从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加块类型。loop 产生的 assistant 消息还会携带提供方模型溯源与可选适配器私有回放状态。dispatch 前,`LlmService` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型/提供方间恢复或转换该状态。核心块集只包含每条已发布路径都支持的块。多模态内容(图像、音频等)没有核心块类型;需要它的功能会通过 map 添加并一并添加支持它的适配器UI压缩实现
`Message` 是投递、持久历史和模型请求共享的不可变值。每条消息从创建起都必须具有 `MessageId`、角色、内容和带类型的来源。`createMessage(input)` 生成标识,并返回与输入分离且深度冻结的值;`createUserMessage({ content, source })` 固定 user 角色;`createAssistantMessage({ content, source })` 固定 assistant 角色与模型来源类别;`createToolResultMessage({ callId, content, isError })` 固定 user 角色,并将工具来源与其结果块耦合;`freezeMessage(message)` 导入已有标识,绝不将其替换。改写消息时会保留标识,并产生另一个冻结值
消息内容是类型化内容块数组:`text``reasoning``tool-call``tool-result`。联合从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加块类型。assistant 消息使用模型来源其中携带提供方模型溯源与可选适配器私有回放状态。dispatch 前,`LlmService` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型/提供方间恢复或转换该状态。核心块集只包含每条已发布路径都支持的块。多模态内容(图像、音频等)没有核心块类型;需要它的功能会通过 map 添加并一并添加支持它的适配器UI压缩实现。
流式输出是原始 chunk 协议(`block-start``text-delta``reasoning-delta``tool-call-delta``block-end``usage``finish`)。`BlockAssembler` 是将 chunk 组装为块/消息的唯一共享实现。
@@ -55,7 +57,7 @@
### 类
- `LlmAdapter`:提供方适配器的抽象基类。唯一必需方法是 `stream()`
- `BlockAssembler`:将原始 chunk 逐步组装为完整内容块 assistant 消息。agent loop 向它提供原始 chunk同时记录以供回放并读取已组装块/消息以构建历史。
- `BlockAssembler`:将原始 chunk 逐步组装为完整内容块,并能据此创建带标识且冻结的 assistant 消息。agent loop 向它提供原始 chunk同时记录以供回放并读取已组装块以构建历史。
- `HarnessError`harness 错误分类体系的基类,包含稳定 `code` 字符串(与面向人的 `message` 不同)加 `cause` 链接。它位于所有其他包都导入的叶子包中,因此可以共享单一基类,无需新的依赖边。每包错误(`LlmError``ToolArgsError``InvariantError` 等)都会扩展它。`isHarnessError(value)` 在 seam 处收窄类型。
- `LlmError`:扩展 `HarnessError`;其稳定 `code` 字符串(`NO_ADAPTER``DUPLICATE_ADAPTER``AUTH``RATE_LIMIT` 等适配器 code与冻结可序列化 `failure.code` 匹配。Payload 还可以保留已验证状态、`Retry-After` 和品牌化提供方请求 id 事实;策略位于错误之外。
- `errorChain(value)`:渲染抛出值的完整 `cause` 链与 AggregateError 成员,供诊断表层使用,包括 UI 通知、logger 行和持久 `turn/end` 消息。因此 undici 的 `TypeError: fetch failed` 等传输包装层会显示底层 `ECONNREFUSED`DNSTLS 详细信息,而不是将其遮蔽。该函数只负责渲染:请按 `code` 路由,绝不解析结果。

View File

@@ -8,7 +8,9 @@
import { CallId } from './brand.ts'
import { assertNever } from './never.ts'
import type { ContentBlock, FinishReason, Message, StreamChunk, TokenUsage } from './types.ts'
import { createMessage } from './message.ts'
import type { Message, MessageSource } from './message.ts'
import type { ContentBlock, FinishReason, StreamChunk, TokenUsage } from './types.ts'
interface PartialBlock {
blockType: string
@@ -149,9 +151,10 @@ export class BlockAssembler {
/**
* The assembled assistant message.
* @returns an assistant-role message over `blocks()` (same open-block assembly rules).
* @param source - producer attribution for the assembled message.
* @returns a frozen assistant-role message over `blocks()` (same open-block assembly rules).
*/
message(): Message {
return { role: 'assistant', content: this.blocks() }
message(source: MessageSource = { kind: 'plugin', plugin: 'dsh-llm/assembler' }): Message {
return createMessage({ role: 'assistant', content: this.blocks(), source })
}
}

View File

@@ -12,6 +12,18 @@
import type { Branded } from '@deepseek-ai/dsh-brand'
/** Stable identity carried by one message across inbox, log, and model-request boundaries. */
export type MessageId = Branded<'MessageId'>
/**
* Brand a message identifier.
* @param id - the opaque message identifier.
* @returns the same string, branded; no validation is performed.
*/
export function MessageId(id: string): MessageId {
return id as MessageId
}
/**
* Correlates a model-issued tool call with its result. Provider-issued for
* real adapters; synthesized by mocks/assembler fallbacks.

View File

@@ -13,9 +13,9 @@ import type {
LlmModelInfo,
LlmResolvedModelInfo,
LlmProviderInfo,
Message,
StreamChunk,
} from './types.ts'
import { freezeMessage, type Message } from './message.ts'
import { resolveRetryPolicy } from './retry-policy.ts'
import type { ResolvedRetryPolicy } from './retry-policy.ts'
import type { ProviderRequestId } from './brand.ts'
@@ -30,6 +30,7 @@ export * from './brand.ts'
export * from './never.ts'
export * from './error.ts'
export * from './types.ts'
export * from './message.ts'
export * from './retry-policy.ts'
export { BlockAssembler } from './assembler.ts'
export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts'
@@ -50,7 +51,8 @@ declare module 'cordis' {
* process-local {@link markAgentLoopRequest} identity and arrives deep-frozen
* (mutation throws): its content is a pure function of the session log (the
* reconstructability Agent Note), so listeners read it, never rewrite it.
* Hand-built calls own their mutability policy and do not carry that marker.
* Hand-built calls do not carry that marker; their messages already obey
* the immutable creation contract.
* @mode waterfall
*/
'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
@@ -457,13 +459,13 @@ export class LlmService extends Service {
/** Remove replay state whose historical route is owned by another adapter. */
private forAdapter(options: GenerateOptions, adapter: LlmAdapter): GenerateOptions {
const messages: Message[] = options.messages.map((message) => {
const provenance = message.provenance
if (message.role !== 'assistant' || provenance?.replayState === undefined) return message
if (this.adapters.get(provenance.provider)?.adapter === adapter) return message
return {
const source = message.source
if (message.role !== 'assistant' || source.kind !== 'model' || source.replayState === undefined) return message
if (this.adapters.get(source.provider)?.adapter === adapter) return message
return freezeMessage({
...message,
provenance: { provider: provenance.provider, model: provenance.model },
}
source: { kind: 'model', provider: source.provider, model: source.model },
})
})
if (messages.every((message, index) => message === options.messages[index])) return options
const filtered = { ...options, messages }

View File

@@ -0,0 +1,159 @@
/** Message value types, identity, and immutable construction helpers. */
import { MessageId, type CallId } from './brand.ts'
import { deepFreeze } from './call-config.ts'
import type { ContentBlock, ToolResultBlock } from './types.ts'
/** Provider ownership and adapter-private replay data for an assistant message. */
export interface AssistantProvenance {
/** Provider route that produced the message. */
provider: string
/** Provider model id that produced the message. */
model: string
/**
* Lossless-JSON adapter state needed to replay the provider response.
* `LlmService` exposes it to a target adapter only when that adapter instance
* currently owns both this historical provider and the target provider.
*/
replayState?: unknown
}
/** Required source of an assistant message produced by a routed model. */
export interface ModelMessageSource extends AssistantProvenance {
kind: 'model'
}
/** Required source of a user-role message carrying one tool result. */
export interface ToolMessageSource {
kind: 'tool'
callId: CallId
}
/**
* Where a message (or injected content) came from.
* Merge-extensible sum type — plugins add their own `kind`s.
*/
export interface MessageSourceMap {
user: { kind: 'user' }
plugin: { kind: 'plugin'; plugin: string }
model: ModelMessageSource
tool: ToolMessageSource
}
/** Any known message source, derived from {@link MessageSourceMap}; switch on `kind` and fall through unknowns (merge-extensible). */
export type MessageSource = MessageSourceMap[keyof MessageSourceMap]
/** One immutable message representation shared by delivery, durable history, and model requests. */
export interface Message {
/** Stable identity preserved across every representation boundary. */
readonly id: MessageId
/** Provider-neutral conversation role. */
readonly role: 'system' | 'user' | 'assistant'
/** Exact model-facing blocks. */
readonly content: ContentBlock[]
/** Required producer provenance. */
readonly source: MessageSource
}
/** A user-role specialization of the one shared message representation. */
export interface UserMessage extends Message {
readonly role: 'user'
}
/** A model-produced assistant specialization of the shared message representation. */
export interface AssistantMessage extends Message {
readonly role: 'assistant'
readonly source: ModelMessageSource
}
/** A tool-result specialization whose model-facing block retains call correlation. */
export interface ToolResultMessage extends Message {
readonly role: 'user'
readonly content: [ToolResultBlock]
readonly source: ToolMessageSource
}
type NewMessage = Omit<Message, 'id'>
type NewUserMessage = Omit<UserMessage, 'id' | 'role'>
type NewAssistantMessage = Omit<AssistantMessage, 'id' | 'role' | 'source'> & {
readonly source: Omit<ModelMessageSource, 'kind'> & { readonly kind?: never }
}
/**
* Detach and deep-freeze a message whose identity already exists.
* @param message - complete message, including its stable identity.
* @returns an immutable snapshot that preserves the identity.
*/
export function freezeMessage<T extends Message>(message: T): T {
return deepFreeze(structuredClone(message))
}
/**
* Create one identified message and freeze it before publication.
* @param input - complete role, content, and source for a new message.
* @returns an immutable message with a fresh stable identity.
*/
export function createMessage<T extends NewMessage>(
input: T & { readonly id?: never },
): T & Pick<Message, 'id'> {
return freezeMessage({
...input,
id: MessageId(crypto.randomUUID()),
})
}
/**
* Create one identified user-role message and freeze it before publication.
* @param input - complete content and source for a new user message.
* @returns an immutable user message with a fresh stable identity.
*/
export function createUserMessage<T extends NewUserMessage>(
input: T & { readonly id?: never; readonly role?: never },
): T & Pick<UserMessage, 'id' | 'role'> {
return createMessage({
...input,
role: 'user',
})
}
/**
* Create one identified model-produced assistant message and freeze it before publication.
* @param input - complete content and model provenance for a new assistant message.
* @returns an immutable assistant message with fixed role/source tags and a fresh stable identity.
*/
export function createAssistantMessage(
input: NewAssistantMessage & { readonly id?: never; readonly role?: never },
): AssistantMessage {
return createMessage({
content: input.content,
role: 'assistant',
source: {
...input.source,
kind: 'model',
},
})
}
/** Input whose acceptance creates one tool-result message. */
export interface ToolResultMessageInput {
readonly callId: CallId
readonly content: ContentBlock[]
readonly isError: boolean
}
/**
* Create and freeze one identified tool-result message.
* @param input - call identity, raw result blocks, and outcome.
* @returns an immutable user-role tool-result message.
*/
export function createToolResultMessage(input: ToolResultMessageInput): ToolResultMessage {
return createUserMessage({
source: { kind: 'tool', callId: input.callId },
content: [{
type: 'tool-result',
toolCallId: input.callId,
content: input.content,
isError: input.isError,
}],
})
}

View File

@@ -6,6 +6,19 @@
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { CallId, ProviderRequestId, ReasoningEffortId } from './brand.ts'
import type { Message } from './message.ts'
export type {
AssistantMessage,
AssistantProvenance,
Message,
MessageSource,
MessageSourceMap,
ModelMessageSource,
ToolMessageSource,
ToolResultMessage,
UserMessage,
} from './message.ts'
/** Serializable provider-boundary facts; policy decides whether they are retryable. */
export interface LlmFailure {
@@ -67,43 +80,6 @@ export type ContentBlockType = keyof ContentBlockMap
/** Any known content block, derived from {@link ContentBlockMap}; switch on `type` and fall through unknowns (merge-extensible). */
export type ContentBlock = ContentBlockMap[ContentBlockType]
/** Provider ownership and adapter-private replay data for an assistant message. */
export interface AssistantProvenance {
/** Provider route that produced the message. */
provider: string
/** Provider model id that produced the message. */
model: string
/**
* Lossless-JSON adapter state needed to replay the provider response.
* `LlmService` exposes it to a target adapter only when that adapter instance
* currently owns both this historical provider and the target provider.
*/
replayState?: unknown
}
/**
* A single message in a conversation history. Loop-derived assistant messages
* always carry provenance; callers may omit it on hand-built foreign history.
*/
export interface Message {
role: 'system' | 'user' | 'assistant'
content: ContentBlock[]
/** Present only on assistant messages produced by a routed adapter. */
provenance?: AssistantProvenance
}
/**
* Where a message (or injected content) came from.
* Merge-extensible sum type — plugins add their own `kind`s.
*/
export interface MessageSourceMap {
user: { kind: 'user' }
plugin: { kind: 'plugin'; plugin: string }
}
/** Any known message source, derived from {@link MessageSourceMap}; switch on `kind` and fall through unknowns (merge-extensible). */
export type MessageSource = MessageSourceMap[keyof MessageSourceMap]
/**
* Why a model response stopped.
* Merge-extensible so adapters can surface provider-specific reasons.

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import {
CallId,
createAssistantMessage,
createToolResultMessage,
createUserMessage,
freezeMessage,
MessageId,
} from '@deepseek-ai/dsh-llm'
describe('message construction', () => {
it('assigns identity immediately and returns a detached deep-frozen message', () => {
const input = {
content: [{ type: 'text' as const, text: 'original' }],
source: { kind: 'plugin' as const, plugin: 'test' },
}
const message = createUserMessage(input)
expect(message.id).toEqual(expect.any(String))
expect(message.role).toBe('user')
expect(message.id).not.toHaveLength(0)
expect(message).not.toBe(input)
expect(Object.isFrozen(message)).toBe(true)
expect(Object.isFrozen(message.content)).toBe(true)
expect(Object.isFrozen(message.content[0])).toBe(true)
expect(Object.isFrozen(message.source)).toBe(true)
input.content[0]!.text = 'caller mutation'
expect(message.content).toEqual([{ type: 'text', text: 'original' }])
expect(() => {
(message.content[0] as { text: string }).text = 'observer mutation'
}).toThrow()
})
it('freezes an existing identity without minting a replacement', () => {
const id = MessageId('existing')
const input = {
id,
role: 'assistant' as const,
content: [{ type: 'text' as const, text: 'answer' }],
source: { kind: 'model' as const, provider: 'test', model: 'test' },
}
const message = freezeMessage(input)
expect(message).not.toBe(input)
expect(message.id).toBe(id)
expect(Object.isFrozen(message)).toBe(true)
expect(Object.isFrozen(message.content[0])).toBe(true)
})
it('fixes the assistant role and model source kind at creation', () => {
const message = createAssistantMessage({
content: [{ type: 'text', text: 'answer' }],
source: {
provider: 'test-provider',
model: 'test-model',
replayState: { request: 1 },
},
})
expect(message).toMatchObject({
role: 'assistant',
source: {
kind: 'model',
provider: 'test-provider',
model: 'test-model',
replayState: { request: 1 },
},
})
expect(message.id).not.toHaveLength(0)
expect(Object.isFrozen(message)).toBe(true)
expect(Object.isFrozen(message.source)).toBe(true)
})
it('couples tool-result content and provenance to one call identity', () => {
const callId = CallId('call-1')
const message = createToolResultMessage({
callId,
content: [{ type: 'text', text: 'result' }],
isError: false,
})
expect(message).toMatchObject({
role: 'user',
source: { kind: 'tool', callId },
content: [{
type: 'tool-result',
toolCallId: callId,
content: [{ type: 'text', text: 'result' }],
isError: false,
}],
})
expect(message.id).not.toHaveLength(0)
expect(Object.isFrozen(message)).toBe(true)
expect(Object.isFrozen(message.content[0])).toBe(true)
})
})

View File

@@ -15,6 +15,7 @@ import LlmService, {
ReasoningEffortId,
resolveRetryPolicy,
StreamChunk,
createMessage,
} from '@deepseek-ai/dsh-llm'
import type {
LlmModelContext,
@@ -175,6 +176,26 @@ describe('LlmService', () => {
expect(chunks).toEqual(SCRIPT)
})
it('trusts the immutable message creation boundary for direct calls', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const adapter = new RecordingAdapter(SCRIPT)
ctx.llm.registerAdapter(['test-provider'], adapter)
const message = createMessage({
role: 'user',
content: [{ type: 'text', text: 'hello' }],
source: { kind: 'user' },
})
for await (const _chunk of ctx.llm.stream({
provider: 'test-provider',
model: 'test-model',
messages: [message],
})) { /* drain */ }
expect(adapter.lastOptions?.messages[0]).toBe(message)
})
it('captures provider-owned retry policy at registration and defaults omission', async () => {
const configured = resolveRetryPolicy({ mode: 'always' }, 'test retryPolicy')
const adapter = new class extends ScriptedAdapter {
@@ -1195,15 +1216,18 @@ describe('LlmService', () => {
for await (const _chunk of ctx.llm.stream({
provider: 'target',
model: 'new-model',
messages: [{
messages: [createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'old response' }],
provenance: { provider: 'historical', model: 'old-model', replayState },
}],
source: {
kind: 'model',
...{ provider: 'historical', model: 'old-model', replayState },
},
})],
})) { /* drain */ }
expect(adapter.lastOptions?.messages[0]?.provenance).toEqual({
provider: 'historical', model: 'old-model', replayState,
expect(adapter.lastOptions?.messages[0]?.source).toEqual({
kind: 'model', provider: 'historical', model: 'old-model', replayState,
})
})
@@ -1217,14 +1241,21 @@ describe('LlmService', () => {
for await (const _chunk of ctx.llm.stream({
provider: 'target',
model: 'new-model',
messages: [{
messages: [createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'old response' }],
provenance: { provider: 'historical', model: 'old-model', replayState: { private: 'state' } },
}],
source: {
kind: 'model',
...{ provider: 'historical', model: 'old-model', replayState: { private: 'state' } },
},
})],
})) { /* drain */ }
expect(target.lastOptions?.messages[0]?.provenance).toEqual({ provider: 'historical', model: 'old-model' })
expect(target.lastOptions?.messages[0]?.source).toEqual({
kind: 'model',
provider: 'historical',
model: 'old-model',
})
})
it('preserves immutability while stripping replay state from frozen requests', async () => {
@@ -1236,18 +1267,27 @@ describe('LlmService', () => {
const options = Object.freeze({
provider: 'target',
model: 'new-model',
messages: [{
messages: [createMessage({
role: 'assistant' as const,
content: [{ type: 'text' as const, text: 'old response' }],
provenance: { provider: 'historical', model: 'old-model', replayState: { private: 'state' } },
}],
source: {
kind: 'model',
provider: 'historical',
model: 'old-model',
replayState: { private: 'state' },
},
})],
})
for await (const _chunk of ctx.llm.stream(options)) { /* drain */ }
expect(target.lastOptions).not.toBe(options)
expect(Object.isFrozen(target.lastOptions)).toBe(true)
expect(target.lastOptions?.messages[0]?.provenance).toEqual({ provider: 'historical', model: 'old-model' })
expect(target.lastOptions?.messages[0]?.source).toEqual({
kind: 'model',
provider: 'historical',
model: 'old-model',
})
})
it('creates LlmError with a code for programmatic handling', () => {

View File

@@ -344,8 +344,8 @@ export class TokenMeterService extends Service {
}
assembler.push(sourceEvent.data.chunk)
}
const providerMessage = assembler.message()
return providerMessage.content.length === 0 ? 0 : this.estimateMessage(providerMessage)
const providerContent = assembler.blocks()
return providerContent.length === 0 ? 0 : this._estimateContent(providerContent) + ROLE_OVERHEAD
}
/** Price content blocks recursively under the fixed density heuristic. */

View File

@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId , createMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session'
import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session'
@@ -12,7 +12,13 @@ function header(model: string, extras: Omit<EpochHeader, 'config'> = {}): EpochH
}
function textMessage(text: string, role: Message['role'] = 'user'): Message {
return { role, content: [{ type: 'text', text }] }
return createMessage({
role,
content: [{ type: 'text', text }],
source: role === 'assistant'
? { kind: 'model', provider: 'mock', model: 'mock' }
: { kind: 'user' },
})
}
function appendHeader(session: Session, value: EpochHeader): void {
@@ -65,13 +71,19 @@ function appendSuccessfulCall(
? { surfaceOp: 'append' as const }
: { surfaceOp: 'append' as const, sourceEventSeqs: provenance === 'empty' ? [] : sources }
session.append('assistant/message', {
provenance: {
provider: value.config.provider,
model: value.config.model,
},
turn,
step,
content: durableText.length === 0 ? [] : [{ type: 'text', text: durableText }],
message: createMessage({
role: 'assistant',
content: durableText.length === 0 ? [] : [{ type: 'text', text: durableText }],
source: {
kind: 'model',
...{
provider: value.config.provider,
model: value.config.model,
},
},
}),
...options.usage === undefined ? {} : { usage: options.usage },
}, intent)
session.append('step/end', { turn, step })
@@ -125,7 +137,10 @@ describe('TokenMeterService pricing', () => {
},
{ type: 'future-block', payload: 'abcd' } as unknown as ContentBlock,
]
const estimated = service.estimateMessage({ role: 'assistant', content: blocks })
const estimated = service.estimateMessage(createMessage({
role: 'assistant', content: blocks,
source: { kind: 'plugin', plugin: 'test' },
}))
expect(estimated).toBeGreaterThan(30)
expect(service.estimateMessage(textMessage('abcd'))).toBe(9)
})
@@ -154,10 +169,10 @@ describe('TokenMeterService pricing', () => {
it('keeps an earlier unified snapshot detached from later replay', () => {
const service = meter()
const session = new Session(SessionId('detached'))
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'first' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
const snapshot = service.measure(session)
const snapshotCopy = structuredClone(snapshot)
expect(Object.isFrozen(snapshot.nodes)).toBe(true)
@@ -170,10 +185,10 @@ describe('TokenMeterService pricing', () => {
;(snapshot.nodes[0] as { seq: number; tokens: number }).tokens = 1
}).toThrow(TypeError)
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'second' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
const advanced = service.measure(session)
expect(advanced.logRevision).toBe(2)
expect(advanced.nodes).toHaveLength(2)
@@ -186,10 +201,10 @@ describe('TokenMeterService pricing', () => {
it('prices header, tools, and surface when no reusable usage exists', () => {
const service = meter()
const session = new Session(SessionId('heuristic'))
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'question' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
appendHeader(session, header('deepseek-v4-flash', {
system: 'system',
tools: [{ name: 'read', description: 'read', parameters: { type: 'object' } }],
@@ -204,10 +219,10 @@ describe('TokenMeterService pricing', () => {
it('keeps request-header overrides out of the returned surface', () => {
const service = meter()
const session = new Session(SessionId('override-surface'))
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'question' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
const logged = service.measure(session)
const overridden = service.measure(session, header('another-model', {
@@ -232,10 +247,10 @@ describe('replay anchors and surface folds', () => {
it('uses disjoint provider usage and signed durable-output rewrites', () => {
const service = meter()
const session = new Session(SessionId('usage'))
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'before' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
appendSuccessfulCall(session, header('deepseek-v4-flash'), {
providerText: 'short',
durableText: 'a much longer rewritten durable assistant answer',
@@ -263,10 +278,10 @@ describe('replay anchors and surface folds', () => {
const anchored = service.measure(session)
expect(anchored.baseline.kind).toBe('estimated')
const assistant = anchored.nodes[0]!.seq
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'short' }],
source: { kind: 'plugin', plugin: 'test' },
}, {
}), {
surfaceOp: { op: 'replace', start: assistant, end: assistant },
sourceEventSeqs: [assistant],
})
@@ -290,10 +305,10 @@ describe('replay anchors and surface folds', () => {
const anchored = service.measure(session)
expect(anchored.baseline.kind).toBe('estimated')
expect(anchored.surfaceDeltaTokens).toBe(0)
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'later' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
const advanced = service.measure(session)
expect(advanced.surfaceDeltaTokens).toBeGreaterThan(0)
})
@@ -378,10 +393,10 @@ describe('replay anchors and surface folds', () => {
usage: USAGE,
providerText: 'long provider answer '.repeat(100),
})
original.append('user/message', {
original.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'new tail' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
const seeded = new Session(SessionId('surface-seeded'), original.events)
const before = service.measure(seeded)
expect(before.nodes).toHaveLength(2)
@@ -389,10 +404,10 @@ describe('replay anchors and surface folds', () => {
expectSurfaceTotal(before)
const first = seeded.surface.nodes[0]!
seeded.append('user/message', {
seeded.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'replacement' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: { op: 'replace', start: first, end: first }, sourceEventSeqs: [first] })
}), { surfaceOp: { op: 'replace', start: first, end: first }, sourceEventSeqs: [first] })
const after = service.measure(seeded)
expect(after.nodes).toHaveLength(2)
expect(after.nodes[0]!.seq).toBe(seeded.events.length - 1)
@@ -431,10 +446,16 @@ describe('malformed replay and listener lifecycle', () => {
const session = new Session(SessionId('bad-step'))
appendHeader(session, header('deepseek-v4-flash'))
session.append('assistant/message', {
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
turn: 1,
step: 1,
content: [{ type: 'text', text: 'bad' }],
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'bad' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
}, { surfaceOp: 'append', sourceEventSeqs: [] })
expectRepeatedFailure(meter(), session, /no matching step\/start/)
})
@@ -454,10 +475,16 @@ describe('malformed replay and listener lifecycle', () => {
appendHeader(late, header('deepseek-v4-flash'))
late.append('step/end', { turn: 1, step: 1 })
late.append('assistant/message', {
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
turn: 1,
step: 1,
content: [],
message: createMessage({
role: 'assistant',
content: [],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
}, { surfaceOp: 'append', sourceEventSeqs: [] })
expectRepeatedFailure(
meter(),
@@ -484,10 +511,10 @@ describe('malformed replay and listener lifecycle', () => {
{
name: 'non-chunk',
appendSource(session) {
return [session.append('user/message', {
return [session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'x' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' }).seq]
}), { surfaceOp: 'append' }).seq]
},
pattern: /is not assistant\/chunk/,
},
@@ -509,10 +536,16 @@ describe('malformed replay and listener lifecycle', () => {
appendHeader(session, header('deepseek-v4-flash'))
const sourceEventSeqs = testCase.appendSource(session)
session.append('assistant/message', {
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
turn: 1,
step: 1,
content: [{ type: 'text', text: 'bad' }],
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'bad' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
usage: { inputTokens: 1, outputTokens: 1 },
}, { surfaceOp: 'append', sourceEventSeqs })
expect(() => meter().measure(session)).toThrow(testCase.pattern)
@@ -533,10 +566,16 @@ describe('malformed replay and listener lifecycle', () => {
seq: duplicate.seq,
time: 0,
data: {
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
turn: 1,
step: 1,
content: [],
message: createMessage({
role: 'assistant',
content: [],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
usage: { inputTokens: 1, outputTokens: 0 },
},
surfaceOp: 'append',
@@ -552,10 +591,16 @@ describe('malformed replay and listener lifecycle', () => {
seq: future.seq,
time: 0,
data: {
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
turn: 1,
step: 1,
content: [],
message: createMessage({
role: 'assistant',
content: [],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
usage: { inputTokens: 1, outputTokens: 0 },
},
surfaceOp: 'append',
@@ -566,17 +611,23 @@ describe('malformed replay and listener lifecycle', () => {
it('does not partially apply a malformed assistant replacement', () => {
const session = new Session(SessionId('transactional-replace'))
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'head' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
appendHeader(session, header('deepseek-v4-flash'))
const head = session.events[0]!.seq
session.append('assistant/message', {
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
turn: 1,
step: 1,
content: [{ type: 'text', text: 'replacement' }],
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'replacement' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
}, { surfaceOp: { op: 'replace', start: head, end: head }, sourceEventSeqs: [head] })
expectRepeatedFailure(
meter(),
@@ -587,18 +638,18 @@ describe('malformed replay and listener lifecycle', () => {
it('rejects corrupt replacement ranges without advancing the replay cursor', () => {
const session = new Session(SessionId('bad-replace'))
const head = session.append('user/message', {
const head = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'head' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' }).seq
}), { surfaceOp: 'append' }).seq
appendUnchecked(session, {
type: 'user/message',
seq: session.seq,
time: 0,
data: {
data: createUserMessage({
content: [{ type: 'text', text: 'bad' }],
source: { kind: 'user' },
},
}),
surfaceOp: { op: 'replace', start: 99, end: 99 },
sourceEventSeqs: [head],
})
@@ -622,10 +673,10 @@ describe('malformed replay and listener lifecycle', () => {
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
}] })
activeMeter.measure(session)
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'one' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
expect(revisions).toEqual([2])
expect(activeMeter.measure(session).logRevision).toBe(2)