fix(native-chat): stop the injected-turn allowlist being load-bearing

The shape fallback ran on every frame without `isReplay`, so an injected turn
whose leading text is not on the hand-maintained allowlist in
harness-injected-user-turns.ts would still steal the send identity - and an
injected turn upserts a body, so it overwrites the user's own bubble before the
real replay appends a second one.

The launch args always request replays, so once this session has seen the CLI
stamp `isReplay`, a frame without it cannot be the echo. Latch that and switch
the fallback off; it now only ever runs on a CLI that never stamps the marker,
where the alternative is every send timing out into a false
"delivery unconfirmed" and being sent twice on retry.

Also corrects the string-content comment: `readClaudeMessageEnvelope` tolerates
a plain-string `content` but builds an EMPTY envelope from it, so such an echo
settles the dispatch without upserting a body - the same as an image-only echo,
not the reconciliation the old wording implied.

Found by the review loop on #15657.
This commit is contained in:
Brennan Benson
2026-08-20 17:36:28 -07:00
parent c0ee83c8d0
commit 341ea87dce
3 changed files with 60 additions and 8 deletions
@@ -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: '<some-new-notice>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 () => {
+19 -8
View File
@@ -53,9 +53,10 @@ const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
}
/** 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<string, unknown>): boolean {
function isClaudeUserMessageReplay(
session: ClaudeSession,
message: Record<string, unknown>
): 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<string, unknown>
): void {
const isUserReplay = isClaudeUserMessageReplay(message)
const isUserReplay = isClaudeUserMessageReplay(session, message)
const isCompletedCommand = message.type === 'result'
if (
(!isUserReplay && !isCompletedCommand) ||
@@ -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<string, string>
reportedOptions: { model?: string; effort?: string }
translator: ClaudeJournalTranslator | null