fix: deliver images reliably with steer and follow-up messages

A steer or follow-up accepted while a turn is closing is now claimed by a
fresh turn at the driver's clean exit instead of stranding in the inbox;
cancellation and pre-step rejection still park accepted work. Continuable
subagent follow-ups accept image parts: the wire is upload-shaped, the Host
admits and persists each batch before inbox acceptance, and delivery is
refused when the child model declines image input. The queue dock renders
durable image thumbnails instead of an [image] text marker.

Fixes #3186
This commit is contained in:
creatixchu
2026-08-27 15:30:35 +08:00
parent 72f1e19184
commit 7c38fd8102
51 changed files with 1037 additions and 108 deletions
@@ -7,12 +7,12 @@
* must stub); implementation-internal entry points (history staging, wire-frame
* dispatch) stay on the class, invisible out here.
*/
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { AttachmentIdType, ImageAttachmentRef, PromptContentPart } from '@deepseek-ai/dsh-attachment'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { PromptContentPart, QueueAction } from '../../types.ts'
import type { QueueAction } from '../../types.ts'
import type { ClientResult } from './result.ts'
import type { SessionSnapshot } from './snapshot.ts'
@@ -5,8 +5,11 @@ import type { QueuedMessage } from '../contract/snapshot.ts'
const QUEUE_PREVIEW_CHARS = 200
// Image blocks are excluded: queue presentation renders them as thumbnails
// from `content`, so the text preview covers only what has no visual form.
function previewOf(content: readonly ContentBlock[]): string {
const flat = content
.filter(block => block.type !== 'image')
.map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
.join(' ').replace(/\s+/g, ' ').trim()
const chars = Array.from(flat)
@@ -208,28 +208,15 @@ export class Session implements SessionFace {
},
}
} else {
if (content.some(part => part.type === 'image')) {
result = {
ok: false,
error: {
code: 'attachment-error',
message: 'Image input is unavailable for subagent continuations.',
details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' },
},
}
} else {
const routed = toSessionResult(await this.remote.subagents.prompt({
requestId: randomUUID() as SessionRequestId,
parentSessionId: this.address.parentSessionId,
childSessionId: this.address.childSessionId,
mode: this.address.mode,
content: content.flatMap(part => part.type === 'text'
? [{ type: 'text' as const, text: part.text }]
: []),
clientTimeZone: resolvedClientTimeZone(),
}, signal))
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
}
const routed = toSessionResult(await this.remote.subagents.prompt({
requestId: randomUUID() as SessionRequestId,
parentSessionId: this.address.parentSessionId,
childSessionId: this.address.childSessionId,
mode: this.address.mode,
content,
clientTimeZone: resolvedClientTimeZone(),
}, signal))
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
}
} catch (error) {
result = transportResult(error)
@@ -4,12 +4,12 @@ import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
import { PresetMountError, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets'
import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment'
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import {
ReasoningEffortId, createUserMessage, freezeMessage,
ReasoningEffortId, createUserMessage, durablePromptContent, freezeMessage,
} from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
@@ -319,7 +319,7 @@ export class SessionCommandController {
)
}
}
const content = await durablePromptContent(this.ctx, request.content)
const content = await durablePromptContent(this.ctx.attachments, request.content)
const message: UserMessage = createUserMessage({ content, source })
if (request.mode === 'steer') agent.steer(message)
else agent.followup(message)
@@ -511,21 +511,6 @@ function reject(code: string, message: string, details: object): never {
throw new TypertRemoteFailure({ code, message, details })
}
async function durablePromptContent(
ctx: Context,
content: readonly SessionPromptRequest['content'][number][],
): Promise<ContentBlock[]> {
if (content.every(part => part.type === 'text')) {
return content.map(part => ({ type: 'text', text: part.text }))
}
const refs = await admitEncodedImages(ctx.attachments, content.filter(part => part.type === 'image'))
let next = 0
return content.map(part => part.type === 'text'
? { type: 'text', text: part.text }
// admitEncodedImages returns one reference per image part in order.
: { type: 'image', attachment: refs[next++] as ImageAttachmentRef })
}
function imageBlockIn(
content: unknown,
match: (ref: ImageAttachmentRef) => boolean,
+2 -10
View File
@@ -1,7 +1,7 @@
/** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */
import type {
AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType,
AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, PromptContentPart,
} from '@deepseek-ai/dsh-attachment'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
@@ -67,15 +67,7 @@ export interface SessionProjectionBaseline {
export type SessionProjectionValues = Partial<SessionProjectionMap>
& Readonly<Record<string, SessionProjectionValue>>
/** Browser-submitted prompt content; the Host promotes image bytes to durable references. */
export type PromptContentPart =
| { readonly type: 'text'; readonly text: string }
| {
readonly type: 'image'
readonly mediaType: ImageMediaType
readonly data: string
readonly name?: string
}
export type { PromptContentPart } from '@deepseek-ai/dsh-attachment'
/** Complete model selection for one Session. */
export interface ModelSelection {