fix(agent-status): keep harness-injected turns out of sidebar prompt labels (#7274)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-07-03 19:49:17 -07:00
committed by GitHub
co-authored by Orca
parent 0d2d44b989
commit bdd8bb0a3b
5 changed files with 130 additions and 25 deletions
@@ -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 = [
'<task-notification>',
'<system-reminder>',
'<local-command-stdout>',
'<local-command-caveat>',
'<command-name>',
'<command-message>',
'<command-args>',
'<bash-',
'[request interrupted',
'caveat: the messages below were generated by the user while running local commands',
'this session is being continued from a previous conversation'
]
// 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
// 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.
function messageText(message: NativeChatMessage): string {
return message.blocks
@@ -40,11 +27,7 @@ export function isNoiseMessage(message: NativeChatMessage): boolean {
if (message.blocks.some((b) => 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. */
+45
View File
@@ -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: '<task-notification> <task-id>bzthj2b8r</task-id> <tool-use-id>t1</tool-use-id>'
}
},
'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: '<system-reminder>background context</system-reminder>'
}
},
'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()
+12
View File
@@ -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') {
@@ -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(
'<task-notification> <task-id>bzthj2b8r</task-id> <tool-use-id>toolu_01abc</tool-use-id>'
)
).toBe(true)
expect(isHarnessInjectedUserTurnText('<system-reminder>context</system-reminder>')).toBe(true)
expect(isHarnessInjectedUserTurnText('<command-name>/review</command-name>')).toBe(true)
expect(isHarnessInjectedUserTurnText('<local-command-stdout>ok</local-command-stdout>')).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(' <TASK-NOTIFICATION> done')).toBe(true)
expect(isHarnessInjectedUserTurnText('\n<System-Reminder> 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 <task-notification> show in the sidebar?')).toBe(
false
)
expect(isHarnessInjectedUserTurnText('')).toBe(false)
expect(isHarnessInjectedUserTurnText(' ')).toBe(false)
})
})
+29
View File
@@ -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 = [
'<task-notification>',
'<system-reminder>',
'<local-command-stdout>',
'<local-command-caveat>',
'<command-name>',
'<command-message>',
'<command-args>',
'<bash-',
'[request interrupted',
'caveat: the messages below were generated by the user while running local commands',
'this session is being continued from a previous conversation'
]
/** True when prompt-like text is a harness-injected machinery turn rather
* than something the user typed. Prefix match on trimmed, lowercased text. */
export function isHarnessInjectedUserTurnText(text: string): boolean {
const normalized = text.trim().toLowerCase()
if (normalized.length === 0) {
return false
}
return HARNESS_INJECTED_TURN_PREFIXES.some((prefix) => normalized.startsWith(prefix))
}