Skip to content

bug(agent-claude-code): idle_prompt notification conflated with permission_prompt, both set activity=waiting_input #2525

Description

@mukesher4

Bug

Claude Code's Notification hook fires for two semantically different cases, and AO's Claude Code adapter maps both to the same activity state, waiting_input:

  • permission_prompt — the agent is genuinely blocked, can't proceed without a tool-permission decision.
  • idle_prompt — Claude Code's own client-side "I'm done and quiet for now" signal, fired after an ordinary end-of-turn idle period. Not a real blocker.

Source: internal investigation | Analyzed against: 8d405c42 (origin/main)
Confidence: High — traced exact code path, root cause confirmed against docs and live session data.
Priority: medium — no data loss, but a persistent, high-frequency, user-visible misclassification with a known-correct minimal fix.

Root Cause

backend/internal/adapters/agent/claudecode/activity.go, notificationState():

switch p.NotificationType {
case "idle_prompt", "permission_prompt":
    return domain.ActivityWaitingInput, true
default:
    return "", false
}

Both notification types collapse to domain.ActivityWaitingInput. Since ActivityWaitingInput is sticky (domain/activity.go, IsSticky()), and deriveStatus() (backend/internal/service/session/status.go) checks Activity.State == ActivityWaitingInput and returns StatusNeedsInput before ever evaluating PR facts, any session that merely finished a turn and went idle for a while — which is the normal, common resting state between turns — gets permanently mislabeled needs_input, identical to a session that's actually stuck on a real permission decision.

Evidence this is a defect, not intended behavior

docs/architecture.md's own state-machine diagram (Lifecycle Management section) documents the intended meaning of this state explicitly:

note right of Waiting
    Agent needs input
    Waiting for user
end note

— i.e. waiting_input is documented to mean "the agent genuinely needs a human/orchestrator decision," not "the agent is merely idle." idle_prompt firing after ordinary end-of-turn idling doesn't meet that bar, so mapping it to the same state as permission_prompt contradicts the state's own documented purpose.

Live confirmation of user-visible impact

Session agent-orchestrator-6 had an open, non-draft PR (#2485) with reviewDecision: REVIEW_REQUIRED — which should display as review_pending per deriveStatus()'s PR-pipeline mapping. Instead it displayed needs_input, solely because the session was sitting idle (activity.state: waiting_input) with no actual blocker. Confirmed via gh pr view 2485 + ao session get agent-orchestrator-6.

Reproduction

Deterministic, via the same hidden CLI path the harness itself uses (backend/internal/cli/hooks.go, ao hooks <agent> <event>, reads AO_SESSION_ID env + JSON payload from stdin, derives activity state, POSTs to the daemon):

  1. Pick (or spawn) any live, non-terminated AO session; note its id.
  2. Run, in that session's environment (or with AO_SESSION_ID exported to match it):
    export AO_SESSION_ID=<session-id>
    echo '{"notification_type":"idle_prompt"}' | ao hooks claude-code notification
  3. Check the result:
    ao session get <session-id> --json
    Actual: activity.state: "waiting_input", status: "needs_input" (or, if the session has an open PR, its real PR-derived status is masked by needs_input).
    Expected: since nothing about the agent is actually blocked, activity.state should reflect ordinary idleness (idle), and status should reflect the session's real state (idle, or its PR's actual pipeline status).

Real-world equivalent (non-deterministic, timing-dependent): let any Claude Code-backed AO session finish a turn and sit untouched past Claude Code's internal idle-notification threshold (undocumented exact duration) — the same transition occurs organically via the real hook, no manual invocation needed.

Suggested Fix

Narrow notificationState() so only permission_prompt maps to ActivityWaitingInput; idle_prompt maps to ActivityIdle instead:

switch p.NotificationType {
case "permission_prompt":
    return domain.ActivityWaitingInput, true
case "idle_prompt":
    return domain.ActivityIdle, true
default:
    return "", false
}

This is a deliberately narrow, minimal fix — it does not change deriveStatus()'s precedence (confirmed separately to be intentional, documented design, not itself defective), does not touch the frontend, and does not address the reaper's staleness-exemption behavior for waiting_input (tracked separately, see Related).

Impact

  • Sessions with open PRs show needs_input instead of their real, more actionable PR-pipeline status (e.g. review_pending, mergeable, ci_failed), for as long as they sit idle — which is most of the time in normal usage.
  • Sidebar/kanban "Needs You" zone gets populated with sessions that aren't actually blocked on anything, diluting its signal.
  • False-positive NotificationNeedsInput alerts/telemetry fire for ordinary end-of-turn idling, not just genuine blocks.

Related

  • #2174 — reports the same user-visible symptom (waiting_input masking PR status) but attributes it to deriveStatus()'s precedence and proposes reversing it. That precedence is confirmed intentional, documented architecture (docs/architecture.md, "Status Derivation" section, explicitly titled "using this precedence (highest to lowest)"); reversing it would also hide genuine permission-blocks behind PR status. This issue's fix addresses the actual upstream root cause instead — narrowing what triggers waiting_input in the first place, leaving the documented precedence untouched.
  • #2501 — the reaper's staleness-exemption for waiting_input sessions is a separate, already-filed concern. Note: fixing this issue as scoped (narrowing waiting_input) will incidentally increase exposure to bug(lifecycle): orchestrator terminated mid-turn by a stale-activity/probe race, then silently replaced with no linkage or notification #2501's failure mode for sessions that previously landed in waiting_input via idle_prompt (they lose an accidental reaper-exemption shield once reclassified as idle). This is a known, accepted tradeoff of shipping this fix on its own — bug(lifecycle): orchestrator terminated mid-turn by a stale-activity/probe race, then silently replaced with no linkage or notification #2501 should be addressed on its own timeline, not as a blocker here.
  • Other harnesses (droid) have an analogous idle/permission conflation in their own native Notification-equivalent hook, but lack a discriminator field in their payload to fix it the same way — out of scope here, tracked separately if needed.

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