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
@@ -73,7 +73,7 @@ describe('Session queue snapshot intake', () => {
])
})
it('marks mixed-content messages non-editable while retaining their preview', () => {
it('marks mixed-content messages non-editable and keeps image blocks out of the text preview', () => {
const session = makeSession()
session.handleControlFrame(queueFrame([{
id: 'q-image',
@@ -86,7 +86,9 @@ describe('Session queue snapshot intake', () => {
{
id: 'q-image', placement: 'queued',
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }],
preview: 'hi [image]', text: null,
// Image blocks render as thumbnails from `content`, so the preview
// carries only the text; non-image foreign blocks keep their marker.
preview: 'hi', text: null,
},
])
})
@@ -231,6 +231,58 @@ describe('Web session model selection', () => {
await ctx.fiber.dispose()
})
it('delivers an admitted image batch through steer with the same ordered content as queue', async () => {
const { ctx, agent, sessionId } = await harness()
const attachments = {
imageLimits: {
maxImageBytes: 4,
maxImagesPerMessage: 2,
maxMessageImageBytes: 4,
maxImagePixels: 4,
maxImageDimension: 2000,
mediaTypes: ['image/png'],
},
validateImage: vi.fn(() => Promise.resolve()),
saveImage: vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => Promise.resolve({
attachmentId: `att-${String(input.data[0])}`,
mediaType: input.mediaType,
bytes: input.data.byteLength,
width: 1,
height: 1,
...input.name === undefined ? {} : { name: input.name },
})),
}
ctx.provide('attachments', Object.setPrototypeOf(attachments, AttachmentStore.prototype) as never)
const steer = vi.fn()
const followup = vi.fn()
Object.assign(agent, { steer, followup })
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
const result = await remote.prompt(promptRequest({
sessionId,
mode: 'steer' as const,
content: [
{ type: 'text' as const, text: 'look at this' },
{ type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'mid-turn.png' },
],
}))
expect(result.ok).toBe(true)
expect(followup).not.toHaveBeenCalled()
expect((steer.mock.calls[0]?.[0] as UserMessage).content).toEqual([
{ type: 'text', text: 'look at this' },
{
type: 'image',
attachment: {
attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'mid-turn.png',
},
},
])
await ctx.fiber.dispose()
})
it('allows a text-only selection while durable or pending images remain available for later models', async () => {
const { ctx, agent, sessionId } = await harness()
registerTextOnly(ctx)
@@ -281,6 +281,32 @@ describe('prompt and cancel errors', () => {
})
})
it('forwards continuation image parts to the subagent prompt Remote unstripped', async () => {
const api = new FakeApiClient()
const session = new Session(SID, fakeRemote(api), {
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
})
await session.open()
const content = [
{ type: 'text' as const, text: '看这张图' },
{ type: 'image' as const, mediaType: 'image/png' as const, data: 'aGk=', name: 'shot.png' },
]
const prompted = await session.prompt(content, 'queue')
expect(prompted).toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('subagents.prompt')).toEqual([
{
requestId: expect.any(String) as unknown as string,
parentSessionId: PARENT, childSessionId: SID,
mode: 'continuable',
content,
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
},
])
expect(session.getSnapshot().promptError).toBeNull()
})
it('lands an interrupt business failure in promptError with op=stop', async () => {
const api = new FakeApiClient()
api.onSubagentInterrupt = () => Promise.resolve(remoteErr({