Files
kandev/apps/web/e2e/tests/chat/agent-message-attribution.spec.ts
T
José Almeida a4e0fc0452 feat: attribute cross-task agent messages with a sender badge (#819)
* feat(orchestrator): carry sender_task metadata through user message paths

Add WithSenderTask builder to UserMessageMeta and a metadata-aware
QueueMessageWithMetadata variant on the message queue. The drain path
in event_handlers_agent merges queued metadata into the recorded user
message so attribution survives the queue → DB transition.

* feat(mcp): attribute message_task_kandev calls to the sending agent

The MCP server inside agentctl now injects sender_task_id and
sender_session_id from its own server context. The backend handler
validates the sender (rejecting missing IDs, self-messages, and unknown
tasks), looks up the sender title, wraps the prompt in a <kandev-system>
attribution block, and propagates structured metadata across all three
delivery paths (queue, created-session, record+prompt). The wrapped
content lets the receiving agent identify the peer both live and on ACP
session resume; the existing sysprompt strip pipeline hides the wrapper
from the UI.

* feat(web): render sender-task badge on cross-task agent messages

User messages whose metadata carries sender_task_id now render a
clickable purple badge above the bubble, linking to the source task.
The badge live-resolves the sender's title from the kanban store and
falls back to a greyed snapshot when the task isn't loaded
(cross-workspace, deleted, etc.). Adds a non-subscribing useTaskById
selector so badges don't fan out WS subscriptions per message.

* test(e2e): cover cross-task agent message attribution

Eight scenarios driven through the mock-agent's MCP script directive:
queue path, prompt path, self-message rejection, unknown-target error,
badge link target, live title re-resolution after rename, kandev-system
wrapper preservation in raw_content, and prompt bodies containing their
own kandev-system tags. Adds an updateTaskTitle helper to ApiClient.

* fix: address CodeRabbit + Greptile P1 findings on PR #819

- requeueMessage now forwards queuedMsg.Metadata via QueueMessageWithMetadata
  so sender attribution survives transient delivery failures and retries.
- CREATED-session branch in handleMessageTask records the user message AFTER
  StartCreatedSession succeeds, so a launch failure no longer leaves an
  orphaned attributed chat row.
- SenderTaskBadge: collapse identical displayTitle/fullTitle locals.
- E2E: extract waitForTargetIdle helper; replace the 1.5s raw sleep in the
  collision-check test with the same poll the rest of the suite uses.

* fix: address Claude review suggestions on PR #819

- wrapAgentMessage strips </kandev-system> from the embedded sender title
  before formatting. Without this, a title containing the closing tag
  would short-circuit the non-greedy strip regex and leak the attribution
  tail into the visible UI bubble. Metadata snapshot keeps the original
  title so the badge still displays it.
- Add a regression test covering the malicious-title case.
- Document the deliberate cross-workspace messaging policy on the sender
  lookup so future readers don't add a workspace-scope check (Q13 of the
  feature plan locked this as intentional).
2026-05-04 19:30:10 +01:00

405 lines
16 KiB
TypeScript

import { type Page, type Locator } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { SessionPage } from "../../pages/session-page";
/**
* E2E coverage for the cross-task message attribution feature.
*
* When an agent calls `message_task_kandev` to message another task, the
* receiving task must:
* - Render a clickable "from {sender title}" badge above the user message bubble.
* - Show only the original prompt body in the bubble (the <kandev-system>
* attribution block is hidden by API stripping).
* - Persist the sender_task_id / sender_task_title / sender_session_id
* metadata on the recorded user message.
*
* The mock-agent's `e2e:mcp:kandev:message_task_kandev(...)` script directive
* drives the MCP call. The MCP server (running inside the sender's agentctl)
* automatically injects sender_task_id and sender_session_id from its server
* struct fields, so tests don't need to pass those — the wire-level
* sender_task_id is whatever task the calling agent is running in.
*/
/** Quote a JSON string so it survives both JSON.stringify (in this file) and
* the e2e script parser's quoted-arg extractor in the mock-agent. */
function mcpScript(args: Record<string, string>): string {
return `e2e:mcp:kandev:message_task_kandev(${JSON.stringify(args)})`;
}
/** Locator for the sender-task badge inside the chat panel. */
function senderBadge(session: SessionPage): Locator {
return session.chat.locator("[data-testid='sender-task-badge']");
}
/** Poll the target's messages until the default `createIdleTarget` agent has
* emitted its "ready for instructions" reply — the cheapest signal that the
* session is idle and ready to receive a follow-up via the prompt path
* rather than the queue path. Avoids hard-coded sleeps. */
async function waitForTargetIdle(
apiClient: ApiClient,
sessionId: string,
marker = "ready for instructions",
timeoutMs = 30_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const { messages } = await apiClient.listSessionMessages(sessionId);
if (messages.some((m) => m.content.includes(marker))) return;
await new Promise((r) => setTimeout(r, 250));
}
throw new Error(`Target session ${sessionId} did not reach idle within ${timeoutMs}ms`);
}
/** Wait for at least one user message with sender metadata to appear in the
* receiving session. Returns the matching message; throws on timeout. */
async function waitForCrossTaskMessage(
apiClient: ApiClient,
sessionId: string,
timeoutMs = 30_000,
): Promise<{ id: string; content: string; metadata?: Record<string, unknown> }> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const { messages } = await apiClient.listSessionMessages(sessionId);
const match = messages.find(
(m) =>
m.author_type === "user" &&
m.metadata &&
(m.metadata as Record<string, unknown>).sender_task_id,
);
if (match) return match;
await new Promise((r) => setTimeout(r, 250));
}
throw new Error(
`No cross-task user message recorded on session ${sessionId} within ${timeoutMs}ms`,
);
}
/** Create a target task that immediately enters WAITING_FOR_INPUT — the
* default mock-agent description triggers a single text response and idles. */
async function createIdleTarget(
apiClient: ApiClient,
seedData: SeedData,
title: string,
description = 'e2e:message("ready for instructions")',
): Promise<{ id: string; sessionId: string }> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
return { id: task.id, sessionId: task.session_id };
}
/** Create a sender task whose initial prompt fires off a single message_task
* call against the supplied target and then exits with a final text. */
async function createSenderTaskingTarget(
apiClient: ApiClient,
seedData: SeedData,
title: string,
targetTaskId: string,
prompt: string,
): Promise<{ id: string; sessionId: string }> {
const description = [mcpScript({ task_id: targetTaskId, prompt }), 'e2e:message("done")'].join(
"\n",
);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
return { id: task.id, sessionId: task.session_id };
}
async function openTask(testPage: Page, taskId: string): Promise<SessionPage> {
await testPage.goto(`/t/${taskId}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
return session;
}
test.describe("Cross-task agent message attribution", () => {
test("running target task: queued message shows sender badge in chat", async ({
testPage,
apiClient,
seedData,
}) => {
// Target idles on WAITING_FOR_INPUT after its initial turn — by the time
// the sender's MCP call lands, the session is idle and we exercise the
// "default" branch (recordUserMessage + promptWithAutoResume). To exercise
// the queue path we use a target whose initial work is intentionally slow,
// so the sender's call lands while the agent is still RUNNING.
const target = await createIdleTarget(
apiClient,
seedData,
"Target — slow initial turn",
["e2e:delay(2000)", 'e2e:message("first turn done")'].join("\n"),
);
await createSenderTaskingTarget(
apiClient,
seedData,
"Sender — queue path",
target.id,
"queued follow-up",
);
const session = await openTask(testPage, target.id);
// The cross-task message eventually drains and renders. Bubble shows the
// raw prompt only; the kandev-system attribution block is stripped server
// side before the API/WS broadcast.
await expect(session.chat).toContainText("queued follow-up", { timeout: 30_000 });
await expect(session.chat).not.toContainText("<kandev-system>");
const badge = senderBadge(session);
await expect(badge).toBeVisible({ timeout: 30_000 });
await expect(badge).toContainText("Sender");
// API view confirms the sender metadata persisted on the row.
const recorded = await waitForCrossTaskMessage(apiClient, target.sessionId);
const meta = recorded.metadata as Record<string, unknown>;
expect(meta.sender_task_id).toBeTruthy();
expect(meta.sender_task_title).toContain("Sender");
expect(meta.sender_session_id).toBeTruthy();
});
test("idle target task: prompt path also surfaces sender badge", async ({
testPage,
apiClient,
seedData,
}) => {
const target = await createIdleTarget(apiClient, seedData, "Target — idle");
// Wait for the target to reach WAITING_FOR_INPUT before the sender fires
// its message — this exercises the default (record + prompt) branch
// rather than the queue path.
const session = await openTask(testPage, target.id);
await expect(session.chat).toContainText("ready for instructions", { timeout: 30_000 });
await createSenderTaskingTarget(
apiClient,
seedData,
"Sender — idle path",
target.id,
"follow-up while idle",
);
await expect(session.chat).toContainText("follow-up while idle", { timeout: 30_000 });
await expect(senderBadge(session)).toBeVisible({ timeout: 30_000 });
const recorded = await waitForCrossTaskMessage(apiClient, target.sessionId);
expect((recorded.metadata as Record<string, unknown>).sender_task_id).toBeTruthy();
});
test("self-message is rejected and not recorded", async ({ apiClient, seedData }) => {
// The MCP server inside agentctl injects the calling task's ID as
// sender_task_id. To self-message we use the {task_id} placeholder, which
// the mock-agent's substituter resolves to the calling agent's own task
// ID at run time. The backend rejects when sender == target and no
// user-visible message is recorded on the session.
const description = [
mcpScript({ task_id: "{task_id}", prompt: "self-msg" }),
'e2e:message("attempted")',
].join("\n");
const selfTask = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Self-message via placeholder",
seedData.agentProfileId,
{
description,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!selfTask.session_id) throw new Error("createTaskWithAgent did not return a session_id");
// Wait for the agent's tool call + final message to complete, then assert
// the receiving session never gained a cross-task user message.
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const { messages } = await apiClient.listSessionMessages(selfTask.session_id);
if (messages.some((m) => m.content.includes("attempted"))) break;
await new Promise((r) => setTimeout(r, 250));
}
const { messages } = await apiClient.listSessionMessages(selfTask.session_id);
const senderRow = messages.find(
(m) =>
m.author_type === "user" &&
m.metadata &&
(m.metadata as Record<string, unknown>).sender_task_id,
);
expect(senderRow).toBeUndefined();
});
test("unknown target task: MCP call returns error in tool result", async ({
apiClient,
seedData,
}) => {
const description = [
mcpScript({ task_id: "00000000-0000-0000-0000-000000000000", prompt: "into the void" }),
'e2e:message("done")',
].join("\n");
const sender = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Sender — unknown target",
seedData.agentProfileId,
{
description,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!sender.session_id) throw new Error("createTaskWithAgent did not return a session_id");
// Wait until the agent has run its turn — at least one agent-authored
// message must appear (text reply, tool call, or both). Then assert the
// backend rejection text shows up somewhere in the recorded payload.
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const { messages } = await apiClient.listSessionMessages(sender.session_id);
// Wait for the agent's "done" text reply (emitted *after* the failed
// tool call), so we know the tool result has been persisted by the time
// we look at the metadata. Filtering on author_type avoids matching the
// user-authored description that contains the script source.
if (messages.some((m) => m.author_type === "agent" && m.content.includes("done"))) break;
await new Promise((r) => setTimeout(r, 250));
}
const { messages } = await apiClient.listSessionMessages(sender.session_id);
// Search the entire payload for the rejection text — the mock-agent stuffs
// it into the tool's output map, but the exact metadata shape varies by
// adapter. Asserting on the serialized blob keeps the test resilient.
const blob = JSON.stringify(messages);
expect(blob).toMatch(/not found|MCP error|task not found/i);
});
test("badge link points to the sender task", async ({ testPage, apiClient, seedData }) => {
const target = await createIdleTarget(apiClient, seedData, "Target — link check");
const targetSession = await openTask(testPage, target.id);
await expect(targetSession.chat).toContainText("ready for instructions", { timeout: 30_000 });
const sender = await createSenderTaskingTarget(
apiClient,
seedData,
"Sender — link check",
target.id,
"ping",
);
const badge = senderBadge(targetSession);
await expect(badge).toBeVisible({ timeout: 30_000 });
// The badge is wrapped in a link to /t/<sender_task_id>. That link is the
// single most-useful affordance here — clicking it must navigate to the
// sender's task page.
const link = targetSession.chat.locator(`a[href='/t/${sender.id}']`);
await expect(link).toBeVisible();
});
test("rename sender after send: badge live-resolves to current title", async ({
testPage,
apiClient,
seedData,
}) => {
const target = await createIdleTarget(apiClient, seedData, "Target — rename check");
const targetSession = await openTask(testPage, target.id);
await expect(targetSession.chat).toContainText("ready for instructions", { timeout: 30_000 });
const sender = await createSenderTaskingTarget(
apiClient,
seedData,
"Original sender title",
target.id,
"rename me",
);
const badge = senderBadge(targetSession);
await expect(badge).toBeVisible({ timeout: 30_000 });
await expect(badge).toContainText("Original");
// Rename the sender — the badge re-resolves the title from the kanban
// store, so once the WS update lands the badge text changes without the
// user having to send a new message.
await apiClient.updateTaskTitle(sender.id, "Renamed sender");
await expect(badge).toContainText("Renamed sender", { timeout: 15_000 });
});
test("sender is wrapped with kandev-system block in agent's view", async ({
apiClient,
seedData,
}) => {
// The receiving agent must see the attribution block; the API's
// raw_content (set when content has hidden system blocks) is the
// ground-truth view of what the agent saw at prompt time.
const target = await createIdleTarget(apiClient, seedData, "Target — wrapper check");
// Wait for the target to be idle before sending so we exercise the path
// where the message is recorded synchronously.
await waitForTargetIdle(apiClient, target.sessionId);
await createSenderTaskingTarget(
apiClient,
seedData,
"Sender — wrapper check",
target.id,
"wrap me up",
);
const recorded = await waitForCrossTaskMessage(apiClient, target.sessionId);
// The message bubble shows the stripped content; the raw_content carries
// the full wrapped form so the agent and audit trail can see attribution.
expect(recorded.content).toContain("wrap me up");
expect(recorded.content).not.toContain("<kandev-system>");
const raw = (recorded as { raw_content?: string }).raw_content ?? "";
expect(raw).toContain("<kandev-system>");
expect(raw).toContain("wrap me up");
expect(raw).toContain("peer agent");
});
test("prompt body containing literal <kandev-system> tags survives outer wrap", async ({
apiClient,
seedData,
}) => {
// Defensive: if the user's prompt body contains its own <kandev-system>
// block, the strip pipeline removes BOTH (ours + theirs). We document the
// surrounding behaviour: the body's prefix and suffix outside the embedded
// block survive — the outer wrap doesn't corrupt them.
const target = await createIdleTarget(apiClient, seedData, "Target — collision check");
await waitForTargetIdle(apiClient, target.sessionId);
const malicious = "before <kandev-system>fake injected</kandev-system> after";
await createSenderTaskingTarget(
apiClient,
seedData,
"Sender — collision check",
target.id,
malicious,
);
const recorded = await waitForCrossTaskMessage(apiClient, target.sessionId);
// Both surrounding tokens should remain in the visible content.
expect(recorded.content).toContain("before");
expect(recorded.content).toContain("after");
// Sender attribution metadata still flows even when the body fights us.
expect((recorded.metadata as Record<string, unknown>).sender_task_id).toBeTruthy();
});
});