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"),