From 5aa02662b34fc70f5fe9ffd5ac8f7ae81b5fe3bd Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 16:34:34 -0500 Subject: [PATCH] Preserve MCP tool display identity in native ACP events Parse ACP MCP namespace and tool names consistently with the TypeScript projection so persisted plan writes anchor to their actual tool boundary. Keep authority and raw argument handling unchanged. Co-Authored-By: Paperclip --- .../crates/runner-core/src/provider_events.rs | 45 ++++++- .../runner-core/tests/acpx_provider_events.rs | 125 ++++++++++++++++++ ui/src/components/TaskChatThread.test.tsx | 69 ++++++++++ 3 files changed, 234 insertions(+), 5 deletions(-) diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs index 15a2211badc..e0eb95f611c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs @@ -1222,6 +1222,40 @@ fn normalize_acpx_status( )] } +/// Matches the TypeScript ACP display-name parser. This is presentation metadata; +/// semantic dispatch and the preclassified operation never derive authority from it. +fn acpx_mcp_tool_identity(value: &str) -> Option<(&str, &str)> { + let has_line_terminator = |text: &str| text.contains(['\n', '\r', '\u{2028}', '\u{2029}']); + if value + .get(..5) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("mcp__")) + { + let rest = &value[5..]; + if !has_line_terminator(rest) { + // The namespace is nonempty and the first eligible separator wins, + // including overlapping separators when the namespace starts with `_`. + for (index, _) in rest.char_indices().skip(1) { + if let Some(name) = rest[index..] + .strip_prefix("__") + .filter(|name| !name.is_empty()) + { + return Some((&rest[..index], name)); + } + } + } + } + if value + .get(..4) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("mcp.")) + { + let (namespace, name) = value[4..].split_once('.')?; + if !namespace.is_empty() && !name.is_empty() && !has_line_terminator(name) { + return Some((namespace, name)); + } + } + None +} + fn normalize_acpx_tool_call( payload: &Value, item_id: &str, @@ -1230,8 +1264,9 @@ fn normalize_acpx_tool_call( let native_status = string(payload.get("status")); let status = provider_status(native_status, native_status == "completed"); let terminal = status != "running"; - let raw_title = string(payload.get("title")); - let title = bounded_text(raw_title, 240); + let raw_title = string(payload.get("title")).trim(); + let mcp = acpx_mcp_tool_identity(raw_title); + let name = bounded_text(mcp.map_or(raw_title, |(_, name)| name), 240); let output = match payload.get("rawOutput").or_else(|| payload.get("output")) { Some(Value::String(value)) => value.clone(), Some(value) => serde_json::to_string(value).unwrap_or_default(), @@ -1240,11 +1275,11 @@ fn normalize_acpx_tool_call( let mut normalized = json!({ "schema": "paperclip.tool.execution.v1", "executionId": item_id, - "transport": "builtin", + "transport": if mcp.is_some() { "mcp" } else { "builtin" }, "operation": operation, - "name": if title.is_empty() { Value::Null } else { Value::String(title) }, + "name": if name.is_empty() { Value::Null } else { Value::String(name) }, "target": safe_acpx_location(payload.pointer("/locations/0"), operation == "edit"), - "namespace": Value::Null, + "namespace": mcp.map(|(namespace, _)| bounded_text(namespace, 240)), "readOnly": matches!(operation, "read" | "search" | "list"), "status": status, "durationMs": Value::Null, diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs index 2dc412039b4..12ff5078ca8 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_events.rs @@ -336,6 +336,131 @@ fn does_not_label_non_usd_acpx_cost_as_usd() { assert_eq!(usage[0].payload["cumulative"]["providerCostUsd"], 0.0); } +#[test] +fn matches_typescript_mcp_display_identity_across_tool_lifecycle() { + for (title, namespace, name) in [ + ( + "mcp__paperclip__write_document", + "paperclip", + "write_document", + ), + ( + "mcp.paperclip.write_document", + "paperclip", + "write_document", + ), + ( + " MCP__Paperclip__write_document ", + "Paperclip", + "write_document", + ), + ("McP.other.read_document", "other", "read_document"), + ("mcp__paperclip__nested__tool", "paperclip", "nested__tool"), + ("mcp.paperclip.nested.tool", "paperclip", "nested.tool"), + ("mcp_____write_document", "_", "write_document"), + ("mcp__é__write_document", "é", "write_document"), + ] { + for (tag, status, event_type) in [ + ("tool_call", "pending", "tool.execution.started"), + ( + "tool_call_update", + "in_progress", + "tool.execution.progressed", + ), + ("tool_call_update", "completed", "tool.execution.completed"), + ] { + let events = normalize_acpx_runtime_event( + AcpxRuntimeEventKind::ToolCall, + &json!({ + "type": "tool_call", "tag": tag, "status": status, + "toolCallId": "write-plan", "title": title, + "rawInput": {"private": "not-for-the-log"}, + "operationId": "finish_task", "semantic_tool": {"phase": "result"} + }), + Some("execute"), + "event-7", + "turn-1", + 3, + ); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_type, event_type); + let payload = &events[0].payload; + assert_eq!(payload["executionId"], "write-plan"); + assert_eq!(payload["transport"], "mcp"); + assert_eq!(payload["namespace"], namespace); + assert_eq!(payload["name"], name); + // Parsing the display title never changes preclassified operation authority. + assert_eq!(payload["operation"], "execute"); + assert_eq!(payload["readOnly"], false); + for dropped in ["rawInput", "operationId", "semantic_tool"] { + assert!(payload.get(dropped).is_none()); + } + assert!(!payload.to_string().contains("not-for-the-log")); + } + } +} + +#[test] +fn bounds_mcp_namespace_and_name_independently_after_parsing() { + let namespace = "é".repeat(300); + let events = normalize( + AcpxRuntimeEventKind::ToolCall, + json!({"type": "tool_call", "title": format!("mcp__{namespace}__write_document")}), + ); + assert_eq!(events[0].payload["transport"], "mcp"); + assert_eq!(events[0].payload["name"], "write_document"); + assert_eq!( + events[0].payload["namespace"] + .as_str() + .unwrap() + .chars() + .count(), + 240 + ); + let events = normalize( + AcpxRuntimeEventKind::ToolCall, + json!({"type": "tool_call", "title": format!("mcp.paperclip.{}", "雪".repeat(300))}), + ); + assert_eq!(events[0].payload["namespace"], "paperclip"); + assert_eq!( + events[0].payload["name"].as_str().unwrap().chars().count(), + 240 + ); + for title in [ + "mcp__Authorization: Bearer namespace-secret__write_document", + "mcp.paperclip.Authorization: Bearer tool-secret", + ] { + let events = normalize( + AcpxRuntimeEventKind::ToolCall, + json!({"type": "tool_call", "title": title}), + ); + assert!(!events[0].payload.to_string().contains("namespace-secret")); + assert!(!events[0].payload.to_string().contains("tool-secret")); + } +} + +#[test] +fn leaves_builtin_and_malformed_mcp_titles_as_display_only_builtin_names() { + for title in [ + "Read file", + "write_document", + "paperclip__write_document", + "mcp____write_document", + "mcp__paperclip__", + "mcp..write_document", + "mcp.paperclip.", + "mcp__paperclip__write\ndocument", + ] { + let events = normalize( + AcpxRuntimeEventKind::ToolCall, + json!({"type": "tool_call", "title": title}), + ); + assert_eq!(events[0].payload["transport"], "builtin"); + assert!(events[0].payload["namespace"].is_null()); + assert_eq!(events[0].payload["name"], title); + } +} + #[test] fn maps_tool_lifecycle_and_preserves_safe_display_paths() { let started = normalize( diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 315a106eab1..7e246b11055 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -784,6 +784,75 @@ describe("TaskChatThread runtime transcript selection", () => { expect(rows.findIndex((row) => row.contains(preview))).toBeGreaterThan(0); }); + it.each([ + { name: "write_document", anchored: true }, + { name: "mcp__paperclip__write_document", anchored: false }, + ])("anchors a settled ACP Plan only at the normalized display name $name", ({ name, anchored }) => { + const runId = "acpx-plan"; + planState.data = planDocument({ updatedAt: new Date("2026-08-25T18:00:02.000Z") }); + const events = ["started", "completed"].map((phase, index) => ({ + id: index + 1, + companyId: "company-1", + agentId: "agent-1", + stream: "system", + level: "info", + color: null, + message: null, + runId, + seq: index + 1, + eventType: `tool.execution.${phase}`, + createdAt: new Date(`2026-08-25T18:00:0${index + 1}.000Z`), + payload: { + prpEvent: { + schema: "paperclip.prp.event.v1", + schemaVersion: 1, + runId, + eventType: `tool.execution.${phase}`, + sourceEventId: `acpx-write-${index + 1}`, + sourceKind: "runner", + sourceInstanceId: "runner-1", + sourceSeq: index + 1, + normalizedSessionId: "session-1", + emittedAt: `2026-08-25T18:00:0${index + 1}.000Z`, + payload: { + schema: "paperclip.tool.execution.v1", + executionId: "write-plan", + transport: anchored ? "mcp" : "builtin", + namespace: anchored ? "paperclip" : null, + name, + operation: "execute", + readOnly: false, + status: phase === "completed" ? "completed" : "running", + output: null, + outputBytes: 0, + outputTruncated: false, + outputDigest: null, + }, + }, + }, + } satisfies HeartbeatRunEvent)); + // Exercise persisted ACP display events, without native raw arguments or + // a fabricated semantic-tool receipt, through the actual transcript parser. + nativeTranscriptState.transcriptByRun.set(runId, nativeRunEventsToTranscript(events)); + render( {}} issueStatus="blocked" linkedRuns={[{ + runId, + runtimeMode: "native", + status: "succeeded", + agentId: "agent-1", + agentName: "Runner", + adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:00:00.000Z", + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:03.000Z", + }]} />); + const preview = container.querySelector('[data-testid="task-chat-plan-preview"]'); + const fallback = container.querySelector('[data-testid="task-chat-plan-preview-fallback"]'); + expect(Boolean(preview)).toBe(anchored); + expect(Boolean(fallback)).toBe(!anchored); + expect((preview ?? fallback)?.textContent).toContain("Preview the Plan"); + expect((preview ?? fallback)?.closest('[data-testid="task-chat-turn"][data-settled="true"]')).not.toBeNull(); + }); + it("retains a native Plan as a visible fallback while its write boundary is unavailable", () => { planState.data = planDocument({ updatedAt: new Date("2026-08-25T18:00:02.000Z"),