Files
multica/packages/core/agents/derive-presence.test.ts
Naiyuan Qing f745a3bbbe feat(agent): presence v3 + execution log + trigger summary (#1823)
* refactor(views): migrate agent/runtime/skill lists to TanStack DataTable

Replace the per-page CSS Grid + minmax(min, fr) + sticky-first-col + truncate
implementation with a TanStack Table backend rendered through a Dice UI-style
DataTable shell. Column widths are now px-based via column.size, so cells
no longer shrink or auto-truncate as the viewport narrows; when the sum of
columns exceeds the viewport, the container scrolls horizontally instead.

- Add @tanstack/react-table to the catalog (8.21.3) and wire it into
  packages/ui (dep) and packages/views (peerDep).
- packages/ui: new DataTable + DataTableColumnHeader + lib/data-table.ts
  (getColumnPinningStyle), adapted from Dice UI's registry. The shell
  renders <table> directly (skipping shadcn's <Table> wrapper) so its own
  outer overflow controls both axes — no nested overflow conflicts.
- packages/views: each list now declares ColumnDef[] with explicit
  cell renderers. Row click navigates to detail via onRowClick (instead of
  wrapping <tr> in <a>, which is invalid HTML); kebab dropdowns
  stopPropagation so they don't trigger the row navigation.
- Drop the previous AGENT_LIST_GRID / GRID_WITH_OWNER / ROW_GRID
  templates and the sticky-first-col / subgrid mechanics that came with
  them. agent-list-item.tsx is removed; runtime-list.tsx and
  skills-page.tsx are trimmed to thin wrappers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agent): cap description at 255 chars (db + api + ui)

Symmetric enforcement across DB, server, and UI:

- Migration 060: pre-flight truncate of any oversize rows, then ADD
  CONSTRAINT NOT VALID + VALIDATE CONSTRAINT so the new check doesn't
  block writes during validation.
- Server handler validates utf8.RuneCountInString on Create/Update and
  rejects over-limit input with 400.
- Front-end gets AGENT_DESCRIPTION_MAX_LENGTH in core/agents/constants
  (single source of truth shared by the create dialog + edit modal +
  test suite) and a CharCounter component that warns at 90% and errors
  past the cap.
- Description editor moves from a 288px popover to a roomy modal.
  Editor body is mounted only while the dialog is open, so the local
  draft state is locked in at mount time and never reset by an external
  WS update — the React-recommended replacement for the
  useEffect(reset, [value]) anti-pattern.

Counted in code points everywhere (rune count / spread length /
char_length) so multibyte input agrees across all three layers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(views): data-table polish across runtime + skill lists

Builds on the DataTable migration in 2be0f287:

- Add ColumnMeta.grow flag — declared via TanStack module augmentation
  in ui/lib/data-table.ts. Columns marked meta.grow skip their inline
  width so fixed table-layout assigns them the leftover container space
  (no spacer column). The Title-grows / others-fixed pattern from
  Linear / GitHub PR rows.
- Authoritative table min-width = sum of column.size, applied to the
  <table> itself (fixed-layout ignores cell-level min-width per spec,
  so the floor has to live on the table).
- Header tightens to h-8 + uppercase + tracking-wider; pinned cells
  switch to opaque bg + group-hover so they cover content scrolling
  beneath them and follow row hover state.
- Toolbar slot removed from DataTable (callers wrap the toolbar
  themselves now — keeps DataTable single-purpose).

Also: hover-card popup stops contextmenu / auxclick / dblclick from
bubbling out (in addition to click). Stops the popup from triggering
ancestor handlers (e.g. issue list rows) on right-click / middle-click
without breaking Base UI's outside-click dismiss, which listens to
pointerdown — pointerdown is deliberately NOT stopped.

Runtime + skill list pages updated to use the new sizing model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(agent): drop LastTaskState, introduce 3-state Workload

Continues the presence-model rework started in #1794 / #1798.

The previous LastTaskState union (running / completed / failed /
cancelled / idle) carried historical outcome at the list level — a
runtime-healthy agent whose last task failed showed a sticky red dot
indistinguishable from a daemon-dead agent.

New model: presence is two orthogonal "right-now" dimensions:

  AgentAvailability — runtime reachability only (online / unstable /
                      offline). Drives the dot colour everywhere.
  Workload          — current load (working / queued / idle). Three
                      states, never historical. Failure / completion /
                      cancellation are surfaced via Recent Work + Inbox,
                      not list-level state.

`queued` (= nothing running, ≥1 queued) is an honest "stuck on offline
runtime" signal. To avoid amber flashes during the brief enqueue→claim
race on healthy runtimes, the queued chip composes with availability:
muted on online, warning amber otherwise.

Activity tab cleanup that follows from the new model:
  - failureReasonLabel relocated from agents/presence.ts to
    tabs/task-failure.ts (presence no longer owns historical state).
  - Recent Work paginates (5 initial, +20 per "Show more"); chat-session
    tasks are filtered out of every Agent-scoped surface to keep
    "team work" separate from private chat.
  - Agents page drops the lastTaskFilter chip group; users find broken
    agents via Inbox / Recent Work, not a list-level filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(task): trigger summary snapshot + task:queued lifecycle event

Two task-lifecycle improvements that ship together because they share
the same enqueue/retry hot paths and changes interleave inside task.go:

1. trigger_summary snapshot (migration 061)

   New nullable column on agent_task_queue. Comment-triggered tasks
   snapshot the comment content; autopilot tasks snapshot the run title.
   Truncated to 200 runes via strings.Builder so multibyte input counts
   correctly without O(N²) concatenation. Snapshot survives source
   edits/deletes — every task row self-describes across surfaces (issue
   detail Execution log, agent activity tooltip, inbox) without joining
   back to the originating row.

   Retry rows inherit the parent's snapshot (CreateRetryTask SELECT) so
   the description stays meaningful across attempts. The UI is
   responsible for stacking "Retry #N" context on top.

2. task:queued WS event

   New protocol event covering the ∅ → queued transition. Front-end
   types/events.ts registers it; use-realtime-sync's task: prefix path
   already invalidates task caches via onAny, so old clients without
   this exact-match subscription still refresh correctly. Specific
   subscribers (sticky banner) get sub-second updates instead of
   waiting for daemon claim.

   Retry path now broadcasts task:queued (not task:dispatch) — same
   status transition shape as enqueue, so all "new task created" paths
   agree on one event type.

   Ordering: broadcastTaskEvent runs *before* notifyTaskAvailable so
   the queued event is published into the WS bus before the daemon is
   poked. Without this, a fast daemon could claim and emit task:dispatch
   over the wire before the in-process queued broadcast fan-out reached
   clients — race window is tiny but unsafe-by-construction.

   Per-agent task list (agentTasksKeys.all) and per-issue task list
   (["issues","tasks"]) added to the task: invalidation set so Activity
   tab Recent Work and the Execution log section stay fresh.

Type contracts: AgentTask gains parent_task_id / attempt /
trigger_comment_id (already returned by the API, just missing from TS)
plus the new trigger_summary field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(issue): ExecutionLogSection — unified active+past runs panel

Replaces two pieces:
  - the click-to-expand timeline that lived inside AgentLiveCard
  - the standalone TaskRunHistory below the main content

with a single right-panel section that lists every agent run for the
issue. Active runs sit at the top (always visible when present); past
runs collapse behind a "Show past runs (N)" toggle, sorted failed →
cancelled → completed within group.

Active rows show the trigger summary, status + relative time, and
Cancel / Transcript actions on hover (gradient backdrop fades the
status text rather than hard-clipping). Past rows show the same
shape minus Cancel.

Retry tasks prepend "Retry #N · " to the inherited summary so they're
distinguishable from their parent (which would otherwise share the
exact same trigger text).

Cache key registered as issueKeys.tasks(issueId); the global
useRealtimeSync task: prefix path already invalidates ["issues","tasks"]
on every task lifecycle event, so the section stays fresh without
local WS subscriptions.

AgentLiveCard slims down to a header-only "agent is working" sticky
banner — keeps the at-a-glance "is anyone working on this right now"
signal and the Stop / Transcript actions, drops the inline timeline
that ExecutionLogSection now owns. Subscribes to both task:queued and
task:dispatch so retries (which only emit queued) land in the banner
without waiting for daemon claim.

issue-detail mounts ExecutionLogSection in the right panel and removes
the now-defunct TaskRunHistory call site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:50:58 +08:00

419 lines
14 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { Agent, AgentRuntime, AgentTask } from "../types";
import {
buildPresenceMap,
deriveAgentAvailability,
deriveAgentPresenceDetail,
deriveWorkload,
deriveWorkloadDetail,
} from "./derive-presence";
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: "agent-1",
workspace_id: "ws-1",
runtime_id: "rt-1",
name: "Test Agent",
description: "",
instructions: "",
avatar_url: null,
runtime_mode: "local",
runtime_config: {},
custom_env: {},
custom_args: [],
custom_env_redacted: false,
visibility: "workspace",
status: "idle",
max_concurrent_tasks: 6,
model: "",
owner_id: null,
skills: [],
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
archived_at: null,
archived_by: null,
...overrides,
};
}
function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
return {
id: "rt-1",
workspace_id: "ws-1",
daemon_id: "daemon-1",
name: "Test Runtime",
runtime_mode: "local",
provider: "claude",
launch_header: "",
status: "online",
device_info: "",
metadata: {},
owner_id: null,
last_seen_at: "2026-04-27T11:59:50Z",
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
...overrides,
};
}
// Anchor for all wall-clock comparisons in the suite. Pairs with the
// runtime fixture's last_seen_at (10s before NOW) so an "online" runtime
// looks fresh by default.
const NOW = new Date("2026-04-27T12:00:00Z").getTime();
function makeTask(overrides: Partial<AgentTask> = {}): AgentTask {
return {
id: "task-1",
agent_id: "agent-1",
runtime_id: "rt-1",
issue_id: "",
status: "queued",
priority: 0,
dispatched_at: null,
started_at: null,
completed_at: null,
result: null,
error: null,
created_at: "2026-04-27T11:00:00Z",
...overrides,
};
}
describe("deriveAgentAvailability", () => {
// Reachability dimension only — runtime + clock decide it; tasks are
// irrelevant to this axis.
it("returns online when runtime is fresh-online", () => {
expect(deriveAgentAvailability(makeRuntime(), NOW)).toBe("online");
});
it("returns unstable when runtime just dropped (< 5 min)", () => {
expect(
deriveAgentAvailability(
makeRuntime({ status: "offline", last_seen_at: "2026-04-27T11:59:30Z" }),
NOW,
),
).toBe("unstable");
});
it("returns offline when runtime has been gone > 5 min", () => {
expect(
deriveAgentAvailability(
makeRuntime({ status: "offline", last_seen_at: "2026-04-27T11:50:00Z" }),
NOW,
),
).toBe("offline");
});
it("collapses about_to_gc into offline (it's a runtime-card concern, not the dot)", () => {
expect(
deriveAgentAvailability(
// 6.5 days ago — past the 6-day about_to_gc threshold.
makeRuntime({ status: "offline", last_seen_at: "2026-04-21T00:00:00Z" }),
NOW,
),
).toBe("offline");
});
it("returns offline when the runtime is null (deleted / never registered)", () => {
expect(deriveAgentAvailability(null, NOW)).toBe("offline");
});
});
describe("deriveWorkload", () => {
// Atomic 3-way classifier — used by both Agent (per-agent task counts)
// and Runtime (per-runtime aggregated counts). Pure functional mapping
// from a count pair to a workload label.
it("returns working when runningCount > 0", () => {
expect(deriveWorkload({ runningCount: 1, queuedCount: 0 })).toBe("working");
expect(deriveWorkload({ runningCount: 3, queuedCount: 5 })).toBe("working");
});
it("returns queued when nothing running but queuedCount > 0", () => {
expect(deriveWorkload({ runningCount: 0, queuedCount: 1 })).toBe("queued");
expect(deriveWorkload({ runningCount: 0, queuedCount: 5 })).toBe("queued");
});
it("returns idle when both counts are zero", () => {
expect(deriveWorkload({ runningCount: 0, queuedCount: 0 })).toBe("idle");
});
});
describe("deriveWorkloadDetail", () => {
// Aggregates a task list into running/queued counts before classifying.
// Terminal statuses (completed / failed / cancelled) are silently
// ignored — workload is "what's on the plate right now", not history.
it("returns idle when no tasks at all", () => {
const r = deriveWorkloadDetail([]);
expect(r.workload).toBe("idle");
expect(r.runningCount).toBe(0);
expect(r.queuedCount).toBe(0);
});
it("returns working when at least one task is running", () => {
const r = deriveWorkloadDetail([makeTask({ status: "running" })]);
expect(r.workload).toBe("working");
expect(r.runningCount).toBe(1);
expect(r.queuedCount).toBe(0);
});
it("returns queued when only queued / dispatched tasks exist (no running)", () => {
// The "stuck on offline runtime" scenario in isolation: runningCount=0,
// queuedCount>0 surfaces as `queued` so the UI can honestly say
// "Queued · N" instead of misleading "Running 0/3 +Nq".
const r = deriveWorkloadDetail([
makeTask({ status: "queued" }),
makeTask({ id: "t2", status: "dispatched" }),
]);
expect(r.workload).toBe("queued");
expect(r.runningCount).toBe(0);
expect(r.queuedCount).toBe(2);
});
it("returns working when running coexists with queued (overflow)", () => {
// Capacity-saturated agent: still running, but with a queue building.
// The chip says "Working" with the queue expressed as a `+Nq` badge.
const r = deriveWorkloadDetail([
makeTask({ id: "t1", status: "running" }),
makeTask({ id: "t2", status: "queued" }),
makeTask({ id: "t3", status: "queued" }),
]);
expect(r.workload).toBe("working");
expect(r.runningCount).toBe(1);
expect(r.queuedCount).toBe(2);
});
it("ignores terminal statuses entirely (no historical state in workload)", () => {
// Failed / completed / cancelled tasks contribute no count and don't
// change the verdict — Recent Work + Inbox handle history, not workload.
const r = deriveWorkloadDetail([
makeTask({
id: "t-failed",
status: "failed",
completed_at: "2026-04-27T11:30:00Z",
}),
makeTask({
id: "t-completed",
status: "completed",
completed_at: "2026-04-27T11:00:00Z",
}),
makeTask({
id: "t-cancelled",
status: "cancelled",
completed_at: "2026-04-27T10:30:00Z",
}),
]);
expect(r.workload).toBe("idle");
expect(r.runningCount).toBe(0);
expect(r.queuedCount).toBe(0);
});
it("classifies running over queued when both present, regardless of order", () => {
const r = deriveWorkloadDetail([
makeTask({ id: "t1", status: "queued" }),
makeTask({ id: "t2", status: "running" }),
]);
expect(r.workload).toBe("working");
});
});
describe("deriveAgentPresenceDetail", () => {
// Composition: the two dimensions are derived independently and the
// detail object exposes both. No cross-axis override — workload never
// colours the dot, availability never overrides workload.
it("composes online + working for the common busy case", () => {
const detail = deriveAgentPresenceDetail({
agent: makeAgent(),
runtime: makeRuntime(),
tasks: [
makeTask({ status: "running" }),
makeTask({ id: "t2", status: "queued" }),
],
now: NOW,
});
expect(detail.availability).toBe("online");
expect(detail.workload).toBe("working");
expect(detail.runningCount).toBe(1);
expect(detail.queuedCount).toBe(1);
expect(detail.capacity).toBe(6);
});
it("composes offline + queued — the canonical 'stuck' case (was previously misleading 'running 0/N')", () => {
// The motivation for the redesign: runtime offline + queued tasks
// used to surface as `running` with `0/3 +2q` counts (literally false).
// Workload now returns `queued` honestly, paired with offline
// availability — UI reads "Offline · Queued · 2".
const detail = deriveAgentPresenceDetail({
agent: makeAgent(),
runtime: makeRuntime({
status: "offline",
last_seen_at: "2026-04-27T11:50:00Z",
}),
tasks: [
makeTask({ status: "queued" }),
makeTask({ id: "t2", status: "queued" }),
],
now: NOW,
});
expect(detail.availability).toBe("offline");
expect(detail.workload).toBe("queued");
expect(detail.runningCount).toBe(0);
expect(detail.queuedCount).toBe(2);
});
it("composes unstable + working — runtime hiccup with tasks still in flight", () => {
// Recently-lost runtime, but a task is still recorded as running.
// Both signals surface independently — amber dot AND working chip —
// so the user sees "connection wobbling" alongside "agent is busy".
const detail = deriveAgentPresenceDetail({
agent: makeAgent(),
runtime: makeRuntime({
status: "offline",
last_seen_at: "2026-04-27T11:59:00Z",
}),
tasks: [makeTask({ status: "running" })],
now: NOW,
});
expect(detail.availability).toBe("unstable");
expect(detail.workload).toBe("working");
});
it("composes offline + idle for an unreachable agent with no tasks pending", () => {
const detail = deriveAgentPresenceDetail({
agent: makeAgent(),
runtime: makeRuntime({
status: "offline",
last_seen_at: "2026-04-27T11:50:00Z",
}),
tasks: [],
now: NOW,
});
expect(detail.availability).toBe("offline");
expect(detail.workload).toBe("idle");
});
it("handles a missing runtime by reporting offline + the task-driven workload", () => {
const detail = deriveAgentPresenceDetail({
agent: makeAgent(),
runtime: null,
tasks: [makeTask({ status: "running" })],
now: NOW,
});
expect(detail.availability).toBe("offline");
expect(detail.workload).toBe("working");
});
it("returns idle workload when only terminal tasks are present (history doesn't bleed in)", () => {
const detail = deriveAgentPresenceDetail({
agent: makeAgent(),
runtime: makeRuntime(),
tasks: [
makeTask({
status: "failed",
completed_at: "2026-04-27T11:30:00Z",
}),
],
now: NOW,
});
expect(detail.availability).toBe("online");
expect(detail.workload).toBe("idle");
});
it("mirrors agent.max_concurrent_tasks into capacity", () => {
const detail = deriveAgentPresenceDetail({
agent: makeAgent({ max_concurrent_tasks: 3 }),
runtime: makeRuntime(),
tasks: [],
now: NOW,
});
expect(detail.capacity).toBe(3);
});
});
describe("buildPresenceMap", () => {
it("returns one entry per agent, sourcing tasks by agent_id from a flat list", () => {
const agentA = makeAgent({ id: "a", runtime_id: "rt-1" });
const agentB = makeAgent({ id: "b", runtime_id: "rt-1" });
const map = buildPresenceMap({
agents: [agentA, agentB],
runtimes: [makeRuntime()],
snapshot: [
makeTask({ id: "t1", agent_id: "a", status: "running" }),
makeTask({ id: "t2", agent_id: "b", status: "queued" }),
],
now: NOW,
});
const a = map.get("a");
const b = map.get("b");
expect(a?.availability).toBe("online");
expect(a?.workload).toBe("working");
expect(b?.availability).toBe("online");
expect(b?.workload).toBe("queued");
});
it("returns offline availability for agents whose runtime_id has no matching runtime", () => {
const orphan = makeAgent({ id: "orphan", runtime_id: "missing" });
const map = buildPresenceMap({
agents: [orphan],
runtimes: [],
snapshot: [makeTask({ agent_id: "orphan", status: "running" })],
now: NOW,
});
const o = map.get("orphan");
expect(o?.availability).toBe("offline");
// Workload still resolves independently — running task counts.
expect(o?.workload).toBe("working");
});
it("threads the same `now` so every agent on a shared runtime gets the same availability", () => {
// Multi-agent scenario: one local daemon backs N agents, daemon dies.
// All dependent agents should report unstable together — the shared
// `now` parameter is what guarantees consistent bucket boundaries.
const agentA = makeAgent({ id: "a", runtime_id: "rt-1" });
const agentB = makeAgent({ id: "b", runtime_id: "rt-1" });
const map = buildPresenceMap({
agents: [agentA, agentB],
runtimes: [
makeRuntime({
status: "offline",
last_seen_at: "2026-04-27T11:59:00Z",
}),
],
snapshot: [
makeTask({ id: "t1", agent_id: "a", status: "queued" }),
makeTask({ id: "t2", agent_id: "b", status: "running" }),
],
now: NOW,
});
expect(map.get("a")?.availability).toBe("unstable");
expect(map.get("b")?.availability).toBe("unstable");
// Workload remains independent: a is queued (waiting), b is working.
expect(map.get("a")?.workload).toBe("queued");
expect(map.get("b")?.workload).toBe("working");
});
it("ignores terminal tasks in the snapshot when building per-agent workload", () => {
// Snapshot intentionally still includes each agent's most recent
// terminal task (back-end SQL didn't change); the front-end now
// filters them out at the workload-derivation step.
const agentA = makeAgent({ id: "a", runtime_id: "rt-1" });
const map = buildPresenceMap({
agents: [agentA],
runtimes: [makeRuntime()],
snapshot: [
makeTask({
id: "t-terminal",
agent_id: "a",
status: "failed",
completed_at: "2026-04-27T11:30:00Z",
}),
],
now: NOW,
});
expect(map.get("a")?.workload).toBe("idle");
});
});