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

fix(llm): degrade unusable Messages replay metadata

Yichen Jiang 1 неделя назад
Родитель
Сommit
74ef3f0510

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.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 .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md
-2026-09-07-deepseek-messages-adapter.md: f0fcaf96264bd70648c59cfd3de05c1edbda40aa
-2026-09-07-deepseek-messages-adapter.zh.md: 4cb2ef79eb8596955f76d4bda024926c3870c33d
+2026-09-07-deepseek-messages-adapter.md: 9cf61a2c5e650479b025d50f96cda0cb3bcbe6ba
+2026-09-07-deepseek-messages-adapter.zh.md: 3fdf991051586e8df5c04d4691b080de2213c7b5

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md

@@ -14,7 +14,7 @@ The [Messages adapter](../../../../packages/llm/llm-deepseek-messages/README.md)
 
 The adapter follows the [DeepSeek compatibility documentation](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) and [Anthropic streaming protocol](https://platform.claude.com/docs/en/build-with-claude/streaming). The pi-ai Anthropic implementation informed the handling of adjacent user messages, cumulative usage, fragmented tool arguments, and optional thinking signatures. DeepSeek effort uses `output_config.effort`; an Anthropic thinking token budget does not control DeepSeek effort.
 
-Assistant blocks remain the durable model-visible content. A versioned `ReplayEnvelope` stores only the model identity, aligned block kinds, and signatures absent from those blocks. Same-model continuation restores signatures verbatim, including empty signatures; foreign history carries no invented signature. This keeps provider replay data opaque to the loop while preserving it through Session persistence and block pruning.
+Assistant blocks remain the durable model-visible content. A versioned `ReplayEnvelope` stores only the model identity, aligned block kinds, and signatures absent from those blocks. Same-model continuation restores signatures verbatim, including empty signatures; foreign history carries no invented signature. Unusable metadata follows the existing [replay degradation rule](../architecture/2026-07-14-provider-routed-llm-adapters.md): the request omits signatures with a warning while preserving durable content; content validation such as tool argument parsing still fails explicitly. This keeps provider replay data opaque to the loop while preserving it through Session persistence and block pruning.
 
 Image requests use bounded inline base64 versions from the attachment service. Shared attachment offload and DeepSeek token measurement keep request and measurement policy consistent. Files uploads remain outside this adapter because their endpoints and cache ownership differ from chat-completions; adding them requires a Messages-specific lifetime and error policy.
 
@@ -32,4 +32,4 @@ The Web profile selects the Messages adapter and disables the Chat Completions a
 
 The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. Unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. The shared base keeps Chat Completions; the Web overlay owns the Messages default.
 
-Verification covers wire fixtures, real Loader composition, per-file unit coverage, a [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml), a Web Messages Session replay, and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy.
+Verification covers wire fixtures, real Loader composition, per-file unit coverage, [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml) with [unknown replay versions](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml), a Web Messages Session replay, and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy.

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 适配器遵循 [DeepSeek 兼容文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) 和 [Anthropic 流协议](https://platform.claude.com/docs/en/build-with-claude/streaming)。pi-ai 的 Anthropic 实现为相邻用户消息、累计用量、工具参数分片和可选思考签名的处理提供参考。DeepSeek 通过 `output_config.effort` 设置思考强度;Anthropic 思考 token 预算不控制 DeepSeek 思考强度。
 
-助手内容块保留持久化的模型可见内容。带版本的 `ReplayEnvelope` 仅保存模型标识、对齐的块类型以及内容块未包含的签名。同模型续接原样恢复签名,包括空签名;外部历史不生成虚构签名。提供者回放数据对循环保持不透明,同时能够随 Session 持久化和内容块裁剪保留。
+助手内容块保留持久化的模型可见内容。带版本的 `ReplayEnvelope` 仅保存模型标识、对齐的块类型以及内容块未包含的签名。同模型续接原样恢复签名,包括空签名;外部历史不生成虚构签名。不可用的元数据遵循现有[回放降级规则](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):请求省略签名并记录警告,保留持久化内容;工具参数等内容校验仍会正常报错。提供者回放数据对循环保持不透明,同时能够随 Session 持久化和内容块裁剪保留。
 
 图片请求使用附件服务生成的、有预算限制的内联 base64 版本。共享附件卸载机制和 DeepSeek token 计量使请求与计量策略保持一致。此适配器不负责 Files 上传,因为其端点和缓存所有权与 chat-completions 不同;增加上传支持需要定义 Messages 专属的生命周期和错误策略。
 
@@ -32,4 +32,4 @@ Web profile 选择 Messages 适配器并禁用 Chat Completions 适配器。配
 
 该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。共享 base 保留 Chat Completions,Web 覆盖层拥有 Messages 默认值。
 
-验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml),Web Messages Session 回放,以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。
+验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml)与[未知回放版本](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml),Web Messages Session 回放,以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。

+ 2 - 2
packages/llm/llm-deepseek-messages/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-deepseek-messages/README.md
-README.md: 4ff26629899b4f03c64e782cb82ddaf7928b1866
-README.zh.md: 07825fa8b2e9b8534e8f7a2a7d2bd89c8423be09
+README.md: 4a33688e101433220879210b33853a149302f7eb
+README.zh.md: 384a82384a8a6ac5126f09dce46489f57e3e0444

+ 1 - 1
packages/llm/llm-deepseek-messages/README.md

@@ -61,7 +61,7 @@ Tools use native `tool_use` and `tool_result` blocks. Adjacent user messages are
 
 Vision routes resolve durable attachments into deterministic request images with a default 640000-pixel and 1 MiB target per image. Oldest-first offload applies before normalization using durable bytes and again using exact encoded bytes. Each retained image carries the standard attachment descriptor; tool-result images remain inside that result. Unlisted and text-only routes receive the shared text-only attachment projection.
 
-Successful streams persist minimal native replay metadata alongside standard assistant blocks. Own-model signatures return unchanged, including signatures on empty thinking blocks. Cross-model or foreign history sends thinking without borrowed signatures. Malformed native replay fails with `INVALID_REPLAY_STATE`; malformed complete tool JSON is rejected, while max-token truncation lets the shared assembler remove unfinished tool calls.
+Successful streams persist minimal native replay metadata alongside standard assistant blocks. Own-model signatures return unchanged, including signatures on empty thinking blocks. Cross-model or foreign history sends thinking without borrowed signatures. Unusable native replay metadata is omitted with a warning; durable content remains unchanged and thinking is sent without signatures. Malformed complete tool JSON is rejected, while max-token truncation lets the shared assembler remove unfinished tool calls.
 
 Transport and provider failures become stable `LlmError` codes, including `AUTH`, `QUOTA`, `RATE_LIMIT`, `CONTEXT_WINDOW_EXCEEDED`, `INVALID_REQUEST`, `SERVER`, `TIMEOUT`, and `ABORTED`. Missing credentials fail before HTTP. EOF before `message_stop` is `STREAM_CLOSED`. `llm-retry` owns retries; the adapter never retries a model request itself.
 

+ 1 - 1
packages/llm/llm-deepseek-messages/README.zh.md

@@ -61,7 +61,7 @@ kind: "package-reference"
 
 视觉路由将持久化附件转换为确定性的请求图片,单图默认目标为 640000 像素和 1 MiB。规范化前按持久化字节数移除最旧图片,随后按实际编码字节数再次检查。保留的图片带有标准附件描述;工具结果中的图片保留在该结果内部。未列入目录和纯文本路由使用共享的附件文本投影。
 
-成功响应在标准 assistant 内容块旁持久化最小原生回放元数据。同模型签名原样回传,包括空思考块的签名。跨模型或外部历史发送思考文本但不借用签名。无效原生回放以 `INVALID_REPLAY_STATE` 失败;完整响应中损坏的工具 JSON 会被拒绝,达到 token 上限的截断则由共享组装器移除未完成工具调用。
+成功响应在标准 assistant 内容块旁持久化最小原生回放元数据。同模型签名原样回传,包括空思考块的签名。跨模型或外部历史发送思考文本但不借用签名。不可用的原生回放元数据会被忽略并记录警告;持久化内容保持不变,思考文本不附带签名。完整响应中损坏的工具 JSON 会被拒绝,达到 token 上限的截断则由共享组装器移除未完成工具调用。
 
 传输和服务端错误映射为稳定的 `LlmError` 代码,包括 `AUTH`、`QUOTA`、`RATE_LIMIT`、`CONTEXT_WINDOW_EXCEEDED`、`INVALID_REQUEST`、`SERVER`、`TIMEOUT` 和 `ABORTED`。缺少凭据在 HTTP 前失败。`message_stop` 之前断流会得到 `STREAM_CLOSED`。重试由 `llm-retry` 负责,适配器自身不重试模型请求。
 

+ 5 - 1
packages/llm/llm-deepseek-messages/src/adapter.ts

@@ -24,6 +24,8 @@ export interface AdapterDependencies {
   attachments(): AttachmentStore | undefined
   /** Current execution-world attachment path. */
   imageAccess: ImageAttachmentAccessResolver
+  /** Report discarded replay metadata without exposing durable content or signatures. */
+  onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void
 }
 
 /** DeepSeek provider using Messages content and native thinking replay. */
@@ -81,7 +83,9 @@ export class DeepSeekMessagesAdapter extends LlmAdapter {
     const { messages, versions } = await prepareImages(
       options.messages, connection, options.model, this.dependencies.attachments(), this.dependencies.imageAccess, signal,
     )
-    const body = serialize(options, connection, messages, versions, this.dependencies.imageAccess)
+    const body = serialize(options, connection, messages, versions, this.dependencies.imageAccess, (reason) => {
+      this.dependencies.onReplayDegrade?.({ provider: options.provider, model: options.model, reason })
+    })
     const key = await this.dependencies.apiKey(connection)
     signal.throwIfAborted()
     const response = await fetch(`${connection.baseURL}/v1/messages`, {

+ 3 - 0
packages/llm/llm-deepseek-messages/src/index.ts

@@ -37,6 +37,9 @@ export function apply(ctx: Context, config: Config): void {
   let userId: string | undefined
   const adapter = new DeepSeekMessagesAdapter({
     connection,
+    onReplayDegrade: ({ provider, model, reason }) => {
+      ctx.logger.warn(`llm-deepseek-messages: unusable replay state on assistant history for route "${provider}/${model}"; sending that message as provider-neutral content (${reason})`)
+    },
     async apiKey(snapshot) {
       const credentials = ctx.get('credentials')
       const value = credentials === undefined

+ 13 - 3
packages/llm/llm-deepseek-messages/src/replay.ts

@@ -30,12 +30,22 @@ export function replayState(model: string, blocks: ReplayBlock[]): ReplayEnvelop
   return { response: { kind: 'deepseek-messages', version: 1, model }, blocks }
 }
 
-/** Validate native replay before using any signature on the wire.
+/** Validate native replay, discarding unusable metadata before serializing durable content.
  * @param message - durable assistant content and provenance.
  * @param model - target model; cross-model signatures are not portable.
- * @returns index-aligned metadata, absent for foreign or cross-model history.
+ * @param onDegrade - diagnostic for unusable metadata; receives no message content or signatures.
+ * @returns index-aligned metadata, absent for foreign, cross-model or degraded history.
  */
-export function readReplay(message: Message, model: string): ReplayBlock[] | undefined {
+export function readReplay(message: Message, model: string, onDegrade?: (reason: string) => void): ReplayBlock[] | undefined {
+  try { return validateReplay(message, model) } catch (error) {
+    /* v8 ignore next -- the validator only throws INVALID_REPLAY_STATE; preserve future non-replay failures. */
+    if (!(error instanceof LlmError) || error.code !== 'INVALID_REPLAY_STATE') throw error
+    onDegrade?.(error.message)
+    return undefined
+  }
+}
+
+function validateReplay(message: Message, model: string): ReplayBlock[] | undefined {
   if (message.source.kind !== 'model' || message.source.replayState === undefined) return undefined
   const fail = (detail: string): never => { throw new LlmError(`DeepSeek Messages replay: ${detail}`, 'INVALID_REPLAY_STATE') }
   const envelope = object(message.source.replayState, 'INVALID_REPLAY_STATE')

+ 5 - 3
packages/llm/llm-deepseek-messages/src/serialize.ts

@@ -20,8 +20,8 @@ function toolInput(raw: string): Record<string, unknown> {
   return object(value, 'INVALID_REQUEST')
 }
 
-function assistant(message: Message, model: string): WireBlock[] {
-  const replay = readReplay(message, model)
+function assistant(message: Message, model: string, onReplayDegrade?: (reason: string) => void): WireBlock[] {
+  const replay = readReplay(message, model, onReplayDegrade)
   return message.content.map((block, index): WireBlock => {
     switch (block.type) {
       case 'text': return { type: 'text', text: block.text }
@@ -41,11 +41,13 @@ function assistant(message: Message, model: string): WireBlock[] {
  * @param history - image-projected history, still ordered by durable occurrence.
  * @param images - request versions for retained images.
  * @param access - execution-world paths for image descriptions.
+ * @param onReplayDegrade - diagnostic for discarded native replay metadata.
  * @returns the Messages API JSON body.
  */
 export function serialize(
   options: GenerateOptions, connection: Connection, history: readonly Message[],
   images: ReadonlyMap<ImageAttachmentRef['attachmentId'], RequestImageAttachment>, access: ImageAttachmentAccessResolver,
+  onReplayDegrade?: (reason: string) => void,
 ): WireRequest {
   const input = (blocks: readonly ContentBlock[]): WireInput[] => blocks.flatMap((block): WireInput[] => {
     if (block.type === 'text') return block.text ? [{ type: 'text', text: block.text }] : []
@@ -66,7 +68,7 @@ export function serialize(
       system.push(texts.map(block => block.text).join(''))
       continue
     }
-    const content: WireBlock[] = message.role === 'assistant' ? assistant(message, options.model) : message.content.flatMap((block): WireBlock[] => {
+    const content: WireBlock[] = message.role === 'assistant' ? assistant(message, options.model, onReplayDegrade) : message.content.flatMap((block): WireBlock[] => {
       if (block.type !== 'tool-result') return input([block])
       return [{ type: 'tool_result', tool_use_id: block.toolCallId, content: input(block.content), ...block.isError === undefined ? {} : { is_error: block.isError } }]
     })

+ 22 - 1
packages/llm/llm-deepseek-messages/tests/adapter.e2e.ts

@@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { Context } from '@deepseek-ai/cordis'
+import { Context, LoggerLevel } from '@deepseek-ai/cordis'
 import LocalAttachments from '@deepseek-ai/dsh-attachment-local'
 import LlmRuntime, { createToolResultMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
 import type { Message } from '@deepseek-ai/dsh-llm'
@@ -82,4 +82,25 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', ()
     expect(cancelled).toBe(true)
     expect(finish).toMatchObject({ kind: 'aborted' })
   })
+
+  it('continues a thinking/tool turn after degrading unusable persisted replay metadata', async () => {
+    const ctx = await boot()
+    const warnings: unknown[][] = []
+    ctx.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } })
+    const history: Message[] = [user('Use lookup_value with key secret. Then reply with the exact tool result and stop.')]
+    const request = options({ messages: history, tools: [tool], reasoningEffort: ReasoningEffortId('high') })
+    const first = await assemble(ctx.llm.stream(request))
+    expect(first.assembler.finish.kind).toBe('tool-calls')
+    const calls = first.message.content.filter(block => block.type === 'tool-call')
+    expect(calls).toHaveLength(1)
+    const restored = JSON.parse(JSON.stringify(first.message)) as typeof first.message
+    restored.source.replayState = { response: { kind: 'deepseek-messages', version: 2 }, blocks: [] }
+    const saved = JSON.stringify(restored)
+    history.push(restored, ...calls.map(call => createToolResultMessage({ callId: call.id, content: [{ type: 'text', text: 'REPLAY_RECOVERED_731' }], isError: false })))
+    const second = await assemble(ctx.llm.stream({ ...request, messages: history }))
+    expect(second.assembler.finish.kind).toBe('stop')
+    expect(second.message.content.filter(block => block.type === 'text').map(block => block.text).join('')).toContain('REPLAY_RECOVERED_731')
+    expect(warnings).toEqual([[expect.stringContaining('unsupported kind or version')]])
+    expect(JSON.stringify(restored)).toBe(saved)
+  })
 })

+ 41 - 2
packages/llm/llm-deepseek-messages/tests/adapter.spec.ts

@@ -4,12 +4,13 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { Context, Service } from '@deepseek-ai/cordis'
+import { Context, LoggerLevel, Service } from '@deepseek-ai/cordis'
 import LocalAttachments from '@deepseek-ai/dsh-attachment-local'
 import { AttachmentId } from '@deepseek-ai/dsh-attachment'
 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, { createAssistantMessage } from '@deepseek-ai/dsh-llm'
+import type { Message } from '@deepseek-ai/dsh-llm'
 import { credentialRef } from '@deepseek-ai/dsh-credentials'
 import LocalCredentials from '@deepseek-ai/dsh-credentials-local'
 import FileSettings from '@deepseek-ai/dsh-settings-file'
@@ -39,6 +40,20 @@ async function context() {
 }
 
 describe('direct Messages HTTP', () => {
+  it('continues without a diagnostic callback when replay metadata is unusable', async () => {
+    const http = await endpoint()
+    const message = createAssistantMessage({ content: [{ type: 'text', text: 'Remember 731.' }], source: {
+      provider: 'deepseek-messages', model: MODEL, replayState: { response: {}, blocks: [] },
+    } })
+    const response = await assemble(adapter({ baseURL: http.url }).stream(options({ messages: [user(), message, user()] })))
+    expect(response.assembler.finish.kind).toBe('stop')
+    expect(http.requests[0]?.body.messages).toEqual([
+      { role: 'user', content: [{ type: 'text', text: 'hello' }] },
+      { role: 'assistant', content: [{ type: 'text', text: 'Remember 731.' }] },
+      { role: 'user', content: [{ type: 'text', text: 'hello' }] },
+    ])
+  })
+
   it('uses the Messages endpoint, authentication, attribution and final usage', async () => {
     const http = await endpoint()
     const llm = adapter({ baseURL: http.url })
@@ -155,6 +170,30 @@ describe('Cordis provider composition', () => {
     return { ctx, http }
   }
 
+  it('continues a recorded tool turn with a warning when its native replay version is unknown', async () => {
+    const { ctx, http } = await boot()
+    const warnings: unknown[][] = []
+    ctx.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } })
+    const fixture = await readFile(new URL('../../../../snapshots/session/deepseek-messages-degraded-replay/session.v2.jsonl', import.meta.url), 'utf8')
+    const records = fixture.trim().split('\n').map(line => JSON.parse(line) as { type: string; data: { message?: Message } })
+    const assistant = records.find(record => record.type === 'assistant/message')!.data.message!
+    const result = records.find(record => record.type === 'tool/result')!.data.message!
+    const saved = JSON.stringify([assistant, result])
+    const response = await assemble(ctx.llm.stream(options({ messages: [user(), assistant, result] })))
+    expect(response.assembler.finish.kind).toBe('stop')
+    expect(warnings).toEqual([[`llm-deepseek-messages: unusable replay state on assistant history for route "deepseek-messages/${MODEL}"; sending that message as provider-neutral content (DeepSeek Messages replay: unsupported kind or version)`]])
+    expect(http.requests).toHaveLength(1)
+    expect(http.requests[0]?.body.messages).toEqual([
+      { role: 'user', content: [{ type: 'text', text: 'hello' }] },
+      { role: 'assistant', content: [
+        { type: 'thinking', thinking: 'The user wants me to run a simple bash command and then reply with "DONE".' },
+        { type: 'tool_use', id: 'call_00_fkbBRJsUrGKd1pWVc4Gn8233', name: 'bash', input: { command: 'echo TERMINAL_OK', description: 'Echo TERMINAL_OK to verify terminal access' } },
+      ] },
+      { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_00_fkbBRJsUrGKd1pWVc4Gn8233', content: [{ type: 'text', text: 'TERMINAL_OK\n' }], is_error: false }] },
+    ])
+    expect(JSON.stringify([assistant, result])).toBe(saved)
+  })
+
   it('loads both routes from YAML, rotates settings and credentials, then removes disposed registrations', async () => {
     const { ctx, http } = await boot()
     expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(expect.arrayContaining(['deepseek-official', 'deepseek-messages']))

+ 48 - 0
packages/llm/llm-deepseek-messages/tests/expected/degraded-replay.json

@@ -0,0 +1,48 @@
+[
+  {
+    "role": "user",
+    "content": [
+      {
+        "type": "text",
+        "text": "hello"
+      }
+    ]
+  },
+  {
+    "role": "assistant",
+    "content": [
+      {
+        "type": "thinking",
+        "thinking": "Read the file."
+      },
+      {
+        "type": "text",
+        "text": "Checking a."
+      },
+      {
+        "type": "tool_use",
+        "id": "a",
+        "name": "read",
+        "input": {
+          "path": "a"
+        }
+      }
+    ]
+  },
+  {
+    "role": "user",
+    "content": [
+      {
+        "type": "tool_result",
+        "tool_use_id": "a",
+        "content": [
+          {
+            "type": "text",
+            "text": "result"
+          }
+        ],
+        "is_error": false
+      }
+    ]
+  }
+]

+ 49 - 3
packages/llm/llm-deepseek-messages/tests/serialize.spec.ts

@@ -1,5 +1,5 @@
 /** Request conversion and durable replay validation. */
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
 import { createAssistantMessage, createMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
 import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment'
@@ -88,15 +88,61 @@ describe('Messages request conversion', () => {
   })
 
   it.each([
+    null,
+    [],
+    { response: null, blocks: [] },
     { response: { kind: 'other', version: 1 }, blocks: [] },
     { response: { kind: 'deepseek-messages', version: 2 }, blocks: [] },
     { response: { kind: 'deepseek-messages', version: 1, model: 'wrong' }, blocks: [] },
     { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [] },
+    { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: null },
+    { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [null] },
     { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [{ type: 'tool-call' }] },
     { response: { kind: 'deepseek-messages', version: 1, model: MODEL }, blocks: [{ type: 'reasoning', signature: 3 }] },
-  ])('rejects unusable replay state %#', (state) => {
+  ].map(state => ({ state })))('degrades unusable replay state with a diagnostic %#', ({ state }) => {
     const message = createAssistantMessage({ content: [{ type: 'reasoning', text: 'think' }], source: { provider: 'deepseek-messages', model: MODEL, replayState: state } })
-    expect(() => readReplay(message, MODEL)).toThrow(/replay/)
+    const onDegrade = vi.fn()
+    expect(readReplay(message, MODEL, onDegrade)).toBeUndefined()
+    expect(onDegrade).toHaveBeenCalledExactlyOnceWith(expect.any(String))
+    expect(body([message]).messages[0]?.content).toEqual([{ type: 'thinking', thinking: 'think' }])
+  })
+
+  it.each([MODEL, 'different-model'])('keeps durable content when replay degrades for %s', async (model) => {
+    const message = createAssistantMessage({
+      content: [{ type: 'reasoning', text: 'Read the file.' }, { type: 'text', text: 'Checking a.' }, call()],
+      source: { provider: 'deepseek-messages', model: MODEL, replayState: replayState(MODEL, [
+        { type: 'reasoning', signature: 'do-not-send' }, { type: 'text', signature: 'invalid-for-text' }, { type: 'tool-call' },
+      ]) },
+    })
+    const saved = JSON.stringify(message)
+    const restored = JSON.parse(saved) as Message
+    const messages = [user(), restored, result()]
+    const onDegrade = vi.fn()
+    const request = serialize(options({ model }), connection, messages, new Map(), () => undefined, onDegrade)
+    expect(onDegrade).toHaveBeenCalledExactlyOnceWith('DeepSeek Messages replay: invalid signature')
+    await expect(JSON.stringify(request.messages, null, 2) + '\n').toMatchFileSnapshot('expected/degraded-replay.json')
+    expect(JSON.stringify(restored)).toBe(saved)
+  })
+
+  it('keeps valid cross-model and foreign history quiet and propagates diagnostic failures', () => {
+    const onDegrade = vi.fn()
+    const message = createAssistantMessage({ content: [{ type: 'reasoning', text: 'think' }], source: {
+      provider: 'deepseek-messages', model: MODEL, replayState: replayState(MODEL, [{ type: 'reasoning', signature: '' }]),
+    } })
+    expect(readReplay(message, MODEL, onDegrade)).toEqual([{ type: 'reasoning', signature: '' }])
+    expect(readReplay(message, 'different-model', onDegrade)).toBeUndefined()
+    expect(readReplay(assistant([{ type: 'text', text: 'foreign' }]), MODEL, onDegrade)).toBeUndefined()
+    expect(onDegrade).not.toHaveBeenCalled()
+    const damaged = { ...message, source: { ...message.source, replayState: { response: {}, blocks: [] } } }
+    const failure = new Error('diagnostic failed')
+    expect(() => readReplay(damaged, MODEL, () => { throw failure })).toThrow(failure)
+  })
+
+  it('still rejects invalid tool JSON after discarding unusable replay metadata', () => {
+    const message = createAssistantMessage({ content: [{ type: 'tool-call', id: ToolCallId('a'), name: 'read', arguments: '{' }], source: {
+      provider: 'deepseek-messages', model: MODEL, replayState: { response: {}, blocks: [] },
+    } })
+    expect(() => body([message, result()])).toThrow(/historical tool input is invalid JSON/)
   })
 })
 

Разница между файлами не показана из-за своего большого размера
+ 13 - 0
snapshots/session/deepseek-messages-degraded-replay/session.v2.jsonl


+ 7 - 0
snapshots/session/deepseek-messages-degraded-replay/snapshot.yml

@@ -0,0 +1,7 @@
+version: 1
+scenario: deepseek-messages-degraded-replay
+profile: headless
+composition: default
+recording: authored
+header:
+  class: deepseek-messages

Некоторые файлы не были показаны из-за большого количества измененных файлов