diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index e548a71462..63bc318051 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -9,8 +9,8 @@ import { createAssistantMessage, createToolResultMessage, createUserMessage, - CallId, -} from '@deepseek-ai/dsh-llm' +} from '@deepseek-ai/dsh-llm/message' +import { CallId } from '@deepseek-ai/dsh-llm/brand' import type { AssistantMessage, ContentBlock, diff --git a/packages/llm/llm-pi-ai/src/replay.ts b/packages/llm/llm-pi-ai/src/replay.ts index 4362ccd24f..dc8d8443ee 100644 --- a/packages/llm/llm-pi-ai/src/replay.ts +++ b/packages/llm/llm-pi-ai/src/replay.ts @@ -9,7 +9,7 @@ */ import { LlmError } from '@deepseek-ai/dsh-llm' -import type { Message } from '@deepseek-ai/dsh-llm' +import type { Message, ModelMessageSource } from '@deepseek-ai/dsh-llm' import type { Api, AssistantMessage, Usage as PiUsage } from '@earendil-works/pi-ai' type PiAiReplayBlock = @@ -155,10 +155,8 @@ function foreignAssistant(message: Message): AssistantMessage { } /** Recombine durable Harness content with validated pi-ai replay metadata. */ -function replayedAssistant(message: Message, rawState: unknown): AssistantMessage { +function replayedAssistant(message: Message, source: ModelMessageSource, rawState: unknown): AssistantMessage { const state = readReplayState(rawState) - 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') @@ -208,6 +206,8 @@ function replayedAssistant(message: Message, rawState: unknown): AssistantMessag * @returns a native pi-ai assistant message reconstructed from durable content. */ export function toPiAssistant(message: Message): AssistantMessage { - const replayState = message.source.kind === 'model' ? message.source.replayState : undefined - return replayState === undefined ? foreignAssistant(message) : replayedAssistant(message, replayState) + const source = message.source + return source.kind !== 'model' || source.replayState === undefined + ? foreignAssistant(message) + : replayedAssistant(message, source, source.replayState) } diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 44b15d2297..d125578b7f 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -102,6 +102,24 @@ describe('toPiContext', () => { expect((context.messages[0] as AssistantMessage).stopReason).toBe('stop') }) + it('preserves model provenance for foreign assistant messages without replay state', () => { + const context = toPiContext({ + provider: 'openai', + model: 'new-model', + messages: [createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'done' }], + source: { kind: 'model', provider: 'deepseek', model: 'old-model' }, + })], + }) + expect(context.messages[0]).toMatchObject({ + role: 'assistant', + api: 'dsh-foreign', + provider: 'deepseek', + model: 'old-model', + }) + }) + it('parses malformed tool-call arguments to {}', () => { const context = toPiContext({ provider: 'deepseek', diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 585b166147..a4716026cd 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/README.i18n.yaml @@ -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: 7c34d5621d6ac644aaac17169f38709147ac1dd5 -README.zh.md: 1d2475272640162beab425ac91fc93dd27b53b63 +README.md: d343449d1530bf70a3a8c57f883894e29c42d18f +README.zh.md: 6ac57b1e6010b58c45b516f13ec6361d47ca8d12 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 7c34d5621d..d343449d15 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -40,7 +40,7 @@ Reasoning identifiers are opaque adapter-owned strings rather than a core enum. ### Messages (`message.ts`) and content blocks (`types.ts`) -`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` 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. Browser code imports these value constructors from the dependency-minimal `@deepseek-ai/dsh-llm/message` entry instead of the service-bearing package root. 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. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index 1d24752726..6ac57b1e60 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -40,7 +40,7 @@ ### 消息(`message.ts`)与内容块(`types.ts`) -`Message` 是投递、持久历史和模型请求共享的不可变值。每条消息从创建起都必须具有 `MessageId`、角色、内容和带类型的来源。`createMessage(input)` 生成标识,并返回与输入分离且深度冻结的值;`createUserMessage({ content, source })` 固定 user 角色;`createAssistantMessage({ content, source })` 固定 assistant 角色与模型来源类别;`createToolResultMessage({ callId, content, isError })` 固定 user 角色,并将工具来源与其结果块耦合;`freezeMessage(message)` 导入已有标识,绝不将其替换。改写消息时会保留标识,并产生另一个冻结值。 +`Message` 是投递、持久历史和模型请求共享的不可变值。每条消息从创建起都必须具有 `MessageId`、角色、内容和带类型的来源。`createMessage(input)` 生成标识,并返回与输入分离且深度冻结的值;`createUserMessage({ content, source })` 固定 user 角色;`createAssistantMessage({ content, source })` 固定 assistant 角色与模型来源类别;`createToolResultMessage({ callId, content, isError })` 固定 user 角色,并将工具来源与其结果块耦合;`freezeMessage(message)` 导入已有标识,绝不将其替换。改写消息时会保留标识,并产生另一个冻结值。浏览器端代码会从依赖最少的 `@deepseek-ai/dsh-llm/message` 入口导入这些值构造函数,而不是从包含服务的包根入口导入。 消息内容是类型化内容块数组:`text`、`reasoning`、`tool-call`、`tool-result`。联合从可合并扩展的 `ContentBlockMap` 派生,因此插件可以通过 declaration merging 添加块类型。assistant 消息使用模型来源,其中携带提供方/模型溯源与可选适配器私有回放状态。dispatch 前,`LlmService` 只在历史提供方路由与目标提供方路由当前由完全相同的适配器实例拥有时才保留该状态;随后由适配器判定能否在模型/提供方间恢复或转换该状态。核心块集只包含每条已发布路径都支持的块。多模态内容(图像、音频等)没有核心块类型;需要它的功能会通过 map 添加,并一并添加支持它的适配器/UI/压缩实现。 diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index d5b0298e50..7adb8e41d9 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -23,6 +23,10 @@ "types": "./lib/types/brand.d.ts", "default": "./lib/types/brand.js" }, + "./message": { + "types": "./lib/types/message.d.ts", + "default": "./lib/types/message.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 4236bd153c..5be755b9e5 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2643,6 +2643,11 @@ describe('pi-tui chat lifecycle and transcript', () => { // A foreign agent's discard leaves the wrapper armed. const foreign = { ...result.agent, id: SessionId('foreign') } as unknown as Agent result.ctx.emit('agent/inbox/discard', foreign, [result.agent.sentMessages.at(-1)!]) + // An unrelated discard for this agent also leaves the wrapper armed. + result.ctx.emit('agent/inbox/discard', result.agent, [createUserMessage({ + content: [{ type: 'text', text: 'unrelated discard' }], + source: { kind: 'user' }, + })]) result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages.at(-1)!]) await tick() // Idempotent: a repeat discard after cleanup is a no-op.