mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user