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:
parent
bc9ba7cd26
commit
b1cd261212
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
}));
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue