fix: address ds-review-bot v7 findings on the merged image-input head

- gate model selection on steering-placement image carriers from enqueue
  until their steering/message event publishes; release the gate when an
  admission ends idle without publication (both behaviorally asserted)
- reject session.updateQueue edits carrying non-text blocks at the RPC
  boundary (queue edits cannot bypass image admission)
- extend the durable-directory walk past a first-created DSH_HOME to the
  deepest pre-existing ancestor
- strip Windows-style separators from attachment display names on POSIX
- verify attachment reads with a header-only probe (digest already proves
  the bytes decoded fully at admission); document the read path
- make SessionInputShell.addImages refusal observable and keep workspace
  transfers/composer intake from leaking refused drafts
- own ONE recursive image walk (dsh-llm contentHasImage) across apiproxy,
  pi-ai, compact-basic, and the DeepSeek text-only assertion
- drop the redundant canonical-base64 regex and the no-op role read
- move AttachmentId/AttachmentError out of types.ts (brand.ts/error.ts);
  document why AttachmentError does not extend HarnessError
- document the hard attachments inject in both consumer READMEs
This commit is contained in:
creatixchu
2026-07-30 14:34:08 +08:00
parent 97cf33b7e0
commit 0d1250f743
37 changed files with 335 additions and 140 deletions
+3 -6
View File
@@ -7,7 +7,7 @@
* @module dsh-llm-deepseek/serialize
*/
import { LlmError } from '@deepseek-ai/dsh-llm'
import { contentHasImage, LlmError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import type { WireMessage, WireRequest, WireTool } from './types.ts'
@@ -62,11 +62,8 @@ function flattenText(blocks: ContentBlock[]): string {
/** Reject core image content before any text-flattening path can silently erase it. */
function assertTextOnly(blocks: readonly ContentBlock[]): void {
for (const block of blocks) {
if (block.type === 'image') {
throw new LlmError('The DeepSeek chat-completions adapter does not support image content.', 'UNSUPPORTED_CONTENT')
}
if (block.type === 'tool-result') assertTextOnly(block.content)
if (contentHasImage(blocks)) {
throw new LlmError('The DeepSeek chat-completions adapter does not support image content.', 'UNSUPPORTED_CONTENT')
}
}
+3 -6
View File
@@ -33,7 +33,8 @@ import type {
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { resolveProfiles } from './config.ts'
import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
import { contentHasImage, toPiContext } from './context.ts'
import { contentHasImage } from '@deepseek-ai/dsh-llm'
import { toPiContext } from './context.ts'
import { toStreamChunks } from './stream.ts'
/** Constructor options for {@link PiAiAdapter}. */
@@ -189,11 +190,7 @@ export class PiAiAdapter extends LlmAdapter {
using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT')
try {
const containsImage = options.messages.some((message) => {
// The discriminant is part of same-process message validity and is read before content.
void message.role
return contentHasImage(message.content)
})
const containsImage = options.messages.some(message => contentHasImage(message.content))
if (containsImage && !model.input.includes('image')) {
throw new LlmError(`pi-ai model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT')
}
+1 -10
View File
@@ -4,7 +4,7 @@
* @module dsh-llm-pi-ai/context
*/
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
import { CallId, contentHasImage, LlmError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai'
@@ -18,15 +18,6 @@ function flattenText(message: Message): string {
.join('')
}
/**
* Return whether content contains an image, including nested tool results.
* @param blocks - content to inspect recursively.
* @returns whether any nested block is an image.
*/
export function contentHasImage(blocks: readonly ContentBlock[]): boolean {
return blocks.some(block => block.type === 'image'
|| (block.type === 'tool-result' && contentHasImage(block.content)))
}
/** Flatten text recursively inside one tool result. */
function toolResultText(blocks: readonly ContentBlock[]): string {
+2 -2
View File
@@ -638,7 +638,7 @@ describe('provider profile lifecycle', () => {
describe('abort wiring', () => {
it('preserves an unknown pre-dispatch adapter Error exactly', async () => {
const original = new Error('SDK context conversion exploded')
const message = Object.defineProperty({}, 'role', {
const message = Object.defineProperty({}, 'content', {
get() { throw original },
})
const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] })
@@ -656,7 +656,7 @@ describe('abort wiring', () => {
it('lets a concurrent caller abort classify a pre-dispatch adapter failure', async () => {
const controller = new AbortController()
const original = new Error('conversion lost its caller')
const message = Object.defineProperty({}, 'role', {
const message = Object.defineProperty({}, 'content', {
get() {
controller.abort('caller cancelled during conversion')
throw original
+16
View File
@@ -0,0 +1,16 @@
/** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */
import type { ContentBlock } from './types.ts'
/**
* True when typed model content contains an image block, walking nested
* tool-result content. This is the one recursive image walk shared by every
* image policy (capability gating, text-only serialization, compaction
* survey), so a consumer cannot silently diverge on nesting depth.
* @param content - typed model content blocks.
* @returns whether any nested block is an image.
*/
export function contentHasImage(content: readonly ContentBlock[]): boolean {
return content.some(block => block.type === 'image'
|| (block.type === 'tool-result' && contentHasImage(block.content)))
}
+1
View File
@@ -31,6 +31,7 @@ export * from './brand.ts'
export * from './never.ts'
export * from './error.ts'
export * from './types.ts'
export * from './content.ts'
export * from './message.ts'
export * from './retry-policy.ts'
export { BlockAssembler } from './assembler.ts'