mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
Integrate MCP write-boundary repair for real Pi plan qualification
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/runner-copilot-acp: Preserve MCP tool display identity in native ACP events
This commit is contained in:
commit
e35b11db21
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