diff --git a/apps/backend/cmd/mock-agent/emitter.go b/apps/backend/cmd/mock-agent/emitter.go index 3903d5d1d0..aa65c72762 100644 --- a/apps/backend/cmd/mock-agent/emitter.go +++ b/apps/backend/cmd/mock-agent/emitter.go @@ -73,6 +73,107 @@ func (e *emitter) plan(entries []acp.PlanEntry) { }) } +// monitorClaudeMeta builds the `_meta.claudeCode.toolName=Monitor` payload +// claude-agent-acp tags Monitor tool_call notifications with. Used by +// e2e:monitor_* directives to reproduce the wire format exactly so the +// kandev ACP adapter's Monitor recognizers fire the same way they do in +// production. +func monitorClaudeMeta() any { + return map[string]any{"claudeCode": map[string]any{"toolName": "Monitor"}} +} + +// monitorClaudeMetaWithTask is like monitorClaudeMeta but also embeds a +// `toolResponse.taskId` field — claude-agent-acp emits this on the +// registration update. +func monitorClaudeMetaWithTask(taskID string) any { + return map[string]any{ + "claudeCode": map[string]any{ + "toolName": "Monitor", + "toolResponse": map[string]any{"taskId": taskID}, + }, + } +} + +// startMonitorTool emits the initial Monitor tool_call (status=pending) and +// then a registration tool_call_update whose rawOutput banner carries the +// taskID. This reproduces the two-frame wire pattern the kandev adapter +// expects: a recognizable banner is the only signal it has that an apparent +// "completed" really means "registered". +// +// The acp-go-sdk does not expose `WithStartMeta` / `WithUpdateMeta` helpers +// at the time of writing, so we set Meta via a local option closure. +func (e *emitter) startMonitorTool(id acp.ToolCallId, taskID, command string) { + withStartMeta := func(meta any) acp.ToolCallStartOpt { + return func(tc *acp.SessionUpdateToolCall) { tc.Meta = toMetaMap(meta) } + } + withUpdateMeta := func(meta any) acp.ToolCallUpdateOpt { + return func(tu *acp.SessionToolCallUpdate) { tu.Meta = toMetaMap(meta) } + } + _ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{ + SessionId: e.sid, + Update: acp.StartToolCall(id, "Monitor", + acp.WithStartKind(acp.ToolKindOther), + acp.WithStartStatus(acp.ToolCallStatusPending), + acp.WithStartRawInput(map[string]any{"command": command}), + withStartMeta(monitorClaudeMeta()), + ), + }) + banner := fmt.Sprintf("Monitor started (task %s, timeout 60000ms). You will be notified on each event.", taskID) + _ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{ + SessionId: e.sid, + Update: acp.UpdateToolCall(id, + acp.WithUpdateStatus(acp.ToolCallStatusCompleted), + acp.WithUpdateRawOutput(banner), + withUpdateMeta(monitorClaudeMetaWithTask(taskID)), + ), + }) +} + +// toMetaMap is a small adapter so the local meta builders can return `any` +// (matching the kandev-side recognizer signature) while the SDK's Meta field +// requires `map[string]any` specifically. +func toMetaMap(v any) map[string]any { + if v == nil { + return nil + } + if m, ok := v.(map[string]any); ok { + return m + } + return nil +} + +// emitMonitorEvent reproduces the model-echoed task-notification envelope +// that fires when a real Monitor's stdout produces a line. The kandev +// adapter parses these out of agent_message_chunks and routes them back to +// the originating Monitor's tool_call card. +func (e *emitter) emitMonitorEvent(taskID, body string) { + envelope := fmt.Sprintf( + "Human: \n%s\n%s\n", + taskID, body, + ) + _ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{ + SessionId: e.sid, + Update: acp.UpdateAgentMessageText(envelope), + }) +} + +// endMonitorTool emits the terminal Monitor tool_call_update — the kandev +// adapter would normally synthesize this on its own at prompt-end, but tests +// drive it explicitly so they don't have to wait for the prompt to finish. +func (e *emitter) endMonitorTool(id acp.ToolCallId) { + withUpdateMeta := func(meta any) acp.ToolCallUpdateOpt { + return func(tu *acp.SessionToolCallUpdate) { tu.Meta = toMetaMap(meta) } + } + _ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{ + SessionId: e.sid, + Update: acp.UpdateToolCall(id, + acp.WithUpdateStatus(acp.ToolCallStatusCompleted), + acp.WithUpdateRawOutput("Monitor exited"), + withUpdateMeta(monitorClaudeMeta()), + ), + }) +} + // requestPermission asks the client for permission to proceed with a tool call. // Returns true if permission was granted, false otherwise. func (e *emitter) requestPermission(toolCallID acp.ToolCallId, title string, kind acp.ToolKind, input any) bool { diff --git a/apps/backend/cmd/mock-agent/script.go b/apps/backend/cmd/mock-agent/script.go index 73d5f69567..b247b5023c 100644 --- a/apps/backend/cmd/mock-agent/script.go +++ b/apps/backend/cmd/mock-agent/script.go @@ -55,9 +55,67 @@ func executeCommand(e *emitter, fullPrompt, line string) { case strings.HasPrefix(line, "e2e:tool_result("): executeSimulatedToolResult(e, line) + + case strings.HasPrefix(line, "e2e:monitor_start("): + executeMonitorStart(e, line) + + case strings.HasPrefix(line, "e2e:monitor_event("): + executeMonitorEvent(e, line) + + case strings.HasPrefix(line, "e2e:monitor_end("): + executeMonitorEnd(e, line) } } +// executeMonitorStart emits a Monitor registration sequence reproducing the +// claude-agent-acp wire format. Format: e2e:monitor_start("taskId", "command") +// +// The taskId is what subsequent monitor_event / monitor_end directives use +// to look the Monitor back up. Command is recorded in the rawInput so the +// frontend can label the card (e.g. "gh pr checks --watch"). +func executeMonitorStart(e *emitter, line string) { + inner := extractParenContent(line, "e2e:monitor_start(") + taskID, rest := extractFirstStringArg(inner) + if taskID == "" { + e.text("Script error: monitor_start requires taskId") + return + } + rest = strings.TrimSpace(rest) + rest = strings.TrimPrefix(rest, ",") + command, _ := extractFirstStringArg(rest) + toolID := nextToolID() + state.monitorTools[taskID] = toolID + e.startMonitorTool(toolID, taskID, command) +} + +// executeMonitorEvent emits a `` envelope as if the +// model echoed an injected user turn. Format: e2e:monitor_event("taskId", "event body") +func executeMonitorEvent(e *emitter, line string) { + inner := extractParenContent(line, "e2e:monitor_event(") + taskID, rest := extractFirstStringArg(inner) + if taskID == "" { + e.text("Script error: monitor_event requires taskId") + return + } + rest = strings.TrimSpace(rest) + rest = strings.TrimPrefix(rest, ",") + body, _ := extractFirstStringArg(rest) + e.emitMonitorEvent(taskID, body) +} + +// executeMonitorEnd emits the terminal tool_call_update for a previously +// started Monitor. Format: e2e:monitor_end("taskId") +func executeMonitorEnd(e *emitter, line string) { + taskID := extractStringArg(line, "e2e:monitor_end(") + tcID, ok := state.monitorTools[taskID] + if !ok { + e.text("Script error: monitor_end for unknown taskId " + taskID) + return + } + delete(state.monitorTools, taskID) + e.endMonitorTool(tcID) +} + // executeMCPCommand parses and executes: e2e:mcp::() func executeMCPCommand(e *emitter, fullPrompt, line string) { rest := strings.TrimPrefix(line, "e2e:mcp:") diff --git a/apps/backend/cmd/mock-agent/script_test.go b/apps/backend/cmd/mock-agent/script_test.go index 1de56265ea..990414a8a5 100644 --- a/apps/backend/cmd/mock-agent/script_test.go +++ b/apps/backend/cmd/mock-agent/script_test.go @@ -561,3 +561,133 @@ func TestExecuteScriptToolUseBadJSON(t *testing.T) { t.Errorf("expected script error message, got %q", text) } } + +// TestExecuteScriptMonitorStart asserts e2e:monitor_start emits the two-frame +// claude-acp wire pattern: a pending tool_call followed by a registration +// tool_call_update whose rawOutput banner carries the taskID and whose Meta +// is tagged with claudeCode.toolName=Monitor. +func TestExecuteScriptMonitorStart(t *testing.T) { + e, mock := newTestEmitter() + resetState() + + executeScript(e, "", `e2e:monitor_start("task-7", "tail -f /var/log/x")`) + + updates := mock.getUpdates() + if len(updates) != 2 { + t.Fatalf("expected 2 updates (start + registration), got %d", len(updates)) + } + if !isToolCallUpdate(updates[0]) { + t.Fatal("first update should be tool_call (pending)") + } + tc := updates[0].notification.Update.ToolCall + if tc.Title != "Monitor" { + t.Errorf("title = %q, want Monitor", tc.Title) + } + if !hasMonitorMeta(tc.Meta) { + t.Errorf("first update missing claudeCode.toolName=Monitor meta: %+v", tc.Meta) + } + + if !isToolCallCompleteUpdate(updates[1]) { + t.Fatal("second update should be tool_call_update (registration)") + } + tcu := updates[1].notification.Update.ToolCallUpdate + if tcu.Status == nil || string(*tcu.Status) != "completed" { + t.Errorf("registration status = %v, want completed", tcu.Status) + } + out, _ := tcu.RawOutput.(string) + if !strings.HasPrefix(out, "Monitor started (task task-7,") { + t.Errorf("rawOutput = %q, want banner starting with 'Monitor started (task task-7,'", out) + } + + // taskID must be remembered for subsequent monitor_event / monitor_end. + if _, ok := state.monitorTools["task-7"]; !ok { + t.Error("monitorTools missing entry for task-7") + } +} + +// TestExecuteScriptMonitorEvent asserts e2e:monitor_event emits an +// agent_message_chunk whose text matches the `` envelope +// pattern the kandev adapter parses. +func TestExecuteScriptMonitorEvent(t *testing.T) { + e, mock := newTestEmitter() + resetState() + + executeScript(e, "", `e2e:monitor_event("task-7", "first event line")`) + + updates := mock.getUpdates() + if len(updates) != 1 { + t.Fatalf("expected 1 update, got %d", len(updates)) + } + if !isTextUpdate(updates[0]) { + t.Fatal("expected text update for monitor event envelope") + } + text := getTextContent(updates[0]) + if !strings.Contains(text, "task-7") || + !strings.Contains(text, "first event line") { + t.Errorf("envelope text = %q, want it to contain task-id and event tags", text) + } +} + +// TestExecuteScriptMonitorEndUsesStoredToolCallID asserts e2e:monitor_end +// resolves the taskID via state.monitorTools and emits a terminal +// tool_call_update against the same toolCallID the start created. +func TestExecuteScriptMonitorEndUsesStoredToolCallID(t *testing.T) { + e, mock := newTestEmitter() + resetState() + + executeScript(e, "", + "e2e:monitor_start(\"task-7\", \"x\")\n"+ + "e2e:monitor_end(\"task-7\")") + + updates := mock.getUpdates() + if len(updates) != 3 { + t.Fatalf("expected 3 updates (start, registration, end), got %d", len(updates)) + } + startTCID := updates[0].notification.Update.ToolCall.ToolCallId + endUpd := updates[2].notification.Update.ToolCallUpdate + if endUpd == nil { + t.Fatal("third update should be tool_call_update (terminal)") + } + if endUpd.ToolCallId != startTCID { + t.Errorf("end ToolCallId = %q, want %q (same as start)", endUpd.ToolCallId, startTCID) + } + if endUpd.Status == nil || string(*endUpd.Status) != "completed" { + t.Errorf("end status = %v, want completed", endUpd.Status) + } + if _, ok := state.monitorTools["task-7"]; ok { + t.Error("monitorTools should drop entry after monitor_end") + } +} + +// TestExecuteScriptMonitorEndUnknownTaskIDReportsError asserts e2e:monitor_end +// for a taskID that was never started emits a "Script error" text update +// (matches the existing handling for malformed directives). +func TestExecuteScriptMonitorEndUnknownTaskIDReportsError(t *testing.T) { + e, mock := newTestEmitter() + resetState() + + executeScript(e, "", `e2e:monitor_end("does-not-exist")`) + + updates := mock.getUpdates() + if len(updates) != 1 { + t.Fatalf("expected 1 update, got %d", len(updates)) + } + text := getTextContent(updates[0]) + if !strings.Contains(text, "Script error") || !strings.Contains(text, "does-not-exist") { + t.Errorf("expected script error referencing unknown taskId, got %q", text) + } +} + +// hasMonitorMeta returns true if the SDK Meta map carries the +// claudeCode.toolName=Monitor marker. +func hasMonitorMeta(meta map[string]any) bool { + if meta == nil { + return false + } + cc, ok := meta["claudeCode"].(map[string]any) + if !ok { + return false + } + name, _ := cc["toolName"].(string) + return name == "Monitor" +} diff --git a/apps/backend/cmd/mock-agent/sequences.go b/apps/backend/cmd/mock-agent/sequences.go index 6ee089b42d..eaa03f8a74 100644 --- a/apps/backend/cmd/mock-agent/sequences.go +++ b/apps/backend/cmd/mock-agent/sequences.go @@ -11,14 +11,18 @@ import ( type scriptState struct { toolCallCounter int lastToolID string + // monitorTools maps user-supplied Monitor taskID -> generated toolCallID, + // so e2e:monitor_event and e2e:monitor_end can reference an earlier + // e2e:monitor_start by name without the script needing to track IDs. + monitorTools map[string]acp.ToolCallId } // state is the process-wide script state. Use resetState() in tests // to get a clean starting point instead of touching fields directly. -var state = &scriptState{} +var state = &scriptState{monitorTools: map[string]acp.ToolCallId{}} func resetState() { - state = &scriptState{} + state = &scriptState{monitorTools: map[string]acp.ToolCallId{}} } func nextToolID() acp.ToolCallId { diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go index d26627cc7f..824ca51b4c 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go @@ -96,6 +96,14 @@ type Adapter struct { // Maps toolCallId -> NormalizedPayload so we can update with results activeToolCalls map[string]*streams.NormalizedPayload + // Active Monitor tools, keyed by sessionID -> taskID -> toolCallID. + // Claude-acp's Monitor tool runs a background script that streams events + // back to the LLM as `` envelopes. We hold this map so + // later agent_message_chunks carrying those envelopes can be routed back + // to the originating Monitor's tool_call card. Cleared on prompt completion + // and rebuilt during session/load replay. + activeMonitors map[string]map[string]string + // OTel tracing: active prompt span context. // Notification spans become children of the prompt span for visual grouping. promptTraceCtx context.Context @@ -130,6 +138,7 @@ func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter { normalizer: NewNormalizer(), updatesCh: make(chan AgentEvent, 100), activeToolCalls: make(map[string]*streams.NormalizedPayload), + activeMonitors: make(map[string]map[string]string), attachMgr: shared.NewAttachmentManager(cfg.WorkDir, l.Zap()), } } @@ -475,6 +484,11 @@ func (a *Adapter) LoadSession(ctx context.Context, sessionID string, mcpServers a.isLoadingSession = false a.mu.Unlock() + // Any Monitor still tracked at this point was running in pre-restart history + // but has no live process to back it now — emit synthetic cancellations so + // the frontend doesn't render a stuck "watching" card. + a.sweepMonitorsOnReplayEnd(sessionID) + if replayPlan != nil { entries := make([]PlanEntry, len(replayPlan.Entries)) for i, e := range replayPlan.Entries { @@ -615,6 +629,12 @@ func (a *Adapter) Prompt(ctx context.Context, message string, attachments []v1.M // tool_call without a terminal status update from the agent). a.cancelActiveToolCalls(sessionID) + // Mark any tracked Monitors as ended. They live longer than a typical tool + // call (the script keeps running across model turns), so this sweep runs + // after `cancelActiveToolCalls` to give the Monitor card a clean terminal + // state when the parent prompt completes naturally. + a.sweepMonitorsOnPromptEnd(sessionID) + // Emit complete event via the stream, including the StopReason from the agent. // This normalizes ACP behavior to match other adapters (stream-json, amp, copilot, opencode). a.logger.Debug("emitting complete event after prompt", @@ -674,13 +694,31 @@ func (a *Adapter) Cancel(ctx context.Context) error { // cancelActiveToolCalls emits cancelled tool_update events for all in-flight tool calls // and clears the activeToolCalls map. +// +// Monitor tool calls are intentionally skipped here — they are tracked in +// activeMonitors and given their own terminal sweep (sweepMonitorsOnPromptEnd +// or sweepMonitorsOnReplayEnd) which uses the appropriate status and a +// payload snapshot. Without this skip the Monitor would receive two +// terminal events with conflicting states. func (a *Adapter) cancelActiveToolCalls(sessionID string) { a.mu.Lock() - toolCalls := a.activeToolCalls - a.activeToolCalls = make(map[string]*streams.NormalizedPayload) + monitorToolCallIDs := make(map[string]bool) + for _, tcID := range a.activeMonitors[sessionID] { + monitorToolCallIDs[tcID] = true + } + toCancel := make(map[string]*streams.NormalizedPayload) + preserved := make(map[string]*streams.NormalizedPayload) + for tcID, payload := range a.activeToolCalls { + if monitorToolCallIDs[tcID] { + preserved[tcID] = payload + } else { + toCancel[tcID] = payload + } + } + a.activeToolCalls = preserved a.mu.Unlock() - for toolCallID, normalized := range toolCalls { + for toolCallID, normalized := range toCancel { a.logger.Debug("cancelling active tool call", zap.String("session_id", sessionID), zap.String("tool_call_id", toolCallID)) @@ -823,6 +861,13 @@ func (a *Adapter) handleACPUpdate(n acp.SessionNotification) { } a.mu.Unlock() + // Even though we suppress replay notifications from reaching clients, + // reconstruct any in-progress Monitor registrations so the post-replay + // sweep can mark them ended-on-restart. Without this, a session where + // Monitor was running before agentctl died would keep showing a + // "watching" card forever after resume. + a.captureReplayMonitor(string(n.SessionId), u) + // Suppress conversation history events during load. // AvailableCommandsUpdate is intentionally NOT suppressed — it may arrive // after the replay completes as a "ready" signal, and the frontend treats @@ -986,7 +1031,23 @@ func (a *Adapter) convertMessageChunk(sessionID string, content acp.ContentBlock // Text content goes directly into the Text field for backward compatibility if content.Text != nil { - event.Text = content.Text.Text + text := content.Text.Text + // Claude-acp's Monitor tool injects each script line back to the model + // as a `` user turn. The wrapper suppresses the + // user_message_chunk so the model often "echoes" the envelope into its + // own assistant text. Parse those out into proper Monitor events and + // strip them from the chat text. Assistant role only — genuine user + // messages don't carry these. + if role == "assistant" { + text = a.routeMonitorEvents(sessionID, text) + if isMonitorHumanEcho(text) { + return nil + } + if strings.TrimSpace(text) == "" { + return nil + } + } + event.Text = text return event } @@ -999,6 +1060,38 @@ func (a *Adapter) convertMessageChunk(sessionID string, content acp.ContentBlock return event } +// routeMonitorEvents extracts Monitor `` envelopes from an +// agent_message_chunk text, emits a synthetic tool_call_update for each event +// against the originating Monitor's toolCallID, and returns the cleaned text. +// Returns the original text unchanged when no envelope is present (the common +// case for non-Monitor sessions). +func (a *Adapter) routeMonitorEvents(sessionID, text string) string { + cleaned, events := extractMonitorEvents(text) + if len(events) == 0 { + return text + } + for _, ev := range events { + toolCallID, ok := a.lookupMonitorByTaskID(sessionID, ev.TaskID) + if !ok { + a.logger.Debug("monitor event for unknown task, dropping envelope and event body", + zap.String("session_id", sessionID), + zap.String("task_id", ev.TaskID)) + continue + } + a.mu.Lock() + payload := a.activeToolCalls[toolCallID] + appendMonitorEvent(payload, ev.TaskID, monitorCommandFromPayload(payload), ev.Body) + a.mu.Unlock() + a.sendUpdate(monitorEventEvent(sessionID, toolCallID, ev.Body, payload)) + a.logger.Debug("monitor event routed", + zap.String("session_id", sessionID), + zap.String("task_id", ev.TaskID), + zap.String("tool_call_id", toolCallID), + zap.Int("body_len", len(ev.Body))) + } + return cleaned +} + // convertAvailableCommands converts an ACP AvailableCommandsUpdate to an AgentEvent, // including input hints when available. func (a *Adapter) convertAvailableCommands(sessionID string, update *acp.SessionAvailableCommandsUpdate) *AgentEvent { @@ -1371,6 +1464,38 @@ func (a *Adapter) convertToolCallResultUpdate(sessionID string, tcu *acp.Session if status == "completed" { status = toolStatusComplete } + // Claude-acp sends incremental updates (title, rawInput, content) with no + // Status field — e.g. the second tool_call_update for Bash carries the actual + // command and human-readable title. The orchestrator only persists updates + // with a known status, so without a synthesized "in_progress" here those + // fields are silently dropped and the message stays on the placeholder + // "Terminal" title from the initial pending tool_call. + if status == "" && (tcu.Title != nil || tcu.RawInput != nil || len(tcu.Content) > 0) { + status = "in_progress" + } + + // Recognize Monitor registration: claude-acp sends `tool_call_update` with + // status="completed" and a `Monitor started (task X, …)` rawOutput about a + // second after the Monitor starts. That status is misleading — the Monitor + // itself is just beginning. Override to "in_progress" so the card stays + // open, and remember taskID -> toolCallID so subsequent task-notification + // envelopes can route their events back to this card. + monitorTaskID, isMonitorRegistration := recognizeMonitorRegistration(tcu.Meta, tcu.RawOutput) + if isMonitorRegistration && status == toolStatusComplete { + a.trackMonitor(sessionID, monitorTaskID, toolCallID) + status = "in_progress" + a.logger.Info("monitor registered", + zap.String("session_id", sessionID), + zap.String("task_id", monitorTaskID), + zap.String("tool_call_id", toolCallID)) + } + + // A terminal tool_call_update for an already-tracked Monitor (the agent + // proactively ended the watch). NormalizeToolResult would otherwise stomp + // the `{monitor: …}` view in Generic.Output with the raw string body, so + // we suppress the normalize call and let the closing-out logic below mark + // the view as ended instead. + isTrackedMonitorTerminal := !isMonitorRegistration && isMonitorMeta(tcu.Meta) && a.isTrackedMonitor(sessionID, toolCallID) isTerminal := status == toolStatusComplete || status == toolStatusError || status == "cancelled" @@ -1383,11 +1508,29 @@ func (a *Adapter) convertToolCallResultUpdate(sessionID string, tcu *acp.Session a.normalizer.UpdatePayloadInput(payload, tcu.RawInput) } - // Update stored payload with tool result output - if tcu.RawOutput != nil && payload != nil { + // Update stored payload with tool result output. Skip for tracked-Monitor + // terminal updates so Generic.Output stays the structured `{monitor: …}` + // view rather than getting clobbered by the rawOutput string. + if tcu.RawOutput != nil && payload != nil && !isTrackedMonitorTerminal { a.normalizer.NormalizeToolResult(payload, tcu.RawOutput) } + // Seed the Monitor view AFTER NormalizeToolResult so we overwrite the + // banner string the normalizer just stuffed into Generic.Output. The + // Monitor card detects itself by `output.monitor` presence — the banner + // would shadow it and the frontend would render this as a generic + // tool_call instead. + if isMonitorRegistration && payload != nil { + seedMonitorView(payload, monitorTaskID, monitorCommandFromPayload(payload)) + } + + // Preserve and mark-ended the Monitor view on tracked-Monitor terminal + // updates so the card flips from "watching" to "ended" without losing + // the accumulated event count or recent-events tail. + if isTrackedMonitorTerminal && payload != nil { + markMonitorEnded(payload, "exited") + } + // Enrich modify_file payload from tool_call_contents. // Claude ACP sends path and content in tool_call_update, not in the initial tool_call. if payload != nil && payload.Kind() == streams.ToolKindModifyFile { @@ -1405,6 +1548,12 @@ func (a *Adapter) convertToolCallResultUpdate(sessionID string, tcu *acp.Session if isTerminal { delete(a.activeToolCalls, toolCallID) + // Also drop tracked Monitor: this terminal update is the + // agent-emitted close, so the prompt-end sweep must not re-emit a + // "Monitor exited" event for this same toolCallID. + if isTrackedMonitorTerminal { + a.dropMonitorByToolCallIDLocked(sessionID, toolCallID) + } } a.mu.Unlock() diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go new file mode 100644 index 0000000000..127c309da0 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go @@ -0,0 +1,545 @@ +package acp + +import ( + "regexp" + "strings" + + acp "github.com/coder/acp-go-sdk" + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// monitorToolName is the literal toolName Claude-acp tags Monitor tool calls with +// in `_meta.claudeCode.toolName`. Used to recognize Monitor across the lifecycle. +const monitorToolName = "Monitor" + +// monitorRegistrationOutputPrefix identifies the rawOutput banner Claude-acp +// emits when a Monitor registers (~1s after start). The wrapper sets status to +// "completed" on this update, but the Monitor itself is only just starting — +// we override that status downstream. +const monitorRegistrationOutputPrefix = "Monitor started (task " + +// monitorRegistrationRE captures the taskId out of the registration banner. +// Banner format: `Monitor started (task , timeout ms)…` +var monitorRegistrationRE = regexp.MustCompile(`^Monitor started \(task ([a-zA-Z0-9_-]+),`) + +// monitorTaskNotifRE matches the `` envelope claude-agent-acp's +// model auto-completes when a Monitor event fires. The model leaks these as +// agent_message_chunk text because the wrapper suppresses the corresponding +// user_message_chunk. Capture groups: 1=taskId, 2=event body. +// +// Pattern is intentionally permissive: the outer `Human:` prefix is optional +// (some chunks split across multiple deltas), we match across newlines, and +// the event body uses lazy `(.*?)` rather than `[^<]*` so script lines that +// contain `<` characters (e.g. `build failed`, ANSI escape +// fragments, shell redirection text) still match instead of leaking the +// raw envelope into the chat. +var monitorTaskNotifRE = regexp.MustCompile( + `(?s)(?:Human:\s*)?\s*([^<]+).*?(.*?)\s*`, +) + +// monitorHumanEchoRE matches an orphan "Human:" prefix with no closing +// `` — i.e. the model started echoing an envelope but the +// chunk got cut off. Conservative pattern: drop only when the chunk is just the +// prefix (possibly with a partial `<…` opener), so genuine assistant messages +// that happen to mention "Human:" survive. +var monitorHumanEchoRE = regexp.MustCompile(`^Human:\s*(<[^>]*)?\s*$`) + +// recognizeMonitorRegistration inspects a tool_call_update for the Monitor +// registration banner. Returns the parsed taskID and true when the update +// represents a registration (not a real completion). The caller is expected to +// override the outgoing status from "completed" to "in_progress" so the +// Monitor card stays open in the UI. +func recognizeMonitorRegistration(meta map[string]any, rawOutput any) (string, bool) { + if !isMonitorMeta(meta) { + return "", false + } + out, ok := rawOutput.(string) + if !ok || !strings.HasPrefix(out, monitorRegistrationOutputPrefix) { + return "", false + } + matches := monitorRegistrationRE.FindStringSubmatch(out) + if len(matches) != 2 { + return "", false + } + return matches[1], true +} + +// isMonitorMeta returns true if the ACP `_meta.claudeCode.toolName` field +// equals "Monitor". The meta map is shaped untyped over the wire (`{claudeCode: +// {toolName: …}}`), so we walk it defensively. +func isMonitorMeta(meta map[string]any) bool { + if meta == nil { + return false + } + cc, ok := meta["claudeCode"].(map[string]any) + if !ok { + return false + } + name, _ := cc["toolName"].(string) + return name == monitorToolName +} + +// recognizeMonitorTaskCall inspects a `tool_call` notification's metadata and +// rawInput. Returns the watched command (best-effort) when the call is a +// Monitor invocation, plus true. Used during replay to seed activeMonitors +// before the matching registration update arrives. +func recognizeMonitorTaskCall(tc *acp.SessionUpdateToolCall) (string, bool) { + if tc == nil || !isMonitorMeta(tc.Meta) { + return "", false + } + cmd := "" + if input, ok := tc.RawInput.(map[string]any); ok { + if c, ok := input["command"].(string); ok { + cmd = c + } + } + return cmd, true +} + +// monitorCommandFromPayload extracts the watched command string from a +// Generic tool payload's Input field. The ACP normalizer stores the entire +// args map (including a `raw_input` key) under Input, so the command lives +// at `Input.raw_input.command` for Generic tools — not at a top-level key. +// Returns empty string when the path is missing or the wrong shape. +func monitorCommandFromPayload(payload *streams.NormalizedPayload) string { + if payload == nil || payload.Generic() == nil { + return "" + } + args, ok := payload.Generic().Input.(map[string]any) + if !ok { + return "" + } + rawInput, ok := args["raw_input"].(map[string]any) + if !ok { + // Fallback: some agents pass command at the top level. + cmd, _ := args["command"].(string) + return cmd + } + cmd, _ := rawInput["command"].(string) + return cmd +} + +// isTrackedMonitor returns true if any taskID -> toolCallID mapping in the +// session's activeMonitors entry equals the given toolCallID. Used by +// convertToolCallResultUpdate to recognize agent-emitted terminal updates +// for Monitors that we already know about, so the rawOutput string doesn't +// stomp the structured Monitor view in Generic.Output. +func (a *Adapter) isTrackedMonitor(sessionID, toolCallID string) bool { + a.mu.RLock() + defer a.mu.RUnlock() + for _, tcID := range a.activeMonitors[sessionID] { + if tcID == toolCallID { + return true + } + } + return false +} + +// dropMonitorByToolCallIDLocked is the same as dropMonitorByToolCallID but +// the caller already holds Adapter.mu. Used inside convertToolCallResultUpdate +// where the lock is already held for the duration of the payload update +// block. +func (a *Adapter) dropMonitorByToolCallIDLocked(sessionID, toolCallID string) { + monitors := a.activeMonitors[sessionID] + if monitors == nil { + return + } + for taskID, tcID := range monitors { + if tcID == toolCallID { + delete(monitors, taskID) + } + } + if len(monitors) == 0 { + delete(a.activeMonitors, sessionID) + } +} + +// trackMonitor records a registered Monitor against a session. +func (a *Adapter) trackMonitor(sessionID, taskID, toolCallID string) { + a.mu.Lock() + defer a.mu.Unlock() + monitors := a.activeMonitors[sessionID] + if monitors == nil { + monitors = make(map[string]string) + a.activeMonitors[sessionID] = monitors + } + monitors[taskID] = toolCallID +} + +// lookupMonitorByTaskID resolves a taskID to its toolCallID for a session. +// Returns the toolCallID and true if the Monitor is currently tracked. +func (a *Adapter) lookupMonitorByTaskID(sessionID, taskID string) (string, bool) { + a.mu.RLock() + defer a.mu.RUnlock() + monitors := a.activeMonitors[sessionID] + if monitors == nil { + return "", false + } + tcID, ok := monitors[taskID] + return tcID, ok +} + +// takeActiveMonitors atomically removes and returns the active Monitor map +// for a session. Used by the prompt-end sweep and the post-replay restart +// sweep, both of which need to drain the map to emit terminal updates. +func (a *Adapter) takeActiveMonitors(sessionID string) map[string]string { + a.mu.Lock() + defer a.mu.Unlock() + monitors := a.activeMonitors[sessionID] + delete(a.activeMonitors, sessionID) + return monitors +} + +// extractMonitorEvents parses every `` envelope out of an +// agent_message_chunk text, replaces each match with empty string, and +// returns the cleaned text plus the parsed events in order. +// +// Each event carries the matched taskID (so the caller can route to the right +// Monitor's toolCallID) and the event body (the actual line stdout produced). +func extractMonitorEvents(text string) (cleaned string, events []monitorEvent) { + matches := monitorTaskNotifRE.FindAllStringSubmatchIndex(text, -1) + if len(matches) == 0 { + return text, nil + } + events = make([]monitorEvent, 0, len(matches)) + var b strings.Builder + prev := 0 + for _, m := range matches { + // m: [matchStart, matchEnd, taskIdStart, taskIdEnd, eventStart, eventEnd] + b.WriteString(text[prev:m[0]]) + events = append(events, monitorEvent{ + TaskID: text[m[2]:m[3]], + Body: text[m[4]:m[5]], + }) + prev = m[1] + } + b.WriteString(text[prev:]) + return b.String(), events +} + +// isMonitorHumanEcho returns true when text is a stripped, orphan "Human:" +// prefix the model emitted while trying to echo a task-notification envelope. +// Such chunks should be dropped to keep them out of the chat UI. +func isMonitorHumanEcho(text string) bool { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return false + } + return monitorHumanEchoRE.MatchString(trimmed) +} + +// monitorEvent represents one Monitor stdout line decoded from a +// `` envelope. +type monitorEvent struct { + TaskID string + Body string +} + +// monitorPayloadCap bounds how many recent event lines we attach to the +// Monitor's NormalizedPayload. Keeping this small keeps message metadata +// reasonable for long-running Monitors (a CI watch can fire hundreds of +// events) while still giving the user a tail of activity in the UI. +const monitorPayloadCap = 10 + +// monitorPayloadView is the structured shape we tuck into the Generic tool +// payload's Output field for Monitor tools. It tells the frontend how to +// render the card (count, recent tail, command) without needing the +// adapter to invent a new NormalizedPayload kind. +type monitorPayloadView struct { + Kind string `json:"kind"` + TaskID string `json:"task_id"` + Command string `json:"command,omitempty"` + EventCount int `json:"event_count"` + RecentEvents []string `json:"recent_events,omitempty"` + Ended bool `json:"ended,omitempty"` + EndReason string `json:"end_reason,omitempty"` +} + +// seedMonitorView writes an empty Monitor view onto the Generic payload at +// registration time so the frontend can switch the card renderer immediately +// — before any events fire. Without this seed a CI watch with a long quiet +// startup would render as a generic tool_call for minutes. +func seedMonitorView(payload *streams.NormalizedPayload, taskID, command string) { + if payload == nil { + return + } + g := payload.Generic() + if g == nil { + return + } + view := readMonitorView(g) + if view.TaskID == "" { + view.TaskID = taskID + } + if view.Command == "" { + view.Command = command + } + g.Output = monitorOutputWrapper(view) +} + +// updateMonitorPayloadView mutates the Monitor tool's NormalizedPayload to +// reflect a new event (or a terminal state). Returns the same payload pointer +// so callers can pass it back into the synthetic AgentEvent. +// +// We piggyback on the Generic payload that was created by `normalizeGeneric` +// when the original tool_call (kind="other") arrived. The adapter's existing +// activeToolCalls map already holds it. +func appendMonitorEvent(payload *streams.NormalizedPayload, taskID, command, body string) *streams.NormalizedPayload { + if payload == nil { + return nil + } + g := payload.Generic() + if g == nil { + // If somebody upstream changed the tool_call kind we won't have a + // Generic payload — leave the payload alone rather than reconstructing. + return payload + } + view := readMonitorView(g) + if view.TaskID == "" { + view.TaskID = taskID + } + if view.Command == "" { + view.Command = command + } + view.EventCount++ + view.RecentEvents = appendCapped(view.RecentEvents, body, monitorPayloadCap) + g.Output = monitorOutputWrapper(view) + return payload +} + +// markMonitorEnded sets the terminal flag and reason on the Monitor view +// without bumping the event counter. +func markMonitorEnded(payload *streams.NormalizedPayload, reason string) *streams.NormalizedPayload { + if payload == nil { + return nil + } + g := payload.Generic() + if g == nil { + return payload + } + view := readMonitorView(g) + view.Ended = true + view.EndReason = reason + g.Output = monitorOutputWrapper(view) + return payload +} + +// readMonitorView extracts the existing Monitor view from a Generic payload +// (the wrapper persists across tool_call_updates because the adapter mutates +// the same payload pointer). Returns a zero-valued view when no Monitor data +// is attached yet. +func readMonitorView(g *streams.GenericPayload) monitorPayloadView { + if g == nil || g.Output == nil { + return monitorPayloadView{Kind: monitorToolName} + } + wrapper, ok := g.Output.(map[string]any) + if !ok { + return monitorPayloadView{Kind: monitorToolName} + } + raw, ok := wrapper["monitor"].(map[string]any) + if !ok { + return monitorPayloadView{Kind: monitorToolName} + } + view := monitorPayloadView{Kind: monitorToolName} + if s, ok := raw["task_id"].(string); ok { + view.TaskID = s + } + if s, ok := raw["command"].(string); ok { + view.Command = s + } + if n, ok := raw["event_count"].(float64); ok { + view.EventCount = int(n) + } else if n, ok := raw["event_count"].(int); ok { + view.EventCount = n + } + if list, ok := raw["recent_events"].([]any); ok { + view.RecentEvents = make([]string, 0, len(list)) + for _, item := range list { + if s, ok := item.(string); ok { + view.RecentEvents = append(view.RecentEvents, s) + } + } + } else if list, ok := raw["recent_events"].([]string); ok { + view.RecentEvents = append([]string{}, list...) + } + if b, ok := raw["ended"].(bool); ok { + view.Ended = b + } + if s, ok := raw["end_reason"].(string); ok { + view.EndReason = s + } + return view +} + +// monitorOutputWrapper boxes the typed view in the `{monitor: {...}}` shape +// the frontend monitor card reads. +func monitorOutputWrapper(view monitorPayloadView) map[string]any { + return map[string]any{"monitor": map[string]any{ + "kind": view.Kind, + "task_id": view.TaskID, + "command": view.Command, + "event_count": view.EventCount, + "recent_events": view.RecentEvents, + "ended": view.Ended, + "end_reason": view.EndReason, + }} +} + +// appendCapped appends s to xs, keeping at most cap most-recent entries. +func appendCapped(xs []string, s string, capacity int) []string { + xs = append(xs, s) + if len(xs) > capacity { + return xs[len(xs)-capacity:] + } + return xs +} + +// monitorEventEvent constructs a synthetic tool_call_update AgentEvent for a +// Monitor event. The frontend appends the body line to the Monitor's content +// stream and renders an updated event count. +func monitorEventEvent(sessionID, toolCallID string, body string, payload *streams.NormalizedPayload) AgentEvent { + return AgentEvent{ + Type: streams.EventTypeToolUpdate, + SessionID: sessionID, + ToolCallID: toolCallID, + ToolStatus: "in_progress", + NormalizedPayload: payload, + ToolCallContents: []streams.ToolCallContentItem{ + {Type: "content", Content: &streams.ContentBlock{Type: "text", Text: body}}, + }, + } +} + +// captureReplayMonitor walks a single replayed session/update during +// session/load history replay and registers any Monitor it finds in +// `activeMonitors`. The post-replay sweep then knows which Monitors to mark +// ended-on-restart. This is purely an internal capture path — the replay +// notifications themselves are still suppressed from reaching clients. +// +// Two notification shapes can register a Monitor: +// - `tool_call` with `_meta.claudeCode.toolName == "Monitor"` (records by toolCallID; +// we don't yet know taskID until the registration update arrives) +// - `tool_call_update` whose `rawOutput` matches the registration banner +// (records the taskID -> toolCallID mapping) +// +// A `tool_call_update` whose status is terminal (`completed` with non-banner +// rawOutput, `failed`, `cancelled`) means the Monitor already ended in +// history — drop it from the map. +func (a *Adapter) captureReplayMonitor(sessionID string, u acp.SessionUpdate) { + if tc := u.ToolCall; tc != nil { + if _, ok := recognizeMonitorTaskCall(tc); ok { + a.trackMonitor(sessionID, replayPendingTaskID(string(tc.ToolCallId)), string(tc.ToolCallId)) + } + } + if tcu := u.ToolCallUpdate; tcu != nil { + toolCallID := string(tcu.ToolCallId) + if taskID, ok := recognizeMonitorRegistration(tcu.Meta, tcu.RawOutput); ok { + // Replace any pending-by-toolCallID placeholder with the real taskID. + a.replaceMonitorPendingKey(sessionID, toolCallID, taskID) + a.trackMonitor(sessionID, taskID, toolCallID) + return + } + // Terminal update for a tracked Monitor — drop it from the map so the + // post-replay sweep doesn't double-emit a cancellation. + if tcu.Status != nil { + s := string(*tcu.Status) + if s == "completed" || s == "failed" || s == "cancelled" { + a.dropMonitorByToolCallID(sessionID, toolCallID) + } + } + } +} + +// replayPendingTaskID synthesizes a placeholder taskID for a Monitor we saw +// during replay before the registration banner arrived. It's namespaced by +// toolCallID so it can never collide with a real taskID. Replaced in place +// once the banner reveals the real taskID via replaceMonitorPendingKey. +func replayPendingTaskID(toolCallID string) string { + return "_pending_" + toolCallID +} + +// replaceMonitorPendingKey rewrites an entry in activeMonitors that was +// registered during replay with a placeholder taskID once the real taskID +// arrives via the registration banner. +func (a *Adapter) replaceMonitorPendingKey(sessionID, toolCallID, realTaskID string) { + a.mu.Lock() + defer a.mu.Unlock() + monitors := a.activeMonitors[sessionID] + if monitors == nil { + return + } + delete(monitors, replayPendingTaskID(toolCallID)) + monitors[realTaskID] = toolCallID +} + +// dropMonitorByToolCallID removes any entry in activeMonitors mapped to the +// given toolCallID. Used during replay to discard Monitors that already had +// a terminal status in history. +func (a *Adapter) dropMonitorByToolCallID(sessionID, toolCallID string) { + a.mu.Lock() + defer a.mu.Unlock() + monitors := a.activeMonitors[sessionID] + if monitors == nil { + return + } + for taskID, tcID := range monitors { + if tcID == toolCallID { + delete(monitors, taskID) + } + } + if len(monitors) == 0 { + delete(a.activeMonitors, sessionID) + } +} + +// sweepMonitorsOnPromptEnd emits a terminal `complete` tool_call_update for +// every tracked Monitor in a session and clears the map. Called when the +// parent prompt naturally completes — the Monitor process exits with the +// agent turn, so the card should flip from "watching" to "ended". +func (a *Adapter) sweepMonitorsOnPromptEnd(sessionID string) { + for _, toolCallID := range a.takeActiveMonitors(sessionID) { + a.mu.Lock() + payload := a.activeToolCalls[toolCallID] + markMonitorEnded(payload, "exited") + delete(a.activeToolCalls, toolCallID) + a.mu.Unlock() + a.sendUpdate(monitorTerminalEvent(sessionID, toolCallID, toolStatusComplete, "Monitor exited", payload)) + } +} + +// sweepMonitorsOnReplayEnd emits a `cancelled` tool_call_update for every +// Monitor reconstructed from session-load history. The agent process restart +// killed the underlying script, so any Monitor that was in-flight before the +// restart is dead — we surface that to the UI rather than leaving the card +// stuck in "watching". +func (a *Adapter) sweepMonitorsOnReplayEnd(sessionID string) { + for _, toolCallID := range a.takeActiveMonitors(sessionID) { + a.mu.Lock() + payload := a.activeToolCalls[toolCallID] + markMonitorEnded(payload, "session_restart") + delete(a.activeToolCalls, toolCallID) + a.mu.Unlock() + a.sendUpdate(monitorTerminalEvent(sessionID, toolCallID, "cancelled", "Monitor ended (session restart)", payload)) + } +} + +// monitorTerminalEvent constructs the synthetic tool_call_update emitted when +// a tracked Monitor must be marked finished — either because the parent +// prompt ended (status "complete") or because the agent process restarted +// (status "cancelled" with a "session restart" note in the contents). +func monitorTerminalEvent(sessionID, toolCallID, status, note string, payload *streams.NormalizedPayload) AgentEvent { + var contents []streams.ToolCallContentItem + if note != "" { + contents = []streams.ToolCallContentItem{ + {Type: "content", Content: &streams.ContentBlock{Type: "text", Text: note}}, + } + } + return AgentEvent{ + Type: streams.EventTypeToolUpdate, + SessionID: sessionID, + ToolCallID: toolCallID, + ToolStatus: status, + NormalizedPayload: payload, + ToolCallContents: contents, + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_test.go new file mode 100644 index 0000000000..9b6c65a0a1 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_test.go @@ -0,0 +1,575 @@ +package acp + +import ( + "strings" + "testing" + + acp "github.com/coder/acp-go-sdk" + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +// monitorMeta builds the `_meta.claudeCode.toolName=Monitor` payload Claude-acp +// attaches to every Monitor tool_call notification. +func monitorMeta() map[string]any { + return map[string]any{"claudeCode": map[string]any{"toolName": monitorToolName}} +} + +// nonMonitorMeta builds a `_meta.claudeCode.toolName=Bash` payload — used to +// confirm the Monitor recognizers don't fire for unrelated tools. +func nonMonitorMeta() map[string]any { + return map[string]any{"claudeCode": map[string]any{"toolName": "Bash"}} +} + +// seedMonitor registers a Monitor as if its registration update had arrived. +// Returns the toolCallID for use in subsequent assertions. +func seedMonitor(t *testing.T, a *Adapter, sessionID, taskID, toolCallID string) { + t.Helper() + a.trackMonitor(sessionID, taskID, toolCallID) + if got, ok := a.lookupMonitorByTaskID(sessionID, taskID); !ok || got != toolCallID { + t.Fatalf("seedMonitor: lookup after track failed (ok=%v, got=%q)", ok, got) + } +} + +// --- recognizeMonitorRegistration --- + +func TestRecognizeMonitorRegistration_HappyPath(t *testing.T) { + taskID, ok := recognizeMonitorRegistration(monitorMeta(), + "Monitor started (task abc123, timeout 60000ms). You will be notified on each event.") + if !ok || taskID != "abc123" { + t.Errorf("recognize = (%q, %v), want (abc123, true)", taskID, ok) + } +} + +func TestRecognizeMonitorRegistration_WrongToolName(t *testing.T) { + if _, ok := recognizeMonitorRegistration(nonMonitorMeta(), + "Monitor started (task abc123, timeout 60000ms)."); ok { + t.Error("recognized non-Monitor meta as Monitor registration") + } +} + +func TestRecognizeMonitorRegistration_NotABanner(t *testing.T) { + if _, ok := recognizeMonitorRegistration(monitorMeta(), "some other output"); ok { + t.Error("recognized arbitrary string as registration banner") + } +} + +func TestRecognizeMonitorRegistration_NonStringRawOutput(t *testing.T) { + if _, ok := recognizeMonitorRegistration(monitorMeta(), + map[string]any{"output": "Monitor started (task abc, …)"}); ok { + t.Error("recognized object rawOutput as banner") + } +} + +// --- extractMonitorEvents --- + +func TestExtractMonitorEvents_SingleEvent(t *testing.T) { + in := "Human: \nt1\nevent-A\n" + cleaned, events := extractMonitorEvents(in) + if cleaned != "" { + t.Errorf("cleaned = %q, want empty", cleaned) + } + if len(events) != 1 { + t.Fatalf("got %d events, want 1", len(events)) + } + if events[0].TaskID != "t1" || events[0].Body != "event-A" { + t.Errorf("event = %+v, want {t1, event-A}", events[0]) + } +} + +func TestExtractMonitorEvents_MultipleEnvelopes(t *testing.T) { + in := "t1a" + + " some text " + + "t1b" + cleaned, events := extractMonitorEvents(in) + if !strings.Contains(cleaned, "some text") { + t.Errorf("cleaned = %q, want it to contain 'some text'", cleaned) + } + if strings.Contains(cleaned, "task-notification") { + t.Errorf("cleaned still contains envelope: %q", cleaned) + } + if len(events) != 2 || events[0].Body != "a" || events[1].Body != "b" { + t.Errorf("events = %+v, want [{t1,a},{t1,b}]", events) + } +} + +func TestExtractMonitorEvents_NoEnvelopeReturnsInputUnchanged(t *testing.T) { + in := "regular assistant text without envelopes" + cleaned, events := extractMonitorEvents(in) + if cleaned != in { + t.Errorf("cleaned mutated text without envelopes: %q", cleaned) + } + if events != nil { + t.Errorf("events = %v, want nil", events) + } +} + +func TestExtractMonitorEvents_EmptyEventBody(t *testing.T) { + in := "t1" + _, events := extractMonitorEvents(in) + if len(events) != 1 || events[0].Body != "" { + t.Errorf("events = %+v, want one with empty body", events) + } +} + +func TestExtractMonitorEvents_BodyContainingAngleBrackets(t *testing.T) { + // Real-world scripts emit lines containing `<` (XML-ish errors, shell + // redirection like `< /dev/null`, ANSI fragments). The regex must not + // abort on the first `<` it sees inside the event body; otherwise the + // whole envelope leaks to the chat as raw assistant text. + in := "t1build failed: exit < 1" + _, events := extractMonitorEvents(in) + if len(events) != 1 { + t.Fatalf("got %d events, want 1 (regex should accept '<' in body)", len(events)) + } + if events[0].Body != "build failed: exit < 1" { + t.Errorf("event body = %q, want literal '...' carried through", events[0].Body) + } +} + +// --- isMonitorHumanEcho --- + +func TestIsMonitorHumanEcho_Variants(t *testing.T) { + cases := []struct { + name string + text string + want bool + }{ + {"bare prefix", "Human:", true}, + {"prefix with whitespace", " Human: ", true}, + {"prefix with partial open tag", "Human: t1event-A") + if strings.TrimSpace(cleaned) != "" { + t.Errorf("cleaned = %q, want empty (envelope-only chunk)", cleaned) + } + + events := drainEvents(a) + if len(events) != 1 { + t.Fatalf("got %d events, want 1", len(events)) + } + ev := events[0] + if ev.Type != streams.EventTypeToolUpdate { + t.Errorf("Type = %q, want %q", ev.Type, streams.EventTypeToolUpdate) + } + if ev.ToolCallID != "tc-monitor" { + t.Errorf("ToolCallID = %q, want tc-monitor", ev.ToolCallID) + } + if ev.ToolStatus != "in_progress" { + t.Errorf("ToolStatus = %q, want in_progress", ev.ToolStatus) + } + if len(ev.ToolCallContents) != 1 || ev.ToolCallContents[0].Content == nil || + ev.ToolCallContents[0].Content.Text != "event-A" { + t.Errorf("contents = %+v, want one text content 'event-A'", ev.ToolCallContents) + } +} + +func TestRouteMonitorEvents_UnknownTaskIDLeavesTextIntact(t *testing.T) { + a := newTestAdapter() + // no Monitor registered + in := "tXorphan" + cleaned := a.routeMonitorEvents("s1", in) + // extractMonitorEvents still strips the matched envelope from the cleaned + // text (the envelope is meaningless to the user even when un-routable), + // but no synthetic update is emitted. + if strings.Contains(cleaned, "task-notification") { + t.Errorf("cleaned still contains envelope: %q", cleaned) + } + if got := drainEvents(a); len(got) != 0 { + t.Errorf("got %d events for unknown taskID, want 0", len(got)) + } +} + +func TestRouteMonitorEvents_NoEnvelopeShortCircuits(t *testing.T) { + a := newTestAdapter() + in := "regular assistant text" + cleaned := a.routeMonitorEvents("s1", in) + if cleaned != in { + t.Errorf("cleaned = %q, want unchanged", cleaned) + } + if got := drainEvents(a); len(got) != 0 { + t.Errorf("got %d events for no-envelope text, want 0", len(got)) + } +} + +// --- convertToolCallResultUpdate Monitor registration override --- + +func TestConvertToolCallResultUpdate_MonitorRegistrationOverridesCompleted(t *testing.T) { + a := newTestAdapter() + completed := acp.ToolCallStatus("completed") + tcu := &acp.SessionToolCallUpdate{ + ToolCallId: "tc-monitor", + Status: &completed, + Meta: monitorMeta(), + RawOutput: "Monitor started (task taskZZ, timeout 60000ms). You will be notified.", + } + + ev := a.convertToolCallResultUpdate("s1", tcu) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.ToolStatus != "in_progress" { + t.Errorf("ToolStatus = %q, want in_progress (registration banner must override)", ev.ToolStatus) + } + // Side effect: activeMonitors records taskID -> toolCallID + if got, ok := a.lookupMonitorByTaskID("s1", "taskZZ"); !ok || got != "tc-monitor" { + t.Errorf("activeMonitors lookup = (%q, %v), want (tc-monitor, true)", got, ok) + } +} + +func TestConvertToolCallResultUpdate_RealCompletedStaysComplete(t *testing.T) { + a := newTestAdapter() + completed := acp.ToolCallStatus("completed") + // rawOutput is NOT a registration banner, so we should not override. + tcu := &acp.SessionToolCallUpdate{ + ToolCallId: "tc-bash", + Status: &completed, + Meta: monitorMeta(), // even Monitor-tagged completes can occur (e.g. final exit) + RawOutput: "exit 0\n", + } + ev := a.convertToolCallResultUpdate("s1", tcu) + if ev == nil { + t.Fatal("expected event") + } + if ev.ToolStatus != "complete" { + t.Errorf("ToolStatus = %q, want complete (non-banner output must not override)", ev.ToolStatus) + } +} + +// TestConvertToolCallResultUpdate_MonitorRegistrationSurvivesNormalize is a +// regression guard: NormalizeToolResult writes the rawOutput banner string +// into the Generic payload's Output field, which would shadow the +// `output.monitor` view the frontend uses to detect Monitor cards. The +// Monitor seed must run AFTER NormalizeToolResult so the synthetic +// `{monitor: …}` wrapper is the final value. +func TestConvertToolCallResultUpdate_MonitorRegistrationSurvivesNormalize(t *testing.T) { + a := newTestAdapter() + + // First the initial pending tool_call lands in activeToolCalls so + // convertToolCallResultUpdate has a Generic payload to mutate. + tc := &acp.SessionUpdateToolCall{ + ToolCallId: "tc-monitor", + Title: monitorToolName, + Kind: acp.ToolKind("other"), + Meta: monitorMeta(), + RawInput: map[string]any{"command": "tail -f /var/log/x"}, + } + if ev := a.convertToolCallUpdate("s1", tc); ev == nil { + t.Fatalf("seed: convertToolCallUpdate returned nil") + } + + completed := acp.ToolCallStatus("completed") + tcu := &acp.SessionToolCallUpdate{ + ToolCallId: "tc-monitor", + Status: &completed, + Meta: monitorMeta(), + RawOutput: "Monitor started (task realTaskId, timeout 60000ms). You will be notified.", + } + + ev := a.convertToolCallResultUpdate("s1", tcu) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.NormalizedPayload == nil || ev.NormalizedPayload.Generic() == nil { + t.Fatalf("expected Generic payload, got %+v", ev.NormalizedPayload) + } + out, ok := ev.NormalizedPayload.Generic().Output.(map[string]any) + if !ok { + t.Fatalf("Generic.Output = %v (%T), want map (the {monitor: …} wrapper, not the raw banner string)", + ev.NormalizedPayload.Generic().Output, ev.NormalizedPayload.Generic().Output) + } + monitor, ok := out["monitor"].(map[string]any) + if !ok { + t.Fatalf("Generic.Output[monitor] missing or wrong type — frontend would fall back to generic tool_call rendering") + } + if monitor["task_id"] != "realTaskId" { + t.Errorf("monitor.task_id = %v, want realTaskId", monitor["task_id"]) + } + if monitor["command"] != "tail -f /var/log/x" { + t.Errorf("monitor.command = %v, want it carried over from initial tool_call", monitor["command"]) + } +} + +// --- sweepMonitorsOnPromptEnd --- + +func TestSweepMonitorsOnPromptEnd_EmitsCompleteAndClears(t *testing.T) { + a := newTestAdapter() + seedMonitor(t, a, "s1", "t1", "tc-1") + seedMonitor(t, a, "s1", "t2", "tc-2") + + a.sweepMonitorsOnPromptEnd("s1") + + events := drainEvents(a) + if len(events) != 2 { + t.Fatalf("got %d events, want 2", len(events)) + } + for _, ev := range events { + if ev.ToolStatus != "complete" { + t.Errorf("ToolStatus = %q, want complete", ev.ToolStatus) + } + } + if _, ok := a.lookupMonitorByTaskID("s1", "t1"); ok { + t.Error("activeMonitors not cleared after sweep") + } +} + +// TestConvertToolCallResultUpdate_AgentEmittedMonitorEndPreservesView is a +// regression guard for an E2E failure on the first PR push: when the agent +// emits its own terminal `tool_call_update` for a tracked Monitor (status +// completed, plain rawOutput like "Monitor exited"), `NormalizeToolResult` +// would clobber `Generic.Output` with that string and the frontend's +// MonitorMessage matcher (which checks for `output.monitor`) would no +// longer fire. Frontend would fall back to the generic tool_call card. +func TestConvertToolCallResultUpdate_AgentEmittedMonitorEndPreservesView(t *testing.T) { + a := newTestAdapter() + + // Initial pending tool_call so activeToolCalls has a Generic payload. + tc := &acp.SessionUpdateToolCall{ + ToolCallId: "tc-monitor", + Title: monitorToolName, + Kind: acp.ToolKind("other"), + Meta: monitorMeta(), + RawInput: map[string]any{"command": "tail -f /var/log/x"}, + } + if ev := a.convertToolCallUpdate("s1", tc); ev == nil { + t.Fatalf("seed tool_call returned nil") + } + + // Registration update — establishes the Monitor view and tracks taskID. + completed := acp.ToolCallStatus("completed") + registerTcu := &acp.SessionToolCallUpdate{ + ToolCallId: "tc-monitor", + Status: &completed, + Meta: monitorMeta(), + RawOutput: "Monitor started (task taskZZ, timeout 60000ms).", + } + if ev := a.convertToolCallResultUpdate("s1", registerTcu); ev == nil { + t.Fatalf("registration update returned nil") + } + + // Agent-emitted Monitor end — what mock-agent's e2e:monitor_end produces + // and what real claude-acp emits when a Monitor script exits naturally. + endTcu := &acp.SessionToolCallUpdate{ + ToolCallId: "tc-monitor", + Status: &completed, + Meta: monitorMeta(), + RawOutput: "Monitor exited", + } + ev := a.convertToolCallResultUpdate("s1", endTcu) + if ev == nil { + t.Fatal("expected event for end update, got nil") + } + if ev.NormalizedPayload == nil || ev.NormalizedPayload.Generic() == nil { + t.Fatalf("expected Generic payload to survive, got %+v", ev.NormalizedPayload) + } + out, ok := ev.NormalizedPayload.Generic().Output.(map[string]any) + if !ok { + t.Fatalf("Generic.Output = %v (%T), want the {monitor: …} wrapper preserved through agent-emitted end", + ev.NormalizedPayload.Generic().Output, ev.NormalizedPayload.Generic().Output) + } + monitor, ok := out["monitor"].(map[string]any) + if !ok { + t.Fatal("Generic.Output[monitor] missing — agent-emitted end stomped the view") + } + if monitor["ended"] != true { + t.Errorf("monitor.ended = %v, want true (terminal update should mark ended)", monitor["ended"]) + } + // The tracked entry should be dropped so the prompt-end sweep does not + // re-emit a terminal event for the same toolCallID. + if a.isTrackedMonitor("s1", "tc-monitor") { + t.Error("Monitor still tracked after agent-emitted end — sweep would double-emit") + } +} + +// TestSweepMonitorsOnPromptEnd_NotDoubleCancelledByActiveToolCalls is a +// regression guard for a real wire-bug discovered in CI: when the parent +// prompt naturally ends, `cancelActiveToolCalls` would blanket-cancel every +// entry in `activeToolCalls` (including the Monitor's tool call) before +// `sweepMonitorsOnPromptEnd` ran, leaving the frontend with two +// conflicting terminal events for the same Monitor. Monitor entries must +// be skipped in the cancel loop so the sweep emits the single +// authoritative "Monitor exited" event. +func TestSweepMonitorsOnPromptEnd_NotDoubleCancelledByActiveToolCalls(t *testing.T) { + a := newTestAdapter() + + // Land an initial Monitor tool_call so it sits in activeToolCalls. + tc := &acp.SessionUpdateToolCall{ + ToolCallId: "tc-monitor", + Title: monitorToolName, + Kind: acp.ToolKind("other"), + Meta: monitorMeta(), + RawInput: map[string]any{"command": "tail -f log"}, + } + if ev := a.convertToolCallUpdate("s1", tc); ev == nil { + t.Fatalf("seed tool_call returned nil") + } + a.trackMonitor("s1", "task-X", "tc-monitor") + drainEvents(a) // ignore the initial events + + a.cancelActiveToolCalls("s1") + a.sweepMonitorsOnPromptEnd("s1") + + events := drainEvents(a) + if len(events) != 1 { + t.Fatalf("got %d events, want 1 (the Monitor sweep's terminal event only)", len(events)) + } + if events[0].ToolCallID != "tc-monitor" { + t.Errorf("event ToolCallID = %q, want tc-monitor", events[0].ToolCallID) + } + if events[0].ToolStatus != "complete" { + t.Errorf("event ToolStatus = %q, want complete (cancelActiveToolCalls must skip Monitors)", events[0].ToolStatus) + } + if events[0].NormalizedPayload == nil { + t.Errorf("event NormalizedPayload is nil — sweep lost payload (activeToolCalls drained too early)") + } +} + +func TestSweepMonitorsOnReplayEnd_EmitsCancelledWithRestartNote(t *testing.T) { + a := newTestAdapter() + seedMonitor(t, a, "s1", "t1", "tc-1") + + a.sweepMonitorsOnReplayEnd("s1") + + events := drainEvents(a) + if len(events) != 1 { + t.Fatalf("got %d events, want 1", len(events)) + } + ev := events[0] + if ev.ToolStatus != "cancelled" { + t.Errorf("ToolStatus = %q, want cancelled", ev.ToolStatus) + } + if len(ev.ToolCallContents) == 0 || ev.ToolCallContents[0].Content == nil || + !strings.Contains(ev.ToolCallContents[0].Content.Text, "session restart") { + t.Errorf("contents = %+v, want a 'session restart' note", ev.ToolCallContents) + } +} + +// --- captureReplayMonitor (replay rebuild) --- + +func TestCaptureReplayMonitor_RebuildsFromToolCallAndUpdate(t *testing.T) { + a := newTestAdapter() + + // Replay the initial tool_call (we don't yet know taskID). + tc := &acp.SessionUpdateToolCall{ + ToolCallId: "tc-replay", + Title: monitorToolName, + Kind: acp.ToolKind("other"), + Meta: monitorMeta(), + RawInput: map[string]any{"command": "tail -f /var/log/x"}, + } + a.captureReplayMonitor("s1", acp.SessionUpdate{ToolCall: tc}) + + // Replay the registration banner — taskID becomes known. + completed := acp.ToolCallStatus("completed") + tcu := &acp.SessionToolCallUpdate{ + ToolCallId: "tc-replay", + Status: &completed, + Meta: monitorMeta(), + RawOutput: "Monitor started (task realTask99, timeout 60000ms).", + } + a.captureReplayMonitor("s1", acp.SessionUpdate{ToolCallUpdate: tcu}) + + if got, ok := a.lookupMonitorByTaskID("s1", "realTask99"); !ok || got != "tc-replay" { + t.Errorf("after replay: lookup = (%q, %v), want (tc-replay, true)", got, ok) + } +} + +func TestCaptureReplayMonitor_TerminalUpdateRemovesFromMap(t *testing.T) { + a := newTestAdapter() + seedMonitor(t, a, "s1", "tX", "tc-9") + + cancelled := acp.ToolCallStatus("cancelled") + tcu := &acp.SessionToolCallUpdate{ + ToolCallId: "tc-9", + Status: &cancelled, + Meta: monitorMeta(), + } + a.captureReplayMonitor("s1", acp.SessionUpdate{ToolCallUpdate: tcu}) + + if _, ok := a.lookupMonitorByTaskID("s1", "tX"); ok { + t.Error("terminal replay update did not drop Monitor from map") + } +} + +// --- convertMessageChunk integration --- + +func TestConvertMessageChunk_MonitorEnvelopeStrippedAndRouted(t *testing.T) { + a := newTestAdapter() + seedMonitor(t, a, "s1", "t1", "tc-monitor") + + chunk := acp.TextBlock( + "Human: t1line-1", + ) + ev := a.convertMessageChunk("s1", chunk, "assistant") + if ev != nil { + t.Errorf("expected nil (envelope-only chunk should drop), got %+v", ev) + } + + // One synthetic event should have been routed to the Monitor's tool card. + events := drainEvents(a) + if len(events) != 1 { + t.Fatalf("got %d routed events, want 1", len(events)) + } + if events[0].ToolCallID != "tc-monitor" { + t.Errorf("ToolCallID = %q, want tc-monitor", events[0].ToolCallID) + } +} + +func TestConvertMessageChunk_PlainAssistantTextPassesThrough(t *testing.T) { + a := newTestAdapter() + chunk := acp.TextBlock("Sure, here's the result.") + ev := a.convertMessageChunk("s1", chunk, "assistant") + if ev == nil { + t.Fatal("expected event for plain text, got nil") + } + if ev.Text != "Sure, here's the result." { + t.Errorf("Text = %q, want unchanged", ev.Text) + } +} + +func TestConvertMessageChunk_HumanEchoOnlyChunkDropped(t *testing.T) { + a := newTestAdapter() + chunk := acp.TextBlock("Human:") + ev := a.convertMessageChunk("s1", chunk, "assistant") + if ev != nil { + t.Errorf("expected nil for orphan Human: prefix, got %+v", ev) + } +} + +func TestConvertMessageChunk_UserRoleSkipsMonitorRouting(t *testing.T) { + a := newTestAdapter() + seedMonitor(t, a, "s1", "t1", "tc-monitor") + + chunk := acp.TextBlock( + "t1x", + ) + ev := a.convertMessageChunk("s1", chunk, "user") + if ev == nil { + t.Fatal("expected event for user role, got nil") + } + // User text must reach the chat untouched — the parser should only run for assistant role. + if !strings.Contains(ev.Text, "task-notification") { + t.Errorf("user text was scrubbed: %q", ev.Text) + } + if got := drainEvents(a); len(got) != 0 { + t.Errorf("got %d routed events for user role, want 0", len(got)) + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/tool_call_update_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/tool_call_update_test.go new file mode 100644 index 0000000000..5c0701133a --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/tool_call_update_test.go @@ -0,0 +1,143 @@ +package acp + +import ( + "testing" + + acp "github.com/coder/acp-go-sdk" +) + +// seedExecuteToolCall registers a pending Bash tool_call (Claude-acp pattern: empty +// rawInput, default "Terminal" title) so subsequent tool_call_update tests have an +// activeToolCalls entry to update. +func seedExecuteToolCall(t *testing.T, a *Adapter, toolCallID string) { + t.Helper() + tc := &acp.SessionUpdateToolCall{ + ToolCallId: acp.ToolCallId(toolCallID), + Title: "Terminal", + Status: acp.ToolCallStatus("pending"), + Kind: acp.ToolKind("execute"), + RawInput: map[string]any{}, + } + if ev := a.convertToolCallUpdate("session-1", tc); ev == nil { + t.Fatalf("seed: convertToolCallUpdate returned nil") + } +} + +// TestConvertToolCallResultUpdate_StatusLessUpdateBecomesInProgress reproduces the +// claude-acp Bash flow: an initial tool_call with empty rawInput and "Terminal" +// placeholder title, followed by a tool_call_update that carries the actual command +// and title but no Status field. Without this fix the orchestrator drops the update +// (its switch only matches known statuses) and the message stays on "Terminal". +func TestConvertToolCallResultUpdate_StatusLessUpdateBecomesInProgress(t *testing.T) { + a := newTestAdapter() + seedExecuteToolCall(t, a, "tc-1") + + cmdTitle := "ls -la /tmp | head -5" + tcu := &acp.SessionToolCallUpdate{ + ToolCallId: "tc-1", + Title: &cmdTitle, + RawInput: map[string]any{ + "command": "ls -la /tmp | head -5", + "description": "List first 5 entries in /tmp", + }, + } + + ev := a.convertToolCallResultUpdate("session-1", tcu) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.ToolStatus != "in_progress" { + t.Errorf("ToolStatus = %q, want %q (status-less updates with content must route through orchestrator)", ev.ToolStatus, "in_progress") + } + if ev.ToolTitle != cmdTitle { + t.Errorf("ToolTitle = %q, want %q", ev.ToolTitle, cmdTitle) + } + if ev.NormalizedPayload == nil || ev.NormalizedPayload.ShellExec() == nil { + t.Fatalf("expected ShellExec payload, got %+v", ev.NormalizedPayload) + } + if got := ev.NormalizedPayload.ShellExec().Command; got != "ls -la /tmp | head -5" { + t.Errorf("ShellExec.Command = %q, want command from rawInput", got) + } +} + +func TestConvertToolCallResultUpdate_StatusLessRawInputOnlyBecomesInProgress(t *testing.T) { + a := newTestAdapter() + seedExecuteToolCall(t, a, "tc-2") + + tcu := &acp.SessionToolCallUpdate{ + ToolCallId: "tc-2", + RawInput: map[string]any{"command": "pwd"}, + } + + ev := a.convertToolCallResultUpdate("session-1", tcu) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.ToolStatus != "in_progress" { + t.Errorf("ToolStatus = %q, want %q", ev.ToolStatus, "in_progress") + } +} + +func TestConvertToolCallResultUpdate_StatusLessContentOnlyBecomesInProgress(t *testing.T) { + a := newTestAdapter() + seedExecuteToolCall(t, a, "tc-3") + + tcu := &acp.SessionToolCallUpdate{ + ToolCallId: "tc-3", + Content: []acp.ToolCallContent{ + { + Content: &acp.ToolCallContentContent{ + Content: acp.TextBlock("partial output"), + Type: "content", + }, + }, + }, + } + + ev := a.convertToolCallResultUpdate("session-1", tcu) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.ToolStatus != "in_progress" { + t.Errorf("ToolStatus = %q, want %q", ev.ToolStatus, "in_progress") + } +} + +func TestConvertToolCallResultUpdate_FullyEmptyUpdateKeepsEmptyStatus(t *testing.T) { + a := newTestAdapter() + seedExecuteToolCall(t, a, "tc-4") + + // A no-op update — no status, no title, no rawInput, no content. Should not + // be promoted to in_progress; orchestrator already ignores empty status, so + // behaviour is unchanged from today. + tcu := &acp.SessionToolCallUpdate{ToolCallId: "tc-4"} + + ev := a.convertToolCallResultUpdate("session-1", tcu) + if ev == nil { + t.Fatal("expected event, got nil") + } + if ev.ToolStatus != "" { + t.Errorf("ToolStatus = %q, want empty (no synthesized status for no-op update)", ev.ToolStatus) + } +} + +func TestConvertToolCallResultUpdate_ExplicitCompletedStatusUnchanged(t *testing.T) { + a := newTestAdapter() + seedExecuteToolCall(t, a, "tc-5") + + completed := acp.ToolCallStatus("completed") + tcu := &acp.SessionToolCallUpdate{ + ToolCallId: "tc-5", + Status: &completed, + RawOutput: "ok", + } + + ev := a.convertToolCallResultUpdate("session-1", tcu) + if ev == nil { + t.Fatal("expected event, got nil") + } + // "completed" is normalized to "complete" by the existing logic — regression guard. + if ev.ToolStatus != "complete" { + t.Errorf("ToolStatus = %q, want %q", ev.ToolStatus, "complete") + } +} diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx index 791dcbf8ab..84855090fb 100644 --- a/apps/web/components/task/chat/message-renderer.tsx +++ b/apps/web/components/task/chat/message-renderer.tsx @@ -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[] = [ ), }, + { + // 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) => , + }, { // Subagent Task tool calls with nested children matches: (comment, ctx) => { diff --git a/apps/web/components/task/chat/messages/monitor-message.test.tsx b/apps/web/components/task/chat/messages/monitor-message.test.tsx new file mode 100644 index 0000000000..b1343b5fe2 --- /dev/null +++ b/apps/web/components/task/chat/messages/monitor-message.test.tsx @@ -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( + , + ); + 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( + , + ); + 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( + , + ); + expect(html).toContain("1 event"); + expect(html).not.toContain("1 events"); + }); + + it("flips to 'ended' when the monitor exits cleanly", () => { + const html = renderToStaticMarkup( + , + ); + 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( + , + ); + 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( + , + ); + 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(); + expect(html).toContain("Monitor"); + expect(html).toContain("watching"); + }); +}); diff --git a/apps/web/components/task/chat/messages/monitor-message.tsx b/apps/web/components/task/chat/messages/monitor-message.tsx new file mode 100644 index 0000000000..4f708e0d70 --- /dev/null +++ b/apps/web/components/task/chat/messages/monitor-message.tsx @@ -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 ; + if (status === "cancelled") return ; + return ; +} + +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 ( +
+ + {vm.title} + {vm.countSuffix && ( + + {vm.countSuffix} + + )} + + {formatStatusLabel(vm.status, vm.ended, vm.endReason)} + + + +
+ ); +} + +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 ( + + } + header={} + hasExpandableContent={vm.recentEvents.length > 0} + isExpanded={isExpanded} + onToggle={handleToggle} + > + + + ); +}); + +function MonitorEventList({ events }: { events: string[] }) { + if (events.length === 0) return null; + return ( +
+ {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: " echo must NOT appear + // anywhere in the chat. The adapter strips matched envelopes from the + // assistant text and drops orphan "Human:" prefixes entirely. + await expect(session.chat).not.toContainText(""); + await expect(session.chat).not.toContainText("Human: { + test.setTimeout(45_000); + + const script = [ + 'e2e:monitor_start("task-1", "tail -f log")', + "e2e:delay(100)", + 'e2e:monitor_event("task-1", "first and only line")', + 'e2e:monitor_end("task-1")', + 'e2e:message("done")', + ].join("\n"); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Monitor singular", + 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 }); + + const card = session.chat.locator('[data-testid="monitor-card"]').first(); + await expect(card).toContainText("1 event"); + await expect(card).not.toContainText("1 events"); + }); + + test("page reload preserves the monitor card and recent events", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(60_000); + + const script = [ + 'e2e:monitor_start("task-1", "wait for ci")', + "e2e:delay(150)", + 'e2e:monitor_event("task-1", "step-1")', + "e2e:delay(150)", + 'e2e:monitor_event("task-1", "step-2")', + 'e2e:monitor_end("task-1")', + 'e2e:message("done")', + ].join("\n"); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Monitor reload", + 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 }); + await expect(session.chat.locator('[data-testid="monitor-card"]').first()).toContainText( + "2 events", + ); + + // Reload — SSR + Zustand hydration must reconstitute the card from DB. + await testPage.reload(); + await session.waitForLoad(); + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + const card = session.chat.locator('[data-testid="monitor-card"]').first(); + await expect(card).toBeVisible(); + await expect(card).toContainText("wait for ci"); + await expect(card).toContainText("2 events"); + await expect(session.chat.locator('[data-testid="monitor-event"]')).toHaveCount(2); + }); +}); diff --git a/docs/plans/2026-04-monitor-tool-acp-support.md b/docs/plans/2026-04-monitor-tool-acp-support.md new file mode 100644 index 0000000000..eccfc8db6e --- /dev/null +++ b/docs/plans/2026-04-monitor-tool-acp-support.md @@ -0,0 +1,143 @@ +# Monitor Tool Support in the Claude ACP Adapter + +**Date:** 2026-04-25 +**Status:** proposed +**PR:** TBD +**Decision:** — + +## Problem + +Newer versions of the Claude Code agent SDK (used via `@agentclientprotocol/claude-agent-acp` ≥ 0.31.0) ship a `Monitor` tool that runs a long-lived background script and streams each stdout line back to the model as a synthetic user turn. Production users running long polling skills (`pr-fixup`, `loop`) report that the kandev chat UI: + +1. Shows the `Monitor` tool card as "completed" within 1 second of starting, even though the watch lasts 30+ minutes. +2. Shows nothing at all during the watch period — when the agent stays quiet between events, no chat output reaches the UI. +3. "Avalanches" output when the user types a follow-up message: kandev's `messageQueue` holds the new prompt until `agent.ready` fires, which only happens when the wrapper's `prompt()` call returns at the end of the watch. + +Investigation (captured in `acp-debug` JSONL traces, see `acp-debug/claude-acp-prompt-*.jsonl`) showed: + +- `claude-agent-acp`'s wrapper keeps `prompt()` alive for the full Monitor lifetime — `session_state_changed=idle` is only emitted once Monitor exits. So `EventTypeComplete` is delayed for the entire watch. +- The wrapper emits the `tool_call_update(Monitor)` with `status=completed` *immediately* after Monitor registers — its `rawOutput` is the literal banner `Monitor started (task X, timeout Yms)…`, **not** a real completion. +- Each Monitor event is injected into the SDK as a "user" turn carrying a `X...Y` envelope. The wrapper *suppresses* `user_message_chunk` for plain-text user turns (`acp-agent.js:712-719`), so kandev never sees the events directly. +- The events do leak through indirectly when the model produces an `agent_message_chunk` whose text starts with `Human: ...` — the model auto-completing the suppressed user turn. + +We want to fix this without modifying `claude-agent-acp`. + +## Design + +All work lands in **`apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go`** (the ACP transport adapter inside agentctl) plus minor frontend additions. No new database tables, no upstream patches. + +### Wire-format quick reference + +``` +tool_call (Monitor) _meta.claudeCode.toolName=Monitor, rawInput.command=… +tool_call_update (Monitor) _meta.claudeCode.toolResponse.taskId=X + status=completed + rawOutput="Monitor started (task X, timeout Yms)…" ← REGISTRATION, not completion +agent_message_chunk text="Human: X + …Y" ← per event, parsed out +… [model output, possibly empty for long stretches] … +[prompt() returns, EventTypeComplete fires] ← real Monitor end +``` + +### Adapter changes + +1. **In-memory tracking** — add `activeMonitors map[string]map[string]string` to `Adapter` (sessionID → taskID → toolCallID), guarded by the existing `Adapter.mu`. + +2. **Recognize Monitor registration** in `convertToolCallResultUpdate` (around line 1363): + - When `_meta.claudeCode.toolName == "Monitor"` AND `rawOutput` matches `^Monitor started \(task (?P[a-z0-9]+),`: + - Record `activeMonitors[sessionID][taskID] = toolCallID`. + - Override outgoing `ToolStatus` from `completed` to `in_progress`. + - Pass through the rawOutput text unchanged so the card still shows the banner. + +3. **Parse Monitor events** in `convertMessageChunk` (around line 976): + - Compile once: `taskNotifRE = regexp.MustCompile((?s)Human:\s*\s*([^<]+).*?([^<]*)\s*)`. + - For each match: + - Look up `activeMonitors[sessionID][taskID]` → toolCallID. If not found, leave the chunk alone (defensive — preserves text in case the parser ever misses). + - Emit a synthetic `EventTypeToolUpdate` with the matched toolCallID, status `in_progress`, and the event body appended via the normalized payload's `content` field. Bump an event counter on the Monitor's normalized payload so the title can read e.g. "Monitor (3 events)". + - Replace the matched block with empty string. If the remaining text is whitespace-only, return `nil` (drop the chunk). Otherwise emit a normal message chunk with the cleaned text. + +4. **Defensive `Human:` echo filter** — same function, after parsing: if the cleaned text is `Human:` followed by a partial/unmatched `<` block (i.e., the regex didn't match but the prefix is suspicious), drop. Conservative pattern: `^Human:\s*(<[^>]*)?$`. + +5. **Prompt-end sweep** — at the end of `Prompt()` (around line 614, alongside `cancelActiveToolCalls`): + - For each remaining entry in `activeMonitors[sessionID]`, emit a synthetic terminal `tool_call_update(Monitor)` with `status=completed` and a final `content` line "Monitor exited" (so the card's terminal state has a clear marker). + - Clear `activeMonitors[sessionID]`. + +6. **Replay-time rebuild** for session restart resilience — in `LoadSession`, while `isLoadingSession` is true (line 817 onward), the existing logic *suppresses* replay notifications from going to clients. Add an inverse hook here: parse Monitor `tool_call`s and `tool_call_update`s out of the replay stream and reconstruct `activeMonitors[sessionID]`. Then, immediately after `isLoadingSession` is cleared (line 475), emit a synthetic `tool_call_update` with `status=cancelled` and content "Monitor ended (session restart)" for each entry, and clear the map. This handles the case where the agent process restarted and the actual scripts are dead. + +### Backend orchestration changes + +In **`apps/backend/internal/orchestrator/event_handlers_agent.go`** (or a sibling, depending on where session-recovery hooks live): + +- After `RecoverInstances()` reconnects to a live agentctl, if any Monitor `tool_call`s in the DB are still in `in_progress`, re-broadcast their latest `tool_call_update` so frontends that connected during the gap window converge to the right state. Single SQL scan + WS publish. + +### Frontend changes + +In **`apps/web/components/task/chat/messages/`**: + +- New `monitor-card.tsx` component, rendered when a tool_call has `toolName == "Monitor"`. Renders: + - Title: `Monitor: ` with a count badge (`N events`). + - Status pill: `watching` | `ended` | `ended (session restart)` driven by latest `status`. + - Inline event list, scrollable, newest-last. + - Spinner while `status == "in_progress"`. +- `use-processed-messages.ts` — Monitor `tool_call_update`s with `status=in_progress` are valid activity messages; ensure grouping logic at line 212 doesn't drop them. +- `apps/web/lib/state/slices/session/` — accumulate Monitor events into the tool's normalized payload as they arrive so the card can read them straight from the store. + +### Restart matrix + +| Scenario | What restarts | How state recovers | +|---|---|---| +| Browser refresh | SPA only | SSR fetches messages from DB, `tool_call.status=in_progress` in DB drives the watching card. New events arrive over the WS once it reconnects. | +| Backend restart | Go backend only | agentctl + adapter in-memory map survive (they live alongside the agent process). WS reconnect flushes the small buffered window. Orchestrator re-broadcasts in-progress Monitors on startup as a belt-and-braces. | +| Agent process restart | agentctl + agent (e.g., container kill) | LoadSession replay rebuilds `activeMonitors`. Stale-sweep at end of replay marks them `cancelled (session restart)`. New prompts start fresh Monitor cards. | + +## Test Strategy + +### Mock agent extension + +`apps/backend/cmd/mock-agent/handler.go` + `script.go` already supports `e2e:tool_use(...)`, `e2e:message(...)`, etc. Add: + +- `e2e:monitor_start(taskId, command)` — emits `tool_call(Monitor)` then `tool_call_update(Monitor)` with the registration-banner rawOutput and `_meta.claudeCode.toolResponse.taskId`. +- `e2e:monitor_event(taskId, body)` — emits an `agent_message_chunk` with the `Human: …` envelope. +- `e2e:monitor_end(taskId)` — emits the final terminal update. + +These three primitives let tests script the full wire contract without depending on the real Claude Code SDK. + +### Go unit tests + +Land in **`apps/backend/internal/agentctl/server/adapter/transport/acp/conversion_test.go`** following the existing table-driven `newTestAdapter()` + `drainEvents()` pattern. Cases: + +1. Monitor registration update → status rewritten to `in_progress`; `activeMonitors` populated. +2. Plain `agent_message_chunk` text passes through unchanged (regression guard). +3. Single `` block in chunk → synthetic `tool_call_update` with event body; remaining text emitted only if non-empty. +4. Multiple blocks in one chunk → one synthetic update each, in order. +5. `` with unknown taskID → fall through, leave chunk text intact. +6. Two concurrent Monitors → events route to the right toolCallID. +7. `Human:` echo without closing tag → dropped. +8. End of `Prompt()` with active Monitors → terminal updates emitted, map cleared. + +In **`load_suppression_test.go`**: replay containing Monitor `tool_call`s rebuilds `activeMonitors`; post-replay sweep emits `cancelled (session restart)` updates. + +In **`ordering_race_test.go`**: race between concurrent monitor_event and monitor_end (run with `-race`). + +### Playwright E2E + +New file **`apps/web/e2e/tests/chat/monitor.spec.ts`** using the `backend` + `apiClient` fixtures from `e2e/fixtures/`. Each test scripts the mock-agent via `monitor_start` / `monitor_event` / `monitor_end` directives. + +1. **Watching state visible**: assert Monitor card with `data-testid="monitor-card"`, status text "watching", event count badge updates to 3 over time, raw `Human: ` text never appears, terminal `monitor_end` flips status to "ended". +2. **Page reload mid-monitor**: emit start + 2 events, `await page.reload()`, assert "watching" + 2 events still rendered, then emit 3rd event + end and assert they land. (Case A) +3. **Backend restart mid-monitor**: same as above but `backend.restart()`. (Case B) +4. **Agent process restart**: emit start + events, force a fresh `session/new` (mock-agent's `--resume false` path), assert original card flips to "ended (session restart)" and a new card appears for the next start. (Case C) +5. **User message during Monitor**: type a message; assert queue indicator visible; let Monitor end; assert message is delivered as a new prompt and lands in chat history. + +### Frontend component tests + +Vitest test next to `monitor-card.tsx`: render with `(status, events[])` permutations, snapshot transitions. One case in `use-processed-messages.test.ts`: Monitor `in_progress` tool_call_update doesn't break activity grouping. + +### Out of scope + +- No integration test against the real `@agentclientprotocol/claude-agent-acp` (too slow, model-flaky). Mock-agent fully covers the wire contract. +- No fuzz tests on the parser; grammar is small and wire format is not user-controlled. + +## Implementation Notes + +_To be filled after the PR lands. Capture any deviations from this plan, surprises in the wire format from real claude-agent-acp versions, and migration considerations if the Monitor envelope format changes upstream._ diff --git a/docs/plans/INDEX.md b/docs/plans/INDEX.md index b603d194e0..baba00296f 100644 --- a/docs/plans/INDEX.md +++ b/docs/plans/INDEX.md @@ -7,3 +7,4 @@ Create new plans via `/record plan` or manually following the format below. | Plan | Status | Date | PR | |------|--------|------|----| | [Host utility agentctl](2026-04-host-utility-agentctl.md) | implemented | 2026-04-08 | TBD | +| [Monitor tool ACP adapter support](2026-04-monitor-tool-acp-support.md) | proposed | 2026-04-25 | TBD |