diff --git a/src/main/claude/claude-structured-dispatch-content.test.ts b/src/main/claude/claude-structured-dispatch-content.test.ts index c85e65ebc02..ef007764193 100644 --- a/src/main/claude/claude-structured-dispatch-content.test.ts +++ b/src/main/claude/claude-structured-dispatch-content.test.ts @@ -1,6 +1,17 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' -import { claudeDispatchMessageContent } from './claude-structured-dispatch-content' +import { + claudeDispatchInvokesSlashCommand, + claudeDispatchMessageContent +} from './claude-structured-dispatch-content' + +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' +) function userMessage(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem { return { kind: 'message', role: 'user', blocks } @@ -72,4 +83,70 @@ describe('claudeDispatchMessageContent', () => { }) ).rejects.toThrow('Claude dispatch accepts only user messages') }) + + it('joins several text blocks so a command is not stranded ahead of trailing prose', async () => { + // Appending each block would leave `thanks` trailing, and Claude reads only that block. + const content = await claudeDispatchMessageContent( + userMessage([ + { type: 'text', text: '/goal ship' }, + REMOTE_IMAGE, + { type: 'text', text: 'thanks' } + ]) + ) + + expect(content).toEqual([ + { type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } }, + { type: 'text', text: '/goal ship\nthanks' } + ]) + expect(claudeDispatchInvokesSlashCommand(content)).toBe(true) + }) + + it('puts a locally attached image ahead of the text, the shape the composer sends', async () => { + const dir = await mkdtemp(join(tmpdir(), 'claude-dispatch-content-')) + const path = join(dir, 'shot.png') + await writeFile(path, PNG) + + try { + const content = await claudeDispatchMessageContent( + userMessage([ + { type: 'text', text: '/goal ship' }, + { type: 'image-ref', path } + ]) + ) + + expect(content).toEqual([ + { + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: PNG.toString('base64') } + }, + { type: 'text', text: '/goal ship' } + ]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe('claudeDispatchInvokesSlashCommand', () => { + it('reads the trailing prompt Claude recovers, not any text block', () => { + expect( + claudeDispatchInvokesSlashCommand([ + { type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } }, + { type: 'text', text: '/goal ship' } + ]) + ).toBe(true) + // The pre-fix order: Claude recovers no prompt at all, so no command runs. + expect( + claudeDispatchInvokesSlashCommand([ + { type: 'text', text: '/goal ship' }, + { type: 'image', source: { type: 'url', url: 'https://example.test/a.png' } } + ]) + ).toBe(false) + }) + + it('matches untrimmed, as Claude does, and ignores a promptless turn', () => { + expect(claudeDispatchInvokesSlashCommand([{ type: 'text', text: ' /goal ship' }])).toBe(false) + expect(claudeDispatchInvokesSlashCommand([{ type: 'text', text: 'ship it' }])).toBe(false) + expect(claudeDispatchInvokesSlashCommand([])).toBe(false) + }) }) diff --git a/src/main/claude/claude-structured-dispatch-content.ts b/src/main/claude/claude-structured-dispatch-content.ts index c26f77333bd..9ce1a7eb9f7 100644 --- a/src/main/claude/claude-structured-dispatch-content.ts +++ b/src/main/claude/claude-structured-dispatch-content.ts @@ -3,6 +3,7 @@ import { open } from 'node:fs/promises' import { extname } from 'node:path' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' +import { claudeRecord } from './claude-structured-item-translation' const MAX_IMAGE_BYTES = 5 * 1024 * 1024 const MAX_IMAGE_COUNT = 20 @@ -88,31 +89,49 @@ async function imageContent( } } +/** + * Claude encodes a user turn as attachment blocks followed by the typed text, and recovers the + * typed prompt by reading only the trailing text block. Verified against the real CLI over + * stream-json: a body ending in an image has no recoverable prompt, so its `/command` reaches + * the model as prose instead of being expanded. + */ export async function claudeDispatchMessageContent( body: AgentJournalMessageItem ): Promise { if (body.role !== 'user') { throw new Error('Claude dispatch accepts only user messages') } - // Claude reads a streamed user message as a slash-command invocation only when the LAST - // content block is text, so images must precede the prompt or `/command` arrives as prose. const images: unknown[] = [] - const texts: unknown[] = [] + const texts: string[] = [] const imageBudget: ImageBudget = { count: 0, localBytes: 0 } for (const block of body.blocks as NativeChatBlock[]) { if (block.type === 'text' && block.text.length > 0) { - texts.push({ type: 'text', text: block.text }) + texts.push(block.text) } else if (block.type === 'image-ref') { images.push(await imageContent(block, imageBudget)) } } - const content = [...images, ...texts] + // Join rather than append each block: only the trailing text is read as the prompt, so several + // text blocks would silently discard every one but the last. + const content = texts.length > 0 ? [...images, { type: 'text', text: texts.join('\n') }] : images if (content.length === 0) { throw new Error('Claude dispatch requires text or an image') } return content } +/** The prompt Claude recovers from a dispatch, or null when the turn carries no prompt. */ +function claudeDispatchPrompt(content: readonly unknown[]): string | null { + const last = claudeRecord(content.at(-1)) + return last?.type === 'text' && typeof last.text === 'string' ? last.text : null +} + +/** Mirrors how Claude decides a turn is a command. Untrimmed on purpose: Claude does not trim + * here either, so leading whitespace really does mean no command runs. */ +export function claudeDispatchInvokesSlashCommand(content: readonly unknown[]): boolean { + return claudeDispatchPrompt(content)?.startsWith('/') === true +} + /** * Keep waiter metadata bounded even when a dispatch contains large base64 images. * The digest is only diagnostic: replay acknowledgement must use provider identity. diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index ad09357df58..21def71cd48 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -423,6 +423,61 @@ describe('Claude structured dispatch image limits', () => { }) }) + it('accepts a slash command sent with an attachment from its result receipt', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { + clientMessageId: 'client-1', + body: userMessage([ + { type: 'text', text: '/permissions' }, + { type: 'image-ref', url: 'https://example.test/a.png' } + ]) + }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + // The mapper moves the image ahead of the prompt, so Claude runs the command and replies + // with a result receipt instead of a user replay. + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'command-result-uuid' + }) + ).toBe(false) + + await expect(dispatched).resolves.toMatchObject({ + state: 'accepted', + providerIdentity: { uuid: 'command-result-uuid' } + }) + }) + + it('does not take a result receipt for leading whitespace Claude never reads as a command', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { + clientMessageId: 'client-1', + body: userMessage([{ type: 'text', text: ' /permissions' }]) + }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + + expect( + resolveClaudeReplayWaiter(session, { + type: 'result', + subtype: 'success', + session_id: 'provider-session', + uuid: 'unrelated-result-uuid' + }) + ).toBe(false) + + await expect(dispatched).resolves.toMatchObject({ state: 'unknown' }) + }) + it('correlates a later slash-command result by user_message_uuid despite a timed-out slash waiter', async () => { const session = sessionFor() const first = dispatchClaudeTurn( diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index b7619a1e94e..3080b5479e4 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -12,6 +12,7 @@ import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-se import { readClaudeFrameString } from './claude-structured-init-proof' import { claudeDispatchContentKey, + claudeDispatchInvokesSlashCommand, claudeDispatchMessageContent } from './claude-structured-dispatch-content' @@ -231,9 +232,9 @@ export async function dispatchClaudeTurn( return { state: 'rejected', reason: (error as Error).message } } const dispatchSequence = ++session.dispatchSequence - const acceptsResult = input.body.blocks.some( - (block) => block.type === 'text' && block.text.trimStart().startsWith('/') - ) + // Read the sent content, not the journal blocks: only the mapped trailing prompt decides + // whether Claude runs a command, so the two cannot disagree about which frame settles this. + const acceptsResult = claudeDispatchInvokesSlashCommand(content) const sentUuid = randomUUID() const replay = waitForReplay( session,