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
2 changes: 2 additions & 0 deletions backend/internal/adapters/agent/activitydispatch/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ package activitydispatch
import (
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/claudecode"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/codex"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/droid"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/opencode"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
Expand All @@ -33,6 +34,7 @@ var Derivers = map[string]DeriveFunc{
"devin": claudecode.DeriveActivityState,
"opencode": opencode.DeriveActivityState,
"codex": codex.DeriveActivityState,
"droid": droid.DeriveActivityState,
}

// Derive looks up the deriver for an agent token and applies it. ok=false when
Expand Down
60 changes: 60 additions & 0 deletions backend/internal/adapters/agent/droid/activity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package droid

import (
"encoding/json"

"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)

// DeriveActivityState maps a Droid hook event (and its native stdin payload)
// onto an AO activity state. The bool is false when the event carries no
// activity signal — e.g. SessionStart (metadata only) or a SessionEnd reason
// that doesn't actually end the AO session — in which case the caller reports
// nothing.
//
// event is the AO hook sub-command name installed in droidManagedHooks
// ("user-prompt-submit", "stop", "notification", "session-end", ...), NOT the
// native Droid event name. Keeping this beside hooks.go means the events AO
// installs and what they mean live in one place.
//
// Droid's payload shapes differ from Claude Code's in one way that matters here:
// the Notification payload carries no notification_type discriminator (it only
// has a free-form message), but Droid only fires Notification when it needs a
// permission decision or has been idle awaiting input for 60s — both mean the
// agent is blocked on the user — so every Notification maps to waiting_input.
func DeriveActivityState(event string, payload []byte) (domain.ActivityState, bool) {
switch event {
case "user-prompt-submit":
return domain.ActivityActive, true
case "stop":
// End of a turn: the agent is idle but alive (not exited). A following
// Notification upgrades this to the sticky waiting_input.
return domain.ActivityIdle, true
case "notification":
return domain.ActivityWaitingInput, true
case "session-end":
return sessionEndState(payload)
default:
return "", false
}
}

// sessionEndState reports exited for reasons that actually end the session.
// "clear" keeps the same AO session alive (a new native session continues in
// the worktree), so it reports nothing. Any other reason — logout,
// prompt_input_exit, other, or an absent/unknown reason on a SessionEnd that did
// fire — is treated as a real exit. SessionEnd is not guaranteed on crash, so
// the reaper remains the backstop; both paths guard on IsTerminated, so
// whichever lands first wins.
func sessionEndState(payload []byte) (domain.ActivityState, bool) {
var p struct {
Reason string `json:"reason"`
}
_ = json.Unmarshal(payload, &p)
switch p.Reason {
case "clear":
return "", false
default:
return domain.ActivityExited, true
}
}
42 changes: 42 additions & 0 deletions backend/internal/adapters/agent/droid/activity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package droid

import (
"testing"

"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)

func TestDeriveActivityState(t *testing.T) {
tests := []struct {
name string
event string
payload string
want domain.ActivityState
wantOK bool
}{
{"user prompt -> active", "user-prompt-submit", `{}`, domain.ActivityActive, true},
{"stop -> idle", "stop", `{}`, domain.ActivityIdle, true},
// Droid notifications fire only on permission-needed or 60s-idle, both of
// which mean the agent is blocked on the user — and the payload carries no
// notification_type to discriminate — so every notification is waiting_input.
{"notification -> waiting_input", "notification", `{"message":"Droid needs your permission"}`, domain.ActivityWaitingInput, true},
{"notification empty payload -> waiting_input", "notification", `{}`, domain.ActivityWaitingInput, true},
{"notification malformed payload -> waiting_input", "notification", `not json`, domain.ActivityWaitingInput, true},
{"session-end logout -> exited", "session-end", `{"reason":"logout"}`, domain.ActivityExited, true},
{"session-end prompt_input_exit -> exited", "session-end", `{"reason":"prompt_input_exit"}`, domain.ActivityExited, true},
{"session-end other -> exited", "session-end", `{"reason":"other"}`, domain.ActivityExited, true},
{"session-end absent reason -> exited", "session-end", `{}`, domain.ActivityExited, true},
{"session-end clear -> no signal", "session-end", `{"reason":"clear"}`, "", false},
{"session-start -> no signal", "session-start", `{}`, "", false},
{"unknown event -> no signal", "frobnicate", `{}`, "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := DeriveActivityState(tt.event, []byte(tt.payload))
if got != tt.want || ok != tt.wantOK {
t.Fatalf("DeriveActivityState(%q, %q) = (%q, %v), want (%q, %v)",
tt.event, tt.payload, got, ok, tt.want, tt.wantOK)
}
})
}
}
Loading
Loading