feat(acp): support claude-acp Monitor tool and fix incremental tool_call updates (#698)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user