Plan mode
@@ -188,6 +260,7 @@ function UserMessageContent({
hasHiddenPrompts,
hasContent,
hasAttachments,
+ senderTask,
} = parseUserMessageMetadata(comment);
return (
@@ -197,6 +270,7 @@ function UserMessageContent({
hasPlanMode={hasPlanMode}
hasReviewComments={hasReviewComments}
contextFiles={contextFiles}
+ senderTask={senderTask}
/>
{hasAttachments && (
diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts
index 8e23dc9ad3..78db75edff 100644
--- a/apps/web/e2e/helpers/api-client.ts
+++ b/apps/web/e2e/helpers/api-client.ts
@@ -218,6 +218,12 @@ export class ApiClient {
await this.request("PATCH", `/api/v1/tasks/${taskId}`, { state });
}
+ /** Rename a task. Used in tests that exercise live-title resolution on the
+ * cross-task message badge. */
+ async updateTaskTitle(taskId: string, title: string): Promise {
+ await this.request("PATCH", `/api/v1/tasks/${taskId}`, { title });
+ }
+
async listAgents(): Promise<{ agents: Agent[]; total: number }> {
return this.request("GET", "/api/v1/agents");
}
diff --git a/apps/web/e2e/tests/chat/agent-message-attribution.spec.ts b/apps/web/e2e/tests/chat/agent-message-attribution.spec.ts
new file mode 100644
index 0000000000..a053acb701
--- /dev/null
+++ b/apps/web/e2e/tests/chat/agent-message-attribution.spec.ts
@@ -0,0 +1,404 @@
+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
+ * 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 {
+ 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 {
+ 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 }> {
+ 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).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 {
+ 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("");
+
+ 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;
+ 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).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).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/. 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("");
+ const raw = (recorded as { raw_content?: string }).raw_content ?? "";
+ expect(raw).toContain("");
+ expect(raw).toContain("wrap me up");
+ expect(raw).toContain("peer agent");
+ });
+
+ test("prompt body containing literal tags survives outer wrap", async ({
+ apiClient,
+ seedData,
+ }) => {
+ // Defensive: if the user's prompt body contains its own
+ // 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 fake injected 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).sender_task_id).toBeTruthy();
+ });
+});
diff --git a/apps/web/hooks/domains/kanban/use-task-by-id.ts b/apps/web/hooks/domains/kanban/use-task-by-id.ts
new file mode 100644
index 0000000000..46e6712068
--- /dev/null
+++ b/apps/web/hooks/domains/kanban/use-task-by-id.ts
@@ -0,0 +1,22 @@
+import { useAppStore } from "@/components/state-provider";
+import { findTaskInSnapshots } from "@/lib/kanban/find-task";
+import type { KanbanState } from "@/lib/state/slices";
+
+type Task = KanbanState["tasks"][number];
+
+/**
+ * Read-only lookup of a task by ID across the active workflow and any loaded
+ * cross-workflow snapshots. Unlike useTask, this hook does not subscribe to
+ * task updates over WebSocket — use it where the caller only needs whatever
+ * task data is already cached (e.g. rendering a sender badge for a message
+ * that came from another task; if the sender task isn't loaded we fall back
+ * to the snapshotted title in metadata).
+ */
+export function useTaskById(taskId: string | null | undefined): Task | null {
+ return useAppStore((state) => {
+ if (!taskId) return null;
+ const fromActive = state.kanban.tasks.find((item: Task) => item.id === taskId);
+ if (fromActive) return fromActive;
+ return findTaskInSnapshots(taskId, state.kanbanMulti.snapshots);
+ });
+}