feat(runner): normalize provider event contracts (#12350)

## Thinking Path

> - The runner already persists PRP events, but provider-native activity
needs one bounded, provider-neutral vocabulary before additional
providers can be added safely.
> - The protocol catalog must describe capabilities without enabling or
authorizing a provider.
> - Provider normalization must not require an ACPX runtime dependency
merely to compile the shared event layer.
> - This pull request adds the event contract and pure normalizers only;
provider transports and production selection remain unchanged.

## Linked Issues or Issue Description

This is the first follow-up stacked on #12321. Codex, OpenCode, and ACP
runtimes expose different activity shapes. Without canonical
normalization, downstream task threads and traces would need
provider-specific branching and could retain unbounded or unsafe
payloads.

## What Changed

- Expand the PRP provider descriptor and canonical activity event
families.
- Add bounded Codex, OpenCode, and ACP event normalizers for plans,
tools, research, delegation, artifacts, review, safety, waits, and
notices.
- Preserve strict schema validation and regenerate the checked-in schema
bundle and manifest.
- Use a structural ACP event input so the provider-neutral layer does
not introduce or authorize an ACPX runtime dependency.
- Export the provider-event contract from the existing package root.

## Verification

- `pnpm --filter @paperclipai/paperclip-runner typecheck:typescript`
- `pnpm --filter @paperclipai/paperclip-runner test:typescript` — 11
files and 88 tests passed.
- `pnpm -r typecheck`
- `pnpm build`
- `git diff --check`
- The local full repository runner reached unrelated macOS
workspace-path fixture failures; the affected runner suites pass and the
repository CI shards are the handoff authority.
- Diff against the declared base: 8 files.

## Compatibility Boundary

- No provider transport, adapter, server route, feature flag, or runtime
selection changes.
- Catalog presence does not authorize discovery or execution.
- Existing Codex execution continues through its current path.
- No dependency, migration, workflow, or lockfile change.

## Risks

The main risk is accepting malformed or unbounded provider payloads.
Schema validation remains fail-closed, text/output fields are bounded
and redacted, unsafe paths and URLs are discarded, and representative
variants for every declared event family are covered by tests.

## Model Used

OpenAI Codex, GPT-5 family. The client does not expose the exact
deployment ID or context window. Agentic reasoning, tool use, and code
execution were enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have described the issue in-PR following the relevant template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal task
identifier
- [x] I have run the affected local tests and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated the compatibility notes for this change
- [x] I have considered and documented risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open actionable comments
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Dotta 2026-08-29 19:44:24 -05:00 committed by GitHub
parent bc9ba7cd26
commit b1cd261212
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1515 additions and 18 deletions

View File

@ -30,7 +30,7 @@
{
"path": "schemas/event.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/event.schema.json",
"sha256": "d69ac4acfd9cd265f9dcb6a6580e949a67a50347093d0a7c12c2000a2da6d482"
"sha256": "e9827a7c31f004ae91388964a96b4ddf6ab40b85f29bf3922683d5dcf64f2559"
},
{
"path": "schemas/fixture.schema.json",
@ -45,12 +45,12 @@
{
"path": "schemas/provider-descriptor.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json",
"sha256": "26e8af9314f1ca49578bfbacdcc111332225b422ea4ff68918a25ddac59129a6"
"sha256": "a56c0c8501171c34bb72f6fba08d3872839a8b45bbc10daa93620afc9dc2b669"
},
{
"path": "schemas/provider-event.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json",
"sha256": "84521ea87ae782a7c07d1ef68b70f87fc4c436e79951a8076b7ba55e923fa88b"
"sha256": "e0ffb95cbde4afb65783106f471c5154cbcc59055461f96838cd8a5239e5ec56"
},
{
"path": "schemas/question-adapter-fixture.schema.json",
@ -75,7 +75,7 @@
{
"path": "schemas/result.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/result.schema.json",
"sha256": "90f02c0a96e8ab9f75970fd788b61c18ccbd9abbd005bedfba280701729adce0"
"sha256": "86d3b3c4d0f79de34a3e2e37beb183ea08d3bc9f70437a9f6f2b98bdf8faf828"
},
{
"path": "schemas/semantic-tool.schema.json",

View File

@ -28,7 +28,7 @@
"itemId": { "type": "string", "minLength": 1, "maxLength": 160 },
"eventType": {
"enum": [
"runner.connected", "runner.reconnected", "runner.reconciled", "runner.disconnected", "runner.draining", "runner.suspending", "runner.suspended", "runner.stopped", "runner.diagnostic",
"runner.connected", "runner.reconnected", "runner.reconciled", "runner.disconnected", "runner.draining", "runner.backpressure", "runner.suspending", "runner.suspended", "runner.stopped", "runner.diagnostic",
"runtime.phase.changed", "sandbox.metric", "workspace.ready", "workspace.change.updated", "workspace.diff.recorded", "workspace.file.referenced",
"harness.starting", "harness.ready", "harness.exited", "harness.diagnostic",
"plan.updated",
@ -43,7 +43,7 @@
"session.starting", "session.started", "session.resuming", "session.resumed", "session.reconciled", "session.updated", "session.closed", "session.failed",
"turn.submitted", "turn.accepted", "turn.started", "turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled",
"item.started", "item.delta", "item.completed", "item.failed", "usage.reported",
"semantic_tool.input", "semantic_tool.result",
"semantic_tool.input", "semantic_tool.result", "semantic_tool.reconciled",
"mcp_app.discovered", "mcp_app.resource.resolved", "mcp_app.initializing", "mcp_app.ready", "mcp_app.tool_input", "mcp_app.tool_result", "mcp_app.action.requested", "mcp_app.action.resolved", "mcp_app.host_context.changed", "mcp_app.failed", "mcp_app.teardown",
"runtime_request.created", "runtime_request.resolved", "runtime_request.expired", "runtime_request.cancelled",
"interaction.request.proposed", "interaction.request.materialized", "interaction.request.rejected", "interaction.response.progressed", "interaction.response.resolved", "interaction.response.delivered",
@ -152,6 +152,25 @@
}
}
},
{
"if": { "properties": { "eventType": { "const": "semantic_tool.reconciled" } } },
"then": {
"properties": {
"payload": {
"type": "object",
"properties": {
"semantic_tool": {
"allOf": [
{ "$ref": "https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json" },
{ "type": "object", "properties": { "phase": { "const": "reconciled" } } }
]
}
},
"additionalProperties": true
}
}
}
},
{
"if": { "properties": { "eventType": { "const": "run.terminal" } } },
"then": {

View File

@ -5,13 +5,96 @@
"type": "object",
"required": ["provider", "driver", "model", "executionKind", "providerVersion"],
"properties": {
"provider": { "const": "codex" },
"driver": { "const": "codex_app_server" },
"provider": { "enum": ["codex", "opencode", "claude_managed", "aws_agentcore", "acpx"] },
"driver": { "enum": ["codex_app_server", "opencode_server", "claude_managed_agents_api", "aws_agentcore_harness_api", "acpx_runtime"] },
"model": { "type": ["string", "null"], "maxLength": 240 },
"executionKind": { "const": "local_process" },
"executionKind": { "enum": ["local_process", "remote_service"] },
"providerVersion": { "type": "string", "minLength": 1, "maxLength": 120 },
"service": { "enum": ["anthropic_managed_agents", "aws_bedrock_agentcore_harness"] },
"providerSessionId": { "type": ["string", "null"], "maxLength": 240 },
"processId": { "type": ["integer", "null"], "minimum": 1 }
"processId": { "type": ["integer", "null"], "minimum": 1 },
"agentProcessId": { "type": ["integer", "null"], "minimum": 1 },
"endpointArn": { "type": "string", "minLength": 1, "maxLength": 2048 },
"endpointQualifier": { "type": "string", "minLength": 1, "maxLength": 240 },
"memoryId": { "type": "string", "minLength": 1, "maxLength": 240 },
"eventExpiryDays": { "const": 90 },
"agent": { "enum": ["pi", "claude", "codex"] },
"requestedModel": { "type": "string", "minLength": 1, "maxLength": 240 },
"acpProtocolVersion": { "const": 1 },
"agentServerPackage": { "type": "string", "minLength": 1, "maxLength": 240 },
"agentServerVersion": { "type": "string", "minLength": 1, "maxLength": 120 },
"agentRuntimePackage": { "type": ["string", "null"], "maxLength": 240 },
"agentRuntimeVersion": { "type": ["string", "null"], "maxLength": 120 },
"acpxRecordId": { "type": ["string", "null"], "maxLength": 240 }
},
"additionalProperties": true
"allOf": [
{
"oneOf": [
{
"properties": {
"provider": { "const": "codex" },
"driver": { "const": "codex_app_server" },
"executionKind": { "const": "local_process" }
}
},
{
"properties": {
"provider": { "const": "opencode" },
"driver": { "const": "opencode_server" },
"executionKind": { "const": "local_process" }
}
},
{
"properties": {
"provider": { "const": "claude_managed" },
"driver": { "const": "claude_managed_agents_api" },
"executionKind": { "const": "remote_service" }
}
},
{
"properties": {
"provider": { "const": "aws_agentcore" },
"driver": { "const": "aws_agentcore_harness_api" },
"executionKind": { "const": "remote_service" }
}
},
{
"properties": {
"provider": { "const": "acpx" },
"driver": { "const": "acpx_runtime" },
"executionKind": { "const": "local_process" }
}
}
]
},
{
"if": { "properties": { "executionKind": { "const": "remote_service" } } },
"then": {
"required": ["service"],
"properties": { "processId": { "const": null } }
}
},
{
"if": { "properties": { "executionKind": { "const": "local_process" } } },
"then": { "properties": { "service": false } }
},
{
"if": { "properties": { "provider": { "const": "claude_managed" } }, "required": ["provider"] },
"then": { "properties": { "service": { "const": "anthropic_managed_agents" } }, "required": ["service"] }
},
{
"if": { "properties": { "provider": { "const": "aws_agentcore" } }, "required": ["provider"] },
"then": {
"properties": { "service": { "const": "aws_bedrock_agentcore_harness" } },
"required": ["service", "endpointArn", "endpointQualifier", "memoryId", "eventExpiryDays"]
}
},
{
"if": { "properties": { "provider": { "const": "acpx" } }, "required": ["provider"] },
"then": {
"required": ["agent", "requestedModel", "acpProtocolVersion", "agentServerPackage", "agentServerVersion", "acpxRecordId", "agentProcessId"]
}
}
],
"additionalProperties": false
}

View File

@ -28,6 +28,7 @@
"safeUrl": { "type": ["string", "null"], "maxLength": 8192, "pattern": "^https?://" },
"plan": {
"type": "object",
"description": "A complete provider-authored within-turn checklist snapshot. Consumers replace the prior snapshot with the same planId in PRP sourceSeq order; this is not Paperclip's durable Plan document.",
"required": ["schema", "planId", "revision", "steps", "complete", "syncStatus"],
"properties": {
"schema": { "const": "paperclip.plan.updated.v1" },
@ -36,8 +37,8 @@
"explanation": { "$ref": "#/$defs/nullableShortText" },
"steps": { "type": "array", "maxItems": 256, "items": { "type": "object", "required": ["stepId", "body", "status"], "properties": { "stepId": { "$ref": "#/$defs/id" }, "body": { "$ref": "#/$defs/shortText" }, "status": { "enum": ["pending", "in_progress", "completed", "blocked"] } }, "additionalProperties": false } },
"complete": { "type": "boolean" },
"syncStatus": { "enum": ["streaming", "pending", "synchronized", "conflict", "not_applicable"] },
"documentRevision": { "type": ["integer", "null"], "minimum": 1 }
"syncStatus": { "description": "Legacy compatibility field. Within-turn checklist snapshots use not_applicable.", "enum": ["streaming", "pending", "synchronized", "conflict", "not_applicable"] },
"documentRevision": { "description": "Legacy compatibility field. Within-turn checklist snapshots use null.", "type": ["integer", "null"], "minimum": 1 }
},
"additionalProperties": false
},

View File

@ -92,7 +92,15 @@
"required": ["reasonCode", "owner", "unblockAction", "scope"],
"properties": {
"reasonCode": { "type": "string", "minLength": 1 },
"owner": { "type": "object", "additionalProperties": true },
"owner": {
"type": "object",
"required": ["kind", "name"],
"properties": {
"kind": { "enum": ["agent", "user", "system", "external"] },
"name": { "type": "string", "minLength": 1 }
},
"additionalProperties": true
},
"unblockAction": { "type": "string", "minLength": 1 },
"scope": { "enum": ["current_track", "task_wide"] }
},

View File

@ -122,6 +122,7 @@ export function compileProtocolValidators(schemaRecords) {
conformanceFixture: get("conformance-fixture"),
conformanceOutput: get("conformance-output"),
fixture: get("fixture"),
providerDescriptor: get("provider-descriptor"),
questionAdapterFixture: get("question-adapter-fixture"),
};
}

View File

@ -8,6 +8,7 @@ export {
export type { DurableRecoveryIdentity } from "./control-plane/prp-transport-types.js";
export * from "./protocol/replay-contract.js";
export * from "./protocol/result-normalization.js";
export * from "./provider-events.js";
export * from "./reducer/session-reducer.js";
export * from "./semantic-tools/index.js";
export * from "./tracer/replay.js";

View File

@ -344,10 +344,22 @@ export const providerDescriptorSchema = {
],
"properties": {
"provider": {
"const": "codex"
"enum": [
"codex",
"opencode",
"claude_managed",
"aws_agentcore",
"acpx"
]
},
"driver": {
"const": "codex_app_server"
"enum": [
"codex_app_server",
"opencode_server",
"claude_managed_agents_api",
"aws_agentcore_harness_api",
"acpx_runtime"
]
},
"model": {
"type": [
@ -357,13 +369,22 @@ export const providerDescriptorSchema = {
"maxLength": 240
},
"executionKind": {
"const": "local_process"
"enum": [
"local_process",
"remote_service"
]
},
"providerVersion": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"service": {
"enum": [
"anthropic_managed_agents",
"aws_bedrock_agentcore_harness"
]
},
"providerSessionId": {
"type": [
"string",
@ -377,9 +398,255 @@ export const providerDescriptorSchema = {
"null"
],
"minimum": 1
},
"agentProcessId": {
"type": [
"integer",
"null"
],
"minimum": 1
},
"endpointArn": {
"type": "string",
"minLength": 1,
"maxLength": 2048
},
"endpointQualifier": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"memoryId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"eventExpiryDays": {
"const": 90
},
"agent": {
"enum": [
"pi",
"claude",
"codex"
]
},
"requestedModel": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"acpProtocolVersion": {
"const": 1
},
"agentServerPackage": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"agentServerVersion": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"agentRuntimePackage": {
"type": [
"string",
"null"
],
"maxLength": 240
},
"agentRuntimeVersion": {
"type": [
"string",
"null"
],
"maxLength": 120
},
"acpxRecordId": {
"type": [
"string",
"null"
],
"maxLength": 240
}
},
"additionalProperties": true
"allOf": [
{
"oneOf": [
{
"properties": {
"provider": {
"const": "codex"
},
"driver": {
"const": "codex_app_server"
},
"executionKind": {
"const": "local_process"
}
}
},
{
"properties": {
"provider": {
"const": "opencode"
},
"driver": {
"const": "opencode_server"
},
"executionKind": {
"const": "local_process"
}
}
},
{
"properties": {
"provider": {
"const": "claude_managed"
},
"driver": {
"const": "claude_managed_agents_api"
},
"executionKind": {
"const": "remote_service"
}
}
},
{
"properties": {
"provider": {
"const": "aws_agentcore"
},
"driver": {
"const": "aws_agentcore_harness_api"
},
"executionKind": {
"const": "remote_service"
}
}
},
{
"properties": {
"provider": {
"const": "acpx"
},
"driver": {
"const": "acpx_runtime"
},
"executionKind": {
"const": "local_process"
}
}
}
]
},
{
"if": {
"properties": {
"executionKind": {
"const": "remote_service"
}
}
},
"then": {
"required": [
"service"
],
"properties": {
"processId": {
"const": null
}
}
}
},
{
"if": {
"properties": {
"executionKind": {
"const": "local_process"
}
}
},
"then": {
"properties": {
"service": false
}
}
},
{
"if": {
"properties": {
"provider": {
"const": "claude_managed"
}
},
"required": [
"provider"
]
},
"then": {
"properties": {
"service": {
"const": "anthropic_managed_agents"
}
},
"required": [
"service"
]
}
},
{
"if": {
"properties": {
"provider": {
"const": "aws_agentcore"
}
},
"required": [
"provider"
]
},
"then": {
"properties": {
"service": {
"const": "aws_bedrock_agentcore_harness"
}
},
"required": [
"service",
"endpointArn",
"endpointQualifier",
"memoryId",
"eventExpiryDays"
]
}
},
{
"if": {
"properties": {
"provider": {
"const": "acpx"
}
},
"required": [
"provider"
]
},
"then": {
"required": [
"agent",
"requestedModel",
"acpProtocolVersion",
"agentServerPackage",
"agentServerVersion",
"acpxRecordId",
"agentProcessId"
]
}
}
],
"additionalProperties": false
} as const;
export const providerEventSchema = {
@ -472,6 +739,7 @@ export const providerEventSchema = {
},
"plan": {
"type": "object",
"description": "A complete provider-authored within-turn checklist snapshot. Consumers replace the prior snapshot with the same planId in PRP sourceSeq order; this is not Paperclip's durable Plan document.",
"required": [
"schema",
"planId",
@ -527,6 +795,7 @@ export const providerEventSchema = {
"type": "boolean"
},
"syncStatus": {
"description": "Legacy compatibility field. Within-turn checklist snapshots use not_applicable.",
"enum": [
"streaming",
"pending",
@ -536,6 +805,7 @@ export const providerEventSchema = {
]
},
"documentRevision": {
"description": "Legacy compatibility field. Within-turn checklist snapshots use null.",
"type": [
"integer",
"null"
@ -2775,6 +3045,24 @@ export const resultSchema = {
},
"owner": {
"type": "object",
"required": [
"kind",
"name"
],
"properties": {
"kind": {
"enum": [
"agent",
"user",
"system",
"external"
]
},
"name": {
"type": "string",
"minLength": 1
}
},
"additionalProperties": true
},
"unblockAction": {
@ -2974,6 +3262,7 @@ export const eventSchema = {
"runner.reconciled",
"runner.disconnected",
"runner.draining",
"runner.backpressure",
"runner.suspending",
"runner.suspended",
"runner.stopped",
@ -3035,6 +3324,7 @@ export const eventSchema = {
"usage.reported",
"semantic_tool.input",
"semantic_tool.result",
"semantic_tool.reconciled",
"mcp_app.discovered",
"mcp_app.resource.resolved",
"mcp_app.initializing",
@ -3533,6 +3823,40 @@ export const eventSchema = {
}
}
},
{
"if": {
"properties": {
"eventType": {
"const": "semantic_tool.reconciled"
}
}
},
"then": {
"properties": {
"payload": {
"type": "object",
"properties": {
"semantic_tool": {
"allOf": [
{
"$ref": "https://paperclip.dev/schemas/prp/v1/semantic-tool.schema.json"
},
{
"type": "object",
"properties": {
"phase": {
"const": "reconciled"
}
}
}
]
}
},
"additionalProperties": true
}
}
}
},
{
"if": {
"properties": {

View File

@ -69,6 +69,44 @@ describe("normalizePrpResultSignals", () => {
});
});
it("requires an identified owner for blocked results", () => {
const blockedResult = {
reportedWorkDisposition: "blocked",
summary: "External input is required.",
completionClaim: {
contractRevision: "1",
objectiveSatisfied: false,
criteria: [
{ criterionId: "objective", status: "not_satisfied", evidenceRefs: [] },
],
remainingWork: [
{ description: "Wait for external input.", blocksCompletion: true },
],
},
evidence: [],
verification: [],
blocker: {
reasonCode: "external_input_required",
owner: {},
unblockAction: "Provide the required input.",
scope: "current_track",
},
};
expect(validatePrpStructuredRunResult(blockedResult)).toMatchObject({
ok: false,
});
expect(
validatePrpStructuredRunResult({
...blockedResult,
blocker: {
...blockedResult.blocker,
owner: { kind: "external", name: "External input" },
},
}),
).toMatchObject({ ok: true });
});
it("normalizes unambiguous completion aliases emitted by smaller tool callers", () => {
const toolShaped = {
schema: "paperclip_paperclip_finish",

View File

@ -0,0 +1,409 @@
import { describe, expect, it } from "vitest";
import {
CODEX_THREAD_ITEM_CLASSIFICATION,
PROVIDER_EVENT_FAMILIES,
canonicalProviderEventsFromAcpxRuntimeEvent,
canonicalProviderEventsFromCodex,
canonicalProviderEventsFromOpenCodePart,
createAcpxToolEventNormalizer,
providerFamilyCapabilities,
} from "./provider-events.js";
import { validatePrpEvent } from "./protocol/replay-contract.js";
function envelope(event: ReturnType<typeof canonicalProviderEventsFromCodex>[number]) {
return {
schema: "paperclip.prp.event.v1", sourceEventId: `source:${event.itemId}`, sourceSeq: 1,
sourceInstanceId: "runner-1", sourceKind: "runner", runId: "run-1",
normalizedSessionId: "session-1", turnId: "turn-1", itemId: event.itemId,
eventType: event.eventType, schemaVersion: 1, priority: 1,
emittedAt: "2026-08-21T12:00:00.000Z", payload: event.payload,
};
}
describe("provider-neutral events", () => {
it("classifies the complete qualified 18-variant Codex ThreadItem inventory", () => {
expect(Object.keys(CODEX_THREAD_ITEM_CLASSIFICATION)).toEqual([
"userMessage", "hookPrompt", "agentMessage", "plan", "reasoning",
"commandExecution", "fileChange", "mcpToolCall", "dynamicToolCall",
"collabAgentToolCall", "subAgentActivity", "webSearch", "imageView",
"sleep", "imageGeneration", "enteredReviewMode", "exitedReviewMode",
"contextCompaction",
]);
expect(Object.values(CODEX_THREAD_ITEM_CLASSIFICATION)).not.toContain("unclassified");
expect(CODEX_THREAD_ITEM_CLASSIFICATION.plan).toBe("existing_transcript");
});
it("negotiates every declared family and preserves explicit unsupported states", () => {
const capabilities = providerFamilyCapabilities({ plan: "available", artifact: "policy_disabled" });
expect(capabilities).toHaveLength(PROVIDER_EVENT_FAMILIES.length);
expect(capabilities.find((entry) => entry.family === "plan")?.detailLevel).toBe("structured");
expect(capabilities.find((entry) => entry.family === "artifact")?.availability).toBe("policy_disabled");
expect(capabilities.find((entry) => entry.family === "memory")?.availability).toBe("unsupported");
});
it("maps representative Codex variants across every canonical family to valid PRP", () => {
const cases: Array<[string, Record<string, unknown>]> = [
["turn/plan/updated", { turnId: "turn-1", plan: [{ step: "Ship it", status: "inProgress" }] }],
["item/completed", { item: { id: "exec-1", type: "commandExecution", status: "completed", output: "ok" } }],
["item/completed", { item: { id: "web-1", type: "webSearch", action: { type: "search", query: "PRP" }, results: [{ ref_id: "source-1", title: "Protocol notes", url: "https://example.com/prp", snippet: "Canonical event details" }] } }],
["item/completed", { item: { id: "child-1", type: "collabAgentToolCall", tool: "spawnAgent" } }],
["model/rerouted", { turnId: "turn-1", fromModel: "gpt-5", toModel: "gpt-5.1", reason: "capacity" }],
["thread/compacted", { itemId: "compact-1" }],
["item/completed", { item: { id: "image-1", type: "imageView", path: "artifacts/a.png" } }],
["item/completed", { item: { id: "review-1", type: "enteredReviewMode" } }],
["hook/completed", { run: { id: "hook-1", eventName: "post-tool", scope: "workspace", status: "completed" } }],
["item/completed", { item: { id: "message-1", type: "agentMessage", memoryCitation: { entries: [{ label: "Decision" }] } } }],
["item/autoApprovalReview/completed", { reviewId: "safety-1", targetItemId: "exec-1" }],
["item/commandExecution/terminalInteraction", { itemId: "exec-1", stdin: "secret input" }],
["item/completed", { item: { id: "wait-1", type: "sleep", durationMs: 1000 } }],
["warning", { message: "Update the provider configuration" }],
];
const mapped = cases.flatMap(([method, params]) => canonicalProviderEventsFromCodex(method, params));
expect(mapped).toHaveLength(cases.length);
for (const event of mapped) expect(validatePrpEvent(envelope(event))).toEqual({ ok: true, event: expect.any(Object), issues: [] });
expect(JSON.stringify(mapped.find((entry) => entry.eventType === "terminal.input.sent"))).not.toContain("secret input");
expect(mapped.find((entry) => entry.eventType === "research.completed")?.payload.sources).toEqual([{
sourceId: "source-1",
title: "Protocol notes",
url: "https://example.com/prp",
snippet: "Canonical event details",
}]);
});
it("bounds and filters Codex research sources before they enter PRP", () => {
const event = canonicalProviderEventsFromCodex("item/completed", {
item: {
id: "web-1",
type: "webSearch",
results: [
{ ref_id: "good", title: "Good", url: "https://example.com/good", snippet: "x".repeat(5000) },
{ ref_id: "unsafe", title: "Unsafe", url: "file:///etc/passwd" },
],
},
})[0]!;
expect(event.payload.sources).toEqual([expect.objectContaining({ sourceId: "good", url: "https://example.com/good", snippet: "x".repeat(4000) })]);
expect(validatePrpEvent(envelope(event))).toEqual({ ok: true, event: expect.any(Object), issues: [] });
});
it("bounds Codex plan explanations before they enter PRP", () => {
const event = canonicalProviderEventsFromCodex("turn/plan/updated", {
turnId: "turn-1",
explanation: "x".repeat(5000),
plan: [{ step: "Ship it", status: "inProgress" }],
})[0]!;
expect(event.payload.explanation).toBe("x".repeat(4000));
expect(validatePrpEvent(envelope(event))).toEqual({ ok: true, event: expect.any(Object), issues: [] });
});
it("normalizes malformed or non-positive Codex plan revisions", () => {
for (const revision of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
const event = canonicalProviderEventsFromCodex("turn/plan/updated", {
turnId: "turn-revision",
revision,
plan: [{ step: "Ship it", status: "inProgress" }],
})[0]!;
expect(event.payload.revision).toBe(1);
expect(validatePrpEvent(envelope(event))).toEqual({
ok: true,
event: expect.any(Object),
issues: [],
});
}
expect(canonicalProviderEventsFromCodex("turn/plan/updated", {
turnId: "turn-revision",
revision: 7,
plan: [],
})[0]?.payload.revision).toBe(7);
});
it("marks a turn plan complete when every native step is complete", () => {
const event = canonicalProviderEventsFromCodex("turn/plan/updated", {
turnId: "turn-1",
plan: [
{ step: "Inspect", status: "completed" },
{ step: "Implement", status: "completed" },
],
})[0]!;
expect(event).toMatchObject({
itemId: "turn-1",
eventType: "plan.updated",
payload: {
planId: "turn-1",
complete: true,
syncStatus: "not_applicable",
documentRevision: null,
},
});
});
it("treats Codex proposed-plan text as transcript content, not a checklist", () => {
expect(canonicalProviderEventsFromCodex("item/completed", {
item: { id: "proposed-plan-1", type: "plan", text: "# Proposed implementation\n\nShip it." },
})).toEqual([]);
});
it("uses an empty native plan as a stable clearing snapshot", () => {
const event = canonicalProviderEventsFromCodex("turn/plan/updated", {
turnId: "turn-1",
plan: [],
})[0]!;
expect(event).toMatchObject({
itemId: "turn-1",
payload: { planId: "turn-1", steps: [], complete: false },
});
});
it("normalizes provider plan statuses without accepting malformed steps", () => {
const event = canonicalProviderEventsFromCodex("turn/plan/updated", {
turnId: "turn-statuses",
plan: [
{ step: "Active", status: "inProgress" },
{ step: "Blocked", status: "failed" },
{ step: "Later", status: "unexpected" },
{ step: " ", status: "completed" },
],
})[0]!;
expect(event.payload.steps).toEqual([
{ stepId: "step-1", body: "Active", status: "in_progress" },
{ stepId: "step-2", body: "Blocked", status: "blocked" },
{ stepId: "step-3", body: "Later", status: "pending" },
]);
expect(event.payload.complete).toBe(false);
});
it("preserves every structured ACPX plan entry in a stable turn snapshot", () => {
const event = canonicalProviderEventsFromAcpxRuntimeEvent({
type: "plan",
tag: "plan",
entries: [
{ content: "Inspect", status: "completed", priority: "high" },
{ content: "Implement", status: "in_progress", priority: "medium" },
{ content: "Verify", status: "pending", priority: "low" },
],
} as never, "fallback", "turn-acp")[0]!;
expect(event).toMatchObject({
itemId: "turn-acp",
eventType: "plan.updated",
payload: {
planId: "turn-acp",
complete: false,
syncStatus: "not_applicable",
steps: [
{ body: "Inspect", status: "completed" },
{ body: "Implement", status: "in_progress" },
{ body: "Verify", status: "pending" },
],
},
});
expect(validatePrpEvent(envelope(event))).toEqual({ ok: true, event: expect.any(Object), issues: [] });
});
it("does not infer an ACPX checklist from legacy status text", () => {
const events = canonicalProviderEventsFromAcpxRuntimeEvent({
type: "status",
tag: "plan",
text: "Implement (in progress)",
} as never, "fallback", "turn-acp");
expect(events.some((event) => event.eventType === "plan.updated")).toBe(false);
});
it("bounds and sanitizes structured ACPX plan snapshots", () => {
const entries = Array.from({ length: 300 }, (_, index) => ({
content: index === 3 ? " " : `Step ${index} ${"x".repeat(5_000)}`,
status: index === 0 ? "completed" : "pending",
}));
const event = canonicalProviderEventsFromAcpxRuntimeEvent({
type: "plan",
tag: "plan",
entries,
} as never, "fallback", "turn-bounded")[0]!;
const steps = event.payload.steps as Array<{ body: string }>;
expect(steps).toHaveLength(255);
expect(steps.every((step) => step.body.length <= 4_000)).toBe(true);
expect(validatePrpEvent(envelope(event))).toEqual({ ok: true, event: expect.any(Object), issues: [] });
});
it("classifies only structured OpenCode parts and never assistant prose", () => {
expect(canonicalProviderEventsFromOpenCodePart({ id: "tool-1", type: "tool", tool: "read", state: { status: "completed", output: "done" } })[0]?.eventType).toBe("tool.execution.completed");
expect(canonicalProviderEventsFromOpenCodePart({ id: "text-1", type: "text", text: "I ran a command and delegated work" })).toEqual([]);
});
it("maps OpenCode pending dynamic tools to a valid builtin execution start", () => {
const event = canonicalProviderEventsFromOpenCodePart({
id: "prt_02c35c313001X42cqyvQrpXOFv",
type: "tool",
tool: "paperclip_report_progress",
callID: "call_6cd0302e681d44c2aba3c164",
state: { status: "pending", input: {}, raw: "" },
})[0]!;
expect(event).toMatchObject({
eventType: "tool.execution.started",
payload: {
schema: "paperclip.tool.execution.v1",
transport: "builtin",
status: "running",
name: "paperclip_report_progress",
},
});
expect(validatePrpEvent(envelope(event))).toEqual({ ok: true, event: expect.any(Object), issues: [] });
});
it("preserves ACPX identity across title-less progress and completion updates", () => {
const normalize = createAcpxToolEventNormalizer();
const started = normalize({
type: "tool_call",
tag: "tool_call",
toolCallId: "tool-1",
title: "mcp__paperclip__search_tasks",
kind: "other",
status: "pending",
text: "mcp__paperclip__search_tasks (pending)",
locations: [{ path: "doc/plan.md", line: 4 }],
} as never);
const progressed = normalize({
type: "tool_call",
tag: "tool_call_update",
toolCallId: "tool-1",
title: "tool call",
status: "in_progress",
text: "Searching the task index",
} as never);
const completed = normalize({
type: "tool_call",
tag: "tool_call_update",
toolCallId: "tool-1",
title: "tool call",
status: "completed",
text: "tool call (completed)",
rawOutput: { ok: true },
} as never);
expect(progressed).toMatchObject({
title: "mcp__paperclip__search_tasks",
kind: "other",
text: "Searching the task index",
locations: [{ path: "doc/plan.md", line: 4 }],
});
expect(completed).toMatchObject({
title: "mcp__paperclip__search_tasks",
kind: "other",
locations: [{ path: "doc/plan.md", line: 4 }],
});
const mapped = [started, progressed, completed].flatMap((event, index) =>
canonicalProviderEventsFromAcpxRuntimeEvent(event, `fallback-${index}`));
expect(mapped.at(-1)).toMatchObject({
eventType: "tool.execution.completed",
payload: {
transport: "mcp",
namespace: "paperclip",
name: "search_tasks",
operation: "search",
target: "doc/plan.md",
status: "completed",
},
});
for (const event of mapped) {
expect(validatePrpEvent(envelope(event))).toEqual({ ok: true, event: expect.any(Object), issues: [] });
}
});
it("treats provider status metacharacters as literal progress text", () => {
const normalize = createAcpxToolEventNormalizer();
expect(() => normalize({
type: "tool_call",
tag: "tool_call",
toolCallId: "tool-hostile-status",
title: "Read",
kind: "read",
status: "[",
text: "Read ([)",
} as never)).not.toThrow();
expect(normalize({
type: "tool_call",
tag: "tool_call_update",
toolCallId: "tool-hostile-status",
title: "Read",
status: "(",
text: "Meaningful progress",
} as never)).toMatchObject({ text: "Meaningful progress" });
});
it("normalizes dotted MCP names, ToolSearch, and truly unnamed calls", () => {
const dotted = canonicalProviderEventsFromAcpxRuntimeEvent({
type: "tool_call", tag: "tool_call", toolCallId: "mcp-1", title: "mcp.paperclip.get_task_context",
kind: "other", status: "pending", text: "Starting",
} as never, "fallback")[0]!;
const toolSearch = canonicalProviderEventsFromAcpxRuntimeEvent({
type: "tool_call", tag: "tool_call", toolCallId: "search-1", title: "ToolSearch",
kind: "other", status: "pending", text: "Starting",
} as never, "fallback")[0]!;
const unnamed = canonicalProviderEventsFromAcpxRuntimeEvent({
type: "tool_call", tag: "tool_call", toolCallId: "unknown-1", title: "tool call",
kind: "other", status: "pending", text: "tool call (pending)",
} as never, "fallback")[0]!;
expect(dotted.payload).toMatchObject({ transport: "mcp", namespace: "paperclip", name: "get_task_context", operation: "read" });
expect(toolSearch.payload).toMatchObject({ transport: "builtin", namespace: null, name: "ToolSearch", operation: "search" });
expect(unnamed.payload).toMatchObject({ name: null, operation: "unknown" });
});
it("presents OpenCode's server-qualified semantic tool with its canonical name", () => {
const event = canonicalProviderEventsFromOpenCodePart({
id: "finish-1",
type: "tool",
tool: "paperclip_paperclip_finish",
state: { status: "running", input: {} },
})[0]!;
expect(event.payload).toMatchObject({ name: "paperclip_finish", transport: "builtin" });
expect(validatePrpEvent(envelope(event))).toEqual({ ok: true, event: expect.any(Object), issues: [] });
});
it("recovers an interrupted OpenCode function label and error from its structured call id", () => {
const event = canonicalProviderEventsFromOpenCodePart({
id: "prt_interrupted",
type: "tool",
tool: "unknown",
callID: "functions.todowrite:0",
state: {
status: "error",
input: {},
error: "Tool execution aborted",
metadata: { interrupted: true },
},
})[0]!;
expect(event).toMatchObject({
eventType: "tool.execution.completed",
payload: {
name: "todowrite",
status: "failed",
output: "Tool execution aborted",
outputBytes: 22,
},
});
expect(validatePrpEvent(envelope(event))).toEqual({ ok: true, event: expect.any(Object), issues: [] });
});
it("does not infer a tool label from an opaque provider call id", () => {
const event = canonicalProviderEventsFromOpenCodePart({
id: "prt_unknown",
type: "tool",
tool: "unknown",
callID: "call_f3d79e",
state: { status: "error", error: "Unavailable" },
})[0]!;
expect(event.payload).toMatchObject({ name: "unknown", output: "Unavailable" });
});
it("rejects a canonical event whose payload belongs to another family", () => {
const plan = canonicalProviderEventsFromCodex("item/completed", {
item: { id: "plan-1", type: "plan", text: "Ship it" },
})[0]!;
expect(validatePrpEvent(envelope({
...plan,
eventType: "tool.execution.completed",
}))).toMatchObject({ ok: false });
});
});

View File

@ -0,0 +1,592 @@
import { createHash } from "node:crypto";
/**
* Structural subset of an ACP runtime event consumed by the canonical event
* mapper. Keeping the protocol layer vendor-neutral avoids making ACPX a
* runtime dependency merely to describe events; the ACPX provider can pass
* its richer event objects directly because they satisfy this shape.
*/
export interface AcpRuntimeEventShape {
type: string;
toolCallId?: string;
title?: string;
kind?: string;
locations?: unknown[];
text?: string;
status?: string;
rawOutput?: unknown;
tag?: string;
entries?: Array<{ content: string; status?: string }>;
[key: string]: unknown;
}
export const PROVIDER_EVENT_FAMILIES = [
"plan", "tool_execution", "research", "delegation", "model_identity",
"context", "artifact", "review", "hook", "memory", "safety", "terminal",
"wait", "provider_notice",
] as const;
export type ProviderEventFamily = (typeof PROVIDER_EVENT_FAMILIES)[number];
export type ProviderEventAvailability = "available" | "unsupported" | "policy_disabled";
/**
* Exhaustive inventory from the qualified Codex app-server ThreadItem schema.
* Keeping this closed makes a provider upgrade fail visibly in the inventory
* test instead of silently dropping a new native item shape.
*/
export const CODEX_THREAD_ITEM_CLASSIFICATION = {
userMessage: "existing_transcript",
hookPrompt: "hook",
agentMessage: "existing_transcript",
// Codex's `plan` ThreadItem is proposed-plan document text. The live
// execution checklist is a separate `turn/plan/updated` notification.
plan: "existing_transcript",
reasoning: "existing_reasoning",
commandExecution: "tool_execution",
fileChange: "existing_workspace_change",
mcpToolCall: "tool_execution",
dynamicToolCall: "tool_execution",
collabAgentToolCall: "delegation",
subAgentActivity: "delegation",
webSearch: "research",
imageView: "artifact",
sleep: "wait",
imageGeneration: "artifact",
enteredReviewMode: "review",
exitedReviewMode: "review",
contextCompaction: "context",
} as const satisfies Record<string, ProviderEventFamily | `existing_${string}`>;
export interface TypedEventFamilyCapability {
family: ProviderEventFamily;
version: 1;
availability: ProviderEventAvailability;
detailLevel: "summary" | "structured";
}
export type CanonicalProviderEventType =
| "plan.updated"
| "tool.execution.started" | "tool.execution.progressed" | "tool.execution.completed"
| "research.started" | "research.progressed" | "research.completed"
| "delegation.started" | "delegation.updated" | "delegation.completed"
| "model.route.changed" | "model.verification.updated" | "context.compacted"
| "artifact.viewed" | "artifact.generated" | "review.mode.changed"
| "hook.started" | "hook.completed" | "memory.citation.referenced"
| "safety.review.started" | "safety.review.completed" | "terminal.input.sent"
| "wait.started" | "wait.completed" | "provider.notice.recorded";
export interface CanonicalProviderEvent {
eventType: CanonicalProviderEventType;
payload: Record<string, unknown>;
itemId: string;
}
const MAX_OUTPUT = 64 * 1024;
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function text(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function integer(value: unknown): number | null {
return Number.isSafeInteger(value) ? Number(value) : null;
}
function safeId(value: string, fallback: string): string {
const candidate = value.replace(/[^A-Za-z0-9._:-]/g, "-").slice(0, 160);
return /^[A-Za-z0-9]/.test(candidate) ? candidate : fallback;
}
const GENERIC_ACP_TOOL_NAMES = new Set(["tool", "tool call", "tool_call", "acp_tool"]);
function normalizedToolWords(value: string): string[] {
return value
.replace(/^mcp__(.+?)__/, "")
.replace(/^mcp\.[^.]+\./, "")
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/[^A-Za-z0-9]+/g, " ")
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
}
function isGenericAcpToolName(value: unknown): boolean {
const name = text(value)
.trim()
.toLowerCase()
.replace(/\s*\((?:pending|in[_ -]?progress|completed|failed|cancelled|canceled)\)$/, "");
return !name || GENERIC_ACP_TOOL_NAMES.has(name);
}
function meaningfulAcpToolProgress(value: string, title: string | undefined, status: string | undefined): boolean {
const progress = value.trim();
if (!progress || /^tool(?:\s+|_)call\b/i.test(progress)) return false;
const statusPattern = (status ?? "")
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
.replaceAll("_", "[ _-]?");
if (!title || !statusPattern) return true;
const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return !new RegExp(`^${escapedTitle}\\s*\\(${statusPattern}\\)\\s*$`, "i").test(progress);
}
interface AcpxToolLifecycleIdentity {
title?: string;
kind?: string;
locations?: unknown[];
progress?: string;
}
/**
* ACP tool updates are deltas: a later update may omit the title, kind, and
* locations that were present on the opening event. ACPX represents a missing
* title as the literal `tool call`, so consumers must restore lifecycle
* identity before translating the event into a durable protocol record.
*/
export function createAcpxToolEventNormalizer<T extends AcpRuntimeEventShape>(): (event: T) => T {
const lifecycle = new Map<string, AcpxToolLifecycleIdentity>();
return (event) => {
if (event.type !== "tool_call" || !event.toolCallId) return event;
const previous = lifecycle.get(event.toolCallId) ?? {};
const incomingTitle = isGenericAcpToolName(event.title) ? undefined : event.title?.trim();
const title = previous.title ?? incomingTitle;
const kind = previous.kind && previous.kind !== "other"
? previous.kind
: event.kind ?? previous.kind;
const locations = event.locations?.length ? event.locations : previous.locations;
const eventText = text(event.text);
const progress = meaningfulAcpToolProgress(eventText, incomingTitle, event.status)
? eventText
: previous.progress;
lifecycle.set(event.toolCallId, { title, kind, locations, progress });
return {
...event,
title,
kind,
locations,
...(progress && canonicalStatus(event.status, "running") === "running" ? { text: progress } : {}),
} as T;
};
}
function acpxMcpToolIdentity(value: string): { namespace: string; name: string } | null {
const doubleUnderscore = value.match(/^mcp__(.+?)__(.+)$/i);
if (doubleUnderscore) return { namespace: doubleUnderscore[1], name: doubleUnderscore[2] };
const dotted = value.match(/^mcp\.([^.]+)\.(.+)$/i);
return dotted ? { namespace: dotted[1], name: dotted[2] } : null;
}
function acpxToolOperation(
kindValue: unknown,
name: string,
): "read" | "search" | "list" | "execute" | "edit" | "unknown" {
const kind = text(kindValue).toLowerCase();
if (kind === "read") return "read";
if (kind === "search" || kind === "fetch") return "search";
if (["edit", "delete", "move"].includes(kind)) return "edit";
if (kind === "execute") return "execute";
const words = normalizedToolWords(name);
const first = words[0] ?? "";
const compact = words.join("");
if (["get", "read", "inspect", "view"].includes(first)) return "read";
if (["list", "glob"].includes(first)) return "list";
if (["toolsearch", "websearch"].includes(compact) || ["find", "search", "grep", "query", "lookup", "fetch", "open", "browse"].includes(first)) return "search";
if (["write", "edit", "update", "set", "patch", "upsert", "sync", "create", "add", "register", "upload", "delete", "remove", "move", "rename", "report", "answer", "request", "decide", "comment", "finish", "complete", "block"].includes(first)) return "edit";
if (["run", "execute", "bash", "shell", "command", "start", "spawn", "delegate", "send", "message", "stop", "cancel", "interrupt", "wait", "sleep", "poll"].includes(first)) return "execute";
return "unknown";
}
function digest(value: string): string {
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
}
function boundedOutput(value: string): Pick<Record<string, unknown>, "output" | "outputBytes" | "outputTruncated" | "outputDigest"> {
const bytes = Buffer.byteLength(value);
const output = Buffer.from(value).subarray(Math.max(0, bytes - MAX_OUTPUT)).toString("utf8");
return { output, outputBytes: bytes, outputTruncated: bytes > MAX_OUTPUT, outputDigest: digest(value) };
}
function canonicalStatus(value: unknown, fallback: "running" | "completed" | "failed" | "cancelled" | "interrupted"): "running" | "completed" | "failed" | "cancelled" | "interrupted" {
const status = text(value).toLowerCase().replaceAll("_", "-");
if (status === "completed" || status === "success") return "completed";
if (status === "failed" || status === "error") return "failed";
if (status === "cancelled" || status === "canceled") return "cancelled";
if (status === "interrupted" || status === "aborted") return "interrupted";
if (status === "running" || status === "pending" || status === "in-progress") return "running";
return fallback;
}
function planStepStatus(value: unknown): "pending" | "in_progress" | "completed" | "blocked" {
const status = text(value)
.replace(/([a-z])([A-Z])/g, "$1_$2")
.toLowerCase()
.replaceAll("-", "_");
if (status === "in_progress" || status === "completed" || status === "blocked") return status;
if (status === "failed" || status === "error") return "blocked";
return "pending";
}
function operationFromActions(item: Record<string, unknown>): "read" | "search" | "list" | "execute" | "edit" | "unknown" {
const action = record(Array.isArray(item.commandActions) ? item.commandActions[0] : undefined);
const kind = text(action.type).toLowerCase();
if (kind === "read") return "read";
if (kind === "search") return "search";
if (kind === "listfiles") return "list";
return text(item.command) ? "execute" : "unknown";
}
function executionPayload(item: Record<string, unknown>, status: string): Record<string, unknown> {
const id = safeId(text(item.id), "execution");
const output = text(item.aggregatedOutput, text(item.output));
const action = record(Array.isArray(item.commandActions) ? item.commandActions[0] : undefined);
const target = text(action.path) || null;
return {
schema: "paperclip.tool.execution.v1",
executionId: id,
transport: text(item.type) === "mcpToolCall" ? "mcp" : text(item.type) === "dynamicToolCall" ? "dynamic" : "process",
operation: text(item.type).includes("ToolCall") ? "unknown" : operationFromActions(item),
name: text(item.tool, text(item.command)) || null,
target: target?.startsWith("/") || target?.split("/").includes("..") ? null : target,
namespace: text(item.server, text(item.namespace)) || null,
readOnly: typeof item.readOnlyHint === "boolean" ? item.readOnlyHint : null,
status,
durationMs: integer(item.durationMs),
exitCode: integer(item.exitCode),
progress: null,
...boundedOutput(output),
};
}
function turnPlanPayload(params: Record<string, unknown>): Record<string, unknown> {
const plan = Array.isArray(params.plan) ? params.plan : [];
const steps = plan.slice(0, 256).flatMap((entry, index) => {
const step = record(entry);
const body = text(step.step).trim().slice(0, 4000);
return body
? [{ stepId: `step-${index + 1}`, body, status: planStepStatus(step.status) }]
: [];
});
const planComplete = steps.length > 0 && steps.every((step) => step.status === "completed");
const planId = safeId(text(params.turnId), "turn-plan");
const revision = Number(params.revision);
return {
schema: "paperclip.plan.updated.v1",
planId,
revision: Number.isSafeInteger(revision) && revision > 0 ? revision : 1,
explanation: text(params.explanation).slice(0, 4000) || null,
steps,
complete: planComplete,
syncStatus: "not_applicable",
documentRevision: null,
};
}
function researchSources(item: Record<string, unknown>, researchId: string): Array<Record<string, unknown>> {
if (!Array.isArray(item.results)) return [];
return item.results.slice(0, 64).flatMap((value, index) => {
const result = record(value);
const url = text(result.url).slice(0, 8192);
if (!url.startsWith("http://") && !url.startsWith("https://")) return [];
const fallbackId = `${researchId}:source:${index + 1}`;
return [{
sourceId: safeId(text(result.ref_id, text(result.refId, fallbackId)), fallbackId),
title: text(result.title, url).slice(0, 4000),
url,
snippet: text(result.snippet).slice(0, 4000) || null,
}];
});
}
/** Maps qualified Codex app-server notifications to bounded canonical PRP events. */
export function canonicalProviderEventsFromCodex(method: string, paramsValue: unknown): CanonicalProviderEvent[] {
const params = record(paramsValue);
const item = record(params.item);
const type = text(item.type);
const itemId = safeId(text(item.id, text(params.itemId)), "provider-item");
const completed = method === "item/completed";
if (method === "turn/plan/updated") {
const turnPlanId = safeId(text(params.turnId), "turn-plan");
return [{ eventType: "plan.updated", payload: turnPlanPayload(params), itemId: turnPlanId }];
}
if ((method === "item/started" || completed) && ["commandExecution", "mcpToolCall", "dynamicToolCall"].includes(type)) {
return [{ eventType: completed ? "tool.execution.completed" : "tool.execution.started", payload: executionPayload(item, completed ? canonicalStatus(item.status, "completed") : "running"), itemId }];
}
if (method === "item/commandExecution/outputDelta" || method === "item/mcpToolCall/progress") {
const delta = text(params.delta, text(params.message));
return [{ eventType: "tool.execution.progressed", payload: { ...executionPayload({ ...item, id: itemId, output: delta }, "running"), progress: delta.slice(0, 4000) || null }, itemId }];
}
if ((method === "item/started" || completed) && type === "webSearch") {
const action = record(item.action);
const actionType = text(action.type, "other").replace("openPage", "open_page").replace("findInPage", "find_in_page");
const url = text(action.url) || null;
return [{ eventType: completed ? "research.completed" : "research.started", payload: { schema: "paperclip.research.v1", researchId: itemId, action: actionType, status: completed ? "completed" : "running", query: text(item.query, text(action.query)) || null, url: url?.startsWith("http://") || url?.startsWith("https://") ? url : null, pattern: text(action.pattern) || null, sources: completed ? researchSources(item, itemId) : [] }, itemId }];
}
if ((method === "item/started" || completed) && (type === "collabAgentToolCall" || type === "subAgentActivity")) {
return [{ eventType: completed ? "delegation.completed" : "delegation.started", payload: { schema: "paperclip.delegation.v1", delegationId: itemId, action: text(item.tool, "spawnAgent").replace("spawnAgent", "spawn").replace("sendInput", "message").replace("resumeAgent", "resume").replace("closeAgent", "close"), status: completed ? "completed" : "running", children: [] }, itemId }];
}
if (method === "model/rerouted") {
return [{ eventType: "model.route.changed", payload: { schema: "paperclip.model.route_changed.v1", routeId: safeId(text(params.turnId), "model-route"), provider: "openai", requestedModel: text(params.fromModel, "unknown"), fromModel: text(params.fromModel) || null, effectiveModel: text(params.toModel, "unknown"), reason: text(params.reason, "provider reroute").slice(0, 4000) }, itemId }];
}
if (method === "model/verification" || method === "model/safetyBuffering/updated") {
return [{ eventType: "model.verification.updated", payload: { schema: "paperclip.model.verification.v1", verificationId: safeId(text(params.turnId), "model-verification"), status: params.showBufferingUi === true ? "running" : "completed", classes: Array.isArray(params.verifications) ? params.verifications.slice(0, 32) : [], buffering: params.showBufferingUi === true, summary: Array.isArray(params.reasons) ? params.reasons.join("; ").slice(0, 4000) : null }, itemId }];
}
if (method === "thread/compacted" || ((method === "item/completed" || method === "item/started") && type === "contextCompaction")) {
return [{ eventType: "context.compacted", payload: { schema: "paperclip.context.compacted.v1", compactionId: itemId, reason: "provider", preTokens: null, postTokens: null, sameSession: true }, itemId }];
}
if ((method === "item/started" || completed) && type === "imageView") {
const path = text(item.path).replaceAll("\\", "/");
return [{ eventType: "artifact.viewed", payload: { schema: "paperclip.artifact.viewed.v1", artifactId: itemId, reference: path.startsWith("/") || path.split("/").includes("..") ? null : path, mediaType: "image/*", title: null }, itemId }];
}
if ((method === "item/started" || completed) && type === "imageGeneration") {
const path = text(item.savedPath).replaceAll("\\", "/");
return [{ eventType: "artifact.generated", payload: { schema: "paperclip.artifact.generated.v1", artifactId: itemId, status: completed ? text(item.status, "completed") : "running", reference: path.startsWith("/") || path.split("/").includes("..") ? null : path || null, mediaType: "image/*", registered: false, transparentBackground: typeof item.transparentBackground === "boolean" ? item.transparentBackground : null, failure: text(record(item.failure).message) || null }, itemId }];
}
if ((method === "item/started" || completed) && (type === "enteredReviewMode" || type === "exitedReviewMode")) {
return [{ eventType: "review.mode.changed", payload: { schema: "paperclip.review.mode_changed.v1", reviewId: itemId, state: type === "enteredReviewMode" ? "entered" : "exited", scope: text(item.review) || null }, itemId }];
}
if (method === "hook/started" || method === "hook/completed") {
const run = record(params.run);
return [{ eventType: method === "hook/started" ? "hook.started" : "hook.completed", payload: { schema: "paperclip.hook.v1", hookId: safeId(text(run.id), "hook"), event: text(run.eventName, "unknown"), scope: text(run.scope, "provider"), status: method === "hook/started" ? "running" : text(run.status, "completed"), blocking: run.executionMode === "blocking", durationMs: integer(run.durationMs), summary: text(run.statusMessage) || null }, itemId: safeId(text(run.id), "hook") }];
}
if (completed && type === "agentMessage" && item.memoryCitation) {
const citation = record(item.memoryCitation);
const entries = Array.isArray(citation.entries) ? citation.entries : [];
return entries.slice(0, 64).map((entry, index) => ({ eventType: "memory.citation.referenced" as const, payload: { schema: "paperclip.memory.citation.v1", citationId: `${itemId}:citation:${index + 1}`, messageItemId: itemId, label: text(record(entry).label, `Memory source ${index + 1}`).slice(0, 4000), available: false, reference: null }, itemId: `${itemId}:citation:${index + 1}` }));
}
if (method === "item/autoApprovalReview/started" || method === "item/autoApprovalReview/completed") {
return [{ eventType: method.endsWith("started") ? "safety.review.started" : "safety.review.completed", payload: { schema: "paperclip.safety.review.v1", reviewId: safeId(text(params.reviewId), "safety-review"), targetExecutionId: text(params.targetItemId) || null, status: method.endsWith("started") ? "running" : "completed", decision: method.endsWith("started") ? "pending" : "unknown", summary: null }, itemId: safeId(text(params.reviewId), "safety-review") }];
}
if (method === "item/commandExecution/terminalInteraction") {
return [{ eventType: "terminal.input.sent", payload: { schema: "paperclip.terminal.input_sent.v1", executionId: safeId(text(params.itemId), "execution"), origin: "agent", inputClass: "text", byteCount: Buffer.byteLength(text(params.stdin)) }, itemId: safeId(text(params.itemId), "execution") }];
}
if ((method === "item/started" || completed) && type === "sleep") {
return [{ eventType: completed ? "wait.completed" : "wait.started", payload: { schema: "paperclip.wait.v1", waitId: itemId, reason: "timer", status: completed ? "completed" : "running", plannedDurationMs: integer(item.durationMs), elapsedDurationMs: completed ? integer(item.durationMs) : null }, itemId }];
}
if (["error", "warning", "guardianWarning", "deprecationNotice", "configWarning", "windows/worldWritableWarning"].includes(method)) {
return [{ eventType: "provider.notice.recorded", payload: { schema: "paperclip.provider.notice.v1", noticeId: safeId(`${method}:${text(params.code, "notice")}`, "provider-notice"), severity: method === "error" ? "error" : "warning", category: method.replaceAll("/", "_").slice(0, 160), scope: method.includes("config") || method.includes("windows") ? "environment" : "turn", recoverable: method !== "error", userActionable: method === "error" || method === "warning", summary: text(params.message, "Provider notice").slice(0, 4000) }, itemId }];
}
return [];
}
/** Maps documented OpenCode structured message parts. Unrecognized parts are deliberately unsupported. */
export function canonicalProviderEventsFromOpenCodePart(partValue: unknown): CanonicalProviderEvent[] {
const part = record(partValue);
const type = text(part.type).toLowerCase();
const itemId = safeId(text(part.id, text(part.callID, text(part.callId))), "opencode-part");
const state = record(part.state);
const nativeStatus = text(state.status).toLowerCase().replaceAll("_", "-");
const status = canonicalStatus(state.status, "running");
if (["tool", "tool-call", "tool_call", "bash", "command"].includes(type)) {
const tool = canonicalOpenCodeDisplayToolName(
text(part.tool, text(part.name, type)),
text(part.callID, text(part.callId)),
);
// OpenCode places terminal tool failures in `state.error`, not `output`.
// Preserve the bounded provider message so a failed row is actionable.
const output = text(state.output, text(part.output, text(state.error)));
const payload = {
schema: "paperclip.tool.execution.v1",
executionId: itemId,
transport: type === "tool" || type.includes("tool") ? "builtin" : "process",
operation: /read/i.test(tool) ? "read" : /search|grep|find/i.test(tool) ? "search" : /list|glob/i.test(tool) ? "list" : /edit|write|patch/i.test(tool) ? "edit" : "execute",
name: tool || null,
target: null,
namespace: null,
readOnly: /read|search|grep|find|list|glob/i.test(tool),
status,
durationMs: integer(state.time),
exitCode: integer(state.exit),
progress: status === "running" ? text(state.title).slice(0, 4000) || null : null,
...boundedOutput(output),
};
const eventType = nativeStatus === "pending"
? "tool.execution.started"
: status === "running"
? "tool.execution.progressed"
: "tool.execution.completed";
return [{ eventType, payload, itemId }];
}
if (["websearch", "web_search"].includes(type)) {
return [{ eventType: status === "running" ? "research.progressed" : "research.completed", payload: { schema: "paperclip.research.v1", researchId: itemId, action: "search", status: status === "interrupted" ? "cancelled" : status, query: text(part.query) || null, url: null, pattern: null, sources: [] }, itemId }];
}
if (["subtask", "task"].includes(type)) {
return [{ eventType: status === "running" ? "delegation.updated" : "delegation.completed", payload: { schema: "paperclip.delegation.v1", delegationId: itemId, action: "spawn", status: status === "cancelled" ? "interrupted" : status, children: [] }, itemId }];
}
if (["compaction", "context-compaction"].includes(type)) {
return [{ eventType: "context.compacted", payload: { schema: "paperclip.context.compacted.v1", compactionId: itemId, reason: "provider", preTokens: null, postTokens: null, sameSession: true }, itemId }];
}
return [];
}
/** Maps ACPX's bounded public runtime events; raw ACP JSON-RPC never enters PRP. */
export function canonicalProviderEventsFromAcpxRuntimeEvent(
event: AcpRuntimeEventShape,
fallbackItemId: string,
turnId?: string,
): CanonicalProviderEvent[] {
const runtimeType = text(record(event).type);
if (!["text_delta", "status", "tool_call", "plan", "error", "done"].includes(runtimeType)) {
return [{
eventType: "provider.notice.recorded",
payload: {
schema: "paperclip.provider.notice.v1",
noticeId: safeId(`${fallbackItemId}:${runtimeType || "unknown"}`, "acpx-unclassified-update"),
severity: "warning",
category: `unclassified_acp_${runtimeType || "unknown"}`.slice(0, 160),
scope: "turn",
recoverable: true,
userActionable: false,
summary: "The qualified ACP agent emitted an unclassified runtime update.",
},
itemId: fallbackItemId,
}];
}
const itemId = safeId(
event.type === "tool_call"
? text(event.toolCallId, fallbackItemId)
: event.type === "plan"
? text(turnId, fallbackItemId)
: fallbackItemId,
"acpx-item",
);
if (event.type === "plan") {
const steps = (event.entries ?? []).slice(0, 256).flatMap((entry, index) => {
const body = entry.content.trim().slice(0, 4000);
return body
? [{
stepId: `step-${index + 1}`,
body,
status: planStepStatus(entry.status),
}]
: [];
});
const complete = steps.length > 0 && steps.every((step) => step.status === "completed");
return [{
eventType: "plan.updated",
payload: {
schema: "paperclip.plan.updated.v1",
planId: safeId(text(turnId, fallbackItemId), "turn-plan"),
revision: 1,
explanation: null,
steps,
complete,
syncStatus: "not_applicable",
documentRevision: null,
},
itemId,
}];
}
if (event.type === "tool_call") {
const status = canonicalStatus(event.status, "running");
const output = typeof event.rawOutput === "string"
? event.rawOutput
: event.rawOutput === undefined
? ""
: JSON.stringify(event.rawOutput);
const title = isGenericAcpToolName(event.title) ? "" : text(event.title).trim();
const mcp = acpxMcpToolIdentity(title);
const name = mcp?.name ?? title;
const operation = acpxToolOperation(event.kind, name);
const payload = {
schema: "paperclip.tool.execution.v1",
executionId: itemId,
transport: mcp ? "mcp" : "builtin",
operation,
name: name || null,
target: safeAcpLocation(event.locations?.[0]),
namespace: mcp?.namespace ?? null,
readOnly: ["read", "search", "list"].includes(operation),
status,
durationMs: null,
exitCode: null,
progress: status === "running" ? text(event.text).slice(0, 4000) || null : null,
...boundedOutput(output),
};
const terminal = status !== "running";
return [{
eventType: terminal ? "tool.execution.completed" : event.tag === "tool_call" ? "tool.execution.started" : "tool.execution.progressed",
payload,
itemId,
}];
}
if (event.type === "status" && event.tag === "current_mode_update") {
const statusText = text(event.text);
const state = /review|plan/i.test(statusText) ? "entered" : "exited";
return [{
eventType: "review.mode.changed",
payload: {
schema: "paperclip.review.mode_changed.v1",
reviewId: itemId,
state,
scope: statusText.slice(0, 4000) || null,
},
itemId,
}];
}
if (event.type === "status" && event.tag && ![
"usage_update",
"available_commands_update",
"config_option_update",
"session_info_update",
].includes(event.tag)) {
return [{
eventType: "provider.notice.recorded",
payload: {
schema: "paperclip.provider.notice.v1",
noticeId: itemId,
severity: "info",
category: `acp_${event.tag}`.slice(0, 160),
scope: "turn",
recoverable: true,
userActionable: false,
summary: text(event.text).slice(0, 4000) || "ACP provider update",
},
itemId,
}];
}
return [];
}
export function canonicalOpenCodeDisplayToolName(value: string, callId = ""): string {
let name = value.trim();
// Some OpenRouter models ask OpenCode for a function using a qualified
// `callID` while OpenCode reports the display tool as `unknown`. Recover
// only this documented, structured prefix; opaque provider call IDs remain
// opaque and continue to display as unknown.
if (!name || name === "unknown" || name === "tool") {
const qualified = /^functions[./:_-]([^:/.]+)(?::\d+)?$/i.exec(callId.trim());
if (qualified?.[1]) name = qualified[1];
}
// OpenCode exposes MCP tools as `<server>_<tool>`. Paperclip's semantic
// tools already carry the paperclip prefix, producing names such as
// `paperclip_paperclip_finish` in native events. Keep the transport-native
// name in the raw trace, while presenting the stable protocol operation.
while (name.startsWith("paperclip_paperclip_")) {
name = name.slice("paperclip_".length);
}
return name;
}
function safeAcpLocation(value: unknown): string | null {
const location = record(value);
const raw = text(location.path, text(location.uri)).replaceAll("\\", "/");
if (!raw || raw.startsWith("/") || raw.split("/").includes("..") || /^[a-z]+:\/\//i.test(raw)) return null;
return raw.slice(0, 4000);
}
export function providerFamilyCapabilities(
availability: Partial<Record<ProviderEventFamily, ProviderEventAvailability>>,
): TypedEventFamilyCapability[] {
return PROVIDER_EVENT_FAMILIES.map((family) => ({
family,
version: 1,
availability: availability[family] ?? "unsupported",
detailLevel: availability[family] === "available" ? "structured" : "summary",
}));
}

View File

@ -85,6 +85,27 @@ test("accepted fixtures satisfy the complete JSON Schemas", async () => {
assert.doesNotThrow(() => assertSchemaInstance(validators.fixture, unsupported, "required-v2", false));
});
test("provider descriptors require coherent provider, driver, and execution combinations", async () => {
const schemas = await loadSchemaCatalog(resolve(protocolRoot, "schemas"));
const validators = compileProtocolValidators(schemas);
const codex = {
provider: "codex",
driver: "codex_app_server",
model: "gpt-5.3-codex",
executionKind: "local_process",
providerVersion: "1",
};
assert.doesNotThrow(() => assertSchemaInstance(validators.providerDescriptor, codex, "codex-provider"));
assert.throws(
() => assertSchemaInstance(
validators.providerDescriptor,
{ ...codex, driver: "opencode_server" },
"mismatched-provider",
),
/schema_validation_failed: mismatched-provider must be accepted/,
);
});
test("the Codex question fixture uses stable provider-neutral IDs", async () => {
const value = await fixture("questions/codex.json");
assert.doesNotThrow(() => assertCodexQuestionFixture(value));