Procházet zdrojové kódy

fix(llm): address multimodal review findings

Yichen Jiang před 1 měsícem
rodič
revize
4a02791c9a

+ 2 - 2
.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.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-08-19-direct-deepseek-vision-input.md
-2026-08-19-direct-deepseek-vision-input.md: 60f55f10e108a29d465e388fa385b1955b7f2ce5
-2026-08-19-direct-deepseek-vision-input.zh.md: 7f41d89ffd13da1b1b170f114dfc481e66075ef2
+2026-08-19-direct-deepseek-vision-input.md: 5c2f2ea3ff87b58906ce76caf9f611e07f3e2d04
+2026-08-19-direct-deepseek-vision-input.zh.md: 3aea6b6a4f27f7116c1cb935b1719dfbcf7ed8fc

+ 1 - 1
.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md

@@ -27,7 +27,7 @@ Canonical messages continue to store only `ImageAttachmentRef`. Data URLs exist
 
 ## Verification
 
-Package tests pin model discovery and fallback capabilities, configuration validation and live settings updates, user and tool-result wire messages, all admitted MIME types, cancellation, attachment failures, 413 classification, exact image-bound behavior, and pi-ai equivalence. A keyless assembled ACP request records the native adapter's tool-result data URL and oldest-image placeholder. A key-gated real-API e2e sends a deterministic image to the official vision model.
+Package tests pin model discovery and fallback capabilities, configuration validation and live settings updates, user and tool-result wire messages, all admitted MIME types, cancellation, attachment failures, 413 classification, exact image-bound behavior, and pi-ai equivalence. A keyless assembled ACP request records the native adapter's tool-result data URL and oldest-image placeholder.
 
 ## Consequences
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md

@@ -27,7 +27,7 @@ DeepSeek 视觉部署使用 chat-completions 图片协议,但直接 `deepseek-
 
 ## Verification
 
-包测试固定模型发现与回退能力、配置校验与存活 settings 更新、user 和工具结果协议消息、所有已准入 MIME 类型、取消、附件失败、413 分类、确切图片上限行为和 pi-ai 等价性。无需密钥的组装 ACP 请求会记录原生适配器的工具结果 data URL 与最旧图片占位文本。受密钥控制的真实 API e2e 会向官方视觉模型发送一张确定性图片。
+包测试固定模型发现与回退能力、配置校验与存活 settings 更新、user 和工具结果协议消息、所有已准入 MIME 类型、取消、附件失败、413 分类、确切图片上限行为和 pi-ai 等价性。无需密钥的组装 ACP 请求会记录原生适配器的工具结果 data URL 与最旧图片占位文本。
 
 ## Consequences
 

+ 1 - 1
docs/config-catalog.i18n.yaml

@@ -3,4 +3,4 @@
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
 config-catalog.md: 7d42923c1bc4c73636b52713694ff8f6eeb9146a
-config-catalog.zh.md: b9327fa127ba8e341a15954685942f2d15d6b8d3
+config-catalog.zh.md: 327a4951c513e7cd2360488026e7578e95458f47

+ 1 - 1
docs/config-catalog.zh.md

@@ -897,7 +897,7 @@ export interface DeepSeekCatalogModel {
 
 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
 
-来源:[`packages/llm/llm-deepseek/src/index.ts:72`](../packages/llm/llm-deepseek/src/index.ts)
+来源:[`packages/llm/llm-deepseek/src/index.ts:66`](../packages/llm/llm-deepseek/src/index.ts)
 
 <a id="deepseek-aidsh-llm-pi-ai"></a>
 

+ 2 - 2
docs/module-graph.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 docs/module-graph.md
-module-graph.md: 0bd5f80534ba65e0d483bd04228cdac321e082bc
-module-graph.zh.md: 6239520b7d819eb14c2b859afa44a0c987545200
+module-graph.md: 5398515912c98962152d2b121b1b02d7a06097b9
+module-graph.zh.md: 7a398374603d66f8a3661a1d927b6418a99235a6

+ 2 - 1
docs/module-graph.md

@@ -407,6 +407,7 @@ flowchart TD
   pkg_settings_file --> pkg_invariants
   pkg_settings_file --> pkg_settings
   pkg_llm_deepseek --> pkg_anonymous_user_id
+  pkg_llm_deepseek --> pkg_attachment
   pkg_llm_deepseek --> pkg_credentials
   pkg_llm_deepseek --> pkg_invariants
   pkg_llm_deepseek --> pkg_launch_environment
@@ -1463,7 +1464,7 @@ flowchart TD
 | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
 | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
-| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
+| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |

+ 2 - 1
docs/module-graph.zh.md

@@ -409,6 +409,7 @@ flowchart TD
   pkg_settings_file --> pkg_invariants
   pkg_settings_file --> pkg_settings
   pkg_llm_deepseek --> pkg_anonymous_user_id
+  pkg_llm_deepseek --> pkg_attachment
   pkg_llm_deepseek --> pkg_credentials
   pkg_llm_deepseek --> pkg_invariants
   pkg_llm_deepseek --> pkg_launch_environment
@@ -1465,7 +1466,7 @@ flowchart TD
 | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
 | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
-| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
+| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |

+ 9 - 5
packages/llm/llm-deepseek/src/serialize.ts

@@ -148,9 +148,12 @@ async function contentParts(
 
 /** Keep text-only user messages on the compact string wire form. */
 function userContent(parts: readonly WireUserContentPart[]): string | WireUserContentPart[] {
-  return parts.some(part => part.type === 'image_url')
-    ? [...parts]
-    : parts.map(part => part.type === 'text' ? part.text : '').join('')
+  const text: string[] = []
+  for (const part of parts) {
+    if (part.type === 'image_url') return [...parts]
+    text.push(part.text)
+  }
+  return text.join('')
 }
 
 /** Serialize one assistant message (text + reasoning + tool calls). */
@@ -268,11 +271,12 @@ export async function serializeMessagesWithImages(
     const toolResults = message.content.filter((block): block is Extract<ContentBlock, { type: 'tool-result' }> => (
       block.type === 'tool-result'
     ))
-    if (regular.length > 0 || toolResults.length === 0) {
+    const content = userContent(await contentParts(regular, attachments, signal))
+    if (content.length > 0 || toolResults.length === 0) {
       flushToolImages()
       wire.push({
         role: 'user',
-        content: userContent(await contentParts(regular, attachments, signal)),
+        content,
       })
     }
     for (const result of toolResults) {

+ 0 - 61
packages/llm/llm-deepseek/tests/adapter.e2e.ts

@@ -1,18 +1,10 @@
 import { mkdtemp, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { createHash } from 'node:crypto'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import LlmRuntime, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm'
 import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
-import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment'
-import type {
-  ImageAttachmentLimits,
-  ImageAttachmentRef,
-  SaveImageAttachment,
-  StoredImageAttachment,
-} from '@deepseek-ai/dsh-attachment'
 import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local'
 import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
 import type { Config } from '@deepseek-ai/dsh-llm-deepseek'
@@ -26,41 +18,6 @@ import { assemble, type AssembledResult } from './assemble.ts'
 
 const FLASH = 'deepseek-v4-flash'
 const PRO = 'deepseek-v4-pro'
-const VISION = 'deepseek-v4-flash-vision-exp'
-const RED_IMAGE = Buffer.from(
-  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC',
-  'base64',
-)
-const RED_IMAGE_REF: ImageAttachmentRef = {
-  attachmentId: AttachmentId(`sha256:${createHash('sha256').update(RED_IMAGE).digest('hex')}`),
-  mediaType: 'image/png',
-  bytes: RED_IMAGE.byteLength,
-  width: 1,
-  height: 1,
-}
-
-class E2eAttachmentStore extends AttachmentStore {
-  readonly imageLimits: ImageAttachmentLimits = {
-    maxImageBytes: 1024,
-    maxImagesPerMessage: 1,
-    maxMessageImageBytes: 1024,
-    maxImagePixels: 1,
-    maxImageDimension: 1,
-    mediaTypes: ['image/png'],
-  }
-
-  validateImage(_input: SaveImageAttachment): Promise<void> {
-    return Promise.resolve()
-  }
-
-  saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
-    return Promise.resolve(RED_IMAGE_REF)
-  }
-
-  readImage(_ref: ImageAttachmentRef, _signal?: AbortSignal): Promise<StoredImageAttachment> {
-    return Promise.resolve({ ref: RED_IMAGE_REF, data: RED_IMAGE })
-  }
-}
 const contexts: Context[] = []
 let identityHome: string
 
@@ -73,7 +30,6 @@ async function harness(_model: string, config: Partial<Config> = {}) {
   const ctx = new Context()
   contexts.push(ctx)
   await ctx.plugin(LlmRuntime)
-  await ctx.plugin(E2eAttachmentStore)
   await ctx.plugin(LlmDeepSeek, config)
   return ctx
 }
@@ -109,23 +65,6 @@ const weatherTool: ToolSchema = {
 }
 
 describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => {
-  it('recognizes a deterministic image with the official vision model', async () => {
-    const ctx = await harness(VISION, { thinking: 'disabled' })
-    const result = await assemble(ctx, {
-      model: VISION,
-      messages: [createUserMessage({
-        content: [
-          { type: 'text', text: 'This image is one solid color. Reply with only its English color name.' },
-          { type: 'image', attachment: RED_IMAGE_REF },
-        ],
-        source: { kind: 'plugin', plugin: 'test' },
-      })],
-      maxTokens: 50,
-    })
-    expect(result.finish.kind).toBe('stop')
-    expect(textOf(result).toLowerCase()).toContain('red')
-  })
-
   it('serves a real request with the key held only by a credentials-local document', async () => {
     const key = process.env.DEEPSEEK_API_KEY
     if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY')

+ 31 - 1
packages/llm/llm-deepseek/tests/adapter.spec.ts

@@ -178,7 +178,10 @@ describe('DeepSeekAdapter against a mock server', () => {
     const server = await mockServer([])
     const resolveApiKey = vi.fn(() => Promise.resolve('k'))
     const adapter = new DeepSeekAdapter({
-      options: () => resolveAdapterOptions({ baseURL: server.url }),
+      options: () => resolveAdapterOptions({
+        baseURL: server.url,
+        models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }],
+      }),
       resolveApiKey,
       resolveUserId: () => TEST_USER_ID,
     })
@@ -865,6 +868,21 @@ describe('plugin registration and config', () => {
     ])
   })
 
+  it('defaults an adapter-supplied catalog entry to text input', async () => {
+    const connection = resolveAdapterOptions({ models: [] })
+    const adapter = new DeepSeekAdapter({
+      options: () => ({ ...connection, models: [{ id: 'adapter-model' }] }),
+      resolveApiKey: () => Promise.resolve('k'),
+      resolveUserId: () => TEST_USER_ID,
+    })
+    await expect(adapter.listModels('deepseek-official')).resolves.toEqual([{
+      provider: 'deepseek-official',
+      id: 'adapter-model',
+      name: 'adapter-model',
+      inputModalities: ['text'],
+    }])
+  })
+
   it('advertises configured models without restricting arbitrary request ids', async () => {
     const ctx = new Context()
     await ctx.plugin(LlmRuntime)
@@ -954,6 +972,18 @@ describe('plugin registration and config', () => {
     expect(ctx.llm.listProviders()).toEqual([])
   })
 
+  const invalidProgrammaticModalities: Array<[LlmDeepSeek.DeepSeekCatalogModel[], RegExp]> = [
+    [[{ id: 'm', inputModalities: [] }], /inputModalities must not be empty/],
+    [[{
+      id: 'm',
+      inputModalities: ['audio'] as unknown as NonNullable<LlmDeepSeek.DeepSeekCatalogModel['inputModalities']>,
+    }], /inputModalities must contain only "text" and "image"/],
+  ]
+
+  it.each(invalidProgrammaticModalities)('rejects programmatic modality config that bypasses the schema', (models, message) => {
+    expect(() => resolveAdapterOptions({ models: [...models] })).toThrow(message)
+  })
+
   it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => {
     expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] }))
       .toThrow(/maxTokens must be a positive integer/)

+ 129 - 0
packages/llm/llm-deepseek/tests/serialize.spec.ts

@@ -393,6 +393,95 @@ describe('image serialization', () => {
     ])
   })
 
+  it('does not emit an empty user message for ignored content beside a tool result', async () => {
+    const messages = [createUserMessage({
+      content: [
+        { type: 'text', text: '' },
+        { type: 'chart', data: 'ignored' } as unknown as ContentBlock,
+        {
+          type: 'tool-result',
+          toolCallId: CallId('result'),
+          content: [{ type: 'text', text: 'ok' }],
+        },
+      ],
+      source: { kind: 'plugin', plugin: 'test' },
+    })]
+
+    await expect(serializeMessagesWithImages(
+      messages,
+      attachmentStore(),
+      new AbortController().signal,
+    )).resolves.toEqual([
+      { role: 'tool', tool_call_id: 'result', content: 'ok' },
+    ])
+  })
+
+  it('recursively converts nested tool-result content and preserves the empty fallback', async () => {
+    const messages = [createUserMessage({
+      content: [
+        {
+          type: 'tool-result',
+          toolCallId: CallId('nested'),
+          content: [{
+            type: 'tool-result',
+            toolCallId: CallId('inner'),
+            content: [{ type: 'text', text: 'inside' }],
+          }],
+        },
+        { type: 'tool-result', toolCallId: CallId('empty'), content: [] },
+      ],
+      source: { kind: 'plugin', plugin: 'test' },
+    })]
+
+    await expect(serializeMessagesWithImages(
+      messages,
+      attachmentStore(),
+      new AbortController().signal,
+    )).resolves.toEqual([
+      { role: 'tool', tool_call_id: 'nested', content: 'inside' },
+      { role: 'tool', tool_call_id: 'empty', content: '(no output)' },
+    ])
+  })
+
+  it('flushes tool-result images before system and assistant history', async () => {
+    const imageResult = (id: string) => createUserMessage({
+      content: [{
+        type: 'tool-result',
+        toolCallId: CallId(id),
+        content: [{ type: 'image', attachment: imageRef() }],
+      }],
+      source: { kind: 'plugin' as const, plugin: 'test' },
+    })
+    const messages = [
+      imageResult('before-system'),
+      createMessage({
+        role: 'system',
+        content: [{ type: 'text', text: 'system history' }],
+        source: { kind: 'plugin', plugin: 'test' },
+      }),
+      imageResult('before-assistant'),
+      createMessage({
+        role: 'assistant',
+        content: [{ type: 'text', text: 'assistant history' }],
+        source: { kind: 'plugin', plugin: 'test' },
+      }),
+    ]
+
+    const wire = await serializeMessagesWithImages(
+      messages,
+      attachmentStore(),
+      new AbortController().signal,
+    )
+    expect(wire).toEqual([
+      { role: 'tool', tool_call_id: 'before-system', content: '(see attached image)' },
+      expect.objectContaining({ role: 'user' }),
+      { role: 'system', content: 'system history' },
+      { role: 'tool', tool_call_id: 'before-assistant', content: '(see attached image)' },
+      expect.objectContaining({ role: 'user' }),
+      { role: 'assistant', content: 'assistant history' },
+    ])
+  })
+
   it('offloads oldest images before reads and keeps the newest image', async () => {
     const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({
       ref,
@@ -435,6 +524,37 @@ describe('image serialization', () => {
     expect(readImage).not.toHaveBeenCalled()
   })
 
+  it('rejects unsupported image history before request offloading can replace it', async () => {
+    const readImage = vi.fn()
+    await expect(serializeRequestWithImages(request({
+      messages: [createMessage({
+        role: 'system',
+        content: [{ type: 'image', attachment: imageRef('image/png', 300) }],
+        source: { kind: 'plugin', plugin: 'test' },
+      })],
+    }), {
+      attachments: attachmentStore(readImage),
+      maxRequestImageBytes: 1,
+      signal: new AbortController().signal,
+    })).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
+    expect(readImage).not.toHaveBeenCalled()
+  })
+
+  it('prepends the request system prompt on the image path', async () => {
+    const wire = await serializeRequestWithImages(request({
+      system: 'system prompt',
+      messages: [createUserMessage({
+        content: [{ type: 'image', attachment: imageRef() }],
+        source: { kind: 'plugin', plugin: 'test' },
+      })],
+    }), {
+      attachments: attachmentStore(),
+      maxRequestImageBytes: 20 * 1024 * 1024,
+      signal: new AbortController().signal,
+    })
+    expect(wire.messages[0]).toEqual({ role: 'system', content: 'system prompt' })
+  })
+
   it('preserves stable attachment failure codes', async () => {
     const readImage = vi.fn(() => Promise.reject(new AttachmentError(
       'Stored attachment bytes are corrupt.',
@@ -446,6 +566,15 @@ describe('image serialization', () => {
     })], attachmentStore(readImage), new AbortController().signal))
       .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' })
   })
+
+  it('preserves non-attachment resolver failures', async () => {
+    const failure = new Error('resolver failed')
+    const readImage = vi.fn(() => Promise.reject(failure))
+    await expect(serializeMessagesWithImages([createUserMessage({
+      content: [{ type: 'image', attachment: imageRef() }],
+      source: { kind: 'plugin', plugin: 'test' },
+    })], attachmentStore(readImage), new AbortController().signal)).rejects.toBe(failure)
+  })
 })
 
 describe('review fixes: assistant content shapes', () => {

+ 13 - 3
packages/llm/llm-pi-ai/src/context.ts

@@ -26,6 +26,18 @@ function toolResultText(blocks: readonly ContentBlock[]): string {
     : block.type === 'tool-result' ? toolResultText(block.content) : '').join('')
 }
 
+/** Reject image roles that pi-ai cannot replay before request-size offloading can replace them. */
+function assertSupportedImageRoles(messages: readonly Message[]): void {
+  for (const message of messages) {
+    if (message.role !== 'user' && contentHasImage(message.content)) {
+      throw new LlmError(
+        `pi-ai cannot represent an image in an in-history ${message.role} message`,
+        'UNSUPPORTED_CONTENT',
+      )
+    }
+  }
+}
+
 async function userContent(
   blocks: readonly ContentBlock[],
   attachments: AttachmentStore,
@@ -169,15 +181,13 @@ async function toPiContextWithImages(
   onReplayDegrade?: (reason: string) => void,
   maxRequestImageBytes?: number,
 ): Promise<PiContext> {
+  assertSupportedImageRoles(options.messages)
   const requestMessages = offloadRequestImages(options.messages, maxRequestImageBytes)
   const toolNames = new Map<CallId, string>()
   const messages: PiMessage[] = []
 
   for (const message of requestMessages) {
     if (message.role === 'system') {
-      if (contentHasImage(message.content)) {
-        throw new LlmError('pi-ai cannot represent an image in an in-history system message', 'UNSUPPORTED_CONTENT')
-      }
       // pi-ai has a single systemPrompt slot; in-history system messages are
       // folded into user messages to preserve order (rare in practice — the
       // harness sends the system prompt via options.system).

+ 61 - 3
packages/llm/llm-pi-ai/tests/context.spec.ts

@@ -140,6 +140,59 @@ describe('pi-ai request context conversion', () => {
     ])
   })
 
+  it('recursively converts nested tool-result text and images', async () => {
+    const callId = CallId('nested-call')
+    const context = await toPiContext(request([user([{
+      type: 'tool-result',
+      toolCallId: callId,
+      content: [
+        {
+          type: 'tool-result',
+          toolCallId: callId,
+          content: [{ type: 'text', text: 'nested text' }],
+        },
+        {
+          type: 'tool-result',
+          toolCallId: callId,
+          content: [{ type: 'image', attachment: ref }],
+        },
+      ],
+    }])]), attachments)
+
+    expect(context.messages).toEqual([{
+      role: 'toolResult',
+      toolCallId: 'nested-call',
+      toolName: 'unknown',
+      content: [
+        { type: 'text', text: 'nested text' },
+        { type: 'image', data: 'AQ==', mimeType: 'image/png' },
+      ],
+      isError: false,
+      timestamp: 0,
+    }])
+  })
+
+  it('flattens nested text-only tool results and ignores other block types without storage', () => {
+    const callId = CallId('nested-text')
+    expect(toPiContext(request([user([{
+      type: 'tool-result',
+      toolCallId: callId,
+      content: [
+        { type: 'chart', data: 'ignored' } as unknown as ContentBlock,
+        {
+          type: 'tool-result',
+          toolCallId: callId,
+          content: [{ type: 'text', text: 'nested' }],
+        },
+      ],
+    }])]))).toMatchObject({
+      messages: [{
+        role: 'toolResult',
+        content: [{ type: 'text', text: 'nested' }],
+      }],
+    })
+  })
+
   it('replaces the oldest images with placeholders once the request payload bound is exceeded', async () => {
     const readImage = vi.fn(() => Promise.resolve({ ref: { ...ref, bytes: 3 }, data: Uint8Array.of(1, 2, 3) }))
     const store = { readImage } as unknown as AttachmentStore
@@ -249,9 +302,14 @@ describe('pi-ai request context conversion', () => {
   })
 
   it('handles in-history system and assistant messages explicitly on the image path', async () => {
-    await expect(toPiContext(request([
-      history('system', [{ type: 'image', attachment: ref }]),
-    ]), attachments)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
+    for (const role of ['system', 'assistant'] as const) {
+      const readImage = vi.fn()
+      const store = { readImage } as unknown as AttachmentStore
+      await expect(toPiContext(request([
+        history(role, [{ type: 'image', attachment: ref }]),
+      ]), store, undefined, 1)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
+      expect(readImage).not.toHaveBeenCalled()
+    }
 
     await expect(toPiContext(request([
       history('system', [{ type: 'text', text: 'history system' }]),

+ 13 - 0
packages/llm/llm/tests/content.spec.ts

@@ -73,4 +73,17 @@ describe('offloadRequestImages', () => {
     expect(offloadRequestImages(messages, 8)[0]?.content)
       .toEqual([{ type: 'text', text: OFFLOADED_IMAGE_TEXT }])
   })
+
+  it('keeps unchanged nested content while replacing a later image', () => {
+    const nested = {
+      type: 'tool-result' as const,
+      toolCallId: CallId('text-only'),
+      content: [{ type: 'text' as const, text: 'kept' }],
+    }
+    const messages = [createUserMessage({ content: [nested, image(3)], source })]
+    expect(offloadRequestImages(messages, 1)[0]?.content).toEqual([
+      nested,
+      { type: 'text', text: OFFLOADED_IMAGE_TEXT },
+    ])
+  })
 })