fix(canvas): delegation rows show real text + bidirectional bubbles

User flagged two paper cuts in Agent Comms after the grouping PR:
"Delegating to f6f3a023-ab3c-4a69-b101-976028a4a7ec" reads as gibberish
because it's a UUID, and the chat is "one way" with only outbound bubbles
even though peers are clearly responding.

Both fixes are in toCommMessage's delegation branch:

1. Pull text from the actual payload, not the platform's audit-log summary.
   - delegate row → request_body.task (the task text the agent sent).
     Fallback when missing: "Delegating to <resolved-peer-name>" — never
     the raw UUID.
   - delegate_result row → response_body.response_preview / .text (the
     peer's actual reply). Fallback paths render human-readable status
     for queued / failed cases ("Queued — Peer Agent is busy on a prior
     task...") instead of platform jargon.

2. delegate_result rows render flow="in" — even though source_id=us
   (the platform writes the row on our side), the conversational
   direction is peer → us. The chat now shows alternating bubbles
   (out: "Build me 10 landing pages" → in: "Done — ZIP at /tmp/...")
   instead of one-sided "→ To X" wall.

The WS push handler in this same file now populates request_body /
response_body from the DELEGATION_SENT / DELEGATION_COMPLETE event
payloads (task_preview, response_preview), so live-pushed bubbles use
the same text-extraction path as the GET-on-mount.

Tests:
  - 4 new in toCommMessage's delegation branch:
    - delegate row prefers request_body.task over summary
    - delegate row falls back to name-resolved label when task missing
    - delegate_result row is INBOUND (flow="in")
    - delegate_result queued shows human-readable wait message including
      the resolved peer name
  - Replaces the previous "delegate row maps text from summary" tests
    which encoded the (now-undesirable) platform-summary-as-text behavior.
  - All 15 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-26 20:24:58 -07:00
parent 5f08455340
commit 26fb4b309e
2 changed files with 123 additions and 25 deletions

View File

@ -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 <UUID>" / "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<string, unknown> | 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<string, unknown> | 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 <id>") instead
// of the real text.
let status: string;
let summary: string;
let requestBody: Record<string, unknown> | null = null;
let responseBody: Record<string, unknown> | 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(),
};

View File

@ -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 <UUID>" — 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", () => {