Files
multica/server/internal/handler/squad_member_status_test.go
Jiayuan Zhang 46c1e2c889 feat(squads): show member working status on squad detail page (#2768)
* feat(squads): show member working status on squad detail page

Add a new GET /api/squads/{id}/members/status endpoint that returns each
member's derived working/idle/offline/unstable status, the issues each
agent is currently running, and the last observed activity timestamp.
The Squad detail page's Members tab consumes this snapshot to render a
status pill and an active-issue link next to each agent, with live
refresh wired through the existing task/agent/daemon WS events.

Human members are returned with status=null so the UI can keep them in
the same list without implying a presence signal. Archived agents stay
in the response and surface as offline rather than being filtered out.

Co-authored-by: multica-agent <github@multica.ai>

* fix(squads): address review feedback on member status endpoint

- i18n the "blocked" issue-status pill in squad members tab (was a
  bare literal that failed `i18next/no-literal-string` lint).
- Treat any dispatched/running task as working, even when its
  `agent_task_queue.issue_id` is NULL (chat / quick-create tasks).
  The agent slot is occupied regardless of whether we can render an
  issue link.
- Force `offline` for archived agents so they appear in the list
  but never look like they're still on duty, matching the RFC
  decision in MUL-2319.
- Include `workspaceKeys.squads` in the post-reconnect /
  workspace-switch bulk invalidation so members-status recovers
  after a disconnect during which task/runtime events were missed.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 10:35:18 +02:00

53 lines
1.9 KiB
Go

package handler
import (
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
func TestDeriveSquadMemberStatus(t *testing.T) {
now := time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)
online := pgtype.Text{String: "online", Valid: true}
offline := pgtype.Text{String: "offline", Valid: true}
missing := pgtype.Text{}
tsAgo := func(d time.Duration) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: now.Add(-d), Valid: true}
}
tsNone := pgtype.Timestamptz{}
cases := []struct {
name string
archived bool
runtimeStatus pgtype.Text
lastSeen pgtype.Timestamptz
hasActiveTask bool
want string
}{
{"active wins over offline runtime", false, offline, tsAgo(time.Hour), true, "working"},
{"active wins over missing runtime", false, missing, tsNone, true, "working"},
{"online runtime, no task", false, online, tsAgo(2 * time.Second), false, "idle"},
{"offline runtime, recent heartbeat", false, offline, tsAgo(2 * time.Minute), false, "unstable"},
{"offline runtime, stale heartbeat", false, offline, tsAgo(2 * time.Hour), false, "offline"},
{"offline runtime, no heartbeat", false, offline, tsNone, false, "offline"},
{"no runtime row", false, missing, tsNone, false, "offline"},
// Archived agents always report offline regardless of any leftover
// runtime row or task — they should appear in the squad listing
// but never look like they're still working.
{"archived agent with active task", true, online, tsAgo(time.Second), true, "offline"},
{"archived agent with online runtime", true, online, tsAgo(time.Second), false, "offline"},
{"archived agent already offline", true, offline, tsAgo(time.Hour), false, "offline"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := deriveSquadMemberStatus(tc.archived, tc.runtimeStatus, tc.lastSeen, tc.hasActiveTask, now)
if got != tc.want {
t.Fatalf("deriveSquadMemberStatus = %q, want %q", got, tc.want)
}
})
}
}