feat(acp): support claude-acp Monitor tool and fix incremental tool_call updates (#698)

This commit is contained in:
Carlos Florêncio
2026-04-25 16:26:20 +01:00
committed by GitHub
parent 9dc1065010
commit 74d44bd26b
15 changed files with 2334 additions and 8 deletions
@@ -21,6 +21,7 @@ import { TodoMessage } from "@/components/task/chat/messages/todo-message";
import { ScriptExecutionMessage } from "@/components/task/chat/messages/script-execution-message";
import { ClarificationRequestMessage } from "@/components/task/chat/messages/clarification-request-message";
import { ToolSubagentMessage } from "@/components/task/chat/messages/tool-subagent-message";
import { MonitorMessage } from "@/components/task/chat/messages/monitor-message";
import { AgentPlanMessage } from "@/components/task/chat/messages/agent-plan-message";
import { ActionMessage } from "@/components/task/chat/messages/action-message";
@@ -127,6 +128,21 @@ const adapters: MessageAdapter[] = [
<ToolExecuteMessage comment={comment} worktreePath={ctx.worktreePath} />
),
},
{
// Claude-acp's Monitor tool — long-lived background script with streaming
// events. Rendered with a dedicated card that shows watching state, event
// count, and the most recent event tail. Detected via the structured
// `monitor` view the adapter writes into the Generic payload's output
// wrapper (presence-based rather than title-based so renames upstream
// don't break the match). Must run BEFORE the generic tool_call adapter.
matches: (comment) => {
if (comment.type !== "tool_call") return false;
const meta = comment.metadata as ToolCallMetadata | undefined;
const out = meta?.normalized?.generic?.output as { monitor?: unknown } | undefined;
return !!out && typeof out === "object" && !!out.monitor;
},
render: (comment) => <MonitorMessage comment={comment} />,
},
{
// Subagent Task tool calls with nested children
matches: (comment, ctx) => {
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { MonitorMessage } from "./monitor-message";
import type { Message } from "@/lib/types/http";
// monitorMessage is a tiny test-helper that constructs the kandev Message
// shape produced by the orchestrator for a Monitor tool_call. The fields
// match what `service_messages.applyToolCallMessageUpdate` writes after
// receiving an in_progress tool_call_update from the ACP adapter.
function monitorMessage(opts: {
status?: "pending" | "running" | "complete" | "error";
command?: string;
taskId?: string;
eventCount?: number;
recentEvents?: string[];
ended?: boolean;
endReason?: string;
}): Message {
return {
id: "msg-1",
session_id: "s1",
task_id: "t1",
author_type: "agent",
content: "Monitor",
type: "tool_call",
created_at: "2026-04-25T10:00:00Z",
metadata: {
tool_call_id: "tc-monitor",
title: "Monitor",
status: opts.status ?? "running",
normalized: {
kind: "generic",
generic: {
name: "other",
input: { command: opts.command ?? "" },
output: {
monitor: {
kind: "Monitor",
task_id: opts.taskId ?? "task-1",
command: opts.command ?? "",
event_count: opts.eventCount ?? 0,
recent_events: opts.recentEvents ?? [],
ended: opts.ended ?? false,
end_reason: opts.endReason ?? "",
},
},
},
},
},
};
}
describe("MonitorMessage", () => {
it("renders a 'watching' pill while the monitor is in progress", () => {
const html = renderToStaticMarkup(
<MonitorMessage comment={monitorMessage({ command: "gh pr checks", eventCount: 0 })} />,
);
expect(html).toContain("Monitor");
expect(html).toContain("gh pr checks");
expect(html).toContain("watching");
expect(html).not.toContain("ended");
});
it("shows the event count when events have arrived", () => {
const html = renderToStaticMarkup(
<MonitorMessage comment={monitorMessage({ eventCount: 3, recentEvents: ["a", "b", "c"] })} />,
);
expect(html).toContain("3 events");
// recent events render under the card body when expanded; the auto-expand
// logic kicks in while watching, so all three should be in the markup.
expect(html).toContain(">a<");
expect(html).toContain(">b<");
expect(html).toContain(">c<");
});
it("uses singular 'event' for a count of 1", () => {
const html = renderToStaticMarkup(
<MonitorMessage comment={monitorMessage({ eventCount: 1, recentEvents: ["x"] })} />,
);
expect(html).toContain("1 event");
expect(html).not.toContain("1 events");
});
it("flips to 'ended' when the monitor exits cleanly", () => {
const html = renderToStaticMarkup(
<MonitorMessage
comment={monitorMessage({ ended: true, endReason: "exited", status: "complete" })}
/>,
);
expect(html).toContain("ended");
expect(html).not.toContain("watching");
});
it("keeps the events tail expanded after the monitor ends", () => {
// After the run completes the user still wants to see what fired.
// Auto-expand keys off `recentEvents.length > 0`, not `!ended`.
const html = renderToStaticMarkup(
<MonitorMessage
comment={monitorMessage({
ended: true,
endReason: "exited",
status: "complete",
eventCount: 3,
recentEvents: ["queued", "running", "passed"],
})}
/>,
);
expect(html).toContain(">queued<");
expect(html).toContain(">running<");
expect(html).toContain(">passed<");
});
it("flips to 'ended (session restart)' when the agent process restarted", () => {
const html = renderToStaticMarkup(
<MonitorMessage comment={monitorMessage({ ended: true, endReason: "session_restart" })} />,
);
expect(html).toContain("ended (session restart)");
});
it("renders a baseline card even when no monitor view is attached", () => {
// Defensive guard: if the adapter didn't seed the view (e.g. very old
// session before this code shipped), the renderer should still produce
// the "watching" baseline rather than crashing.
const empty: Message = {
id: "msg-1",
session_id: "s1",
task_id: "t1",
author_type: "agent",
content: "Monitor",
type: "tool_call",
created_at: "2026-04-25T10:00:00Z",
metadata: { tool_call_id: "tc-monitor", title: "Monitor", status: "running" },
};
const html = renderToStaticMarkup(<MonitorMessage comment={empty} />);
expect(html).toContain("Monitor");
expect(html).toContain("watching");
});
});
@@ -0,0 +1,135 @@
"use client";
import { memo } from "react";
import { IconActivity, IconCheck, IconX } from "@tabler/icons-react";
import { GridSpinner } from "@/components/grid-spinner";
import { cn } from "@/lib/utils";
import type { Message } from "@/lib/types/http";
import type { ToolCallMetadata } from "@/components/task/chat/types";
import { readMonitorView } from "@/components/task/chat/types";
import { ExpandableRow } from "./expandable-row";
import { useExpandState } from "./use-expand-state";
type MonitorMessageProps = {
comment: Message;
};
// Status icon mirroring the other tool cards: spinner while watching,
// checkmark when the parent prompt completes naturally, X when the agent
// process restarted (the adapter sets status=cancelled in that case).
function MonitorStatusIcon({ status, ended }: { status: string | undefined; ended: boolean }) {
if (!ended) return <GridSpinner className="text-muted-foreground" />;
if (status === "cancelled") return <IconX className="h-3.5 w-3.5 text-amber-500" />;
return <IconCheck className="h-3.5 w-3.5 text-green-500" />;
}
function formatStatusLabel(
status: string | undefined,
ended: boolean,
endReason: string | undefined,
) {
if (!ended) return "watching";
if (status === "cancelled" || endReason === "session_restart") return "ended (session restart)";
return "ended";
}
type MonitorViewModel = {
status: ToolCallMetadata["status"];
ended: boolean;
endReason: string;
eventCount: number;
recentEvents: string[];
title: string;
countSuffix: string;
};
function pluralizeEventCount(n: number): string {
if (n <= 0) return "";
return ` · ${n} event${n === 1 ? "" : "s"}`;
}
function buildMonitorViewModel(comment: Message): MonitorViewModel {
const metadata = comment.metadata as ToolCallMetadata | undefined;
const view = readMonitorView(metadata?.normalized?.generic) ?? {};
const command = view.command ?? "";
const eventCount = view.event_count ?? 0;
return {
status: metadata?.status,
ended: view.ended === true,
endReason: view.end_reason ?? "",
eventCount,
recentEvents: view.recent_events ?? [],
title: command ? `Monitor: ${command}` : "Monitor",
countSuffix: pluralizeEventCount(eventCount),
};
}
function MonitorHeader({ vm }: { vm: MonitorViewModel }) {
return (
<div className="flex items-center gap-2 text-xs" data-testid="monitor-card">
<span className="inline-flex items-center gap-1.5">
<span className="font-mono text-xs text-muted-foreground">{vm.title}</span>
{vm.countSuffix && (
<span className="text-xs text-muted-foreground/80" data-testid="monitor-event-count">
{vm.countSuffix}
</span>
)}
<span
className={cn(
"text-[10px] uppercase tracking-wide rounded px-1.5 py-0.5",
vm.ended
? "bg-muted text-muted-foreground"
: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
)}
data-testid="monitor-status-pill"
>
{formatStatusLabel(vm.status, vm.ended, vm.endReason)}
</span>
<MonitorStatusIcon status={vm.status} ended={vm.ended} />
</span>
</div>
);
}
export const MonitorMessage = memo(function MonitorMessage({ comment }: MonitorMessageProps) {
const vm = buildMonitorViewModel(comment);
// Auto-expand whenever there are events to surface — both during the
// watch (so users see the stream live) and after it ends (so they can
// review what happened). Empty Monitors collapse since there's nothing
// to show.
const { isExpanded, handleToggle } = useExpandState(vm.status, vm.recentEvents.length > 0);
return (
<ExpandableRow
icon={
<IconActivity
className={cn("h-4 w-4", vm.ended ? "text-muted-foreground" : "text-blue-500")}
data-testid="monitor-card-icon"
/>
}
header={<MonitorHeader vm={vm} />}
hasExpandableContent={vm.recentEvents.length > 0}
isExpanded={isExpanded}
onToggle={handleToggle}
>
<MonitorEventList events={vm.recentEvents} />
</ExpandableRow>
);
});
function MonitorEventList({ events }: { events: string[] }) {
if (events.length === 0) return null;
return (
<div className="pl-4 border-l-2 border-border/30 space-y-1" data-testid="monitor-event-list">
{events.map((line, idx) => (
<pre
key={`${idx}-${line.slice(0, 32)}`}
className="text-xs bg-muted/30 rounded p-2 overflow-x-auto whitespace-pre-wrap"
data-testid="monitor-event"
>
{line}
</pre>
))}
</div>
);
}
+26
View File
@@ -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;