diff --git a/a2a_impl/executor.py b/a2a_impl/executor.py index 6fa3b021..9bcd69b9 100644 --- a/a2a_impl/executor.py +++ b/a2a_impl/executor.py @@ -117,10 +117,12 @@ def _notify_terminal(outcome: TurnOutcome) -> None: # A progress hook (ADR 0051) the host can register to observe a turn's realtime -# frames — fired at turn start (``turn_started``, which carries the task_id) and on -# each tool start/end with ``(context_id, task_id, frame)``. No-op when unset, so live -# turns (which already stream over their own SSE) pay nothing; the background-subagents -# publisher uses it to surface a detached turn's progress on the event bus. +# frames — fired at turn start (``turn_started``, which carries the task_id + a +# ``resumed`` flag for HITL continuations), on each tool start/end, and on a HITL +# pause (``input_required``, with the human-readable prompt) with +# ``(context_id, task_id, frame)``. No-op when unset, so live turns (which already +# stream over their own SSE) pay nothing; the host publisher uses it to surface +# detached-turn progress and HITL pauses on the event bus. _ON_PROGRESS: list[Callable[[str, str, dict], None] | None] = [None] @@ -242,8 +244,12 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non ) await updater.start_work() # Realtime: announce the turn (carries the task_id — the handle a host needs to - # control or re-attach to this turn) before any work runs (ADR 0051). - _notify_progress(context.context_id, context.task_id, {"phase": "turn_started"}) + # control or re-attach to this turn) before any work runs (ADR 0051). `resumed` + # marks a HITL continuation (the parked task got its answer), so a host can flip + # a "needs approval" surface back to "running" (#2132). + _notify_progress( + context.context_id, context.task_id, {"phase": "turn_started", "resumed": resume} + ) text = context.get_user_input() images = _extract_image_parts(context) @@ -508,6 +514,15 @@ def _outcome(state: str, final_text: str) -> TurnOutcome: elif event_type == "input_required": await _flush_text() # persist any answer text streamed before the pause + # Surface the pause to the host hook (ADR 0051) so it can publish a + # "needs approval" bus event — a fleet console can then show a pending + # HITL without polling. Best-effort by contract (_notify_progress + # swallows everything), so it can never break the pause itself. + _notify_progress( + context.context_id, + context.task_id, + {"phase": "input_required", "prompt": _hitl_prompt(payload)}, + ) # Human-readable prompt for plain consumers; the full # form/approval payload rides a protoAgent-local hitl-v1 # DataPart so the console renders the form / approval card. diff --git a/apps/web/e2e/fleet.spec.ts b/apps/web/e2e/fleet.spec.ts index ecc93aef..b33cd98d 100644 --- a/apps/web/e2e/fleet.spec.ts +++ b/apps/web/e2e/fleet.spec.ts @@ -219,60 +219,67 @@ test("discover → add to fleet → switch into the remote member (ADR 0042 §I) await expect(page.locator(".fleet-row", { hasText: "remy" })).toHaveCount(0); }); -// ── Command-palette fleet toggle (#1769) ─────────────────────────────────────────── -// Toggle a local, non-host member on/off straight from ⌘K — no Settings dive. The -// picker lists togglable members with their live process state; picking one flips it and -// toasts. Reuses this file's per-spec fleet scope (beforeEach resets to baseline). - -// The DS palette morphs views with a popLayout cross-fade, so the exiting root list lingers -// in the DOM for a beat next to the entering sub-view. A sub-view row's accessible name is -// " " (e.g. "ava on") — and the sub-view states ("on"/"off") never collide with -// the root quick-chat's hints ("switch"/"stopped"/"unreachable"), so keying every assertion -// on the exact " " name sidesteps the transient overlap entirely. -async function openToggleFleet(page) { +// ── Folded-in fleet controls (#1733 quick-chat + #1769 toggle → the Fleet Room) ───── +// The per-member root commands and the `Toggle Fleet Agent ▸` submorph are gone: member +// names ride the Fleet Room command's keywords, and the roster row carries DM / open / +// start / stop. These tests pin the fold — the old flows keep working, one hop away. + +test("⌘K root: member names surface the Fleet Room; the old per-member commands are gone", async ({ page }) => { + await page.goto("/app/", { waitUntil: "load" }); await page.keyboard.press("ControlOrMeta+k"); await expect(page.locator(".pl-cmdk__panel")).toBeVisible(); - // A reopen momentarily re-morphs the last sub-view back to the root view (the DS palette - // resets its stack on open), so the sub-view's search box lingers next to the root one for a - // beat — wait for it to leave before filling, or `.pl-cmdk-commands__input` is ambiguous. - await expect(page.getByPlaceholder("Toggle a fleet agent on/off…")).toHaveCount(0); - // Filter the root list to the toggle command, then enter its submorph. - await page.locator(".pl-cmdk__panel .pl-cmdk-commands__input").fill("Toggle Fleet Agent"); - await page.getByRole("option", { name: "Toggle Fleet Agent" }).click(); - await expect(page.locator(".pl-cmdk__title")).toHaveText("Toggle Fleet Agent"); // in the sub-view -} - -const row = (page, name, state) => page.getByRole("option", { name: `${name} ${state}`, exact: true }); + const input = page.locator(".pl-cmdk__panel .pl-cmdk-commands__input"); + // The toggle submorph is folded away. + await input.fill("Toggle Fleet Agent"); + await expect(page.getByRole("option", { name: "Toggle Fleet Agent" })).toHaveCount(0); + // Typing a member's name routes to the room (roster keywords), not a per-member row. + await input.fill("ava"); + await expect(page.getByRole("option", { name: "Fleet Room" })).toBeVisible(); + await expect(page.getByRole("option", { name: /^ava\b/ })).toHaveCount(0); + await page.getByRole("option", { name: "Fleet Room" }).click(); + await expect(page.locator(".flr")).toBeVisible(); +}); -test("⌘K → Toggle Fleet Agent lists non-host members with state and flips one", async ({ page }) => { +test("Fleet Room roster: stop a running member, start a stopped one (folded #1769)", async ({ page }) => { await page.goto("/app/", { waitUntil: "load" }); - await openToggleFleet(page); - - // Local members are listed with their live process state; the host (main) never is. - await expect(row(page, "ava", "on")).toBeVisible(); // running in baseline - await expect(row(page, "roxy", "off")).toBeVisible(); // stopped, still listed - await expect(page.getByRole("option", { name: /^main\b/ })).toHaveCount(0); // host excluded + await openFleetRoom(page); + const room = page.locator(".flr"); - // Toggle ava off — the palette closes, a toast confirms, and the roster flips. - await row(page, "ava", "on").click(); - await expect(page.locator(".pl-cmdk__panel")).toHaveCount(0); + // Stop ava (running in baseline) straight from her roster row; the dot flips on the + // invalidated poll. The host (main) never gets a toggle — it serves this console. + await expect(room.locator(".flr__member", { hasText: "main" }).getByRole("button", { name: /^(Stop|Start) main$/ })).toHaveCount(0); + await room.locator(".flr__member", { hasText: "ava" }).getByRole("button", { name: "Stop ava" }).click(); await expect(page.locator(".pl-toast", { hasText: "Stopping ava" })).toBeVisible(); + await expect(room.locator(".flr__member", { hasText: "ava" }).locator(".flr__dot--stopped")).toBeVisible(); - // Reopen the picker: ava now reads "off" (its running state flipped on the invalidated poll). - await openToggleFleet(page); - await expect(row(page, "ava", "off")).toBeVisible(); + // Start roxy (stopped in baseline). + await room.locator(".flr__member", { hasText: "roxy" }).getByRole("button", { name: "Start roxy" }).click(); + await expect(page.locator(".pl-toast", { hasText: "Starting roxy" })).toBeVisible(); + await expect(room.locator(".flr__member", { hasText: "roxy" }).locator(".flr__dot--online")).toBeVisible(); }); -test("⌘K → Toggle Fleet Agent starts a stopped member", async ({ page }) => { +test("Fleet Room: a parked member turn shows 'needs approval', then hands back (#2132)", async ({ page }, testInfo) => { + // setExtraHTTPHeaders REPLACES the set — re-carry the fleet scope alongside the HITL gate. + await page.setExtraHTTPHeaders({ + "x-e2e-fleet": `fleet-spec-${testInfo.parallelIndex}`, + "x-e2e-hitl": "ava", + }); await page.goto("/app/", { waitUntil: "load" }); - await openToggleFleet(page); + await openFleetRoom(page); + const room = page.locator(".flr"); + const ava = room.locator(".flr__member", { hasText: "ava" }); - await expect(row(page, "roxy", "off")).toBeVisible(); // stopped in baseline - await row(page, "roxy", "off").click(); - await expect(page.locator(".pl-toast", { hasText: "Starting roxy" })).toBeVisible(); + // ava's stream emits turn.input_required → attention pill + the actionable feed row. + await expect(ava.locator(".flr__pill--attn")).toBeVisible(); + await expect(room.locator(".flr-feed__event", { hasText: "needs your approval" }).first()).toBeVisible(); + + // The answer lands (turn.resumed) — needs-approval hands back to a live "running" pill… + await expect(ava.locator(".flr__pill--run")).toBeVisible(); + await expect(room.locator(".flr-feed__event", { hasText: "resumed — input received" }).first()).toBeVisible(); - await openToggleFleet(page); - await expect(row(page, "roxy", "on")).toBeVisible(); + // …and the terminal turn.usage clears it. + await expect(ava.locator(".flr__pill--run")).toHaveCount(0, { timeout: 6000 }); + await expect(ava.locator(".flr__pill--attn")).toHaveCount(0); }); async function openFleetRoom(page) { @@ -298,6 +305,8 @@ test("⌘K → Fleet Room: presence, DM a member (the wired chat), broadcast", a // (placeholder names them). Back returns to the roster. await room.locator(".flr__member", { hasText: "ava" }).locator(".flr__who").click(); await expect(page.getByPlaceholder(/Message ava/i)).toBeVisible(); + // The DM header names the member (DmTitle store) — not the old generic "Direct message". + await expect(page.locator(".pl-cmdk__title")).toHaveText("@ava"); await page.locator(".pl-cmdk__back").click(); await expect(room.locator(".flr__composer")).toBeVisible(); @@ -319,3 +328,29 @@ test("⌘K → Fleet Room shows the roster + live activity feed side by side", a // pushes activity/inbox/goal frames, so a mapped event lands in the column. await expect(room.locator(".flr-feed__event").first()).toBeVisible({ timeout: 6000 }); }); + +test("⌘K → Fleet Room: @-address a member in the composer, then send opens its DM", async ({ page }) => { + await page.goto("/app/", { waitUntil: "load" }); + await openFleetRoom(page); + const room = page.locator(".flr"); + // Typing "@" opens a member picker; picking sets the address chip. + await room.locator(".flr__input").fill("@ava"); + await room.locator(".flr__mention", { hasText: "ava" }).click(); + await expect(room.locator(".flr__target")).toContainText("@ava"); + // Type a message and send → morphs into ava's DM (the wired chat), message pre-sent. + await room.locator(".flr__input").fill("ship it"); + await room.locator(".flr__send").click(); + await expect(page.getByPlaceholder(/Message ava/i)).toBeVisible(); +}); + +test("⌘K → Fleet Room: a TYPED @name addresses that member without using the picker", async ({ page }) => { + await page.goto("/app/", { waitUntil: "load" }); + await openFleetRoom(page); + const room = page.locator(".flr"); + // Never touch the picker — just type "@ava " and send, the way people actually + // type. It must address ava (open its DM), NOT broadcast the literal text. + await room.locator(".flr__input").fill("@ava ship it"); + await room.locator(".flr__send").click(); + await expect(page.getByPlaceholder(/Message ava/i)).toBeVisible(); + await expect(page.locator(".pl-toast", { hasText: /Broadcast to/ })).toHaveCount(0); +}); diff --git a/apps/web/e2e/mock-server.mjs b/apps/web/e2e/mock-server.mjs index 7bb4d337..8e0041d5 100644 --- a/apps/web/e2e/mock-server.mjs +++ b/apps/web/e2e/mock-server.mjs @@ -537,7 +537,18 @@ const server = createServer(async (req, res) => { ? [setTimeout(() => frame("turn.started", { session_id: turnSession, origin: "scheduler" }), 300), setTimeout(() => frame("turn.finished", { session_id: turnSession }), 2500)] : []; - req.on("close", () => { clearInterval(t); goals.forEach(clearTimeout); turns.forEach(clearTimeout); }); + // Fleet Room HITL lifecycle (#2132), slug-gated: the spec names the member whose + // proxied stream should park a turn (x-e2e-hitl: ) and ONLY that stream emits + // the sequence — pause → answer lands → terminal — so the roster pill walks + // needs-approval → running → cleared, deterministically per connection. + const memberSlug = (url.pathname.match(/^\/agents\/([^/]+)\//) || [])[1] || ""; + const hitlSlug = req.headers["x-e2e-hitl"]; + const hitl = hitlSlug && memberSlug === hitlSlug + ? [setTimeout(() => frame("turn.input_required", { task_id: "t-hitl", context_id: "sess-hitl", prompt: "Approve the deploy?" }), 400), + setTimeout(() => frame("turn.resumed", { task_id: "t-hitl", context_id: "sess-hitl" }), 1800), + setTimeout(() => frame("turn.usage", { task_id: "t-hitl", context_id: "sess-hitl", state: "completed", input_tokens: 10, output_tokens: 5 }), 3200)] + : []; + req.on("close", () => { clearInterval(t); goals.forEach(clearTimeout); turns.forEach(clearTimeout); hitl.forEach(clearTimeout); }); return; } if (pathname.startsWith("/api/")) { diff --git a/apps/web/src/app/FleetActivity.tsx b/apps/web/src/app/FleetActivity.tsx index da16683e..c6e9d936 100644 --- a/apps/web/src/app/FleetActivity.tsx +++ b/apps/web/src/app/FleetActivity.tsx @@ -17,7 +17,15 @@ import { buildEventsUrl } from "../lib/events"; import type { FleetAgent } from "../lib/types"; import "./fleet-activity.css"; -export type FleetEventKind = "online" | "offline" | "added" | "removed" | "broadcast" | "turn" | "activity"; +export type FleetEventKind = + | "online" + | "offline" + | "added" + | "removed" + | "broadcast" + | "turn" + | "activity" + | "attn"; export type FleetEvent = { id: string; @@ -29,7 +37,16 @@ export type FleetEvent = { type FleetActivityState = { events: FleetEvent[]; + /** slug → count of turns currently in flight (drives the roster "running" pill). */ + running: Record; + /** slug → a turn is parked awaiting a human answer (drives "needs approval"). */ + awaiting: Record; push: (e: Omit) => void; + markRunning: (slug: string) => void; + markDone: (slug: string) => void; + clear: (slug: string) => void; + markAwaiting: (slug: string) => void; + clearAwaiting: (slug: string) => void; }; let seq = 0; @@ -37,14 +54,54 @@ const MAX = 60; const useFleetActivity = create((set) => ({ events: [], + running: {}, + awaiting: {}, push: (e) => set((s) => ({ events: [{ ...e, id: `flev-${(seq += 1)}`, ts: Date.now() }, ...s.events].slice(0, MAX), })), + markRunning: (slug) => set((s) => ({ running: { ...s.running, [slug]: (s.running[slug] ?? 0) + 1 } })), + markDone: (slug) => + set((s) => { + const n = Math.max(0, (s.running[slug] ?? 0) - 1); + const running = { ...s.running }; + if (n === 0) delete running[slug]; + else running[slug] = n; + return { running }; + }), + clear: (slug) => + set((s) => { + const hadRun = slug in s.running; + const hadWait = slug in s.awaiting; + if (!hadRun && !hadWait) return s; + const running = { ...s.running }; + const awaiting = { ...s.awaiting }; + delete running[slug]; + delete awaiting[slug]; + return { running, awaiting }; + }), + markAwaiting: (slug) => set((s) => (s.awaiting[slug] ? s : { awaiting: { ...s.awaiting, [slug]: true as const } })), + clearAwaiting: (slug) => + set((s) => { + if (!(slug in s.awaiting)) return s; + const awaiting = { ...s.awaiting }; + delete awaiting[slug]; + return { awaiting }; + }), })); /** Append an event from anywhere (e.g. the Fleet Room broadcast). */ export const pushFleetEvent = (e: Omit) => useFleetActivity.getState().push(e); +/** Optimistically mark a member busy when you send to it (cleared by its terminal turn.usage). */ +export const markMemberRunning = (slug: string) => useFleetActivity.getState().markRunning(slug); +/** Undo an optimistic mark when the send itself fails (no turn will run to clear it). */ +export const markMemberDone = (slug: string) => useFleetActivity.getState().markDone(slug); +/** Reset a member's in-flight count entirely (its stream closed / it went offline). */ +export const clearMemberRunning = (slug: string) => useFleetActivity.getState().clear(slug); +/** Members with a turn in flight — for the roster "running a turn" pill. */ +export const useMemberRunning = (): Record => useFleetActivity((s) => s.running); +/** Members parked on a HITL pause — for the roster "needs approval" pill. */ +export const useMemberAwaiting = (): Record => useFleetActivity((s) => s.awaiting); const slugOf = (a: FleetAgent): string => (a.host ? "host" : a.id); const hhmm = (ts: number): string => @@ -91,8 +148,17 @@ function mapTopic(topic: string, data: Record): { text: string; } case "turn.started": return { text: "is running a turn", kind: "turn" }; - case "turn.finished": - return { text: "finished a turn", kind: "turn" }; + // A turn parked on a HITL form/approval (ADR 0051) — the actionable row. + case "turn.input_required": { + const p = str(data.prompt); + return { text: p ? `⚠ needs your approval — ${shorten(p, 70)}` : "⚠ needs your approval", kind: "attn" }; + } + // The parked turn got its answer and is running again — closes the loop on the row above. + case "turn.resumed": + return { text: "resumed — input received", kind: "turn" }; + // turn.finished is intentionally unmapped — turn.usage (which fires for both autonomous + // and direct turns) is the single "finished a turn" row, so autonomous turns don't + // produce two. case "activity.message": { const t = str(data.text) || str(data.message); return { text: t ? `“${shorten(t)}”` : "posted to Activity", kind: "activity" }; @@ -109,6 +175,24 @@ function mapTopic(topic: string, data: Record): { text: string; return { text: "a goal failed", kind: "offline" }; case "background.completed": return { text: "finished background work", kind: "activity" }; + // GitHub plugin lifecycle (ADR 0039, namespaced by the plugin's registry.emit). + case "github.pr.opened": { + const n = str(data.number); + const t = str(data.title); + return { text: `opened PR${n ? ` #${n}` : ""}${t ? ` · ${shorten(t, 48)}` : ""}`, kind: "activity" }; + } + case "github.pr.merged": { + const n = str(data.number); + return { text: `merged PR${n ? ` #${n}` : ""}`, kind: "activity" }; + } + case "chat.resumed": { + const t = str(data.text); + return { text: t ? `resumed: “${shorten(t)}”` : "resumed a turn", kind: "activity" }; + } + case "goal.changed": { + const c = str(data.condition) || str(data.title); + return { text: c ? `working a goal: ${shorten(c, 40)}` : "updated a goal", kind: "activity" }; + } default: return null; } @@ -124,6 +208,8 @@ function useFleetStreams() { const opening = useRef>(new Set()); const seen = useRef>(new Set()); const nameBySlug = useRef>(new Map()); + const wantRef = useRef>(new Set()); + const disposed = useRef(false); const agents = data?.agents ?? []; const sig = agents @@ -135,11 +221,13 @@ function useFleetStreams() { useEffect(() => { nameBySlug.current = new Map(agents.map((a) => [slugOf(a), a.name])); const want = new Set(agents.filter((a) => a.running).map(slugOf)); + wantRef.current = want; for (const [slug, es] of streams.current) { if (!want.has(slug)) { es.close(); streams.current.delete(slug); + useFleetActivity.getState().clear(slug); // member offline → reset its in-flight count } } @@ -157,6 +245,28 @@ function useFleetStreams() { seen.current.add(key); if (seen.current.size > 1500) seen.current.clear(); } + // Live "running" state for the roster pill. Count UP on turn.started (autonomous + // turns) or an optimistic broadcast mark; count DOWN only on the terminal turn.usage, + // which fires for BOTH autonomous and direct turns — so each turn is +1/−1 exactly + // once. (turn.finished also fires for autonomous turns but is NOT counted, to avoid a + // double-decrement that would corrupt a concurrently-running member's count.) + const store = useFleetActivity.getState(); + if (frame.topic === "turn.started") store.markRunning(slug); + else if (frame.topic === "turn.usage") { + store.markDone(slug); + store.clearAwaiting(slug); // the parked turn reached a terminal state + } else if (frame.topic === "turn.input_required") { + // Parked awaiting a human answer — it's no longer "running", it's blocked on you. + store.markDone(slug); + store.markAwaiting(slug); + } else if (frame.topic === "turn.resumed") { + // The parked turn got its answer — hand "needs approval" back to "running". + // Balanced in any arrival order: +1 here is always closed by the terminal + // turn.usage's −1, and a missed pause/resume still settles at zero (markDone + // clamps). See the core publisher in server/a2a.py. + store.clearAwaiting(slug); + store.markRunning(slug); + } const m = mapTopic(frame.topic, frame.data ?? {}); if (!m) return; pushFleetEvent({ source: nameBySlug.current.get(slug) ?? slug, text: m.text, kind: m.kind }); @@ -172,12 +282,20 @@ function useFleetStreams() { /* open mode → tokenless */ } opening.current.delete(slug); - if (streams.current.has(slug) || typeof EventSource === "undefined") return; + // Bail if we were torn down (or the member went offline) while awaiting the token. + if (disposed.current || !wantRef.current.has(slug) || streams.current.has(slug) || typeof EventSource === "undefined") + return; const es = new EventSource(buildEventsUrl(memberPath(slug, "/api/events"), token, null)); es.onmessage = (e) => handle(slug, (e as MessageEvent).data); es.onerror = () => { es.close(); - streams.current.delete(slug); // the next roster poll reopens with a fresh token + streams.current.delete(slug); + // Reopen with a fresh token after a backoff — the roster sig may not change, so we + // can't rely on the effect re-running. Only while still mounted + still a member. + if (!disposed.current) + setTimeout(() => { + if (!disposed.current && wantRef.current.has(slug)) void openStream(slug); + }, 3000); }; streams.current.set(slug, es); }; @@ -188,9 +306,15 @@ function useFleetStreams() { useEffect(() => { const map = streams.current; + const flag = disposed; return () => { + flag.current = true; for (const es of map.values()) es.close(); map.clear(); + // Streams are closing, so terminal turn.usage frames won't arrive to clear in-flight + // counts — reset them (a reopen re-detects fresh turns via turn.started). + const st = useFleetActivity.getState(); + for (const slug of Object.keys(st.running)) st.clear(slug); }; }, []); } diff --git a/apps/web/src/app/FleetRoom.tsx b/apps/web/src/app/FleetRoom.tsx index a8519504..2e0e7670 100644 --- a/apps/web/src/app/FleetRoom.tsx +++ b/apps/web/src/app/FleetRoom.tsx @@ -19,7 +19,14 @@ import { api, currentSlug } from "../lib/api"; import { fleetQuery, queryKeys } from "../lib/queries"; import { errMsg } from "../lib/format"; import type { FleetAgent } from "../lib/types"; -import { FleetActivityFeed, pushFleetEvent } from "./FleetActivity"; +import { + FleetActivityFeed, + markMemberDone, + markMemberRunning, + pushFleetEvent, + useMemberAwaiting, + useMemberRunning, +} from "./FleetActivity"; import "./fleet-room.css"; /** The routing slug for a member — the host entry is the reserved "host" (ADR 0042). */ @@ -42,6 +49,7 @@ function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (sl const qc = useQueryClient(); const toast = useToast(); const [draft, setDraft] = useState(""); + const [target, setTarget] = useState<"broadcast" | string>("broadcast"); const inputRef = useRef(null); const here = currentSlug(); @@ -65,6 +73,8 @@ function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (sl [roster, here], ); const onlineCount = roster.filter((a) => a.running).length; + const running = useMemberRunning(); + const awaiting = useMemberAwaiting(); // DM a member = the wired chat, retargeted. Push it on the palette stack so Back/Escape // return here. Only running members are reachable. @@ -92,18 +102,20 @@ function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (sl .catch((e) => toast({ tone: "error", title: "Couldn't toggle agent", message: errMsg(e) })); }; - const broadcast = () => { - const msg = draft.trim(); + const broadcast = (msg: string) => { if (!msg) return; if (!broadcastTargets.length) { toast({ tone: "error", title: "No one to broadcast to", message: "No other members are online." }); return; } // Fire-and-forget fan-out — each member runs the turn durably on its own instance. + // Mark each busy now (optimistic "running" pill); its terminal turn.usage clears it. for (const a of broadcastTargets) { - api - .sendToAgent(slugOf(a), msg) - .catch((e) => toast({ tone: "error", title: `Couldn't reach ${a.name}`, message: errMsg(e) })); + markMemberRunning(slugOf(a)); + api.sendToAgent(slugOf(a), msg).catch((e) => { + markMemberDone(slugOf(a)); // the send failed → no turn will run to clear the pill + toast({ tone: "error", title: `Couldn't reach ${a.name}`, message: errMsg(e) }); + }); } toast({ tone: "success", @@ -115,10 +127,85 @@ function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (sl inputRef.current?.focus(); }; + // @-mention in the composer: a trailing "@token" opens a member picker; picking sets the + // address target (a chip) and strips the token. No target chip = broadcast to all online. + const mention = (() => { + const m = draft.match(/(?:^|\s)@([\w-]*)$/); + return m ? m[1].toLowerCase() : null; + })(); + const mentionMatches = + mention !== null + ? roster.filter((a) => slugOf(a) !== here && a.name.toLowerCase().includes(mention)).slice(0, 6) + : []; + const pickMention = (a: FleetAgent) => { + setTarget(slugOf(a)); + setDraft((d) => d.replace(/(?:^|\s)@[\w-]*$/, "")); + inputRef.current?.focus(); + }; + + const targetAgent = target === "broadcast" ? undefined : roster.find((a) => slugOf(a) === target); + + // Send: an addressed member opens its DM with the message pre-sent (the wired chat streams + // the reply); otherwise broadcast to all online. ⌘↵ always broadcasts. + /** Resolve a typed leading "@name" even when the picker was never used — people type + * "@scout do the thing" and expect it to address scout, not broadcast it verbatim. + * Returns the member plus the message with the mention stripped. */ + const resolveTypedMention = (text: string): { agent?: FleetAgent; rest: string } => { + const m = text.match(/^\s*@([\w-]+)[\s,:]*/); + if (!m) return { rest: text }; + const typed = m[1].toLowerCase(); + const others = roster.filter((a) => slugOf(a) !== here); + const agent = + others.find((a) => a.name.toLowerCase() === typed) ?? + others.find((a) => a.name.toLowerCase().startsWith(typed)); + return agent ? { agent, rest: text.slice(m[0].length) } : { rest: text }; + }; + + const submit = (forceBroadcast: boolean) => { + const raw = draft.trim(); + if (!raw) return; + + if (!forceBroadcast) { + // An explicit chip wins; otherwise honor a typed "@name". + if (!targetAgent && target !== "broadcast") { + toast({ tone: "error", title: "That member left the fleet", message: "Pick another, or broadcast." }); + setTarget("broadcast"); + return; + } + let addressed = targetAgent; + let msg = raw; + if (!addressed) { + const r = resolveTypedMention(raw); + if (r.agent) { + addressed = r.agent; + msg = r.rest.trim(); + } + } + if (addressed) { + if (!addressed.running) { + toast({ tone: "error", title: `${addressed.name} is offline`, message: "Start it first, or broadcast." }); + return; + } + if (!msg) { + toast({ tone: "error", title: `Add a message for @${addressed.name}`, message: "e.g. “@name ship it”." }); + return; + } + ctx.enter("member-dm", { slug: slugOf(addressed), name: addressed.name, initial: msg }); + return; + } + } + broadcast(raw); + }; + const onKeyDown = (e: ReactKeyboardEvent) => { + if (mentionMatches.length && (e.key === "Enter" || e.key === "Tab")) { + e.preventDefault(); + pickMention(mentionMatches[0]); + return; + } if (e.key === "Enter") { e.preventDefault(); - broadcast(); + submit(e.metaKey || e.ctrlKey); } }; @@ -160,6 +247,15 @@ function FleetRoom({ ctx, onOpenAgent }: { ctx: PaletteContext; onOpenAgent: (sl
+ {awaiting[slug] && a.running ? ( + + needs approval + + ) : running[slug] && a.running ? ( + + running + + ) : null} {local && ( + ); + })} +
+ )} + setDraft(e.target.value)} onKeyDown={onKeyDown} - placeholder="Message everyone online…" - aria-label="Broadcast message" + placeholder={targetAgent ? `Message @${targetAgent.name}…` : "Message everyone… (@ to address one)"} + aria-label={targetAgent ? `Message ${targetAgent.name}` : "Broadcast message"} /> @@ -236,10 +374,10 @@ export function fleetRoomView(opts: { onOpenAgent: (slug: string) => void }): Pa click DM a member - broadcast + @ address in composer - esc close + send · ⌘↵ broadcast ), diff --git a/apps/web/src/app/PaletteChat.tsx b/apps/web/src/app/PaletteChat.tsx index c598dc78..928d7510 100644 --- a/apps/web/src/app/PaletteChat.tsx +++ b/apps/web/src/app/PaletteChat.tsx @@ -4,6 +4,7 @@ // handlers. ONE preserved thread per agent (stable contextId + persisted transcript, // see paletteChatStore) — `/clear` wipes it (transcript + server checkpoint). import { useEffect, useRef, useState } from "react"; +import { create } from "zustand"; import { Conversation, Message, PromptInput } from "@protolabsai/ui/ai"; import { ChatMessageView } from "../chat/ChatMessageView"; import { addToolRef, appendReasoning, appendText, replaceText } from "../chat/parts"; @@ -60,7 +61,16 @@ function finalize(message: ChatMessage): ChatMessage { // Deterministic, client-side. `/clear` wipes the thread; typed or picked from the menu. const SLASH = [{ name: "clear", description: "Wipe this chat + its history" }]; -export function PaletteChat({ agentName, agentSlug }: { agentName: string; agentSlug?: string }) { +export function PaletteChat({ + agentName, + agentSlug, + initial, +}: { + agentName: string; + agentSlug?: string; + /** A message to auto-send once on open (Fleet Room composer → DM a member). */ + initial?: string; +}) { // A Fleet Room DM streams to a SPECIFIC member (agentSlug) and keeps its own thread, // scoped so DMing different members never crosses transcripts. No slug = the normal // ⌘K chat with this window's agent (unchanged). @@ -214,6 +224,19 @@ export function PaletteChat({ agentName, agentSlug }: { agentName: string; agent }; const stop = () => abortRef.current?.abort(); + + // Auto-send the initial message once (Fleet Room composer → DM a member with a message). + // Skip it if this member's persisted thread has a turn still streaming — the self-heal + // below owns that reconnect, and a concurrent send would clobber the same message. + const initialSent = useRef(false); + useEffect(() => { + if (!initial || initialSent.current) return; + const last = messagesRef.current[messagesRef.current.length - 1]; + if (last?.role === "assistant" && last.status === "streaming" && last.taskId) return; + initialSent.current = true; + void send(initial); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const empty = messages.length === 0; // Minimal slash menu — `/clear` hint while the draft starts with "/". const slashMatches = draft.startsWith("/") @@ -270,19 +293,36 @@ export function PaletteChat({ agentName, agentSlug }: { agentName: string; agent ); } +// The DS PaletteView title is fixed at registration — it can't read enter() props. A +// tiny store bridges that: the DM body records who it's pointed at on mount, and the +// title node (a component, which PaletteView.title accepts) re-renders to "@". +const useDmTarget = create<{ name: string }>(() => ({ name: "" })); + +function DmTitle() { + const name = useDmTarget((s) => s.name); + return <>{name ? `@${name}` : "Direct message"}; +} + +function MemberDm({ slug, name, initial }: { slug?: string; name?: string; initial?: string }) { + useEffect(() => { + useDmTarget.setState({ name: name ?? "" }); + }, [name]); + return ; +} + /** The Fleet Room DM view — the SAME wired ⌘K chat (PaletteChat), pointed at a specific * member. A member row does `ctx.enter("member-dm", { slug, name })`; the palette pushes * it on the stack (so Back/Escape return to the roster) and the turn streams to that * member via the hub proxy. Keyed by slug so switching members remounts a clean pane - * bound to that member's own thread. */ + * bound to that member's own thread. The header names the member (via DmTitle above). */ export function memberDmView(): PaletteView { return { id: "member-dm", - title: "Direct message", + title: , width: 620, render: (ctx) => { - const p = (ctx.props ?? {}) as { slug?: string; name?: string }; - return ; + const p = (ctx.props ?? {}) as { slug?: string; name?: string; initial?: string }; + return ; }, }; } diff --git a/apps/web/src/app/fleet-activity.css b/apps/web/src/app/fleet-activity.css index af683ad5..e650817d 100644 --- a/apps/web/src/app/fleet-activity.css +++ b/apps/web/src/app/fleet-activity.css @@ -101,6 +101,9 @@ .flr-feed__src--activity { color: var(--pl-color-fg); } +.flr-feed__src--attn { + color: var(--pl-color-status-error); +} .flr-feed__text { margin: 2px 0 0; font-size: 12.5px; diff --git a/apps/web/src/app/fleet-room.css b/apps/web/src/app/fleet-room.css index acaf4e54..bb3cfa40 100644 --- a/apps/web/src/app/fleet-room.css +++ b/apps/web/src/app/fleet-room.css @@ -198,14 +198,77 @@ cursor: default; } +/* status pills on a member row (e.g. a turn in flight) */ +.flr__pill { + font: 600 10px inherit; + letter-spacing: 0.03em; + text-transform: uppercase; + padding: 2px 6px; + border-radius: 999px; + white-space: nowrap; +} +.flr__pill--run { + color: var(--pl-color-status-warning); + background: color-mix(in srgb, var(--pl-color-status-warning) 16%, transparent); +} +.flr__pill--attn { + color: var(--pl-color-status-error); + background: color-mix(in srgb, var(--pl-color-status-error) 16%, transparent); +} + /* composer */ .flr__composer { + position: relative; display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--pl-color-border); } + +/* @-mention member picker (opens above the composer) */ +.flr__mentions { + position: absolute; + left: 12px; + right: 12px; + bottom: calc(100% - 2px); + z-index: 3; + display: flex; + flex-direction: column; + gap: 2px; + max-height: 210px; + overflow-y: auto; + padding: 4px; + background: var(--pl-color-bg-raised, var(--pl-color-bg)); + border: 1px solid var(--pl-color-border-strong, var(--pl-color-border)); + border-radius: 10px; + box-shadow: 0 -10px 28px -14px rgba(0, 0, 0, 0.45); +} +.flr__mention { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border: 0; + border-radius: 7px; + background: none; + cursor: pointer; + text-align: left; + font: inherit; + color: var(--pl-color-fg); +} +.flr__mention:hover { + background: var(--pl-color-bg-hover); +} +.flr__mention-name { + font-size: 13px; + font-weight: 550; +} +.flr__mention-meta { + margin-left: auto; + font-size: 11px; + color: var(--pl-color-fg-subtle); +} .flr__target { display: inline-flex; align-items: center; diff --git a/apps/web/src/app/fleetPalette.test.ts b/apps/web/src/app/fleetPalette.test.ts index aaed15fb..121657dd 100644 --- a/apps/web/src/app/fleetPalette.test.ts +++ b/apps/web/src/app/fleetPalette.test.ts @@ -1,94 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { fleetPaletteEntries, markAgentOpened, readAgentRecency, togglableFleetAgents } from "./fleetPalette"; -import type { FleetAgent } from "../lib/types"; +import { markAgentOpened, readAgentRecency } from "./fleetPalette"; -function agent(over: Partial): FleetAgent { - return { name: "a", id: "a", port: 7870, pid: 1, running: true, bundle: "", ...over }; -} - -describe("fleetPaletteEntries (#1733)", () => { - it("lists other agents reachable-first then alphabetical, omitting the focused one", () => { - const agents = [ - agent({ name: "zed", id: "zed", running: true }), - agent({ name: "ava", id: "ava", running: true }), - agent({ name: "down", id: "down", running: false }), - agent({ name: "me", id: "me", running: true }), - ]; - const out = fleetPaletteEntries(agents, "me"); - expect(out.map((e) => e.slug)).toEqual(["ava", "zed", "down"]); // reachable alpha, then the down one - expect(out.find((e) => e.slug === "me")).toBeUndefined(); // the focused agent is omitted - expect(out.find((e) => e.slug === "down")!.disabled).toBe(true); - }); - - it("routes the host entry by the literal 'host' slug, not its id", () => { - const out = fleetPaletteEntries([agent({ name: "Main", id: "ignored", host: true })], "other"); - expect(out[0].slug).toBe("host"); - }); - - it("floats a recently-opened agent above alphabetical order", () => { - const agents = [agent({ name: "ava", id: "ava" }), agent({ name: "bob", id: "bob" })]; - expect(fleetPaletteEntries(agents, "me", { bob: 999 }).map((e) => e.slug)).toEqual(["bob", "ava"]); - }); - - it("shows a down remote as disabled with an 'unreachable' hint", () => { - const [only] = fleetPaletteEntries([agent({ name: "r", id: "r", remote: true, running: false })], "me"); - expect(only.disabled).toBe(true); - expect(only.hint).toBe("unreachable"); - }); - - it("labels a reachable remote 'remote · switch'", () => { - const [only] = fleetPaletteEntries([agent({ name: "r", id: "r", remote: true, running: true })], "me"); - expect(only.disabled).toBe(false); - expect(only.hint).toBe("remote · switch"); - }); -}); - -describe("togglableFleetAgents (#1769)", () => { - it("excludes the host — it serves this console and can't be stopped from itself", () => { - const agents = [ - agent({ name: "main", id: "main", host: true }), - agent({ name: "ava", id: "ava" }), - ]; - const out = togglableFleetAgents(agents); - expect(out.map((a) => a.name)).toEqual(["ava"]); - expect(out.find((a) => a.host)).toBeUndefined(); - }); - - it("excludes remotes — they have no local process to start/stop from here", () => { - const agents = [ - agent({ name: "ava", id: "ava" }), - agent({ name: "remy", id: "remy", remote: true, running: true }), - ]; - expect(togglableFleetAgents(agents).map((a) => a.name)).toEqual(["ava"]); - }); - - it("lists both running and stopped local members (on/off is live process state)", () => { - const agents = [ - agent({ name: "up", id: "up", running: true }), - agent({ name: "down", id: "down", running: false, pid: null }), - ]; - const out = togglableFleetAgents(agents); - expect(out.map((a) => a.name)).toEqual(["down", "up"]); // both present, name-sorted - expect(out.map((a) => a.running)).toEqual([false, true]); - }); - - it("sorts stably by display name", () => { - const agents = [ - agent({ name: "zed", id: "zed" }), - agent({ name: "ava", id: "ava" }), - agent({ name: "bob", id: "bob" }), - ]; - expect(togglableFleetAgents(agents).map((a) => a.name)).toEqual(["ava", "bob", "zed"]); - }); - - it("does not mutate the input array", () => { - const agents = [agent({ name: "zed", id: "zed" }), agent({ name: "ava", id: "ava" })]; - const snapshot = agents.map((a) => a.name); - togglableFleetAgents(agents); - expect(agents.map((a) => a.name)).toEqual(snapshot); - }); -}); +// The per-member palette entries (#1733) and the toggle picker (#1769) are folded into +// the Fleet Room — their helpers are gone. What's left is the recency store. describe("agent recency store", () => { beforeEach(() => localStorage.clear()); diff --git a/apps/web/src/app/fleetPalette.ts b/apps/web/src/app/fleetPalette.ts index aab42297..c07cc7f6 100644 --- a/apps/web/src/app/fleetPalette.ts +++ b/apps/web/src/app/fleetPalette.ts @@ -1,21 +1,12 @@ -// Fleet quick-chat palette entries (#1733). The command palette gets an "Agents" section listing -// every OTHER fleet agent; picking one navigates to that agent's slug-routed console, where you -// chat with it immediately. Reachability comes straight from the roster: `running` IS the remote's -// reachability probe (see FleetManagerPanel), so a down/unreachable agent is shown disabled. -import type { FleetAgent } from "../lib/types"; - -export type FleetPaletteEntry = { - id: string; - slug: string; - label: string; - hint: string; - disabled: boolean; - keywords: string[]; -}; +// Fleet ⌘K recency store. The old per-member root palette entries — quick-chat (#1733) +// and Toggle Fleet Agent (#1769) — are folded into the Fleet Room: the roster row carries +// DM / open-console / start / stop, and member names ride the room command's keywords +// (usePaletteRegistry). What remains here is the recency store the room's "Open" action +// still feeds, so a future surface can sort by last-opened. const RECENCY_KEY = "protoagent.fleet.recent"; -/** Last-opened timestamp per agent slug (localStorage) — powers "recently chatted sorts first". */ +/** Last-opened timestamp per agent slug (localStorage). */ export function readAgentRecency(): Record { try { const raw = localStorage.getItem(RECENCY_KEY); @@ -26,7 +17,7 @@ export function readAgentRecency(): Record { } } -/** Record that an agent was just opened from the palette so it sorts to the top next time. */ +/** Record that an agent was just opened from the palette. */ export function markAgentOpened(slug: string, now: number = Date.now()): void { try { const r = readAgentRecency(); @@ -36,49 +27,3 @@ export function markAgentOpened(slug: string, now: number = Date.now()): void { /* localStorage unavailable — recency is a nicety, never let it block the open */ } } - -/** Build the palette's fleet entries from the live roster: every agent EXCEPT the one this window - * is focused on, ordered reachable-first → most-recently-opened → alphabetical. Down agents stay - * in the list but disabled (you can't chat with one that isn't up). The host entry's slug is the - * literal "host" (ADR 0042 routing), not its `id`. */ -export function fleetPaletteEntries( - agents: FleetAgent[], - currentSlug: string, - recency: Record = {}, -): FleetPaletteEntry[] { - return agents - .map((a) => { - const slug = a.host ? "host" : a.id; - const reachable = a.running; - const hint = reachable ? (a.remote ? "remote · switch" : "switch") : a.remote ? "unreachable" : "stopped"; - return { - id: `fleet:${slug}`, - slug, - label: a.name, - hint, - disabled: !reachable, - keywords: ["fleet", "agent", "chat", "switch", a.name, slug], - }; - }) - .filter((e) => e.slug !== currentSlug) // you're already on this one — its own "Chat with…" covers it - .sort( - (a, b) => - Number(a.disabled) - Number(b.disabled) || // reachable before down - (recency[b.slug] ?? 0) - (recency[a.slug] ?? 0) || // most-recently-opened next - a.label.localeCompare(b.label), // then alphabetical - ); -} - -/** The fleet agents whose live process can be toggled on/off from the palette (#1769). - * ON/OFF is the live process state (`FleetAgent.running`), not a persisted flag: "on" = - * `POST /api/fleet//start`, "off" = `POST /api/fleet//stop`. Only LOCAL, - * non-host members qualify — the SAME gate FleetManagerPanel uses to show Start/Stop: - * • the host serves this console, so stopping it would kill the session — never listed; - * • a REMOTE member has no local process here (its `/start|/stop` 400s) — excluded too. - * Sorted stably by display name so the picker order is deterministic across polls. */ -export function togglableFleetAgents(agents: FleetAgent[]): FleetAgent[] { - return agents - .filter((a) => !a.host && !a.remote) - .slice() - .sort((a, b) => a.name.localeCompare(b.name)); -} diff --git a/apps/web/src/app/usePaletteRegistry.ts b/apps/web/src/app/usePaletteRegistry.ts index 2b85b538..431e7f17 100644 --- a/apps/web/src/app/usePaletteRegistry.ts +++ b/apps/web/src/app/usePaletteRegistry.ts @@ -12,17 +12,16 @@ import type { ReactNode } from "react"; import { useEffect, useMemo } from "react"; import { commandsView, createPaletteRegistry, pluginView } from "@protolabsai/ui/command-palette"; import type { Command, PaletteRegistry, PaletteView } from "@protolabsai/ui/command-palette"; -import { useToast } from "@protolabsai/ui/overlays"; import { useUI } from "../state/uiStore"; import type { View } from "../lib/viewRegistry"; import { registerPaletteCommand, registeredPaletteCommands } from "../ext/paletteRegistry"; import type { PaletteCommand } from "../ext/paletteRegistry"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { agentHref, api, currentSlug } from "../lib/api"; -import { errMsg } from "../lib/format"; -import { fleetQuery, queryKeys } from "../lib/queries"; -import { fleetPaletteEntries, markAgentOpened, readAgentRecency, togglableFleetAgents } from "./fleetPalette"; +import { useQuery } from "@tanstack/react-query"; +import { agentHref, currentSlug } from "../lib/api"; +import { fleetQuery } from "../lib/queries"; +import { markAgentOpened } from "./fleetPalette"; import { fleetRoomView } from "./FleetRoom"; +import { fleetSettingsDisabledReason } from "./fleetSettingsGate"; import { memberDmView } from "./PaletteChat"; /** Optional inline chat with the focused agent (ADR 0057). App builds the native chat @@ -187,11 +186,8 @@ export function usePaletteRegistry( ): PaletteRegistry { const registry = useMemo(() => createPaletteRegistry(), []); const inlineIds = useMemo(() => new Set(inlineViews.map((v) => v.id)), [inlineViews]); - // Stable across renders — closed over by the fleet-toggle command's `run` (#1769). - const toast = useToast(); - const qc = useQueryClient(); - // The live fleet roster (polled) → a "Quick-chat with any fleet agent" section (#1733). Works + // The live fleet roster (polled) → member names on the Fleet Room command's keywords. Works // in both the console window and the desktop launcher (both sit under QueryClientProvider). const { data: fleet } = useQuery(fleetQuery()); const agents = fleet?.agents ?? []; @@ -274,35 +270,43 @@ export function usePaletteRegistry( : undefined; // Fleet Room (⌘K palette overhaul) — the co-present roster + address/broadcast, opened // as a morph-view. Top of the Agents group, right under "Chat with ". + // Quick-chat (#1733) and Toggle Fleet Agent (#1769) are FOLDED INTO the room: the + // roster row carries DM / open-console / start / stop, and every member's name rides + // this command's keywords — so typing "ava" still lands you one step from her. + // (Re-registered on fleetSig, so the keyword list tracks the live roster.) + // Host-scoped (ADR 0042), mirroring the Fleet settings gate: the fleet is managed from + // its HOST instance. On a member window `/api/fleet` is a fleet-of-one by construction, + // so the room would be an empty, misleading surface (and its DM/broadcast targets would + // be nothing). Disable it there with a pointer at the host rather than hiding it, so the + // command stays discoverable and explains itself. + const fleetGate = fleetSettingsDisabledReason(agents, currentSlug()); const offFleetRoom = registry.registerCommands([ { id: "fleet-room", label: "Fleet Room", - hint: "members · DM · broadcast", + hint: fleetGate ? "host instance only" : "members · DM · broadcast", + disabled: !!fleetGate, group: "Agents", - keywords: ["fleet", "room", "members", "agents", "team", "crew", "broadcast", "dm", "roster"], + keywords: [ + "fleet", + "room", + "members", + "agents", + "team", + "crew", + "broadcast", + "dm", + "roster", + "chat", + "switch", + "toggle", + "start", + "stop", + ...agents.map((a) => a.name), + ], run: (c) => c.enter("fleet-room"), }, ]); - // Fleet quick-chat (#1733): every OTHER agent, in the "Agents" group beneath "Chat with - // ". Picking one navigates to its slug-routed console via a serializable `agent` - // NavIntent (so it also works forwarded from the launcher window). A down agent is listed - // but disabled, and opening one records recency so it floats up next time. - const recency = readAgentRecency(); - const fleetCommands: Command[] = fleetPaletteEntries(agents, currentSlug(), recency).map((e) => ({ - id: e.id, - label: e.label, - hint: e.hint, - group: "Agents", - keywords: e.keywords, - disabled: e.disabled, - run: (c) => { - markAgentOpened(e.slug); - navigate({ kind: "agent", slug: e.slug }); - c.close(); - }, - })); - const offFleet = fleetCommands.length ? registry.registerCommands(fleetCommands) : undefined; // Each plugin's views: inline ones morph IN PLACE (also in the launcher window); a // rail view navigates — routed through `navigate()` so the launcher hands it off to // the main window instead of mutating its own (shell-less) store. @@ -326,52 +330,8 @@ export function usePaletteRegistry( }; }); const offPlugins = pluginCommands.length ? registry.registerCommands(pluginCommands) : undefined; - // Fleet on/off (#1769): a `Toggle Fleet Agent ▸` submorph listing every LOCAL, non-host - // member with its live process state as a hint chip ("on"/"off"). ON/OFF isn't a persisted - // flag — it's `FleetAgent.running`: picking a stopped agent starts it, a running one stops - // it, then we invalidate the roster (so its dot + this list's hint flip on the next poll) - // and toast the outcome. The row for the agent THIS window is proxied to is disabled - // ("current") so the operator can't kill their own console from here. - const focused = currentSlug(); - const toggleCommands: Command[] = togglableFleetAgents(agents).map((a) => { - const on = a.running; - const isCurrent = a.id === focused; - return { - id: `fleet-toggle:${a.id}`, - label: a.name, - hint: isCurrent ? "current" : on ? "on" : "off", - group: "Agents", - keywords: ["fleet", "agent", "toggle", on ? "disable" : "enable", on ? "stop" : "start", a.name], - disabled: isCurrent, // can't stop the agent serving this very window - run: (c) => { - c.close(); - const action = on ? api.stopAgent(a.name) : api.startAgent(a.name); - action - .then(() => { - qc.invalidateQueries({ queryKey: queryKeys.fleet }); - toast({ - tone: "success", - title: on ? `Stopping ${a.name}…` : `Starting ${a.name}…`, - message: on ? `${a.name} is going offline.` : `${a.name} is coming online.`, - }); - }) - .catch((e) => toast({ tone: "error", title: "Couldn't toggle agent", message: errMsg(e) })); - }, - }; - }); - const offToggleView = registry.registerViews([ - { - ...commandsView({ - commands: toggleCommands, - placeholder: "Toggle a fleet agent on/off…", - emptyLabel: "No fleet agents to toggle", - }), - id: "fleet-toggle", - title: "Toggle Fleet Agent", - }, - ]); - // Commands group: `Open ▸` (morphs to the built-in surfaces), `Toggle Fleet Agent ▸`, and - // the deep-link actions. + // Commands group: `Open ▸` (morphs to the built-in surfaces) and the deep-link actions. + // (Fleet start/stop lives on the Fleet Room roster now — #1769 folded in.) const openCommand: Command = { id: "open", label: "Open…", @@ -380,25 +340,14 @@ export function usePaletteRegistry( keywords: ["open", "go to", "surface", "view", "navigate", "switch", "panel"], run: (c) => c.enter("open"), }; - const toggleCommand: Command = { - id: "fleet:toggle", - label: "Toggle Fleet Agent", - hint: "on / off", - group: "Commands", - keywords: ["fleet", "agent", "toggle", "enable", "disable", "start", "stop", "on", "off"], - run: (c) => c.enter("fleet-toggle"), - }; const offCommands = registry.registerCommands([ openCommand, - toggleCommand, ...registeredPaletteCommands().map(toDsCommand), ]); return () => { offChat?.(); offFleetRoom(); - offFleet?.(); offPlugins?.(); - offToggleView(); offCommands(); }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/server/a2a.py b/server/a2a.py index cdc97618..f7afb778 100644 --- a/server/a2a.py +++ b/server/a2a.py @@ -591,11 +591,30 @@ def _record_a2a_telemetry(outcome) -> None: def _a2a_progress(context_id: str, task_id: str, frame: dict) -> None: - """Per-frame progress hook (ADR 0051). Only background turns get a bus push — live - turns already stream over their own SSE. On the opening ``turn_started`` frame it - records the A2A task id on the job row (the handle ``stop_task`` needs); on tool - frames it republishes ``background.progress`` for the console's live job card. + """Per-frame progress hook (ADR 0051). A HITL pause (``input_required``) is published + for EVERY context as ``turn.input_required`` so a fleet console can surface "needs your + approval" without polling. Otherwise only background turns get a bus push — live turns + already stream over their own SSE. On the opening ``turn_started`` frame it records the + A2A task id on the job row (the handle ``stop_task`` needs); on tool frames it + republishes ``background.progress`` for the console's live job card. Best-effort — never raises into the executor.""" + if frame.get("phase") == "input_required": + # The turn is parked awaiting a human answer/approval. Carries a short prompt so a + # consumer can render "needs your approval — " without fetching the task. + _event_bus.publish( + "turn.input_required", + { + "task_id": task_id, + "context_id": context_id, + "prompt": str(frame.get("prompt") or "")[:300], + }, + ) + return + if frame.get("phase") == "turn_started" and frame.get("resumed"): + # The parked turn got its answer and is running again — lets a console flip + # "needs approval" back to "running" without waiting for the terminal frame. + # Falls through: a resumed BACKGROUND turn still records its task id below. + _event_bus.publish("turn.resumed", {"task_id": task_id, "context_id": context_id}) if not context_id.startswith("background:"): return mgr = getattr(STATE, "background_mgr", None) diff --git a/tests/test_a2a_handler.py b/tests/test_a2a_handler.py index 50875a50..ef22d6aa 100644 --- a/tests/test_a2a_handler.py +++ b/tests/test_a2a_handler.py @@ -37,7 +37,13 @@ from google.protobuf.json_format import MessageToDict import protolabs_a2a as pa -from a2a_impl.executor import _CONTEXT_MIME, ProtoAgentExecutor, TurnOutcome, set_terminal_hook +from a2a_impl.executor import ( + _CONTEXT_MIME, + ProtoAgentExecutor, + TurnOutcome, + set_progress_hook, + set_terminal_hook, +) from a2a_impl.registry import OwnedProducerActiveTaskRegistry, harden_active_task_registry A2A_HEADERS = {"A2A-Version": "1.0"} @@ -261,6 +267,13 @@ def _clear_terminal_hook(): set_terminal_hook(None) +@pytest.fixture(autouse=True) +def _clear_progress_hook(): + set_progress_hook(None) + yield + set_progress_hook(None) + + # Handlers built by _build_app during the current test — drained at teardown so # their retire-then-remove cleanup tasks (#1713) finish inside the test's event # loop instead of being destroyed pending when pytest closes it. @@ -391,6 +404,71 @@ async def stream(text, ctx, *, resume=False, caller_trace=None, **kwargs): assert "Approve the merge?" in text +@pytest.mark.asyncio +async def test_input_required_fires_progress_frame_with_prompt(): + """A HITL pause reaches the host progress hook (#2132): the turn parks with no + terminal frame, so this is the only signal an off-SSE consumer (the fleet room's + "needs approval" pill) ever gets.""" + frames: list = [] + set_progress_hook(lambda ctx, task, frame: frames.append(frame)) + + async def stream(text, ctx, *, resume=False, caller_trace=None, **kwargs): + yield ("input_required", {"question": "Approve the merge?"}) + + app = _build_app(stream) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=10) as c: + task = (await _send_msg(c)).json()["result"]["task"] + final = await _poll_terminal(c, task["id"]) + assert final["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + assert frames[0]["phase"] == "turn_started" and frames[0]["resumed"] is False + parked = [f for f in frames if f.get("phase") == "input_required"] + assert parked and parked[0]["prompt"] == "Approve the merge?" + + +@pytest.mark.asyncio +async def test_resumed_turn_started_frame_carries_resumed_flag(): + """Answering a parked task re-enters execute() with resume=True — the second + turn_started frame must say so, so a host can flip "needs approval" back to + "running" the moment the answer lands.""" + frames: list = [] + set_progress_hook(lambda ctx, task, frame: frames.append(frame)) + + async def stream(text, ctx, *, resume=False, caller_trace=None, **kwargs): + if resume: + yield ("done", "resumed fine") + else: + yield ("input_required", {"question": "go?"}) + + app = _build_app(stream) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=10) as c: + task = (await _send_msg(c)).json()["result"]["task"] + parked = await _poll_terminal(c, task["id"]) + assert parked["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + r2 = await c.post( + "/a2a", + headers=A2A_HEADERS, + json={ + "jsonrpc": "2.0", + "id": "r2", + "method": "SendMessage", + "params": { + "message": { + "messageId": "m2", + "role": "ROLE_USER", + "taskId": task["id"], + "contextId": task["contextId"], + "parts": [{"text": "yes"}], + } + }, + }, + ) + assert r2.status_code == 200 + final = await _poll_terminal(c, task["id"]) + assert final["status"]["state"] == "TASK_STATE_COMPLETED" + started = [f for f in frames if f.get("phase") == "turn_started"] + assert [f["resumed"] for f in started] == [False, True] + + @pytest.mark.asyncio async def test_tool_events_surface_as_tool_call_metadata(): """tool_start/tool_end become tool-call-v1 **metadata** on working status frames diff --git a/tests/test_background.py b/tests/test_background.py index 8686fcbf..e1027832 100644 --- a/tests/test_background.py +++ b/tests/test_background.py @@ -815,6 +815,80 @@ def test_non_background_context_ignored(self, monkeypatch): assert published == [] +class TestHitlTurnEvents: + """``turn.input_required`` / ``turn.resumed`` publish for EVERY context (#2132): + a parked turn emits nothing terminal (no ``turn.usage``), so these frames are the + only bus signal the fleet room's "needs approval" pill ever gets.""" + + def test_live_input_required_publishes(self, monkeypatch): + import server.a2a as a2a + + published: list = [] + monkeypatch.setattr(a2a._event_bus, "publish", lambda t, d=None: published.append((t, d))) + a2a._a2a_progress( + "chat-session-1", "task-9", {"phase": "input_required", "prompt": "Approve the merge?"} + ) + assert published == [ + ( + "turn.input_required", + {"task_id": "task-9", "context_id": "chat-session-1", "prompt": "Approve the merge?"}, + ) + ] + + def test_input_required_prompt_clipped(self, monkeypatch): + import server.a2a as a2a + + published: list = [] + monkeypatch.setattr(a2a._event_bus, "publish", lambda t, d=None: published.append((t, d))) + a2a._a2a_progress("s", "t", {"phase": "input_required", "prompt": "x" * 1000}) + assert len(published) == 1 + assert len(published[0][1]["prompt"]) == 300 + + def test_background_input_required_publishes_and_skips_job_plumbing(self, monkeypatch): + import server.a2a as a2a + from runtime.state import STATE + + monkeypatch.setattr(STATE, "background_mgr", None, raising=False) + published: list = [] + monkeypatch.setattr(a2a._event_bus, "publish", lambda t, d=None: published.append((t, d))) + a2a._a2a_progress("background:j1", "t1", {"phase": "input_required", "prompt": "?"}) + # Publishes the pause (even with no background manager) and does NOT fall + # through into the background.progress tool-frame shape. + assert [t for t, _ in published] == ["turn.input_required"] + + def test_resumed_turn_started_publishes_turn_resumed(self, monkeypatch): + import server.a2a as a2a + + published: list = [] + monkeypatch.setattr(a2a._event_bus, "publish", lambda t, d=None: published.append((t, d))) + a2a._a2a_progress("chat-session-1", "task-9", {"phase": "turn_started", "resumed": True}) + assert published == [("turn.resumed", {"task_id": "task-9", "context_id": "chat-session-1"})] + + def test_fresh_turn_started_stays_silent_for_live_context(self, monkeypatch): + import server.a2a as a2a + + published: list = [] + monkeypatch.setattr(a2a._event_bus, "publish", lambda t, d=None: published.append((t, d))) + a2a._a2a_progress("chat-session-1", "task-9", {"phase": "turn_started", "resumed": False}) + assert published == [] + + def test_resumed_background_turn_started_still_records_task_id(self, tmp_path, monkeypatch): + import server.a2a as a2a + from runtime.state import STATE + + mgr = _manager(tmp_path) + jid = mgr.store.create( + agent_name="a", origin_session="s1", subagent_type="researcher", description="d", prompt="p" + ) + monkeypatch.setattr(STATE, "background_mgr", mgr, raising=False) + published: list = [] + monkeypatch.setattr(a2a._event_bus, "publish", lambda t, d=None: published.append((t, d))) + a2a._a2a_progress(f"background:{jid}", "task-42", {"phase": "turn_started", "resumed": True}) + # Both effects: the resume marker publishes AND the job row still gets its handle. + assert [t for t, _ in published] == ["turn.resumed"] + assert mgr.store.get(jid).a2a_task_id == "task-42" + + # ── drain into the spawning chat turn (server/chat.py) ───────────────────────