fix(native-chat): suppress provider user echoes in Claude and Codex (#19136)

* fix(native-chat): keep provider user echoes out of the conversation

* fix(native-chat): retain input beside Codex skill context

---------

Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-09-06 16:28:10 -07:00
committed by GitHub
co-authored by Merge Sim
parent c7bcfa750a
commit ade9718557
15 changed files with 337 additions and 40 deletions
@@ -47,6 +47,92 @@ const BASE64_IMAGE = {
}
describe('Claude message content parts', () => {
it.each(['isMeta', 'isSynthetic', 'isCompactSummary'])(
'consumes %s skill context without a user bubble, fallback, or new turn',
(flag) => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
const event = userMessageWith({ type: 'text', text: '# Skill instructions' })
translator.handle({ ...event, message: { ...event.message, [flag]: true } })
expect(state.items).toEqual([])
expect(state.sink.publish).not.toHaveBeenCalled()
}
)
it('keeps tool results in an injected skill message', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
const event = userMessageWith({
type: 'tool_result',
tool_use_id: 'skill-call',
content: 'Skill loaded'
})
translator.handle({ ...event, message: { ...event.message, isMeta: true } })
expect(state.items.map((item) => item.body)).toEqual([
expect.objectContaining({
kind: 'tool-call',
state: 'completed',
output: expect.objectContaining({ head: 'Skill loaded', truncated: false })
})
])
})
it.each([
{ content: '# Skill instructions' },
{ content: [{ type: 'future_context', text: '# Skill instructions' }] },
{
content: [
{ type: 'text', text: '[Image: source: /tmp/pasted.png]' },
{ type: 'text', text: '# Skill instructions' }
]
}
])('does not surface injected content as text or a provider fallback: %j', ({ content }) => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
const event = userMessageWith(null)
translator.handle({
...event,
message: { ...event.message, isMeta: true, message: { role: 'user', content } }
})
expect(state.items).toEqual([])
})
it('does not render user echoes even without metadata flags', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
for (const content of ['/example-skill', '# Skill instructions']) {
const event = userMessageWith(null)
translator.handle({
...event,
message: { ...event.message, message: { role: 'user', content } }
})
}
expect(state.items.flatMap(({ body }) => (body.kind === 'message' ? body.blocks : []))).toEqual(
[]
)
})
it('silently consumes unmarked user context with unknown content parts', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
const event = userMessageWith({ type: 'future_context', text: 'Expanded instructions' })
translator.handle({ ...event, startsTurn: undefined })
expect(state.items).toEqual([])
expect(state.sink.publish).not.toHaveBeenCalled()
})
it('does not render injected image companions or start a turn', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
const event = userMessageWith(null)
const content = [{ type: 'text', text: '[Image: source: /tmp/pasted.png]' }]
translator.handle({
...event,
message: { ...event.message, isMeta: true, message: { role: 'user', content } }
})
expect(state.items).toEqual([])
})
it('does not leak a wire kind for a locally attached image', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
@@ -56,7 +142,7 @@ describe('Claude message content parts', () => {
expect(providerRows(state.items)).toEqual([])
})
it('still renders an image the CLI sends by url', () => {
it('does not render echoed image URLs', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
@@ -67,21 +153,29 @@ describe('Claude message content parts', () => {
expect(providerRows(state.items)).toEqual([])
expect(
state.items.flatMap((item) => (item.body.kind === 'message' ? item.body.blocks : []))
).toContainEqual({ type: 'image-ref', url: 'https://x.test/a.png' })
).toEqual([])
})
it('says what is true for a content part it cannot render, not the wire kind', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
translator.handle(userMessageWith({ type: 'some_future_part', payload: { a: 1 } }))
const event = userMessageWith(null)
translator.handle({
...event,
message: {
...event.message,
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'some_future_part', payload: { a: 1 } }] }
}
})
const rows = providerRows(state.items)
expect(rows).toHaveLength(1)
// The kind stays on the row for debugging, behind the disclosure.
expect(rows[0].kind).toBe('message:user:content:some_future_part')
expect(rows[0].kind).toBe('message:assistant:content:some_future_part')
// ...but the visible text is a sentence, not the opcode.
expect(rows[0].text).not.toContain('message:user:content')
expect(rows[0].text).not.toContain('message:assistant:content')
expect(rows[0].text.toLowerCase()).toContain('claude')
})
@@ -89,9 +183,18 @@ describe('Claude message content parts', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
translator.handle(
userMessageWith({ type: 'some_future_part', message: 'the server refused the upload' })
)
const event = userMessageWith(null)
translator.handle({
...event,
message: {
...event.message,
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'some_future_part', message: 'the server refused the upload' }]
}
}
})
expect(providerRows(state.items)[0].text).toBe('the server refused the upload')
})
@@ -49,6 +49,28 @@ function userReplayFrame(uuid: string, text: string): Record<string, unknown> {
}
describe('Claude structured dispatch image limits', () => {
it.each(['isMeta', 'isSynthetic', 'isCompactSummary'])(
'does not acknowledge a dispatch with %s context even when the client uuid matches',
async (flag) => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
session,
{ clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/example' }]) },
1000
)
await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1))
const sentUuid = session.dispatchWaiters[0]!.sentUuid
const replay = userReplayFrame(sentUuid, '/example')
expect(resolveClaudeReplayWaiter(session, { ...replay, [flag]: true })).toBe(false)
expect(session.dispatchWaiters).toHaveLength(1)
expect(resolveClaudeReplayWaiter(session, replay)).toBe(true)
await expect(dispatched).resolves.toMatchObject({
state: 'accepted',
providerIdentity: { uuid: sentUuid }
})
}
)
it('recovers the active identity when a timed-out replay arrives late', async () => {
const session = sessionFor()
const dispatched = dispatchClaudeTurn(
@@ -17,6 +17,7 @@ export type ClaudeMessageEnvelope = {
/** Messages API id shared by every frame of one streamed assistant message. */
messageId: string | null
parentToolUseId: string | null
isInjectedUserTurn?: boolean
}
export type ClaudeToolUse = { id: string; name: string; input: unknown }
@@ -42,12 +43,16 @@ export function readClaudeMessageEnvelope(
const sessionId = claudeText(frame.session_id)
const uuid = claudeText(frame.uuid)
const role = message?.role
const isInjectedUserTurn =
frame.type === 'user' &&
(frame.isMeta === true || frame.isSynthetic === true || frame.isCompactSummary === true)
return sessionId && uuid && (role === 'assistant' || role === 'user')
? {
sessionId,
uuid,
role,
content: messageContent(message?.content),
isInjectedUserTurn,
messageId: claudeText(message?.id),
parentToolUseId: claudeText(frame.parent_tool_use_id)
}
@@ -93,6 +98,9 @@ export function claudeMessageBody(envelope: ClaudeMessageEnvelope): AgentJournal
}
export function claudeHasReplayContent(envelope: ClaudeMessageEnvelope): boolean {
if (envelope.isInjectedUserTurn) {
return false
}
return envelope.content.some((value) => {
const part = claudeRecord(value)
return part !== null && part.type !== 'tool_result'
@@ -342,10 +342,7 @@ describe('Claude structured journal translation', () => {
state.items.flatMap((item) =>
item.body.kind === 'message' && item.body.role === 'user' ? [item.body.blocks] : []
)
).toEqual([
[{ type: 'text', text: 'Reply with exactly PROBE_OK_1 and nothing else.' }],
[{ type: 'text', text: '[Request interrupted by user]' }]
])
).toEqual([])
expect(
state.items.some((item) => item.body.kind === 'status' && !item.body.turnLifecycle)
).toBe(false)
@@ -521,10 +518,7 @@ describe('Claude structured journal translation', () => {
const keyed = new Map(
state.items.map((item) => [agentJournalItemKey(item.identity), item.body])
)
expect(keyed.get('claude:claude-session:user-1')).toMatchObject({
kind: 'message',
role: 'user'
})
expect(keyed.has('claude:claude-session:user-1')).toBe(false)
expect(keyed.get('orca:claude-tool%3Aclaude-session%3Atool-1')).toMatchObject({
kind: 'tool-call',
name: 'Bash',
@@ -683,7 +677,6 @@ describe('Claude structured journal translation', () => {
'message:system:local_command_output',
'message:system:command_started',
'message:result',
'message:user:content:document',
'control_request:future_control'
])
)
@@ -130,7 +130,15 @@ export function createClaudeJournalTranslator(
return false
}
let changed = false
const body = claudeMessageBody(envelope)
// User bubbles belong to the submitted message; SDK user frames carry echoes and tool results.
const outputEnvelope =
envelope.role === 'user'
? {
...envelope,
content: envelope.content.filter((part) => claudeRecord(part)?.type === 'tool_result')
}
: envelope
const body = claudeMessageBody(outputEnvelope)
// The final frame of a streamed block lands on the block's identity, not its own uuid.
const identity =
(body && envelope.role === 'assistant' ? streamedBlocks.reconcile(envelope) : null) ??
@@ -140,7 +148,7 @@ export function createClaudeJournalTranslator(
deps.sink.appendItem(identity, body)
changed = true
}
for (const tool of claudeToolUses(envelope)) {
for (const tool of claudeToolUses(outputEnvelope)) {
tools.set(tool.id, tool)
deps.sink.appendItem(
claudeToolIdentity(envelope.sessionId, tool.id),
@@ -162,7 +170,7 @@ export function createClaudeJournalTranslator(
tools.delete(result.toolUseId)
changed = true
}
const thinking = claudeThinkingText(envelope)
const thinking = claudeThinkingText(outputEnvelope)
if (thinking) {
deps.sink.appendItem(claudeThinkingIdentity(envelope.sessionId, envelope.uuid), {
kind: 'status',
@@ -170,7 +178,7 @@ export function createClaudeJournalTranslator(
})
changed = true
}
const unhandledContent = envelope.content.filter((part) => !isModeledClaudeContent(part))
const unhandledContent = outputEnvelope.content.filter((part) => !isModeledClaudeContent(part))
for (const part of unhandledContent) {
const partType = claudeText(claudeRecord(part)?.type) ?? 'unknown'
providerFallback.append(
@@ -60,7 +60,10 @@ export class CodexJournalItems {
return this.details.get(codexStructuredItemKey(threadId, itemId)) ?? null
}
handle(event: { threadId: string; method: string; params: unknown }): CodexItemTranslation {
handle(
event: { threadId: string; method: string; params: unknown },
source: 'live' | 'history' = 'live'
): CodexItemTranslation {
const params =
typeof event.params === 'object' && event.params !== null
? (event.params as Record<string, unknown>)
@@ -71,6 +74,10 @@ export class CodexJournalItems {
}
const turnId = readCodexTurnId(event.params) ?? this.activeTurn(event.threadId)
const identity = this.identityFor(event.threadId, turnId, item)
// Count echoes for stable resume ordinals, but user bubbles come from submissions.
if (source === 'live' && item.type === 'userMessage') {
return { handled: true, admission: CODEX_JOURNAL_ADMITTED }
}
const translated = codexJournalItem(item)
const command = readCodexJournalString(item, 'command')
if (command) {
@@ -652,7 +652,7 @@ describe('codex journal translation', () => {
translator.handle(TURN_STARTED)
translator.handle(
notification('item/completed', { item: { type: 'userMessage', id: 'item-0', text: 'one' } })
notification('item/completed', { item: { type: 'agentMessage', id: 'item-0', text: 'one' } })
)
translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))
translator.handle(
@@ -662,7 +662,7 @@ describe('codex journal translation', () => {
)
translator.handle(notification('turn/started', { turn: { id: 'turn-2' } }))
translator.handle(
notification('item/completed', { item: { type: 'userMessage', id: 'item-2', text: 'two' } })
notification('item/completed', { item: { type: 'agentMessage', id: 'item-2', text: 'two' } })
)
expect(tap.rows.map((row) => row.key)).toEqual([
@@ -679,7 +679,7 @@ describe('codex journal translation', () => {
translator.handle(
notification('item/completed', {
turnId: 'turn-9',
item: { type: 'userMessage', id: 'item-0', text: 'late' }
item: { type: 'agentMessage', id: 'item-0', text: 'late' }
})
)
@@ -157,7 +157,7 @@ describe('codex journal translation', () => {
translator.handle(TURN_STARTED)
translator.handle(
notification('item/completed', { item: { type: 'userMessage', id: 'item-0', text: 'hi' } })
notification('item/completed', { item: { type: 'agentMessage', id: 'item-0', text: 'hi' } })
)
expect(tap.publishes()).toBe(1)
@@ -520,7 +520,7 @@ describe('codex journal translation', () => {
expect(timeline).toEqual([])
})
it('projects only user and assistant content for a complete turn with hooks', () => {
it('projects assistant content without provider user echoes for a complete turn with hooks', () => {
const { translator, tap } = translatorWith()
translator.handle(notification('thread/started', { thread: { id: THREAD_ID } }))
@@ -552,7 +552,6 @@ describe('codex journal translation', () => {
}))
)
expect(timeline.map(({ role, blocks }) => ({ role, blocks }))).toEqual([
{ role: 'user', blocks: [{ type: 'text', text: 'hi' }] },
{ role: 'assistant', blocks: [{ type: 'text', text: 'hello' }] }
])
})
@@ -302,7 +302,7 @@ describe('codex journal translation', () => {
).toBe('idle')
})
it('journals a user turn and the assistant answer under durable codex keys', () => {
it('counts a user echo without rendering it and preserves the assistant ordinal', () => {
const { translator, tap } = translatorWith()
translator.handle(TURN_STARTED)
@@ -317,17 +317,37 @@ describe('codex journal translation', () => {
})
)
expect(tap.rows.map((row) => row.key)).toEqual([
'codex:thread-abc:turn-1:0',
'codex:thread-abc:turn-1:1'
])
expect(tap.rows[1]?.body).toEqual({
expect(tap.rows.map((row) => row.key)).toEqual(['codex:thread-abc:turn-1:1'])
expect(tap.rows[0]?.body).toEqual({
kind: 'message',
role: 'assistant',
blocks: [{ type: 'text', text: 'hello' }]
})
})
it('suppresses both echo lifecycle frames, including skill and unknown parts', () => {
const { translator, tap } = translatorWith()
translator.handle(TURN_STARTED)
const item = {
type: 'userMessage',
id: 'echo',
content: [
{ type: 'text', text: 'Expanded instructions' },
{ type: 'skill', name: 'example', path: '/tmp/SKILL.md' },
{ type: 'future_context', text: 'More context' }
]
}
translator.handle(notification('item/started', { item }))
translator.handle(notification('item/completed', { item }))
expect(tap.rows).toEqual([])
translator.handle(
notification('item/completed', {
item: { type: 'agentMessage', id: 'answer', text: 'Done' }
})
)
expect(tap.rows.map((row) => row.key)).toEqual(['codex:thread-abc:turn-1:1'])
})
it('folds streamed deltas into one snapshot row on the same key the item started under', () => {
const { translator, tap, window } = translatorWith()
@@ -64,7 +64,7 @@ export function createCodexJournalTranslator(
currentTurnIds: activeTurns.byThread,
ordinals: items.ordinals,
handleItem: (event) => {
const translated = items.handle(event)
const translated = items.handle(event, 'history')
return translated.handled
? translated.admission
: { accepted: false, reason: 'untranslated' }
@@ -285,7 +285,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => {
})
codex.connections[0].handlers.onNotification?.('item/completed', {
item: { type: 'userMessage', id: 'message-1', text: 'hello' }
item: { type: 'agentMessage', id: 'message-1', text: 'hello' }
})
await vi.waitFor(() => {
@@ -207,11 +207,46 @@ describe('submission and dispatch state machine', () => {
const items = renderJournalState(state).items
expect(items).toHaveLength(1)
expect(items[0]?.itemId).toBe(agentJournalSubmissionKey('cm_1'))
// The echo updates content in place; the bubble keeps its original slot.
// The echo advances the revision; the submitted bubble keeps its original slot.
expect(items[0]?.sequence).toBe(1)
expect(items[0]?.revision).toBe(1)
})
it.each(['codex:thread-1:turn-1:0', 'claude:session-1:user-1'])(
'preserves submitted text and attachments when %s is restored',
(providerItemId) => {
const body: AgentJournalMessageItem = {
kind: 'message',
role: 'user',
blocks: [
{ type: 'text', text: '/example-skill inspect this' },
{ type: 'image-ref', path: '/tmp/original.png' }
]
}
const state = fold([
{ ...submission, body, payloadFingerprint: sendFingerprint(body) },
{
kind: 'dispatch',
clientMessageId: 'cm_1',
state: 'accepted',
providerItemId,
reason: null,
...base(2)
},
{
kind: 'item',
itemId: providerItemId,
revision: 1,
body: userText('# Expanded skill instructions'),
...base(3)
}
])
expect(renderJournalState(state).items).toEqual([
expect.objectContaining({ itemId: agentJournalSubmissionKey('cm_1'), body, revision: 1 })
])
}
)
it('adopts a provider echo that arrives before dispatch settles', () => {
const body = userText('early echo')
const state = fold([
@@ -190,7 +190,17 @@ function upsertItem(
// so letting a revision advance it makes the row jump past everything that
// landed in between — the provider's own echo of a send revises the submission
// row, which relocated the user's bubble below later rows.
state.items.set(itemId, { ...next, sequence: existing.sequence, observedAt: existing.observedAt })
const submitted =
existing.body.kind === 'message' &&
existing.body.role === 'user' &&
parseAgentJournalItemKey(itemId)?.provider === 'orca'
state.items.set(itemId, {
...next,
// Provider history may normalize text or omit local attachments from the original send.
body: submitted ? existing.body : next.body,
sequence: existing.sequence,
observedAt: existing.observedAt
})
state.tombstones.delete(itemId)
}
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import { decodeCodexTranscriptLine } from './transcript-line-decoders-codex'
describe('Codex transcript skill context', () => {
it.each(['message', 'response_item'])(
'preserves prompt text and images beside a skill expansion in %s',
(type) => {
const message = {
type: 'message',
role: 'user',
content: [
{ type: 'text', text: 'Inspect this image' },
{ type: 'text', text: '<skill>\nInstructions\n</skill>' },
{ type: 'image', url: 'https://example.test/image.png' }
]
}
const record = type === 'message' ? message : { type, payload: message }
expect(decodeCodexTranscriptLine(JSON.stringify(record), 'mixed')?.blocks).toEqual([
{ type: 'text', text: 'Inspect this image' },
{ type: 'image-ref', url: 'https://example.test/image.png' }
])
}
)
it('preserves an authoritative user event containing a literal skill wrapper', () => {
const text = '<skill>Explain this XML</skill>'
expect(
decodeCodexTranscriptLine(
JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: text } }),
'submitted'
)?.blocks
).toEqual([{ type: 'text', text }])
})
it.each(['<skill>', ' \n<SKILL>'])(
'drops expanded skill response items beginning with %j',
(prefix) => {
const message = {
type: 'message',
role: 'user',
content: [{ type: 'text', text: `${prefix}\n<name>example</name>\nInstructions\n</skill>` }]
}
for (const record of [message, { type: 'response_item', payload: message }]) {
expect(decodeCodexTranscriptLine(JSON.stringify(record), 'context')).toBeNull()
}
}
)
it.each(['$example', 'Explain <skill> tags', '<skillset>user XML</skillset>'])(
'preserves the actual user prompt %j',
(text) => {
expect(
decodeCodexTranscriptLine(
JSON.stringify({
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'text', text }]
}
}),
'user'
)?.blocks
).toEqual([{ type: 'text', text }])
}
)
it('preserves assistant explanations containing the skill wrapper', () => {
expect(
decodeCodexTranscriptLine(
JSON.stringify({
type: 'response_item',
payload: {
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: '<skill>example</skill>' }]
}
}),
'assistant'
)?.role
).toBe('assistant')
})
})
@@ -48,7 +48,9 @@ function codexUnwrappedResponseItem(
return codexResponseItem(record, id, timestamp)
}
const role = record.role === 'assistant' ? 'assistant' : record.role === 'user' ? 'user' : null
const blocks = codexTurnItemBlocks(record.content)
const decodedBlocks = codexTurnItemBlocks(record.content)
const blocks =
role === 'user' ? decodedBlocks.filter((block) => !isSkillContext(block)) : decodedBlocks
return role && blocks.length > 0 ? { id, role, blocks, timestamp, source: 'transcript' } : null
}
@@ -63,7 +65,9 @@ function codexResponseItem(
if (!role) {
return null
}
const blocks = claudeContentBlocks(payload.content)
const decodedBlocks = claudeContentBlocks(payload.content)
const blocks =
role === 'user' ? decodedBlocks.filter((block) => !isSkillContext(block)) : decodedBlocks
if (blocks.length === 0) {
return null
}
@@ -108,6 +112,11 @@ function codexResponseItem(
return null
}
// Explicit skill expansions are model context, not the user's recorded prompt.
function isSkillContext(block: NativeChatBlock): boolean {
return block.type === 'text' && block.text.trimStart().slice(0, 7).toLowerCase() === '<skill>'
}
function codexEventMessage(
payload: Record<string, unknown>,
id: string,