Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions backend/internal/adapters/agent/claudecode/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -33,18 +36,22 @@ 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 {
NotificationType string `json:"notification_type"`
}
_ = 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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/cli/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 7 additions & 3 deletions backend/internal/domain/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading