diff --git a/backend/internal/adapters/agent/claudecode/activity.go b/backend/internal/adapters/agent/claudecode/activity.go index a0e49119d8..c1f397dbb3 100644 --- a/backend/internal/adapters/agent/claudecode/activity.go +++ b/backend/internal/adapters/agent/claudecode/activity.go @@ -10,7 +10,9 @@ import ( // payload) onto an AO activity state. The bool is false when the event carries // no activity signal — e.g. SessionStart (metadata only, v1), a Notification // type we don't track, or a SessionEnd reason that doesn't actually end the AO -// session — in which case the caller reports nothing. +// session — in which case the caller reports nothing. WaitingInput is reserved +// for genuine blocking prompts (permission_prompt); routine end-of-turn +// idleness (stop, idle_prompt) reports Idle. // // event is the AO hook sub-command name installed in claudeManagedHooks // ("user-prompt-submit", "stop", "notification", "session-end", ...), NOT the @@ -22,7 +24,8 @@ func DeriveActivityState(event string, payload []byte) (domain.ActivityState, bo return domain.ActivityActive, true case "stop": // End of a turn: the agent is idle but alive (not exited). A following - // Notification(idle_prompt) upgrades this to the sticky waiting_input. + // Notification(idle_prompt) just reaffirms ActivityIdle — it's a routine + // "done and quiet for now" signal, not a real block. return domain.ActivityIdle, true case "notification": return notificationState(payload) @@ -33,9 +36,11 @@ func DeriveActivityState(event string, payload []byte) (domain.ActivityState, bo } } -// notificationState reports waiting_input only for the notification types that -// mean "the agent is blocked on the user": a pending tool-permission prompt or -// an idle prompt awaiting the next instruction. Other types (auth_success, +// notificationState reports waiting_input only for permission_prompt: the +// agent is genuinely blocked on a tool-permission decision that requires a +// human/orchestrator response. idle_prompt fires after ordinary end-of-turn +// idling — it's a routine "done and quiet for now" signal, not a real +// blocker — so it maps to ActivityIdle instead. Other types (auth_success, // elicitation_*) carry no activity meaning, as does a malformed payload. func notificationState(payload []byte) (domain.ActivityState, bool) { var p struct { @@ -43,8 +48,10 @@ func notificationState(payload []byte) (domain.ActivityState, bool) { } _ = json.Unmarshal(payload, &p) switch p.NotificationType { - case "idle_prompt", "permission_prompt": + case "permission_prompt": return domain.ActivityWaitingInput, true + case "idle_prompt": + return domain.ActivityIdle, true default: return "", false } diff --git a/backend/internal/adapters/agent/claudecode/activity_test.go b/backend/internal/adapters/agent/claudecode/activity_test.go index c503dc6290..bd8cb881b8 100644 --- a/backend/internal/adapters/agent/claudecode/activity_test.go +++ b/backend/internal/adapters/agent/claudecode/activity_test.go @@ -16,7 +16,7 @@ func TestDeriveActivityState(t *testing.T) { }{ {"user prompt -> active", "user-prompt-submit", `{}`, domain.ActivityActive, true}, {"stop -> idle", "stop", `{}`, domain.ActivityIdle, true}, - {"notification idle_prompt -> waiting_input", "notification", `{"notification_type":"idle_prompt"}`, domain.ActivityWaitingInput, true}, + {"notification idle_prompt -> idle", "notification", `{"notification_type":"idle_prompt"}`, domain.ActivityIdle, true}, {"notification permission_prompt -> waiting_input", "notification", `{"notification_type":"permission_prompt"}`, domain.ActivityWaitingInput, true}, {"notification auth_success -> no signal", "notification", `{"notification_type":"auth_success"}`, "", false}, {"notification empty type -> no signal", "notification", `{}`, "", false}, diff --git a/backend/internal/cli/hooks_test.go b/backend/internal/cli/hooks_test.go index 25723f87eb..56fa81ba6d 100644 --- a/backend/internal/cli/hooks_test.go +++ b/backend/internal/cli/hooks_test.go @@ -62,7 +62,7 @@ func TestHooks_NotificationReportsWaitingInput(t *testing.T) { writeRunFileFor(t, cfg, srv) _, errOut, err := executeCLI(t, Deps{ - In: strings.NewReader(`{"notification_type":"idle_prompt"}`), + In: strings.NewReader(`{"notification_type":"permission_prompt"}`), ProcessAlive: func(int) bool { return true }, }, "hooks", "claude-code", "notification") if err != nil { diff --git a/backend/internal/domain/activity.go b/backend/internal/domain/activity.go index f4a5126966..2280b6b58f 100644 --- a/backend/internal/domain/activity.go +++ b/backend/internal/domain/activity.go @@ -8,14 +8,18 @@ type ActivityState string // Activity states. WaitingInput is sticky (see IsSticky). const ( - ActivityActive ActivityState = "active" - ActivityIdle ActivityState = "idle" + ActivityActive ActivityState = "active" + ActivityIdle ActivityState = "idle" + // ActivityWaitingInput means the agent is genuinely blocked on a + // human/orchestrator decision (e.g. a tool-permission prompt) — not merely + // idle at the end of an ordinary turn. ActivityWaitingInput ActivityState = "waiting_input" ActivityExited ActivityState = "exited" ) // IsSticky reports whether an activity state must NOT be aged/demoted by the -// passage of time (a paused agent is still paused until a new signal says so). +// passage of time (an agent genuinely blocked on a decision is still blocked +// until a new signal says otherwise). func (a ActivityState) IsSticky() bool { return a == ActivityWaitingInput }