diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 30040a1ca74..39a09f8fa6d 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -923,6 +923,49 @@ describe('shared agent-hook-listener', () => { expect(event!.hasExplicitPrompt).toBe(false) }) + it('treats a custom-element paste as an explicit user turn, not machinery', () => { + normalizeHookPayload( + state, + 'claude', + { paneKey: PANE_KEY, payload: { hook_event_name: 'UserPromptSubmit', prompt: 'fix login' } }, + 'production' + ) + // Why: a real prompt starting with an unknown kebab tag () + // is the user's turn — it must reset the cached prompt and count as explicit, + // so interrupt recovery does not leave the pane visibly done. + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: ' render this component' + } + }, + 'production' + ) + expect(event).not.toBeNull() + expect(event!.payload.prompt).toBe(' render this component') + expect(event!.hasExplicitPrompt).toBe(true) + }) + + it('treats a Grok user_query prompt as an explicit user turn', () => { + const event = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { hookEventName: 'user_prompt_submit', prompt: 'fix the bug' } + }, + 'production' + ) + expect(event).not.toBeNull() + // Grok wraps the real typed prompt; the envelope is stripped but it stays explicit. + expect(event!.payload.prompt).toBe('fix the bug') + expect(event!.hasExplicitPrompt).toBe(true) + }) + 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 764b9f8f6ee..6534650dc1e 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -52,7 +52,7 @@ import { type AgentProviderSessionMetadata } from './agent-session-resume' import { parsePaneKey } from './stable-pane-id' -import { isHarnessInjectedUserTurnText } from './harness-injected-user-turns' +import { isKnownHarnessInjectedUserTurnText } from './harness-injected-user-turns' import { buildGrokChatHistoryPathCandidates, findGrokChatHistoryBySessionId, @@ -451,8 +451,10 @@ function resolvePrompt( ): 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)) { + // instead of surfacing raw machinery tags in status labels. Match only known + // harness tags: a real prompt pasting a custom `` must reset the + // turn, not be mistaken for machinery and leave the pane on a stale prompt. + if (isKnownHarnessInjectedUserTurnText(promptText)) { return state.lastPromptByPaneKey.get(paneKey) ?? '' } if (options?.resetOnNewTurn) { @@ -2266,7 +2268,10 @@ function hasExplicitUserPrompt( } // 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)) { + // Match only known harness tags: a real prompt starting with a custom + // `` is an explicit user turn and must survive interrupt recovery + // (a false "not explicit" leaves the agent visibly done after Ctrl+C). + if (isKnownHarnessInjectedUserTurnText(extractedPrompt.text)) { return false } // Why: bare `message` fields often contain permission or status copy. They