Fix F3: use known-tag predicate for explicit-turn decisions

isHarnessInjectedUserTurnText matches any multi-word kebab tag, which is
safe for non-destructive UI classification but wrong for state-transition
decisions: a real prompt starting with a custom <my-element> paste looked
like machinery in resolvePrompt/hasExplicitUserPrompt. Combined with the
post-interrupt working-suppression in agent-hooks/server.ts, that left the
agent visibly done after Ctrl+C. Switch both explicit-turn callsites to the
known-tag isKnownHarnessInjectedUserTurnText predicate; known harness tags
and Grok <user_query> behave unchanged.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-07-11 17:31:53 -07:00
co-authored by Orca
parent 3c4518a574
commit 092f52c2c7
2 changed files with 52 additions and 4 deletions
+43
View File
@@ -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 (<my-custom-element>)
// 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: '<my-custom-element> render this component'
}
},
'production'
)
expect(event).not.toBeNull()
expect(event!.payload.prompt).toBe('<my-custom-element> 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: '<user_query>fix the bug</user_query>' }
},
'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()
+9 -4
View File
@@ -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 `<my-element>` 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
// `<my-element>` 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