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 b783fc28c33..a8f3bb8c0da 100644 --- a/src/renderer/src/components/native-chat/native-chat-noise.ts +++ b/src/renderer/src/components/native-chat/native-chat-noise.ts @@ -1,25 +1,12 @@ import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' +import { isHarnessInjectedUserTurnText } from '../../../../shared/harness-injected-user-turns' -// Why: the harness injects machinery into the agent's conversation as user-role -// turns — background task-notifications, system reminders, local-command output, -// slash-command envelopes, interruption/compaction notices. These 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). Mirrors the mobile -// predicate in mobile/src/session/mobile-native-chat-noise.ts. - -const NOISE_PREFIXES = [ - '', - '', - '', - '', - '', - '', - '', - ' b.type === 'tool-call' || b.type === 'tool-result')) { return false } - const text = messageText(message).toLowerCase() - if (text.length === 0) { - return false - } - return NOISE_PREFIXES.some((prefix) => text.startsWith(prefix)) + return isHarnessInjectedUserTurnText(messageText(message)) } /** Drop harness-noise messages from a transcript. */ diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index a28cfe0bc19..cedc0efda32 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -808,6 +808,51 @@ describe('shared agent-hook-listener', () => { expect(event).toBeNull() }) + it('keeps the cached prompt when a harness-injected turn fires UserPromptSubmit', () => { + normalizeHookPayload( + state, + 'claude', + { paneKey: PANE_KEY, payload: { hook_event_name: 'UserPromptSubmit', prompt: 'fix login' } }, + 'production' + ) + // Why: the harness injects background task notifications as user turns; + // they must not replace the user's real prompt in status labels. + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: ' bzthj2b8r t1' + } + }, + 'production' + ) + expect(event).not.toBeNull() + expect(event!.payload.state).toBe('working') + expect(event!.payload.prompt).toBe('fix login') + expect(event!.hasExplicitPrompt).toBe(false) + }) + + it('resolves an empty prompt for a harness-injected turn with nothing cached', () => { + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: 'background context' + } + }, + 'production' + ) + expect(event).not.toBeNull() + expect(event!.payload.prompt).toBe('') + expect(event!.hasExplicitPrompt).toBe(false) + }) + it('isolates caches between listener instances', () => { const a = createHookListenerState() const b = createHookListenerState() diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 23f1e9d4c0f..e73d2ae9dc9 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -35,6 +35,7 @@ import { type AgentProviderSessionMetadata } from './agent-session-resume' import { parsePaneKey } from './stable-pane-id' +import { isHarnessInjectedUserTurnText } from './harness-injected-user-turns' /** Maximum request body size accepted by the listener (1 MB). */ export const HOOK_REQUEST_MAX_BYTES = 1_000_000 @@ -392,6 +393,12 @@ function resolvePrompt( promptText: string, options?: { resetOnNewTurn?: boolean } ): string { + // Why: harness-injected turns (task notifications, system reminders) fire + // UserPromptSubmit but are not the user's ask — keep the cached real prompt + // instead of surfacing raw machinery tags in status labels. + if (isHarnessInjectedUserTurnText(promptText)) { + return state.lastPromptByPaneKey.get(paneKey) ?? '' + } if (options?.resetOnNewTurn) { state.lastPromptByPaneKey.delete(paneKey) } @@ -2085,6 +2092,11 @@ function hasExplicitUserPrompt( if (extractedPrompt.text.length === 0) { return false } + // Why: harness-injected machinery turns are not proof of a user submit — + // they must not count for prompt-sent telemetry or permission stickiness. + if (isHarnessInjectedUserTurnText(extractedPrompt.text)) { + return false + } // Why: bare `message` fields often contain permission or status copy. They // may update visible status prompts, but they are not proof of user submit. if (extractedPrompt.source === 'message') { diff --git a/src/shared/harness-injected-user-turns.test.ts b/src/shared/harness-injected-user-turns.test.ts new file mode 100644 index 00000000000..1b012b7d4ba --- /dev/null +++ b/src/shared/harness-injected-user-turns.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { isHarnessInjectedUserTurnText } from './harness-injected-user-turns' + +describe('isHarnessInjectedUserTurnText', () => { + it('matches harness machinery turns by prefix', () => { + expect( + isHarnessInjectedUserTurnText( + ' bzthj2b8r toolu_01abc' + ) + ).toBe(true) + expect(isHarnessInjectedUserTurnText('context')).toBe(true) + expect(isHarnessInjectedUserTurnText('/review')).toBe(true) + expect(isHarnessInjectedUserTurnText('ok')).toBe( + true + ) + expect(isHarnessInjectedUserTurnText('[Request interrupted by user]')).toBe(true) + expect( + isHarnessInjectedUserTurnText('This session is being continued from a previous conversation.') + ).toBe(true) + }) + + it('is case-insensitive and ignores surrounding whitespace', () => { + expect(isHarnessInjectedUserTurnText(' done')).toBe(true) + expect(isHarnessInjectedUserTurnText('\n hi')).toBe(true) + }) + + it('keeps real user prompts, including ones that mention the tags', () => { + expect(isHarnessInjectedUserTurnText('fix the login bug')).toBe(false) + expect(isHarnessInjectedUserTurnText('why does show in the sidebar?')).toBe( + false + ) + expect(isHarnessInjectedUserTurnText('')).toBe(false) + expect(isHarnessInjectedUserTurnText(' ')).toBe(false) + }) +}) diff --git a/src/shared/harness-injected-user-turns.ts b/src/shared/harness-injected-user-turns.ts new file mode 100644 index 00000000000..21d732a0cc7 --- /dev/null +++ b/src/shared/harness-injected-user-turns.ts @@ -0,0 +1,29 @@ +// 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. +const HARNESS_INJECTED_TURN_PREFIXES = [ + '', + '', + '', + '', + '', + '', + '', + ' normalized.startsWith(prefix)) +}