fix(native-chat): derive Claude slash-command recognition from the sent prompt

Claude encodes a user turn as attachment blocks followed by the typed text and
recovers the prompt by reading only the trailing text block, so a body ending in
an image has no recoverable prompt and its `/command` reaches the model as prose.
The composer builds text-then-images, so that was every send with an attachment.

Emit one trailing text block rather than appending each block: a partitioned
`[...images, ...texts]` still strands a command ahead of trailing prose, because
only the last block is read. Derive `acceptsResult` from that same sent content
so the mapper and the dispatch waiter cannot disagree about whether a command
ran; the previous `blocks.some(trimStart)` was strictly more permissive than
Claude, which does not trim before matching `/`.
This commit is contained in:
Merge Sim
2026-09-08 18:27:42 -07:00
parent 01958eb964
commit d41f3dd64f
4 changed files with 161 additions and 9 deletions
@@ -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)
})
})
@@ -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<unknown[]> {
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.
@@ -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(
@@ -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,