Skip to content

bug(hook): SDK session string-form user prompts defeat stop-hook staleness guard (re-opens #60) #68

@codevibesmatter

Description

@codevibesmatter

Summary

The stop-hook escape hatch hasActiveBackgroundAgents() (src/commands/hook.ts:610-671) is silently broken for SDK-driven sessions (entrypoint: "sdk-ts" — duraclaw, eval harness, anything using @anthropic-ai/claude-agent-sdk). The staleness heuristic added in PR #61 for issue #60 never fires because SDK sessions encode typed user prompts as a bare string on message.content, not as an [{type:'text', text:'...'}] array. Unmatched Agent tool_use IDs persist forever and the guard false-allows exit for the rest of the session — the exact symptom #60 was filed to fix, still present.

Findings

The guard assumes array-of-blocks shapesrc/commands/hook.ts:640-659:

const contentBlocks = (message.content as Array<...>) ?? []
for (const block of contentBlocks) {
  if (block.type === 'text' && block.text?.trim()) hasUserText = true
  ...
}
if (hasUserText && !hasToolResult) agentToolUseIds.clear()  // #61 staleness clear

SDK sessions produce string-form content:

{"type":"user","message":{"role":"user","content":"fix the button position"}}

When message.content is a string, for...of iterates characters. Each char is a one-letter primitive with no .typehasUserText stays false → agentToolUseIds.clear() never runs → stale IDs persist forever.

Empirical data — one representative SDK transcript (~/.claude/projects/-data-projects-duraclaw-dev2/8d76757f-....jsonl, 594 lines, version: 2.1.98, entrypoint: sdk-ts):

user message.content shape count
STRING (typed prompt) 5
ARRAY with [0].type == 'tool_result' 138
ARRAY with [0].type == 'text' 0

A comparable CLI-entrypoint session in the same tree had 4 ARRAY_text entries — that's where #61's heuristic works today. SDK sessions literally never produce the shape that triggers it.

Secondary issue: tool-spawn tool name differs across CC versions. Guard only matches block.name === 'Agent' (hook.ts:631), but 2.1.50 sessions emit "name":"Task". Recent 2.1.98+ SDK/CLI sessions use Agent. The eval harness allowedTools whitelist includes Task (eval/harness.ts:310).

Follow-up

Order by smallest-diff-first:

  • Normalize content in hook.ts scanner: treat non-empty string as [{type:'text',text:content}]. Single-line fix, restores fix(hook): ignore stale unmatched Agent tool_uses in stop-conditions #61 staleness clear for SDK sessions.
  • Time-based staleness floor (issue bug(hook): stop-conditions false-allows exit due to stale unmatched Agent tool_uses #60 recommendation (a)): ignore Agent tool_use IDs older than e.g. 120s. Necessary for fire-and-forget SDK orchestrators that never send a second user prompt.
  • Match block.name === 'Task' too for older CC/SDK compatibility.
  • Unit tests in src/commands/hook.test.ts:
    • SDK fixture with string-content user prompt after unmatched Agent → guard returns false
    • 2.1.50-shape transcript using "name":"Task" → guard detects when fresh, ignores when stale
    • Recency-filter boundary case

Evidence

Research doc: planning/research/2026-04-23-sdk-stop-hook-guard-failure.md (commit 1e4e30e).

Related: #60 (closed by #61 — incomplete fix for SDK sessions).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions