mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
915adc4d33
commit
5aa02662b3
3 files changed
+234
-5
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(<TaskChatThread comments={[]} onAdd={async () => {}} 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"),
|
||||
|
||||
Reference in new issue
Block a user