diff --git a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx index 6564666f..db2b6ed2 100644 --- a/canvas/src/components/tabs/chat/AgentCommsPanel.tsx +++ b/canvas/src/components/tabs/chat/AgentCommsPanel.tsx @@ -60,28 +60,63 @@ function resolveName(id: string): string { export function toCommMessage(entry: ActivityEntry, workspaceId: string): CommMessage | null { // delegation activity rows are written by the platform's /delegate - // handler. They're always outbound from this workspace's POV (the - // platform proxies the A2A on our behalf). Two methods: + // handler. Two methods: // - "delegate" — the initial outbound; status pending/dispatched // - "delegate_result" — the eventual reply; status completed/queued/failed - // We surface them in Agent Comms because they ARE agent-to-agent - // calls; without this branch they'd be dropped by the activity_type - // filter and the user would see "No agent-to-agent communications yet" - // even when the director made delegations. + // + // Flow direction: even though both rows have source_id=us (the + // platform writes them on our row), the CONVERSATIONAL direction + // differs. 'delegate' is us asking the peer; 'delegate_result' is + // the peer's reply coming back. Render them as alternating bubbles + // (out + in) so the user sees a chat-like back-and-forth instead + // of a one-sided wall of "→ To X" rows. + // + // Text content: the platform's `summary` is boilerplate + // ("Delegating to " / "Delegation queued — target at + // capacity") — useful for an audit log, useless in a chat UI. + // Prefer the real payload: + // - outbound: request_body.task (the task text the agent sent) + // - inbound: response_body.response_preview (the peer's reply text) + // Falls back to a name-resolved summary when the payload is empty. if (entry.activity_type === "delegation") { const peerId = entry.target_id || ""; if (!peerId) return null; + const isResult = entry.method === "delegate_result"; + const peerName = resolveName(peerId); + + let text: string; + if (isResult) { + const rb = entry.response_body as Record | null; + const replyText = + (typeof rb?.response_preview === "string" && rb.response_preview) || + (typeof rb?.text === "string" && rb.text) || + ""; + if (replyText) { + text = replyText; + } else if (entry.status === "queued") { + // No actual reply yet — peer's a2a-proxy queued the call; + // show what the user needs to know without the boilerplate. + text = `Queued — ${peerName} is busy on a prior task, reply will arrive when they're free`; + } else if (entry.status === "failed") { + text = entry.summary || `Delegation to ${peerName} failed`; + } else { + text = entry.summary || "(no reply)"; + } + } else { + const reqTask = (entry.request_body as Record | null)?.task; + text = (typeof reqTask === "string" && reqTask) || `Delegating to ${peerName}`; + } + return { id: entry.id, - flow: "out", - peerName: resolveName(peerId), + flow: isResult ? "in" : "out", + peerName, peerId, - // Prefer summary (set by the platform with a human-readable - // string like "Delegating to X" or "Delegation queued — target - // at capacity"). Fall back to request body for older rows that - // pre-date the summary column being populated. - text: entry.summary || extractRequestText(entry.request_body) || "(delegation)", - responseText: entry.response_body ? extractResponseText(entry.response_body) : null, + text, + // Result text is now the primary `text` (above), so don't + // duplicate it as responseText — that would render a divider + // line under the reply with the same content below. + responseText: null, status: entry.status || "ok", timestamp: entry.created_at, }; @@ -265,20 +300,39 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) { // own `status` field (queued / dispatched). Other events have // implicit status: SENT → pending, COMPLETE → completed, // FAILED → failed. + // + // Populate request_body / response_body from the payload so + // toCommMessage's delegation branch can read the actual + // task / reply text via the same code path the GET-on-mount + // uses. Without this, live-pushed bubbles would fall back + // to the boilerplate summary ("Delegating to ") instead + // of the real text. let status: string; let summary: string; + let requestBody: Record | null = null; + let responseBody: Record | null = null; if (msg.event === "DELEGATION_STATUS") { status = (p.status as string) || "queued"; summary = `Delegation ${status}`; } else if (msg.event === "DELEGATION_COMPLETE") { status = "completed"; - summary = `Delegation completed (${(p.response_preview as string)?.slice(0, 60) || ""})`; + const preview = (p.response_preview as string) || ""; + summary = `Delegation completed (${preview.slice(0, 60)})`; + responseBody = { response_preview: preview }; } else if (msg.event === "DELEGATION_FAILED") { status = "failed"; summary = `Delegation failed: ${(p.error as string) || "unknown"}`; } else { status = "pending"; + // DELEGATION_SENT carries `task_preview` (truncated to 100 + // chars at broadcast time in delegation.go). Surface as + // request_body.task so the inbound bubble shows what was + // actually delegated, not the UUID stub summary. + const taskPreview = (p.task_preview as string) || ""; summary = `Delegating to ${(p.target_id as string)?.slice(0, 8) || "peer"}`; + if (taskPreview) { + requestBody = { task: taskPreview }; + } } entry = { id: (p.delegation_id as string) || crypto.randomUUID(), @@ -287,8 +341,8 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) { target_id: targetId, method: msg.event === "DELEGATION_SENT" ? "delegate" : "delegate_result", summary, - request_body: null, - response_body: null, + request_body: requestBody, + response_body: responseBody, status, created_at: msg.timestamp || new Date().toISOString(), }; diff --git a/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.test.ts b/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.test.ts index 453a310b..8fd962d2 100644 --- a/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.test.ts +++ b/canvas/src/components/tabs/chat/__tests__/AgentCommsPanel.test.ts @@ -118,7 +118,10 @@ describe("toCommMessage — flow derivation", () => { // Pre-fix the panel filtered these out and showed "no agent comms" // even when 6+ delegations existed in the DB. - it("delegation 'delegate' row maps as outbound to target", () => { + it("delegation 'delegate' row prefers request_body.task over the boilerplate summary", () => { + // The platform's `summary` field is "Delegating to " — useless + // in chat. The real task text lives in request_body.task. Show that + // so the user sees WHAT was delegated, not just where. const m = toCommMessage( makeEntry({ activity_type: "delegation", @@ -126,6 +129,7 @@ describe("toCommMessage — flow derivation", () => { source_id: SELF, target_id: PEER, summary: "Delegating to ws-peer", + request_body: { task: "Build me 10 landing pages" }, status: "pending", }), SELF, @@ -134,15 +138,52 @@ describe("toCommMessage — flow derivation", () => { expect(m!.flow).toBe("out"); expect(m!.peerId).toBe(PEER); expect(m!.peerName).toBe("Peer Agent"); - expect(m!.text).toBe("Delegating to ws-peer"); + expect(m!.text).toBe("Build me 10 landing pages"); expect(m!.status).toBe("pending"); }); - it("delegation 'delegate_result' queued row preserves status='queued'", () => { - // The "queued" status is the load-bearing signal the LLM uses to - // decide whether to wait or fall back. If toCommMessage drops or - // rewrites it, the UI loses the ability to show the "peer busy, - // will reply" affordance. + it("delegation 'delegate' row falls back to a name-resolved label when request_body is missing", () => { + // Older rows or some queued paths don't have request_body.task. + // Don't render the raw UUID — resolve to the peer name so the + // bubble at least reads "Delegating to Peer Agent". + const m = toCommMessage( + makeEntry({ + activity_type: "delegation", + method: "delegate", + source_id: SELF, + target_id: PEER, + summary: "Delegating to ws-peer", + request_body: null, + status: "pending", + }), + SELF, + ); + expect(m!.text).toBe("Delegating to Peer Agent"); + }); + + it("delegation 'delegate_result' row is INBOUND so the chat shows alternating bubbles", () => { + // Even though source_id=us (we wrote the row), the conversational + // direction is peer → us. Render as flow="in" so the user sees + // a chat-style back-and-forth instead of a one-sided "→ To X" wall. + const m = toCommMessage( + makeEntry({ + activity_type: "delegation", + method: "delegate_result", + source_id: SELF, + target_id: PEER, + summary: "Delegation completed (...)", + response_body: { response_preview: "Done — ZIP at /tmp/x.zip" }, + status: "completed", + }), + SELF, + ); + expect(m!.flow).toBe("in"); + expect(m!.text).toBe("Done — ZIP at /tmp/x.zip"); + }); + + it("delegation 'delegate_result' queued row shows a human-readable wait message", () => { + // "Delegation queued — target at capacity" is platform jargon. + // Render with the resolved peer name so the user knows WHO is busy. const m = toCommMessage( makeEntry({ activity_type: "delegation", @@ -150,12 +191,15 @@ describe("toCommMessage — flow derivation", () => { source_id: SELF, target_id: PEER, summary: "Delegation queued — target at capacity", + response_body: { queued: true }, status: "queued", }), SELF, ); + expect(m!.flow).toBe("in"); expect(m!.status).toBe("queued"); - expect(m!.text).toContain("queued"); + expect(m!.text).toContain("Peer Agent"); + expect(m!.text.toLowerCase()).toContain("busy"); }); it("delegation row with no target_id returns null", () => {