+ {events.map((line, idx) => (
+
+ {line}
+
+ ))}
+
+ );
+}
diff --git a/apps/web/components/task/chat/types.ts b/apps/web/components/task/chat/types.ts
index fd4e091f21..62a03815f5 100644
--- a/apps/web/components/task/chat/types.ts
+++ b/apps/web/components/task/chat/types.ts
@@ -12,6 +12,32 @@ export type GenericPayload = {
output?: unknown;
};
+// MonitorView is the structured shape the kandev ACP adapter writes into the
+// Generic tool payload's `output.monitor` field for Claude-acp's `Monitor`
+// tool. The adapter mutates this in place across tool_call_updates so the
+// UI sees a stable view of the Monitor's state (event count, recent tail,
+// terminal flag) without needing a custom NormalizedPayload kind.
+export type MonitorView = {
+ kind?: string;
+ task_id?: string;
+ command?: string;
+ event_count?: number;
+ recent_events?: string[];
+ ended?: boolean;
+ end_reason?: string;
+};
+
+// Output wrapper helper: when the adapter attaches a Monitor view, it lands
+// at `generic.output.monitor`. Frontend code should narrow via this guard
+// rather than casting on its own.
+export function readMonitorView(payload: GenericPayload | undefined): MonitorView | null {
+ const out = payload?.output;
+ if (!out || typeof out !== "object") return null;
+ const wrapper = out as { monitor?: unknown };
+ if (!wrapper.monitor || typeof wrapper.monitor !== "object") return null;
+ return wrapper.monitor as MonitorView;
+}
+
export type ReadFileOutput = {
content?: string;
line_count?: number;
diff --git a/apps/web/e2e/tests/chat/monitor.spec.ts b/apps/web/e2e/tests/chat/monitor.spec.ts
new file mode 100644
index 0000000000..40539a310b
--- /dev/null
+++ b/apps/web/e2e/tests/chat/monitor.spec.ts
@@ -0,0 +1,162 @@
+import { test, expect } from "../../fixtures/test-base";
+import { SessionPage } from "../../pages/session-page";
+
+// All Monitor scenarios drive the kandev backend with the mock-agent's new
+// e2e:monitor_* directives, which reproduce claude-agent-acp's wire format
+// without depending on the real Claude Code SDK. The kandev ACP adapter
+// recognizes the registration banner, parses task-notification envelopes,
+// strips the model's "Human:" echoes, and tracks live monitor state — those
+// behaviours are unit-tested in the backend; this file asserts the full
+// pipeline lands the right thing in the chat UI.
+
+test.describe("Claude-acp Monitor tool", () => {
+ test("renders watching card, accumulates events, hides envelope text", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(60_000);
+
+ // Three monitor events fire over ~3s. The agent then ends the monitor and
+ // produces a final assistant message so the turn completes deterministically.
+ const script = [
+ 'e2e:monitor_start("task-1", "gh pr checks --watch")',
+ "e2e:delay(200)",
+ 'e2e:monitor_event("task-1", "queued: lint")',
+ "e2e:delay(200)",
+ 'e2e:monitor_event("task-1", "in_progress: lint")',
+ "e2e:delay(200)",
+ 'e2e:monitor_event("task-1", "success: lint")',
+ 'e2e:monitor_end("task-1")',
+ 'e2e:message("watching done")',
+ ].join("\n");
+
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Monitor watching",
+ seedData.agentProfileId,
+ {
+ description: script,
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+
+ await testPage.goto(`/t/${task.id}`);
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
+
+ // The dedicated Monitor card renders, not the generic tool_call row.
+ const monitorCard = session.chat.locator('[data-testid="monitor-card"]').first();
+ await expect(monitorCard).toBeVisible();
+ await expect(monitorCard).toContainText("gh pr checks --watch");
+
+ // Event count badge surfaces all three events. Status pill flipped to
+ // "ended" because the script issued monitor_end and the parent turn
+ // completed.
+ await expect(monitorCard).toContainText("3 events");
+ await expect(session.chat.locator('[data-testid="monitor-status-pill"]').first()).toContainText(
+ /ended/,
+ );
+
+ // Each event body landed in the recent-events tail.
+ const eventList = session.chat.locator('[data-testid="monitor-event"]');
+ await expect(eventList).toHaveCount(3);
+ await expect(eventList.nth(0)).toContainText("queued: lint");
+ await expect(eventList.nth(2)).toContainText("success: lint");
+
+ // Critical: the model's "Human: