diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index b770eacc38f..a886cdb4b02 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -157,6 +157,44 @@ describe('Claude structured dispatch image limits', () => { }) }) + // Once this CLI is seen stamping the marker, the injected-turn allowlist must + // stop being load-bearing: an unrecognized injected shape would otherwise still + // steal the identity. + it('stops trusting frame shape once the CLI is seen stamping isReplay', async () => { + const session = sessionFor() + const first = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: 'one' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + resolveClaudeReplayWaiter(session, userReplayFrame('replay-one', 'one')) + await expect(first).resolves.toMatchObject({ state: 'accepted' }) + + const second = dispatchClaudeTurn( + session, + { clientMessageId: 'client-2', body: userMessage([{ type: 'text', text: 'two' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + + // An injected shape nobody has enumerated yet, so the allowlist would pass it. + resolveClaudeReplayWaiter(session, { + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'hi' }] }, + parent_tool_use_id: null, + session_id: 'provider-session', + uuid: 'unknown-injected-uuid' + }) + expect(session.dispatchWaiters).toHaveLength(1) + + resolveClaudeReplayWaiter(session, userReplayFrame('replay-two', 'two')) + await expect(second).resolves.toMatchObject({ + state: 'accepted', + providerIdentity: { uuid: 'replay-two' } + }) + }) + // `readClaudeMessageEnvelope` already tolerates string content, so refusing it // here would be a new way for a send to never be acknowledged. it('accepts a replay whose content is a plain string', async () => { diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 739ef8a3cbe..7ea922106c0 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -53,9 +53,10 @@ const IMAGE_MIME_BY_EXTENSION: Record = { } /** Text of a top-level user frame, or null when it is a tool-result turn (or - * carries nothing). `content` may be a plain string - a shape - * `readClaudeMessageEnvelope` already tolerates. An image-only echo has no text - * and correctly yields ''. */ + * carries nothing). A plain-string `content` is accepted so a CLI that sends the + * simpler shape can still settle its send - note the translator models only the + * array form, so such an echo settles the dispatch without upserting a body, the + * same way an image-only echo does. An image-only echo has no text, yielding ''. */ function claudeUserFrameText(content: unknown): string | null { if (typeof content === 'string') { return content @@ -82,22 +83,32 @@ function claudeUserFrameText(content: unknown): string | null { * bubble. * * `--replay-user-messages` stamps `isReplay` on the genuine echo, so trust that - * when it is there. The shape test is the fallback for a CLI that omits it: - * without one, a send that is never acknowledged times out into a false - * "delivery unconfirmed", and retrying it sends the message to Claude twice. + * when it is there. The shape test is only a fallback for a CLI that omits the + * marker, and it stays off once this session has seen one: the launch args always + * request replays, so after that a frame without the marker is by construction not + * the echo, and the hand-maintained injected-turn list must not be load-bearing. + * Without any fallback, a CLI that never stamps it would leave every send timing + * out into a false "delivery unconfirmed", and retrying sends the message twice. * * Measured against the real CLI: a queued send's replay is published at the * first tool boundary after it is queued, 2-3 ms AFTER that boundary's tool * result. So rejecting tool results costs no time - whenever there was a frame * to adopt, the right one follows immediately. */ -function isClaudeUserMessageReplay(message: Record): boolean { +function isClaudeUserMessageReplay( + session: ClaudeSession, + message: Record +): boolean { if (message.type !== 'user' || message.parent_tool_use_id !== null) { return false } if (message.isReplay === true) { + session.sawUserReplayMarker = true return true } + if (session.sawUserReplayMarker) { + return false + } const text = claudeUserFrameText(claudeRecord(message.message)?.content) return text !== null && !isKnownHarnessInjectedUserTurnText(text) } @@ -106,7 +117,7 @@ export function resolveClaudeReplayWaiter( session: ClaudeSession, message: Record ): void { - const isUserReplay = isClaudeUserMessageReplay(message) + const isUserReplay = isClaudeUserMessageReplay(session, message) const isCompletedCommand = message.type === 'result' if ( (!isUserReplay && !isCompletedCommand) || diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 9a8462f6ac2..f32b2c240ca 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -65,6 +65,9 @@ export type ClaudeSession = { fence: number prompts: ClaudePromptRegistry dispatchWaiters: ClaudeDispatchWaiter[] + /** Set once this CLI is seen stamping `isReplay` on its user echo. From then on + * a frame without it cannot be an echo, so the shape fallback is switched off. */ + sawUserReplayMarker?: boolean options: Map reportedOptions: { model?: string; effort?: string } translator: ClaudeJournalTranslator | null