mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
Project Copilot native events into attributed runner activity
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
cd4db5660a
commit
fe3b7e6fb8
3 files changed
+230
No files matched your search
@@ -67,6 +67,13 @@ commands capable of changing permissions, cwd, remote/export, MCP and schedules;
|
||||
these are not authority to offer an unrestricted command UI. Adversarial
|
||||
workspace/config qualification remains required before release.
|
||||
|
||||
The pinned ACP handler maps `allow_always` to native `approve-for-session` for
|
||||
commands, writes, reads, MCP and other supported tools. Path approval is also
|
||||
session-scoped; URL approval is session-scoped to an origin pattern. Factory
|
||||
permissions omit that option and reject fabricated permanent approval. This
|
||||
scope is confirmed in source, not inferred from the option label. Read/write
|
||||
session grants are broader than one file and must be described accurately.
|
||||
|
||||
## Negotiation and event contract
|
||||
|
||||
The observed initialize result advertises protocol 1; `loadSession: true`;
|
||||
@@ -104,6 +111,15 @@ reads, workspace rebinding, permission changes, native-input replies or terminal
|
||||
settlement. Inline binary assets are content-address verified; only metadata is
|
||||
forwarded until a provider-session artifact resolver can upload them safely.
|
||||
|
||||
`copilot-extension-adapter.ts` converts the normalized events into canonical
|
||||
delegation, compaction and unregistered artifact activity. Every safe projected
|
||||
field is retained in bounded provider-notice details with method/event/session/
|
||||
turn provenance. Notices have readable summaries; secret-shaped string values
|
||||
are scrubbed without erasing numeric token counters. These display events never
|
||||
create a usage charge, input-resolution acknowledgment, registered artifact, or
|
||||
turn terminal event. The provider registry installs this factory and initialize
|
||||
capability metadata in the provider branch.
|
||||
|
||||
## Comparison against Codex app-server
|
||||
|
||||
“Source” means verified in the pinned implementation; “wire” means observed in
|
||||
@@ -180,6 +196,7 @@ python3 packages/paperclip-runner/scripts/probe-copilot-acp.py --package-root /p
|
||||
python3 packages/paperclip-runner/scripts/probe-copilot-acp.py --package-root /path/to/copilot-darwin-arm64/package --scenario attached-shell
|
||||
node --test packages/paperclip-runner/scripts/materialize-copilot-binary.test.mjs
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run src/drivers/acpx/copilot-events.test.ts src/drivers/acpx/copilot-profile.test.ts src/drivers/acpx/copilot-evidence.test.ts
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run src/drivers/acpx/copilot-extension-adapter.test.ts
|
||||
```
|
||||
|
||||
Retained real-binary evidence:
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { COPILOT_ACP_EVENT_METHOD } from "./copilot-events.js";
|
||||
import { createCopilotProfileExtensionAdapter } from "./copilot-extension-adapter.js";
|
||||
import { validateAcpxRichEvent } from "./profile-extensions.js";
|
||||
|
||||
const context = { workspacePath: "/workspace", sessionId: "session-1", turnId: "turn-1" };
|
||||
const params = (type: string, data: unknown, extra = {}) => ({ sessionId: context.sessionId, type, data, ...extra });
|
||||
|
||||
describe("Copilot display extension adapter", () => {
|
||||
it("preserves subagent lifecycle identity and all safe details with canonical provenance", async () => {
|
||||
const adapter = createCopilotProfileExtensionAdapter(context);
|
||||
const started = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("subagent.started", { agentName: "review", agentDisplayName: "Reviewer", toolCallId: "tool-1", agentDescription: "Review the change", model: "exact-model", resumable: true }, { agentId: "agent-2" }));
|
||||
const completed = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("subagent.completed", { agentName: "review", toolCallId: "tool-1", model: "exact-model", totalToolCalls: 3, durationMs: 12.5, totalTokens: 40, modelSelectionSource: "agent" }, { agentId: "agent-2", timestamp: "2026-09-28T00:00:00Z" }));
|
||||
[...started, ...completed].forEach(validateAcpxRichEvent);
|
||||
expect(started[0].eventType).toBe("delegation.started");
|
||||
expect(completed[0].eventType).toBe("delegation.completed");
|
||||
expect(started[0].itemId).toBe(completed[0].itemId);
|
||||
expect(completed[1].payload).toMatchObject({
|
||||
provenance: { method: COPILOT_ACP_EVENT_METHOD, eventType: "subagent.completed", agentId: "agent-2", sessionId: "session-1", turnId: "turn-1", timestamp: "2026-09-28T00:00:00Z" },
|
||||
details: expect.arrayContaining([{ name: "totalTokens", value: "40" }, { name: "durationMs", value: "12.5" }, { name: "modelSelectionSource", value: "agent" }]),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not register provider-session paths or binary assets as task artifacts", async () => {
|
||||
const adapter = createCopilotProfileExtensionAdapter(context);
|
||||
const file = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.workspace_file_changed", { operation: "create", path: "reports/result.md" }));
|
||||
file.forEach(validateAcpxRichEvent);
|
||||
expect(file[0].payload).toMatchObject({ registered: false, reference: null });
|
||||
expect(file[1].payload.details).toEqual(expect.arrayContaining([{ name: "path", value: "reports/result.md" }, { name: "locationKind", value: "provider_session_workspace" }]));
|
||||
expect(await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.workspace_file_changed", { operation: "create", path: "../../secret" }))).toEqual([]);
|
||||
const bytes = Buffer.from("image fixture");
|
||||
const asset = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.binary_asset", { assetId: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, type: "image", mimeType: "image/png", byteLength: bytes.length, data: bytes.toString("base64") }));
|
||||
asset.forEach(validateAcpxRichEvent);
|
||||
expect(asset[0].payload).toMatchObject({ mediaType: "image/png", reference: null, registered: false });
|
||||
expect(JSON.stringify(asset)).not.toContain(bytes.toString("base64"));
|
||||
});
|
||||
|
||||
it("keeps supplemental non-dollar usage separate from authoritative terminal accounting", async () => {
|
||||
const events = await createCopilotProfileExtensionAdapter(context).notification(COPILOT_ACP_EVENT_METHOD, params("assistant.usage", { model: "m", inputTokens: 10, outputTokens: 2, cost: 3, copilotUsage: { totalNanoAiu: 50 }, quotaSnapshots: { private: "secret" } }));
|
||||
events.forEach(validateAcpxRichEvent);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].eventType).toBe("provider.notice.recorded");
|
||||
expect(events[0].payload.details).toEqual(expect.arrayContaining([{ name: "modelMultiplier", value: "3" }, { name: "totalNanoAiu", value: "50" }, { name: "inputTokens", value: "10" }, { name: "accounting", value: "supplemental_provider_notice" }]));
|
||||
expect(JSON.stringify(events)).not.toMatch(/costUsd|quotaSnapshots|secret/);
|
||||
});
|
||||
|
||||
it("redacts credentials in provider strings while preserving numeric token counters", async () => {
|
||||
const events = await createCopilotProfileExtensionAdapter(context).notification(COPILOT_ACP_EVENT_METHOD, params("subagent.failed", { agentName: "review", totalTokens: 42, error: "Authorization: Bearer secretsecretsecret", model: "ghp_abcdefghijklmnopqrstuvwxyz" }));
|
||||
events.forEach(validateAcpxRichEvent);
|
||||
expect(JSON.stringify(events)).not.toMatch(/secretsecretsecret|ghp_abcdefghijklmnopqrstuvwxyz/);
|
||||
expect(events[1].payload.details).toContainEqual({ name: "totalTokens", value: "42" });
|
||||
});
|
||||
|
||||
it("reports native unsupported input honestly and ignores unrelated or cross-session notifications", async () => {
|
||||
const adapter = createCopilotProfileExtensionAdapter(context);
|
||||
await expect(adapter.request("user_input.requested", {})).rejects.toThrow("no qualified");
|
||||
const events = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("user_input.requested", { requestId: "q-1", question: "Do not fabricate an input form" }));
|
||||
events.forEach(validateAcpxRichEvent);
|
||||
expect(events[0].payload).toMatchObject({ severity: "warning", userActionable: false });
|
||||
expect(JSON.stringify(events)).not.toContain("Do not fabricate");
|
||||
expect(await adapter.notification("unrelated/event", {})).toEqual([]);
|
||||
expect(await adapter.notification(COPILOT_ACP_EVENT_METHOD, { ...params("session.idle", {}), sessionId: "another" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits compaction completion only after provider-reported success and keeps settlement notices nonterminal", async () => {
|
||||
const adapter = createCopilotProfileExtensionAdapter(context);
|
||||
const success = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.compaction_complete", { success: true, preCompactionTokens: 100, postCompactionTokens: 40, checkpointNumber: 1 }));
|
||||
success.forEach(validateAcpxRichEvent);
|
||||
expect(success[0]).toMatchObject({ eventType: "context.compacted", payload: { preTokens: 100, postTokens: 40, sameSession: true } });
|
||||
const failure = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.compaction_complete", { success: false }));
|
||||
expect(failure.map(event => event.eventType)).toEqual(["provider.notice.recorded"]);
|
||||
const idle = await adapter.notification(COPILOT_ACP_EVENT_METHOD, params("session.idle", {}));
|
||||
expect(idle.map(event => event.eventType)).toEqual(["provider.notice.recorded"]);
|
||||
expect(idle[0].payload.details).toContainEqual({ name: "authoritativeTurnCompletion", value: "false" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { CanonicalProviderEvent } from "../../provider-events.js";
|
||||
import { redactPaperclipSemanticValue } from "../../semantic-tools/redaction.js";
|
||||
import { COPILOT_ACP_EVENT_METHOD, normalizeCopilotSessionEvent, type CopilotSessionEvent } from "./copilot-events.js";
|
||||
import type { AcpxProfileExtensionAdapter, AcpxProfileExtensionContext } from "./profile-extensions.js";
|
||||
|
||||
/** Display projection only. Native notices cannot settle or charge a runner turn. */
|
||||
export function createCopilotProfileExtensionAdapter(context: AcpxProfileExtensionContext): AcpxProfileExtensionAdapter {
|
||||
let sequence = 0;
|
||||
return {
|
||||
async request() {
|
||||
throw new Error("Copilot 1.0.88 has no qualified inbound ACP extension request responder");
|
||||
},
|
||||
async notification(method, params) {
|
||||
if (method !== COPILOT_ACP_EVENT_METHOD) return [];
|
||||
const event = normalizeCopilotSessionEvent({ method, params }, context);
|
||||
if (!event) return [];
|
||||
const noticeId = identity(context, "notice", String(++sequence));
|
||||
const details = detailFields(event.data);
|
||||
const notice: CanonicalProviderEvent = {
|
||||
itemId: noticeId,
|
||||
eventType: "provider.notice.recorded",
|
||||
payload: {
|
||||
schema: "paperclip.provider.notice.v1", noticeId,
|
||||
severity: event.kind === "capability_gap" ? "warning" : "info",
|
||||
category: `copilot.${event.sourceType}`, scope: "turn",
|
||||
recoverable: true, userActionable: false,
|
||||
summary: summary(event),
|
||||
provenance: {
|
||||
method, eventType: event.sourceType, sessionId: bounded(context.sessionId, 240), turnId: bounded(context.turnId, 240),
|
||||
...(event.agentId ? { agentId: bounded(event.agentId, 240) } : {}),
|
||||
...(event.timestamp ? { timestamp: bounded(event.timestamp, 80) } : {}),
|
||||
},
|
||||
details,
|
||||
},
|
||||
};
|
||||
const typed = typedProjection(event, context, noticeId);
|
||||
return typed ? [typed, notice] : [notice];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function typedProjection(event: CopilotSessionEvent, context: AcpxProfileExtensionContext, fallbackId: string): CanonicalProviderEvent | null {
|
||||
const data = event.data;
|
||||
if (event.kind === "capability_gap") return null;
|
||||
if (event.sourceType.startsWith("subagent.")) {
|
||||
const correlation = event.agentId ?? string(data.toolCallId);
|
||||
const itemId = correlation ? identity(context, "delegation", correlation) : fallbackId;
|
||||
const status = data.cancelled === true ? "interrupted"
|
||||
: event.sourceType === "subagent.completed" ? "completed"
|
||||
: event.sourceType === "subagent.failed" ? "failed" : "running";
|
||||
return {
|
||||
itemId,
|
||||
eventType: status === "running" ? (event.sourceType === "subagent.started" ? "delegation.started" : "delegation.updated") : "delegation.completed",
|
||||
payload: {
|
||||
schema: "paperclip.delegation.v1", delegationId: itemId, action: "spawn", status,
|
||||
children: [{
|
||||
childId: itemId, role: optional(data.agentDisplayName ?? data.agentName ?? data.agentType, 160),
|
||||
model: optional(data.model, 240), status,
|
||||
summary: optional(data.agentDescription ?? data.error, 4000),
|
||||
activitySummary: summary(event),
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (event.sourceType === "session.compaction_complete" && data.success === true) {
|
||||
const itemId = identity(context, "compaction", String(data.checkpointNumber ?? fallbackId));
|
||||
return { itemId, eventType: "context.compacted", payload: {
|
||||
schema: "paperclip.context.compacted.v1", compactionId: itemId, reason: "provider",
|
||||
preTokens: nonnegativeInteger(data.preCompactionTokens), postTokens: nonnegativeInteger(data.postCompactionTokens), sameSession: true,
|
||||
} };
|
||||
}
|
||||
if (event.kind === "artifact_reference") {
|
||||
const itemId = identity(context, "artifact", string(data.assetId) || string(data.path) || fallbackId);
|
||||
return { itemId, eventType: "artifact.generated", payload: {
|
||||
schema: "paperclip.artifact.generated.v1", artifactId: itemId, status: "completed",
|
||||
// Provider-session paths are not task-workspace paths or registered URLs.
|
||||
reference: null, mediaType: optional(data.mimeType, 160), registered: false, failure: null,
|
||||
} };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function summary(event: CopilotSessionEvent): string {
|
||||
const data = event.data;
|
||||
if (event.kind === "capability_gap") return data.reason === "provider_omitted_event_data"
|
||||
? "Copilot omitted the payload of a native activity event."
|
||||
: "Copilot reported native input without a qualified ACP response method.";
|
||||
switch (event.sourceType) {
|
||||
case "assistant.usage": return "Copilot reported supplemental model usage; these counters do not establish a dollar charge.";
|
||||
case "session.usage_checkpoint": return "Copilot reported a cumulative AI-unit checkpoint; it is not a dollar charge.";
|
||||
case "session.usage_info": return "Copilot updated context usage.";
|
||||
case "session.workspace_file_changed": return `Copilot ${data.operation === "create" ? "created" : "updated"} a provider-session file; artifact resolution is pending.`;
|
||||
case "session.binary_asset": return "Copilot reported a verified binary asset; artifact registration is pending.";
|
||||
case "subagent.started": return "Copilot started a subagent.";
|
||||
case "subagent.configured": return "Copilot updated subagent configuration.";
|
||||
case "subagent.completed": return data.cancelled === true ? "Copilot subagent was interrupted." : "Copilot subagent completed.";
|
||||
case "subagent.failed": return "Copilot subagent failed.";
|
||||
case "session.plan_changed": return "Copilot changed its plan; this notification does not contain the plan document.";
|
||||
case "session.compaction_start": return "Copilot started context compaction.";
|
||||
case "session.compaction_complete": return data.success === true ? "Copilot completed context compaction." : "Copilot reported context compaction without a successful result.";
|
||||
case "pending_messages.modified": return "Copilot changed its pending messages; the notification does not include queue contents.";
|
||||
case "session.background_tasks_changed": return "Copilot background tasks changed; the notification does not include task contents.";
|
||||
case "session.context_changed": return "Copilot reported context changes; the admitted workspace remains fixed.";
|
||||
case "session.permissions_changed": return "Copilot reported permission state; Paperclip policy remains authoritative.";
|
||||
case "session.mode_changed": return "Copilot reported a mode change.";
|
||||
case "session.idle": return "Copilot reported idle activity; the ACP prompt response determines turn settlement.";
|
||||
case "session.completion_receipt": return "Copilot reported a completion receipt; it does not independently settle the turn.";
|
||||
default: return "Copilot reported native activity.";
|
||||
}
|
||||
}
|
||||
|
||||
function detailFields(data: Record<string, unknown>): Array<{ name: string; value: string }> {
|
||||
const details: Array<{ name: string; value: string }> = [];
|
||||
const visit = (value: unknown, name: string): void => {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||
for (const [key, entry] of Object.entries(value)) visit(entry, name ? `${name}.${key}` : key);
|
||||
} else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
// Redact values individually: numeric token counters are not credentials.
|
||||
details.push({ name: bounded(name, 160), value: bounded(String(value), 4000) });
|
||||
}
|
||||
};
|
||||
visit(data, "");
|
||||
if (details.length > 64) throw new Error("Copilot projected detail fields exceed their declared bound");
|
||||
return details;
|
||||
}
|
||||
function identity(context: AcpxProfileExtensionContext, family: string, source: string): string {
|
||||
return `copilot-${family}-${createHash("sha256").update(JSON.stringify([context.sessionId, context.turnId, source])).digest("hex")}`;
|
||||
}
|
||||
function bounded(value: string, max: number): string {
|
||||
const safe = String(redactPaperclipSemanticValue(value));
|
||||
return safe.length <= max ? safe : `${safe.slice(0, max - 12)} [truncated]`;
|
||||
}
|
||||
function optional(value: unknown, max: number): string | null { return typeof value === "string" && value ? bounded(value, max) : null; }
|
||||
function string(value: unknown): string { return typeof value === "string" ? value : ""; }
|
||||
function nonnegativeInteger(value: unknown): number | null { return Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : null; }
|
||||
Reference in new issue
Block a user