diff --git a/src/main/native-chat/transcript-line-decoders-claude.ts b/src/main/native-chat/transcript-line-decoders-claude.ts index dc19280dfdf..76c8952580e 100644 --- a/src/main/native-chat/transcript-line-decoders-claude.ts +++ b/src/main/native-chat/transcript-line-decoders-claude.ts @@ -22,17 +22,19 @@ export function decodeClaudeTranscriptLine( return null } const message = asRecord(record.message) - const blocks = claudeContentBlocks(message?.content) - if (blocks.length === 0) { + const decodedBlocks = claudeContentBlocks(message?.content) + if (decodedBlocks.length === 0) { return null } // Why: Claude structurally marks injected turns, but tool-result records are // genuine output and must remain visible even when the containing turn is meta. - if ( + const isInjectedUserTurn = role === 'user' && - (record.isMeta === true || record.isSynthetic === true || record.isCompactSummary === true) && - !blocks.some((block) => block.type === 'tool-result') - ) { + (record.isMeta === true || record.isSynthetic === true || record.isCompactSummary === true) + const blocks = isInjectedUserTurn + ? decodedBlocks.filter((block) => block.type === 'tool-result') + : decodedBlocks + if (blocks.length === 0) { return null } const messageId = extractString(record.uuid) ?? extractString(message?.id) diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index 44b1236ee68..c6c2223c81f 100644 --- a/src/main/native-chat/transcript-reader.test.ts +++ b/src/main/native-chat/transcript-reader.test.ts @@ -124,14 +124,35 @@ describe('readNativeChatTranscript (claude)', () => { role: 'user', content: [{ type: 'tool_result', content: 'ok', is_error: false }] } + }, + { + type: 'user', + uuid: 'u-meta-mixed', + isMeta: true, + timestamp: '2026-06-01T10:00:04.000Z', + message: { + role: 'user', + content: [ + { type: 'text', text: 'hidden machinery' }, + { type: 'tool_result', content: 'mixed result', is_error: false } + ] + } } ]) const result = await readNativeChatTranscript('claude', 'sess', { filePath }) if (!('messages' in result)) { throw new Error('expected messages') } - expect(result.messages.map((m) => m.id)).toEqual(['u-real', 'u-meta-toolresult']) + expect(result.messages.map((m) => m.id)).toEqual([ + 'u-real', + 'u-meta-toolresult', + 'u-meta-mixed' + ]) expect(result.messages[1].role).toBe('tool') + expect(result.messages[2]).toMatchObject({ + role: 'tool', + blocks: [{ type: 'tool-result', output: 'mixed result' }] + }) }) it('marks thinking-only assistant content as a reasoning surface', async () => { diff --git a/src/renderer/src/components/native-chat/native-chat-noise.test.ts b/src/renderer/src/components/native-chat/native-chat-noise.test.ts index 1f451d9c742..198749b351d 100644 --- a/src/renderer/src/components/native-chat/native-chat-noise.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-noise.test.ts @@ -33,6 +33,7 @@ describe('isNoiseMessage', () => { it('keeps real user messages', () => { expect(isNoiseMessage(msg('user', 'make it work for codex'))).toBe(false) + expect(isNoiseMessage(msg('user', 'pasted code'))).toBe(false) }) it('keeps assistant and tool turns', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-noise.ts b/src/renderer/src/components/native-chat/native-chat-noise.ts index f5922843191..70e7a1d8cd3 100644 --- a/src/renderer/src/components/native-chat/native-chat-noise.ts +++ b/src/renderer/src/components/native-chat/native-chat-noise.ts @@ -1,12 +1,12 @@ import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' -import { isHarnessInjectedUserTurnText } from '../../../../shared/harness-injected-user-turns' +import { isKnownHarnessInjectedUserTurnText } from '../../../../shared/harness-injected-user-turns' // Why: harness machinery turns land in the transcript but are not real user // messages, so the chat filters them out (they were confusingly rendered as // the user's own bubbles). The classifier lives in // src/shared/harness-injected-user-turns.ts, shared with the agent-status // prompt pipeline; structurally marked turns (isMeta etc.) are already -// dropped at decode in src/main/native-chat/transcript-line-decoders.ts. +// dropped by the Claude transcript decoder. function messageText(message: NativeChatMessage): string { return message.blocks @@ -27,7 +27,7 @@ export function isNoiseMessage(message: NativeChatMessage): boolean { if (message.blocks.some((b) => b.type === 'tool-call' || b.type === 'tool-result')) { return false } - return isHarnessInjectedUserTurnText(messageText(message)) + return isKnownHarnessInjectedUserTurnText(messageText(message)) } /** Drop harness-noise messages from a transcript. */ diff --git a/src/shared/harness-injected-user-turns.test.ts b/src/shared/harness-injected-user-turns.test.ts index 0cbd2fffa09..579bc0b5f75 100644 --- a/src/shared/harness-injected-user-turns.test.ts +++ b/src/shared/harness-injected-user-turns.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' -import { isHarnessInjectedUserTurnText } from './harness-injected-user-turns' +import { + isHarnessInjectedUserTurnText, + isKnownHarnessInjectedUserTurnText +} from './harness-injected-user-turns' describe('isHarnessInjectedUserTurnText', () => { it('matches every known harness tag, including attribute-carrying forms', () => { @@ -30,6 +33,7 @@ describe('isHarnessInjectedUserTurnText', () => { ] for (const text of injected) { expect(isHarnessInjectedUserTurnText(text), text).toBe(true) + expect(isKnownHarnessInjectedUserTurnText(text), text).toBe(true) } }) @@ -82,4 +86,11 @@ describe('isHarnessInjectedUserTurnText', () => { expect(isHarnessInjectedUserTurnText(' what is this?')).toBe(false) expect(isHarnessInjectedUserTurnText(' sent me this')).toBe(false) }) + + it('limits destructive filtering to observed harness tags', () => { + expect(isKnownHarnessInjectedUserTurnText('done')).toBe(true) + expect(isKnownHarnessInjectedUserTurnText('done')).toBe(true) + expect(isKnownHarnessInjectedUserTurnText('pasted code')).toBe(false) + expect(isKnownHarnessInjectedUserTurnText('unknown')).toBe(false) + }) }) diff --git a/src/shared/harness-injected-user-turns.ts b/src/shared/harness-injected-user-turns.ts index d9b2bd23983..5d5f31e1bc5 100644 --- a/src/shared/harness-injected-user-turns.ts +++ b/src/shared/harness-injected-user-turns.ts @@ -10,9 +10,35 @@ // so we match that shape instead of chasing individual tag names — the tag // set grows faster than a literal list stays current. Underscore tags // () are deliberately NOT matched: Grok wraps real typed prompts -// in them. A prompt that begins with pasted kebab-tag markup (e.g. a -// web-component snippet) is misclassified, costing only a stale status label. +// in them. This broad shape is limited to classification that does not remove +// transcript content; transcript filtering uses the observed-tag list below. const HARNESS_KEBAB_TAG_PREFIX = /^<[a-z][a-z0-9]*(?:-[a-z0-9]+)+(?:[\s>]|$)/ +const LEADING_TAG_NAME = /^<([a-z][a-z0-9-]*)(?:[\s>]|$)/ + +// Destructive consumers such as transcript filtering must only hide tags we +// have observed from harnesses; arbitrary kebab tags can be genuine user code. +const KNOWN_HARNESS_TAG_NAMES = new Set([ + 'agent-message', + 'bash-input', + 'bash-stderr', + 'bash-stdout', + 'channel', + 'command-args', + 'command-message', + 'command-name', + 'cross-session-message', + 'fork-boilerplate', + 'local-command-caveat', + 'local-command-stderr', + 'local-command-stdout', + 'mcp-polling-update', + 'mcp-resource-update', + 'system-reminder', + 'task-notification', + 'teammate-message', + 'user-memory-input', + 'user-prompt-submit-hook' +]) // Injected shapes the kebab-tag rule cannot see: single-word attribute tags // and the prose sentences the harness wraps deliveries and notices in. @@ -38,3 +64,17 @@ export function isHarnessInjectedUserTurnText(text: string): boolean { } return HARNESS_INJECTED_TURN_PREFIXES.some((prefix) => normalized.startsWith(prefix)) } + +/** True only for observed harness shapes. Use this when a match removes user + * content rather than merely suppressing ephemeral prompt-derived UI. */ +export function isKnownHarnessInjectedUserTurnText(text: string): boolean { + const normalized = text.trim().toLowerCase() + if (normalized.length === 0) { + return false + } + const tagName = LEADING_TAG_NAME.exec(normalized)?.[1] + if (tagName && KNOWN_HARNESS_TAG_NAMES.has(tagName)) { + return true + } + return HARNESS_INJECTED_TURN_PREFIXES.some((prefix) => normalized.startsWith(prefix)) +}