From b1cd2612121afe5cec75e571c968cf558845cfd5 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:44:24 -0500 Subject: [PATCH] feat(runner): normalize provider event contracts (#12350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../paperclip-runner/protocol/manifest.json | 8 +- .../protocol/schemas/event.schema.json | 23 +- .../schemas/provider-descriptor.schema.json | 93 ++- .../schemas/provider-event.schema.json | 5 +- .../protocol/schemas/result.schema.json | 10 +- .../scripts/protocol-contract.mjs | 1 + packages/paperclip-runner/src/index.ts | 1 + .../src/protocol/generated/schema-bundle.ts | 332 +++++++++- .../src/protocol/result-normalization.test.ts | 38 ++ .../src/provider-events.test.ts | 409 ++++++++++++ .../paperclip-runner/src/provider-events.ts | 592 ++++++++++++++++++ .../test/protocol-contract.test.mjs | 21 + 12 files changed, 1515 insertions(+), 18 deletions(-) create mode 100644 packages/paperclip-runner/src/provider-events.test.ts create mode 100644 packages/paperclip-runner/src/provider-events.ts diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json index f3de19ef14..8575322f47 100644 --- a/packages/paperclip-runner/protocol/manifest.json +++ b/packages/paperclip-runner/protocol/manifest.json @@ -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", diff --git a/packages/paperclip-runner/protocol/schemas/event.schema.json b/packages/paperclip-runner/protocol/schemas/event.schema.json index 355e1f6477..9d2b3ed873 100644 --- a/packages/paperclip-runner/protocol/schemas/event.schema.json +++ b/packages/paperclip-runner/protocol/schemas/event.schema.json @@ -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": { diff --git a/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json b/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json index 3bfbb66a06..53c174d983 100644 --- a/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json +++ b/packages/paperclip-runner/protocol/schemas/provider-descriptor.schema.json @@ -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 } diff --git a/packages/paperclip-runner/protocol/schemas/provider-event.schema.json b/packages/paperclip-runner/protocol/schemas/provider-event.schema.json index 8af5200ddf..f0275dabf5 100644 --- a/packages/paperclip-runner/protocol/schemas/provider-event.schema.json +++ b/packages/paperclip-runner/protocol/schemas/provider-event.schema.json @@ -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 }, diff --git a/packages/paperclip-runner/protocol/schemas/result.schema.json b/packages/paperclip-runner/protocol/schemas/result.schema.json index 62983b280e..95f438d45a 100644 --- a/packages/paperclip-runner/protocol/schemas/result.schema.json +++ b/packages/paperclip-runner/protocol/schemas/result.schema.json @@ -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"] } }, diff --git a/packages/paperclip-runner/scripts/protocol-contract.mjs b/packages/paperclip-runner/scripts/protocol-contract.mjs index 35743d4c7d..9ee5e8bde8 100644 --- a/packages/paperclip-runner/scripts/protocol-contract.mjs +++ b/packages/paperclip-runner/scripts/protocol-contract.mjs @@ -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"), }; } diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts index 8c8f4d86db..4a01c2340d 100644 --- a/packages/paperclip-runner/src/index.ts +++ b/packages/paperclip-runner/src/index.ts @@ -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"; diff --git a/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts b/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts index e6a87e032f..d6b096a2db 100644 --- a/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts +++ b/packages/paperclip-runner/src/protocol/generated/schema-bundle.ts @@ -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": { diff --git a/packages/paperclip-runner/src/protocol/result-normalization.test.ts b/packages/paperclip-runner/src/protocol/result-normalization.test.ts index cf68d2977d..2473b821fe 100644 --- a/packages/paperclip-runner/src/protocol/result-normalization.test.ts +++ b/packages/paperclip-runner/src/protocol/result-normalization.test.ts @@ -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", diff --git a/packages/paperclip-runner/src/provider-events.test.ts b/packages/paperclip-runner/src/provider-events.test.ts new file mode 100644 index 0000000000..887ad58d32 --- /dev/null +++ b/packages/paperclip-runner/src/provider-events.test.ts @@ -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[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]> = [ + ["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 }); + }); +}); diff --git a/packages/paperclip-runner/src/provider-events.ts b/packages/paperclip-runner/src/provider-events.ts new file mode 100644 index 0000000000..65bedc04ca --- /dev/null +++ b/packages/paperclip-runner/src/provider-events.ts @@ -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; + +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; + itemId: string; +} + +const MAX_OUTPUT = 64 * 1024; + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : {}; +} + +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(): (event: T) => T { + const lifecycle = new Map(); + 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, "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): "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, status: string): Record { + 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): Record { + 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, researchId: string): Array> { + 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 `_`. 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>, +): TypedEventFamilyCapability[] { + return PROVIDER_EVENT_FAMILIES.map((family) => ({ + family, + version: 1, + availability: availability[family] ?? "unsupported", + detailLevel: availability[family] === "available" ? "structured" : "summary", + })); +} diff --git a/packages/paperclip-runner/test/protocol-contract.test.mjs b/packages/paperclip-runner/test/protocol-contract.test.mjs index 523b79f5ac..fb63206d6a 100644 --- a/packages/paperclip-runner/test/protocol-contract.test.mjs +++ b/packages/paperclip-runner/test/protocol-contract.test.mjs @@ -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));