From 60ff5fece546fe8c91d2e7711409f8141bdcdba2 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Thu, 9 Jul 2026 13:53:03 -0700 Subject: [PATCH] fix(agent-status): filter harness-injected turns by tag shape, not a literal list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harness machinery turns (task-notification, agent-message, bash-input/stdout, system-reminder, …) were leaking into prompt-derived UI as if the user typed them — hook-status labels, native-chat bubbles, and AI Vault session titles. The literal tag blocklist went stale twice in three months because the harness adds tags faster than we chase them. Generalize the text classifier to match the harness's tag *shape*: any prompt beginning with a lowercase multi-word kebab tag (, , , …) is machinery. Underscore tags () are deliberately excluded — Grok wraps real typed prompts in them. Explicit prefixes remain only for the single-word tag ( { + vi.restoreAllMocks() + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('scanAiVaultSessions harness-injected title seeding', () => { + it('keeps harness-injected Claude turns without isMeta out of session titles', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-claude-injected-title-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true }) + + await writeFile( + join(roots.claudeProjectsDir, 'project', 'injected-first.jsonl'), + jsonLines([ + { + type: 'user', + sessionId: 'injected-first', + timestamp: '2026-06-11T10:00:00.000Z', + cwd: '/repo/app', + // Task notifications carry no isMeta — only their text marks them. + message: { + role: 'user', + content: ' abc completed' + } + }, + { + type: 'user', + sessionId: 'injected-first', + timestamp: '2026-06-11T10:00:01.000Z', + cwd: '/repo/app', + message: { role: 'user', content: 'Fix the sidebar label bug' } + } + ]) + ) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'darwin' + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]?.title).toBe('Fix the sidebar label bug') + }) +}) diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index 9ffe364c12f..9e270194712 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -2,6 +2,7 @@ import { createReadStream } from 'node:fs' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import { isHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns' import type { FileWithMtime, ResumableSessionParseState, @@ -119,8 +120,10 @@ export function consumeClaudeSessionLine(state: ClaudeSessionParseState, line: s const title = extractMessageText(record.message) addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp) if (title) { - // Meta prompts (injected context) only seed the last-resort title. - if (record.isMeta === true) { + // Meta prompts (injected context) only seed the last-resort title. Some + // injected turns (task notifications) carry no isMeta, so also gate on + // the shared machinery-text classifier. + if (record.isMeta === true || isHarnessInjectedUserTurnText(title)) { state.metaTitle ??= title } else { state.firstUserTitle ??= title diff --git a/src/main/native-chat/transcript-line-decoders-claude.ts b/src/main/native-chat/transcript-line-decoders-claude.ts index 68dcf77701b..dc19280dfdf 100644 --- a/src/main/native-chat/transcript-line-decoders-claude.ts +++ b/src/main/native-chat/transcript-line-decoders-claude.ts @@ -26,6 +26,15 @@ export function decodeClaudeTranscriptLine( if (blocks.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 ( + role === 'user' && + (record.isMeta === true || record.isSynthetic === true || record.isCompactSummary === true) && + !blocks.some((block) => block.type === 'tool-result') + ) { + return null + } const messageId = extractString(record.uuid) ?? extractString(message?.id) return { id: messageId ?? fallbackId, diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index 09c922ca347..44b1236ee68 100644 --- a/src/main/native-chat/transcript-reader.test.ts +++ b/src/main/native-chat/transcript-reader.test.ts @@ -87,6 +87,53 @@ describe('readNativeChatTranscript (claude)', () => { expect(toolResult?.blocks[0]).toEqual({ type: 'tool-result', output: 'file-a\nfile-b' }) }) + it('drops structurally marked injected user turns but keeps their tool results', async () => { + const filePath = await writeFixture('orca-native-chat-claude-meta-', [ + { + type: 'user', + uuid: 'u-real', + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'user', content: 'fix the login bug' } + }, + { + type: 'user', + uuid: 'u-meta', + isMeta: true, + timestamp: '2026-06-01T10:00:01.000Z', + message: { + role: 'user', + content: 'Another Claude session sent a message:\nhi' + } + }, + { + type: 'user', + uuid: 'u-compact', + isCompactSummary: true, + timestamp: '2026-06-01T10:00:02.000Z', + message: { + role: 'user', + content: 'This session is being continued from a previous conversation.' + } + }, + { + type: 'user', + uuid: 'u-meta-toolresult', + isMeta: true, + timestamp: '2026-06-01T10:00:03.000Z', + message: { + role: 'user', + content: [{ type: 'tool_result', content: 'ok', 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[1].role).toBe('tool') + }) + it('marks thinking-only assistant content as a reasoning surface', async () => { const filePath = await writeFixture('orca-native-chat-claude-think-', [ { 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 a8f3bb8c0da..f5922843191 100644 --- a/src/renderer/src/components/native-chat/native-chat-noise.ts +++ b/src/renderer/src/components/native-chat/native-chat-noise.ts @@ -3,10 +3,10 @@ import { isHarnessInjectedUserTurnText } from '../../../../shared/harness-inject // 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 prefix list lives in +// the user's own bubbles). The classifier lives in // src/shared/harness-injected-user-turns.ts, shared with the agent-status -// prompt pipeline. Mirrors the mobile predicate in -// mobile/src/session/mobile-native-chat-noise.ts. +// prompt pipeline; structurally marked turns (isMeta etc.) are already +// dropped at decode in src/main/native-chat/transcript-line-decoders.ts. function messageText(message: NativeChatMessage): string { return message.blocks diff --git a/src/shared/harness-injected-user-turns.test.ts b/src/shared/harness-injected-user-turns.test.ts index 1b012b7d4ba..0cbd2fffa09 100644 --- a/src/shared/harness-injected-user-turns.test.ts +++ b/src/shared/harness-injected-user-turns.test.ts @@ -3,21 +3,59 @@ import { describe, expect, it } from 'vitest' import { isHarnessInjectedUserTurnText } from './harness-injected-user-turns' describe('isHarnessInjectedUserTurnText', () => { - it('matches harness machinery turns by prefix', () => { + it('matches every known harness tag, including attribute-carrying forms', () => { + // Inventory mirrors the tags the harness itself classifies as machinery. + const injected = [ + ' bzthj2b8r toolu_01abc', + '', + 'context', + ' REVIEW the diff', + 'status?', + 'hello', + 'new post', + 'Your directive: ship it', + 'remember this', + '', + 'tick', + '/review', + 'review', + '--fix', + 'ok', + 'boom', + 'Caveat: tool output', + 'ls', + 'file.txt', + 'err', + 'hook context' + ] + for (const text of injected) { + expect(isHarnessInjectedUserTurnText(text), text).toBe(true) + } + }) + + it('matches future kebab tags without a list update', () => { + expect(isHarnessInjectedUserTurnText('payload')).toBe(true) + expect(isHarnessInjectedUserTurnText('\nlater')).toBe(true) + }) + + it('matches harness prose wrappers and notices', () => { expect( - isHarnessInjectedUserTurnText( - ' bzthj2b8r toolu_01abc' - ) + isHarnessInjectedUserTurnText('A message arrived from teammate-b:\nhi') ).toBe(true) - expect(isHarnessInjectedUserTurnText('context')).toBe(true) - expect(isHarnessInjectedUserTurnText('/review')).toBe(true) - expect(isHarnessInjectedUserTurnText('ok')).toBe( - true - ) + expect( + isHarnessInjectedUserTurnText('Another Claude session sent a message:\nhi') + ).toBe(true) + expect(isHarnessInjectedUserTurnText('No response requested.')).toBe(true) expect(isHarnessInjectedUserTurnText('[Request interrupted by user]')).toBe(true) + expect(isHarnessInjectedUserTurnText('[Request interrupted by user for tool use]')).toBe(true) expect( isHarnessInjectedUserTurnText('This session is being continued from a previous conversation.') ).toBe(true) + expect( + isHarnessInjectedUserTurnText( + 'Caveat: the messages below were generated by the user while running local commands.' + ) + ).toBe(true) }) it('is case-insensitive and ignores surrounding whitespace', () => { @@ -33,4 +71,15 @@ describe('isHarnessInjectedUserTurnText', () => { expect(isHarnessInjectedUserTurnText('')).toBe(false) expect(isHarnessInjectedUserTurnText(' ')).toBe(false) }) + + it('keeps single-word tag pastes and underscore wrappers', () => { + // Grok wraps REAL typed prompts in — never classify as noise. + expect(isHarnessInjectedUserTurnText('fix the bug')).toBe(false) + expect(isHarnessInjectedUserTurnText('
pasted html
')).toBe(false) + expect(isHarnessInjectedUserTurnText(' — why is this flagged?')).toBe( + false + ) + expect(isHarnessInjectedUserTurnText(' what is this?')).toBe(false) + expect(isHarnessInjectedUserTurnText(' sent me this')).toBe(false) + }) }) diff --git a/src/shared/harness-injected-user-turns.ts b/src/shared/harness-injected-user-turns.ts index 21d732a0cc7..d9b2bd23983 100644 --- a/src/shared/harness-injected-user-turns.ts +++ b/src/shared/harness-injected-user-turns.ts @@ -1,29 +1,40 @@ // Why: agent harnesses (Claude Code and its forks) inject machinery into the // conversation as user-role turns — background task notifications, system -// reminders, slash-command envelopes, local-command output, interruption and -// compaction notices. These fire user-prompt hooks and land in transcripts, -// but they are not something the user typed, so prompt-derived UI must not -// surface them. +// reminders, inter-agent messages, slash-command envelopes, local-command +// output, interruption and compaction notices. These fire user-prompt hooks +// and land in transcripts, but they are not something the user typed, so +// prompt-derived UI must not surface them. +// +// Every tag the harness injects is a lowercase multi-word kebab tag +// (, , , …), +// 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. +const HARNESS_KEBAB_TAG_PREFIX = /^<[a-z][a-z0-9]*(?:-[a-z0-9]+)+(?:[\s>]|$)/ + +// Injected shapes the kebab-tag rule cannot see: single-word attribute tags +// and the prose sentences the harness wraps deliveries and notices in. const HARNESS_INJECTED_TURN_PREFIXES = [ - '', - '', - '', - '', - '', - '', - '', - ' normalized.startsWith(prefix)) }