fix(native-chat): avoid hiding user markup

This commit is contained in:
Brennan Benson
2026-07-10 17:38:32 -07:00
parent 60ff5fece5
commit e4e7fe2506
6 changed files with 88 additions and 13 deletions
@@ -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)
+22 -1
View File
@@ -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: '<system-reminder>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 () => {
@@ -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', '<my-custom-element>pasted code'))).toBe(false)
})
it('keeps assistant and tool turns', () => {
@@ -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. */
+12 -1
View File
@@ -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('<https://example.com/a-b> what is this?')).toBe(false)
expect(isHarnessInjectedUserTurnText('<foo-bar@example.com> sent me this')).toBe(false)
})
it('limits destructive filtering to observed harness tags', () => {
expect(isKnownHarnessInjectedUserTurnText('<task-notification>done')).toBe(true)
expect(isKnownHarnessInjectedUserTurnText('<agent-message from="worker">done')).toBe(true)
expect(isKnownHarnessInjectedUserTurnText('<my-custom-element>pasted code')).toBe(false)
expect(isKnownHarnessInjectedUserTurnText('<brand-new-harness-tag>unknown')).toBe(false)
})
})
+42 -2
View File
@@ -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
// (<user_query>) 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))
}