Просмотр исходного кода

fix: restore message migration CI coverage

_Kerman 1 месяц назад
Родитель
Сommit
971f20b66e

+ 2 - 2
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,

+ 6 - 6
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)
 }

+ 18 - 0
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',

+ 2 - 2
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

+ 1 - 1
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.
 

+ 1 - 1
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/压缩实现。
 

+ 4 - 0
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"
   },

+ 5 - 0
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.