fix(llm): align replay state with assembled content and degrade unusable state
A max-tokens response that included a tool call persisted assembler-transformed content next to replay metadata projected from the untransformed native message, so the next request died in history reconstruction with INVALID_REPLAY_STATE and the session stayed permanently stuck. Write side: the finish chunk's replayState becomes a typed ReplayEnvelope — opaque response-level metadata plus optional per-block entries aligned with the emitted block sequence. BlockAssembler computes one keep/drop decision for blocks and entries together, so stored metadata always describes stored content and retained blocks keep their signatures. pi-ai splits its state into a version-2 response half and per-block signature entries. Read side: durable content is authoritative. toPiAssistant degrades any unusable state — foreign kind, other versions (including the flat v1 form already on disk), malformed metadata, or content/block mismatches — to the existing provider-neutral conversion with an onReplayDegrade diagnostic instead of failing the request, which un-bricks sessions poisoned before this change. Covered by assembler and replay unit tests, an agent-loop continuation regression, keyless real-composition continuation tests (native pruned-envelope replay and legacy flat-state degrade), and the authored keyless snapshot scenario max-tokens-continue through the assembled ACP app.
This commit is contained in:
@@ -62,7 +62,7 @@ function inboxText(message: UserMessage): string {
|
||||
describe('assistant replay provider and model fields', () => {
|
||||
it('records adapter replay state with the assembled assistant content', async () => {
|
||||
const response = textResponse('unchanged')
|
||||
const replayState = { private: 'state' }
|
||||
const replayState = { response: { private: 'state' }, blocks: ['block-meta'] }
|
||||
response[response.length - 1] = { type: 'finish', reason: { kind: 'stop' }, replayState }
|
||||
const adapter = new MockAdapter([response])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -1192,15 +1192,29 @@ describe('agent loop', () => {
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: 'partial text' } },
|
||||
{ type: 'block-start', index: 1, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 1, id: callId, name: 'echo', argumentsDelta: '{"text"' },
|
||||
{ type: 'finish', reason: { kind: 'max-tokens' } },
|
||||
]])
|
||||
{
|
||||
type: 'finish',
|
||||
reason: { kind: 'max-tokens' },
|
||||
replayState: { response: { responseId: 'resp-1' }, blocks: ['text-meta', 'tool-meta'] },
|
||||
},
|
||||
], textResponse('continued')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
send(agent, 'continue')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
|
||||
// The follow-up request replays the truncated message with its replay
|
||||
// metadata pruned in step with the dropped tool call.
|
||||
expect(adapter.requests[1]?.messages[1]?.source).toEqual({
|
||||
kind: 'model',
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
replayState: { response: { responseId: 'resp-1' }, blocks: ['text-meta'] },
|
||||
})
|
||||
expect(agent.session.deriveMessages()).toEqual([
|
||||
{
|
||||
id: expect.any(String) as unknown,
|
||||
@@ -1212,6 +1226,23 @@ describe('agent loop', () => {
|
||||
id: expect.any(String) as unknown,
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'partial text' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
replayState: { response: { responseId: 'resp-1' }, blocks: ['text-meta'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: expect.any(String) as unknown,
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'continue' }],
|
||||
source: { kind: 'user' },
|
||||
},
|
||||
{
|
||||
id: expect.any(String) as unknown,
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'continued' }],
|
||||
source: { kind: 'model', provider: 'mock', model: 'mock' },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -3577,6 +3577,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'RedactedSecret',
|
||||
declaration: 'export interface RedactedSecret {\n path: string[];\n set: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ReplayEnvelope',
|
||||
declaration: 'export interface ReplayEnvelope {\n response: unknown;\n blocks?: readonly unknown[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'RequestContext',
|
||||
declaration: 'export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n}',
|
||||
@@ -4091,7 +4095,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'StreamChunk',
|
||||
declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: unknown;\n};',
|
||||
declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: ReplayEnvelope;\n};',
|
||||
},
|
||||
{
|
||||
name: 'SubagentCapabilities',
|
||||
|
||||
@@ -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-pi-ai/README.md
|
||||
README.md: 6120f8d982c6d475cd508e6cf9e41cabfc9ba159
|
||||
README.zh.md: 4b47976c6c6c67968b5b93edbdfd5dfa9530eb1d
|
||||
README.md: d775e72616822ce0deee063ac0f3fc453af1a126
|
||||
README.zh.md: 621d67d1c181c6d4c78ea0078f521acccce92653
|
||||
|
||||
@@ -137,9 +137,9 @@ Credentials never enter that collection. The harness resolves a route's key thro
|
||||
|
||||
The selected model descriptor supplies the protocol implementation. This includes native API differences such as OpenAI models whose descriptor uses the Responses API rather than Chat Completions; the harness adapter does not hardcode endpoint selection by model name.
|
||||
|
||||
Successful assistant responses store a versioned, lossless-JSON replay state beside the provider and model that produced them. At request time, `LlmRuntime` passes replay state only when the historical provider route and target provider route are currently owned by this same `PiAiAdapter` instance. The adapter validates the state and restores pi-ai response ids and provider signatures even when the target provider or model changes; pi-ai then decides which metadata its target API can reuse. History without replay state is translated as foreign provider-neutral content and never impersonates a native pi-ai response.
|
||||
Successful assistant responses store a versioned, lossless-JSON replay state beside the provider and model that produced them, as a `ReplayEnvelope`: a response-level half (kind, version, API, route, response ids, native stop reason) plus one per-block entry per streamed block carrying that block's signatures. The per-block alignment is what `BlockAssembler` prunes when assembly drops a block (a `max-tokens` tool call), so the stored entries always describe the stored content — the retained blocks keep their signatures. At request time, `LlmRuntime` passes replay state only when the historical provider route and target provider route are currently owned by this same `PiAiAdapter` instance. The adapter validates the state and restores pi-ai response ids and provider signatures even when the target provider or model changes; pi-ai then decides which metadata its target API can reuse. History without replay state is translated as foreign provider-neutral content and never impersonates a native pi-ai response.
|
||||
|
||||
If a listener rewrites assembled assistant content, the loop drops replay state before logging the message because its provider metadata no longer describes the content. Invalid versions, malformed metadata, provider/model mismatches between the message and replay state, and content/block mismatches fail explicitly with `LlmError('INVALID_REPLAY_STATE')`.
|
||||
Durable content is the authoritative record; replay state only restores native fidelity. A stored state this build cannot use — another adapter's kind, another version (including the flat pre-envelope form older logs carry), malformed metadata, provider/model mismatches between the message and replay state, or content/block mismatches — degrades that one assistant message to the same foreign provider-neutral conversion instead of failing the request, and the plugin logs the `INVALID_REPLAY_STATE` diagnostic through its `onReplayDegrade` hook.
|
||||
|
||||
## Vocabulary differences
|
||||
|
||||
|
||||
@@ -138,9 +138,9 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩
|
||||
|
||||
所选模型 descriptor 提供协议实现。这包括原生 API 差异,例如 descriptor 使用 Responses API 而非 Chat Completions 的 OpenAI 模型;harness 适配器不会按模型名称硬编码端点选择。
|
||||
|
||||
成功的 assistant 响应会将经版本化的无损 JSON 回放状态与生成该响应的提供方和模型一同存储。请求时,`LlmRuntime` 只有在历史提供方路由与目标提供方路由当前由同一个 `PiAiAdapter` 实例拥有时,才会传递回放状态。即使目标提供方或模型改变,适配器也会验证状态并恢复 pi-ai 响应 id 与提供方 signature;随后由 pi-ai 判定目标 API 可以复用哪些元数据。没有回放状态的历史会被转换为外来的、与提供方无关的内容,绝不伪装为原生 pi-ai 响应。
|
||||
成功的 assistant 响应会将经版本化的无损 JSON 回放状态与生成该响应的提供方和模型一同存储,其形式是 `ReplayEnvelope`:一个响应级半区(kind、版本、API、路由、响应 id、原生停止原因),加上每个流式块一条、携带该块 signature 的逐块条目。逐块对齐正是 `BlockAssembler` 在组装丢弃某个块(`max-tokens` 下的工具调用)时裁剪的对象,因此存储的条目始终描述存储的内容——保留的块保有其 signature。请求时,`LlmRuntime` 只有在历史提供方路由与目标提供方路由当前由同一个 `PiAiAdapter` 实例拥有时,才会传递回放状态。即使目标提供方或模型改变,适配器也会验证状态并恢复 pi-ai 响应 id 与提供方 signature;随后由 pi-ai 判定目标 API 可以复用哪些元数据。没有回放状态的历史会被转换为外来的、与提供方无关的内容,绝不伪装为原生 pi-ai 响应。
|
||||
|
||||
如果 listener 改写已组装 assistant 内容,loop 会在记录消息前丢弃回放状态,因为其提供方元数据不再描述该内容。无效版本、格式错误元数据、消息与回放状态之间的提供方/模型不匹配,以及内容/块不匹配都会显式以 `LlmError('INVALID_REPLAY_STATE')` 失败。
|
||||
持久化内容是权威记录;回放状态只负责恢复原生保真度。当前构建无法使用的已存状态——其他适配器的 kind、其他版本(包括旧日志携带的平铺前信封形式)、格式错误的元数据、消息与回放状态之间的提供方/模型不匹配,或内容/块不匹配——会把这一条 assistant 消息降级为同样的外来提供方无关转换而不是让请求失败,插件通过其 `onReplayDegrade` 钩子记录 `INVALID_REPLAY_STATE` 诊断。
|
||||
|
||||
## 词汇差异
|
||||
|
||||
|
||||
@@ -76,6 +76,11 @@ export interface PiAiAdapterOptions {
|
||||
resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise<string | undefined>
|
||||
/** Resolve the optional durable attachment service at request time. */
|
||||
resolveAttachments?: () => AttachmentStore | undefined
|
||||
/**
|
||||
* Observe one assistant history message degrading to provider-neutral
|
||||
* conversion because its stored replay state is unusable by this build.
|
||||
*/
|
||||
onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void
|
||||
}
|
||||
|
||||
/** Copy profile stream knobs into pi-ai's common option vocabulary. */
|
||||
@@ -307,9 +312,12 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
if (containsImage && attachments === undefined) {
|
||||
throw new LlmError('pi-ai image input requires the durable attachment service', 'UNSUPPORTED_CONTENT')
|
||||
}
|
||||
const onReplayDegrade = (reason: string): void => {
|
||||
this.config.onReplayDegrade?.({ provider: options.provider, model: options.model, reason })
|
||||
}
|
||||
const context = attachments === undefined
|
||||
? toPiContext(options)
|
||||
: await toPiContext(options, attachments)
|
||||
? toPiContext(options, undefined, onReplayDegrade)
|
||||
: await toPiContext(options, attachments, onReplayDegrade)
|
||||
const events = snapshot.models.streamSimple(model, context, {
|
||||
...profileOptions(profile, reasoning, apiKey),
|
||||
...options.temperature === undefined ? {} : { temperature: options.temperature },
|
||||
|
||||
@@ -84,7 +84,7 @@ function piContext(options: GenerateOptions, messages: PiMessage[]): PiContext {
|
||||
}
|
||||
}
|
||||
|
||||
function textOnlyContext(options: GenerateOptions): PiContext {
|
||||
function textOnlyContext(options: GenerateOptions, onReplayDegrade?: (reason: string) => void): PiContext {
|
||||
const toolNames = new Map<CallId, string>()
|
||||
const messages: PiMessage[] = []
|
||||
for (const message of options.messages) {
|
||||
@@ -96,7 +96,7 @@ function textOnlyContext(options: GenerateOptions): PiContext {
|
||||
continue
|
||||
}
|
||||
if (message.role === 'assistant') {
|
||||
const assistant = toPiAssistant(message)
|
||||
const assistant = toPiAssistant(message, onReplayDegrade)
|
||||
for (const block of assistant.content) if (block.type === 'toolCall') toolNames.set(CallId(block.id), block.name)
|
||||
messages.push(assistant)
|
||||
continue
|
||||
@@ -125,22 +125,43 @@ function textOnlyContext(options: GenerateOptions): PiContext {
|
||||
* Convert text-only harness history to a synchronous pi-ai Context. Tool
|
||||
* result names are recovered from preceding assistant tool calls.
|
||||
* @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot.
|
||||
* @param attachments - absent; selects the synchronous conversion.
|
||||
* @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message.
|
||||
* @returns the pi-ai context; `tools` is omitted when the request declares none.
|
||||
*/
|
||||
export function toPiContext(options: GenerateOptions): PiContext
|
||||
export function toPiContext(
|
||||
options: GenerateOptions,
|
||||
attachments?: undefined,
|
||||
onReplayDegrade?: (reason: string) => void,
|
||||
): PiContext
|
||||
/**
|
||||
* Convert harness history to a pi-ai Context while resolving durable images.
|
||||
* Tool result names are recovered from preceding assistant tool calls.
|
||||
* @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot.
|
||||
* @param attachments - durable byte resolver for image references.
|
||||
* @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message.
|
||||
* @returns the asynchronously resolved pi-ai context.
|
||||
*/
|
||||
export function toPiContext(options: GenerateOptions, attachments: AttachmentStore): Promise<PiContext>
|
||||
export function toPiContext(options: GenerateOptions, attachments?: AttachmentStore): PiContext | Promise<PiContext> {
|
||||
return attachments === undefined ? textOnlyContext(options) : toPiContextWithImages(options, attachments)
|
||||
export function toPiContext(
|
||||
options: GenerateOptions,
|
||||
attachments: AttachmentStore,
|
||||
onReplayDegrade?: (reason: string) => void,
|
||||
): Promise<PiContext>
|
||||
export function toPiContext(
|
||||
options: GenerateOptions,
|
||||
attachments?: AttachmentStore,
|
||||
onReplayDegrade?: (reason: string) => void,
|
||||
): PiContext | Promise<PiContext> {
|
||||
return attachments === undefined
|
||||
? textOnlyContext(options, onReplayDegrade)
|
||||
: toPiContextWithImages(options, attachments, onReplayDegrade)
|
||||
}
|
||||
|
||||
async function toPiContextWithImages(options: GenerateOptions, attachments: AttachmentStore): Promise<PiContext> {
|
||||
async function toPiContextWithImages(
|
||||
options: GenerateOptions,
|
||||
attachments: AttachmentStore,
|
||||
onReplayDegrade?: (reason: string) => void,
|
||||
): Promise<PiContext> {
|
||||
const toolNames = new Map<CallId, string>()
|
||||
const messages: PiMessage[] = []
|
||||
|
||||
@@ -156,7 +177,7 @@ async function toPiContextWithImages(options: GenerateOptions, attachments: Atta
|
||||
continue
|
||||
}
|
||||
if (message.role === 'assistant') {
|
||||
const assistant = toPiAssistant(message)
|
||||
const assistant = toPiAssistant(message, onReplayDegrade)
|
||||
for (const block of assistant.content) {
|
||||
if (block.type === 'toolCall') toolNames.set(CallId(block.id), block.name)
|
||||
}
|
||||
|
||||
@@ -201,6 +201,12 @@ export function apply(ctx: Context, config: Config): void {
|
||||
profiles,
|
||||
resolveApiKey,
|
||||
resolveAttachments: () => ctx.get('attachments'),
|
||||
onReplayDegrade: ({ provider, model, reason }) => {
|
||||
ctx.logger.warn(
|
||||
`llm-pi-ai: unusable replay state on assistant history for route "${provider}/${model}";`
|
||||
+ ` sending that message as provider-neutral content (${reason})`,
|
||||
)
|
||||
},
|
||||
})
|
||||
// The full installed catalog is configurable from the moment the plugin
|
||||
// mounts — dormant or not — so configuration surfaces can offer every
|
||||
|
||||
@@ -9,24 +9,30 @@
|
||||
*/
|
||||
|
||||
import { LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message, ModelMessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message, ModelMessageSource, ReplayEnvelope } from '@deepseek-ai/dsh-llm'
|
||||
import type { Api, AssistantMessage, Usage as PiUsage } from '@earendil-works/pi-ai'
|
||||
|
||||
type PiAiReplayBlock =
|
||||
/** Per-block half of the pi-ai replay envelope, one entry per content block. */
|
||||
export type PiAiReplayBlock =
|
||||
| { type: 'text'; textSignature?: string }
|
||||
| { type: 'reasoning'; thinkingSignature?: string; redacted?: boolean }
|
||||
| { type: 'tool-call'; thoughtSignature?: string }
|
||||
|
||||
/** Versioned adapter-private projection required to replay a pi-ai response. */
|
||||
export interface PiAiReplayState {
|
||||
/** Versioned response-level half of the pi-ai replay envelope. */
|
||||
export interface PiAiReplayResponse {
|
||||
kind: 'pi-ai'
|
||||
version: 1
|
||||
version: 2
|
||||
api: Api
|
||||
provider: string
|
||||
model: string
|
||||
responseModel?: string
|
||||
responseId?: string
|
||||
stopReason: AssistantMessage['stopReason']
|
||||
}
|
||||
|
||||
/** The validated halves of one pi-ai replay envelope. */
|
||||
interface PiAiReplayState {
|
||||
response: PiAiReplayResponse
|
||||
blocks: PiAiReplayBlock[]
|
||||
}
|
||||
|
||||
@@ -57,19 +63,25 @@ function emptyPiUsage(): PiUsage {
|
||||
|
||||
/**
|
||||
* Project a successful pi-ai response into the minimal durable replay state.
|
||||
* The per-block half is index-aligned with the streamed blocks (pi-ai content
|
||||
* order), so `BlockAssembler` prunes an entry with its block whenever assembly
|
||||
* removes one.
|
||||
* @param message - completed native pi-ai assistant response.
|
||||
* @returns the versioned lossless-JSON replay projection.
|
||||
*/
|
||||
export function toPiReplayState(message: AssistantMessage): PiAiReplayState {
|
||||
return {
|
||||
export function toPiReplayState(message: AssistantMessage): ReplayEnvelope {
|
||||
const response: PiAiReplayResponse = {
|
||||
kind: 'pi-ai',
|
||||
version: 1,
|
||||
version: 2,
|
||||
api: message.api,
|
||||
provider: message.provider,
|
||||
model: message.model,
|
||||
...message.responseModel === undefined ? {} : { responseModel: message.responseModel },
|
||||
...message.responseId === undefined ? {} : { responseId: message.responseId },
|
||||
stopReason: message.stopReason,
|
||||
}
|
||||
return {
|
||||
response,
|
||||
blocks: message.content.map((block): PiAiReplayBlock => {
|
||||
switch (block.type) {
|
||||
case 'text': return {
|
||||
@@ -94,22 +106,26 @@ function invalidReplay(message: string): never {
|
||||
throw new LlmError(`invalid pi-ai replay state: ${message}`, 'INVALID_REPLAY_STATE')
|
||||
}
|
||||
|
||||
/** Validate the adapter-private state before it reaches pi-ai. */
|
||||
/** Validate the durable adapter-private envelope before it reaches pi-ai. */
|
||||
function readReplayState(value: unknown): PiAiReplayState {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalidReplay('expected an object')
|
||||
const state = value as Record<string, unknown>
|
||||
if (state['kind'] !== 'pi-ai') return invalidReplay('unknown state kind')
|
||||
if (state['version'] !== 1) return invalidReplay(`unsupported version ${String(state['version'])}`)
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalidReplay('expected a replay envelope')
|
||||
const envelope = value as Record<string, unknown>
|
||||
const rawResponse = envelope['response']
|
||||
if (typeof rawResponse !== 'object' || rawResponse === null || Array.isArray(rawResponse)) return invalidReplay('expected a response object')
|
||||
const response = rawResponse as Record<string, unknown>
|
||||
if (response['kind'] !== 'pi-ai') return invalidReplay('unknown state kind')
|
||||
if (response['version'] !== 2) return invalidReplay(`unsupported version ${String(response['version'])}`)
|
||||
for (const key of ['api', 'provider', 'model'] as const) {
|
||||
if (typeof state[key] !== 'string' || state[key].length === 0) return invalidReplay(`${key} must be a non-empty string`)
|
||||
if (typeof response[key] !== 'string' || response[key].length === 0) return invalidReplay(`${key} must be a non-empty string`)
|
||||
}
|
||||
if (!['stop', 'length', 'toolUse', 'error', 'aborted'].includes(String(state['stopReason']))) {
|
||||
if (!['stop', 'length', 'toolUse', 'error', 'aborted'].includes(String(response['stopReason']))) {
|
||||
return invalidReplay('unknown stopReason')
|
||||
}
|
||||
if (state['responseModel'] !== undefined && typeof state['responseModel'] !== 'string') return invalidReplay('responseModel must be a string')
|
||||
if (state['responseId'] !== undefined && typeof state['responseId'] !== 'string') return invalidReplay('responseId must be a string')
|
||||
if (!Array.isArray(state['blocks'])) return invalidReplay('blocks must be an array')
|
||||
for (const [index, value] of state['blocks'].entries()) {
|
||||
if (response['responseModel'] !== undefined && typeof response['responseModel'] !== 'string') return invalidReplay('responseModel must be a string')
|
||||
if (response['responseId'] !== undefined && typeof response['responseId'] !== 'string') return invalidReplay('responseId must be a string')
|
||||
const blocks = envelope['blocks']
|
||||
if (!Array.isArray(blocks)) return invalidReplay('blocks must be an array')
|
||||
for (const [index, value] of blocks.entries()) {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalidReplay(`block ${index} must be an object`)
|
||||
const block = value as Record<string, unknown>
|
||||
if (!['text', 'reasoning', 'tool-call'].includes(String(block['type']))) return invalidReplay(`block ${index} has an unknown type`)
|
||||
@@ -118,7 +134,10 @@ function readReplayState(value: unknown): PiAiReplayState {
|
||||
}
|
||||
if (block['redacted'] !== undefined && typeof block['redacted'] !== 'boolean') return invalidReplay(`block ${index} redacted must be boolean`)
|
||||
}
|
||||
return state as unknown as PiAiReplayState
|
||||
return {
|
||||
response: response as unknown as PiAiReplayResponse,
|
||||
blocks: blocks as PiAiReplayBlock[],
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert provider-neutral blocks without trusting them as same-model replay. */
|
||||
@@ -159,8 +178,8 @@ function foreignAssistant(message: Message): AssistantMessage {
|
||||
/** Recombine durable Harness content with validated pi-ai replay metadata. */
|
||||
function replayedAssistant(message: Message, source: ModelMessageSource, rawState: unknown): AssistantMessage {
|
||||
const state = readReplayState(rawState)
|
||||
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.response.provider !== source.provider) return invalidReplay('provider does not match assistant source')
|
||||
if (state.response.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]
|
||||
@@ -191,25 +210,40 @@ function replayedAssistant(message: Message, source: ModelMessageSource, rawStat
|
||||
return {
|
||||
role: 'assistant',
|
||||
content,
|
||||
api: state.api,
|
||||
provider: state.provider,
|
||||
model: state.model,
|
||||
...state.responseModel === undefined ? {} : { responseModel: state.responseModel },
|
||||
...state.responseId === undefined ? {} : { responseId: state.responseId },
|
||||
api: state.response.api,
|
||||
provider: state.response.provider,
|
||||
model: state.response.model,
|
||||
...state.response.responseModel === undefined ? {} : { responseModel: state.response.responseModel },
|
||||
...state.response.responseId === undefined ? {} : { responseId: state.response.responseId },
|
||||
usage: emptyPiUsage(),
|
||||
stopReason: state.stopReason,
|
||||
stopReason: state.response.stopReason,
|
||||
timestamp: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert one durable Harness assistant message into pi-ai history.
|
||||
*
|
||||
* Durable content is the authoritative record; replay metadata only restores
|
||||
* native fidelity (ids, signatures). A replay state this build cannot use —
|
||||
* another adapter's kind, another version, a malformed value, or metadata that
|
||||
* no longer matches the content — therefore degrades the one message to
|
||||
* provider-neutral history instead of failing the request.
|
||||
* @param message - assistant content with required source and optional adapter-owned replay metadata.
|
||||
* @param onDegrade - called with the diagnostic reason when an unusable replay
|
||||
* state falls back to provider-neutral conversion.
|
||||
* @returns a native pi-ai assistant message reconstructed from durable content.
|
||||
*/
|
||||
export function toPiAssistant(message: Message): AssistantMessage {
|
||||
export function toPiAssistant(message: Message, onDegrade?: (reason: string) => void): AssistantMessage {
|
||||
const source = message.source
|
||||
return source.kind !== 'model' || source.replayState === undefined
|
||||
? foreignAssistant(message)
|
||||
: replayedAssistant(message, source, source.replayState)
|
||||
if (source.kind !== 'model' || source.replayState === undefined) return foreignAssistant(message)
|
||||
try {
|
||||
return replayedAssistant(message, source, source.replayState)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- replayedAssistant throws only INVALID_REPLAY_STATE LlmErrors today; the
|
||||
guard keeps a future non-replay failure loud instead of silently degrading it */
|
||||
if (!(error instanceof LlmError) || error.code !== 'INVALID_REPLAY_STATE') throw error
|
||||
onDegrade?.(error.message)
|
||||
return foreignAssistant(message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
import { createUserMessage, CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmError, createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, 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'
|
||||
@@ -415,35 +415,68 @@ describe('toPiContext', () => {
|
||||
expect(context.messages[0]).not.toHaveProperty('responseId')
|
||||
})
|
||||
|
||||
it('rejects unsupported replay-state versions with a stable error code', () => {
|
||||
try {
|
||||
toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
messages: [createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{
|
||||
provider: 'deepseek',
|
||||
model: 'old',
|
||||
replayState: { kind: 'pi-ai', version: 2 },
|
||||
},
|
||||
it('degrades unsupported replay-state versions to provider-neutral history', () => {
|
||||
const onDegrade = vi.fn()
|
||||
const context = toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
messages: [createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{
|
||||
provider: 'deepseek',
|
||||
model: 'old',
|
||||
replayState: { response: { kind: 'pi-ai', version: 3 }, blocks: [] },
|
||||
},
|
||||
})],
|
||||
})
|
||||
expect.fail('expected invalid replay state')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(LlmError)
|
||||
expect((error as LlmError).code).toBe('INVALID_REPLAY_STATE')
|
||||
expect((error as Error).message).toContain('unsupported version 2')
|
||||
}
|
||||
},
|
||||
})],
|
||||
}, undefined, onDegrade)
|
||||
expect(context.messages[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
api: 'dsh-foreign',
|
||||
provider: 'deepseek',
|
||||
model: 'old',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
})
|
||||
expect(onDegrade).toHaveBeenCalledWith(expect.stringContaining('unsupported version 3'))
|
||||
})
|
||||
|
||||
it('rejects replay metadata whose blocks do not match the durable content', () => {
|
||||
it('degrades the flat pre-envelope replay state a legacy session log carries', () => {
|
||||
const onDegrade = vi.fn()
|
||||
const context = toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
messages: [createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
replayState: {
|
||||
kind: 'pi-ai',
|
||||
version: 1,
|
||||
api: 'openai-completions',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
stopReason: 'stop',
|
||||
blocks: [{ type: 'text' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})],
|
||||
}, undefined, onDegrade)
|
||||
expect(context.messages[0]).toMatchObject({ role: 'assistant', api: 'dsh-foreign' })
|
||||
expect(onDegrade).toHaveBeenCalledWith(expect.stringContaining('expected a response object'))
|
||||
})
|
||||
|
||||
it('degrades replay metadata whose blocks do not match the durable content', () => {
|
||||
const onDegrade = vi.fn()
|
||||
const state = toPiReplayState(assistant({ content: [{ type: 'text', text: 'done' }] }))
|
||||
expect(() => toPiContext({
|
||||
const context = toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
messages: [createMessage({
|
||||
@@ -454,12 +487,19 @@ describe('toPiContext', () => {
|
||||
...{ provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state },
|
||||
},
|
||||
})],
|
||||
})).toThrow(/block 0 does not match assistant content/)
|
||||
}, undefined, onDegrade)
|
||||
expect(context.messages[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
api: 'dsh-foreign',
|
||||
content: [{ type: 'thinking', thinking: 'done' }],
|
||||
})
|
||||
expect(onDegrade).toHaveBeenCalledWith(expect.stringContaining('block 0 does not match assistant content'))
|
||||
})
|
||||
|
||||
it('rejects replay metadata whose block count differs from durable content', () => {
|
||||
it('degrades replay metadata whose block count differs from durable content', () => {
|
||||
const onDegrade = vi.fn()
|
||||
const state = toPiReplayState(assistant())
|
||||
expect(() => toPiContext({
|
||||
const context = toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
messages: [createMessage({
|
||||
@@ -470,66 +510,34 @@ describe('toPiContext', () => {
|
||||
...{ provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state },
|
||||
},
|
||||
})],
|
||||
})).toThrow(/block count does not match assistant content/)
|
||||
}, undefined, onDegrade)
|
||||
expect(context.messages[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
api: 'dsh-foreign',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
stopReason: 'stop',
|
||||
})
|
||||
expect(onDegrade).toHaveBeenCalledWith(expect.stringContaining('block count does not match assistant content'))
|
||||
})
|
||||
|
||||
const validReplay = {
|
||||
const validResponse = {
|
||||
kind: 'pi-ai',
|
||||
version: 1,
|
||||
version: 2,
|
||||
api: 'openai-completions',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
stopReason: 'stop',
|
||||
blocks: [{ type: 'text' }],
|
||||
}
|
||||
const validReplay = { response: validResponse, blocks: [{ type: 'text' }] }
|
||||
|
||||
it.each([
|
||||
['provider', { ...validReplay, provider: 'openai' }],
|
||||
['model', { ...validReplay, model: 'deepseek-v4-pro' }],
|
||||
])('rejects replay metadata whose %s differs from assistant source', (field, replayState) => {
|
||||
try {
|
||||
toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'next-model',
|
||||
messages: [createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'deepseek', model: 'deepseek-v4-flash', replayState },
|
||||
},
|
||||
})],
|
||||
})
|
||||
expect.fail('expected invalid replay state')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(LlmError)
|
||||
expect((error as LlmError).code).toBe('INVALID_REPLAY_STATE')
|
||||
expect((error as Error).message).toContain(`${field} does not match assistant source`)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['number state', 1, 'expected an object'],
|
||||
['null state', null, 'expected an object'],
|
||||
['array state', [], 'expected an object'],
|
||||
['unknown kind', { ...validReplay, kind: 'other' }, 'unknown state kind'],
|
||||
['non-string api', { ...validReplay, api: 1 }, 'api must be a non-empty string'],
|
||||
['empty provider', { ...validReplay, provider: '' }, 'provider must be a non-empty string'],
|
||||
['missing model', { ...validReplay, model: undefined }, 'model must be a non-empty string'],
|
||||
['unknown stop reason', { ...validReplay, stopReason: 'pause' }, 'unknown stopReason'],
|
||||
['non-string response model', { ...validReplay, responseModel: 1 }, 'responseModel must be a string'],
|
||||
['non-string response id', { ...validReplay, responseId: 1 }, 'responseId must be a string'],
|
||||
['non-array blocks', { ...validReplay, blocks: 'text' }, 'blocks must be an array'],
|
||||
['number block', { ...validReplay, blocks: [1] }, 'block 0 must be an object'],
|
||||
['null block', { ...validReplay, blocks: [null] }, 'block 0 must be an object'],
|
||||
['array block', { ...validReplay, blocks: [[]] }, 'block 0 must be an object'],
|
||||
['unknown block type', { ...validReplay, blocks: [{ type: 'audio' }] }, 'block 0 has an unknown type'],
|
||||
['non-string signature', { ...validReplay, blocks: [{ type: 'text', textSignature: 1 }] }, 'textSignature must be a string'],
|
||||
['non-boolean redaction', { ...validReplay, blocks: [{ type: 'reasoning', redacted: 'yes' }] }, 'redacted must be boolean'],
|
||||
])('rejects malformed replay state: %s', (_name, replayState, message) => {
|
||||
expect(() => toPiContext({
|
||||
/** Convert with the given state and assert the message degraded to foreign with the given reason. */
|
||||
function expectDegraded(replayState: unknown, message: string): void {
|
||||
const onDegrade = vi.fn()
|
||||
const context = toPiContext({
|
||||
provider: 'deepseek',
|
||||
model: 'm',
|
||||
model: 'next-model',
|
||||
messages: [createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
@@ -538,7 +546,45 @@ describe('toPiContext', () => {
|
||||
...{ provider: 'deepseek', model: 'deepseek-v4-flash', replayState },
|
||||
},
|
||||
})],
|
||||
})).toThrow(message)
|
||||
}, undefined, onDegrade)
|
||||
expect(context.messages[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
api: 'dsh-foreign',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
})
|
||||
expect(onDegrade).toHaveBeenCalledWith(expect.stringContaining(message))
|
||||
}
|
||||
|
||||
it.each([
|
||||
['provider', { ...validReplay, response: { ...validResponse, provider: 'openai' } }],
|
||||
['model', { ...validReplay, response: { ...validResponse, model: 'deepseek-v4-pro' } }],
|
||||
])('degrades replay metadata whose %s differs from assistant source', (field, replayState) => {
|
||||
expectDegraded(replayState, `${field} does not match assistant source`)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['number state', 1, 'expected a replay envelope'],
|
||||
['null state', null, 'expected a replay envelope'],
|
||||
['array state', [], 'expected a replay envelope'],
|
||||
['missing response', { blocks: [] }, 'expected a response object'],
|
||||
['array response', { ...validReplay, response: [] }, 'expected a response object'],
|
||||
['unknown kind', { ...validReplay, response: { ...validResponse, kind: 'other' } }, 'unknown state kind'],
|
||||
['non-string api', { ...validReplay, response: { ...validResponse, api: 1 } }, 'api must be a non-empty string'],
|
||||
['empty provider', { ...validReplay, response: { ...validResponse, provider: '' } }, 'provider must be a non-empty string'],
|
||||
['missing model', { ...validReplay, response: { ...validResponse, model: undefined } }, 'model must be a non-empty string'],
|
||||
['unknown stop reason', { ...validReplay, response: { ...validResponse, stopReason: 'pause' } }, 'unknown stopReason'],
|
||||
['non-string response model', { ...validReplay, response: { ...validResponse, responseModel: 1 } }, 'responseModel must be a string'],
|
||||
['non-string response id', { ...validReplay, response: { ...validResponse, responseId: 1 } }, 'responseId must be a string'],
|
||||
['missing blocks', { response: validResponse }, 'blocks must be an array'],
|
||||
['non-array blocks', { ...validReplay, blocks: 'text' }, 'blocks must be an array'],
|
||||
['number block', { ...validReplay, blocks: [1] }, 'block 0 must be an object'],
|
||||
['null block', { ...validReplay, blocks: [null] }, 'block 0 must be an object'],
|
||||
['array block', { ...validReplay, blocks: [[]] }, 'block 0 must be an object'],
|
||||
['unknown block type', { ...validReplay, blocks: [{ type: 'audio' }] }, 'block 0 has an unknown type'],
|
||||
['non-string signature', { ...validReplay, blocks: [{ type: 'text', textSignature: 1 }] }, 'textSignature must be a string'],
|
||||
['non-boolean redaction', { ...validReplay, blocks: [{ type: 'reasoning', redacted: 'yes' }] }, 'redacted must be boolean'],
|
||||
])('degrades malformed replay state: %s', (_name, replayState, message) => {
|
||||
expectDegraded(replayState, message)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -565,12 +611,14 @@ describe('toStreamChunks', () => {
|
||||
type: 'finish',
|
||||
reason: { kind: 'stop' },
|
||||
replayState: {
|
||||
kind: 'pi-ai',
|
||||
version: 1,
|
||||
api: 'openai-completions',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
stopReason: 'stop',
|
||||
response: {
|
||||
kind: 'pi-ai',
|
||||
version: 2,
|
||||
api: 'openai-completions',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
stopReason: 'stop',
|
||||
},
|
||||
blocks: [{ type: 'text' }],
|
||||
},
|
||||
},
|
||||
@@ -614,12 +662,14 @@ describe('toStreamChunks', () => {
|
||||
type: 'finish',
|
||||
reason: { kind: 'tool-calls' },
|
||||
replayState: {
|
||||
kind: 'pi-ai',
|
||||
version: 1,
|
||||
api: 'openai-completions',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
stopReason: 'toolUse',
|
||||
response: {
|
||||
kind: 'pi-ai',
|
||||
version: 2,
|
||||
api: 'openai-completions',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
stopReason: 'toolUse',
|
||||
},
|
||||
blocks: [{ type: 'tool-call' }],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,13 +16,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Include from '@deepseek-ai/cordis-plugin-include'
|
||||
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
||||
import LlmRuntime, { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local'
|
||||
import FileSettingsProvider from '@deepseek-ai/dsh-settings-file'
|
||||
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import { assemble } from './assemble.ts'
|
||||
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
|
||||
|
||||
/** One text block, then a tool call truncated by the output-token ceiling. */
|
||||
const truncatedToolCallEvents = [
|
||||
'{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
|
||||
'{"choices":[{"delta":{"content":"partial"},"index":0,"finish_reason":null}]}',
|
||||
'{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"echo","arguments":"{\\"text\\":"}}]},"index":0,"finish_reason":null}]}',
|
||||
'{"choices":[{"delta":{},"index":0,"finish_reason":"length"}],"usage":{"prompt_tokens":3,"completion_tokens":4}}',
|
||||
'[DONE]',
|
||||
]
|
||||
|
||||
let root: string | undefined
|
||||
let context: Context | undefined
|
||||
|
||||
@@ -113,4 +122,123 @@ describe('llm-pi-ai real dormant composition', () => {
|
||||
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer key-from-store')
|
||||
})
|
||||
|
||||
it('continues natively after max-token assembly drops a tool call, with pruned replay metadata', async () => {
|
||||
vi.stubEnv('PI_COMPOSITION_KEY', '')
|
||||
const server = await mockServer([
|
||||
{ events: truncatedToolCallEvents },
|
||||
{ events: textEvents },
|
||||
])
|
||||
const { ctx, settingsPath } = await loadComposition()
|
||||
await writeFile(settingsPath, [
|
||||
'llm-pi-ai:',
|
||||
' providers:',
|
||||
' deepseek:',
|
||||
' apiKeyEnv: PI_COMPOSITION_KEY',
|
||||
` baseURL: ${server.url}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek'])
|
||||
}, { timeout: 5000 })
|
||||
|
||||
const truncated = await assemble(ctx, {
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [],
|
||||
})
|
||||
expect(truncated.finish).toEqual({ kind: 'max-tokens' })
|
||||
expect(truncated.message.content).toEqual([{ type: 'text', text: 'partial' }])
|
||||
expect(truncated.message.source).toEqual({
|
||||
kind: 'model',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
replayState: {
|
||||
response: {
|
||||
kind: 'pi-ai',
|
||||
version: 2,
|
||||
api: 'openai-completions',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
stopReason: 'length',
|
||||
},
|
||||
blocks: [{ type: 'text' }],
|
||||
},
|
||||
})
|
||||
|
||||
const continued = await assemble(ctx, {
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [
|
||||
truncated.message,
|
||||
createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }),
|
||||
],
|
||||
})
|
||||
expect(continued.message.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
expect(server.requests).toHaveLength(2)
|
||||
expect(server.requests[1]).toMatchObject({
|
||||
messages: [
|
||||
{ role: 'assistant', content: 'partial' },
|
||||
{ role: 'user', content: 'continue' },
|
||||
],
|
||||
})
|
||||
const followup = server.requests[1] as { messages?: unknown[] }
|
||||
expect(followup.messages?.[0]).not.toHaveProperty('tool_calls')
|
||||
})
|
||||
|
||||
it('continues a legacy session whose stored replay state no longer matches its content', async () => {
|
||||
vi.stubEnv('PI_COMPOSITION_KEY', '')
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const { ctx, settingsPath } = await loadComposition()
|
||||
await writeFile(settingsPath, [
|
||||
'llm-pi-ai:',
|
||||
' providers:',
|
||||
' deepseek:',
|
||||
' apiKeyEnv: PI_COMPOSITION_KEY',
|
||||
` baseURL: ${server.url}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek'])
|
||||
}, { timeout: 5000 })
|
||||
|
||||
// A pre-envelope session log entry: max-token assembly dropped the tool
|
||||
// call from content while the flat v1 state still describes both blocks.
|
||||
const poisoned = createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'partial' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
replayState: {
|
||||
kind: 'pi-ai',
|
||||
version: 1,
|
||||
api: 'openai-completions',
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
stopReason: 'length',
|
||||
blocks: [{ type: 'text' }, { type: 'tool-call' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const continued = await assemble(ctx, {
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [
|
||||
poisoned,
|
||||
createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }),
|
||||
],
|
||||
})
|
||||
expect(continued.finish).toEqual({ kind: 'stop' })
|
||||
expect(continued.message.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
expect(server.requests[0]).toMatchObject({
|
||||
messages: [
|
||||
{ role: 'assistant', content: 'partial' },
|
||||
{ role: 'user', content: 'continue' },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
import LlmRuntime, { 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'
|
||||
import type { PiAiReplayResponse } from '../src/replay.ts'
|
||||
import { assemble, type AssembledResult } from './assemble.ts'
|
||||
|
||||
interface ProviderCase {
|
||||
@@ -118,18 +118,20 @@ function expectFinish(result: AssembledResult, expected: 'stop' | 'tool-calls'):
|
||||
expect(result.finish.kind).toBe(expected)
|
||||
}
|
||||
|
||||
function expectNativeReplay(result: AssembledResult, profile: ProviderCase): PiAiReplayState {
|
||||
function expectNativeReplay(result: AssembledResult, profile: ProviderCase): PiAiReplayResponse {
|
||||
const replayState = result.message.source.kind === 'model'
|
||||
? result.message.source.replayState
|
||||
: undefined
|
||||
expect(replayState).toMatchObject({
|
||||
kind: 'pi-ai',
|
||||
version: 1,
|
||||
api: profile.api,
|
||||
provider: profile.provider,
|
||||
model: profile.model,
|
||||
response: {
|
||||
kind: 'pi-ai',
|
||||
version: 2,
|
||||
api: profile.api,
|
||||
provider: profile.provider,
|
||||
model: profile.model,
|
||||
},
|
||||
})
|
||||
return replayState as PiAiReplayState
|
||||
return (replayState as { response: PiAiReplayResponse }).response
|
||||
}
|
||||
|
||||
const lookupTool: ToolSchema = {
|
||||
|
||||
@@ -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: 2cae9a05a58295b06d25382729a3304dbdc9a6fa
|
||||
README.zh.md: 110cc128f1c19bef1741ae59d8106139c4a72649
|
||||
README.md: fb6bd84240b41dd730d45b3eb34c35827dc4c991
|
||||
README.zh.md: 5c22767a7c654972cbf614505382fa4755d7d318
|
||||
|
||||
@@ -53,7 +53,7 @@ Exact-model metadata is a separate correctness query, not a catalog decoration o
|
||||
|
||||
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 the provider and model that produced them plus optional adapter-private replay state. Before dispatch, `LlmRuntime` 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`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
|
||||
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). Every adapter outcome reaches consumers as one terminal `finish`; operational failure uses its `error` or `aborted` reason rather than throwing across the stream API. `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. A successful `finish` may carry a `ReplayEnvelope` — opaque response-level replay metadata plus optional per-block entries aligned with the emitted block sequence. Assembly makes one keep/drop decision for content and metadata together: a `max-tokens` finish drops tool calls that may have been truncated, and the envelope loses the entry at each dropped position, so stored metadata always describes stored content.
|
||||
|
||||
### Call configuration (`call-config.ts`)
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
消息内容是类型化内容块数组:`text`、`reasoning`、`tool-call`、`tool-result`。联合从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加块类型。assistant 消息使用模型来源,其中携带生成该消息的提供方和模型,以及可选的适配器私有回放状态。dispatch 前,`LlmRuntime` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型/提供方间恢复或转换该状态。核心块集只包含每条已发布路径都支持的块。多模态内容(图像、音频等)没有核心块类型;需要它的功能会通过 map 添加,并一并添加相应的适配器/UI/压缩(compaction)支持。
|
||||
|
||||
流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用 `error` 或 `aborted` 作为结束原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。
|
||||
流式输出是原始分片协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)。每个适配器结果都以一个终止 `finish` 到达消费方;运行故障使用 `error` 或 `aborted` 作为结束原因,而不会跨流 API 抛出。`BlockAssembler` 是将分片组装为块/消息的唯一共享实现。成功的 `finish` 可以携带 `ReplayEnvelope`——不透明的响应级回放元数据,加上与发射块序列对齐的可选逐块条目。组装对内容与元数据只做一次保留/丢弃决定:`max-tokens` 结束会丢弃可能被截断的工具调用,数据在每个被丢弃的位置同步失去对应条目,因此存储的元数据始终描述存储的内容。
|
||||
|
||||
### 调用配置(`call-config.ts`)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { CallId } from './brand.ts'
|
||||
import { assertNever } from './never.ts'
|
||||
import { createMessage } from './message.ts'
|
||||
import type { Message, MessageSource } from './message.ts'
|
||||
import type { ContentBlock, FinishReason, StreamChunk, TokenUsage } from './types.ts'
|
||||
import type { ContentBlock, FinishReason, ReplayEnvelope, StreamChunk, TokenUsage } from './types.ts'
|
||||
|
||||
interface PartialBlock {
|
||||
blockType: string
|
||||
@@ -38,7 +38,7 @@ export class BlockAssembler {
|
||||
private order: number[] = []
|
||||
private _usage: TokenUsage | undefined
|
||||
private _finish: FinishReason | undefined
|
||||
private _replayState: unknown = undefined
|
||||
private _replayState: ReplayEnvelope | undefined
|
||||
|
||||
/**
|
||||
* Feed one chunk into the assembly state.
|
||||
@@ -125,6 +125,28 @@ export class BlockAssembler {
|
||||
return partial
|
||||
}
|
||||
|
||||
/**
|
||||
* The one shared keep/drop decision over all seen blocks: max-token
|
||||
* truncation drops tool calls that cannot be executed safely. Emitted blocks
|
||||
* and replay metadata both derive from this result, so they cannot disagree.
|
||||
*/
|
||||
private assembled(): { blocks: ContentBlock[]; replay: ReplayEnvelope | undefined } {
|
||||
const all = this.order.map(index => this.assemble(this.mustGet(index), index))
|
||||
const kept = this.finish.kind === 'max-tokens'
|
||||
? all.map(block => block.type !== 'tool-call')
|
||||
: undefined
|
||||
const blocks = kept === undefined ? all : all.filter((_, position) => kept[position])
|
||||
const envelope = this._replayState
|
||||
if (envelope?.blocks === undefined) return { blocks, replay: envelope }
|
||||
if (envelope.blocks.length !== all.length) return { blocks, replay: undefined }
|
||||
return {
|
||||
blocks,
|
||||
replay: kept === undefined || blocks.length === all.length
|
||||
? envelope
|
||||
: { response: envelope.response, blocks: envelope.blocks.filter((_, position) => kept[position]) },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble all blocks seen so far, in stream order.
|
||||
* @returns one block per seen index, except that max-token truncation drops
|
||||
@@ -132,10 +154,7 @@ export class BlockAssembler {
|
||||
* its accumulated deltas (an unknown block type never closed by `block-end` throws).
|
||||
*/
|
||||
blocks(): ContentBlock[] {
|
||||
const blocks = this.order.map(index => this.assemble(this.mustGet(index), index))
|
||||
return this.finish.kind === 'max-tokens'
|
||||
? blocks.filter(block => block.type !== 'tool-call')
|
||||
: blocks
|
||||
return this.assembled().blocks
|
||||
}
|
||||
|
||||
/** Usage from the `usage` chunk; undefined until one arrives. */
|
||||
@@ -148,9 +167,13 @@ export class BlockAssembler {
|
||||
return this._finish ?? { kind: 'stop' }
|
||||
}
|
||||
|
||||
/** Adapter-private replay state from the terminal finish chunk, if any. */
|
||||
get replayState(): unknown {
|
||||
return this._replayState
|
||||
/**
|
||||
* Replay metadata from the terminal finish chunk, if any, with per-block
|
||||
* entries pruned in step with {@link blocks}. Undefined when the envelope's
|
||||
* entries do not align with the emitted blocks.
|
||||
*/
|
||||
get replayState(): ReplayEnvelope | undefined {
|
||||
return this.assembled().replay
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -280,6 +280,27 @@ export interface LlmResolvedModelInfo extends LlmModelInfo {
|
||||
reasoning?: LlmModelReasoningInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter-private lossless-JSON state for replaying a successful response,
|
||||
* carried by a terminal `finish` chunk and stored on the assembled assistant
|
||||
* message's model source. Both halves stay opaque to the harness; only the
|
||||
* split is shared vocabulary, so assembly can keep stored metadata aligned
|
||||
* with stored content without reading either half.
|
||||
*/
|
||||
export interface ReplayEnvelope {
|
||||
/** Response-level adapter-private metadata (ids, native stop reason). */
|
||||
response: unknown
|
||||
/**
|
||||
* Per-block adapter-private metadata, one entry per emitted block in
|
||||
* first-seen stream order. When assembly drops a block it drops the entry at
|
||||
* the same position; entries whose length does not match the emitted block
|
||||
* count discard the whole envelope. An adapter whose metadata is independent
|
||||
* of block structure omits this field and the envelope passes through
|
||||
* assembly unchanged.
|
||||
*/
|
||||
blocks?: readonly unknown[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw streaming protocol emitted by adapters.
|
||||
* Block indexes correlate interleaved deltas, and `block-end` carries the
|
||||
@@ -298,8 +319,8 @@ export type StreamChunk =
|
||||
| {
|
||||
type: 'finish'
|
||||
reason: FinishReason
|
||||
/** Adapter-private lossless-JSON state for replaying a successful response. */
|
||||
replayState?: unknown
|
||||
/** Replay metadata for a successful response; see {@link ReplayEnvelope}. */
|
||||
replayState?: ReplayEnvelope
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -118,6 +118,85 @@ describe('BlockAssembler', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BlockAssembler replay metadata', () => {
|
||||
const response = { responseId: 'resp-1' }
|
||||
|
||||
it('prunes per-block replay entries with the tool calls a max-tokens finish drops', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'lead' } })
|
||||
assembler.push({
|
||||
type: 'block-end',
|
||||
index: 1,
|
||||
block: { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{"text":' },
|
||||
})
|
||||
assembler.push({ type: 'block-end', index: 2, block: { type: 'reasoning', text: 'tail' } })
|
||||
assembler.push({
|
||||
type: 'finish',
|
||||
reason: { kind: 'max-tokens' },
|
||||
replayState: { response, blocks: ['meta-0', 'meta-1', 'meta-2'] },
|
||||
})
|
||||
|
||||
expect(assembler.blocks()).toEqual([
|
||||
{ type: 'text', text: 'lead' },
|
||||
{ type: 'reasoning', text: 'tail' },
|
||||
])
|
||||
expect(assembler.replayState).toEqual({ response, blocks: ['meta-0', 'meta-2'] })
|
||||
})
|
||||
|
||||
it('omits replay metadata whose per-block entries misalign with the emitted blocks', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'one' } })
|
||||
assembler.push({ type: 'block-end', index: 1, block: { type: 'text', text: 'two' } })
|
||||
assembler.push({
|
||||
type: 'finish',
|
||||
reason: { kind: 'stop' },
|
||||
replayState: { response, blocks: ['meta-0'] },
|
||||
})
|
||||
|
||||
expect(assembler.blocks()).toHaveLength(2)
|
||||
expect(assembler.replayState).toBeUndefined()
|
||||
})
|
||||
|
||||
it('passes replay metadata through unchanged when assembly drops nothing', () => {
|
||||
const replayState = { response, blocks: ['meta-0', 'meta-1'] }
|
||||
const assembler = new BlockAssembler()
|
||||
assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'partial' } })
|
||||
assembler.push({
|
||||
type: 'block-end',
|
||||
index: 1,
|
||||
block: { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' },
|
||||
})
|
||||
assembler.push({ type: 'finish', reason: { kind: 'tool-calls' }, replayState })
|
||||
|
||||
expect(assembler.replayState).toBe(replayState)
|
||||
})
|
||||
|
||||
it('keeps a max-tokens replay state with no per-block entries across a tool-call drop', () => {
|
||||
const replayState = { response }
|
||||
const assembler = new BlockAssembler()
|
||||
assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'partial' } })
|
||||
assembler.push({
|
||||
type: 'block-end',
|
||||
index: 1,
|
||||
block: { type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{"text":' },
|
||||
})
|
||||
assembler.push({ type: 'finish', reason: { kind: 'max-tokens' }, replayState })
|
||||
|
||||
expect(assembler.blocks()).toEqual([{ type: 'text', text: 'partial' }])
|
||||
expect(assembler.replayState).toBe(replayState)
|
||||
})
|
||||
|
||||
it('keeps a text-only max-tokens response and its replay metadata intact', () => {
|
||||
const replayState = { response, blocks: ['meta-0'] }
|
||||
const assembler = new BlockAssembler()
|
||||
assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'partial' } })
|
||||
assembler.push({ type: 'finish', reason: { kind: 'max-tokens' }, replayState })
|
||||
|
||||
expect(assembler.blocks()).toEqual([{ type: 'text', text: 'partial' }])
|
||||
expect(assembler.replayState).toBe(replayState)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertNever', () => {
|
||||
it('throws with diagnostics when a value escapes a closed union at runtime', async () => {
|
||||
const { assertNever } = await import('@deepseek-ai/dsh-llm')
|
||||
|
||||
Reference in New Issue
Block a user