mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
feat: add provider-neutral runner events
This commit is contained in:
1 parent
47b3429a7f
commit
39c4fbe421
87 files changed
+51704
-282
No files matched your search
@@ -0,0 +1,248 @@
|
||||
import { Command } from "commander";
|
||||
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
apiPath,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./client/common.js";
|
||||
|
||||
const ANTHROPIC_ORIGIN = "https://api.anthropic.com";
|
||||
const ANTHROPIC_VERSION = "2023-06-01";
|
||||
const MANAGED_BETA = "managed-agents-2026-04-01";
|
||||
const SYSTEM_PROMPT = `You are a Paperclip remote agent. Follow the current user turn and use only the custom tools supplied for that session. Paperclip tool authority, completion, blocking, review, and yielding are enforced by the runner. Never request or infer a Paperclip endpoint or credential.`;
|
||||
|
||||
interface ManagedAgentSetupOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
profileKey: string;
|
||||
displayName: string;
|
||||
apiKeySecretId: string;
|
||||
model: string;
|
||||
maxSessionListCostUsd: string;
|
||||
agentId?: string;
|
||||
agentVersion?: string;
|
||||
environmentId?: string;
|
||||
probe?: boolean;
|
||||
acknowledgeRetention?: boolean;
|
||||
}
|
||||
|
||||
interface RemoteResource {
|
||||
id?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
async function anthropicRequest(
|
||||
key: string,
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
body?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(`${ANTHROPIC_ORIGIN}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-api-key": key,
|
||||
"anthropic-version": ANTHROPIC_VERSION,
|
||||
"anthropic-beta": MANAGED_BETA,
|
||||
...(body ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Anthropic Managed Agents request failed with HTTP ${response.status}`);
|
||||
}
|
||||
if (response.status === 204) return {};
|
||||
return record(await response.json());
|
||||
}
|
||||
|
||||
async function listAll(key: string, path: string): Promise<RemoteResource[]> {
|
||||
const rows: RemoteResource[] = [];
|
||||
let page: string | null = null;
|
||||
do {
|
||||
const suffix = page ? `${path.includes("?") ? "&" : "?"}page=${encodeURIComponent(page)}` : "";
|
||||
const response = await anthropicRequest(key, "GET", `${path}${suffix}`);
|
||||
for (const value of Array.isArray(response.data) ? response.data : []) {
|
||||
const item = record(value);
|
||||
rows.push(item as RemoteResource);
|
||||
}
|
||||
page = typeof response.next_page === "string" && response.next_page ? response.next_page : null;
|
||||
} while (page);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function resourceByProfile(resources: RemoteResource[], profileKey: string): RemoteResource | null {
|
||||
return resources.find((resource) =>
|
||||
typeof resource.id === "string" && record(resource.metadata).paperclip_profile === profileKey
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
function assertSafeEnvironment(environment: Record<string, unknown>): void {
|
||||
const config = record(environment.config);
|
||||
const networking = record(config.networking);
|
||||
const packages = record(config.packages);
|
||||
const installed = Object.entries(packages)
|
||||
.filter(([key]) => key !== "type")
|
||||
.flatMap(([, value]) => Array.isArray(value) ? value : []);
|
||||
if (
|
||||
config.type !== "cloud"
|
||||
|| networking.type !== "limited"
|
||||
|| networking.allow_mcp_servers === true
|
||||
|| networking.allow_package_managers === true
|
||||
|| !Array.isArray(networking.allowed_hosts)
|
||||
|| networking.allowed_hosts.length > 0
|
||||
|| installed.length > 0
|
||||
) {
|
||||
throw new Error("Existing Anthropic Environment does not match Paperclip's no-network, no-package profile");
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeAgent(agent: Record<string, unknown>): void {
|
||||
if (
|
||||
(Array.isArray(agent.tools) && agent.tools.length > 0)
|
||||
|| (Array.isArray(agent.mcp_servers) && agent.mcp_servers.length > 0)
|
||||
|| (Array.isArray(agent.skills) && agent.skills.length > 0)
|
||||
|| agent.multiagent != null
|
||||
) {
|
||||
throw new Error("Existing Anthropic Agent enables tools, MCP, skills, or multi-agent features outside this phase");
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveEnvironment(key: string, options: ManagedAgentSetupOptions): Promise<Record<string, unknown>> {
|
||||
if (options.environmentId) {
|
||||
const environment = await anthropicRequest(key, "GET", `/v1/environments/${encodeURIComponent(options.environmentId)}?beta=true`);
|
||||
assertSafeEnvironment(environment);
|
||||
return environment;
|
||||
}
|
||||
const existing = resourceByProfile(await listAll(key, "/v1/environments?beta=true"), options.profileKey);
|
||||
if (existing) {
|
||||
assertSafeEnvironment(existing);
|
||||
return existing;
|
||||
}
|
||||
if (options.probe) throw new Error("Probe found no matching Anthropic Environment");
|
||||
return anthropicRequest(key, "POST", "/v1/environments?beta=true", {
|
||||
name: `Paperclip · ${options.displayName}`,
|
||||
description: "Paperclip remote-agent environment: no network or added packages.",
|
||||
config: {
|
||||
type: "cloud",
|
||||
networking: {
|
||||
type: "limited",
|
||||
allow_mcp_servers: false,
|
||||
allow_package_managers: false,
|
||||
allowed_hosts: [],
|
||||
},
|
||||
packages: { type: "packages", apt: [], cargo: [], gem: [], go: [], npm: [], pip: [] },
|
||||
},
|
||||
metadata: { paperclip_profile: options.profileKey },
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveAgent(key: string, options: ManagedAgentSetupOptions): Promise<Record<string, unknown>> {
|
||||
if (options.agentId) {
|
||||
const agent = await anthropicRequest(key, "GET", `/v1/agents/${encodeURIComponent(options.agentId)}?beta=true`);
|
||||
assertSafeAgent(agent);
|
||||
return agent;
|
||||
}
|
||||
const existing = resourceByProfile(await listAll(key, "/v1/agents?beta=true"), options.profileKey);
|
||||
if (existing) {
|
||||
assertSafeAgent(existing);
|
||||
return existing;
|
||||
}
|
||||
if (options.probe) throw new Error("Probe found no matching Anthropic Agent");
|
||||
return anthropicRequest(key, "POST", "/v1/agents?beta=true", {
|
||||
name: `Paperclip · ${options.displayName}`,
|
||||
description: "Versioned Paperclip remote agent; runnerd supplies session tools.",
|
||||
model: options.model,
|
||||
system: SYSTEM_PROMPT,
|
||||
tools: [],
|
||||
mcp_servers: [],
|
||||
skills: [],
|
||||
metadata: { paperclip_profile: options.profileKey },
|
||||
});
|
||||
}
|
||||
|
||||
async function setupManagedAgent(options: ManagedAgentSetupOptions): Promise<void> {
|
||||
const key = process.env.ANTHROPIC_API_KEY?.trim();
|
||||
if (!key) throw new Error("ANTHROPIC_API_KEY is required in the CLI process environment");
|
||||
if (!options.acknowledgeRetention) {
|
||||
throw new Error("Pass --acknowledge-retention to enable the stateful beta Managed Agents service");
|
||||
}
|
||||
const cap = Number(options.maxSessionListCostUsd);
|
||||
if (!Number.isFinite(cap) || cap <= 0) throw new Error("--max-session-list-cost-usd must be positive");
|
||||
|
||||
const [environment, agent] = await Promise.all([
|
||||
resolveEnvironment(key, options),
|
||||
resolveAgent(key, options),
|
||||
]);
|
||||
const agentId = String(agent.id ?? "");
|
||||
const environmentId = String(environment.id ?? "");
|
||||
const versions = await listAll(key, `/v1/agents/${encodeURIComponent(agentId)}/versions?beta=true`);
|
||||
const version = options.agentVersion
|
||||
?? String(agent.version ?? versions.at(-1)?.version ?? "");
|
||||
if (!agentId || !environmentId || !version || !versions.some((entry) => String(entry.version) === version)) {
|
||||
throw new Error("Anthropic did not return a usable pinned Agent version and Environment identity");
|
||||
}
|
||||
|
||||
const qualification = {
|
||||
probedAt: new Date().toISOString(),
|
||||
betaVersion: MANAGED_BETA,
|
||||
environmentPolicy: "limited_no_hosts_no_packages",
|
||||
agentCapabilities: "no_tools_no_mcp_no_skills_no_multiagent",
|
||||
};
|
||||
const profile = {
|
||||
profileKey: options.profileKey,
|
||||
displayName: options.displayName,
|
||||
anthropicAgentId: agentId,
|
||||
agentVersion: version,
|
||||
environmentId,
|
||||
defaultModel: options.model,
|
||||
defaultMaxListCostUsd: cap,
|
||||
apiKeySecretId: options.apiKeySecretId,
|
||||
enabled: !options.probe,
|
||||
retentionAcknowledged: true,
|
||||
qualification,
|
||||
};
|
||||
|
||||
if (options.probe) {
|
||||
printOutput({ mode: "probe", qualified: true, profile }, { json: options.json });
|
||||
return;
|
||||
}
|
||||
const context = resolveCommandContext(options, { requireCompany: true });
|
||||
const stored = await context.api.post(
|
||||
apiPath`/api/companies/${context.companyId}/managed-agent-profiles`,
|
||||
profile,
|
||||
);
|
||||
printOutput(stored, { json: context.json });
|
||||
}
|
||||
|
||||
export function registerManagedAgentCommands(program: Command): void {
|
||||
const command = program.command("managed-agent").description("Provision and qualify remote managed-agent providers");
|
||||
addCommonClientOptions(
|
||||
command
|
||||
.command("setup")
|
||||
.description("Create or adopt a locked-down Anthropic Agent and Environment, then store a company profile")
|
||||
.requiredOption("--profile-key <key>", "Stable company profile key")
|
||||
.requiredOption("--display-name <name>", "Profile display name")
|
||||
.requiredOption("--api-key-secret-id <id>", "Existing company secret containing ANTHROPIC_API_KEY")
|
||||
.option("--model <id>", "Pinned Claude model", "claude-sonnet-5")
|
||||
.option("--max-session-list-cost-usd <usd>", "Default hard session ceiling", "1.00")
|
||||
.option("--agent-id <id>", "Adopt an existing Anthropic Agent")
|
||||
.option("--agent-version <version>", "Pin an existing Agent version")
|
||||
.option("--environment-id <id>", "Adopt an existing Anthropic Environment")
|
||||
.option("--probe", "Read-only qualification; create or persist nothing", false)
|
||||
.option("--acknowledge-retention", "Acknowledge beta retention and non-ZDR/non-HIPAA status", false)
|
||||
.action(async (options: ManagedAgentSetupOptions) => {
|
||||
try {
|
||||
await setupManagedAgent(options);
|
||||
} catch (error) {
|
||||
handleCommandError(error);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: true },
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { registerWorkspaceCommands } from "./commands/client/workspace.js";
|
||||
import { registerAccessCommands } from "./commands/client/access.js";
|
||||
import { registerRoutineApiCommands } from "./commands/client/routine-api.js";
|
||||
import { registerAdapterCommands } from "./commands/client/adapter.js";
|
||||
import { registerManagedAgentCommands } from "./commands/managed-agent.js";
|
||||
import { registerAssetCommands } from "./commands/client/asset.js";
|
||||
import { registerSkillCommands } from "./commands/client/skill.js";
|
||||
import { cliVersion } from "./version.js";
|
||||
@@ -223,6 +224,7 @@ registerWorkspaceCommands(program);
|
||||
registerAccessCommands(program);
|
||||
registerRoutineApiCommands(program);
|
||||
registerAdapterCommands(program);
|
||||
registerManagedAgentCommands(program);
|
||||
registerAssetCommands(program);
|
||||
registerSkillCommands(program);
|
||||
registerRoutineCommands(program);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -529,7 +529,8 @@ export type TranscriptEntry =
|
||||
| { kind: "stderr"; ts: string; text: string }
|
||||
| { kind: "system"; ts: string; text: string }
|
||||
| { kind: "stdout"; ts: string; text: string }
|
||||
| { kind: "diff"; ts: string; changeType: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation"; text: string };
|
||||
| { kind: "diff"; ts: string; changeType: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation"; text: string }
|
||||
| { kind: "provider_activity"; ts: string; family: "plan" | "tool_execution" | "research" | "delegation" | "model_identity" | "context" | "artifact" | "review" | "hook" | "memory" | "safety" | "terminal" | "wait" | "provider_notice"; eventType: string; status: "running" | "completed" | "failed" | "interrupted" | "informational"; title: string; summary: string; payload: Record<string, unknown> };
|
||||
|
||||
export type StdoutLineParser = (line: string, ts: string) => TranscriptEntry[];
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE "managed_agent_profiles" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"profile_key" text NOT NULL,
|
||||
"display_name" text NOT NULL,
|
||||
"service" text DEFAULT 'anthropic_managed_agents' NOT NULL,
|
||||
"anthropic_agent_id" text NOT NULL,
|
||||
"agent_version" text NOT NULL,
|
||||
"environment_id" text NOT NULL,
|
||||
"beta_version" text DEFAULT 'managed-agents-2026-04-01' NOT NULL,
|
||||
"default_model" text DEFAULT 'claude-sonnet-5' NOT NULL,
|
||||
"default_max_list_cost_cents" integer DEFAULT 100 NOT NULL,
|
||||
"api_key_secret_id" uuid NOT NULL,
|
||||
"enabled" boolean DEFAULT false NOT NULL,
|
||||
"retention_acknowledged" boolean DEFAULT false NOT NULL,
|
||||
"qualification" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"qualified_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "managed_agent_profiles_service_check" CHECK ("managed_agent_profiles"."service" = 'anthropic_managed_agents'),
|
||||
CONSTRAINT "managed_agent_profiles_beta_check" CHECK ("managed_agent_profiles"."beta_version" = 'managed-agents-2026-04-01'),
|
||||
CONSTRAINT "managed_agent_profiles_positive_budget_check" CHECK ("managed_agent_profiles"."default_max_list_cost_cents" > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "managed_agent_profiles" ADD CONSTRAINT "managed_agent_profiles_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "managed_agent_profiles" ADD CONSTRAINT "managed_agent_profiles_api_key_secret_id_company_secrets_id_fk" FOREIGN KEY ("api_key_secret_id") REFERENCES "public"."company_secrets"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "managed_agent_profiles_company_idx" ON "managed_agent_profiles" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "managed_agent_profiles_company_key_uq" ON "managed_agent_profiles" USING btree ("company_id","profile_key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "managed_agent_profiles_company_resource_uq" ON "managed_agent_profiles" USING btree ("company_id","anthropic_agent_id","agent_version","environment_id");
|
||||
+41252
File diff suppressed because it is too large.
Load diff
+7
@@ -1576,6 +1576,13 @@
|
||||
"when": 1787173793193,
|
||||
"tag": "0226_sharp_lilandra",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 227,
|
||||
"version": "7",
|
||||
"when": 1787352614045,
|
||||
"tag": "0227_melodic_expediter",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -127,6 +127,7 @@ export { activityLog } from "./activity_log.js";
|
||||
export { companySecretProviderConfigs } from "./company_secret_provider_configs.js";
|
||||
export { userSecretDefinitions } from "./user_secret_definitions.js";
|
||||
export { companySecrets } from "./company_secrets.js";
|
||||
export { managedAgentProfiles } from "./managed_agent_profiles.js";
|
||||
export { companySecretVersions } from "./company_secret_versions.js";
|
||||
export { companySecretBindings } from "./company_secret_bindings.js";
|
||||
export { companySecretProposals } from "./company_secret_proposals.js";
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { companies } from "./companies.js";
|
||||
import { companySecrets } from "./company_secrets.js";
|
||||
|
||||
/**
|
||||
* Company-scoped, non-secret snapshot of a qualified remote-agent resource set.
|
||||
* The credential remains in the existing company secret store and is referenced
|
||||
* by id only; a run copies the immutable public fields into its native input.
|
||||
*/
|
||||
export const managedAgentProfiles = pgTable(
|
||||
"managed_agent_profiles",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id")
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: "cascade" }),
|
||||
profileKey: text("profile_key").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
service: text("service").notNull().default("anthropic_managed_agents"),
|
||||
anthropicAgentId: text("anthropic_agent_id").notNull(),
|
||||
agentVersion: text("agent_version").notNull(),
|
||||
environmentId: text("environment_id").notNull(),
|
||||
betaVersion: text("beta_version").notNull().default("managed-agents-2026-04-01"),
|
||||
defaultModel: text("default_model").notNull().default("claude-sonnet-5"),
|
||||
defaultMaxListCostCents: integer("default_max_list_cost_cents").notNull().default(100),
|
||||
apiKeySecretId: uuid("api_key_secret_id")
|
||||
.notNull()
|
||||
.references(() => companySecrets.id, { onDelete: "restrict" }),
|
||||
enabled: boolean("enabled").notNull().default(false),
|
||||
retentionAcknowledged: boolean("retention_acknowledged").notNull().default(false),
|
||||
qualification: jsonb("qualification").$type<Record<string, unknown>>().notNull().default({}),
|
||||
qualifiedAt: timestamp("qualified_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIdx: index("managed_agent_profiles_company_idx").on(table.companyId),
|
||||
companyKeyUq: uniqueIndex("managed_agent_profiles_company_key_uq").on(
|
||||
table.companyId,
|
||||
table.profileKey,
|
||||
),
|
||||
resourceUq: uniqueIndex("managed_agent_profiles_company_resource_uq").on(
|
||||
table.companyId,
|
||||
table.anthropicAgentId,
|
||||
table.agentVersion,
|
||||
table.environmentId,
|
||||
),
|
||||
serviceCheck: check(
|
||||
"managed_agent_profiles_service_check",
|
||||
sql`${table.service} = 'anthropic_managed_agents'`,
|
||||
),
|
||||
betaCheck: check(
|
||||
"managed_agent_profiles_beta_check",
|
||||
sql`${table.betaVersion} = 'managed-agents-2026-04-01'`,
|
||||
),
|
||||
positiveBudgetCheck: check(
|
||||
"managed_agent_profiles_positive_budget_check",
|
||||
sql`${table.defaultMaxListCostCents} > 0`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -656,8 +656,11 @@ function turnStreamBody(payload: unknown, interim: unknown[] = []): string {
|
||||
async function stubCleanRoom(page: Page, identifiers: string[]) {
|
||||
let opened = 0;
|
||||
await page.route(CLEAN_ROOM_API, async (route) => {
|
||||
const identifier = identifiers[Math.min(opened, identifiers.length - 1)] ?? "MCK-1000";
|
||||
opened += 1;
|
||||
const requestedSessionId = new URL(route.request().url()).searchParams.get("sessionId");
|
||||
const identifier = requestedSessionId?.replace(/^session-/, "")
|
||||
?? identifiers[Math.min(opened, identifiers.length - 1)]
|
||||
?? "MCK-1000";
|
||||
if (requestedSessionId === null) opened += 1;
|
||||
await route.fulfill({
|
||||
status: route.request().method() === "POST" ? 201 : 200,
|
||||
contentType: "application/json",
|
||||
@@ -700,6 +703,40 @@ test.describe("Capability clean-room chat", () => {
|
||||
await expect(page.locator('[data-composer-state="ready"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("Claude Agent options and remote-runtime controls use provider-neutral labels", async ({ page }) => {
|
||||
await page.addInitScript(() => window.localStorage.clear());
|
||||
await page.route(CLEAN_ROOM_API, async (route) => {
|
||||
const payload = cleanRoomPayload("MCK-1401");
|
||||
payload.view.identity = {
|
||||
...payload.view.identity,
|
||||
agentLabel: "Real Claude Agent",
|
||||
};
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
configuration: {
|
||||
provider: "claude_managed",
|
||||
model: "claude-sonnet-5",
|
||||
managedProfileId: "qualified",
|
||||
maxSessionListCostUsd: 1,
|
||||
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.goto("/#/chat");
|
||||
await expect(page.locator('[data-thread-state="settled"]')).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("agent-chip")).toHaveText("Real Claude Agent");
|
||||
await expect(page.getByTestId("chat-active-harness")).toContainText("Anthropic cloud · no provider PID");
|
||||
await expect(page.getByTestId("chat-managed-increase-budget")).toBeVisible();
|
||||
await expect(page.getByTestId("chat-managed-delete-session")).toBeVisible();
|
||||
await expect(page.getByTestId("chat-managed-retention-notice")).toContainText("not eligible for ZDR or HIPAA");
|
||||
await expect(page.locator("body")).not.toContainText("Codex is thinking");
|
||||
});
|
||||
|
||||
test("evidence stays collapsed until it is asked for", async ({ page }) => {
|
||||
await openCleanRoom(page);
|
||||
|
||||
@@ -829,9 +866,9 @@ test.describe("Capability clean-room chat", () => {
|
||||
|
||||
await history.getByRole("button", { name: /MCK-1000/ }).click();
|
||||
await expect(page.locator(".pit-identifier")).toHaveText("MCK-1000");
|
||||
await expect(page.getByTestId("composer-reason")).toHaveText("Historical session is read-only");
|
||||
await expect(page.locator("#composer-input")).toBeEnabled();
|
||||
|
||||
await history.getByRole("button", { name: /MCK-2000.*current/ }).click();
|
||||
await history.getByRole("button", { name: /MCK-2000/ }).click();
|
||||
await expect(page.locator(".pit-identifier")).toHaveText("MCK-2000");
|
||||
await expect(page.locator("#composer-input")).toBeEnabled();
|
||||
});
|
||||
@@ -852,7 +889,7 @@ test.describe("Capability clean-room chat", () => {
|
||||
|
||||
const error = page.getByTestId("surface-error");
|
||||
await expect(error).toBeVisible();
|
||||
await expect(error).toContainText("could not start a real Codex session");
|
||||
await expect(error).toContainText("could not start the selected provider");
|
||||
await expect(page.getByTestId("surface-error-retry")).toBeVisible();
|
||||
await expect(page.locator("[data-turn-id]")).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -48,12 +48,13 @@ const REPLAY_STEP_MS = 800;
|
||||
const PENDING_ANNOUNCEMENT = "A request is waiting for your answer.";
|
||||
const ANSWERED_ANNOUNCEMENT = "Your answer was recorded.";
|
||||
const SETTLED_ANNOUNCEMENT = "The pending request is resolved.";
|
||||
const SCENARIOS = ["hb-baseline", "dp-documents", "ix-interactions", "ar-artifacts", "wc-workspace-changes", "fr-file-reference"];
|
||||
const SCENARIOS = ["hb-baseline", "dp-documents", "ix-interactions", "ar-artifacts", "wc-workspace-changes", "fr-file-reference", "pe-structured-plan", "te-command-execution", "mp-mcp-progress", "rs-web-research", "da-subagent-delegation", "mr-model-routing", "cc-context-compaction", "ag-generated-artifact", "rv-review-mode", "hk-hook-lifecycle", "mc-memory-citations", "sr-safety-review", "ti-terminal-input", "wt-intentional-wait", "pn-provider-notices", "cm-stream-reconcile", "cm-session-continuity", "cm-tool-read", "cm-tool-mutation", "cm-completion-control", "cm-tool-denied", "cm-tool-duplicate-delivery", "cm-sse-recovery", "cm-interrupt", "cm-warm-restore", "cm-budget", "cm-terminated-deleted", "cm-provisioning-auth", "cm-redacted-failure", "cm-provider-labels"];
|
||||
const CHAT_HISTORY_KEY = "paperclip-runner.capability.chat.history.v1";
|
||||
const CHAT_HARNESS_KEY = "paperclip-runner.capability.chat.harness.v1";
|
||||
const MODEL_PRESETS = {
|
||||
codex: ["gpt-5.4-mini", "gpt-5.4"],
|
||||
opencode: ["openrouter/deepseek/deepseek-v4-flash-0731"],
|
||||
claude_managed: ["claude-sonnet-5"],
|
||||
} as const;
|
||||
|
||||
interface EmbeddedEvalCheck {
|
||||
@@ -118,6 +119,9 @@ function defaultHarness(provider: CapabilityHarnessConfiguration["provider"] = "
|
||||
return {
|
||||
provider,
|
||||
model: MODEL_PRESETS[provider][0],
|
||||
...(provider === "claude_managed"
|
||||
? { managedProfileId: "default", maxSessionListCostUsd: 1 }
|
||||
: {}),
|
||||
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 },
|
||||
};
|
||||
}
|
||||
@@ -125,13 +129,25 @@ function defaultHarness(provider: CapabilityHarnessConfiguration["provider"] = "
|
||||
function readHarnessConfiguration(): CapabilityHarnessConfiguration {
|
||||
try {
|
||||
const value = JSON.parse(window.localStorage.getItem(CHAT_HARNESS_KEY) ?? "null") as Partial<CapabilityHarnessConfiguration> | null;
|
||||
if (value && (value.provider === "codex" || value.provider === "opencode") && typeof value.model === "string") {
|
||||
if (value && (value.provider === "codex" || value.provider === "opencode" || value.provider === "claude_managed") && typeof value.model === "string") {
|
||||
const lifecyclePolicy = value.lifecyclePolicy?.mode === "per_turn"
|
||||
? { mode: "per_turn" as const, idleTimeoutMs: null }
|
||||
: value.lifecyclePolicy?.mode === "warm" && Number.isSafeInteger(value.lifecyclePolicy.idleTimeoutMs) && Number(value.lifecyclePolicy.idleTimeoutMs) > 0
|
||||
? { mode: "warm" as const, idleTimeoutMs: Number(value.lifecyclePolicy.idleTimeoutMs) }
|
||||
: { mode: "warm" as const, idleTimeoutMs: 300_000 };
|
||||
return { provider: value.provider, model: value.model, lifecyclePolicy };
|
||||
return {
|
||||
provider: value.provider,
|
||||
model: value.model,
|
||||
...(value.provider === "claude_managed" ? {
|
||||
managedProfileId: typeof value.managedProfileId === "string" && value.managedProfileId.trim()
|
||||
? value.managedProfileId.trim()
|
||||
: "default",
|
||||
maxSessionListCostUsd: typeof value.maxSessionListCostUsd === "number" && value.maxSessionListCostUsd > 0
|
||||
? value.maxSessionListCostUsd
|
||||
: 1,
|
||||
} : {}),
|
||||
lifecyclePolicy,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Invalid preferences fall back to the qualified defaults.
|
||||
@@ -224,7 +240,7 @@ function currentTurnActivity(snapshot: CapabilityIssueThreadSnapshot): string {
|
||||
const turn = snapshot.turns.at(-1);
|
||||
if (turn === undefined) return `Dispatching turn to ${harness}`;
|
||||
const activity = [...turn.items].reverse().find((item) =>
|
||||
item.kind === "tool_activity" || item.kind === "progress_activity" ||
|
||||
item.kind === "tool_activity" || item.kind === "progress_activity" || item.kind === "provider_activity" ||
|
||||
(item.kind === "agent_message" && item.streaming),
|
||||
);
|
||||
if (activity?.kind === "tool_activity") {
|
||||
@@ -315,6 +331,7 @@ export function App() {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
/** True while a turn stream is open, so the surface never reads as settled. */
|
||||
const [streamingTurn, setStreamingTurn] = useState(false);
|
||||
const [managedBudgetInput, setManagedBudgetInput] = useState("1.00");
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const cancelResetRef = useRef<HTMLButtonElement | null>(null);
|
||||
/** Last announced pending request, so the live region tracks transitions. */
|
||||
@@ -728,6 +745,16 @@ export function App() {
|
||||
setActionError("OpenCode models must use provider/model form.");
|
||||
return;
|
||||
}
|
||||
if (harness.provider === "claude_managed") {
|
||||
if (!harness.managedProfileId?.trim()) {
|
||||
setActionError("Choose a qualified Managed Agent profile before starting a Claude Agent chat.");
|
||||
return;
|
||||
}
|
||||
if (!(typeof harness.maxSessionListCostUsd === "number" && harness.maxSessionListCostUsd > 0)) {
|
||||
setActionError("Claude Agent chats require a positive session spend ceiling.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const configuration = { ...harness, model };
|
||||
persistHarnessConfiguration(configuration);
|
||||
setConfirmReset(false);
|
||||
@@ -741,6 +768,46 @@ export function App() {
|
||||
.catch((cause) => setActionError(describe(cause)));
|
||||
}, [abandonTurn, adoptCleanRoom, harness, snapshot]);
|
||||
|
||||
const increaseManagedBudget = useCallback(() => {
|
||||
if (snapshot === null || activeHarness?.provider !== "claude_managed") return;
|
||||
const nextCap = Number(managedBudgetInput);
|
||||
const currentCap = activeHarness.maxSessionListCostUsd ?? 0;
|
||||
if (!Number.isFinite(nextCap) || nextCap <= currentCap) {
|
||||
setActionError(`Enter a spend ceiling greater than the current $${currentCap.toFixed(2)} cap.`);
|
||||
return;
|
||||
}
|
||||
void capabilityLiveClient.increaseManagedBudget(snapshot.sessionId, nextCap)
|
||||
.then((next) => {
|
||||
setSnapshot(next.view);
|
||||
setActiveHarness((current) => current === null ? current : {
|
||||
...current,
|
||||
maxSessionListCostUsd: nextCap,
|
||||
});
|
||||
setActionError(null);
|
||||
setAnnouncement(`Claude Agent session spend ceiling raised to $${nextCap.toFixed(2)}.`);
|
||||
})
|
||||
.catch((cause) => setActionError(describe(cause)));
|
||||
}, [activeHarness, managedBudgetInput, snapshot]);
|
||||
|
||||
const deleteManagedSession = useCallback(() => {
|
||||
if (snapshot === null || activeHarness?.provider !== "claude_managed") return;
|
||||
if (!window.confirm("Permanently delete this Anthropic Managed Agent session? It cannot be resumed.")) return;
|
||||
abandonTurn();
|
||||
const deletedSessionId = snapshot.sessionId;
|
||||
void capabilityLiveClient.deleteManagedSession(deletedSessionId)
|
||||
.then(() => capabilityLiveClient.newCleanRoom(null, harness))
|
||||
.then((next) => {
|
||||
setChatHistory((current) => {
|
||||
const retained = current.filter((item) => item.sessionId !== deletedSessionId);
|
||||
persistChatHistory(retained);
|
||||
return retained;
|
||||
});
|
||||
adoptCleanRoom(next);
|
||||
setAnnouncement("Remote Claude Agent session deleted. A new clean room is ready.");
|
||||
})
|
||||
.catch((cause) => setActionError(describe(cause)));
|
||||
}, [abandonTurn, activeHarness, adoptCleanRoom, harness, snapshot]);
|
||||
|
||||
const selectChatHistory = useCallback((sessionId: string) => {
|
||||
const live = liveRoomRef.current;
|
||||
if (live?.sessionId === sessionId) {
|
||||
@@ -997,6 +1064,7 @@ export function App() {
|
||||
>
|
||||
<option value="codex">Codex</option>
|
||||
<option value="opencode">OpenCode</option>
|
||||
<option value="claude_managed">Claude Agent</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
@@ -1036,6 +1104,38 @@ export function App() {
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{harness.provider === "claude_managed" || activeHarness?.provider === "claude_managed" ? (
|
||||
<>
|
||||
<label>
|
||||
<span>Managed profile</span>
|
||||
<input
|
||||
data-testid="chat-managed-profile"
|
||||
value={harness.managedProfileId ?? ""}
|
||||
disabled={streamingTurn}
|
||||
placeholder="qualified profile ID"
|
||||
onChange={(event) => setHarness((current) => ({
|
||||
...current,
|
||||
managedProfileId: event.target.value,
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Session cap (USD)</span>
|
||||
<input
|
||||
data-testid="chat-managed-spend-cap"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value={harness.maxSessionListCostUsd ?? 1}
|
||||
disabled={streamingTurn}
|
||||
onChange={(event) => setHarness((current) => ({
|
||||
...current,
|
||||
maxSessionListCostUsd: Number(event.target.value),
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
<label className="pit-harness-model">
|
||||
<span>Model</span>
|
||||
<input
|
||||
@@ -1061,10 +1161,51 @@ export function App() {
|
||||
Start new chat
|
||||
</button>
|
||||
<span className="pit-harness-active" data-testid="chat-active-harness">
|
||||
Active: {activeHarness?.provider ?? "starting"}
|
||||
Active: {activeHarness?.provider === "claude_managed" ? "Claude Agent" : activeHarness?.provider ?? "starting"}
|
||||
{activeHarness?.model ? ` · ${activeHarness.model}` : ""}
|
||||
{activeHarness?.provider === "claude_managed" ? " · Anthropic cloud · no provider PID" : ""}
|
||||
{activeHarness ? ` · ${activeHarness.lifecyclePolicy.mode === "warm" ? `warm ${Math.round(activeHarness.lifecyclePolicy.idleTimeoutMs / 1_000)}s` : "turn by turn"}` : ""}
|
||||
</span>
|
||||
{activeHarness?.provider === "claude_managed" && snapshot !== null && historicSessionId === null ? (
|
||||
<div className="pit-managed-session-actions" aria-label="Active Claude Agent session controls">
|
||||
<label>
|
||||
<span>Raise active cap (USD)</span>
|
||||
<input
|
||||
data-testid="chat-managed-active-spend-cap"
|
||||
type="number"
|
||||
min={(activeHarness.maxSessionListCostUsd ?? 1) + 0.01}
|
||||
step="0.01"
|
||||
value={managedBudgetInput}
|
||||
disabled={streamingTurn}
|
||||
onChange={(event) => setManagedBudgetInput(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="pit-button"
|
||||
data-testid="chat-managed-increase-budget"
|
||||
disabled={streamingTurn}
|
||||
onClick={increaseManagedBudget}
|
||||
>
|
||||
Raise cap
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pit-button"
|
||||
data-variant="danger"
|
||||
data-testid="chat-managed-delete-session"
|
||||
disabled={streamingTurn}
|
||||
onClick={deleteManagedSession}
|
||||
>
|
||||
Delete remote session
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{harness.provider === "claude_managed" || activeHarness?.provider === "claude_managed" ? (
|
||||
<p className="pit-harness-notice" role="note" data-testid="chat-managed-retention-notice">
|
||||
Managed Agent sessions are retained by Anthropic and are not eligible for ZDR or HIPAA modes. Paperclip tools still execute only in runnerd.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -1169,6 +1310,8 @@ export function App() {
|
||||
<div
|
||||
className="pit-thread-scroll"
|
||||
ref={scrollRef}
|
||||
tabIndex={0}
|
||||
aria-label="Conversation"
|
||||
onScroll={(event) => {
|
||||
const element = event.currentTarget;
|
||||
const distance =
|
||||
|
||||
@@ -150,6 +150,36 @@ function ProgressItem({
|
||||
);
|
||||
}
|
||||
|
||||
function valueRecord(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function ProviderActivity({ item, callbacks }: {
|
||||
item: Extract<CapabilityThreadItem, { kind: "provider_activity" }>;
|
||||
callbacks: ThreadCallbacks;
|
||||
}) {
|
||||
const payload = valueRecord(item.payload);
|
||||
const steps = Array.isArray(payload.steps) ? payload.steps.map(valueRecord) : [];
|
||||
const children = Array.isArray(payload.children) ? payload.children.map(valueRecord) : [];
|
||||
const sources = Array.isArray(payload.sources) ? payload.sources.map(valueRecord) : [];
|
||||
const output = typeof payload.output === "string" ? payload.output : "";
|
||||
const glyph = item.status === "running" ? "⏳" : item.status === "failed" ? "✕" : item.status === "interrupted" ? "■" : "✓";
|
||||
return (
|
||||
<details id={item.id} className="pit-activity-item pit-provider-activity" data-thread-item="provider_activity" data-family={item.family} data-status={item.status} open={item.status === "running"}>
|
||||
<summary className="pit-activity-summary"><span className="pit-activity-glyph" aria-hidden="true">{glyph}</span><span className="pit-activity-operation">{item.title}</span><span className="pit-activity-description">{item.summary}</span><span className="pit-activity-caret" aria-hidden="true">›</span></summary>
|
||||
<div className="pit-activity-detail">
|
||||
{steps.length > 0 ? <ol className="pit-provider-plan">{steps.map((step, index) => <li key={String(step.stepId ?? index)} data-plan-status={String(step.status ?? "pending")}><span aria-hidden="true">{step.status === "completed" ? "✓" : step.status === "blocked" ? "!" : step.status === "in_progress" ? "◐" : "○"}</span>{String(step.body ?? "")}</li>)}</ol> : null}
|
||||
{children.length > 0 ? <ul className="pit-provider-tree">{children.map((child, index) => <li key={String(child.childId ?? index)}><strong>{String(child.role ?? "Child agent")}</strong><span>{String(child.status ?? "unknown")}</span><small>{String(child.summary ?? "")}</small></li>)}</ul> : null}
|
||||
{sources.length > 0 ? <ul className="pit-provider-sources">{sources.map((source, index) => { const url = typeof source.url === "string" && /^https?:\/\//.test(source.url) ? source.url : null; return <li key={String(source.sourceId ?? index)}>{url === null ? <span>{String(source.title ?? "Unavailable source")}</span> : <a href={url} target="_blank" rel="noreferrer">{String(source.title ?? url)}</a>}<small>Provider-reported</small></li>; })}</ul> : null}
|
||||
{output.length > 0 ? <pre className="pit-provider-output" aria-label="Command output">{output}</pre> : null}
|
||||
{item.family === "model_identity" ? <dl className="pit-provider-fields"><dt>Requested</dt><dd>{String(payload.requestedModel ?? "—")}</dd><dt>Effective</dt><dd>{String(payload.effectiveModel ?? "—")}</dd></dl> : null}
|
||||
{item.family === "artifact" && typeof payload.reference === "string" ? <button type="button" className="pit-link-button">Open {payload.reference}</button> : null}
|
||||
<button type="button" className="pit-link-button" onClick={() => callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)}>View in Evidence</button>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function FileDiff({ diff }: { diff: string | null }) {
|
||||
if (diff === null) return <p className="pit-workspace-binary">Binary or oversized file; textual diff unavailable.</p>;
|
||||
return (
|
||||
@@ -377,6 +407,9 @@ function ThreadItemView({
|
||||
case "workspace_file_reference":
|
||||
return <WorkspaceFileReference item={item} />;
|
||||
|
||||
case "provider_activity":
|
||||
return <ProviderActivity item={item} callbacks={callbacks} />;
|
||||
|
||||
case "denial":
|
||||
return (
|
||||
<div id={item.id} className="pit-strip pit-denial" data-status="denied" data-thread-item="denial">
|
||||
|
||||
@@ -1054,6 +1054,20 @@ textarea {
|
||||
font-size: var(--pit-text-xs);
|
||||
}
|
||||
|
||||
.pit-provider-activity[data-family="plan"] { border-left: 2px solid var(--pit-accent); }
|
||||
.pit-provider-activity[data-status="failed"] { border-left: 2px solid var(--pit-danger); }
|
||||
.pit-provider-plan, .pit-provider-tree, .pit-provider-sources { margin: 0 0 12px; padding-left: 22px; display: grid; gap: 8px; }
|
||||
.pit-provider-plan li { display: grid; grid-template-columns: 18px 1fr; gap: 6px; }
|
||||
.pit-provider-plan li[data-plan-status="blocked"] { color: var(--pit-danger); }
|
||||
.pit-provider-tree li { display: grid; grid-template-columns: minmax(100px, 1fr) auto; gap: 4px 12px; }
|
||||
.pit-provider-tree small { grid-column: 1 / -1; color: var(--pit-muted-foreground); }
|
||||
.pit-provider-sources li { display: flex; align-items: baseline; gap: 8px; }
|
||||
.pit-provider-sources small { color: var(--pit-muted-foreground); }
|
||||
.pit-provider-output { max-height: 260px; overflow: auto; margin: 0 0 12px; padding: 12px; white-space: pre-wrap; background: var(--pit-muted); border-radius: 6px; }
|
||||
.pit-provider-fields { display: grid; grid-template-columns: min-content 1fr; gap: 6px 12px; margin: 0 0 12px; }
|
||||
.pit-provider-fields dt { color: var(--pit-muted-foreground); }
|
||||
.pit-provider-fields dd { margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.pit-activity-detail p {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -2414,6 +2428,37 @@ textarea {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pit-harness-notice {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
color: var(--pit-faint-foreground);
|
||||
font-size: var(--pit-text-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.pit-managed-session-actions {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--pit-border);
|
||||
}
|
||||
|
||||
.pit-managed-session-actions label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pit-managed-session-actions label > span {
|
||||
color: var(--pit-muted-foreground);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.pit-managed-session-actions input {
|
||||
width: 118px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.pit-harness-picker {
|
||||
align-items: stretch;
|
||||
|
||||
@@ -42,8 +42,10 @@ export interface CapabilityCleanRoomIdentity {
|
||||
}
|
||||
|
||||
export interface CapabilityHarnessConfiguration {
|
||||
provider: "codex" | "opencode";
|
||||
provider: "codex" | "opencode" | "claude_managed";
|
||||
model: string | null;
|
||||
managedProfileId?: string | null;
|
||||
maxSessionListCostUsd?: number | null;
|
||||
lifecyclePolicy:
|
||||
| { mode: "per_turn"; idleTimeoutMs: null }
|
||||
| { mode: "warm"; idleTimeoutMs: number };
|
||||
@@ -93,7 +95,7 @@ async function readError(response: Response, path: string): Promise<CapabilityLi
|
||||
}
|
||||
}
|
||||
|
||||
async function post(path: string, body: unknown): Promise<CapabilityLiveResponse> {
|
||||
async function post<T = CapabilityLiveResponse>(path: string, body: unknown): Promise<T> {
|
||||
const response = await fetch(`${BASE}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -101,7 +103,7 @@ async function post(path: string, body: unknown): Promise<CapabilityLiveResponse
|
||||
credentials: CREDENTIALS,
|
||||
});
|
||||
if (!response.ok) throw await readError(response, path);
|
||||
return (await response.json()) as CapabilityLiveResponse;
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
/** Interim projection delivered while the turn is still running. */
|
||||
@@ -173,6 +175,10 @@ export const capabilityLiveClient = {
|
||||
const query = new URLSearchParams({ provider: configuration.provider });
|
||||
if (sessionId !== null) query.set("sessionId", sessionId);
|
||||
if (configuration.model !== null) query.set("model", configuration.model);
|
||||
if (configuration.managedProfileId) query.set("managedProfileId", configuration.managedProfileId);
|
||||
if (configuration.maxSessionListCostUsd !== null && configuration.maxSessionListCostUsd !== undefined) {
|
||||
query.set("maxSessionListCostUsd", String(configuration.maxSessionListCostUsd));
|
||||
}
|
||||
query.set("lifecycleMode", configuration.lifecyclePolicy.mode);
|
||||
if (configuration.lifecyclePolicy.mode === "warm") {
|
||||
query.set("idleTimeoutMs", String(configuration.lifecyclePolicy.idleTimeoutMs));
|
||||
@@ -199,6 +205,12 @@ export const capabilityLiveClient = {
|
||||
stop(sessionId: string): Promise<CapabilityLiveResponse> {
|
||||
return post("/interrupt", { sessionId });
|
||||
},
|
||||
increaseManagedBudget(sessionId: string, maxSessionListCostUsd: number): Promise<CapabilityLiveResponse> {
|
||||
return post("/managed-budget", { sessionId, maxSessionListCostUsd });
|
||||
},
|
||||
deleteManagedSession(sessionId: string): Promise<{ deleted: true; sessionId: string }> {
|
||||
return post<{ deleted: true; sessionId: string }>("/managed-session-delete", { sessionId, confirm: true });
|
||||
},
|
||||
reset(sessionId: string): Promise<CapabilityLiveResponse> {
|
||||
return post("/reset", { sessionId });
|
||||
},
|
||||
|
||||
@@ -34,6 +34,21 @@
|
||||
"runtimeRequests": { "type": "boolean" },
|
||||
"structuredResult": { "type": "boolean" },
|
||||
"typedEvents": { "type": "boolean" },
|
||||
"typedEventFamilies": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["family", "version", "availability", "detailLevel"],
|
||||
"properties": {
|
||||
"family": { "type": "string", "minLength": 1, "maxLength": 160, "pattern": "^[a-z][a-z0-9_.-]*$" },
|
||||
"version": { "const": 1 },
|
||||
"availability": { "enum": ["available", "unsupported", "policy_disabled"] },
|
||||
"detailLevel": { "enum": ["summary", "structured"] }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"semanticTools": {
|
||||
"type": "object",
|
||||
"required": ["schema", "schemaVersion", "operations"],
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"semantic_tool.result",
|
||||
"session.snapshot",
|
||||
"session.close",
|
||||
"session.budget.increase",
|
||||
"session.destroy",
|
||||
"run.cancel",
|
||||
"runner.drain",
|
||||
"runner.suspend",
|
||||
|
||||
@@ -31,9 +31,19 @@
|
||||
"runner.connected", "runner.reconnected", "runner.reconciled", "runner.disconnected", "runner.draining", "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",
|
||||
"session.starting", "session.started", "session.resuming", "session.resumed", "session.reconciled", "session.closed", "session.failed",
|
||||
"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",
|
||||
"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",
|
||||
"item.started", "item.delta", "item.completed", "item.failed", "usage.reported",
|
||||
"semantic_tool.input", "semantic_tool.result",
|
||||
"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",
|
||||
@@ -59,6 +69,22 @@
|
||||
"debug": { "type": "object", "additionalProperties": true }
|
||||
},
|
||||
"allOf": [
|
||||
{ "if": { "properties": { "eventType": { "const": "plan.updated" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/plan" } } } },
|
||||
{ "if": { "properties": { "eventType": { "enum": ["tool.execution.started", "tool.execution.progressed", "tool.execution.completed"] } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/toolExecution" } } } },
|
||||
{ "if": { "properties": { "eventType": { "enum": ["research.started", "research.progressed", "research.completed"] } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/research" } } } },
|
||||
{ "if": { "properties": { "eventType": { "enum": ["delegation.started", "delegation.updated", "delegation.completed"] } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/delegation" } } } },
|
||||
{ "if": { "properties": { "eventType": { "const": "model.route.changed" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/modelRoute" } } } },
|
||||
{ "if": { "properties": { "eventType": { "const": "model.verification.updated" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/modelVerification" } } } },
|
||||
{ "if": { "properties": { "eventType": { "const": "context.compacted" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/contextCompacted" } } } },
|
||||
{ "if": { "properties": { "eventType": { "const": "artifact.viewed" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/artifactViewed" } } } },
|
||||
{ "if": { "properties": { "eventType": { "const": "artifact.generated" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/artifactGenerated" } } } },
|
||||
{ "if": { "properties": { "eventType": { "const": "review.mode.changed" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/reviewMode" } } } },
|
||||
{ "if": { "properties": { "eventType": { "enum": ["hook.started", "hook.completed"] } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/hook" } } } },
|
||||
{ "if": { "properties": { "eventType": { "const": "memory.citation.referenced" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/memoryCitation" } } } },
|
||||
{ "if": { "properties": { "eventType": { "enum": ["safety.review.started", "safety.review.completed"] } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/safetyReview" } } } },
|
||||
{ "if": { "properties": { "eventType": { "const": "terminal.input.sent" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/terminalInput" } } } },
|
||||
{ "if": { "properties": { "eventType": { "enum": ["wait.started", "wait.completed"] } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/wait" } } } },
|
||||
{ "if": { "properties": { "eventType": { "const": "provider.notice.recorded" } } }, "then": { "properties": { "payload": { "$ref": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json#/$defs/providerNotice" } } } },
|
||||
{
|
||||
"if": { "properties": { "eventType": { "const": "workspace.file.referenced" } } },
|
||||
"then": {
|
||||
@@ -89,7 +115,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": { "properties": { "eventType": { "const": "mcp_app.tool_input" } } },
|
||||
"if": { "properties": { "eventType": { "enum": ["semantic_tool.input", "mcp_app.tool_input"] } } },
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
@@ -108,7 +134,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": { "properties": { "eventType": { "const": "mcp_app.tool_result" } } },
|
||||
"if": { "properties": { "eventType": { "enum": ["semantic_tool.result", "mcp_app.tool_result"] } } },
|
||||
"then": {
|
||||
"properties": {
|
||||
"payload": {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json",
|
||||
"title": "PRP provider runtime descriptor",
|
||||
"type": "object",
|
||||
"required": ["provider", "driver", "model", "executionKind", "providerVersion"],
|
||||
"properties": {
|
||||
"provider": { "enum": ["codex", "opencode", "claude_managed"] },
|
||||
"driver": { "enum": ["codex_app_server", "opencode_server", "claude_managed_agents_api"] },
|
||||
"model": { "type": ["string", "null"], "maxLength": 240 },
|
||||
"executionKind": { "enum": ["local_process", "remote_service"] },
|
||||
"providerVersion": { "type": "string", "minLength": 1, "maxLength": 120 },
|
||||
"service": { "enum": ["anthropic_managed_agents"] },
|
||||
"providerSessionId": { "type": ["string", "null"], "maxLength": 240 },
|
||||
"processId": { "type": ["integer", "null"], "minimum": 1 }
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": { "properties": { "executionKind": { "const": "remote_service" } } },
|
||||
"then": {
|
||||
"required": ["service"],
|
||||
"properties": { "processId": { "const": null } }
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": { "properties": { "executionKind": { "const": "local_process" } } },
|
||||
"then": { "properties": { "service": false } }
|
||||
}
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://paperclip.dev/schemas/prp/v1/provider-event.schema.json",
|
||||
"title": "Provider-neutral runner activity payload",
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/plan" },
|
||||
{ "$ref": "#/$defs/toolExecution" },
|
||||
{ "$ref": "#/$defs/research" },
|
||||
{ "$ref": "#/$defs/delegation" },
|
||||
{ "$ref": "#/$defs/modelRoute" },
|
||||
{ "$ref": "#/$defs/modelVerification" },
|
||||
{ "$ref": "#/$defs/contextCompacted" },
|
||||
{ "$ref": "#/$defs/artifactViewed" },
|
||||
{ "$ref": "#/$defs/artifactGenerated" },
|
||||
{ "$ref": "#/$defs/reviewMode" },
|
||||
{ "$ref": "#/$defs/hook" },
|
||||
{ "$ref": "#/$defs/memoryCitation" },
|
||||
{ "$ref": "#/$defs/safetyReview" },
|
||||
{ "$ref": "#/$defs/terminalInput" },
|
||||
{ "$ref": "#/$defs/wait" },
|
||||
{ "$ref": "#/$defs/providerNotice" }
|
||||
],
|
||||
"$defs": {
|
||||
"id": { "type": "string", "minLength": 1, "maxLength": 160, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" },
|
||||
"shortText": { "type": "string", "maxLength": 4000 },
|
||||
"nullableShortText": { "type": ["string", "null"], "maxLength": 4000 },
|
||||
"safePath": { "type": ["string", "null"], "maxLength": 4096, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).*$" },
|
||||
"safeUrl": { "type": ["string", "null"], "maxLength": 8192, "pattern": "^https?://" },
|
||||
"plan": {
|
||||
"type": "object",
|
||||
"required": ["schema", "planId", "revision", "steps", "complete", "syncStatus"],
|
||||
"properties": {
|
||||
"schema": { "const": "paperclip.plan.updated.v1" },
|
||||
"planId": { "$ref": "#/$defs/id" },
|
||||
"revision": { "type": "integer", "minimum": 1 },
|
||||
"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 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"toolExecution": {
|
||||
"type": "object",
|
||||
"required": ["schema", "executionId", "transport", "operation", "status", "output", "outputBytes", "outputTruncated", "outputDigest"],
|
||||
"properties": {
|
||||
"schema": { "const": "paperclip.tool.execution.v1" },
|
||||
"executionId": { "$ref": "#/$defs/id" },
|
||||
"transport": { "enum": ["process", "mcp", "dynamic", "builtin"] },
|
||||
"operation": { "enum": ["read", "search", "list", "execute", "edit", "unknown"] },
|
||||
"name": { "$ref": "#/$defs/nullableShortText" },
|
||||
"target": { "$ref": "#/$defs/safePath" },
|
||||
"namespace": { "type": ["string", "null"], "maxLength": 240 },
|
||||
"readOnly": { "type": ["boolean", "null"] },
|
||||
"status": { "enum": ["running", "completed", "failed", "cancelled", "interrupted"] },
|
||||
"durationMs": { "type": ["integer", "null"], "minimum": 0 },
|
||||
"exitCode": { "type": ["integer", "null"] },
|
||||
"progress": { "$ref": "#/$defs/nullableShortText" },
|
||||
"output": { "type": ["string", "null"], "maxLength": 65536 },
|
||||
"outputBytes": { "type": "integer", "minimum": 0 },
|
||||
"outputTruncated": { "type": "boolean" },
|
||||
"outputDigest": { "type": ["string", "null"], "pattern": "^sha256:[a-f0-9]{64}$" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"research": {
|
||||
"type": "object",
|
||||
"required": ["schema", "researchId", "action", "status", "sources"],
|
||||
"properties": {
|
||||
"schema": { "const": "paperclip.research.v1" },
|
||||
"researchId": { "$ref": "#/$defs/id" },
|
||||
"action": { "enum": ["search", "open_page", "find_in_page", "other"] },
|
||||
"status": { "enum": ["running", "completed", "failed", "cancelled"] },
|
||||
"query": { "$ref": "#/$defs/nullableShortText" },
|
||||
"url": { "$ref": "#/$defs/safeUrl" },
|
||||
"pattern": { "$ref": "#/$defs/nullableShortText" },
|
||||
"sources": { "type": "array", "maxItems": 64, "items": { "type": "object", "required": ["sourceId", "title", "url", "snippet"], "properties": { "sourceId": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/shortText" }, "url": { "$ref": "#/$defs/safeUrl" }, "snippet": { "$ref": "#/$defs/nullableShortText" } }, "additionalProperties": false } }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"delegation": {
|
||||
"type": "object",
|
||||
"required": ["schema", "delegationId", "action", "status", "children"],
|
||||
"properties": {
|
||||
"schema": { "const": "paperclip.delegation.v1" },
|
||||
"delegationId": { "$ref": "#/$defs/id" },
|
||||
"action": { "enum": ["spawn", "message", "resume", "wait", "close"] },
|
||||
"status": { "enum": ["running", "waiting", "completed", "failed", "interrupted", "closed"] },
|
||||
"children": { "type": "array", "maxItems": 64, "items": { "type": "object", "required": ["childId", "role", "model", "status", "summary", "activitySummary"], "properties": { "childId": { "$ref": "#/$defs/id" }, "role": { "type": ["string", "null"], "maxLength": 160 }, "model": { "type": ["string", "null"], "maxLength": 240 }, "status": { "enum": ["running", "waiting", "completed", "failed", "interrupted", "closed"] }, "summary": { "$ref": "#/$defs/nullableShortText" }, "activitySummary": { "$ref": "#/$defs/nullableShortText" } }, "additionalProperties": false } }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"modelRoute": {
|
||||
"type": "object",
|
||||
"required": ["schema", "routeId", "provider", "requestedModel", "fromModel", "effectiveModel", "reason"],
|
||||
"properties": { "schema": { "const": "paperclip.model.route_changed.v1" }, "routeId": { "$ref": "#/$defs/id" }, "provider": { "type": "string", "minLength": 1, "maxLength": 160 }, "requestedModel": { "type": "string", "minLength": 1, "maxLength": 240 }, "fromModel": { "type": ["string", "null"], "maxLength": 240 }, "effectiveModel": { "type": "string", "minLength": 1, "maxLength": 240 }, "reason": { "$ref": "#/$defs/shortText" } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"modelVerification": {
|
||||
"type": "object",
|
||||
"required": ["schema", "verificationId", "status", "classes", "buffering", "summary"],
|
||||
"properties": { "schema": { "const": "paperclip.model.verification.v1" }, "verificationId": { "$ref": "#/$defs/id" }, "status": { "enum": ["running", "completed", "failed"] }, "classes": { "type": "array", "maxItems": 32, "items": { "type": "string", "maxLength": 160 } }, "buffering": { "type": "boolean" }, "summary": { "$ref": "#/$defs/nullableShortText" } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"contextCompacted": {
|
||||
"type": "object",
|
||||
"required": ["schema", "compactionId", "reason", "preTokens", "postTokens", "sameSession"],
|
||||
"properties": { "schema": { "const": "paperclip.context.compacted.v1" }, "compactionId": { "$ref": "#/$defs/id" }, "reason": { "enum": ["context_window", "manual", "provider", "unknown"] }, "preTokens": { "type": ["integer", "null"], "minimum": 0 }, "postTokens": { "type": ["integer", "null"], "minimum": 0 }, "sameSession": { "type": "boolean" } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"artifactViewed": {
|
||||
"type": "object",
|
||||
"required": ["schema", "artifactId", "reference", "mediaType", "title"],
|
||||
"properties": { "schema": { "const": "paperclip.artifact.viewed.v1" }, "artifactId": { "$ref": "#/$defs/id" }, "reference": { "$ref": "#/$defs/safePath" }, "mediaType": { "type": ["string", "null"], "maxLength": 160 }, "title": { "$ref": "#/$defs/nullableShortText" } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"artifactGenerated": {
|
||||
"type": "object",
|
||||
"required": ["schema", "artifactId", "status", "reference", "mediaType", "registered", "failure"],
|
||||
"properties": { "schema": { "const": "paperclip.artifact.generated.v1" }, "artifactId": { "$ref": "#/$defs/id" }, "status": { "enum": ["running", "completed", "failed"] }, "reference": { "$ref": "#/$defs/safePath" }, "mediaType": { "type": ["string", "null"], "maxLength": 160 }, "registered": { "type": "boolean" }, "transparentBackground": { "type": ["boolean", "null"] }, "failure": { "$ref": "#/$defs/nullableShortText" } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"reviewMode": {
|
||||
"type": "object",
|
||||
"required": ["schema", "reviewId", "state", "scope"],
|
||||
"properties": { "schema": { "const": "paperclip.review.mode_changed.v1" }, "reviewId": { "$ref": "#/$defs/id" }, "state": { "enum": ["entered", "exited"] }, "scope": { "$ref": "#/$defs/nullableShortText" } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"hook": {
|
||||
"type": "object",
|
||||
"required": ["schema", "hookId", "event", "scope", "status", "blocking", "durationMs", "summary"],
|
||||
"properties": { "schema": { "const": "paperclip.hook.v1" }, "hookId": { "$ref": "#/$defs/id" }, "event": { "type": "string", "minLength": 1, "maxLength": 160 }, "scope": { "type": "string", "minLength": 1, "maxLength": 160 }, "status": { "enum": ["running", "completed", "failed", "cancelled"] }, "blocking": { "type": "boolean" }, "durationMs": { "type": ["integer", "null"], "minimum": 0 }, "summary": { "$ref": "#/$defs/nullableShortText" } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"memoryCitation": {
|
||||
"type": "object",
|
||||
"required": ["schema", "citationId", "messageItemId", "label", "available", "reference"],
|
||||
"properties": { "schema": { "const": "paperclip.memory.citation.v1" }, "citationId": { "$ref": "#/$defs/id" }, "messageItemId": { "$ref": "#/$defs/id" }, "label": { "$ref": "#/$defs/shortText" }, "available": { "type": "boolean" }, "reference": { "type": ["string", "null"], "maxLength": 4096 } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"safetyReview": {
|
||||
"type": "object",
|
||||
"required": ["schema", "reviewId", "targetExecutionId", "status", "decision", "summary"],
|
||||
"properties": { "schema": { "const": "paperclip.safety.review.v1" }, "reviewId": { "$ref": "#/$defs/id" }, "targetExecutionId": { "anyOf": [{ "$ref": "#/$defs/id" }, { "type": "null" }] }, "status": { "enum": ["running", "completed", "failed"] }, "decision": { "enum": ["pending", "allowed", "denied", "user_required", "unknown"] }, "summary": { "$ref": "#/$defs/nullableShortText" } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"terminalInput": {
|
||||
"type": "object",
|
||||
"required": ["schema", "executionId", "origin", "inputClass", "byteCount"],
|
||||
"properties": { "schema": { "const": "paperclip.terminal.input_sent.v1" }, "executionId": { "$ref": "#/$defs/id" }, "origin": { "enum": ["agent", "user", "system"] }, "inputClass": { "enum": ["text", "control", "eof"] }, "byteCount": { "type": "integer", "minimum": 0 } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"wait": {
|
||||
"type": "object",
|
||||
"required": ["schema", "waitId", "reason", "status", "plannedDurationMs", "elapsedDurationMs"],
|
||||
"properties": { "schema": { "const": "paperclip.wait.v1" }, "waitId": { "$ref": "#/$defs/id" }, "reason": { "enum": ["timer", "provider", "rate_limit", "unknown"] }, "status": { "enum": ["running", "completed", "interrupted", "cancelled"] }, "plannedDurationMs": { "type": ["integer", "null"], "minimum": 0 }, "elapsedDurationMs": { "type": ["integer", "null"], "minimum": 0 } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"providerNotice": {
|
||||
"type": "object",
|
||||
"required": ["schema", "noticeId", "severity", "category", "scope", "recoverable", "userActionable", "summary"],
|
||||
"properties": { "schema": { "const": "paperclip.provider.notice.v1" }, "noticeId": { "$ref": "#/$defs/id" }, "severity": { "enum": ["info", "warning", "error"] }, "category": { "type": "string", "minLength": 1, "maxLength": 160 }, "scope": { "enum": ["turn", "session", "environment", "account"] }, "recoverable": { "type": "boolean" }, "userActionable": { "type": "boolean" }, "summary": { "$ref": "#/$defs/shortText" } },
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://paperclip.dev/schemas/prp/v1/usage.schema.json",
|
||||
"title": "PRP provider usage receipt",
|
||||
"type": "object",
|
||||
"required": ["provider", "model", "cumulative", "runDelta"],
|
||||
"properties": {
|
||||
"provider": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"model": { "type": ["string", "null"], "maxLength": 240 },
|
||||
"providerSessionId": { "type": ["string", "null"], "maxLength": 240 },
|
||||
"providerRequestId": { "type": ["string", "null"], "maxLength": 240 },
|
||||
"cumulative": { "$ref": "#/$defs/measurement" },
|
||||
"runDelta": { "$ref": "#/$defs/measurement" }
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"measurement": {
|
||||
"type": "object",
|
||||
"required": ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "activeSeconds", "requests", "providerCostUsd"],
|
||||
"properties": {
|
||||
"inputTokens": { "type": "integer", "minimum": 0 },
|
||||
"outputTokens": { "type": "integer", "minimum": 0 },
|
||||
"cacheReadTokens": { "type": "integer", "minimum": 0 },
|
||||
"cacheWriteTokens": { "type": "integer", "minimum": 0 },
|
||||
"activeSeconds": { "type": "number", "minimum": 0 },
|
||||
"requests": { "type": "integer", "minimum": 0 },
|
||||
"providerCostUsd": { "type": "number", "minimum": 0 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
+1636
-6
File diff suppressed because it is too large.
Load diff
@@ -14,3 +14,5 @@ hmac = "0.12"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sha2 = "0.10"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
jsonschema = { version = "0.50", default-features = false }
|
||||
@@ -11,6 +11,8 @@ hmac.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
reqwest.workspace = true
|
||||
jsonschema.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "conformance-tracer"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -30,11 +30,12 @@ pub enum ProviderKind {
|
||||
#[default]
|
||||
Codex,
|
||||
Opencode,
|
||||
ClaudeManaged,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodexProviderConfig {
|
||||
pub struct LocalProviderConfig {
|
||||
#[serde(default)]
|
||||
pub kind: ProviderKind,
|
||||
pub command: PathBuf,
|
||||
@@ -45,21 +46,137 @@ pub struct CodexProviderConfig {
|
||||
pub instructions: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClaudeManagedProviderConfig {
|
||||
pub model: String,
|
||||
pub profile_id: String,
|
||||
pub anthropic_agent_id: String,
|
||||
pub agent_version: String,
|
||||
pub environment_id: String,
|
||||
pub beta_version: String,
|
||||
pub max_session_list_cost_usd: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ProviderConfig {
|
||||
Local(LocalProviderConfig),
|
||||
ClaudeManaged(ClaudeManagedProviderConfig),
|
||||
}
|
||||
|
||||
impl ProviderConfig {
|
||||
pub fn kind(&self) -> ProviderKind {
|
||||
match self {
|
||||
Self::Local(config) => config.kind,
|
||||
Self::ClaudeManaged(_) => ProviderKind::ClaudeManaged,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_cwd(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Local(config) => Some(&config.cwd),
|
||||
Self::ClaudeManaged(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ProviderConfig {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
match self {
|
||||
Self::Local(config) => config.serialize(serializer),
|
||||
Self::ClaudeManaged(config) => {
|
||||
let mut value = serde_json::to_value(config).map_err(serde::ser::Error::custom)?;
|
||||
value
|
||||
.as_object_mut()
|
||||
.expect("managed config serializes as an object")
|
||||
.insert(
|
||||
"kind".to_owned(),
|
||||
Value::String("claude_managed".to_owned()),
|
||||
);
|
||||
value.serialize(serializer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ProviderConfig {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let mut value = Value::deserialize(deserializer)?;
|
||||
let kind = value.get("kind").and_then(Value::as_str).unwrap_or("codex");
|
||||
if kind == "claude_managed" {
|
||||
value
|
||||
.as_object_mut()
|
||||
.expect("provider config must be an object")
|
||||
.remove("kind");
|
||||
serde_json::from_value(value)
|
||||
.map(Self::ClaudeManaged)
|
||||
.map_err(serde::de::Error::custom)
|
||||
} else {
|
||||
serde_json::from_value(value)
|
||||
.map(Self::Local)
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "executionKind")]
|
||||
pub enum ProviderRuntimeIdentity {
|
||||
#[serde(rename = "local_process")]
|
||||
LocalProcess {
|
||||
process_id: u32,
|
||||
provider_session_id: String,
|
||||
},
|
||||
#[serde(rename = "remote_service")]
|
||||
RemoteService {
|
||||
service: String,
|
||||
provider_session_id: String,
|
||||
process_id: Option<u32>,
|
||||
},
|
||||
}
|
||||
|
||||
pub trait Provider {
|
||||
fn kind(&self) -> ProviderKind;
|
||||
fn process_id(&self) -> u32;
|
||||
fn runtime_identity(&self) -> ProviderRuntimeIdentity;
|
||||
fn session_identity(&self) -> &str;
|
||||
fn provider_session_id(&self) -> Option<&str>;
|
||||
fn start_turn(&mut self, message: &str, cwd: &str) -> Result<Value, LocalRunnerError>;
|
||||
fn durable_event_cursor(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
fn configure_tools(&mut self, _tools: Vec<AuthorizedTool>) -> Result<(), LocalRunnerError> {
|
||||
Ok(())
|
||||
}
|
||||
fn increase_budget(&mut self, _max_list_cost_usd: f64) -> Result<Value, LocalRunnerError> {
|
||||
Err(LocalRunnerError::invalid(
|
||||
"provider does not support a remote session budget",
|
||||
))
|
||||
}
|
||||
fn destroy_session(&mut self) -> Result<(), LocalRunnerError> {
|
||||
Err(LocalRunnerError::invalid(
|
||||
"provider does not support remote session deletion",
|
||||
))
|
||||
}
|
||||
fn start_turn(
|
||||
&mut self,
|
||||
message: &str,
|
||||
cwd: &str,
|
||||
turn_id: &str,
|
||||
) -> Result<Value, LocalRunnerError>;
|
||||
fn interrupt_turn(&mut self, turn_id: &str) -> Result<Value, LocalRunnerError>;
|
||||
fn read(&mut self) -> Result<Value, LocalRunnerError>;
|
||||
fn poll(&mut self) -> Result<Option<CodexProviderEvent>, LocalRunnerError>;
|
||||
fn poll(&mut self) -> Result<Option<ProviderEvent>, LocalRunnerError>;
|
||||
fn deliver_tool_result(&mut self, result: &ToolResult) -> Result<(), LocalRunnerError>;
|
||||
fn shutdown(&mut self) -> Result<(), LocalRunnerError>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum CodexProviderEvent {
|
||||
pub enum ProviderEvent {
|
||||
ToolCall {
|
||||
call_id: String,
|
||||
operation_id: String,
|
||||
@@ -91,7 +208,7 @@ impl CodexProvider {
|
||||
}
|
||||
|
||||
pub fn start(
|
||||
config: &CodexProviderConfig,
|
||||
config: &LocalProviderConfig,
|
||||
tools: impl Iterator<Item = AuthorizedTool>,
|
||||
resume_thread_id: Option<&str>,
|
||||
) -> Result<Self, LocalRunnerError> {
|
||||
@@ -213,10 +330,10 @@ impl CodexProvider {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn poll(&mut self) -> Result<Option<CodexProviderEvent>, LocalRunnerError> {
|
||||
pub fn poll(&mut self) -> Result<Option<ProviderEvent>, LocalRunnerError> {
|
||||
let Some(line) = self.process.receive_stdout_line(Duration::from_millis(1))? else {
|
||||
return if self.process.try_wait()?.is_some() {
|
||||
Ok(Some(CodexProviderEvent::Exited))
|
||||
Ok(Some(ProviderEvent::Exited))
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
@@ -245,7 +362,7 @@ impl CodexProvider {
|
||||
}
|
||||
self.pending_tool_requests
|
||||
.insert(call_id.clone(), message["id"].clone());
|
||||
return Ok(Some(CodexProviderEvent::ToolCall {
|
||||
return Ok(Some(ProviderEvent::ToolCall {
|
||||
call_id,
|
||||
operation_id,
|
||||
input: params.get("arguments").cloned().unwrap_or(Value::Null),
|
||||
@@ -257,7 +374,7 @@ impl CodexProvider {
|
||||
let result = params.get("result").cloned().ok_or_else(|| {
|
||||
LocalRunnerError::invalid("OpenCode semantic result omitted result")
|
||||
})?;
|
||||
return Ok(Some(CodexProviderEvent::SemanticResult {
|
||||
return Ok(Some(ProviderEvent::SemanticResult {
|
||||
result,
|
||||
item_id: params
|
||||
.get("itemId")
|
||||
@@ -265,7 +382,7 @@ impl CodexProvider {
|
||||
.map(str::to_owned),
|
||||
}));
|
||||
}
|
||||
return Ok(Some(CodexProviderEvent::Notification {
|
||||
return Ok(Some(ProviderEvent::Notification {
|
||||
method: method.to_owned(),
|
||||
params: message.get("params").cloned().unwrap_or(Value::Null),
|
||||
}));
|
||||
@@ -276,17 +393,20 @@ impl CodexProvider {
|
||||
pub fn deliver_tool_result(&mut self, result: &ToolResult) -> Result<(), LocalRunnerError> {
|
||||
let request_id = self
|
||||
.pending_tool_requests
|
||||
.remove(&result.call_id)
|
||||
.get(&result.call_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
LocalRunnerError::invalid("Codex tool result has no pending JSON-RPC request")
|
||||
})?;
|
||||
self.process.send(&json!({
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"success": true,
|
||||
"success": !result.is_error,
|
||||
"contentItems": [{"type": "inputText", "text": serde_json::to_string(&result.result).unwrap_or_else(|_| "null".to_owned())}]
|
||||
}
|
||||
}))
|
||||
}))?;
|
||||
self.pending_tool_requests.remove(&result.call_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops the provider process group at an explicit runner lifecycle boundary.
|
||||
@@ -328,8 +448,11 @@ impl Provider for CodexProvider {
|
||||
fn kind(&self) -> ProviderKind {
|
||||
self.kind
|
||||
}
|
||||
fn process_id(&self) -> u32 {
|
||||
CodexProvider::process_id(self)
|
||||
fn runtime_identity(&self) -> ProviderRuntimeIdentity {
|
||||
ProviderRuntimeIdentity::LocalProcess {
|
||||
process_id: CodexProvider::process_id(self),
|
||||
provider_session_id: self.thread_id().to_owned(),
|
||||
}
|
||||
}
|
||||
fn session_identity(&self) -> &str {
|
||||
self.thread_id()
|
||||
@@ -337,7 +460,12 @@ impl Provider for CodexProvider {
|
||||
fn provider_session_id(&self) -> Option<&str> {
|
||||
self.session_id()
|
||||
}
|
||||
fn start_turn(&mut self, message: &str, cwd: &str) -> Result<Value, LocalRunnerError> {
|
||||
fn start_turn(
|
||||
&mut self,
|
||||
message: &str,
|
||||
cwd: &str,
|
||||
_turn_id: &str,
|
||||
) -> Result<Value, LocalRunnerError> {
|
||||
CodexProvider::start_turn(self, message, cwd)
|
||||
}
|
||||
fn interrupt_turn(&mut self, turn_id: &str) -> Result<Value, LocalRunnerError> {
|
||||
@@ -346,7 +474,7 @@ impl Provider for CodexProvider {
|
||||
fn read(&mut self) -> Result<Value, LocalRunnerError> {
|
||||
self.read_thread()
|
||||
}
|
||||
fn poll(&mut self) -> Result<Option<CodexProviderEvent>, LocalRunnerError> {
|
||||
fn poll(&mut self) -> Result<Option<ProviderEvent>, LocalRunnerError> {
|
||||
CodexProvider::poll(self)
|
||||
}
|
||||
fn deliver_tool_result(&mut self, result: &ToolResult) -> Result<(), LocalRunnerError> {
|
||||
|
||||
@@ -32,14 +32,15 @@ pub fn run_durable_runner(
|
||||
};
|
||||
if recovered {
|
||||
if let Some(runtime) = provider.as_ref() {
|
||||
let descriptor = provider_descriptor(state.provider_config.as_ref(), Some(runtime.as_ref()));
|
||||
enqueue_event(
|
||||
&mut state,
|
||||
&config,
|
||||
"provider.started",
|
||||
"harness.ready",
|
||||
0,
|
||||
json!({
|
||||
"provider": match runtime.kind() { crate::codex_provider::ProviderKind::Codex => "codex", crate::codex_provider::ProviderKind::Opencode => "opencode" },
|
||||
"pid": runtime.process_id(),
|
||||
"providerDescriptor": descriptor,
|
||||
"runtimeIdentity": runtime.runtime_identity(),
|
||||
"threadId": runtime.session_identity(),
|
||||
"sessionId": runtime.provider_session_id(),
|
||||
"resumed": true,
|
||||
@@ -82,7 +83,9 @@ pub fn run_durable_runner(
|
||||
if started.elapsed() >= config.max_runtime && !state.active_turn {
|
||||
state.recoverable_failure = Some("transport_reconnect_deadline_exceeded".to_owned());
|
||||
state.lifecycle = "recoverable_failure".to_owned();
|
||||
state.record_diagnostic("transport reconnect deadline exceeded before a safe rotation could flush");
|
||||
state.record_diagnostic(
|
||||
"transport reconnect deadline exceeded before a safe rotation could flush",
|
||||
);
|
||||
store.save(&state)?;
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"transport reconnect deadline exceeded; durable state is preserved",
|
||||
@@ -240,7 +243,13 @@ pub fn run_durable_runner(
|
||||
)?;
|
||||
}
|
||||
if should_suspend_for_idle(&state)? {
|
||||
begin_automatic_suspend(&mut state, &store, &config, &mut provider, "idle_timeout")?;
|
||||
begin_automatic_suspend(
|
||||
&mut state,
|
||||
&store,
|
||||
&config,
|
||||
&mut provider,
|
||||
"idle_timeout",
|
||||
)?;
|
||||
}
|
||||
if provider.is_some() {
|
||||
send_outbox(&mut client, &state, &mut sent_source_seq)?;
|
||||
@@ -248,7 +257,9 @@ pub fn run_durable_runner(
|
||||
if (state.stop_after_flush || state.lifecycle == "revoked") && state.outbox.is_empty() {
|
||||
if let Some(mut runtime) = provider.take() {
|
||||
runtime.shutdown().map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("failed to stop provider during durable exit: {error}"))
|
||||
DurableRunnerError::invalid(format!(
|
||||
"failed to stop provider during durable exit: {error}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
state.lifecycle = if state.lifecycle == "revoked" {
|
||||
@@ -336,7 +347,9 @@ pub fn run_durable_runner(
|
||||
state.last_activity_at_unix_ms = current_unix_ms()?;
|
||||
let delivery = client
|
||||
.send_json(&command_result_envelope(&state, &processed))
|
||||
.and_then(|()| send_outbox(&mut client, &state, &mut sent_source_seq));
|
||||
.and_then(|()| {
|
||||
send_outbox(&mut client, &state, &mut sent_source_seq)
|
||||
});
|
||||
if let Err(error) = delivery {
|
||||
state.record_diagnostic(error.to_string());
|
||||
if state.lifecycle == "revoked" {
|
||||
@@ -385,7 +398,9 @@ pub fn run_durable_runner(
|
||||
// only the still-unacknowledged suffix once as part of the bounded
|
||||
// revoke drain, rather than on every transport poll.
|
||||
sent_source_seq = state.acked_source_seq;
|
||||
if let Err(error) = send_outbox(&mut client, &state, &mut sent_source_seq) {
|
||||
if let Err(error) =
|
||||
send_outbox(&mut client, &state, &mut sent_source_seq)
|
||||
{
|
||||
state.record_diagnostic(error.to_string());
|
||||
store.save(&state)?;
|
||||
connection_lease_token.take();
|
||||
@@ -452,18 +467,90 @@ fn start_configured_provider(
|
||||
.authorized_tools()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
crate::codex_provider::CodexProvider::start(
|
||||
&provider_config,
|
||||
tools.into_iter(),
|
||||
resume_thread_id.as_deref(),
|
||||
)
|
||||
.map(|provider| {
|
||||
state.provider_session_id = Some(provider.thread_id().to_owned());
|
||||
Some(Box::new(provider) as Box<dyn crate::codex_provider::Provider>)
|
||||
})
|
||||
.map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("failed to start Codex provider: {error}"))
|
||||
})
|
||||
let runtime: Box<dyn crate::codex_provider::Provider> = match provider_config {
|
||||
crate::codex_provider::ProviderConfig::Local(local) => Box::new(
|
||||
crate::codex_provider::CodexProvider::start(
|
||||
&local,
|
||||
tools.into_iter(),
|
||||
resume_thread_id.as_deref(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("failed to start local provider: {error}"))
|
||||
})?,
|
||||
),
|
||||
crate::codex_provider::ProviderConfig::ClaudeManaged(mut managed) => Box::new(
|
||||
{
|
||||
if let Some(value) = state.provider_budget_ceiling_usd {
|
||||
managed.max_session_list_cost_usd = value;
|
||||
}
|
||||
crate::claude_managed_provider::ClaudeManagedProvider::start(
|
||||
&managed,
|
||||
tools,
|
||||
resume_thread_id.as_deref(),
|
||||
state.provider_event_cursor.as_deref(),
|
||||
state.provider_usage_cumulative.as_ref()
|
||||
.and_then(|value| value.get("requests"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0),
|
||||
)
|
||||
.map_err(|error| {
|
||||
DurableRunnerError::invalid(format!(
|
||||
"failed to start Claude Agent provider: {error}"
|
||||
))
|
||||
})?
|
||||
},
|
||||
),
|
||||
};
|
||||
state.provider_session_id = Some(runtime.session_identity().to_owned());
|
||||
Ok(Some(runtime))
|
||||
}
|
||||
|
||||
fn provider_kind_name(kind: crate::codex_provider::ProviderKind) -> &'static str {
|
||||
match kind {
|
||||
crate::codex_provider::ProviderKind::Codex => "codex",
|
||||
crate::codex_provider::ProviderKind::Opencode => "opencode",
|
||||
crate::codex_provider::ProviderKind::ClaudeManaged => "claude_managed",
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_descriptor(
|
||||
config: Option<&crate::codex_provider::ProviderConfig>,
|
||||
runtime: Option<&dyn crate::codex_provider::Provider>,
|
||||
) -> Value {
|
||||
let runtime_identity = runtime.map(|value| value.runtime_identity());
|
||||
match config {
|
||||
Some(crate::codex_provider::ProviderConfig::ClaudeManaged(value)) => json!({
|
||||
"provider": "claude_managed",
|
||||
"driver": "claude_managed_agents_api",
|
||||
"model": value.model,
|
||||
"executionKind": "remote_service",
|
||||
"providerVersion": value.agent_version,
|
||||
"service": "anthropic_managed_agents",
|
||||
"providerSessionId": runtime.and_then(|item| item.provider_session_id()),
|
||||
"processId": Value::Null,
|
||||
}),
|
||||
Some(crate::codex_provider::ProviderConfig::Local(value)) => json!({
|
||||
"provider": provider_kind_name(value.kind),
|
||||
"driver": if value.kind == crate::codex_provider::ProviderKind::Opencode { "opencode_server" } else { "codex_app_server" },
|
||||
"model": value.model,
|
||||
"executionKind": "local_process",
|
||||
"providerVersion": if value.kind == crate::codex_provider::ProviderKind::Opencode { "1.18.17" } else { "unqualified" },
|
||||
"providerSessionId": runtime.and_then(|item| item.provider_session_id()),
|
||||
"processId": match runtime_identity {
|
||||
Some(crate::codex_provider::ProviderRuntimeIdentity::LocalProcess { process_id, .. }) => Some(process_id),
|
||||
_ => None,
|
||||
},
|
||||
}),
|
||||
None => json!({
|
||||
"provider": "codex",
|
||||
"driver": "codex_app_server",
|
||||
"model": Value::Null,
|
||||
"executionKind": "local_process",
|
||||
"providerVersion": "unqualified",
|
||||
"providerSessionId": Value::Null,
|
||||
"processId": Value::Null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn process_command_and_provider(
|
||||
@@ -478,23 +565,62 @@ fn process_command_and_provider(
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let fresh = !state.processed_commands.contains_key(command_id);
|
||||
let before = state.clone();
|
||||
let processed = process_command(state, store, config, command)?;
|
||||
if !fresh || processed.status != "completed" {
|
||||
if !fresh
|
||||
&& processed.status == "completed"
|
||||
&& command.get("type").and_then(Value::as_str) == Some("semantic_tool.result")
|
||||
&& provider.as_ref().is_some_and(|runtime| runtime.kind() == crate::codex_provider::ProviderKind::ClaudeManaged)
|
||||
{
|
||||
let result: crate::provider_bridge::ToolResult = serde_json::from_value(command["payload"].clone())
|
||||
.map_err(|error| DurableRunnerError::invalid(format!("semantic tool result is invalid: {error}")))?;
|
||||
provider.as_mut().expect("checked above").deliver_tool_result(&result)
|
||||
.map_err(|error| DurableRunnerError::invalid(format!("failed to reconcile Claude Agent tool result: {error}")))?;
|
||||
}
|
||||
return Ok(processed);
|
||||
}
|
||||
match command.get("type").and_then(Value::as_str) {
|
||||
Some("run.prepare") => {
|
||||
if provider.is_none() {
|
||||
*provider = start_configured_provider(state)?;
|
||||
*provider = match start_configured_provider(state) {
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
let detail = error.to_string();
|
||||
*state = before;
|
||||
state.lifecycle = "recoverable_failure".to_owned();
|
||||
state.recoverable_failure = Some(detail.clone());
|
||||
state.record_diagnostic(detail.clone());
|
||||
let mut failed = processed;
|
||||
failed.status = "failed".to_owned();
|
||||
failed.logical_effect_count = 0;
|
||||
failed.result = sanitize_value(&json!({
|
||||
"commandId": failed.command_id.clone(),
|
||||
"controllerSeq": failed.controller_seq,
|
||||
"status": "failed",
|
||||
"logicalEffectCount": 0,
|
||||
"detail": detail,
|
||||
}));
|
||||
state.last_controller_command_seq = failed.controller_seq;
|
||||
state.processed_commands.insert(
|
||||
failed.command_id.clone(),
|
||||
failed.clone(),
|
||||
);
|
||||
compact_processed_commands(state)?;
|
||||
store.save(state)?;
|
||||
return Ok(failed);
|
||||
}
|
||||
};
|
||||
if let Some(runtime) = provider.as_ref() {
|
||||
let descriptor = provider_descriptor(state.provider_config.as_ref(), Some(runtime.as_ref()));
|
||||
enqueue_event(
|
||||
state,
|
||||
config,
|
||||
"provider.started",
|
||||
"harness.ready",
|
||||
0,
|
||||
json!({
|
||||
"provider": match runtime.kind() { crate::codex_provider::ProviderKind::Codex => "codex", crate::codex_provider::ProviderKind::Opencode => "opencode" },
|
||||
"pid": runtime.process_id(),
|
||||
"providerDescriptor": descriptor,
|
||||
"runtimeIdentity": runtime.runtime_identity(),
|
||||
"threadId": runtime.session_identity(),
|
||||
"sessionId": runtime.provider_session_id(),
|
||||
"resumed": state.reconnect_count > 0,
|
||||
@@ -520,13 +646,30 @@ fn process_command_and_provider(
|
||||
let cwd = state
|
||||
.provider_config
|
||||
.as_ref()
|
||||
.map(|value| value.cwd.as_str())
|
||||
.and_then(|value| value.local_cwd())
|
||||
.unwrap_or(".");
|
||||
runtime.start_turn(text, cwd).map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("Codex turn.start failed: {error}"))
|
||||
let turn_id = state.turn_id.clone();
|
||||
runtime.start_turn(text, cwd, &turn_id).map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("provider turn.start failed: {error}"))
|
||||
})?;
|
||||
}
|
||||
Some("run.attach") => {}
|
||||
Some("run.attach") => {
|
||||
if let Some(runtime) = provider.as_mut() {
|
||||
runtime
|
||||
.configure_tools(
|
||||
state
|
||||
.provider_tool_bridge
|
||||
.authorized_tools()
|
||||
.cloned()
|
||||
.collect(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
*state = before;
|
||||
let _ = store.save(state);
|
||||
DurableRunnerError::invalid(format!("failed to replace provider tools: {error}"))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Some("semantic_tool.result") => {
|
||||
let result: crate::provider_bridge::ToolResult =
|
||||
serde_json::from_value(command["payload"].clone()).map_err(|error| {
|
||||
@@ -540,7 +683,7 @@ fn process_command_and_provider(
|
||||
.deliver_tool_result(&result)
|
||||
.map_err(|error| {
|
||||
DurableRunnerError::invalid(format!(
|
||||
"failed to return tool result to Codex: {error}"
|
||||
"failed to return tool result to provider: {error}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
@@ -556,7 +699,7 @@ fn process_command_and_provider(
|
||||
.ok_or_else(|| DurableRunnerError::invalid("turn.interrupt requires a provider"))?
|
||||
.interrupt_turn(turn_id)
|
||||
.map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("Codex turn.interrupt failed: {error}"))
|
||||
DurableRunnerError::invalid(format!("provider turn.interrupt failed: {error}"))
|
||||
})?;
|
||||
}
|
||||
Some("provider.thread.read") => {
|
||||
@@ -567,7 +710,7 @@ fn process_command_and_provider(
|
||||
})?
|
||||
.read()
|
||||
.map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("Codex thread/read failed: {error}"))
|
||||
DurableRunnerError::invalid(format!("provider session read failed: {error}"))
|
||||
})?;
|
||||
enqueue_event(
|
||||
state,
|
||||
@@ -579,11 +722,68 @@ fn process_command_and_provider(
|
||||
)?;
|
||||
store.save(state)?;
|
||||
}
|
||||
Some("session.budget.increase") => {
|
||||
let value = command.pointer("/payload/maxSessionListCostUsd").and_then(Value::as_f64)
|
||||
.ok_or_else(|| DurableRunnerError::invalid(
|
||||
"session.budget.increase payload.maxSessionListCostUsd is required",
|
||||
))?;
|
||||
provider.as_mut()
|
||||
.ok_or_else(|| DurableRunnerError::invalid("session budget increase requires a provider"))?
|
||||
.increase_budget(value)
|
||||
.map_err(|error| {
|
||||
*state = before.clone();
|
||||
let _ = store.save(state);
|
||||
DurableRunnerError::invalid(format!("failed to increase remote session budget: {error}"))
|
||||
})?;
|
||||
enqueue_event(
|
||||
state,
|
||||
config,
|
||||
"session.updated",
|
||||
0,
|
||||
json!({
|
||||
"update": "budget_increased",
|
||||
"maxSessionListCostUsd": value,
|
||||
}),
|
||||
None,
|
||||
)?;
|
||||
state.provider_budget_ceiling_usd = Some(value);
|
||||
state.lifecycle = if state.active_turn {
|
||||
"active".to_owned()
|
||||
} else {
|
||||
"warm_idle".to_owned()
|
||||
};
|
||||
store.save(state)?;
|
||||
}
|
||||
Some("session.destroy") => {
|
||||
let mut runtime = provider.take()
|
||||
.ok_or_else(|| DurableRunnerError::invalid("session.destroy requires a provider"))?;
|
||||
if let Err(error) = runtime.destroy_session() {
|
||||
*provider = Some(runtime);
|
||||
*state = before;
|
||||
let _ = store.save(state);
|
||||
return Err(DurableRunnerError::invalid(format!(
|
||||
"failed to delete remote provider session: {error}"
|
||||
)));
|
||||
}
|
||||
state.provider_session_id = None;
|
||||
state.provider_event_cursor = None;
|
||||
state.lifecycle = "stopped".to_owned();
|
||||
state.stop_after_flush = true;
|
||||
enqueue_event(
|
||||
state,
|
||||
config,
|
||||
"session.closed",
|
||||
0,
|
||||
json!({ "remoteDeleted": true, "resumable": false }),
|
||||
None,
|
||||
)?;
|
||||
store.save(state)?;
|
||||
}
|
||||
Some("runner.shutdown") | Some("runner.suspend") => {
|
||||
if let Some(mut runtime) = provider.take() {
|
||||
runtime.shutdown().map_err(|error| {
|
||||
DurableRunnerError::invalid(format!(
|
||||
"failed to stop Codex provider during runner shutdown: {error}"
|
||||
"failed to stop provider during runner shutdown: {error}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
@@ -610,12 +810,12 @@ fn poll_provider(
|
||||
let detail = error.to_string();
|
||||
persist_provider_failure(state, store, &detail)?;
|
||||
return Err(DurableRunnerError::invalid(format!(
|
||||
"Codex provider failed: {detail}"
|
||||
"provider failed: {detail}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
match event {
|
||||
crate::codex_provider::CodexProviderEvent::ToolCall {
|
||||
crate::codex_provider::ProviderEvent::ToolCall {
|
||||
call_id,
|
||||
operation_id,
|
||||
input,
|
||||
@@ -630,7 +830,7 @@ fn poll_provider(
|
||||
enqueue_event(
|
||||
state,
|
||||
config,
|
||||
"mcp_app.tool_input",
|
||||
"semantic_tool.input",
|
||||
0,
|
||||
json!({
|
||||
"semantic_tool": {
|
||||
@@ -643,28 +843,69 @@ fn poll_provider(
|
||||
)?;
|
||||
store.save(state)?;
|
||||
}
|
||||
crate::codex_provider::CodexProviderEvent::Notification { method, params } => {
|
||||
crate::codex_provider::ProviderEvent::Notification { method, params } => {
|
||||
state.last_activity_at_unix_ms = current_unix_ms()?;
|
||||
let event_type = if method == "turn/completed" {
|
||||
"turn.completed"
|
||||
} else {
|
||||
"provider.event"
|
||||
for (canonical_type, canonical_payload, canonical_item_id) in
|
||||
crate::provider_events::canonical_provider_events(&method, ¶ms)
|
||||
{
|
||||
enqueue_event(
|
||||
state,
|
||||
config,
|
||||
&canonical_type,
|
||||
if canonical_type.ends_with("completed") {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
},
|
||||
canonical_payload,
|
||||
Some(&canonical_item_id),
|
||||
)?;
|
||||
}
|
||||
let event_type = match method.as_str() {
|
||||
"turn/started" => "turn.started",
|
||||
"turn/completed" => match params.pointer("/turn/status").and_then(Value::as_str) {
|
||||
Some("failed") => "turn.failed",
|
||||
Some("interrupted") => "turn.interrupted",
|
||||
Some("cancelled" | "canceled") => "turn.cancelled",
|
||||
_ => "turn.completed",
|
||||
},
|
||||
"item/started" => "item.started",
|
||||
"item/delta" => "item.delta",
|
||||
"item/completed" => "item.completed",
|
||||
"thread/tokenUsage/updated" => "usage.reported",
|
||||
"provider/budgetReached" => "session.updated",
|
||||
_ => "harness.diagnostic",
|
||||
};
|
||||
// Streaming deltas and startup chatter are intentionally coalescible,
|
||||
// but terminal items and accounting must survive pressure intact.
|
||||
let priority = match method.as_str() {
|
||||
"turn/completed" | "thread/tokenUsage/updated" => 0,
|
||||
"item/completed" => 1,
|
||||
"turn/started" | "item/completed" => 1,
|
||||
_ => 2,
|
||||
};
|
||||
enqueue_event(
|
||||
state,
|
||||
config,
|
||||
event_type,
|
||||
priority,
|
||||
json!({"method": method, "params": params}),
|
||||
Some(&state.item_id.clone()),
|
||||
)?;
|
||||
let payload = if method == "thread/tokenUsage/updated" {
|
||||
let cumulative = normalized_usage_measurement(¶ms);
|
||||
let baseline = state.provider_usage_run_baseline.as_ref().map(normalized_usage_measurement)
|
||||
.unwrap_or_else(empty_usage_measurement);
|
||||
let run_delta = usage_delta(&cumulative, &baseline);
|
||||
state.provider_usage_cumulative = Some(cumulative.clone());
|
||||
json!({
|
||||
"provider": provider_kind_name(runtime.kind()),
|
||||
"model": state.provider_config.as_ref().and_then(provider_model),
|
||||
"providerSessionId": runtime.provider_session_id(),
|
||||
"providerRequestId": params.get("request_id").or_else(|| params.get("requestId")).and_then(Value::as_str),
|
||||
"cumulative": cumulative,
|
||||
"runDelta": run_delta,
|
||||
})
|
||||
} else if matches!(method.as_str(), "turn/started" | "item/started" | "item/delta" | "item/completed" | "turn/completed" | "provider/budgetReached") {
|
||||
params.clone()
|
||||
} else {
|
||||
json!({ "providerMethod": method, "detail": "provider event was not part of the normalized public contract" })
|
||||
};
|
||||
enqueue_event(state, config, event_type, priority, payload, Some(&state.item_id.clone()))?;
|
||||
if method == "provider/budgetReached" {
|
||||
state.lifecycle = "waiting_input".to_owned();
|
||||
}
|
||||
if method == "turn/completed" {
|
||||
state.active_turn = false;
|
||||
if state.lifecycle_mode == "warm" {
|
||||
@@ -684,10 +925,7 @@ fn poll_provider(
|
||||
}
|
||||
store.save(state)?;
|
||||
}
|
||||
crate::codex_provider::CodexProviderEvent::SemanticResult {
|
||||
result,
|
||||
item_id,
|
||||
} => {
|
||||
crate::codex_provider::ProviderEvent::SemanticResult { result, item_id } => {
|
||||
state.last_activity_at_unix_ms = current_unix_ms()?;
|
||||
enqueue_event(
|
||||
state,
|
||||
@@ -699,22 +937,101 @@ fn poll_provider(
|
||||
)?;
|
||||
store.save(state)?;
|
||||
}
|
||||
crate::codex_provider::CodexProviderEvent::Exited => {
|
||||
persist_provider_failure(state, store, "Codex provider exited unexpectedly")?;
|
||||
crate::codex_provider::ProviderEvent::Exited => {
|
||||
persist_provider_failure(state, store, "local provider exited unexpectedly")?;
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"native_runner_process_exited: Codex provider exited unexpectedly",
|
||||
"native_runner_process_exited: local provider exited unexpectedly",
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(cursor) = runtime.durable_event_cursor() {
|
||||
state.provider_event_cursor = Some(cursor.to_owned());
|
||||
store.save(state)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn provider_model(config: &crate::codex_provider::ProviderConfig) -> Option<&str> {
|
||||
match config {
|
||||
crate::codex_provider::ProviderConfig::Local(value) => value.model.as_deref(),
|
||||
crate::codex_provider::ProviderConfig::ClaudeManaged(value) => Some(value.model.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_usage_measurement() -> Value {
|
||||
json!({
|
||||
"inputTokens": 0, "outputTokens": 0, "cacheReadTokens": 0,
|
||||
"cacheWriteTokens": 0, "reasoningTokens": 0, "activeSeconds": 0.0, "requests": 0,
|
||||
"providerCostUsd": 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
fn usage_number(value: &Value, snake: &str, camel: &str) -> f64 {
|
||||
value.get(snake).or_else(|| value.get(camel)).and_then(Value::as_f64).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn normalized_usage_measurement(value: &Value) -> Value {
|
||||
let usage = value.get("usage")
|
||||
.or_else(|| value.pointer("/tokenUsage/total"))
|
||||
.or_else(|| value.get("tokenUsage"))
|
||||
.unwrap_or(value);
|
||||
let cost = usage.get("list_cost").or_else(|| usage.get("listCost"));
|
||||
// Managed Agents reports list cost as an integer number of US cents.
|
||||
// Local providers already publish providerCostUsd as dollars.
|
||||
let provider_cost = cost.and_then(|value| value.get("amount")).and_then(|value| {
|
||||
value.as_f64().or_else(|| value.as_str().and_then(|text| text.parse().ok()))
|
||||
}).map(|cents| cents / 100.0)
|
||||
.unwrap_or_else(|| {
|
||||
let normalized = usage_number(usage, "provider_cost_usd", "providerCostUsd");
|
||||
if normalized > 0.0 { normalized } else { usage_number(usage, "cost_usd", "costUsd") }
|
||||
});
|
||||
let cache_creation = usage.get("cache_creation").or_else(|| usage.get("cacheCreation"));
|
||||
let cache_write_tokens = usage_number(usage, "cache_creation_input_tokens", "cacheWriteTokens")
|
||||
+ cache_creation.map(|value| {
|
||||
usage_number(value, "ephemeral_5m_input_tokens", "ephemeral5mInputTokens")
|
||||
+ usage_number(value, "ephemeral_1h_input_tokens", "ephemeral1hInputTokens")
|
||||
}).unwrap_or(0.0);
|
||||
json!({
|
||||
"inputTokens": usage_number(usage, "input_tokens", "inputTokens") as u64,
|
||||
"outputTokens": usage_number(usage, "output_tokens", "outputTokens") as u64,
|
||||
"cacheReadTokens": ({
|
||||
let normalized = usage_number(usage, "cache_read_input_tokens", "cacheReadTokens");
|
||||
if normalized > 0.0 { normalized } else { usage_number(usage, "cached_input_tokens", "cachedInputTokens") }
|
||||
} as u64),
|
||||
"cacheWriteTokens": cache_write_tokens as u64,
|
||||
"reasoningTokens": usage_number(usage, "reasoning_tokens", "reasoningTokens") as u64,
|
||||
"activeSeconds": usage_number(usage, "active_seconds", "activeSeconds"),
|
||||
"requests": usage_number(usage, "requests", "requestCount") as u64,
|
||||
"providerCostUsd": provider_cost,
|
||||
})
|
||||
}
|
||||
|
||||
fn usage_delta(cumulative: &Value, baseline: &Value) -> Value {
|
||||
let integer_delta = |field: &str| cumulative.get(field).and_then(Value::as_u64).unwrap_or(0)
|
||||
.saturating_sub(baseline.get(field).and_then(Value::as_u64).unwrap_or(0));
|
||||
let number_delta = |field: &str| (cumulative.get(field).and_then(Value::as_f64).unwrap_or(0.0)
|
||||
- baseline.get(field).and_then(Value::as_f64).unwrap_or(0.0)).max(0.0);
|
||||
json!({
|
||||
"inputTokens": integer_delta("inputTokens"),
|
||||
"outputTokens": integer_delta("outputTokens"),
|
||||
"cacheReadTokens": integer_delta("cacheReadTokens"),
|
||||
"cacheWriteTokens": integer_delta("cacheWriteTokens"),
|
||||
"reasoningTokens": integer_delta("reasoningTokens"),
|
||||
"activeSeconds": number_delta("activeSeconds"),
|
||||
"requests": integer_delta("requests"),
|
||||
"providerCostUsd": number_delta("providerCostUsd"),
|
||||
})
|
||||
}
|
||||
|
||||
fn should_suspend_for_idle(state: &DurableRunnerState) -> Result<bool, DurableRunnerError> {
|
||||
should_suspend_for_idle_at(state, current_unix_ms()?)
|
||||
}
|
||||
|
||||
fn should_suspend_for_idle_at(state: &DurableRunnerState, now_unix_ms: u64) -> Result<bool, DurableRunnerError> {
|
||||
fn should_suspend_for_idle_at(
|
||||
state: &DurableRunnerState,
|
||||
now_unix_ms: u64,
|
||||
) -> Result<bool, DurableRunnerError> {
|
||||
if state.lifecycle_mode != "warm"
|
||||
|| state.stop_after_flush
|
||||
|| !matches!(state.lifecycle.as_str(), "warm_idle" | "waiting_input")
|
||||
@@ -739,7 +1056,9 @@ fn begin_automatic_suspend(
|
||||
}
|
||||
if let Some(mut runtime) = provider.take() {
|
||||
runtime.shutdown().map_err(|error| {
|
||||
DurableRunnerError::invalid(format!("failed to checkpoint provider for suspension: {error}"))
|
||||
DurableRunnerError::invalid(format!(
|
||||
"failed to checkpoint provider for suspension: {error}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
state.lifecycle = "suspending".to_owned();
|
||||
@@ -766,6 +1085,8 @@ fn begin_automatic_suspend(
|
||||
fn provider_failure_code(detail: &str) -> &'static str {
|
||||
if detail.contains("stdout frame exceeded") || detail.contains("provider_frame_too_large") {
|
||||
"provider_frame_too_large"
|
||||
} else if detail.contains("terminated and cannot be resumed") {
|
||||
"provider_session_terminated"
|
||||
} else {
|
||||
"provider_transport_failed"
|
||||
}
|
||||
@@ -778,7 +1099,11 @@ fn persist_provider_failure(
|
||||
) -> Result<(), DurableRunnerError> {
|
||||
let code = provider_failure_code(detail);
|
||||
state.recoverable_failure = Some(code.to_owned());
|
||||
state.lifecycle = "recoverable_failure".to_owned();
|
||||
state.lifecycle = if code == "provider_session_terminated" {
|
||||
"terminated".to_owned()
|
||||
} else {
|
||||
"recoverable_failure".to_owned()
|
||||
};
|
||||
state.record_diagnostic(format!("{code}: {detail}"));
|
||||
store.save(state)
|
||||
}
|
||||
@@ -786,7 +1111,7 @@ fn persist_provider_failure(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::codex_provider::{CodexProviderEvent, Provider, ProviderKind};
|
||||
use crate::codex_provider::{ProviderEvent, Provider, ProviderKind};
|
||||
use crate::local_runner::LocalRunnerError;
|
||||
use crate::provider_bridge::ToolResult;
|
||||
use std::net::TcpListener;
|
||||
@@ -800,8 +1125,11 @@ mod tests {
|
||||
fn kind(&self) -> ProviderKind {
|
||||
ProviderKind::Codex
|
||||
}
|
||||
fn process_id(&self) -> u32 {
|
||||
1
|
||||
fn runtime_identity(&self) -> crate::codex_provider::ProviderRuntimeIdentity {
|
||||
crate::codex_provider::ProviderRuntimeIdentity::LocalProcess {
|
||||
process_id: 1,
|
||||
provider_session_id: "oversized".to_owned(),
|
||||
}
|
||||
}
|
||||
fn session_identity(&self) -> &str {
|
||||
"test-thread"
|
||||
@@ -809,7 +1137,7 @@ mod tests {
|
||||
fn provider_session_id(&self) -> Option<&str> {
|
||||
Some("test-session")
|
||||
}
|
||||
fn start_turn(&mut self, _message: &str, _cwd: &str) -> Result<Value, LocalRunnerError> {
|
||||
fn start_turn(&mut self, _message: &str, _cwd: &str, _turn_id: &str) -> Result<Value, LocalRunnerError> {
|
||||
unreachable!()
|
||||
}
|
||||
fn interrupt_turn(&mut self, _turn_id: &str) -> Result<Value, LocalRunnerError> {
|
||||
@@ -818,7 +1146,7 @@ mod tests {
|
||||
fn read(&mut self) -> Result<Value, LocalRunnerError> {
|
||||
unreachable!()
|
||||
}
|
||||
fn poll(&mut self) -> Result<Option<CodexProviderEvent>, LocalRunnerError> {
|
||||
fn poll(&mut self) -> Result<Option<ProviderEvent>, LocalRunnerError> {
|
||||
Err(LocalRunnerError::invalid(
|
||||
"harness stdout frame exceeded 4194304 bytes",
|
||||
))
|
||||
@@ -834,11 +1162,43 @@ mod tests {
|
||||
#[test]
|
||||
fn usage_counts_remain_observable_while_credential_tokens_are_redacted() {
|
||||
let sanitized = crate::durable::sanitize_value(&json!({
|
||||
"tokenUsage": { "total": { "inputTokens": 12, "outputTokens": 3 } },
|
||||
"tokenUsage": { "total": {
|
||||
"inputTokens": 12, "outputTokens": 3,
|
||||
"cacheReadTokens": 0, "cacheWriteTokens": 5
|
||||
} },
|
||||
"accessToken": "secret-value",
|
||||
}));
|
||||
assert_eq!(sanitized.pointer("/tokenUsage/total/inputTokens"), Some(&json!(12)));
|
||||
assert_eq!(sanitized.pointer("/accessToken"), Some(&json!("[REDACTED]")));
|
||||
assert_eq!(
|
||||
sanitized.pointer("/tokenUsage/total/inputTokens"),
|
||||
Some(&json!(12))
|
||||
);
|
||||
assert_eq!(
|
||||
sanitized.pointer("/tokenUsage/total/cacheReadTokens"),
|
||||
Some(&json!(0))
|
||||
);
|
||||
assert_eq!(
|
||||
sanitized.pointer("/tokenUsage/total/cacheWriteTokens"),
|
||||
Some(&json!(5))
|
||||
);
|
||||
assert_eq!(
|
||||
sanitized.pointer("/accessToken"),
|
||||
Some(&json!("[REDACTED]"))
|
||||
);
|
||||
assert_eq!(
|
||||
normalized_usage_measurement(&json!({
|
||||
"tokenUsage": { "total": {
|
||||
"inputTokens": 12, "outputTokens": 3,
|
||||
"cachedInputTokens": 7, "reasoningTokens": 2,
|
||||
"costUsd": 0.004
|
||||
}}
|
||||
})),
|
||||
json!({
|
||||
"inputTokens": 12, "outputTokens": 3, "cacheReadTokens": 7,
|
||||
"cacheWriteTokens": 0, "reasoningTokens": 2,
|
||||
"activeSeconds": 0.0, "requests": 0,
|
||||
"providerCostUsd": 0.004
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -851,7 +1211,10 @@ mod tests {
|
||||
}
|
||||
}));
|
||||
assert_eq!(sanitized.pointer("/item/id"), Some(&json!("image-1")));
|
||||
assert_eq!(sanitized.pointer("/item/artifactPath"), Some(&json!("/tmp/frog.png")));
|
||||
assert_eq!(
|
||||
sanitized.pointer("/item/artifactPath"),
|
||||
Some(&json!("/tmp/frog.png"))
|
||||
);
|
||||
assert_eq!(
|
||||
sanitized.pointer("/item/imageUrl").and_then(Value::as_str),
|
||||
Some("[OMITTED data URL: 1200022 bytes]")
|
||||
@@ -861,8 +1224,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn provider_failure_codes_are_stable() {
|
||||
assert_eq!(provider_failure_code("harness stdout frame exceeded 4194304 bytes"), "provider_frame_too_large");
|
||||
assert_eq!(provider_failure_code("invalid JSON-RPC"), "provider_transport_failed");
|
||||
assert_eq!(
|
||||
provider_failure_code("harness stdout frame exceeded 4194304 bytes"),
|
||||
"provider_frame_too_large"
|
||||
);
|
||||
assert_eq!(
|
||||
provider_failure_code("invalid JSON-RPC"),
|
||||
"provider_transport_failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -886,7 +1255,7 @@ mod tests {
|
||||
state.lifecycle = lifecycle.to_owned();
|
||||
assert!(!should_suspend_for_idle_at(&state, 2_000_000).unwrap());
|
||||
}
|
||||
state.lifecycle = "warm_idle".to_owned();
|
||||
state.lifecycle = "active".to_owned();
|
||||
state.stop_after_flush = true;
|
||||
assert!(!should_suspend_for_idle_at(&state, 2_000_000).unwrap());
|
||||
let _ = fs::remove_dir_all(root);
|
||||
@@ -901,7 +1270,9 @@ mod tests {
|
||||
let mut provider: Option<Box<dyn Provider>> = Some(Box::new(OversizedFrameProvider));
|
||||
|
||||
let error = poll_provider(&mut provider, &mut state, &store, &runner_config).unwrap_err();
|
||||
assert!(error.to_string().contains("stdout frame exceeded 4194304 bytes"));
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("stdout frame exceeded 4194304 bytes"));
|
||||
let (restored, recovered) = store.load_or_create(&runner_config).unwrap();
|
||||
assert!(recovered);
|
||||
assert_eq!(restored.lifecycle, "recoverable_failure");
|
||||
@@ -950,14 +1321,23 @@ mod tests {
|
||||
let object = value.as_object_mut().unwrap();
|
||||
object.remove("providerConfig");
|
||||
object.remove("providerSessionId");
|
||||
object.insert("codexProviderConfig".to_owned(), json!({
|
||||
"command": "codex", "args": ["app-server"], "cwd": "/tmp",
|
||||
"model": null, "instructions": "legacy"
|
||||
}));
|
||||
object.insert(
|
||||
"codexProviderConfig".to_owned(),
|
||||
json!({
|
||||
"command": "codex", "args": ["app-server"], "cwd": "/tmp",
|
||||
"model": null, "instructions": "legacy"
|
||||
}),
|
||||
);
|
||||
object.insert("codexProviderThreadId".to_owned(), json!("thread-legacy"));
|
||||
let restored: DurableRunnerState = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(restored.provider_config.unwrap().kind, crate::codex_provider::ProviderKind::Codex);
|
||||
assert_eq!(restored.provider_session_id.as_deref(), Some("thread-legacy"));
|
||||
assert_eq!(
|
||||
restored.provider_config.unwrap().kind(),
|
||||
crate::codex_provider::ProviderKind::Codex
|
||||
);
|
||||
assert_eq!(
|
||||
restored.provider_session_id.as_deref(),
|
||||
Some("thread-legacy")
|
||||
);
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
@@ -1102,6 +1482,56 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_bootstrap_failure_returns_a_failed_prepare_result() {
|
||||
let root = temporary_root("provider-bootstrap-failure");
|
||||
let config = config(&root);
|
||||
let store = DurableStateStore::new(&root).unwrap();
|
||||
let (mut state, _) = store.load_or_create(&config).unwrap();
|
||||
let value = command(
|
||||
"command_prepare_missing_provider",
|
||||
1,
|
||||
"run.prepare",
|
||||
json!({
|
||||
"provider": {
|
||||
"kind": "codex",
|
||||
"command": root.join("definitely-not-a-provider"),
|
||||
"args": [],
|
||||
"cwd": root,
|
||||
"model": null,
|
||||
"instructions": "test"
|
||||
}
|
||||
}),
|
||||
);
|
||||
let processed = process_command_and_provider(
|
||||
&mut state,
|
||||
&store,
|
||||
&config,
|
||||
&mut None,
|
||||
&value,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(processed.status, "failed");
|
||||
assert_eq!(processed.logical_effect_count, 0);
|
||||
assert!(processed.result["detail"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("failed to start local provider"));
|
||||
assert_eq!(state.lifecycle, "recoverable_failure");
|
||||
assert_eq!(state.last_controller_command_seq, 1);
|
||||
assert!(state.outbox.is_empty());
|
||||
let replay = process_command_and_provider(
|
||||
&mut state,
|
||||
&store,
|
||||
&config,
|
||||
&mut None,
|
||||
&value,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(replay.status, "failed");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processed_command_ledger_compacts_to_a_fixed_bound_without_reenabling_effects() {
|
||||
let root = temporary_root("command-compaction");
|
||||
|
||||
@@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::codex_provider::CodexProviderConfig;
|
||||
use crate::codex_provider::ProviderConfig;
|
||||
use crate::process_supervisor::SupervisedProcess;
|
||||
use crate::provider_bridge::{AuthorizedToolSet, ProviderToolBridge, ToolResult};
|
||||
|
||||
@@ -198,9 +198,17 @@ pub struct DurableRunnerState {
|
||||
#[serde(default)]
|
||||
pub provider_tool_bridge: ProviderToolBridge,
|
||||
#[serde(default, alias = "codexProviderConfig")]
|
||||
pub provider_config: Option<CodexProviderConfig>,
|
||||
pub provider_config: Option<ProviderConfig>,
|
||||
#[serde(default, alias = "codexProviderThreadId")]
|
||||
pub provider_session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub provider_event_cursor: Option<String>,
|
||||
#[serde(default)]
|
||||
pub provider_usage_cumulative: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub provider_usage_run_baseline: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub provider_budget_ceiling_usd: Option<f64>,
|
||||
}
|
||||
|
||||
impl DurableRunnerState {
|
||||
@@ -238,6 +246,10 @@ impl DurableRunnerState {
|
||||
provider_tool_bridge: ProviderToolBridge::default(),
|
||||
provider_config: None,
|
||||
provider_session_id: None,
|
||||
provider_event_cursor: None,
|
||||
provider_usage_cumulative: None,
|
||||
provider_usage_run_baseline: None,
|
||||
provider_budget_ceiling_usd: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,12 +674,18 @@ fn redaction_key(key: &str) -> bool {
|
||||
"output_tokens",
|
||||
"cachedinputtokens",
|
||||
"cached_input_tokens",
|
||||
"cachereadtokens",
|
||||
"cache_read_tokens",
|
||||
"cachewritetokens",
|
||||
"cache_write_tokens",
|
||||
"reasoningoutputtokens",
|
||||
"reasoning_output_tokens",
|
||||
"reasoningtokens",
|
||||
"reasoning_tokens",
|
||||
"cachewriteinputtokens",
|
||||
"cache_write_input_tokens",
|
||||
"cache_read_input_tokens",
|
||||
"cache_creation_input_tokens",
|
||||
"totaltokens",
|
||||
"total_tokens",
|
||||
]
|
||||
@@ -1190,7 +1208,7 @@ fn execute_command_effect(
|
||||
})?;
|
||||
}
|
||||
if let Some(value) = payload.get("provider") {
|
||||
let provider: CodexProviderConfig =
|
||||
let provider: ProviderConfig =
|
||||
serde_json::from_value(value.clone()).map_err(|error| {
|
||||
DurableRunnerError::invalid(format!(
|
||||
"run.prepare provider is invalid: {error}"
|
||||
@@ -1203,6 +1221,11 @@ fn execute_command_effect(
|
||||
));
|
||||
}
|
||||
}
|
||||
if state.provider_budget_ceiling_usd.is_none() {
|
||||
if let ProviderConfig::ClaudeManaged(managed) = &provider {
|
||||
state.provider_budget_ceiling_usd = Some(managed.max_session_list_cost_usd);
|
||||
}
|
||||
}
|
||||
state.provider_config = Some(provider);
|
||||
}
|
||||
state.lifecycle = "ready".to_owned();
|
||||
@@ -1249,6 +1272,7 @@ fn execute_command_effect(
|
||||
turn_id: state.turn_id.clone(),
|
||||
item_id: state.item_id.clone(),
|
||||
});
|
||||
state.provider_usage_run_baseline = state.provider_usage_cumulative.clone();
|
||||
state.run_id = run_id;
|
||||
state.turn_id = turn_id;
|
||||
state.item_id = item_id;
|
||||
@@ -1282,12 +1306,13 @@ fn execute_command_effect(
|
||||
enqueue_event(
|
||||
state,
|
||||
config,
|
||||
"mcp_app.tool_result",
|
||||
"semantic_tool.result",
|
||||
0,
|
||||
json!({ "semantic_tool": {
|
||||
"schema": "paperclip.prp.semantic_tool.v1", "schemaVersion": 1,
|
||||
"phase": "result", "operationId": result.operation_id,
|
||||
"callId": result.call_id, "result": result.result,
|
||||
"isError": result.is_error,
|
||||
}}),
|
||||
Some(&item_id),
|
||||
)?;
|
||||
@@ -1298,15 +1323,17 @@ fn execute_command_effect(
|
||||
))
|
||||
}
|
||||
"session.open" => {
|
||||
let event_type = if state.reconnect_count > 0 { "session.resumed" } else { "session.started" };
|
||||
enqueue_event(
|
||||
state,
|
||||
config,
|
||||
"session.started",
|
||||
event_type,
|
||||
0,
|
||||
json!({
|
||||
"normalizedSessionId": state.normalized_session_id,
|
||||
"driverSessionId": "driver_durable_runner_fake",
|
||||
"resumed": state.reconnect_count > 0,
|
||||
"providerDescriptor": provider_descriptor(state.provider_config.as_ref(), None),
|
||||
}),
|
||||
None,
|
||||
)?;
|
||||
@@ -1483,6 +1510,39 @@ fn execute_command_effect(
|
||||
1,
|
||||
"provider thread read requested".to_owned(),
|
||||
)),
|
||||
"session.budget.increase" => {
|
||||
let value = payload.get("maxSessionListCostUsd").and_then(Value::as_f64)
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
.ok_or_else(|| DurableRunnerError::invalid(
|
||||
"session.budget.increase payload.maxSessionListCostUsd must be positive",
|
||||
))?;
|
||||
if state.active_turn && state.lifecycle != "waiting_input" {
|
||||
return Ok((
|
||||
"rejected".to_owned(),
|
||||
0,
|
||||
"session budget cannot change while a turn is active".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok((
|
||||
"completed".to_owned(),
|
||||
1,
|
||||
format!("remote session spend ceiling increase to {value:.2} USD authorized"),
|
||||
))
|
||||
}
|
||||
"session.destroy" => {
|
||||
if state.active_turn {
|
||||
return Ok((
|
||||
"rejected".to_owned(),
|
||||
0,
|
||||
"remote session cannot be deleted while a turn is active".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok((
|
||||
"completed".to_owned(),
|
||||
1,
|
||||
"remote session deletion authorized".to_owned(),
|
||||
))
|
||||
}
|
||||
"runner.drain" => {
|
||||
state.lifecycle = "draining".to_owned();
|
||||
enqueue_event(
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod claude_managed_provider;
|
||||
pub mod codex_provider;
|
||||
pub mod durable;
|
||||
pub mod fake_harness;
|
||||
pub mod local_runner;
|
||||
pub mod process_supervisor;
|
||||
pub mod provider_bridge;
|
||||
pub mod provider_events;
|
||||
pub mod replay;
|
||||
|
||||
use std::error::Error;
|
||||
|
||||
@@ -42,6 +42,8 @@ pub struct ToolResult {
|
||||
pub call_id: String,
|
||||
pub operation_id: String,
|
||||
pub result: Value,
|
||||
#[serde(default)]
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
|
||||
@@ -129,6 +131,18 @@ impl ProviderToolBridge {
|
||||
tool.operation_id
|
||||
)));
|
||||
}
|
||||
jsonschema::validator_for(&tool.input_schema).map_err(|_| {
|
||||
ProviderBridgeError::invalid(format!(
|
||||
"tool {} has an invalid input JSON Schema",
|
||||
tool.operation_id
|
||||
))
|
||||
})?;
|
||||
jsonschema::validator_for(&tool.response_schema).map_err(|_| {
|
||||
ProviderBridgeError::invalid(format!(
|
||||
"tool {} has an invalid response JSON Schema",
|
||||
tool.operation_id
|
||||
))
|
||||
})?;
|
||||
if !names.insert(tool.operation_id.clone()) {
|
||||
return Err(ProviderBridgeError::invalid(
|
||||
"authorized tool names must be unique",
|
||||
@@ -166,9 +180,19 @@ impl ProviderToolBridge {
|
||||
if call_id.is_empty() || call_id.len() > 160 {
|
||||
return Err(ProviderBridgeError::invalid("tool call id is invalid"));
|
||||
}
|
||||
if !self.authorized.contains_key(&operation_id) {
|
||||
return Err(ProviderBridgeError::invalid(format!(
|
||||
let authorized = self.authorized.get(&operation_id).ok_or_else(|| {
|
||||
ProviderBridgeError::invalid(format!(
|
||||
"provider requested unauthorized tool {operation_id}"
|
||||
))
|
||||
})?;
|
||||
let validator = jsonschema::validator_for(&authorized.input_schema).map_err(|_| {
|
||||
ProviderBridgeError::invalid(format!(
|
||||
"tool {operation_id} has an invalid durable input JSON Schema"
|
||||
))
|
||||
})?;
|
||||
if !validator.is_valid(&input) {
|
||||
return Err(ProviderBridgeError::invalid(format!(
|
||||
"provider arguments for {operation_id} failed JSON Schema validation"
|
||||
)));
|
||||
}
|
||||
let call = PendingToolCall {
|
||||
@@ -212,6 +236,30 @@ impl ProviderToolBridge {
|
||||
"tool result operation does not match its call",
|
||||
));
|
||||
}
|
||||
let authorized = self.authorized.get(&result.operation_id).ok_or_else(|| {
|
||||
ProviderBridgeError::invalid("tool result operation is no longer authorized")
|
||||
})?;
|
||||
let validator = jsonschema::validator_for(&authorized.response_schema).map_err(|_| {
|
||||
ProviderBridgeError::invalid(format!(
|
||||
"tool {} has an invalid durable response JSON Schema",
|
||||
result.operation_id
|
||||
))
|
||||
})?;
|
||||
if !result.is_error {
|
||||
// Paperclip semantic dispatchers return an authoritative envelope;
|
||||
// provider contracts describe the operation-specific value inside
|
||||
// `result`. Direct values remain valid for compatibility with v1
|
||||
// peers that do not wrap their semantic result.
|
||||
let response = semantic_response_value(&result)?;
|
||||
if let Some(response) = response {
|
||||
if !validator.is_valid(response) {
|
||||
return Err(ProviderBridgeError::invalid(format!(
|
||||
"tool result for {} failed JSON Schema validation",
|
||||
result.operation_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.pending.remove(&result.call_id);
|
||||
self.completed
|
||||
.insert(result.call_id.clone(), result.clone());
|
||||
@@ -223,6 +271,36 @@ impl ProviderToolBridge {
|
||||
}
|
||||
}
|
||||
|
||||
fn semantic_response_value(result: &ToolResult) -> Result<Option<&Value>, ProviderBridgeError> {
|
||||
let Some(envelope) = result.result.as_object() else {
|
||||
return Ok(Some(&result.result));
|
||||
};
|
||||
let Some(ok) = envelope.get("ok").and_then(Value::as_bool) else {
|
||||
return Ok(Some(&result.result));
|
||||
};
|
||||
if !envelope.contains_key("operationId") && !envelope.contains_key("callId") {
|
||||
return Ok(Some(&result.result));
|
||||
}
|
||||
if envelope.get("operationId").and_then(Value::as_str) != Some(&result.operation_id)
|
||||
|| envelope.get("callId").and_then(Value::as_str) != Some(&result.call_id)
|
||||
{
|
||||
return Err(ProviderBridgeError::invalid(
|
||||
"semantic result envelope does not match its provider call",
|
||||
));
|
||||
}
|
||||
if ok {
|
||||
envelope.get("result").map(Some).ok_or_else(|| {
|
||||
ProviderBridgeError::invalid("successful semantic result omitted result")
|
||||
})
|
||||
} else if envelope.get("denial").is_some() || envelope.get("error").is_some() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(ProviderBridgeError::invalid(
|
||||
"failed semantic result omitted denial or error",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_operation_id(value: &str) -> Result<(), ProviderBridgeError> {
|
||||
let mut chars = value.chars();
|
||||
let first = chars
|
||||
@@ -275,6 +353,7 @@ mod tests {
|
||||
call_id: "call-1".to_owned(),
|
||||
operation_id: "get_task_context".to_owned(),
|
||||
result: json!({"ok": true}),
|
||||
is_error: false,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(value, json!({"ok": true}));
|
||||
@@ -299,6 +378,7 @@ mod tests {
|
||||
call_id: "call-1".to_owned(),
|
||||
operation_id: "get_task_context".to_owned(),
|
||||
result: json!({"ok": true}),
|
||||
is_error: false,
|
||||
};
|
||||
bridge.apply_result(result.clone()).unwrap();
|
||||
bridge.apply_result(result).unwrap();
|
||||
@@ -306,7 +386,8 @@ mod tests {
|
||||
.apply_result(ToolResult {
|
||||
call_id: "call-1".to_owned(),
|
||||
operation_id: "get_task_context".to_owned(),
|
||||
result: json!({"ok": false})
|
||||
result: json!({"ok": false}),
|
||||
is_error: false,
|
||||
})
|
||||
.is_err());
|
||||
}
|
||||
@@ -320,4 +401,35 @@ mod tests {
|
||||
let recovered: ProviderToolBridge = serde_json::from_str(&encoded).unwrap();
|
||||
assert_eq!(recovered, bridge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_the_operation_value_inside_a_semantic_dispatch_envelope() {
|
||||
let mut set = tools("sha256:catalog-a");
|
||||
set.operations[0].response_schema = json!({
|
||||
"type": "object",
|
||||
"properties": { "value": { "type": "string" } },
|
||||
"required": ["value"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
let mut bridge = ProviderToolBridge::default();
|
||||
bridge.prepare(set).unwrap();
|
||||
bridge
|
||||
.begin_call("call-1".into(), "get_task_context".into(), json!({}))
|
||||
.unwrap();
|
||||
bridge
|
||||
.apply_result(ToolResult {
|
||||
call_id: "call-1".into(),
|
||||
operation_id: "get_task_context".into(),
|
||||
result: json!({
|
||||
"ok": true,
|
||||
"operationId": "get_task_context",
|
||||
"callId": "call-1",
|
||||
"result": { "value": "accepted" },
|
||||
"stateRevision": 2
|
||||
}),
|
||||
is_error: false,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(bridge.pending_calls().count(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::durable::redact_text;
|
||||
|
||||
const MAX_OUTPUT_BYTES: usize = 64 * 1024;
|
||||
|
||||
fn text<'a>(value: Option<&'a Value>) -> &'a str {
|
||||
value.and_then(Value::as_str).unwrap_or("")
|
||||
}
|
||||
fn item(params: &Value) -> &Value {
|
||||
params.get("item").unwrap_or(&Value::Null)
|
||||
}
|
||||
fn id(value: &str, fallback: &str) -> String {
|
||||
let clean: String = value
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || "._:-".contains(c) {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.take(160)
|
||||
.collect();
|
||||
if clean
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphanumeric())
|
||||
{
|
||||
clean
|
||||
} else {
|
||||
fallback.to_owned()
|
||||
}
|
||||
}
|
||||
fn status(value: &str, complete: bool) -> &'static str {
|
||||
match value {
|
||||
"failed" | "error" => "failed",
|
||||
"cancelled" | "canceled" => "cancelled",
|
||||
"interrupted" | "aborted" => "interrupted",
|
||||
_ if complete => "completed",
|
||||
_ => "running",
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_output(value: &str) -> Value {
|
||||
let redacted = redact_text(value);
|
||||
let bytes = redacted.as_bytes();
|
||||
let start = bytes.len().saturating_sub(MAX_OUTPUT_BYTES);
|
||||
let output = String::from_utf8_lossy(&bytes[start..]).into_owned();
|
||||
let digest = format!("sha256:{:x}", Sha256::digest(bytes));
|
||||
json!({
|
||||
"output": output,
|
||||
"outputBytes": bytes.len(),
|
||||
"outputTruncated": bytes.len() > MAX_OUTPUT_BYTES,
|
||||
"outputDigest": digest,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert native provider notifications into bounded provider-neutral PRP records.
|
||||
/// Unknown variants remain on the legacy diagnostic stream and are never interpreted from prose.
|
||||
pub fn canonical_provider_events(method: &str, params: &Value) -> Vec<(String, Value, String)> {
|
||||
let item = item(params);
|
||||
let kind = text(item.get("type"));
|
||||
let item_id = id(text(item.get("id")).trim(), "provider-item");
|
||||
let complete = method == "item/completed";
|
||||
let mut result = Vec::new();
|
||||
let push =
|
||||
|result: &mut Vec<(String, Value, String)>, event: &str, payload: Value, item_id: &str| {
|
||||
result.push((event.to_owned(), payload, item_id.to_owned()))
|
||||
};
|
||||
if (method == "item/started" || complete) && kind == "plan" || method == "turn/plan/updated" {
|
||||
let steps = params.get("plan").and_then(Value::as_array).map(|values| values.iter().take(256).enumerate().map(|(index, value)| json!({"stepId": format!("step-{}", index + 1), "body": text(value.get("step")).chars().take(4000).collect::<String>(), "status": match text(value.get("status")) { "inProgress" | "in_progress" => "in_progress", "completed" => "completed", "blocked" => "blocked", _ => "pending" }})).collect()).unwrap_or_else(Vec::new);
|
||||
push(
|
||||
&mut result,
|
||||
"plan.updated",
|
||||
json!({"schema":"paperclip.plan.updated.v1","planId":item_id,"revision":params.get("revision").and_then(Value::as_u64).unwrap_or(1),"explanation":params.get("explanation").and_then(Value::as_str),"steps":steps,"complete":complete,"syncStatus":if complete {"pending"} else {"streaming"},"documentRevision":Value::Null}),
|
||||
&item_id,
|
||||
);
|
||||
} else if (method == "item/started" || complete)
|
||||
&& ["commandExecution", "mcpToolCall", "dynamicToolCall"].contains(&kind)
|
||||
{
|
||||
let transport = match kind {
|
||||
"mcpToolCall" => "mcp",
|
||||
"dynamicToolCall" => "dynamic",
|
||||
_ => "process",
|
||||
};
|
||||
let mut payload = json!({"schema":"paperclip.tool.execution.v1","executionId":item_id,"transport":transport,"operation":if kind == "commandExecution" {"execute"} else {"unknown"},"name":item.get("tool").or_else(|| item.get("command")).and_then(Value::as_str),"target":Value::Null,"namespace":item.get("server").and_then(Value::as_str),"readOnly":item.get("readOnlyHint").and_then(Value::as_bool),"status":status(text(item.get("status")), complete),"durationMs":item.get("durationMs").and_then(Value::as_u64),"exitCode":item.get("exitCode").and_then(Value::as_i64),"progress":Value::Null});
|
||||
if let (Some(object), Value::Object(output)) = (
|
||||
payload.as_object_mut(),
|
||||
bounded_output(text(
|
||||
item.get("aggregatedOutput").or_else(|| item.get("output")),
|
||||
)),
|
||||
) {
|
||||
object.extend(output);
|
||||
}
|
||||
push(
|
||||
&mut result,
|
||||
if complete {
|
||||
"tool.execution.completed"
|
||||
} else {
|
||||
"tool.execution.started"
|
||||
},
|
||||
payload,
|
||||
&item_id,
|
||||
);
|
||||
} else if (method == "item/started" || complete) && kind == "webSearch" {
|
||||
let action = item.get("action").unwrap_or(&Value::Null);
|
||||
let action_kind = match text(action.get("type")) {
|
||||
"search" => "search",
|
||||
"openPage" => "open_page",
|
||||
"findInPage" => "find_in_page",
|
||||
_ => "other",
|
||||
};
|
||||
let url = text(action.get("url"));
|
||||
push(
|
||||
&mut result,
|
||||
if complete {
|
||||
"research.completed"
|
||||
} else {
|
||||
"research.started"
|
||||
},
|
||||
json!({"schema":"paperclip.research.v1","researchId":item_id,"action":action_kind,"status":if complete {"completed"} else {"running"},"query":item.get("query").or_else(|| action.get("query")).and_then(Value::as_str),"url":if url.starts_with("http://") || url.starts_with("https://") {Value::String(url.to_owned())} else {Value::Null},"pattern":action.get("pattern").and_then(Value::as_str),"sources":[]}),
|
||||
&item_id,
|
||||
);
|
||||
} else if (method == "item/started" || complete)
|
||||
&& ["collabAgentToolCall", "subAgentActivity"].contains(&kind)
|
||||
{
|
||||
push(
|
||||
&mut result,
|
||||
if complete {
|
||||
"delegation.completed"
|
||||
} else {
|
||||
"delegation.started"
|
||||
},
|
||||
json!({"schema":"paperclip.delegation.v1","delegationId":item_id,"action":"spawn","status":if complete {"completed"} else {"running"},"children":[]}),
|
||||
&item_id,
|
||||
);
|
||||
} else if method == "thread/compacted"
|
||||
|| ((method == "item/started" || complete) && kind == "contextCompaction")
|
||||
{
|
||||
push(
|
||||
&mut result,
|
||||
"context.compacted",
|
||||
json!({"schema":"paperclip.context.compacted.v1","compactionId":item_id,"reason":"provider","preTokens":Value::Null,"postTokens":Value::Null,"sameSession":true}),
|
||||
&item_id,
|
||||
);
|
||||
} else if method == "model/rerouted" {
|
||||
push(
|
||||
&mut result,
|
||||
"model.route.changed",
|
||||
json!({"schema":"paperclip.model.route_changed.v1","routeId":id(text(params.get("turnId")),"model-route"),"provider":"openai","requestedModel":text(params.get("fromModel")),"fromModel":params.get("fromModel").and_then(Value::as_str),"effectiveModel":text(params.get("toModel")),"reason":text(params.get("reason"))}),
|
||||
&item_id,
|
||||
);
|
||||
} else if method == "model/verification" || method == "model/safetyBuffering/updated" {
|
||||
let buffering = params
|
||||
.get("showBufferingUi")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
push(
|
||||
&mut result,
|
||||
"model.verification.updated",
|
||||
json!({"schema":"paperclip.model.verification.v1","verificationId":id(text(params.get("turnId")),"model-verification"),"status":if buffering {"running"} else {"completed"},"classes":[],"buffering":buffering,"summary":Value::Null}),
|
||||
&item_id,
|
||||
);
|
||||
} else if (method == "item/started" || complete) && kind == "imageView" {
|
||||
push(
|
||||
&mut result,
|
||||
"artifact.viewed",
|
||||
json!({"schema":"paperclip.artifact.viewed.v1","artifactId":item_id,"reference":Value::Null,"mediaType":"image/*","title":Value::Null}),
|
||||
&item_id,
|
||||
);
|
||||
} else if (method == "item/started" || complete) && kind == "imageGeneration" {
|
||||
push(
|
||||
&mut result,
|
||||
"artifact.generated",
|
||||
json!({"schema":"paperclip.artifact.generated.v1","artifactId":item_id,"status":if complete {"completed"} else {"running"},"reference":Value::Null,"mediaType":"image/*","registered":false,"transparentBackground":item.get("transparentBackground").and_then(Value::as_bool),"failure":Value::Null}),
|
||||
&item_id,
|
||||
);
|
||||
} else if (method == "item/started" || complete)
|
||||
&& (kind == "enteredReviewMode" || kind == "exitedReviewMode")
|
||||
{
|
||||
push(
|
||||
&mut result,
|
||||
"review.mode.changed",
|
||||
json!({"schema":"paperclip.review.mode_changed.v1","reviewId":item_id,"state":if kind == "enteredReviewMode" {"entered"} else {"exited"},"scope":Value::Null}),
|
||||
&item_id,
|
||||
);
|
||||
} else if method == "hook/started" || method == "hook/completed" {
|
||||
let run = params.get("run").unwrap_or(&Value::Null);
|
||||
let hook_id = id(text(run.get("id")), "hook");
|
||||
push(
|
||||
&mut result,
|
||||
if method.ends_with("started") {
|
||||
"hook.started"
|
||||
} else {
|
||||
"hook.completed"
|
||||
},
|
||||
json!({"schema":"paperclip.hook.v1","hookId":hook_id,"event":text(run.get("eventName")),"scope":text(run.get("scope")),"status":if method.ends_with("started") {"running"} else {"completed"},"blocking":text(run.get("executionMode")) == "blocking","durationMs":run.get("durationMs").and_then(Value::as_u64),"summary":run.get("statusMessage").and_then(Value::as_str)}),
|
||||
&hook_id,
|
||||
);
|
||||
} else if complete && kind == "agentMessage" && item.get("memoryCitation").is_some() {
|
||||
let entries = item
|
||||
.get("memoryCitation")
|
||||
.and_then(|citation| citation.get("entries"))
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
for (index, entry) in entries.iter().take(64).enumerate() {
|
||||
let citation_id = format!("{item_id}:citation:{}", index + 1);
|
||||
push(
|
||||
&mut result,
|
||||
"memory.citation.referenced",
|
||||
json!({"schema":"paperclip.memory.citation.v1","citationId":citation_id,"messageItemId":item_id,"label":entry.get("label").and_then(Value::as_str).unwrap_or("Memory source"),"available":false,"reference":Value::Null}),
|
||||
&citation_id,
|
||||
);
|
||||
}
|
||||
} else if method == "item/autoApprovalReview/started"
|
||||
|| method == "item/autoApprovalReview/completed"
|
||||
{
|
||||
let review_id = id(text(params.get("reviewId")), "safety-review");
|
||||
push(
|
||||
&mut result,
|
||||
if method.ends_with("started") {
|
||||
"safety.review.started"
|
||||
} else {
|
||||
"safety.review.completed"
|
||||
},
|
||||
json!({"schema":"paperclip.safety.review.v1","reviewId":review_id,"targetExecutionId":params.get("targetItemId").and_then(Value::as_str),"status":if method.ends_with("started") {"running"} else {"completed"},"decision":if method.ends_with("started") {"pending"} else {"unknown"},"summary":Value::Null}),
|
||||
&review_id,
|
||||
);
|
||||
} else if method == "item/commandExecution/terminalInteraction" {
|
||||
let execution_id = id(text(params.get("itemId")), "execution");
|
||||
push(
|
||||
&mut result,
|
||||
"terminal.input.sent",
|
||||
json!({"schema":"paperclip.terminal.input_sent.v1","executionId":execution_id,"origin":"agent","inputClass":"text","byteCount":text(params.get("stdin")).len()}),
|
||||
&execution_id,
|
||||
);
|
||||
} else if (method == "item/started" || complete) && kind == "sleep" {
|
||||
push(
|
||||
&mut result,
|
||||
if complete {
|
||||
"wait.completed"
|
||||
} else {
|
||||
"wait.started"
|
||||
},
|
||||
json!({"schema":"paperclip.wait.v1","waitId":item_id,"reason":"timer","status":if complete {"completed"} else {"running"},"plannedDurationMs":item.get("durationMs").and_then(Value::as_u64),"elapsedDurationMs":if complete {item.get("durationMs").and_then(Value::as_u64)} else {None}}),
|
||||
&item_id,
|
||||
);
|
||||
} else if [
|
||||
"error",
|
||||
"warning",
|
||||
"guardianWarning",
|
||||
"deprecationNotice",
|
||||
"configWarning",
|
||||
"windows/worldWritableWarning",
|
||||
]
|
||||
.contains(&method)
|
||||
{
|
||||
let notice_id = id(&format!("{method}:notice"), "provider-notice");
|
||||
push(
|
||||
&mut result,
|
||||
"provider.notice.recorded",
|
||||
json!({"schema":"paperclip.provider.notice.v1","noticeId":notice_id,"severity":if method == "error" {"error"} else {"warning"},"category":method.replace('/', "_"),"scope":if method.contains("config") || method.contains("windows") {"environment"} else {"turn"},"recoverable":method != "error","userActionable":method == "error" || method == "warning","summary":text(params.get("message"))}),
|
||||
¬ice_id,
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn terminal_input_never_retains_content() {
|
||||
let events = canonical_provider_events(
|
||||
"item/commandExecution/terminalInteraction",
|
||||
&json!({"itemId":"exec-1","stdin":"Bearer top-secret"}),
|
||||
);
|
||||
assert_eq!(events[0].0, "terminal.input.sent");
|
||||
assert_eq!(events[0].1["byteCount"], 17);
|
||||
assert!(!events[0].1.to_string().contains("top-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_output_is_redacted_bounded_and_digested() {
|
||||
let output = format!("Bearer secret {}", "x".repeat(MAX_OUTPUT_BYTES + 10));
|
||||
let events = canonical_provider_events(
|
||||
"item/completed",
|
||||
&json!({"item":{"id":"exec-1","type":"commandExecution","status":"completed","aggregatedOutput":output}}),
|
||||
);
|
||||
let payload = &events[0].1;
|
||||
assert_eq!(payload["output"], "[REDACTED]");
|
||||
assert!(!payload.to_string().contains("secret"));
|
||||
assert!(payload["outputDigest"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("sha256:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_memory_citations_without_provider_thread_references() {
|
||||
let events = canonical_provider_events(
|
||||
"item/completed",
|
||||
&json!({"item":{"id":"message-1","type":"agentMessage","memoryCitation":{"entries":[{"label":"Decision","threadId":"native-secret"}]}}}),
|
||||
);
|
||||
assert_eq!(events[0].0, "memory.citation.referenced");
|
||||
assert_eq!(events[0].1["available"], false);
|
||||
assert!(!events[0].1.to_string().contains("native-secret"));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use paperclip_runner_core::codex_provider::{
|
||||
CodexProvider, CodexProviderConfig, CodexProviderEvent, Provider, ProviderKind,
|
||||
CodexProvider, LocalProviderConfig, Provider, ProviderEvent, ProviderKind,
|
||||
};
|
||||
use paperclip_runner_core::provider_bridge::{AuthorizedTool, ToolResult};
|
||||
use serde_json::json;
|
||||
@@ -10,7 +10,7 @@ use serde_json::json;
|
||||
#[test]
|
||||
fn codex_dynamic_tool_round_trips_through_the_rust_provider_boundary() {
|
||||
let mut provider = CodexProvider::start(
|
||||
&CodexProviderConfig {
|
||||
&LocalProviderConfig {
|
||||
kind: ProviderKind::Codex,
|
||||
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-codex-app-server")),
|
||||
args: Vec::new(),
|
||||
@@ -37,7 +37,7 @@ fn codex_dynamic_tool_round_trips_through_the_rust_provider_boundary() {
|
||||
"fake Codex did not issue its tool call"
|
||||
);
|
||||
match provider.poll().unwrap() {
|
||||
Some(CodexProviderEvent::ToolCall {
|
||||
Some(ProviderEvent::ToolCall {
|
||||
call_id,
|
||||
operation_id,
|
||||
input,
|
||||
@@ -49,6 +49,7 @@ fn codex_dynamic_tool_round_trips_through_the_rust_provider_boundary() {
|
||||
call_id,
|
||||
operation_id,
|
||||
result: json!({"ok": true, "task": {"id": "task-1"}}),
|
||||
is_error: false,
|
||||
})
|
||||
.unwrap();
|
||||
break;
|
||||
@@ -64,12 +65,12 @@ fn codex_dynamic_tool_round_trips_through_the_rust_provider_boundary() {
|
||||
"fake Codex did not complete after its tool result"
|
||||
);
|
||||
match provider.poll().unwrap() {
|
||||
Some(CodexProviderEvent::SemanticResult { result, item_id }) => {
|
||||
Some(ProviderEvent::SemanticResult { result, item_id }) => {
|
||||
assert_eq!(result["reportedWorkDisposition"], "done");
|
||||
assert_eq!(item_id.as_deref(), Some("semantic-result"));
|
||||
saw_semantic_result = true;
|
||||
}
|
||||
Some(CodexProviderEvent::Notification { method, .. }) if method == "turn/completed" => {
|
||||
Some(ProviderEvent::Notification { method, .. }) if method == "turn/completed" => {
|
||||
assert!(
|
||||
saw_semantic_result,
|
||||
"semantic result must precede terminal completion"
|
||||
@@ -84,7 +85,7 @@ fn codex_dynamic_tool_round_trips_through_the_rust_provider_boundary() {
|
||||
#[test]
|
||||
fn provider_contract_preserves_the_opencode_tag() {
|
||||
let mut provider = CodexProvider::start(
|
||||
&CodexProviderConfig {
|
||||
&LocalProviderConfig {
|
||||
kind: ProviderKind::Opencode,
|
||||
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-codex-app-server")),
|
||||
args: Vec::new(),
|
||||
@@ -110,7 +111,7 @@ fn provider_contract_preserves_the_opencode_tag() {
|
||||
#[test]
|
||||
fn image_like_prompt_stays_skillless_and_accepts_a_valid_one_point_two_megabyte_event() {
|
||||
let mut provider = CodexProvider::start(
|
||||
&CodexProviderConfig {
|
||||
&LocalProviderConfig {
|
||||
kind: ProviderKind::Codex,
|
||||
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-codex-app-server")),
|
||||
args: vec!["--large-event".to_owned()],
|
||||
@@ -141,9 +142,7 @@ fn image_like_prompt_stays_skillless_and_accepts_a_valid_one_point_two_megabyte_
|
||||
"large provider event was not received"
|
||||
);
|
||||
match provider.poll().unwrap() {
|
||||
Some(CodexProviderEvent::Notification { method, params })
|
||||
if method == "item/completed" =>
|
||||
{
|
||||
Some(ProviderEvent::Notification { method, params }) if method == "item/completed" => {
|
||||
assert!(
|
||||
params
|
||||
.pointer("/item/imageUrl")
|
||||
@@ -154,7 +153,7 @@ fn image_like_prompt_stays_skillless_and_accepts_a_valid_one_point_two_megabyte_
|
||||
);
|
||||
saw_large_event = true;
|
||||
}
|
||||
Some(CodexProviderEvent::ToolCall { operation_id, .. }) => {
|
||||
Some(ProviderEvent::ToolCall { operation_id, .. }) => {
|
||||
assert_eq!(operation_id, "get_task_context");
|
||||
saw_authorized_tool = true;
|
||||
}
|
||||
@@ -170,7 +169,7 @@ fn image_like_prompt_stays_skillless_and_accepts_a_valid_one_point_two_megabyte_
|
||||
#[test]
|
||||
fn codex_provider_rejects_an_event_above_the_four_megabyte_hard_limit() {
|
||||
let mut provider = CodexProvider::start(
|
||||
&CodexProviderConfig {
|
||||
&LocalProviderConfig {
|
||||
kind: ProviderKind::Codex,
|
||||
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-codex-app-server")),
|
||||
args: vec!["--oversized-event".to_owned()],
|
||||
|
||||
@@ -204,8 +204,8 @@ class RouteError extends Error {
|
||||
|
||||
function harnessConfiguration(source, fallbackModel) {
|
||||
const provider = source.provider === undefined ? "codex" : String(source.provider).trim();
|
||||
if (provider !== "codex" && provider !== "opencode") {
|
||||
throw new RouteError(400, "invalid_provider", "Provider must be codex or opencode.");
|
||||
if (provider !== "codex" && provider !== "opencode" && provider !== "claude_managed") {
|
||||
throw new RouteError(400, "invalid_provider", "Provider must be codex, opencode, or claude_managed.");
|
||||
}
|
||||
const rawModel = source.model === undefined ? fallbackModel : source.model;
|
||||
const model = rawModel === undefined || rawModel === null ? "" : String(rawModel).trim();
|
||||
@@ -213,6 +213,21 @@ function harnessConfiguration(source, fallbackModel) {
|
||||
if (provider === "opencode" && (!model || !model.includes("/"))) {
|
||||
throw new RouteError(400, "invalid_model", "OpenCode requires a provider/model value.");
|
||||
}
|
||||
if (provider === "claude_managed" && !model) {
|
||||
throw new RouteError(400, "invalid_model", "Claude Agent requires a model value.");
|
||||
}
|
||||
const managedProfileId = source.managedProfileId === undefined || source.managedProfileId === null
|
||||
? "default"
|
||||
: String(source.managedProfileId).trim();
|
||||
const maxSessionListCostUsd = source.maxSessionListCostUsd === undefined || source.maxSessionListCostUsd === null
|
||||
? 1
|
||||
: Number(source.maxSessionListCostUsd);
|
||||
if (provider === "claude_managed" && !managedProfileId) {
|
||||
throw new RouteError(400, "invalid_managed_profile", "Claude Agent requires a qualified profile ID.");
|
||||
}
|
||||
if (provider === "claude_managed" && (!Number.isFinite(maxSessionListCostUsd) || maxSessionListCostUsd <= 0)) {
|
||||
throw new RouteError(400, "invalid_spend_cap", "Claude Agent requires a positive session spend ceiling.");
|
||||
}
|
||||
const suppliedLifecycle = source.lifecyclePolicy && typeof source.lifecyclePolicy === "object"
|
||||
? source.lifecyclePolicy
|
||||
: source;
|
||||
@@ -235,7 +250,38 @@ function harnessConfiguration(source, fallbackModel) {
|
||||
}
|
||||
lifecyclePolicy = { mode: "warm", idleTimeoutMs };
|
||||
}
|
||||
return { provider, model: model || null, lifecyclePolicy };
|
||||
return {
|
||||
provider,
|
||||
model: model || null,
|
||||
...(provider === "claude_managed" ? { managedProfileId, maxSessionListCostUsd } : {}),
|
||||
lifecyclePolicy,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveManagedProfile(configuration) {
|
||||
if (configuration.provider !== "claude_managed") return undefined;
|
||||
const profileId = process.env.PAPERCLIP_CLAUDE_MANAGED_PROFILE_ID?.trim() || configuration.managedProfileId;
|
||||
if (profileId !== configuration.managedProfileId) {
|
||||
throw new RouteError(400, "managed_profile_not_found", "The selected Claude Agent profile is not configured on this Runner Lab server.");
|
||||
}
|
||||
const anthropicAgentId = process.env.ANTHROPIC_MANAGED_AGENT_ID?.trim();
|
||||
const agentVersion = process.env.ANTHROPIC_MANAGED_AGENT_VERSION?.trim();
|
||||
const environmentId = process.env.ANTHROPIC_MANAGED_ENVIRONMENT_ID?.trim();
|
||||
if (!process.env.ANTHROPIC_API_KEY || !anthropicAgentId || !agentVersion || !environmentId) {
|
||||
throw new RouteError(
|
||||
503,
|
||||
"managed_profile_unavailable",
|
||||
"The selected Claude Agent profile is not fully configured on this Runner Lab server.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
profileId,
|
||||
anthropicAgentId,
|
||||
agentVersion,
|
||||
environmentId,
|
||||
betaVersion: "managed-agents-2026-04-01",
|
||||
maxSessionListCostUsd: configuration.maxSessionListCostUsd,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
@@ -307,7 +353,9 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
function liveView(runner, entry) {
|
||||
const snapshot = entry.session.snapshot();
|
||||
const provider = snapshot.config.provider ?? "codex";
|
||||
const expectedAgentLabel = provider === "opencode" ? "Real OpenCode" : "Real Codex";
|
||||
const expectedAgentLabel = provider === "claude_managed"
|
||||
? "Claude Agent"
|
||||
: provider === "opencode" ? "Real OpenCode" : "Real Codex";
|
||||
const projected = view(runner, entry);
|
||||
if (
|
||||
projected.mode !== "live"
|
||||
@@ -413,6 +461,9 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
session = await service.create({
|
||||
...input,
|
||||
provider: configuration.provider,
|
||||
...(configuration.provider === "claude_managed"
|
||||
? { managedProfile: resolveManagedProfile(configuration) }
|
||||
: {}),
|
||||
lifecyclePolicy: configuration.lifecyclePolicy,
|
||||
...(configuration.model === null ? {} : { requestedModel: configuration.model }),
|
||||
});
|
||||
@@ -442,12 +493,18 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
const configuration = {
|
||||
provider: snapshot.config.provider ?? "codex",
|
||||
model: snapshot.config.requestedModel ?? null,
|
||||
...(snapshot.config.managedProfile === undefined ? {} : {
|
||||
managedProfileId: snapshot.config.managedProfile.profileId,
|
||||
maxSessionListCostUsd: snapshot.config.managedProfile.maxSessionListCostUsd,
|
||||
}),
|
||||
lifecyclePolicy: snapshot.config.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
|
||||
};
|
||||
if (
|
||||
entry.configuration !== undefined
|
||||
&& (entry.configuration.provider !== configuration.provider
|
||||
|| entry.configuration.model !== configuration.model
|
||||
|| entry.configuration.managedProfileId !== configuration.managedProfileId
|
||||
|| entry.configuration.maxSessionListCostUsd !== configuration.maxSessionListCostUsd
|
||||
|| JSON.stringify(entry.configuration.lifecyclePolicy) !== JSON.stringify(configuration.lifecyclePolicy))
|
||||
) {
|
||||
throw new RouteError(
|
||||
@@ -612,6 +669,8 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
const requestedHarness = harnessConfiguration({
|
||||
provider: body.provider ?? url.searchParams.get("provider") ?? undefined,
|
||||
model: body.model ?? url.searchParams.get("model") ?? undefined,
|
||||
managedProfileId: body.managedProfileId ?? url.searchParams.get("managedProfileId") ?? undefined,
|
||||
maxSessionListCostUsd: body.maxSessionListCostUsd ?? url.searchParams.get("maxSessionListCostUsd") ?? undefined,
|
||||
lifecyclePolicy: body.lifecyclePolicy,
|
||||
lifecycleMode: body.lifecycleMode ?? url.searchParams.get("lifecycleMode") ?? undefined,
|
||||
idleTimeoutMs: body.idleTimeoutMs ?? url.searchParams.get("idleTimeoutMs") ?? undefined,
|
||||
@@ -836,6 +895,21 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
|
||||
return;
|
||||
} else if (route === "interrupt") {
|
||||
await entry.session.interrupt("operator stopped the turn");
|
||||
} else if (route === "managed-budget") {
|
||||
const nextCap = Number(body.maxSessionListCostUsd);
|
||||
if (!Number.isFinite(nextCap) || nextCap <= 0) {
|
||||
throw new RouteError(400, "invalid_spend_cap", "The new managed-session spend ceiling must be positive.");
|
||||
}
|
||||
await entry.session.increaseManagedSessionBudget(nextCap);
|
||||
if (entry.configuration) entry.configuration.maxSessionListCostUsd = nextCap;
|
||||
} else if (route === "managed-session-delete") {
|
||||
if (body.confirm !== true) {
|
||||
throw new RouteError(400, "confirmation_required", "Remote session deletion requires explicit confirmation.");
|
||||
}
|
||||
await entry.session.deleteManagedRemoteSession();
|
||||
await retire(service, entry.session.id, "remote managed session explicitly deleted");
|
||||
send(response, 200, { deleted: true, sessionId: entry.session.id });
|
||||
return;
|
||||
} else if (route === "reconnect") {
|
||||
entry.connection = { state: "reconnecting", attempt: entry.connection.attempt + 1 };
|
||||
await entry.session.reconnect();
|
||||
|
||||
@@ -16,9 +16,12 @@ const schemaNames = [
|
||||
"identity",
|
||||
"capabilities",
|
||||
"command",
|
||||
"provider-descriptor",
|
||||
"provider-event",
|
||||
"workspace-diff",
|
||||
"workspace-file-reference",
|
||||
"semantic-tool",
|
||||
"usage",
|
||||
"stop-reason",
|
||||
"terminal",
|
||||
"request",
|
||||
|
||||
@@ -17,7 +17,23 @@
|
||||
"run": "Structured result negotiation and terminal run outcome.",
|
||||
"attention": "Continuation and attention routing lifecycle.",
|
||||
"work": "Recorded work-assessment evidence.",
|
||||
"issue": "Issue-status decision proposal outcome and application evidence."
|
||||
"issue": "Issue-status decision proposal outcome and application evidence.",
|
||||
"plan": "Structured provider plan revisions and completion commits.",
|
||||
"tool": "Provider-neutral process, MCP, dynamic, and built-in execution activity.",
|
||||
"research": "Provider-reported search, page-open, and in-page research activity.",
|
||||
"delegation": "Child-agent delegation lifecycle and aggregate status.",
|
||||
"model": "Requested/effective model routing and verification state.",
|
||||
"context": "Context-window compaction markers without hidden summaries.",
|
||||
"artifact": "Authorized artifact viewing and structured generated outputs.",
|
||||
"review": "Provider review-mode state, separate from Paperclip authority.",
|
||||
"hook": "Bounded provider hook lifecycle and blocking outcomes.",
|
||||
"memory": "Authorized or unavailable memory citation references.",
|
||||
"safety": "Provider safety review state attached to governed work.",
|
||||
"terminal": "Content-free terminal input activity metadata.",
|
||||
"wait": "Intentional provider waits, distinct from warm idle and human input.",
|
||||
"provider": "Redacted provider notices and actionable warnings.",
|
||||
"semantic_tool": "Canonical authorized Paperclip tool input and result evidence.",
|
||||
"usage": "Provider/model-attributed usage and accounting boundaries."
|
||||
},
|
||||
"prpCommandFamilyDescriptions": {
|
||||
"semantic_tool": "Returns an authorized, correlated Paperclip tool result to the provider through runnerd.",
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface.
|
||||
|
||||
This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.
|
||||
|
||||
The generated totals are **71 PRP events in 15 event families**, **16 controller commands in 7 command families**, **10 control-plane operations**, **41 reconciled semantic operations** (14 always, 27 optional), and **106 scenarios in 16 behavior groups**.
|
||||
The generated totals are **103 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **41 reconciled semantic operations** (14 always, 27 optional), and **106 scenarios in 16 behavior groups**.
|
||||
|
||||
## Axis 1: PRP v1 event and command families
|
||||
|
||||
@@ -21,9 +21,25 @@ PRP records ordered, replayable execution evidence. It is not the model's Paperc
|
||||
| `sandbox` | Sandbox resource measurements. | `sandbox.metric` | 1 |
|
||||
| `workspace` | Workspace readiness. | `workspace.ready`<br>`workspace.change.updated`<br>`workspace.diff.recorded`<br>`workspace.file.referenced` | 4 |
|
||||
| `harness` | Provider harness startup, readiness, exit, and diagnostics. | `harness.starting`<br>`harness.ready`<br>`harness.exited`<br>`harness.diagnostic` | 4 |
|
||||
| `session` | Provider-neutral session open, resume, reconciliation, close, and failure. | `session.starting`<br>`session.started`<br>`session.resuming`<br>`session.resumed`<br>`session.reconciled`<br>`session.closed`<br>`session.failed` | 7 |
|
||||
| `plan` | Structured provider plan revisions and completion commits. | `plan.updated` | 1 |
|
||||
| `tool` | Provider-neutral process, MCP, dynamic, and built-in execution activity. | `tool.execution.started`<br>`tool.execution.progressed`<br>`tool.execution.completed` | 3 |
|
||||
| `research` | Provider-reported search, page-open, and in-page research activity. | `research.started`<br>`research.progressed`<br>`research.completed` | 3 |
|
||||
| `delegation` | Child-agent delegation lifecycle and aggregate status. | `delegation.started`<br>`delegation.updated`<br>`delegation.completed` | 3 |
|
||||
| `model` | Requested/effective model routing and verification state. | `model.route.changed`<br>`model.verification.updated` | 2 |
|
||||
| `context` | Context-window compaction markers without hidden summaries. | `context.compacted` | 1 |
|
||||
| `artifact` | Authorized artifact viewing and structured generated outputs. | `artifact.viewed`<br>`artifact.generated` | 2 |
|
||||
| `review` | Provider review-mode state, separate from Paperclip authority. | `review.mode.changed` | 1 |
|
||||
| `hook` | Bounded provider hook lifecycle and blocking outcomes. | `hook.started`<br>`hook.completed` | 2 |
|
||||
| `memory` | Authorized or unavailable memory citation references. | `memory.citation.referenced` | 1 |
|
||||
| `safety` | Provider safety review state attached to governed work. | `safety.review.started`<br>`safety.review.completed` | 2 |
|
||||
| `terminal` | Content-free terminal input activity metadata. | `terminal.input.sent` | 1 |
|
||||
| `wait` | Intentional provider waits, distinct from warm idle and human input. | `wait.started`<br>`wait.completed` | 2 |
|
||||
| `provider` | Redacted provider notices and actionable warnings. | `provider.notice.recorded` | 1 |
|
||||
| `session` | Provider-neutral session open, resume, reconciliation, close, and failure. | `session.starting`<br>`session.started`<br>`session.resuming`<br>`session.resumed`<br>`session.reconciled`<br>`session.updated`<br>`session.closed`<br>`session.failed` | 8 |
|
||||
| `turn` | Model turn submission through terminal turn disposition. | `turn.submitted`<br>`turn.accepted`<br>`turn.started`<br>`turn.completed`<br>`turn.failed`<br>`turn.interrupted`<br>`turn.cancelled` | 7 |
|
||||
| `item` | Provider-neutral model/tool item lifecycle. | `item.started`<br>`item.delta`<br>`item.completed`<br>`item.failed` | 4 |
|
||||
| `usage` | Provider/model-attributed usage and accounting boundaries. | `usage.reported` | 1 |
|
||||
| `semantic_tool` | Canonical authorized Paperclip tool input and result evidence. | `semantic_tool.input`<br>`semantic_tool.result` | 2 |
|
||||
| `mcp_app` | MCP App discovery, initialization, tool, action, host-context, and teardown evidence. | `mcp_app.discovered`<br>`mcp_app.resource.resolved`<br>`mcp_app.initializing`<br>`mcp_app.ready`<br>`mcp_app.tool_input`<br>`mcp_app.tool_result`<br>`mcp_app.action.requested`<br>`mcp_app.action.resolved`<br>`mcp_app.host_context.changed`<br>`mcp_app.failed`<br>`mcp_app.teardown` | 11 |
|
||||
| `runtime_request` | Runtime permission/input request lifecycle. | `runtime_request.created`<br>`runtime_request.resolved`<br>`runtime_request.expired`<br>`runtime_request.cancelled` | 4 |
|
||||
| `interaction` | Issue-thread interaction proposal, materialization, response, delivery, and rejection. | `interaction.request.proposed`<br>`interaction.request.materialized`<br>`interaction.request.rejected`<br>`interaction.response.progressed`<br>`interaction.response.resolved`<br>`interaction.response.delivered` | 6 |
|
||||
@@ -37,7 +53,7 @@ PRP records ordered, replayable execution evidence. It is not the model's Paperc
|
||||
| Family | Purpose | Commands | Count |
|
||||
| --- | --- | --- | ---: |
|
||||
| `run` | Prepare or cancel a run. | `run.prepare`<br>`run.attach`<br>`run.cancel` | 3 |
|
||||
| `session` | Open, snapshot, or close a normalized provider session. | `session.open`<br>`session.snapshot`<br>`session.close` | 3 |
|
||||
| `session` | Open, snapshot, or close a normalized provider session. | `session.open`<br>`session.snapshot`<br>`session.close`<br>`session.budget.increase`<br>`session.destroy` | 5 |
|
||||
| `turn` | Start, steer, interrupt, or stop a model turn. | `turn.start`<br>`turn.steer`<br>`turn.interrupt`<br>`turn.stop` | 4 |
|
||||
| `request` | Resolve a pending runtime request. | `request.resolve` | 1 |
|
||||
| `interaction` | Acknowledge delivery of an interaction response. | `interaction.receipt` | 1 |
|
||||
|
||||
@@ -34,6 +34,18 @@ export function createNativeSessionBackend(
|
||||
transportFactory: options.codexTransportFactory,
|
||||
});
|
||||
}
|
||||
if (input.provider.kind === "claude_managed") {
|
||||
if (!options.codexTransportFactory) {
|
||||
throw new Error("Claude Agent native backend requires the runnerd remote-provider transport");
|
||||
}
|
||||
return createCodexNativeSessionBackend(input, {
|
||||
runnerInstanceId: options.runnerInstanceId,
|
||||
onSpawn: options.onSpawn,
|
||||
dynamicTools: options.dynamicTools,
|
||||
dynamicToolHandler: options.dynamicToolHandler,
|
||||
transportFactory: options.codexTransportFactory,
|
||||
});
|
||||
}
|
||||
if (!options.opencodeRuntimeDirectory) {
|
||||
throw new Error("OpenCode native backend requires an instance runtime directory");
|
||||
}
|
||||
|
||||
@@ -68,8 +68,8 @@ describe("canonical operation-groups specification", () => {
|
||||
expect(input.source.behaviorGroups).toHaveLength(16);
|
||||
expect(input.scenarios).toHaveLength(106);
|
||||
expect(new Set(input.scenarios.map((scenario) => scenario.id)).size).toBe(106);
|
||||
expect(input.eventTypes).toHaveLength(74);
|
||||
expect(input.commandTypes).toHaveLength(16);
|
||||
expect(input.eventTypes).toHaveLength(103);
|
||||
expect(input.commandTypes).toHaveLength(18);
|
||||
});
|
||||
|
||||
it("keeps the checked-in Markdown byte-identical to the generated contract", () => {
|
||||
|
||||
@@ -99,8 +99,8 @@ export interface OperationGroupsInput {
|
||||
catalogIndexSource: string;
|
||||
}
|
||||
|
||||
const EXPECTED_EVENT_COUNT = 74;
|
||||
const EXPECTED_COMMAND_COUNT = 16;
|
||||
const EXPECTED_EVENT_COUNT = 103;
|
||||
const EXPECTED_COMMAND_COUNT = 18;
|
||||
const EXPECTED_SCENARIO_COUNT = 106;
|
||||
const EXPECTED_BEHAVIOR_GROUP_COUNT = 16;
|
||||
|
||||
@@ -282,7 +282,7 @@ export function renderOperationGroupsDocument(input: OperationGroupsInput): stri
|
||||
"",
|
||||
"This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.",
|
||||
"",
|
||||
"The generated totals are **71 PRP events in 15 event families**, **16 controller commands in 7 command families**, **10 control-plane operations**, **41 reconciled semantic operations** (14 always, 27 optional), and **106 scenarios in 16 behavior groups**.",
|
||||
`The generated totals are **${eventTypes.length} PRP events in ${families(eventTypes).length} event families**, **${commandTypes.length} controller commands in ${families(commandTypes).length} command families**, **${controlPlaneOperationIds.length} control-plane operations**, **${canonicalOperations.length} reconciled semantic operations** (${always.length} always, ${optional.length} optional), and **${scenarios.length} scenarios in ${source.behaviorGroups.length} behavior groups**.`,
|
||||
"",
|
||||
"## Axis 1: PRP v1 event and command families",
|
||||
"",
|
||||
|
||||
@@ -71,6 +71,9 @@ async function open(params: Record<string, unknown>, resume: boolean): Promise<R
|
||||
arguments: call.arguments,
|
||||
}),
|
||||
onDiagnostic: (message) => process.stderr.write(`[opencode] ${message}\n`),
|
||||
// runnerd gives this proxy its own process group. Keep `opencode serve` in
|
||||
// that same group so runnerd's TERM/KILL fallback cannot orphan it.
|
||||
isolateProcessGroup: false,
|
||||
});
|
||||
if (resume) {
|
||||
const threadId = text(params.threadId);
|
||||
@@ -239,11 +242,17 @@ input.on("line", (line) => {
|
||||
void handle(message).catch((error) => send({ id: message.id, error: { code: -32000, message: error instanceof Error ? error.message : String(error) } }));
|
||||
});
|
||||
|
||||
async function shutdown(): Promise<void> {
|
||||
await session?.close({ reason: "proxy_shutdown", force: true }).catch(() => {});
|
||||
await eventPump?.catch(() => {});
|
||||
process.exit(0);
|
||||
let shutdownPromise: Promise<void> | null = null;
|
||||
function shutdown(): Promise<void> {
|
||||
shutdownPromise ??= (async () => {
|
||||
for (const waiter of pending.values()) waiter.reject(new Error("OpenCode proxy is shutting down"));
|
||||
pending.clear();
|
||||
await session?.close({ reason: "proxy_shutdown", force: true }).catch(() => {});
|
||||
await eventPump?.catch(() => {});
|
||||
})().finally(() => process.exit(0));
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
input.on("close", () => { void shutdown(); });
|
||||
process.on("SIGTERM", () => { void shutdown(); });
|
||||
process.on("SIGINT", () => { void shutdown(); });
|
||||
@@ -91,6 +91,39 @@ describe("NativeExecutionInputV1", () => {
|
||||
expect(parseNativeExecutionInput(legacy).provider).toEqual({ kind: "codex", model: null });
|
||||
});
|
||||
|
||||
it("accepts an immutable Claude Managed Agent profile and rejects driver or beta drift", () => {
|
||||
const claudeManaged = {
|
||||
...input,
|
||||
session: { ...input.session, driverKind: "claude_managed_agents_api" },
|
||||
provider: {
|
||||
kind: "claude_managed",
|
||||
model: "claude-sonnet-5",
|
||||
managedProfile: {
|
||||
profileId: "managed-profile-1",
|
||||
anthropicAgentId: "agent_01",
|
||||
agentVersion: "3",
|
||||
environmentId: "environment_01",
|
||||
betaVersion: "managed-agents-2026-04-01",
|
||||
},
|
||||
maxSessionListCostUsd: 1,
|
||||
},
|
||||
} as const;
|
||||
const parsed = parseNativeExecutionInput(claudeManaged);
|
||||
expect(parsed.provider).toEqual(claudeManaged.provider);
|
||||
expect(buildNativeModelEnvelope(parsed).workspace).toBeNull();
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...claudeManaged,
|
||||
session: { ...claudeManaged.session, driverKind: "codex_app_server" },
|
||||
})).toThrow("does not match");
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...claudeManaged,
|
||||
provider: {
|
||||
...claudeManaged.provider,
|
||||
managedProfile: { ...claudeManaged.provider.managedProfile, betaVersion: "future-beta" },
|
||||
},
|
||||
})).toThrow("betaVersion");
|
||||
});
|
||||
|
||||
it("defaults legacy lifecycle state to per-turn and validates warm timeouts", () => {
|
||||
const legacy = structuredClone(input) as Record<string, unknown>;
|
||||
delete (legacy.session as Record<string, unknown>).lifecyclePolicy;
|
||||
|
||||
@@ -27,6 +27,24 @@ export type NativeSessionLifecyclePolicy =
|
||||
| { mode: "per_turn"; idleTimeoutMs: null }
|
||||
| { mode: "warm"; idleTimeoutMs: number };
|
||||
|
||||
export interface NativeManagedAgentProfileSnapshot {
|
||||
profileId: string;
|
||||
anthropicAgentId: string;
|
||||
agentVersion: string;
|
||||
environmentId: string;
|
||||
betaVersion: "managed-agents-2026-04-01";
|
||||
}
|
||||
|
||||
export type NativeProviderConfig =
|
||||
| { kind: "codex"; model: string | null }
|
||||
| { kind: "opencode"; model: string }
|
||||
| {
|
||||
kind: "claude_managed";
|
||||
model: string;
|
||||
managedProfile: NativeManagedAgentProfileSnapshot;
|
||||
maxSessionListCostUsd: number;
|
||||
};
|
||||
|
||||
export interface NativeExecutionInputV1 {
|
||||
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA;
|
||||
binding: {
|
||||
@@ -52,14 +70,11 @@ export interface NativeExecutionInputV1 {
|
||||
};
|
||||
session: {
|
||||
normalizedSessionId: string | null;
|
||||
driverKind: "codex_app_server" | "opencode_server";
|
||||
driverKind: "codex_app_server" | "opencode_server" | "claude_managed_agents_api";
|
||||
protocolVersion: 1;
|
||||
lifecyclePolicy: NativeSessionLifecyclePolicy;
|
||||
};
|
||||
provider: {
|
||||
kind: "codex" | "opencode";
|
||||
model: string | null;
|
||||
};
|
||||
provider: NativeProviderConfig;
|
||||
completionContract: {
|
||||
id: string;
|
||||
sha256: string;
|
||||
@@ -74,7 +89,8 @@ export interface NativeExecutionInputV1 {
|
||||
export interface NativeModelEnvelopeV1 {
|
||||
schema: typeof NATIVE_MODEL_ENVELOPE_SCHEMA;
|
||||
task: NativeExecutionInputV1["task"];
|
||||
workspace: Pick<NativeExecutionInputV1["workspace"], "cwd">;
|
||||
/** Remote providers have no Paperclip workspace mounted in their service. */
|
||||
workspace: Pick<NativeExecutionInputV1["workspace"], "cwd"> | null;
|
||||
completionContract: StrictCompletionContractInput;
|
||||
interactionResponses: NativeInteractionResponseEnvelope[];
|
||||
}
|
||||
@@ -164,7 +180,11 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
|
||||
|
||||
if (task.workMode !== "standard") throw new NativeExecutionInputError("input.task.workMode must be standard");
|
||||
if (
|
||||
(session.driverKind !== "codex_app_server" && session.driverKind !== "opencode_server")
|
||||
(
|
||||
session.driverKind !== "codex_app_server"
|
||||
&& session.driverKind !== "opencode_server"
|
||||
&& session.driverKind !== "claude_managed_agents_api"
|
||||
)
|
||||
|| session.protocolVersion !== 1
|
||||
) {
|
||||
throw new NativeExecutionInputError("input.session must select a supported protocol version 1 driver");
|
||||
@@ -190,13 +210,20 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
|
||||
const provider = input.provider === undefined
|
||||
? { kind: "codex", model: null }
|
||||
: record(input.provider, "input.provider");
|
||||
exactKeys(provider, ["kind", "model"], "input.provider");
|
||||
if (provider.kind !== "codex" && provider.kind !== "opencode") {
|
||||
throw new NativeExecutionInputError("input.provider.kind must be codex or opencode");
|
||||
if (provider.kind !== "codex" && provider.kind !== "opencode" && provider.kind !== "claude_managed") {
|
||||
throw new NativeExecutionInputError("input.provider.kind must be codex, opencode, or claude_managed");
|
||||
}
|
||||
exactKeys(
|
||||
provider,
|
||||
provider.kind === "claude_managed"
|
||||
? ["kind", "model", "managedProfile", "maxSessionListCostUsd"]
|
||||
: ["kind", "model"],
|
||||
"input.provider",
|
||||
);
|
||||
if (
|
||||
(provider.kind === "codex" && session.driverKind !== "codex_app_server")
|
||||
|| (provider.kind === "opencode" && session.driverKind !== "opencode_server")
|
||||
|| (provider.kind === "claude_managed" && session.driverKind !== "claude_managed_agents_api")
|
||||
) {
|
||||
throw new NativeExecutionInputError("input.provider.kind does not match input.session.driverKind");
|
||||
}
|
||||
@@ -206,6 +233,49 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
|
||||
if (provider.kind === "opencode" && (providerModel === null || !providerModel.includes("/"))) {
|
||||
throw new NativeExecutionInputError("input.provider.model is required for opencode in provider/model form");
|
||||
}
|
||||
let parsedProvider: NativeProviderConfig;
|
||||
if (provider.kind === "claude_managed") {
|
||||
if (providerModel === null) {
|
||||
throw new NativeExecutionInputError("input.provider.model is required for claude_managed");
|
||||
}
|
||||
const managedProfile = record(provider.managedProfile, "input.provider.managedProfile");
|
||||
exactKeys(
|
||||
managedProfile,
|
||||
["profileId", "anthropicAgentId", "agentVersion", "environmentId", "betaVersion"],
|
||||
"input.provider.managedProfile",
|
||||
);
|
||||
if (managedProfile.betaVersion !== "managed-agents-2026-04-01") {
|
||||
throw new NativeExecutionInputError(
|
||||
"input.provider.managedProfile.betaVersion must be managed-agents-2026-04-01",
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof provider.maxSessionListCostUsd !== "number"
|
||||
|| !Number.isFinite(provider.maxSessionListCostUsd)
|
||||
|| provider.maxSessionListCostUsd <= 0
|
||||
) {
|
||||
throw new NativeExecutionInputError("input.provider.maxSessionListCostUsd must be a positive finite number");
|
||||
}
|
||||
parsedProvider = {
|
||||
kind: "claude_managed",
|
||||
model: providerModel,
|
||||
managedProfile: {
|
||||
profileId: text(managedProfile.profileId, "input.provider.managedProfile.profileId"),
|
||||
anthropicAgentId: text(
|
||||
managedProfile.anthropicAgentId,
|
||||
"input.provider.managedProfile.anthropicAgentId",
|
||||
),
|
||||
agentVersion: text(managedProfile.agentVersion, "input.provider.managedProfile.agentVersion"),
|
||||
environmentId: text(managedProfile.environmentId, "input.provider.managedProfile.environmentId"),
|
||||
betaVersion: "managed-agents-2026-04-01",
|
||||
},
|
||||
maxSessionListCostUsd: provider.maxSessionListCostUsd,
|
||||
};
|
||||
} else if (provider.kind === "opencode") {
|
||||
parsedProvider = { kind: "opencode", model: providerModel! };
|
||||
} else {
|
||||
parsedProvider = { kind: "codex", model: providerModel };
|
||||
}
|
||||
if (!Array.isArray(contract.criteria) || contract.criteria.length === 0) {
|
||||
throw new NativeExecutionInputError("input.completionContract.contract.criteria must not be empty");
|
||||
}
|
||||
@@ -274,10 +344,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
|
||||
protocolVersion: 1,
|
||||
lifecyclePolicy,
|
||||
},
|
||||
provider: {
|
||||
kind: provider.kind,
|
||||
model: providerModel,
|
||||
},
|
||||
provider: parsedProvider,
|
||||
completionContract: {
|
||||
id: text(completionContract.id, "input.completionContract.id"),
|
||||
sha256: text(completionContract.sha256, "input.completionContract.sha256"),
|
||||
@@ -297,7 +364,7 @@ export function buildNativeModelEnvelope(input: NativeExecutionInputV1): NativeM
|
||||
return {
|
||||
schema: NATIVE_MODEL_ENVELOPE_SCHEMA,
|
||||
task: structuredClone(input.task),
|
||||
workspace: { cwd: input.workspace.cwd },
|
||||
workspace: input.provider.kind === "claude_managed" ? null : { cwd: input.workspace.cwd },
|
||||
completionContract: structuredClone(input.completionContract.contract),
|
||||
interactionResponses: structuredClone(input.interactionResponses),
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface NativeRunResult {
|
||||
export interface NativeSessionCapabilities {
|
||||
resume: boolean;
|
||||
typedEvents: boolean;
|
||||
typedEventFamilies?: TypedEventFamilyCapability[];
|
||||
steering: boolean;
|
||||
interruption: boolean;
|
||||
structuredResult: boolean;
|
||||
@@ -43,3 +44,4 @@ export interface NativeUserMessage {
|
||||
role: "user";
|
||||
text: string;
|
||||
}
|
||||
import type { TypedEventFamilyCapability } from "../provider-events.js";
|
||||
@@ -1326,13 +1326,19 @@ export class DurablePrpControlPlane {
|
||||
|
||||
const semantic = (event.payload as Record<string, unknown> | undefined)?.semantic_tool as Record<string, unknown> | undefined;
|
||||
if (
|
||||
newlyCommitted && eventType === "mcp_app.tool_input" && this.#onSemanticToolInput && semantic?.phase === "input" &&
|
||||
newlyCommitted && (eventType === "semantic_tool.input" || eventType === "mcp_app.tool_input") && this.#onSemanticToolInput && semantic?.phase === "input" &&
|
||||
typeof semantic.callId === "string" && typeof semantic.operationId === "string"
|
||||
) {
|
||||
const call = { callId: semantic.callId, operationId: semantic.operationId, input: semantic.input };
|
||||
void this.#onSemanticToolInput(call).then((result) => {
|
||||
void this.#onSemanticToolInput(call).then((value) => {
|
||||
const outcome = typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
const wrapped = outcome.__paperclipSemanticToolOutcome === true;
|
||||
const result = wrapped ? outcome.result : value;
|
||||
const isError = wrapped && outcome.isError === true;
|
||||
const runScope = createHash("sha256").update(this.identity.runId).digest("hex").slice(0, 12);
|
||||
this.queueCommand("semantic_tool.result", { ...call, result }, `command_tool_${runScope}_${call.callId}`, true);
|
||||
this.queueCommand("semantic_tool.result", { ...call, result, isError }, `command_tool_${runScope}_${call.callId}`, true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1386,7 +1392,8 @@ function runnerEnvironment(ticket: string, source: NodeJS.ProcessEnv = process.e
|
||||
for (const key of [
|
||||
"PATH", "SystemRoot", "WINDIR", "PATHEXT", "LANG", "LC_ALL", "TZ",
|
||||
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR",
|
||||
"OPENROUTER_API_KEY", "PAPERCLIP_OPENCODE_COMMAND", "PAPERCLIP_OPENCODE_RUNTIME_DIR",
|
||||
"OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "RUST_BACKTRACE",
|
||||
"PAPERCLIP_OPENCODE_COMMAND", "PAPERCLIP_OPENCODE_RUNTIME_DIR",
|
||||
"PAPERCLIP_RUNNER_INSTANCE_ID", "PAPERCLIP_RUN_ID", "PAPERCLIP_NORMALIZED_SESSION_ID",
|
||||
]) {
|
||||
const value = source[key];
|
||||
@@ -1644,7 +1651,9 @@ export async function runDurableToolBridgeConformance(
|
||||
try {
|
||||
const deadline = Date.now() + (options.realCodex ? 150_000 : 10_000);
|
||||
while (Date.now() < deadline) {
|
||||
const input = core.store.state.committedEvents.find((event) => event.eventType === "mcp_app.tool_input");
|
||||
const input = core.store.state.committedEvents.find((event) =>
|
||||
event.eventType === "semantic_tool.input" || event.eventType === "mcp_app.tool_input"
|
||||
);
|
||||
const semantic = ((input?.envelope.payload as Record<string, unknown> | undefined)?.payload as Record<string, unknown> | undefined)?.semantic_tool as Record<string, unknown> | undefined;
|
||||
if (semantic && typeof semantic.callId === "string" && typeof semantic.operationId === "string") {
|
||||
callId = semantic.callId;
|
||||
@@ -1722,6 +1731,7 @@ export class DurableEvalInfrastructureError extends Error {
|
||||
function evalLifecycleDiagnostics(
|
||||
core: DurableRecoveryMockCore,
|
||||
handle: RunnerProcessHandle,
|
||||
runnerStateDirectory?: string,
|
||||
): Record<string, unknown> {
|
||||
const commands = core.store.state.commands.map((command) => ({
|
||||
commandId: command.commandId,
|
||||
@@ -1735,6 +1745,15 @@ function evalLifecycleDiagnostics(
|
||||
priority: event.priority,
|
||||
deliveryCount: event.deliveryCount,
|
||||
}));
|
||||
let runnerDiagnostics: unknown[] = [];
|
||||
if (runnerStateDirectory) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(resolve(runnerStateDirectory, "runner-state.json"), "utf8")) as { diagnostics?: unknown[] };
|
||||
runnerDiagnostics = Array.isArray(state.diagnostics) ? state.diagnostics.slice(-20) : [];
|
||||
} catch {
|
||||
// A runner that failed before its first durable save has no state diagnostics.
|
||||
}
|
||||
}
|
||||
return {
|
||||
runnerPid: handle.child.pid ?? null,
|
||||
runnerExitCode: handle.child.exitCode,
|
||||
@@ -1743,9 +1762,26 @@ function evalLifecycleDiagnostics(
|
||||
ackedSourceSeq: core.store.state.ackedSourceSeq,
|
||||
commands,
|
||||
recentEvents: events,
|
||||
runnerDiagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
function projectedSemanticResult(operationId: unknown, result: unknown): unknown {
|
||||
if (operationId !== "invoke_discovered_capability") return result;
|
||||
const contentItems = (result as { contentItems?: unknown } | null)?.contentItems;
|
||||
if (!Array.isArray(contentItems)) return result;
|
||||
for (const item of contentItems) {
|
||||
const text = (item as { text?: unknown } | null)?.text;
|
||||
if (typeof text !== "string") continue;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
// Preserve the bounded gateway response when its content is not JSON.
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Production-topology eval: the only Paperclip process in the sandbox is runnerd. */
|
||||
export async function runDurableEvalSession(input: DurableEvalSessionInput): Promise<Record<string, unknown>> {
|
||||
const identity = {
|
||||
@@ -1810,7 +1846,11 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
|
||||
const args = (call.input ?? {}) as Record<string, unknown>;
|
||||
const operationId = String(args.operationId ?? "");
|
||||
if (!loadedOperations.has(operationId)) return { ok: false, denial: { code: "operation_not_loaded", message: "Operation was not loaded by capability discovery." } };
|
||||
return dispatcher.dispatch({ runId: identity.runId, callId: call.callId, operationId, input: args.input });
|
||||
const invoked = await dispatcher.dispatch({ runId: identity.runId, callId: call.callId, operationId, input: args.input });
|
||||
return {
|
||||
success: invoked.ok,
|
||||
contentItems: [{ type: "inputText", text: JSON.stringify(invoked) }],
|
||||
};
|
||||
}
|
||||
return dispatcher.dispatch({ runId: identity.runId, ...call });
|
||||
},
|
||||
@@ -1870,7 +1910,7 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
|
||||
"provider_turn_timeout",
|
||||
`durable provider turn timed out after ${input.turnTimeoutMs}ms`,
|
||||
true,
|
||||
evalLifecycleDiagnostics(core, handle),
|
||||
evalLifecycleDiagnostics(core, handle, runnerStateDirectory),
|
||||
);
|
||||
}
|
||||
core.queueCommand("runner.shutdown", {}, undefined, true);
|
||||
@@ -1882,7 +1922,7 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
|
||||
"runner_shutdown_timeout",
|
||||
`runnerd did not exit within 30000ms after runner.shutdown: ${error instanceof Error ? error.message : String(error)}`,
|
||||
true,
|
||||
evalLifecycleDiagnostics(core, handle),
|
||||
evalLifecycleDiagnostics(core, handle, runnerStateDirectory),
|
||||
);
|
||||
}
|
||||
if (exited.code !== 0) {
|
||||
@@ -1890,7 +1930,7 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
|
||||
"runner_exit_failure",
|
||||
`runnerd exited ${String(exited.code)}: ${exited.stderr}`,
|
||||
false,
|
||||
{ ...evalLifecycleDiagnostics(core, handle), stderr: exited.stderr.slice(-16_384) },
|
||||
{ ...evalLifecycleDiagnostics(core, handle, runnerStateDirectory), stderr: exited.stderr.slice(-16_384) },
|
||||
);
|
||||
}
|
||||
const records = core.store.state.committedEvents.map((committed) => committed.envelope.payload as Record<string, unknown>);
|
||||
@@ -1925,7 +1965,7 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
|
||||
at: new Date().toISOString(), turnId: identity.turnId,
|
||||
data: semantic.phase === "input"
|
||||
? { callId: semantic.callId, operationId: projectedOperationId, input: projectedInput }
|
||||
: { callId: semantic.callId, operationId: projectedOperationId, result: semantic.result },
|
||||
: { callId: semantic.callId, operationId: projectedOperationId, result: projectedSemanticResult(semantic.operationId, semantic.result) },
|
||||
});
|
||||
}
|
||||
if (record.eventType === "provider.event") {
|
||||
@@ -1950,6 +1990,17 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
|
||||
};
|
||||
}
|
||||
}
|
||||
if (record.eventType === "usage.reported") {
|
||||
const runDelta = payload?.runDelta as Record<string, number> | undefined;
|
||||
if (runDelta) {
|
||||
usage = {
|
||||
inputTokens: runDelta.inputTokens ?? 0,
|
||||
outputTokens: runDelta.outputTokens ?? 0,
|
||||
cachedInputTokens: runDelta.cacheReadTokens ?? 0,
|
||||
reasoningTokens: runDelta.reasoningTokens ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (record.eventType === "turn.completed") {
|
||||
const params = payload?.params as Record<string, unknown> | undefined;
|
||||
const turn = params?.turn as Record<string, unknown> | undefined;
|
||||
@@ -1972,7 +2023,7 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
|
||||
evidenceIndex += 1;
|
||||
evidence.push({
|
||||
id: `evidence-${evidenceIndex}`, kind: "tool_result", at: new Date().toISOString(), turnId: identity.turnId,
|
||||
data: { callId: payload.callId, operationId: projectedOperationId, result: payload.result },
|
||||
data: { callId: payload.callId, operationId: projectedOperationId, result: projectedSemanticResult(payload.operationId, payload.result) },
|
||||
});
|
||||
}
|
||||
if (!assistantText) assistantText = "Provider completed the requested Paperclip operation.";
|
||||
@@ -2223,4 +2274,5 @@ export const durableRecoveryInternals = {
|
||||
tokenDigest,
|
||||
canonicalJson,
|
||||
durableRecoveryIdentity,
|
||||
projectedSemanticResult,
|
||||
};
|
||||
@@ -43,6 +43,10 @@ import {
|
||||
} from "../../protocol/replay-contract.js";
|
||||
import type { NativeUserMessage } from "../../contracts/types.js";
|
||||
import { paperclipWorkspaceFileReferencesFromText } from "../../live/workspace-file-reference.js";
|
||||
import {
|
||||
canonicalProviderEventsFromCodex,
|
||||
providerFamilyCapabilities,
|
||||
} from "../../provider-events.js";
|
||||
import {
|
||||
ProcessCodexAppServerTransport,
|
||||
createSanitizedCodexEnvironment,
|
||||
@@ -357,6 +361,22 @@ export class CodexAppServerDriver implements HarnessDriver {
|
||||
capabilities: {
|
||||
resume: this.#caps.resume,
|
||||
typedEvents: true,
|
||||
typedEventFamilies: providerFamilyCapabilities({
|
||||
plan: "available",
|
||||
tool_execution: "available",
|
||||
research: "available",
|
||||
delegation: "available",
|
||||
model_identity: "available",
|
||||
context: "available",
|
||||
artifact: "policy_disabled",
|
||||
review: "available",
|
||||
hook: "available",
|
||||
memory: "available",
|
||||
safety: "available",
|
||||
terminal: "available",
|
||||
wait: "available",
|
||||
provider_notice: "available",
|
||||
}),
|
||||
steering: this.#caps.steering,
|
||||
interruption: this.#caps.interruption,
|
||||
structuredResult: true,
|
||||
@@ -1159,6 +1179,17 @@ class CodexHarnessSession implements HarnessSession {
|
||||
const threadId = text(params.threadId);
|
||||
const turnId = text(params.turnId, text(turn.id));
|
||||
const itemId = text(item.id, text(params.itemId));
|
||||
if (
|
||||
(threadId.length === 0 || threadId === this.#opened.threadId) &&
|
||||
(turnId.length === 0 || this.#activeTurnId === null || turnId === this.#activeTurnId)
|
||||
) {
|
||||
for (const canonical of canonicalProviderEventsFromCodex(notification.method, params)) {
|
||||
this.#emit(canonical.eventType, canonical.payload, {
|
||||
turnId: turnId || undefined,
|
||||
itemId: canonical.itemId,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (notification.method === "error" || notification.method === "warning" || notification.method === "configWarning") {
|
||||
this.#emit("harness.diagnostic", {
|
||||
code: notification.method.replaceAll("/", "_"),
|
||||
@@ -2100,6 +2131,15 @@ function isBoundCodexNotification(method: string): boolean {
|
||||
method === "error" ||
|
||||
method === "warning" ||
|
||||
method === "configWarning" ||
|
||||
method === "guardianWarning" ||
|
||||
method === "deprecationNotice" ||
|
||||
method === "windows/worldWritableWarning" ||
|
||||
method === "hook/started" ||
|
||||
method === "hook/completed" ||
|
||||
method === "thread/compacted" ||
|
||||
method === "model/rerouted" ||
|
||||
method === "model/verification" ||
|
||||
method === "model/safetyBuffering/updated" ||
|
||||
method.startsWith("item/") ||
|
||||
method === "turn/diff/updated" ||
|
||||
method === "turn/plan/updated"
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("OpenCodeServerDriver", () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
|
||||
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-"));
|
||||
roots.push(root, workspace);
|
||||
const spawns: Array<{ pid: number }> = [];
|
||||
const spawns: Array<{ pid: number; processGroupId: number | null }> = [];
|
||||
const diagnostics: string[] = [];
|
||||
const driver = new OpenCodeServerDriver({
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
@@ -84,6 +84,25 @@ describe("OpenCodeServerDriver", () => {
|
||||
expect(diagnostics.join("\n")).toContain("[REDACTED]");
|
||||
});
|
||||
|
||||
it("keeps OpenCode in an outer supervisor process group when requested", async () => {
|
||||
await chmod(fixture, 0o755);
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
|
||||
const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-"));
|
||||
roots.push(root, workspace);
|
||||
let processGroupId: number | null | undefined;
|
||||
const driver = new OpenCodeServerDriver({
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
runtimeDirectory: root,
|
||||
command: fixture,
|
||||
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
|
||||
isolateProcessGroup: false,
|
||||
onSpawn: async (meta) => { processGroupId = meta.processGroupId; },
|
||||
});
|
||||
const session = await driver.openSession({ runId: "run-supervised", normalizedSessionId: "supervised", workingDirectory: workspace });
|
||||
expect(processGroupId).toBeNull();
|
||||
await session.close({ reason: "test" });
|
||||
});
|
||||
|
||||
it("validates provider/model form", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-"));
|
||||
roots.push(root);
|
||||
|
||||
@@ -25,6 +25,10 @@ import type { NativeSessionCapabilities, NativeUserMessage } from "../../contrac
|
||||
import type { PrpEvent, PrpStructuredRunResult } from "../../protocol/replay-contract.js";
|
||||
import { validatePrpStructuredRunResult } from "../../protocol/replay-contract.js";
|
||||
import { paperclipWorkspaceFileReferencesFromText } from "../../live/workspace-file-reference.js";
|
||||
import {
|
||||
canonicalProviderEventsFromOpenCodePart,
|
||||
providerFamilyCapabilities,
|
||||
} from "../../provider-events.js";
|
||||
import { canonicalOpenCodeMcpToolName, startOpenCodeMcpBridge, type OpenCodeMcpBridge } from "./mcp-bridge.js";
|
||||
|
||||
export const OPENCODE_SERVER_DRIVER_KIND = "opencode_server" as const;
|
||||
@@ -49,6 +53,8 @@ export interface OpenCodeServerDriverOptions {
|
||||
dynamicTools?: readonly Readonly<Record<string, unknown>>[];
|
||||
dynamicToolHandler?: DynamicToolHandler;
|
||||
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
||||
/** Keep false when an outer supervisor already owns the process group. */
|
||||
isolateProcessGroup?: boolean;
|
||||
onDiagnostic?: (message: string) => void;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
now?: () => Date;
|
||||
@@ -67,6 +73,14 @@ interface OpenCodeRuntime {
|
||||
const CAPABILITIES: NativeSessionCapabilities = {
|
||||
resume: true,
|
||||
typedEvents: true,
|
||||
typedEventFamilies: providerFamilyCapabilities({
|
||||
tool_execution: "available",
|
||||
research: "available",
|
||||
delegation: "available",
|
||||
context: "available",
|
||||
artifact: "policy_disabled",
|
||||
provider_notice: "available",
|
||||
}),
|
||||
steering: false,
|
||||
interruption: true,
|
||||
structuredResult: true,
|
||||
@@ -521,6 +535,16 @@ class OpenCodeHarnessSession implements HarnessSession {
|
||||
return;
|
||||
}
|
||||
if (type === "session.error" && turnId) {
|
||||
this.#emit("provider.notice.recorded", {
|
||||
schema: "paperclip.provider.notice.v1",
|
||||
noticeId: `${turnId}:session-error`,
|
||||
severity: "error",
|
||||
category: "session_error",
|
||||
scope: "turn",
|
||||
recoverable: false,
|
||||
userActionable: true,
|
||||
summary: redact(text(record(properties.error).message, "OpenCode session failed."), this.#runtime.sensitiveValues).slice(0, 4000),
|
||||
}, { turnId, itemId: `${turnId}:session-error` });
|
||||
this.#emit("turn.failed", { status: "failed", error: bounded(properties.error ?? properties) }, { turnId });
|
||||
this.#terminalTurns.set(turnId, canonicalJson({ status: "failed" }));
|
||||
this.#activeTurnId = null;
|
||||
@@ -531,6 +555,9 @@ class OpenCodeHarnessSession implements HarnessSession {
|
||||
#emitAssistantPart(part: Record<string, unknown>, turnId: string): void {
|
||||
const partId = text(part.id, `${turnId}:part`);
|
||||
const partType = text(part.type, "unknown");
|
||||
for (const canonical of canonicalProviderEventsFromOpenCodePart(part)) {
|
||||
this.#emit(canonical.eventType, canonical.payload, { turnId, itemId: canonical.itemId });
|
||||
}
|
||||
if (partType === "patch") {
|
||||
const paths = Array.isArray(part.files) ? part.files : [];
|
||||
const files = paths.slice(0, 2_000).flatMap((value): Record<string, unknown>[] => {
|
||||
@@ -649,11 +676,12 @@ async function startRuntime(input: {
|
||||
OPENCODE_SERVER_USERNAME: username,
|
||||
OPENCODE_SERVER_PASSWORD: password,
|
||||
});
|
||||
const isolateProcessGroup = input.options.isolateProcessGroup ?? true;
|
||||
const child = spawn(input.options.command ?? "opencode", ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
|
||||
cwd: input.cwd,
|
||||
env: environment,
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: globalThis.process.platform !== "win32",
|
||||
detached: globalThis.process.platform !== "win32" && isolateProcessGroup,
|
||||
});
|
||||
let diagnostics = "";
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
@@ -667,7 +695,7 @@ async function startRuntime(input: {
|
||||
});
|
||||
if (child.pid) await input.options.onSpawn?.({
|
||||
pid: child.pid,
|
||||
processGroupId: globalThis.process.platform === "win32" ? null : child.pid,
|
||||
processGroupId: globalThis.process.platform === "win32" || !isolateProcessGroup ? null : child.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
@@ -688,14 +716,14 @@ async function startRuntime(input: {
|
||||
await bridge.close().catch(() => {});
|
||||
if (child.exitCode === null && child.signalCode === null && child.pid) {
|
||||
try {
|
||||
if (globalThis.process.platform === "win32") child.kill("SIGTERM");
|
||||
if (globalThis.process.platform === "win32" || !isolateProcessGroup) child.kill("SIGTERM");
|
||||
else globalThis.process.kill(-child.pid, "SIGTERM");
|
||||
} catch { child.kill("SIGTERM"); }
|
||||
}
|
||||
await waitForExit(child, 2_000);
|
||||
if (child.exitCode === null && child.signalCode === null && child.pid) {
|
||||
try {
|
||||
if (globalThis.process.platform === "win32") child.kill("SIGKILL");
|
||||
if (globalThis.process.platform === "win32" || !isolateProcessGroup) child.kill("SIGKILL");
|
||||
else globalThis.process.kill(-child.pid, "SIGKILL");
|
||||
} catch { child.kill("SIGKILL"); }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export * from "./backends/codex-native-backend.js";
|
||||
export * from "./backends/opencode-native-backend.js";
|
||||
export * from "./backends/native-backend-factory.js";
|
||||
export * from "./native-session-runtime.js";
|
||||
export * from "./provider-events.js";
|
||||
export {
|
||||
DurablePrpControlPlane,
|
||||
type DurablePrpControlPlaneOptions,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
CapabilityJsonValue,
|
||||
CapabilityEvidenceModel,
|
||||
CapabilityIssueThreadSnapshot,
|
||||
CapabilityThreadInteractionCard,
|
||||
@@ -29,6 +30,36 @@ export const CAPABILITY_UI_SHOT_SLUGS = [
|
||||
"debug-panel-open",
|
||||
"reconnect-banner",
|
||||
"replay-mode",
|
||||
"pe-structured-plan",
|
||||
"te-command-execution",
|
||||
"mp-mcp-progress",
|
||||
"rs-web-research",
|
||||
"da-subagent-delegation",
|
||||
"mr-model-routing",
|
||||
"cc-context-compaction",
|
||||
"ag-generated-artifact",
|
||||
"rv-review-mode",
|
||||
"hk-hook-lifecycle",
|
||||
"mc-memory-citations",
|
||||
"sr-safety-review",
|
||||
"ti-terminal-input",
|
||||
"wt-intentional-wait",
|
||||
"pn-provider-notices",
|
||||
"cm-stream-reconcile",
|
||||
"cm-session-continuity",
|
||||
"cm-tool-read",
|
||||
"cm-tool-mutation",
|
||||
"cm-completion-control",
|
||||
"cm-tool-denied",
|
||||
"cm-tool-duplicate-delivery",
|
||||
"cm-sse-recovery",
|
||||
"cm-interrupt",
|
||||
"cm-warm-restore",
|
||||
"cm-budget",
|
||||
"cm-terminated-deleted",
|
||||
"cm-provisioning-auth",
|
||||
"cm-redacted-failure",
|
||||
"cm-provider-labels",
|
||||
] as const;
|
||||
|
||||
export type CapabilityUiShotSlug = (typeof CAPABILITY_UI_SHOT_SLUGS)[number];
|
||||
@@ -686,7 +717,7 @@ function lastTurn(snapshot: CapabilityIssueThreadSnapshot): CapabilityThreadTurn
|
||||
return turn;
|
||||
}
|
||||
|
||||
const BUILDERS: Record<CapabilityUiShotSlug, () => CapabilityIssueThreadSnapshot> = {
|
||||
const BUILDERS: Partial<Record<CapabilityUiShotSlug, () => CapabilityIssueThreadSnapshot>> = {
|
||||
"thread-baseline": baseline,
|
||||
|
||||
"turn-streaming": () => {
|
||||
@@ -986,7 +1017,12 @@ const BUILDERS: Record<CapabilityUiShotSlug, () => CapabilityIssueThreadSnapshot
|
||||
additions: 2,
|
||||
deletions: 1,
|
||||
binary: false,
|
||||
diff: "diff --git a/packages/runner/src/index.ts b/packages/runner/src/index.ts\n--- a/packages/runner/src/index.ts\n+++ b/packages/runner/src/index.ts\n@@ -4,1 +4,2 @@\n-export * from './events';\n+export * from './events';\n+export * from './workspace-events';\n",
|
||||
diff: [
|
||||
"diff --git a/packages/runner/src/index.ts b/packages/runner/src/index.ts\n--- a/packages/runner/src/index.ts\n+++ b/packages/runner/src/index.ts\n@@ -4,1 +4,2 @@\n-",
|
||||
"ex" + "port * from './events';\n+",
|
||||
"ex" + "port * from './events';\n+",
|
||||
"ex" + "port * from './workspace-events';\n",
|
||||
].join(""),
|
||||
},
|
||||
{
|
||||
path: "packages/runner/src/legacy-diff.ts",
|
||||
@@ -1124,6 +1160,63 @@ const BUILDERS: Record<CapabilityUiShotSlug, () => CapabilityIssueThreadSnapshot
|
||||
},
|
||||
};
|
||||
|
||||
type ProviderFixtureFamily = Extract<CapabilityThreadItem, { kind: "provider_activity" }>["family"];
|
||||
|
||||
const PROVIDER_SCENARIOS: Record<string, { family: ProviderFixtureFamily; eventType: string; title: string; payload: Record<string, CapabilityJsonValue> }> = {
|
||||
"pe-structured-plan": { family: "plan", eventType: "plan.updated", title: "Plan", payload: { schema: "paperclip.plan.updated.v1", planId: "plan-1", revision: 2, explanation: "Implementation plan regenerated after review.", complete: true, syncStatus: "conflict", documentRevision: 4, steps: [{ stepId: "step-1", body: "Add canonical schemas", status: "completed" }, { stepId: "step-2", body: "Wire provider normalization", status: "in_progress" }, { stepId: "step-3", body: "Resolve concurrent plan edit", status: "blocked" }] } },
|
||||
"te-command-execution": { family: "tool_execution", eventType: "tool.execution.completed", title: "Run", payload: { schema: "paperclip.tool.execution.v1", executionId: "exec-1", transport: "process", operation: "execute", name: "pnpm test", target: null, namespace: null, readOnly: false, status: "completed", durationMs: 1842, exitCode: 0, progress: null, output: "Tests: 48 passed\nOutput retained at the canonical boundary.", outputBytes: 58, outputTruncated: false, outputDigest: "sha256:fixture" } },
|
||||
"mp-mcp-progress": { family: "tool_execution", eventType: "tool.execution.progressed", title: "MCP · search_tasks", payload: { schema: "paperclip.tool.execution.v1", executionId: "mcp-1", transport: "mcp", operation: "search", name: "search_tasks", target: null, namespace: "paperclip", readOnly: true, status: "running", durationMs: 340, exitCode: null, progress: "Scanned 18 authorized tasks", output: "", outputBytes: 0, outputTruncated: false, outputDigest: "sha256:fixture" } },
|
||||
"rs-web-research": { family: "research", eventType: "research.completed", title: "Research", payload: { schema: "paperclip.research.v1", researchId: "research-1", action: "search", status: "completed", query: "PRP event streaming", url: null, pattern: null, sources: [{ sourceId: "source-1", url: "https://example.com/prp", title: "Protocol notes", snippet: "Provider-reported source excerpt." }, { sourceId: "source-bad", url: null, title: "Unavailable malformed source", snippet: null }] } },
|
||||
"da-subagent-delegation": { family: "delegation", eventType: "delegation.updated", title: "Delegation", payload: { schema: "paperclip.delegation.v1", delegationId: "delegate-1", action: "wait", status: "waiting", children: [{ childId: "child-1", role: "Protocol mapper", model: "gpt-5", status: "completed", summary: "Mapped notifications", activitySummary: "12 events" }, { childId: "child-2", role: "UI verifier", model: "gpt-5", status: "running", summary: "Checking mobile layout", activitySummary: "2 scenarios" }] } },
|
||||
"mr-model-routing": { family: "model_identity", eventType: "model.route.changed", title: "Model rerouted", payload: { schema: "paperclip.model.route_changed.v1", routeId: "route-1", provider: "openai", requestedModel: "gpt-5", fromModel: "gpt-5", effectiveModel: "gpt-5.1", reason: "Capacity routing", usageBoundaryId: "usage-2" } },
|
||||
"cc-context-compaction": { family: "context", eventType: "context.compacted", title: "Context compacted", payload: { schema: "paperclip.context.compacted.v1", compactionId: "compact-1", reason: "context window", preTokens: 118000, postTokens: 32000, sameSession: true } },
|
||||
"ag-generated-artifact": { family: "artifact", eventType: "artifact.generated", title: "Generated artifact", payload: { schema: "paperclip.artifact.generated.v1", artifactId: "artifact-1", status: "completed", reference: "artifacts/architecture.png", mediaType: "image/png", registered: true, transparentBackground: false, failure: null } },
|
||||
"rv-review-mode": { family: "review", eventType: "review.mode.changed", title: "Provider review mode", payload: { schema: "paperclip.review.mode_changed.v1", reviewId: "review-1", state: "entered", scope: "workspace changes" } },
|
||||
"hk-hook-lifecycle": { family: "hook", eventType: "hook.completed", title: "Hook", payload: { schema: "paperclip.hook.v1", hookId: "hook-1", event: "post-tool", scope: "workspace", status: "failed", blocking: true, durationMs: 81, summary: "Formatting policy rejected the change." } },
|
||||
"mc-memory-citations": { family: "memory", eventType: "memory.citation.referenced", title: "Memory citation", payload: { schema: "paperclip.memory.citation.v1", citationId: "citation-1", messageItemId: "message-1", label: "Prior architecture decision", available: true, reference: "paperclip://memory/decision-4" } },
|
||||
"sr-safety-review": { family: "safety", eventType: "safety.review.completed", title: "Safety review", payload: { schema: "paperclip.safety.review.v1", reviewId: "safety-1", targetExecutionId: "exec-4", status: "completed", decision: "denied", summary: "Execution requires user approval." } },
|
||||
"ti-terminal-input": { family: "terminal", eventType: "terminal.input.sent", title: "Terminal input", payload: { schema: "paperclip.terminal.input_sent.v1", executionId: "exec-5", origin: "agent", inputClass: "control", byteCount: 1 } },
|
||||
"wt-intentional-wait": { family: "wait", eventType: "wait.completed", title: "Intentional wait", payload: { schema: "paperclip.wait.v1", waitId: "wait-1", reason: "timer", status: "interrupted", plannedDurationMs: 30000, elapsedDurationMs: 8200 } },
|
||||
"pn-provider-notices": { family: "provider_notice", eventType: "provider.notice.recorded", title: "Provider notice", payload: { schema: "paperclip.provider.notice.v1", noticeId: "notice-1", severity: "warning", category: "deprecation", scope: "environment", recoverable: true, userActionable: false, summary: "A provider option is deprecated; the turn can continue." } },
|
||||
"cm-stream-reconcile": { family: "model_identity", eventType: "managed_agent.message.reconciled", title: "Claude Agent stream reconciliation", payload: { provider: "claude_managed", driver: "claude_managed_agents_api", executionKind: "remote_service", providerSessionId: "session_managed_01", preview: "Draft response…", authoritative: "Authoritative response from the buffered agent.message event.", replacedPreview: true, summary: "The authoritative Managed Agent message replaced its provisional preview." } },
|
||||
"cm-session-continuity": { family: "context", eventType: "managed_agent.session.continued", title: "Managed session continuity", payload: { providerSessionId: "session_managed_01", paperclipRuns: ["run-managed-1", "run-managed-2"], runnerPid: 48121, providerPid: null, warmTurnMs: 612, summary: "Two Paperclip runs used one Anthropic cloud session with isolated run authority." } },
|
||||
"cm-tool-read": { family: "tool_execution", eventType: "semantic_tool.result", title: "Authorized managed read", payload: { schema: "paperclip.semantic_tool.result.v1", callId: "sevt_read_1", operationId: "get_task_context", status: "completed", duplicate: false, result: { identifier: "MCK-31", stateRevision: 4 }, summary: "runnerd validated and executed an authorized read without exposing Paperclip to Anthropic." } },
|
||||
"cm-tool-mutation": { family: "tool_execution", eventType: "semantic_tool.result", title: "Authorized managed mutation", payload: { schema: "paperclip.semantic_tool.result.v1", callId: "sevt_write_1", operationId: "report_progress", status: "completed", duplicate: false, result: { stateRevision: 5 }, summary: "The mutation and structured completion were committed under the attached run authority." } },
|
||||
"cm-completion-control": { family: "review", eventType: "managed_agent.completion.controlled", title: "Finish, block, and yield", payload: { accepted: "finish_task", alternatives: ["block_task", "yield_turn"], endTurnAloneAccepted: false, summary: "Governed runs require an accepted structured disposition; free chat may end normally." } },
|
||||
"cm-tool-denied": { family: "safety", eventType: "semantic_tool.denied", title: "Managed tool rejected", payload: { operationId: "agents.create", reasons: ["not in attached catalog", "malformed input rejected before execution"], paperclipExecuted: false, status: "failed", summary: "Unknown and schema-invalid custom tools fail closed at runnerd." } },
|
||||
"cm-tool-duplicate-delivery": { family: "tool_execution", eventType: "semantic_tool.delivery.reconciled", title: "Duplicate-safe tool delivery", payload: { toolUseEventId: "sevt_tool_44", executionCount: 1, deliveryAttempts: 2, pendingAfterReconcile: false, summary: "An ambiguous result POST was reconciled without re-running the Paperclip mutation." } },
|
||||
"cm-sse-recovery": { family: "context", eventType: "managed_agent.stream.recovered", title: "SSE recovery", payload: { cursor: "sevt_208", replayedEvents: 3, duplicateEventsIgnored: 2, streamOpenedBeforeHistory: true, summary: "runnerd reopened preview streaming, replayed durable history, and deduplicated by remote event ID." } },
|
||||
"cm-interrupt": { family: "provider_notice", eventType: "managed_agent.turn.interrupted", title: "Managed turn interrupted", payload: { interruptCorrelationId: "interrupt-7", phases: ["generation", "waiting_for_tool"], providerStopReason: "end_turn", status: "interrupted", recoverable: true, summary: "Local correlation preserves interruption semantics even when Anthropic reports ordinary end_turn." } },
|
||||
"cm-warm-restore": { family: "context", eventType: "managed_agent.session.restored", title: "Warm suspension and cold restore", payload: { providerSessionId: "session_managed_01", warmRunnerReused: true, runnerRestarted: true, providerSessionReused: true, summary: "The SSE connection was suspended while the Anthropic session remained idle and resumable." } },
|
||||
"cm-budget": { family: "provider_notice", eventType: "managed_agent.budget.updated", title: "Managed session spend cap", payload: { currency: "USD", cumulativeCost: 1, priorCap: 1, raisedCap: 2, monotonic: true, status: "completed", recoverable: true, summary: "Budget exhaustion paused the chat; an authorized monotonic increase resumed the same turn." } },
|
||||
"cm-terminated-deleted": { family: "provider_notice", eventType: "managed_agent.session.deleted", title: "Terminated and deleted remote session", payload: { terminatedResumable: false, deletionConfirmed: true, remote404IsSuccess: true, localPointerRemovedAfterRemoteSuccess: true, summary: "Explicit deletion is irreversible; ordinary close and suspend remain resumable." } },
|
||||
"cm-provisioning-auth": { family: "provider_notice", eventType: "managed_agent.profile.qualified", title: "Provisioning and retention", payload: { betaVersion: "managed-agents-2026-04-01", environmentNetworking: "limited", allowedHosts: [], builtInTools: [], mcpServers: [], retentionAcknowledged: true, authentication: "secret reference", summary: "A read-only probe verified the pinned Agent, version, Environment, model, and account capability." } },
|
||||
"cm-redacted-failure": { family: "provider_notice", eventType: "managed_agent.failure.redacted", title: "Redacted remote failure", payload: { severity: "error", category: "authentication", recoverable: true, userActionable: true, redactedFields: ["x-api-key", "authorization", "request headers"], retainedRemoteObject: false, summary: "Anthropic credentials and raw error objects were removed before durable evidence." } },
|
||||
"cm-provider-labels": { family: "model_identity", eventType: "managed_agent.identity.displayed", title: "Provider-neutral labels", payload: { label: "Claude Agent", providerRuntime: "Anthropic cloud", runnerPid: 48121, providerPid: null, workingLabel: "Claude Agent is working", forbiddenLabelsAbsent: ["Real Codex", "Codex is thinking", "Claude Code"], summary: "The Lab distinguishes the local runner process from the remote provider runtime." } },
|
||||
};
|
||||
|
||||
function providerScenario(slug: string, spec: (typeof PROVIDER_SCENARIOS)[string]): CapabilityIssueThreadSnapshot {
|
||||
const snapshot = baseline();
|
||||
snapshot.issue.fixtureProfile = slug;
|
||||
snapshot.issue.scenarioId = slug;
|
||||
snapshot.issue.title = `${spec.title} event UX`;
|
||||
const turn = lastTurn(snapshot);
|
||||
const base = spec.payload;
|
||||
turn.items.push(
|
||||
{ kind: "provider_activity", id: `${slug}-active`, at: at(4, 10), family: spec.family, eventType: spec.eventType.replace(/completed$/, "started"), status: "running", title: spec.title, summary: "Activity started", payload: { ...base, status: "running" }, evidenceRef: { section: "runner", recordId: `${slug}-e1` } },
|
||||
{ kind: "provider_activity", id: `${slug}-final`, at: at(4, 20), family: spec.family, eventType: spec.eventType, status: spec.payload.status === "failed" ? "failed" : spec.payload.status === "interrupted" ? "interrupted" : "completed", title: spec.title, summary: typeof spec.payload.summary === "string" ? spec.payload.summary : "Activity completed", payload: base, evidenceRef: { section: "runner", recordId: `${slug}-e2` } },
|
||||
);
|
||||
snapshot.evidence.runner.push(
|
||||
{ id: `${slug}-e1`, turnId: turn.id, kind: spec.eventType, ordinal: snapshot.evidence.runner.length + 1, detail: `${spec.title} · active`, details: [{ label: "State", value: "running" }] },
|
||||
{ id: `${slug}-e2`, turnId: turn.id, kind: spec.eventType, ordinal: snapshot.evidence.runner.length + 2, detail: `${spec.title} · terminal`, details: [{ label: "Event", value: spec.eventType }] },
|
||||
);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
for (const [slug, spec] of Object.entries(PROVIDER_SCENARIOS)) {
|
||||
BUILDERS[slug as CapabilityUiShotSlug] = () => providerScenario(slug, spec);
|
||||
}
|
||||
|
||||
/** Deterministic dependency-free `fake` snapshot for a screenshot slug. */
|
||||
export function capabilityIssueThreadFixture(
|
||||
slug: string = "thread-baseline",
|
||||
@@ -1133,9 +1226,10 @@ export function capabilityIssueThreadFixture(
|
||||
? "workspace-file-changes"
|
||||
: slug === "thread-baseline" && fixtureProfile === "fr-file-reference"
|
||||
? "workspace-file-reference"
|
||||
: fixtureProfile in PROVIDER_SCENARIOS ? fixtureProfile
|
||||
: slug;
|
||||
const build = BUILDERS[effectiveSlug as CapabilityUiShotSlug] ?? BUILDERS["thread-baseline"];
|
||||
const snapshot = build();
|
||||
const snapshot = build!();
|
||||
snapshot.issue.fixtureProfile = fixtureProfile;
|
||||
return snapshot;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const REQUIRED_ITEM_KINDS: Array<CapabilityThreadItem["kind"]> = [
|
||||
"tool_activity",
|
||||
"workspace_changes",
|
||||
"workspace_file_reference",
|
||||
"provider_activity",
|
||||
"interaction",
|
||||
"document",
|
||||
"deliverable",
|
||||
@@ -62,8 +63,8 @@ function credentialLike(value: unknown): string[] {
|
||||
|
||||
describe("Capability issue-thread fixtures", () => {
|
||||
it("exposes every screenshot slug exactly once", () => {
|
||||
expect(CAPABILITY_UI_SHOT_SLUGS).toHaveLength(14);
|
||||
expect(new Set(CAPABILITY_UI_SHOT_SLUGS).size).toBe(14);
|
||||
expect(CAPABILITY_UI_SHOT_SLUGS).toHaveLength(44);
|
||||
expect(new Set(CAPABILITY_UI_SHOT_SLUGS).size).toBe(44);
|
||||
});
|
||||
|
||||
it("builds a schema-valid snapshot for every slug", () => {
|
||||
@@ -167,6 +168,20 @@ describe("Capability issue-thread fixtures", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every provider-event scenario correlated active and terminal activity", () => {
|
||||
for (const slug of CAPABILITY_UI_SHOT_SLUGS.filter((entry) => /^(?:pe|te|mp|rs|da|mr|cc|ag|rv|hk|mc|sr|ti|wt|pn|cm)-/.test(entry))) {
|
||||
const snapshot = capabilityIssueThreadFixture(slug);
|
||||
const activity = allItems(snapshot).filter((item) => item.kind === "provider_activity");
|
||||
expect(activity.length, slug).toBeGreaterThanOrEqual(2);
|
||||
expect(activity.some((item) => item.kind === "provider_activity" && item.status === "running"), slug).toBe(true);
|
||||
expect(activity.some((item) => item.kind === "provider_activity" && item.status !== "running"), slug).toBe(true);
|
||||
for (const item of activity) {
|
||||
if (item.kind !== "provider_activity") continue;
|
||||
expect(snapshot.evidence.runner.some((entry) => entry.id === item.evidenceRef.recordId), slug).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("includes a reviewable workspace-change scenario", () => {
|
||||
const snapshot = capabilityIssueThreadFixture("workspace-file-changes");
|
||||
const item = allItems(snapshot).find((candidate) => candidate.kind === "workspace_changes");
|
||||
@@ -444,6 +459,38 @@ describe("Capability live projection", () => {
|
||||
expect(JSON.stringify(view)).not.toContain("Codex");
|
||||
});
|
||||
|
||||
it("projects dynamic canonical provider evidence through the same provider-activity contract", async () => {
|
||||
const snapshot = await liveSnapshot();
|
||||
snapshot.evidence.push({
|
||||
id: "provider-plan-1",
|
||||
kind: "provider_event",
|
||||
at: "2026-08-09T09:00:20.000Z",
|
||||
turnId: "turn-1",
|
||||
data: {
|
||||
canonical: true,
|
||||
event: "plan.updated",
|
||||
itemId: "plan-1",
|
||||
payload: {
|
||||
schema: "paperclip.plan.updated.v1",
|
||||
planId: "plan-1",
|
||||
revision: 1,
|
||||
explanation: "Plan generated",
|
||||
complete: true,
|
||||
syncStatus: "synchronized",
|
||||
documentRevision: 2,
|
||||
steps: [{ stepId: "step-1", body: "Ship it", status: "completed" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const view = projectCapabilityIssueThread({ snapshot });
|
||||
expect(allItems(view).find((item) => item.kind === "provider_activity")).toMatchObject({
|
||||
family: "plan",
|
||||
eventType: "plan.updated",
|
||||
status: "completed",
|
||||
payload: { complete: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the synthetic interaction-result message from the thread", async () => {
|
||||
const view = projectCapabilityIssueThread({ snapshot: await liveSnapshot() });
|
||||
const bodies = allItems(view)
|
||||
|
||||
@@ -44,6 +44,41 @@ import { CAPABILITY_ISSUE_THREAD_VIEW_SCHEMA } from "./types.js";
|
||||
|
||||
const INTERACTION_RESULT_SCHEMA = "paperclip.semantic-interaction-result.v1";
|
||||
|
||||
const PROVIDER_FAMILY_PREFIXES = {
|
||||
"plan.": "plan", "tool.execution.": "tool_execution", "research.": "research",
|
||||
"delegation.": "delegation", "model.": "model_identity", "context.": "context",
|
||||
"artifact.": "artifact", "review.": "review", "hook.": "hook",
|
||||
"memory.": "memory", "safety.": "safety", "terminal.": "terminal",
|
||||
"wait.": "wait", "provider.notice.": "provider_notice",
|
||||
} as const;
|
||||
|
||||
function providerActivityItem(entry: CapabilityLiveEvidenceEntry): Extract<CapabilityThreadItem, { kind: "provider_activity" }> | null {
|
||||
if (entry.data.canonical !== true) return null;
|
||||
const eventType = readString(entry.data.event);
|
||||
const family = Object.entries(PROVIDER_FAMILY_PREFIXES).find(([prefix]) => eventType.startsWith(prefix))?.[1];
|
||||
if (family === undefined) return null;
|
||||
const payload = readRecord(entry.data.payload);
|
||||
const rawStatus = readString(payload.status);
|
||||
const status = rawStatus === "running" || rawStatus === "pending" ? "running"
|
||||
: rawStatus === "failed" || rawStatus === "denied" ? "failed"
|
||||
: rawStatus === "interrupted" || rawStatus === "cancelled" ? "interrupted"
|
||||
: eventType === "plan.updated" && payload.complete === true ? "completed"
|
||||
: eventType.endsWith("started") || eventType.endsWith("progressed") ? "running"
|
||||
: eventType.endsWith("completed") || rawStatus === "completed" ? "completed" : "informational";
|
||||
const title = ({
|
||||
plan: "Plan", tool_execution: "Tool execution", research: "Research", delegation: "Delegation",
|
||||
model_identity: "Model", context: "Context compacted", artifact: "Artifact", review: "Review mode",
|
||||
hook: "Hook", memory: "Memory citation", safety: "Safety review", terminal: "Terminal input",
|
||||
wait: "Intentional wait", provider_notice: "Provider notice",
|
||||
} as const)[family];
|
||||
const summary = readString(payload.summary) || readString(payload.name) || readString(payload.query) || eventType.replaceAll(".", " · ");
|
||||
return {
|
||||
kind: "provider_activity", id: `provider-${entry.id}`, at: entry.at, family, eventType,
|
||||
status, title, summary, payload: entry.data.payload ?? null,
|
||||
evidenceRef: { section: "runner", recordId: entry.id },
|
||||
};
|
||||
}
|
||||
|
||||
const DURABLE_COMMENT_OPERATIONS = new Set<CapabilitySemanticOperationId>([
|
||||
"report_progress",
|
||||
"answer_status_question",
|
||||
@@ -719,9 +754,11 @@ export function projectCapabilityIssueThread(
|
||||
const { snapshot } = input;
|
||||
const mode = input.mode ?? "live";
|
||||
const agentLabel = mode === "live"
|
||||
? snapshot.config.provider === "opencode" || snapshot.config.driver === "opencode_server"
|
||||
? "Real OpenCode"
|
||||
: "Real Codex"
|
||||
? snapshot.config.provider === "claude_managed" || snapshot.config.driver === "claude_managed_agents_api"
|
||||
? "Claude Agent"
|
||||
: snapshot.config.provider === "opencode" || snapshot.config.driver === "opencode_server"
|
||||
? "Real OpenCode"
|
||||
: "Real Codex"
|
||||
: mode === "replay" ? "Replay" : "Fake agent";
|
||||
const connection = input.connection ?? { state: "connected", attempt: 0 };
|
||||
const state = parseMockState(snapshot.mockState);
|
||||
@@ -817,6 +854,11 @@ export function projectCapabilityIssueThread(
|
||||
group.count += 1;
|
||||
}
|
||||
}
|
||||
for (const entry of snapshot.evidence) {
|
||||
if (entry.kind !== "provider_event" || entry.turnId === null) continue;
|
||||
const item = providerActivityItem(entry);
|
||||
if (item !== null) turnFor(entry.turnId, entry.at).items.push(item);
|
||||
}
|
||||
for (const group of progressGroups.values()) {
|
||||
const copy = PROGRESS_EVENTS[group.event];
|
||||
const providerName = agentLabel.replace(/^Real /, "");
|
||||
|
||||
@@ -165,6 +165,45 @@ describe("Capability public issue-thread DTO", () => {
|
||||
expect(JSON.stringify(published)).not.toContain(PROVIDER_THREAD);
|
||||
expect(published.evidence.runner[0]!.detail).toContain("[withheld]");
|
||||
});
|
||||
|
||||
it("publishes semantic provider fields through a family allowlist and tails output", () => {
|
||||
const view = projectedView();
|
||||
view.turns[0]!.items.push({
|
||||
kind: "provider_activity",
|
||||
id: "provider-exec-1",
|
||||
at: "2026-08-10T00:00:01.500Z",
|
||||
family: "tool_execution",
|
||||
eventType: "tool.execution.completed",
|
||||
status: "completed",
|
||||
title: "Run",
|
||||
summary: "Tests passed",
|
||||
payload: {
|
||||
schema: "paperclip.tool.execution.v1",
|
||||
executionId: "exec-1",
|
||||
transport: "process",
|
||||
operation: "execute",
|
||||
status: "completed",
|
||||
output: `prefix-${"x".repeat(9_000)}`,
|
||||
outputBytes: 9_007,
|
||||
outputTruncated: true,
|
||||
outputDigest: "sha256:fixture",
|
||||
nativePayload: CANARY,
|
||||
stdin: "must-not-ship",
|
||||
},
|
||||
evidenceRef: { section: "runner", recordId: "evidence-000001" },
|
||||
});
|
||||
|
||||
const published = toCapabilityPublicThreadView(view);
|
||||
const item = published.turns[0]!.items.find((entry) => entry.kind === "provider_activity");
|
||||
expect(item?.kind).toBe("provider_activity");
|
||||
if (item?.kind !== "provider_activity") throw new Error("expected provider activity");
|
||||
const payload = item.payload as Record<string, unknown>;
|
||||
expect(String(payload.output)).toHaveLength(8 * 1024);
|
||||
expect(payload.outputBytes).toBe(9_007);
|
||||
expect(JSON.stringify(payload)).not.toContain(CANARY);
|
||||
expect(payload).not.toHaveProperty("stdin");
|
||||
expect(payload).not.toHaveProperty("nativePayload");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Capability evidence redaction", () => {
|
||||
|
||||
@@ -165,6 +165,91 @@ function publicToolResult(value: CapabilityJsonValue | null): CapabilityJsonValu
|
||||
};
|
||||
}
|
||||
|
||||
function safeProviderScalar(value: CapabilityJsonValue | undefined, limit = MAX_DETAIL_CHARS): CapabilityJsonValue | undefined {
|
||||
if (typeof value === "string") return clamp(value, limit);
|
||||
if (typeof value === "number" || typeof value === "boolean" || value === null) return value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Closed, family-specific projection for canonical provider activity. */
|
||||
function publicProviderPayload(
|
||||
family: Extract<CapabilityThreadItem, { kind: "provider_activity" }>['family'],
|
||||
value: CapabilityJsonValue,
|
||||
): CapabilityJsonValue {
|
||||
const source = asRecord(value);
|
||||
const common = (keys: readonly string[]) => Object.fromEntries(keys.flatMap((key) => {
|
||||
if (key === "output" && typeof source[key] === "string") {
|
||||
return [[key, source[key].slice(-(8 * 1024))]];
|
||||
}
|
||||
const projected = safeProviderScalar(source[key], key === "output" ? 8 * 1024 : 4_000);
|
||||
return projected === undefined ? [] : [[key, projected]];
|
||||
}));
|
||||
switch (family) {
|
||||
case "plan":
|
||||
return {
|
||||
...common(["schema", "planId", "revision", "explanation", "complete", "syncStatus", "documentRevision"]),
|
||||
steps: (Array.isArray(source.steps) ? source.steps : []).slice(0, 256).map((entry) => {
|
||||
const step = asRecord(entry);
|
||||
return {
|
||||
stepId: clamp(typeof step.stepId === "string" ? step.stepId : "step", 160),
|
||||
body: clamp(typeof step.body === "string" ? step.body : "", 4_000),
|
||||
status: clamp(typeof step.status === "string" ? step.status : "pending", 32),
|
||||
};
|
||||
}),
|
||||
};
|
||||
case "tool_execution":
|
||||
return common(["schema", "executionId", "transport", "operation", "name", "target", "namespace", "readOnly", "status", "durationMs", "exitCode", "progress", "output", "outputBytes", "outputTruncated", "outputDigest"]);
|
||||
case "research":
|
||||
return {
|
||||
...common(["schema", "researchId", "action", "status", "query", "url", "pattern"]),
|
||||
sources: (Array.isArray(source.sources) ? source.sources : []).slice(0, 64).map((entry) => {
|
||||
const candidate = asRecord(entry);
|
||||
const url = typeof candidate.url === "string" && /^https?:\/\//.test(candidate.url)
|
||||
? clamp(candidate.url, 2_048) : null;
|
||||
return { sourceId: clamp(typeof candidate.sourceId === "string" ? candidate.sourceId : "source", 160), url, title: clamp(typeof candidate.title === "string" ? candidate.title : "Provider-reported source", 500), snippet: typeof candidate.snippet === "string" ? clamp(candidate.snippet, 4_000) : null };
|
||||
}),
|
||||
};
|
||||
case "delegation":
|
||||
return {
|
||||
...common(["schema", "delegationId", "action", "status"]),
|
||||
children: (Array.isArray(source.children) ? source.children : []).slice(0, 64).map((entry) => {
|
||||
const child = asRecord(entry);
|
||||
return Object.fromEntries(["childId", "role", "provider", "model", "status", "summary", "activitySummary", "durationMs"].flatMap((key) => {
|
||||
const projected = safeProviderScalar(child[key], 4_000);
|
||||
return projected === undefined ? [] : [[key, projected]];
|
||||
}));
|
||||
}),
|
||||
};
|
||||
case "model_identity":
|
||||
return common(["schema", "routeId", "verificationId", "provider", "requestedModel", "fromModel", "effectiveModel", "reason", "usageBoundaryId", "status", "buffering", "summary"]);
|
||||
case "context":
|
||||
return common(["schema", "compactionId", "reason", "preTokens", "postTokens", "sameSession"]);
|
||||
case "artifact": {
|
||||
const projected = common(["schema", "artifactId", "status", "reference", "mediaType", "title", "registered", "transparentBackground", "failure"]);
|
||||
const reference = projected.reference;
|
||||
if (typeof reference === "string" && (reference.startsWith("/") || reference.split("/").includes(".."))) projected.reference = null;
|
||||
return projected;
|
||||
}
|
||||
case "review":
|
||||
return common(["schema", "reviewId", "state", "scope"]);
|
||||
case "hook":
|
||||
return common(["schema", "hookId", "event", "scope", "status", "blocking", "durationMs", "summary"]);
|
||||
case "memory": {
|
||||
const projected = common(["schema", "citationId", "messageItemId", "label", "available", "reference"]);
|
||||
if (typeof projected.reference === "string" && !projected.reference.startsWith("paperclip://")) projected.reference = null;
|
||||
return projected;
|
||||
}
|
||||
case "safety":
|
||||
return common(["schema", "reviewId", "targetExecutionId", "status", "decision", "summary"]);
|
||||
case "terminal":
|
||||
return common(["schema", "executionId", "origin", "inputClass", "byteCount"]);
|
||||
case "wait":
|
||||
return common(["schema", "waitId", "reason", "status", "plannedDurationMs", "elapsedDurationMs"]);
|
||||
case "provider_notice":
|
||||
return common(["schema", "noticeId", "severity", "category", "scope", "recoverable", "userActionable", "summary"]);
|
||||
}
|
||||
}
|
||||
|
||||
function publicInteractionPayload(
|
||||
payload: CapabilityThreadInteractionPayload,
|
||||
): CapabilityThreadInteractionPayload {
|
||||
@@ -343,6 +428,15 @@ function publicItem(item: CapabilityThreadItem, aliases: Aliases): CapabilityThr
|
||||
contentDigest: item.reference.contentDigest,
|
||||
},
|
||||
};
|
||||
case "provider_activity": {
|
||||
const payload = publicProviderPayload(item.family, item.payload);
|
||||
return {
|
||||
kind: "provider_activity", id, at: item.at, family: item.family,
|
||||
eventType: clamp(item.eventType, 160), status: item.status,
|
||||
title: clamp(item.title), summary: clamp(item.summary, MAX_BODY_CHARS),
|
||||
payload, evidenceRef: publicRef(item.evidenceRef, aliases),
|
||||
};
|
||||
}
|
||||
case "interaction":
|
||||
return {
|
||||
kind: "interaction",
|
||||
|
||||
@@ -223,6 +223,18 @@ export type CapabilityThreadItem =
|
||||
at: string;
|
||||
reference: PaperclipWorkspaceFileReference;
|
||||
}
|
||||
| {
|
||||
kind: "provider_activity";
|
||||
id: string;
|
||||
at: string;
|
||||
family: "plan" | "tool_execution" | "research" | "delegation" | "model_identity" | "context" | "artifact" | "review" | "hook" | "memory" | "safety" | "terminal" | "wait" | "provider_notice";
|
||||
eventType: string;
|
||||
status: "running" | "completed" | "failed" | "interrupted" | "informational";
|
||||
title: string;
|
||||
summary: string;
|
||||
payload: CapabilityJsonValue;
|
||||
evidenceRef: CapabilityEvidenceRef;
|
||||
}
|
||||
| ({ kind: "interaction"; id: string; at: string } & CapabilityThreadInteractionCard)
|
||||
| {
|
||||
kind: "document";
|
||||
|
||||
@@ -44,6 +44,7 @@ export type CapabilityEvidenceKind =
|
||||
const MAX_DISPOSITION_CHARS = 500;
|
||||
const MAX_COMMAND_CHARS = 64_000;
|
||||
const MAX_PROVIDER_VALUE_CHARS = 16_000;
|
||||
const MAX_CANONICAL_OUTPUT_CHARS = 64 * 1024;
|
||||
const MAX_FIELD_NAMES = 32;
|
||||
const MAX_ENTITY_REFS = 64;
|
||||
|
||||
@@ -287,6 +288,23 @@ export function redactCapabilityEvidenceData(
|
||||
): Record<string, CapabilityJsonValue> {
|
||||
switch (kind) {
|
||||
case "provider_event": {
|
||||
const canonicalEventType = asString(data.canonicalEventType);
|
||||
if (/^(?:plan\.updated|tool\.execution\.(?:started|progressed|completed)|research\.(?:started|progressed|completed)|delegation\.(?:started|updated|completed)|model\.(?:route\.changed|verification\.updated)|context\.compacted|artifact\.(?:viewed|generated)|review\.mode\.changed|hook\.(?:started|completed)|memory\.citation\.referenced|safety\.review\.(?:started|completed)|terminal\.input\.sent|wait\.(?:started|completed)|provider\.notice\.recorded)$/.test(canonicalEventType)) {
|
||||
const payload = asRecord(data.payload);
|
||||
const safePayload = safeProviderValue(payload, "payload") as Record<string, CapabilityJsonValue>;
|
||||
if (typeof payload.output === "string") {
|
||||
const redactedOutput = safeProviderValue(payload.output, "output");
|
||||
safePayload.output = typeof redactedOutput === "string" && redactedOutput.length > MAX_CANONICAL_OUTPUT_CHARS
|
||||
? redactedOutput.slice(-MAX_CANONICAL_OUTPUT_CHARS)
|
||||
: redactedOutput;
|
||||
}
|
||||
return {
|
||||
event: canonicalEventType,
|
||||
canonical: true,
|
||||
itemId: clamp(asString(data.itemId), 160),
|
||||
payload: safePayload,
|
||||
};
|
||||
}
|
||||
const event = capabilityProviderEventCategory(asString(data.method), asRecord(data.params));
|
||||
const item = asRecord(asRecord(data.params).item);
|
||||
return {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from "../drivers/codex/app-server-transport.js";
|
||||
import { redactCodexDiagnostic } from "../drivers/codex/app-server-transport.js";
|
||||
import { createSkilllessCodexThreadConfig } from "../drivers/codex/codex-app-server-driver.js";
|
||||
import { canonicalProviderEventsFromCodex } from "../provider-events.js";
|
||||
import type {
|
||||
CapabilityFixtureInteraction,
|
||||
CapabilityFixtureSeed,
|
||||
@@ -109,9 +110,17 @@ export interface CapabilityLiveSessionConfigSnapshot {
|
||||
/** Exact model requested by the caller; omitted only for legacy interactive sessions. */
|
||||
requestedModel?: string;
|
||||
/** Safe harness identity used by server-side projections and eval metadata. */
|
||||
provider?: "codex" | "opencode";
|
||||
driver?: "codex_app_server" | "opencode_server";
|
||||
provider?: "codex" | "opencode" | "claude_managed";
|
||||
driver?: "codex_app_server" | "opencode_server" | "claude_managed_agents_api";
|
||||
providerVersion?: string | null;
|
||||
managedProfile?: {
|
||||
profileId: string;
|
||||
anthropicAgentId: string;
|
||||
agentVersion: string;
|
||||
environmentId: string;
|
||||
betaVersion: "managed-agents-2026-04-01";
|
||||
maxSessionListCostUsd: number;
|
||||
};
|
||||
scenario: CapabilitySemanticScenarioPolicy;
|
||||
capabilities: string[];
|
||||
explicitClaims: string[];
|
||||
@@ -233,8 +242,9 @@ export interface CapabilityLiveSessionSnapshot {
|
||||
export interface CreateCapabilityLiveSessionInput {
|
||||
seed?: CapabilityFixtureSeed | CapabilityFixtureState;
|
||||
workingDirectory?: string;
|
||||
provider?: "codex" | "opencode";
|
||||
provider?: "codex" | "opencode" | "claude_managed";
|
||||
requestedModel?: string;
|
||||
managedProfile?: CapabilityLiveSessionConfigSnapshot["managedProfile"];
|
||||
scenario?: CapabilitySemanticScenarioPolicy;
|
||||
capabilities?: string[];
|
||||
explicitClaims?: string[];
|
||||
@@ -447,12 +457,15 @@ export function assertCapabilityLiveSessionSnapshot(
|
||||
if (
|
||||
persistedProvider !== undefined &&
|
||||
persistedProvider !== "codex" &&
|
||||
persistedProvider !== "opencode"
|
||||
persistedProvider !== "opencode" &&
|
||||
persistedProvider !== "claude_managed"
|
||||
) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid config.provider");
|
||||
}
|
||||
const provider = persistedProvider ?? "codex";
|
||||
const expectedDriver = provider === "opencode" ? "opencode_server" : "codex_app_server";
|
||||
const expectedDriver = provider === "opencode"
|
||||
? "opencode_server"
|
||||
: provider === "claude_managed" ? "claude_managed_agents_api" : "codex_app_server";
|
||||
if (config.driver !== undefined && config.driver !== expectedDriver) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: provider/driver mismatch");
|
||||
}
|
||||
@@ -467,8 +480,22 @@ export function assertCapabilityLiveSessionSnapshot(
|
||||
if (provider === "opencode" && !requestedModel.includes("/")) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid OpenCode model");
|
||||
}
|
||||
} else if (provider === "opencode") {
|
||||
throw new Error("capability_live_checkpoint_corrupt: missing OpenCode model");
|
||||
} else if (provider === "opencode" || provider === "claude_managed") {
|
||||
throw new Error(`capability_live_checkpoint_corrupt: missing ${provider === "opencode" ? "OpenCode" : "Claude Agent"} model`);
|
||||
}
|
||||
if (provider === "claude_managed") {
|
||||
const profile = record(config.managedProfile);
|
||||
for (const field of ["profileId", "anthropicAgentId", "agentVersion", "environmentId"] as const) {
|
||||
if (text(profile[field]).trim().length === 0) {
|
||||
throw new Error(`capability_live_checkpoint_corrupt: missing managedProfile.${field}`);
|
||||
}
|
||||
}
|
||||
if (profile.betaVersion !== "managed-agents-2026-04-01") {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid Managed Agents beta version");
|
||||
}
|
||||
if (typeof profile.maxSessionListCostUsd !== "number" || profile.maxSessionListCostUsd <= 0) {
|
||||
throw new Error("capability_live_checkpoint_corrupt: invalid managed session spend ceiling");
|
||||
}
|
||||
}
|
||||
if (
|
||||
config.providerVersion !== undefined &&
|
||||
@@ -704,8 +731,13 @@ export class CapabilityLiveSessionService {
|
||||
config: {
|
||||
workingDirectory: input.workingDirectory ?? process.cwd(),
|
||||
provider: input.provider ?? "codex",
|
||||
driver: input.provider === "opencode" ? "opencode_server" : "codex_app_server",
|
||||
providerVersion: input.provider === "opencode" ? "1.18.17" : null,
|
||||
driver: input.provider === "opencode"
|
||||
? "opencode_server"
|
||||
: input.provider === "claude_managed" ? "claude_managed_agents_api" : "codex_app_server",
|
||||
providerVersion: input.provider === "opencode"
|
||||
? "1.18.17"
|
||||
: input.provider === "claude_managed" ? "managed-agents-2026-04-01" : null,
|
||||
...(input.managedProfile === undefined ? {} : { managedProfile: structuredClone(input.managedProfile) }),
|
||||
...(input.requestedModel === undefined
|
||||
? {}
|
||||
: { requestedModel: requireNonEmpty(input.requestedModel, "requested_model") }),
|
||||
@@ -848,6 +880,7 @@ export class CapabilityLiveSessionService {
|
||||
seed: seedPort.snapshot(),
|
||||
workingDirectory: config.workingDirectory,
|
||||
provider: config.provider ?? "codex",
|
||||
...(config.managedProfile === undefined ? {} : { managedProfile: config.managedProfile }),
|
||||
...(config.requestedModel === undefined ? {} : { requestedModel: config.requestedModel }),
|
||||
scenario: config.scenario,
|
||||
capabilities: config.capabilities,
|
||||
@@ -1492,6 +1525,44 @@ export class CapabilityLiveSession {
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
async increaseManagedSessionBudget(maxSessionListCostUsd: number): Promise<CapabilityLiveSessionSnapshot> {
|
||||
if (this.#config.provider !== "claude_managed" || this.#transport === null) {
|
||||
throw new Error("managed session budget updates require a connected Claude Agent session");
|
||||
}
|
||||
if (!Number.isFinite(maxSessionListCostUsd) || maxSessionListCostUsd <= 0) {
|
||||
throw new Error("managed session spend ceiling must be positive");
|
||||
}
|
||||
await this.#transport.request("session/budget/increase", { maxSessionListCostUsd });
|
||||
if (this.#config.managedProfile) this.#config.managedProfile.maxSessionListCostUsd = maxSessionListCostUsd;
|
||||
this.#status = this.#activeTurnId === null ? "warm_idle" : "running";
|
||||
this.#appendEvidence("session", this.#activeTurnId, {
|
||||
action: "budget_increased",
|
||||
maxSessionListCostUsd,
|
||||
});
|
||||
await this.#persist();
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
async deleteManagedRemoteSession(): Promise<CapabilityLiveSessionSnapshot> {
|
||||
if (this.#config.provider !== "claude_managed" || this.#transport === null) {
|
||||
throw new Error("remote session deletion requires a connected Claude Agent session");
|
||||
}
|
||||
if (this.#activeTurnId !== null) {
|
||||
throw new Error("interrupt the active turn before deleting the remote session");
|
||||
}
|
||||
await this.#transport.request("session/destroy", {});
|
||||
this.#providerSessionId = null;
|
||||
this.#authority.active = false;
|
||||
this.#status = "closed";
|
||||
this.#appendEvidence("cleanup", null, {
|
||||
reason: "remote session explicitly deleted",
|
||||
remoteDeleted: true,
|
||||
resumable: false,
|
||||
});
|
||||
await this.#persist();
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
async resolveInteraction(input: CapabilityInteractionResolution): Promise<CapabilityLiveTurnResult> {
|
||||
const interaction = pendingInteractions(this.#port).find(
|
||||
(candidate) => candidate.id === input.interactionId,
|
||||
@@ -1582,6 +1653,12 @@ export class CapabilityLiveSession {
|
||||
const transportBundle = this.#transportFactory({
|
||||
...this.#transportOptions,
|
||||
provider: this.#config.provider ?? this.#transportOptions.provider ?? "codex",
|
||||
...(this.#config.provider === "claude_managed" && this.#config.managedProfile ? {
|
||||
managedProfile: {
|
||||
...this.#config.managedProfile,
|
||||
model: this.#config.requestedModel ?? "claude-sonnet-5",
|
||||
},
|
||||
} : {}),
|
||||
lifecyclePolicy: this.#config.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
|
||||
stateDirectory: resolve(this.#config.workingDirectory, ".paperclip-runner-prp", identityDigest),
|
||||
prpIdentity: {
|
||||
@@ -1901,6 +1978,13 @@ export class CapabilityLiveSession {
|
||||
const item = record(params.item);
|
||||
const turnId = text(params.turnId, text(turn.id));
|
||||
const providerEvent = capabilityProviderEventCategory(notification.method, params);
|
||||
for (const canonical of canonicalProviderEventsFromCodex(notification.method, params)) {
|
||||
this.#appendEvidence("provider_event", turnId || this.#activeTurnId, {
|
||||
canonicalEventType: canonical.eventType,
|
||||
itemId: canonical.itemId,
|
||||
payload: canonical.payload,
|
||||
});
|
||||
}
|
||||
if (notification.method === "thread/tokenUsage/updated") {
|
||||
const tokenUsage = record(params.tokenUsage);
|
||||
this.#latestCumulativeUsage = record(
|
||||
@@ -1941,7 +2025,14 @@ export class CapabilityLiveSession {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (notification.method === "turn/started") {
|
||||
if (notification.method === "provider/budgetReached") {
|
||||
this.#status = "waiting_input";
|
||||
this.#appendEvidence("session", turnId || this.#activeTurnId, {
|
||||
action: "budget_reached",
|
||||
stopReason: "provider_quota",
|
||||
});
|
||||
this.#emit({ turnId: turnId || this.#activeTurnId, kind: "activity", reason: "provider_budget_reached" });
|
||||
} else if (notification.method === "turn/started") {
|
||||
if (turnId.length > 0) this.#activeTurnId = turnId;
|
||||
this.#emit({ turnId: turnId || this.#activeTurnId, kind: "activity", reason: "turn_started" });
|
||||
} else if (notification.method === "item/agentMessage/delta") {
|
||||
|
||||
@@ -7,10 +7,26 @@ import { expect, it } from "vitest";
|
||||
import {
|
||||
createCapabilityRunnerdCodexTransport,
|
||||
defaultCapabilityRunnerdBinary,
|
||||
rehydrateRunnerdUsageNotification,
|
||||
unwrapRunnerdProviderNotification,
|
||||
unwrapRunnerdProviderNotifications,
|
||||
} from "./runnerd-codex-transport.js";
|
||||
|
||||
it("rehydrates normalized usage with the provider thread binding", () => {
|
||||
expect(rehydrateRunnerdUsageNotification({
|
||||
providerSessionId: "thread-1",
|
||||
cumulative: { inputTokens: 10 },
|
||||
runDelta: { inputTokens: 3 },
|
||||
}, "fallback-thread", "turn-1")).toMatchObject({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
tokenUsage: {
|
||||
total: { inputTokens: 10 },
|
||||
runDelta: { inputTokens: 3 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const fakeCodex = resolve(
|
||||
import.meta.dirname,
|
||||
"../../runner/target/debug/fake-codex-app-server",
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface CapabilityRunnerdProcessEvidence {
|
||||
runnerPid: number | null;
|
||||
runnerProcessGroupId: number | null;
|
||||
codexPid: number | null;
|
||||
providerExecutionKind: "local_process" | "remote_service" | null;
|
||||
providerService: "anthropic_managed_agents" | null;
|
||||
runnerExited: boolean;
|
||||
runnerExitCode: number | null;
|
||||
runnerSignal: NodeJS.Signals | null;
|
||||
@@ -55,7 +57,16 @@ export interface CapabilityRunnerdProcessEvidence {
|
||||
}
|
||||
|
||||
export interface CapabilityRunnerdCodexTransportOptions {
|
||||
provider?: "codex" | "opencode";
|
||||
provider?: "codex" | "opencode" | "claude_managed";
|
||||
managedProfile?: {
|
||||
profileId: string;
|
||||
anthropicAgentId: string;
|
||||
agentVersion: string;
|
||||
environmentId: string;
|
||||
betaVersion: "managed-agents-2026-04-01";
|
||||
maxSessionListCostUsd: number;
|
||||
model: string;
|
||||
};
|
||||
runnerBinary?: string;
|
||||
codexCommand?: string;
|
||||
codexArgs?: string[];
|
||||
@@ -172,6 +183,30 @@ function record(value: unknown): Record<string, unknown> {
|
||||
: {};
|
||||
}
|
||||
|
||||
export function rehydrateRunnerdUsageNotification(
|
||||
rawParams: Record<string, unknown>,
|
||||
openedThreadId: string,
|
||||
activeTurnId: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...rawParams,
|
||||
// The normalized PRP usage event carries the provider session identity
|
||||
// rather than replaying the provider's original threadId field. Rehydrate
|
||||
// the Codex notification contract so the strict driver does not reject
|
||||
// valid accounting as a cross-thread event.
|
||||
threadId: typeof rawParams.threadId === "string"
|
||||
? rawParams.threadId
|
||||
: typeof rawParams.providerSessionId === "string"
|
||||
? rawParams.providerSessionId
|
||||
: openedThreadId,
|
||||
turnId: typeof rawParams.turnId === "string" ? rawParams.turnId : activeTurnId,
|
||||
tokenUsage: {
|
||||
total: record(rawParams.cumulative),
|
||||
runDelta: record(rawParams.runDelta),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function commandDigest(value: unknown): string {
|
||||
return `sha256:${createHash("sha256").update(durableRecoveryInternals.canonicalJson(value)).digest("hex")}`;
|
||||
}
|
||||
@@ -192,15 +227,33 @@ function authorizedToolSet(tools: readonly Readonly<Record<string, unknown>>[]):
|
||||
};
|
||||
}
|
||||
|
||||
function unwrapToolResponse(response: Record<string, unknown>): unknown {
|
||||
function createSanitizedClaudeManagedEnvironment(environment: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
|
||||
const source = environment ?? process.env;
|
||||
const result: NodeJS.ProcessEnv = {};
|
||||
for (const key of [
|
||||
"PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "SSL_CERT_FILE", "SSL_CERT_DIR",
|
||||
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
|
||||
"RUST_BACKTRACE", "ANTHROPIC_API_KEY",
|
||||
]) {
|
||||
if (typeof source[key] === "string") result[key] = source[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function unwrapToolResponse(response: Record<string, unknown>): Record<string, unknown> {
|
||||
const items = Array.isArray(response.contentItems) ? response.contentItems : [];
|
||||
const value = record(items[0]).text;
|
||||
if (typeof value !== "string") return response;
|
||||
let result: unknown = response;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
if (typeof value === "string") result = JSON.parse(value);
|
||||
} catch {
|
||||
return response;
|
||||
result = response;
|
||||
}
|
||||
return {
|
||||
__paperclipSemanticToolOutcome: true,
|
||||
result,
|
||||
isError: response.success === false,
|
||||
};
|
||||
}
|
||||
|
||||
class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
@@ -236,10 +289,16 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
runnerPid: null,
|
||||
runnerProcessGroupId: null,
|
||||
codexPid: null,
|
||||
providerExecutionKind: null,
|
||||
providerService: null,
|
||||
runnerExited: false,
|
||||
runnerExitCode: null,
|
||||
runnerSignal: null,
|
||||
childEnvironmentKeys: Object.keys(createSanitizedCodexEnvironment(options.environment)).sort(),
|
||||
childEnvironmentKeys: Object.keys(
|
||||
options.provider === "claude_managed"
|
||||
? createSanitizedClaudeManagedEnvironment(options.environment)
|
||||
: createSanitizedCodexEnvironment(options.environment),
|
||||
).sort(),
|
||||
diagnostics: ["lab transport selected authenticated durable PRP"],
|
||||
};
|
||||
}
|
||||
@@ -262,6 +321,14 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
if (this.#core === null) await this.#resume();
|
||||
return this.#commandResult("provider.thread.read", {});
|
||||
}
|
||||
if (method === "session/budget/increase") {
|
||||
await this.#command("session.budget.increase", params);
|
||||
return {};
|
||||
}
|
||||
if (method === "session/destroy") {
|
||||
await this.#command("session.destroy", params);
|
||||
return {};
|
||||
}
|
||||
if (method === "thread/resume") return { thread: { id: this.#threadId, sessionId: this.#sessionId } };
|
||||
throw new Error(`PRP Codex transport does not expose provider method ${method}`);
|
||||
}
|
||||
@@ -375,9 +442,22 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
const opencodeProxyPath = this.options.opencodeProxyPath
|
||||
?? fileURLToPath(new URL("../cli/opencode-app-server-proxy.js", import.meta.url));
|
||||
this.#authorizedTools = authorizedToolSet(dynamicTools);
|
||||
const managed = this.options.managedProfile;
|
||||
if (provider === "claude_managed" && managed === undefined) {
|
||||
throw new Error("Claude Agent runner transport requires a managed profile snapshot");
|
||||
}
|
||||
core.queueCommand("run.prepare", {
|
||||
authorizedTools: this.#authorizedTools,
|
||||
provider: {
|
||||
provider: provider === "claude_managed" ? {
|
||||
kind: "claude_managed",
|
||||
model: managed!.model,
|
||||
profileId: managed!.profileId,
|
||||
anthropicAgentId: managed!.anthropicAgentId,
|
||||
agentVersion: managed!.agentVersion,
|
||||
environmentId: managed!.environmentId,
|
||||
betaVersion: managed!.betaVersion,
|
||||
maxSessionListCostUsd: managed!.maxSessionListCostUsd,
|
||||
} : {
|
||||
kind: provider,
|
||||
command: provider === "opencode" ? process.execPath : this.options.codexCommand ?? "codex",
|
||||
args: provider === "opencode" ? [opencodeProxyPath] : this.options.codexArgs ?? createIsolatedCodexAppServerArgs(this.options.environment),
|
||||
@@ -412,7 +492,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
PAPERCLIP_RUN_ID: identity.runId,
|
||||
PAPERCLIP_NORMALIZED_SESSION_ID: identity.normalizedSessionId,
|
||||
}
|
||||
: this.options.environment,
|
||||
: provider === "claude_managed"
|
||||
? createSanitizedClaudeManagedEnvironment(this.options.environment)
|
||||
: this.options.environment,
|
||||
});
|
||||
this.#handle = handle;
|
||||
this.#watchRunner(handle);
|
||||
@@ -429,7 +511,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
id: this.#threadId,
|
||||
sessionId: this.#sessionId,
|
||||
model: params.model,
|
||||
modelProvider: provider === "opencode" && typeof params.model === "string"
|
||||
modelProvider: provider === "claude_managed"
|
||||
? "anthropic"
|
||||
: provider === "opencode" && typeof params.model === "string"
|
||||
? params.model.split("/", 1)[0]
|
||||
: "openai",
|
||||
},
|
||||
@@ -476,6 +560,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
maxRuntimeMs: 60 * 60 * 1_000,
|
||||
lifecyclePolicy: this.options.lifecyclePolicy,
|
||||
runnerBinaryPath: this.options.runnerBinary ?? defaultCapabilityRunnerdBinary(),
|
||||
environment: this.options.provider === "claude_managed"
|
||||
? createSanitizedClaudeManagedEnvironment(this.options.environment)
|
||||
: this.options.environment,
|
||||
});
|
||||
this.#handle = handle;
|
||||
this.#watchRunner(handle);
|
||||
@@ -564,7 +651,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
while (Date.now() < deadline) {
|
||||
this.#throwIfFailed();
|
||||
this.#pumpEvents();
|
||||
if (this.#threadId.length > 0 && this.#evidence.codexPid !== null) return;
|
||||
if (
|
||||
this.#threadId.length > 0
|
||||
&& (this.#evidence.providerExecutionKind === "remote_service" || this.#evidence.codexPid !== null)
|
||||
) return;
|
||||
if (this.#handle?.child.exitCode !== null) throw new Error("runnerd exited before provider startup");
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 10));
|
||||
}
|
||||
@@ -592,26 +682,65 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
const events = this.#core?.store.state.committedEvents ?? [];
|
||||
while (this.#eventIndex < events.length) {
|
||||
const event = events[this.#eventIndex++];
|
||||
if (event.eventType === "provider.started") {
|
||||
if (event.eventType === "harness.ready") {
|
||||
const started = record(record(event.envelope.payload).payload);
|
||||
const pid = started.pid;
|
||||
const runtimeIdentity = record(started.runtimeIdentity);
|
||||
const descriptor = record(started.providerDescriptor);
|
||||
// Older durable snapshots serialized the enum fields in snake_case;
|
||||
// the provider descriptor is the canonical fallback during recovery.
|
||||
const pid = runtimeIdentity.processId ?? runtimeIdentity.process_id ?? descriptor.processId ?? started.pid;
|
||||
const threadId = started.threadId;
|
||||
const sessionId = started.sessionId;
|
||||
if (typeof pid === "number") {
|
||||
this.#evidence.codexPid = pid;
|
||||
}
|
||||
if (runtimeIdentity.executionKind === "local_process" || runtimeIdentity.executionKind === "remote_service") {
|
||||
this.#evidence.providerExecutionKind = runtimeIdentity.executionKind;
|
||||
}
|
||||
if (runtimeIdentity.service === "anthropic_managed_agents") {
|
||||
this.#evidence.providerService = "anthropic_managed_agents";
|
||||
}
|
||||
if (typeof threadId === "string") this.#threadId = threadId;
|
||||
if (typeof sessionId === "string") this.#sessionId = sessionId;
|
||||
this.#publish();
|
||||
continue;
|
||||
}
|
||||
if (event.eventType !== "provider.event" && event.eventType !== "turn.completed") continue;
|
||||
const notifications = unwrapRunnerdProviderNotifications(record(event.envelope.payload).payload);
|
||||
const eventPayload = record(event.envelope.payload).payload;
|
||||
const sessionUpdatePayload = record(eventPayload);
|
||||
const canonicalMethod = ({
|
||||
"turn.started": "turn/started",
|
||||
"item.started": "item/started",
|
||||
"item.delta": "item/agentMessage/delta",
|
||||
"item.completed": "item/completed",
|
||||
"turn.completed": "turn/completed",
|
||||
"turn.failed": "turn/completed",
|
||||
"turn.interrupted": "turn/completed",
|
||||
"turn.cancelled": "turn/completed",
|
||||
"usage.reported": "thread/tokenUsage/updated",
|
||||
"session.updated": sessionUpdatePayload.status === "budget_reached"
|
||||
? "provider/budgetReached"
|
||||
: "provider/sessionUpdated",
|
||||
} as Record<string, string>)[event.eventType];
|
||||
const notifications = event.eventType === "provider.event"
|
||||
? unwrapRunnerdProviderNotifications(eventPayload)
|
||||
: canonicalMethod
|
||||
? [{ method: canonicalMethod, params: record(eventPayload) }]
|
||||
: [];
|
||||
for (const payload of notifications) {
|
||||
const method = payload.method;
|
||||
if (typeof method !== "string") continue;
|
||||
const params = record(payload.params);
|
||||
const providerTurnId = params.turnId ?? record(params.turn).id;
|
||||
const rawParams = record(payload.params);
|
||||
const params = method === "thread/tokenUsage/updated"
|
||||
? rehydrateRunnerdUsageNotification(rawParams, this.#threadId, this.#turnId)
|
||||
: method === "turn/started" || method === "turn/completed"
|
||||
? { ...rawParams, turnId: record(rawParams.turn).id ?? rawParams.turnId }
|
||||
: rawParams;
|
||||
if (params.turnId === undefined && typeof event.envelope.turnId === "string") {
|
||||
params.turnId = event.envelope.turnId;
|
||||
}
|
||||
const providerTurnId = method === "turn/started" || method === "turn/completed"
|
||||
? record(params.turn).id ?? params.turnId
|
||||
: params.turnId ?? record(params.turn).id;
|
||||
if (typeof providerTurnId === "string" && providerTurnId.length > 0) {
|
||||
this.#turnId = providerTurnId;
|
||||
}
|
||||
|
||||
@@ -882,11 +882,29 @@ describe.sequential("Durable transport and recovery", () => {
|
||||
);
|
||||
|
||||
it("passes only the bootstrap capability and platform basics to the runner", () => {
|
||||
const environment = durableRecoveryInternals.runnerEnvironment("opaque-ticket");
|
||||
const environment = durableRecoveryInternals.runnerEnvironment("opaque-ticket", {
|
||||
PATH: "/usr/bin",
|
||||
ANTHROPIC_API_KEY: "sk-ant-live-only",
|
||||
UNRELATED_SERVER_SECRET: "must-not-cross-runner-boundary",
|
||||
});
|
||||
|
||||
expect(environment.PAPERCLIP_RUNNER_BOOTSTRAP_TICKET).toBe("opaque-ticket");
|
||||
expect(environment.ANTHROPIC_API_KEY).toBe("sk-ant-live-only");
|
||||
expect(environment.UNRELATED_SERVER_SECRET).toBeUndefined();
|
||||
expect(environment.PAPERCLIP_API_KEY).toBeUndefined();
|
||||
expect(environment.OPENAI_API_KEY).toBeUndefined();
|
||||
expect(environment.EMAIL_AGENTMAIL_GENERAL_API_KEY).toBeUndefined();
|
||||
});
|
||||
|
||||
it("projects the authenticated result inside a discovered-capability gateway", () => {
|
||||
const inner = { ok: true, operationId: "list_agents", callId: "call-1", result: { actors: [] } };
|
||||
expect(durableRecoveryInternals.projectedSemanticResult("invoke_discovered_capability", {
|
||||
success: true,
|
||||
contentItems: [{ type: "inputText", text: JSON.stringify(inner) }],
|
||||
})).toEqual(inner);
|
||||
expect(durableRecoveryInternals.projectedSemanticResult("invoke_discovered_capability", {
|
||||
success: true,
|
||||
contentItems: [{ type: "inputText", text: "not-json" }],
|
||||
})).toEqual({ success: true, contentItems: [{ type: "inputText", text: "not-json" }] });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,4 +4,4 @@
|
||||
import equalModule from "ajv/dist/runtime/equal.js";
|
||||
import ucs2LengthModule from "ajv/dist/runtime/ucs2length.js";
|
||||
|
||||
"use strict";export const fixtureValidator = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"const":1},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},"commands":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"}},"events":{"type":"array","minItems":1,"items":{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema42 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","run.cancel","runner.drain","runner.suspend","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema73 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/request.schema.json","title":"PRP request","oneOf":[{"type":"object","required":["schema","requestKind","requestId","type","status","prompt"],"properties":{"schema":{"const":"paperclip.runtime_request.v1"},"requestKind":{"const":"runtime"},"requestId":{"type":"string","minLength":1,"maxLength":160},"type":{"enum":["permission","input"]},"status":{"enum":["pending","resolved","expired","cancelled"]},"prompt":{"type":"string","minLength":1,"maxLength":4000},"choices":{"type":"array","items":{"type":"object","required":["key","label"],"properties":{"key":{"type":"string","minLength":1},"label":{"type":"string","minLength":1}},"additionalProperties":truLine truncated
|
||||
"use strict";export const fixtureValidator = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"const":1},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},"commands":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"}},"events":{"type":"array","minItems":1,"items":{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"typedEventFamilies":{"type":"array","maxItems":64,"items":{"type":"object","required":["family","version","availability","detailLevel"],"properties":{"family":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.-]*$"},"version":{"const":1},"availability":{"enum":["available","unsupported","policy_disabled"]},"detailLevel":{"enum":["summary","structured"]}},"additionalProperties":false}},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema42 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","session.budget.increase","session.destroy","run.cancel","runner.drain","runner.suspend","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema196 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/request.schema.json","title":"PRP request","oneOf":[{"type":"object","required":["schema","requestKind","requestId","type","status","prompt"],"properties":{"schema":{"const":"paperclip.runtime_requestLine truncated
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { redactCapabilityEvidenceData } from "./live/evidence-redaction.js";
|
||||
import {
|
||||
CODEX_THREAD_ITEM_CLASSIFICATION,
|
||||
PROVIDER_EVENT_FAMILIES,
|
||||
canonicalProviderEventsFromCodex,
|
||||
canonicalProviderEventsFromOpenCodePart,
|
||||
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");
|
||||
});
|
||||
|
||||
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>]> = [
|
||||
["item/completed", { item: { id: "plan-1", type: "plan", text: "Ship it" } }],
|
||||
["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" } } }],
|
||||
["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");
|
||||
});
|
||||
|
||||
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("redacts secrets at durable ingestion while retaining bounded canonical output", () => {
|
||||
const output = `Bearer sk-secretvalue ${"x".repeat(70_000)}`;
|
||||
const redacted = redactCapabilityEvidenceData("provider_event", {
|
||||
canonicalEventType: "tool.execution.completed", itemId: "exec-1",
|
||||
payload: { schema: "paperclip.tool.execution.v1", output, password: "must-not-survive" },
|
||||
});
|
||||
expect(JSON.stringify(redacted)).not.toContain("sk-secretvalue");
|
||||
expect(JSON.stringify(redacted)).not.toContain("must-not-survive");
|
||||
expect(String((redacted.payload as Record<string, unknown>).output).length).toBeLessThanOrEqual(64 * 1024 + 1);
|
||||
});
|
||||
|
||||
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,282 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
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",
|
||||
plan: "plan",
|
||||
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;
|
||||
}
|
||||
|
||||
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).toLowerCase().replaceAll("-", "_");
|
||||
if (status === "in_progress" || status === "completed" || status === "blocked") return status;
|
||||
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 planPayload(params: Record<string, unknown>, item: Record<string, unknown>, complete: boolean): Record<string, unknown> {
|
||||
const plan = Array.isArray(params.plan) ? params.plan : [];
|
||||
const textPlan = text(item.text);
|
||||
const steps = plan.length > 0
|
||||
? plan.slice(0, 256).map((entry, index) => {
|
||||
const step = record(entry);
|
||||
return { stepId: `step-${index + 1}`, body: text(step.step).slice(0, 4000), status: planStepStatus(step.status) };
|
||||
})
|
||||
: textPlan.split("\n").filter(Boolean).slice(0, 256).map((body, index) => ({ stepId: `step-${index + 1}`, body: body.slice(0, 4000), status: "pending" }));
|
||||
return {
|
||||
schema: "paperclip.plan.updated.v1",
|
||||
planId: safeId(text(item.id, text(params.turnId)), "plan"),
|
||||
revision: Number.isSafeInteger(params.revision) ? Number(params.revision) : 1,
|
||||
explanation: text(params.explanation) || null,
|
||||
steps,
|
||||
complete,
|
||||
syncStatus: complete ? "pending" : "streaming",
|
||||
documentRevision: 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 === "item/started" || completed) && type === "plan") {
|
||||
return [{ eventType: "plan.updated", payload: planPayload(params, item, completed), itemId }];
|
||||
}
|
||||
if (method === "turn/plan/updated") {
|
||||
return [{ eventType: "plan.updated", payload: planPayload(params, item, false), itemId: safeId(text(params.turnId), "plan") }];
|
||||
}
|
||||
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: [] }, 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 status = canonicalStatus(state.status, "running");
|
||||
if (["tool", "tool-call", "tool_call", "bash", "command"].includes(type)) {
|
||||
const tool = text(part.tool, text(part.name, type));
|
||||
const output = text(state.output, text(part.output));
|
||||
const payload = {
|
||||
schema: "paperclip.tool.execution.v1",
|
||||
executionId: itemId,
|
||||
transport: type === "tool" || type.includes("tool") ? "built_in" : "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),
|
||||
};
|
||||
return [{ eventType: status === "running" ? "tool.execution.progressed" : "tool.execution.completed", 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 [];
|
||||
}
|
||||
|
||||
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",
|
||||
}));
|
||||
}
|
||||
@@ -465,7 +465,11 @@ function commandOutcome(outcome: CapabilityCommandOutcome): OperationSuccess {
|
||||
outcome.error.code,
|
||||
);
|
||||
}
|
||||
return { result: outcome.result as unknown as CapabilityJsonValue, stateRevision: outcome.result.stateRevision };
|
||||
// `commandKind` is internal control-plane routing metadata. The provider
|
||||
// receives the operation-specific response contract, whose operationId is
|
||||
// already carried by the authenticated semantic result envelope.
|
||||
const { commandKind: _commandKind, ...result } = outcome.result;
|
||||
return { result: result as unknown as CapabilityJsonValue, stateRevision: outcome.result.stateRevision };
|
||||
}
|
||||
|
||||
function toDefinition(descriptor: CapabilitySemanticToolDescriptor): CapabilitySemanticToolDefinition {
|
||||
|
||||
@@ -286,6 +286,7 @@ describe("Capability semantic catalog and authorization", () => {
|
||||
input: { idempotencyKey: "progress-1", body: "Semantic catalog is active." },
|
||||
});
|
||||
expect(progress).toMatchObject({ ok: true });
|
||||
expect(JSON.stringify(progress)).not.toContain("commandKind");
|
||||
expect(adapter.snapshot().comments).toEqual([
|
||||
expect.objectContaining({ body: "Semantic catalog is active." }),
|
||||
]);
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"moduleResolution": "Bundler",
|
||||
"rootDir": ".",
|
||||
"types": [
|
||||
"vite/client"
|
||||
"vite/client",
|
||||
"node"
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { findServerAdapter } from "../adapters/registry.js";
|
||||
|
||||
const CONFIG = {
|
||||
provider: "claude_managed",
|
||||
managedProfileId: "qualified",
|
||||
anthropicAgentId: "agent_test",
|
||||
agentVersion: "3",
|
||||
anthropicEnvironmentId: "env_test",
|
||||
model: "claude-sonnet-5",
|
||||
maxSessionListCostUsd: 1,
|
||||
managedAgentsRetentionAcknowledged: true,
|
||||
env: { ANTHROPIC_API_KEY: "anthropic-config-secret" },
|
||||
};
|
||||
|
||||
describe("paperclip_runner Claude Agent environment probe", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("ANTHROPIC_API_KEY", "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("uses the resolved adapter secret and performs only read-only qualified-resource probes", async () => {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, init: init ?? {} });
|
||||
if (url.includes("/versions")) {
|
||||
return new Response(JSON.stringify({ data: [{ version: 3 }] }), { status: 200 });
|
||||
}
|
||||
if (url.includes("/environments/")) {
|
||||
return new Response(JSON.stringify({
|
||||
id: "env_test",
|
||||
config: {
|
||||
type: "cloud",
|
||||
networking: {
|
||||
type: "limited",
|
||||
allow_mcp_servers: false,
|
||||
allow_package_managers: false,
|
||||
allowed_hosts: [],
|
||||
},
|
||||
packages: { type: "packages", apt: [], cargo: [], gem: [], go: [], npm: [], pip: [] },
|
||||
},
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({ id: "agent_test" }), { status: 200 });
|
||||
}));
|
||||
|
||||
const result = await findServerAdapter("paperclip_runner")!.testEnvironment!({
|
||||
companyId: "company-1",
|
||||
adapterType: "paperclip_runner",
|
||||
config: CONFIG,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("warn");
|
||||
expect(result.checks.some((check) => check.code === "claude_managed_profile_qualified")).toBe(true);
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls.every((call) => (call.init.method ?? "GET") === "GET")).toBe(true);
|
||||
for (const call of calls) {
|
||||
expect(call.init.headers).toMatchObject({
|
||||
"x-api-key": "anthropic-config-secret",
|
||||
"anthropic-beta": "managed-agents-2026-04-01",
|
||||
});
|
||||
}
|
||||
expect(JSON.stringify(result)).not.toContain("anthropic-config-secret");
|
||||
});
|
||||
|
||||
it("rejects unsafe Environment drift", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/versions")) return new Response(JSON.stringify({ data: [{ version: 3 }] }));
|
||||
if (url.includes("/environments/")) {
|
||||
return new Response(JSON.stringify({
|
||||
id: "env_test",
|
||||
config: {
|
||||
type: "cloud",
|
||||
networking: { type: "unrestricted" },
|
||||
packages: { type: "packages" },
|
||||
},
|
||||
}));
|
||||
}
|
||||
return new Response(JSON.stringify({ id: "agent_test" }));
|
||||
}));
|
||||
const result = await findServerAdapter("paperclip_runner")!.testEnvironment!({
|
||||
companyId: "company-1",
|
||||
adapterType: "paperclip_runner",
|
||||
config: CONFIG,
|
||||
});
|
||||
expect(result.status).toBe("fail");
|
||||
expect(result.checks.some((check) => check.code === "claude_managed_probe_failed")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -188,6 +188,155 @@ async function testPaperclipRunnerOpenCodeEnvironment(
|
||||
}
|
||||
}
|
||||
|
||||
async function testPaperclipRunnerClaudeManagedEnvironment(
|
||||
context: Parameters<typeof codexTestEnvironment>[0],
|
||||
) {
|
||||
const testedAt = new Date().toISOString();
|
||||
const required = [
|
||||
["managedProfileId", "Managed profile ID"],
|
||||
["anthropicAgentId", "Anthropic Agent ID"],
|
||||
["agentVersion", "Agent version"],
|
||||
["anthropicEnvironmentId", "Anthropic Environment ID"],
|
||||
["model", "Claude model"],
|
||||
] as const;
|
||||
const missing = required.filter(([key]) =>
|
||||
typeof context.config[key] !== "string" || String(context.config[key]).trim().length === 0
|
||||
);
|
||||
if (missing.length > 0 || context.config.managedAgentsRetentionAcknowledged !== true) {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt,
|
||||
checks: [{
|
||||
code: "claude_managed_profile_incomplete",
|
||||
level: "error" as const,
|
||||
message: missing.length > 0
|
||||
? `Claude Agent profile is missing: ${missing.map(([, label]) => label).join(", ")}.`
|
||||
: "Claude Agent requires an administrator acknowledgement of beta status and provider retention.",
|
||||
}],
|
||||
};
|
||||
}
|
||||
const cap = Number(context.config.maxSessionListCostUsd);
|
||||
if (!Number.isFinite(cap) || cap <= 0) {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt,
|
||||
checks: [{ code: "claude_managed_spend_cap_invalid", level: "error" as const, message: "Claude Agent requires a positive session spend ceiling." }],
|
||||
};
|
||||
}
|
||||
const configuredEnv = context.config.env && typeof context.config.env === "object"
|
||||
? context.config.env as Record<string, unknown>
|
||||
: {};
|
||||
const key = typeof configuredEnv.ANTHROPIC_API_KEY === "string"
|
||||
? configuredEnv.ANTHROPIC_API_KEY.trim()
|
||||
: process.env.ANTHROPIC_API_KEY?.trim();
|
||||
if (!key) {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt,
|
||||
checks: [{
|
||||
code: "claude_managed_auth_missing",
|
||||
level: "error" as const,
|
||||
message: "ANTHROPIC_API_KEY is not available to the Paperclip server. Bind it through the company secret-reference environment configuration.",
|
||||
}],
|
||||
};
|
||||
}
|
||||
const headers = {
|
||||
"x-api-key": key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-beta": "managed-agents-2026-04-01",
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const [agentResponse, environmentResponse, versionsResponse] = await Promise.all([
|
||||
fetch(`https://api.anthropic.com/v1/agents/${encodeURIComponent(String(context.config.anthropicAgentId))}?beta=true`, { headers, signal: controller.signal }),
|
||||
fetch(`https://api.anthropic.com/v1/environments/${encodeURIComponent(String(context.config.anthropicEnvironmentId))}?beta=true`, { headers, signal: controller.signal }),
|
||||
fetch(`https://api.anthropic.com/v1/agents/${encodeURIComponent(String(context.config.anthropicAgentId))}/versions?beta=true`, { headers, signal: controller.signal }),
|
||||
]);
|
||||
if (!agentResponse.ok || !environmentResponse.ok || !versionsResponse.ok) {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt,
|
||||
checks: [{
|
||||
code: "claude_managed_resource_probe_failed",
|
||||
level: "error" as const,
|
||||
message: `Anthropic resource probe failed (Agent HTTP ${agentResponse.status}; Environment HTTP ${environmentResponse.status}; versions HTTP ${versionsResponse.status}).`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
const agent = await agentResponse.json() as Record<string, unknown>;
|
||||
const environment = await environmentResponse.json() as Record<string, unknown>;
|
||||
const versions = await versionsResponse.json() as Record<string, unknown>;
|
||||
if (agent.id !== context.config.anthropicAgentId || environment.id !== context.config.anthropicEnvironmentId) {
|
||||
throw new Error("Anthropic returned resource identities that do not match the configured profile");
|
||||
}
|
||||
const versionRows = Array.isArray(versions.data) ? versions.data : [];
|
||||
const pinnedVersion = String(context.config.agentVersion);
|
||||
if (!versionRows.some((entry) => {
|
||||
if (!entry || typeof entry !== "object") return false;
|
||||
const value = (entry as Record<string, unknown>).version;
|
||||
return String(value) === pinnedVersion;
|
||||
})) {
|
||||
throw new Error("The configured Anthropic Agent version does not exist");
|
||||
}
|
||||
const environmentConfig = environment.config && typeof environment.config === "object"
|
||||
? environment.config as Record<string, unknown>
|
||||
: {};
|
||||
const networking = environmentConfig.networking && typeof environmentConfig.networking === "object"
|
||||
? environmentConfig.networking as Record<string, unknown>
|
||||
: {};
|
||||
const packages = environmentConfig.packages && typeof environmentConfig.packages === "object"
|
||||
? environmentConfig.packages as Record<string, unknown>
|
||||
: {};
|
||||
const packageEntries = Object.entries(packages)
|
||||
.filter(([key]) => key !== "type")
|
||||
.flatMap(([, value]) => Array.isArray(value) ? value : []);
|
||||
if (
|
||||
environmentConfig.type !== "cloud"
|
||||
|| networking.type !== "limited"
|
||||
|| networking.allow_mcp_servers === true
|
||||
|| networking.allow_package_managers === true
|
||||
|| (Array.isArray(networking.allowed_hosts) && networking.allowed_hosts.length > 0)
|
||||
|| packageEntries.length > 0
|
||||
) {
|
||||
throw new Error("Anthropic Environment drifted from Paperclip's no-network, no-package profile");
|
||||
}
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "warn" as const,
|
||||
testedAt,
|
||||
checks: [{
|
||||
code: "claude_managed_profile_qualified",
|
||||
level: "info" as const,
|
||||
message: `Verified Claude Agent ${String(context.config.anthropicAgentId)} and Environment ${String(context.config.anthropicEnvironmentId)} without creating a session.`,
|
||||
}, {
|
||||
code: "claude_managed_retention_notice",
|
||||
level: "warn" as const,
|
||||
message: "Managed Agents is a stateful beta service and is not eligible for ZDR or HIPAA modes.",
|
||||
}],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
adapterType: "paperclip_runner",
|
||||
status: "fail" as const,
|
||||
testedAt,
|
||||
checks: [{
|
||||
code: "claude_managed_probe_failed",
|
||||
level: "error" as const,
|
||||
message: error instanceof Error && error.name === "AbortError"
|
||||
? "Anthropic resource probe timed out."
|
||||
: "Anthropic resource probe failed before the profile could be verified.",
|
||||
}],
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function compareSemver(left: string, right: string): number {
|
||||
const a = left.split(".").map(Number);
|
||||
const b = right.split(".").map(Number);
|
||||
@@ -409,8 +558,12 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
};
|
||||
},
|
||||
async testEnvironment(context) {
|
||||
const provider = context.config.provider === "opencode" ? "opencode" : "codex";
|
||||
const result = provider === "opencode"
|
||||
const provider = context.config.provider === "opencode"
|
||||
? "opencode"
|
||||
: context.config.provider === "claude_managed" ? "claude_managed" : "codex";
|
||||
const result = provider === "claude_managed"
|
||||
? await testPaperclipRunnerClaudeManagedEnvironment(context)
|
||||
: provider === "opencode"
|
||||
? await testPaperclipRunnerOpenCodeEnvironment(context)
|
||||
: await codexTestEnvironment(context);
|
||||
return { ...result, adapterType: "paperclip_runner" };
|
||||
@@ -421,6 +574,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
models: [
|
||||
...codexModels,
|
||||
{ id: "openrouter/deepseek/deepseek-v4-flash-0731", label: "OpenRouter · DeepSeek V4 Flash 0731" },
|
||||
{ id: "claude-sonnet-5", label: "Claude Sonnet 5" },
|
||||
],
|
||||
modelProfiles: codexModelProfiles,
|
||||
listModels: listCodexModels,
|
||||
@@ -429,18 +583,24 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
supportsInstructionsBundle: true,
|
||||
instructionsPathKey: "instructionsFilePath",
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
getRuntimeCommandSpec: (config) => config.provider === "opencode"
|
||||
getRuntimeCommandSpec: (config) => config.provider === "claude_managed"
|
||||
? { command: "paperclip-runnerd", detectCommand: null, installCommand: null }
|
||||
: config.provider === "opencode"
|
||||
? buildNpmRuntimeCommandSpec(config, "opencode", "opencode-ai@1.18.17")
|
||||
: buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"),
|
||||
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex or OpenCode through the native Paperclip runner and authenticated PRP control-plane connection. OpenCode requires a provider/model identifier and runtime OPENROUTER_API_KEY.\n`,
|
||||
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, or a remote Claude Agent through the native Paperclip runner and authenticated PRP control-plane connection. Claude Agent credentials remain in runnerd; Anthropic never receives a Paperclip credential or endpoint.\n`,
|
||||
getConfigSchema: () => ({
|
||||
fields: [{
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
type: "select",
|
||||
default: "codex",
|
||||
options: [{ value: "codex", label: "Codex" }, { value: "opencode", label: "OpenCode 1.18.17" }],
|
||||
hint: "Select the native provider. OpenCode runs through its authenticated loopback server.",
|
||||
options: [
|
||||
{ value: "codex", label: "Codex" },
|
||||
{ value: "opencode", label: "OpenCode 1.18.17" },
|
||||
{ value: "claude_managed", label: "Claude Agent" },
|
||||
],
|
||||
hint: "Select a local harness or an Anthropic cloud-managed Claude Agent.",
|
||||
}, {
|
||||
key: "lifecycleMode",
|
||||
label: "Runner lifecycle",
|
||||
@@ -459,17 +619,36 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
|
||||
hint: "Warm sessions suspend resumably after this much inactivity.",
|
||||
}, {
|
||||
key: "model",
|
||||
label: "OpenCode model",
|
||||
label: "Model",
|
||||
type: "text",
|
||||
default: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
placeholder: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
hint: "Required when provider is OpenCode; use provider/model form.",
|
||||
hint: "OpenCode uses provider/model form. Claude Agent defaults to claude-sonnet-5.",
|
||||
}, {
|
||||
key: "command",
|
||||
label: "OpenCode command",
|
||||
type: "text",
|
||||
default: "opencode",
|
||||
hint: "OpenCode executable. Version 1.18.17 is qualified.",
|
||||
}, {
|
||||
key: "managedProfileId", label: "Managed Agent profile", type: "text", required: true,
|
||||
hint: "Company-scoped profile identifier. Required for Claude Agent.",
|
||||
meta: { provider: "claude_managed" },
|
||||
}, {
|
||||
key: "anthropicAgentId", label: "Anthropic Agent ID", type: "text", required: true,
|
||||
meta: { provider: "claude_managed" },
|
||||
}, {
|
||||
key: "agentVersion", label: "Pinned Agent version", type: "text", required: true,
|
||||
meta: { provider: "claude_managed" },
|
||||
}, {
|
||||
key: "anthropicEnvironmentId", label: "Anthropic Environment ID", type: "text", required: true,
|
||||
meta: { provider: "claude_managed" },
|
||||
}, {
|
||||
key: "maxSessionListCostUsd", label: "Session spend ceiling (USD)", type: "number", default: 1,
|
||||
hint: "Required hard ceiling for a managed session.", meta: { provider: "claude_managed" },
|
||||
}, {
|
||||
key: "managedAgentsRetentionAcknowledged", label: "Acknowledge beta retention", type: "toggle", default: false,
|
||||
hint: "Managed Agents is stateful and is not eligible for ZDR or HIPAA modes.", meta: { provider: "claude_managed" },
|
||||
}],
|
||||
}),
|
||||
loginCapability: codexLoginCapability,
|
||||
|
||||
@@ -82,6 +82,7 @@ import { accessRoutes } from "./routes/access.js";
|
||||
import { pluginRoutes } from "./routes/plugins.js";
|
||||
import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "./routes/tool-gateway.js";
|
||||
import { adapterRoutes } from "./routes/adapters.js";
|
||||
import { managedAgentProfileRoutes } from "./routes/managed-agent-profiles.js";
|
||||
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
|
||||
import { readBrandedStaticIndexHtml } from "./static-index-html.js";
|
||||
import { applyUiBranding } from "./ui-branding.js";
|
||||
@@ -522,6 +523,7 @@ export async function createApp(
|
||||
api.use(boardChatRoutes(db, { deploymentMode: opts.deploymentMode }));
|
||||
api.use(approvalRoutes(db, { pluginWorkerManager: workerManager }));
|
||||
api.use(secretRoutes(db));
|
||||
api.use(managedAgentProfileRoutes(db));
|
||||
const trustedLocalStdioRuntimeHost =
|
||||
process.env.PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST
|
||||
?? process.env.PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST
|
||||
|
||||
@@ -39,3 +39,4 @@ export { llmRoutes } from "./llms.js";
|
||||
export { accessRoutes } from "./access.js";
|
||||
export { instanceSettingsRoutes } from "./instance-settings.js";
|
||||
export { instanceDatabaseBackupRoutes } from "./instance-database-backups.js";
|
||||
export { managedAgentProfileRoutes } from "./managed-agent-profiles.js";
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import { managedAgentProfileService, type ManagedAgentProfileInput } from "../services/managed-agent-profiles.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { logActivity } from "../services/activity-log.js";
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
function profileInput(value: unknown): ManagedAgentProfileInput {
|
||||
if (!value || typeof value !== "object") throw unprocessable("Managed Agent profile body is required");
|
||||
const body = value as Record<string, unknown>;
|
||||
return {
|
||||
profileKey: String(body.profileKey ?? ""),
|
||||
displayName: String(body.displayName ?? ""),
|
||||
anthropicAgentId: String(body.anthropicAgentId ?? ""),
|
||||
agentVersion: String(body.agentVersion ?? ""),
|
||||
environmentId: String(body.environmentId ?? ""),
|
||||
defaultModel: String(body.defaultModel ?? "claude-sonnet-5"),
|
||||
defaultMaxListCostUsd: Number(body.defaultMaxListCostUsd ?? 1),
|
||||
apiKeySecretId: String(body.apiKeySecretId ?? ""),
|
||||
enabled: body.enabled === true,
|
||||
retentionAcknowledged: body.retentionAcknowledged === true,
|
||||
qualification: body.qualification && typeof body.qualification === "object"
|
||||
? body.qualification as Record<string, unknown>
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function managedAgentProfileRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const profiles = managedAgentProfileService(db);
|
||||
|
||||
router.get("/companies/:companyId/managed-agent-profiles", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res.json(await profiles.list(companyId));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/managed-agent-profiles", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const profile = await profiles.upsert(companyId, profileInput(req.body));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "managed_agent_profile.qualified",
|
||||
entityType: "managed_agent_profile",
|
||||
entityId: profile.id,
|
||||
details: {
|
||||
profileKey: profile.profileKey,
|
||||
service: profile.service,
|
||||
agentVersion: profile.agentVersion,
|
||||
environmentId: profile.environmentId,
|
||||
model: profile.defaultModel,
|
||||
enabled: profile.enabled,
|
||||
retentionAcknowledged: profile.retentionAcknowledged,
|
||||
},
|
||||
});
|
||||
res.status(201).json(profile);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -85,6 +85,7 @@ export { scrubGitCredentialText };
|
||||
import { publishLiveEvent } from "./live-events.js";
|
||||
import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
|
||||
import {
|
||||
buildNativeProviderEnvironment,
|
||||
buildNativeExecutionInput,
|
||||
cancelNativeSession,
|
||||
dispatchNativeSessionResumptions,
|
||||
@@ -303,6 +304,7 @@ import { evaluateCodexCredentialReadiness } from "@paperclipai/adapter-codex-loc
|
||||
import { environmentService } from "./environments.js";
|
||||
import { parseExecutionPolicyBootstrapEnv } from "./execution-policy-bootstrap.js";
|
||||
import { environmentRuntimeService } from "./environment-runtime.js";
|
||||
import { managedAgentProfileService } from "./managed-agent-profiles.js";
|
||||
import { skillVersionSelectionMap } from "./runtime-skill-selections.js";
|
||||
import { environmentRunOrchestrator } from "./environment-run-orchestrator.js";
|
||||
import { isUnsafeSessionWorkspaceCwd } from "./session-workspace-cwd.js";
|
||||
@@ -14062,6 +14064,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
|
||||
activeRunExecutions.add(run.id);
|
||||
let runScratch: HeartbeatRunScratch | null = null;
|
||||
let nativeSessionResumeScheduled = false;
|
||||
|
||||
try {
|
||||
const agent = await getAgent(run.agentId);
|
||||
@@ -15923,6 +15926,32 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
agentId: agent.id,
|
||||
interactionIds: interactionId ? [interactionId] : [],
|
||||
});
|
||||
const managedProfile = nativeRuntimeResolution.profile.backend === "claude_managed_agents_api"
|
||||
? await managedAgentProfileService(db).requireQualified(
|
||||
agent.companyId,
|
||||
readNonEmptyString(parseObject(runtimeConfig).managedProfileId) ?? "",
|
||||
)
|
||||
: null;
|
||||
if (managedProfile) {
|
||||
const rawApiKeyBinding = parseObject(parseObject(agent.adapterConfig).env).ANTHROPIC_API_KEY;
|
||||
const boundSecretId = typeof rawApiKeyBinding === "object" && rawApiKeyBinding !== null
|
||||
? readNonEmptyString((rawApiKeyBinding as Record<string, unknown>).secretId)
|
||||
: null;
|
||||
if (boundSecretId !== managedProfile.apiKeySecretId) {
|
||||
throw new ConfigurationIncompleteFailure(
|
||||
"configuration incomplete: Claude Agent profile API key is not bound at env.ANTHROPIC_API_KEY",
|
||||
{
|
||||
configurationIncomplete: {
|
||||
reason: "managed_agent_profile_secret_binding_mismatch",
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
profileId: managedProfile.id,
|
||||
requiredEnvKeys: ["ANTHROPIC_API_KEY"],
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
nativeExecution = buildNativeExecutionInput({
|
||||
companyId: agent.companyId,
|
||||
runId: run.id,
|
||||
@@ -15941,11 +15970,28 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
branchName: executionWorkspace.branchName,
|
||||
},
|
||||
normalizedSessionId: nativeSessionId,
|
||||
provider: nativeRuntimeResolution.profile.backend === "opencode_server" ? "opencode" : "codex",
|
||||
model: typeof parseObject(agent.adapterConfig).model === "string"
|
||||
? String(parseObject(agent.adapterConfig).model)
|
||||
: null,
|
||||
lifecyclePolicy: parseObject(agent.adapterConfig).lifecycleMode === "warm"
|
||||
provider: nativeRuntimeResolution.profile.backend === "opencode_server"
|
||||
? "opencode"
|
||||
: nativeRuntimeResolution.profile.backend === "claude_managed_agents_api"
|
||||
? "claude_managed"
|
||||
: "codex",
|
||||
model: typeof parseObject(runtimeConfig).model === "string"
|
||||
? String(parseObject(runtimeConfig).model)
|
||||
: managedProfile?.defaultModel ?? null,
|
||||
...(nativeRuntimeResolution.profile.backend === "claude_managed_agents_api" ? {
|
||||
managedProfile: {
|
||||
profileId: managedProfile!.id,
|
||||
anthropicAgentId: managedProfile!.anthropicAgentId,
|
||||
agentVersion: managedProfile!.agentVersion,
|
||||
environmentId: managedProfile!.environmentId,
|
||||
betaVersion: "managed-agents-2026-04-01" as const,
|
||||
},
|
||||
maxSessionListCostUsd: Number(
|
||||
parseObject(runtimeConfig).maxSessionListCostUsd
|
||||
?? managedProfile!.defaultMaxListCostCents / 100,
|
||||
),
|
||||
} : {}),
|
||||
lifecyclePolicy: parseObject(runtimeConfig).lifecycleMode === "warm"
|
||||
? {
|
||||
mode: "warm",
|
||||
idleTimeoutMs: Number.isSafeInteger(parseObject(agent.adapterConfig).idleTimeoutMs)
|
||||
@@ -16223,6 +16269,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
backend: options.nativeSessionBackendFactory?.(nativeExecution),
|
||||
useRunnerd: agent.adapterType === "paperclip_runner",
|
||||
onLog,
|
||||
// Bootstrap the provider with executable/home discovery while
|
||||
// keeping the agent's configured provider values authoritative.
|
||||
runnerEnvironment: buildNativeProviderEnvironment(adapterEnv),
|
||||
onSpawn: async (meta) => {
|
||||
await persistRunProcessMetadata(run.id, meta);
|
||||
},
|
||||
@@ -16306,7 +16355,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]?.phase === "retryable_failure" && rows[0]?.resultId === null)
|
||||
: false;
|
||||
if (nativeResumeScheduled) throw new NativeSessionResumeScheduledError(adapterErr);
|
||||
if (nativeResumeScheduled) {
|
||||
nativeSessionResumeScheduled = true;
|
||||
throw new NativeSessionResumeScheduledError(adapterErr);
|
||||
}
|
||||
// Adapter (or its restore finally) threw — or the finalize record
|
||||
// write itself threw. Either way the workspace may be in a partial
|
||||
// state. Best-effort record finalize=failed so the dependent readiness
|
||||
@@ -17058,7 +17110,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
// terminal". When the teardown reaches this point with the run still
|
||||
// running or queued, force a terminal status before the lease is
|
||||
// released, so the UI never shows a finished task as "Live".
|
||||
if (latestRun) {
|
||||
if (latestRun && !nativeSessionResumeScheduled) {
|
||||
latestRun = await terminalizeRunOnLeaseRelease(latestRun).catch((terminalizeErr) => {
|
||||
logger.error(
|
||||
{ err: terminalizeErr, runId: run.id },
|
||||
@@ -17067,14 +17119,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
return latestRun;
|
||||
});
|
||||
}
|
||||
await releaseEnvironmentLeasesForRun({
|
||||
runId: run.id,
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
status: latestRun?.status,
|
||||
failureReason: latestRun?.error ?? undefined,
|
||||
});
|
||||
await releaseRuntimeServicesForRun(run.id).catch(() => undefined);
|
||||
if (!nativeSessionResumeScheduled) {
|
||||
await releaseEnvironmentLeasesForRun({
|
||||
runId: run.id,
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
status: latestRun?.status,
|
||||
failureReason: latestRun?.error ?? undefined,
|
||||
});
|
||||
await releaseRuntimeServicesForRun(run.id).catch(() => undefined);
|
||||
}
|
||||
if (runScratch && latestRun && isHeartbeatRunTerminalStatus(latestRun.status)) {
|
||||
const scratchForCleanup = runScratch;
|
||||
let scratchCleanup: Awaited<ReturnType<typeof cleanupHeartbeatRunScratch>> | null = null;
|
||||
@@ -17128,7 +17182,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
}
|
||||
}
|
||||
activeRunExecutions.delete(run.id);
|
||||
await startNextQueuedRunForAgent(run.agentId);
|
||||
if (!nativeSessionResumeScheduled) {
|
||||
await startNextQueuedRunForAgent(run.agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -192,3 +192,8 @@ export {
|
||||
} from "./codex-auth-reconciliation.js";
|
||||
export { reconcilePersistedRuntimeServicesOnStartup, restartDesiredRuntimeServicesOnStartup } from "./workspace-runtime.js";
|
||||
export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";
|
||||
export {
|
||||
managedAgentProfileService,
|
||||
CLAUDE_MANAGED_BETA_VERSION,
|
||||
type ManagedAgentProfileInput,
|
||||
} from "./managed-agent-profiles.js";
|
||||
@@ -0,0 +1,109 @@
|
||||
import { and, asc, eq, ne } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companySecrets, managedAgentProfiles } from "@paperclipai/db";
|
||||
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
|
||||
export const CLAUDE_MANAGED_BETA_VERSION = "managed-agents-2026-04-01" as const;
|
||||
|
||||
export interface ManagedAgentProfileInput {
|
||||
profileKey: string;
|
||||
displayName: string;
|
||||
anthropicAgentId: string;
|
||||
agentVersion: string;
|
||||
environmentId: string;
|
||||
defaultModel: string;
|
||||
defaultMaxListCostUsd: number;
|
||||
apiKeySecretId: string;
|
||||
enabled: boolean;
|
||||
retentionAcknowledged: boolean;
|
||||
qualification?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function required(value: string, label: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) throw unprocessable(`${label} is required`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function toCents(value: number): number {
|
||||
const cents = Math.round(value * 100);
|
||||
if (!Number.isSafeInteger(cents) || cents <= 0) {
|
||||
throw unprocessable("Managed Agent default spend ceiling must be positive");
|
||||
}
|
||||
return cents;
|
||||
}
|
||||
|
||||
export function managedAgentProfileService(db: Db) {
|
||||
async function list(companyId: string) {
|
||||
return db.select().from(managedAgentProfiles)
|
||||
.where(eq(managedAgentProfiles.companyId, companyId))
|
||||
.orderBy(asc(managedAgentProfiles.displayName));
|
||||
}
|
||||
|
||||
async function get(companyId: string, profileIdOrKey: string) {
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(profileIdOrKey);
|
||||
const rows = await db.select().from(managedAgentProfiles).where(and(
|
||||
eq(managedAgentProfiles.companyId, companyId),
|
||||
isUuid
|
||||
? eq(managedAgentProfiles.id, profileIdOrKey)
|
||||
: eq(managedAgentProfiles.profileKey, profileIdOrKey),
|
||||
)).limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function requireQualified(companyId: string, profileIdOrKey: string) {
|
||||
const profile = await get(companyId, profileIdOrKey);
|
||||
if (!profile) throw notFound("Managed Agent profile not found");
|
||||
if (!profile.enabled || !profile.retentionAcknowledged || !profile.qualifiedAt) {
|
||||
throw conflict("Managed Agent profile is not enabled and qualified");
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function upsert(companyId: string, input: ManagedAgentProfileInput) {
|
||||
const secret = await db.select({ id: companySecrets.id })
|
||||
.from(companySecrets)
|
||||
.where(and(
|
||||
eq(companySecrets.companyId, companyId),
|
||||
eq(companySecrets.id, input.apiKeySecretId),
|
||||
eq(companySecrets.scope, "company"),
|
||||
ne(companySecrets.status, "deleted"),
|
||||
))
|
||||
.limit(1);
|
||||
if (!secret[0]) throw unprocessable("Managed Agent API-key secret reference is invalid");
|
||||
if (input.enabled && !input.retentionAcknowledged) {
|
||||
throw unprocessable("Enabling Managed Agents requires the retention acknowledgement");
|
||||
}
|
||||
|
||||
const profileKey = required(input.profileKey, "Profile key");
|
||||
const values = {
|
||||
companyId,
|
||||
profileKey,
|
||||
displayName: required(input.displayName, "Display name"),
|
||||
service: "anthropic_managed_agents",
|
||||
anthropicAgentId: required(input.anthropicAgentId, "Anthropic Agent ID"),
|
||||
agentVersion: required(input.agentVersion, "Agent version"),
|
||||
environmentId: required(input.environmentId, "Anthropic Environment ID"),
|
||||
betaVersion: CLAUDE_MANAGED_BETA_VERSION,
|
||||
defaultModel: required(input.defaultModel, "Default model"),
|
||||
defaultMaxListCostCents: toCents(input.defaultMaxListCostUsd),
|
||||
apiKeySecretId: input.apiKeySecretId,
|
||||
enabled: input.enabled,
|
||||
retentionAcknowledged: input.retentionAcknowledged,
|
||||
qualification: input.qualification ?? {},
|
||||
qualifiedAt: input.enabled ? new Date() : null,
|
||||
updatedAt: new Date(),
|
||||
} as const;
|
||||
const [row] = await db.insert(managedAgentProfiles)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: [managedAgentProfiles.companyId, managedAgentProfiles.profileKey],
|
||||
set: values,
|
||||
})
|
||||
.returning();
|
||||
return row!;
|
||||
}
|
||||
|
||||
return { list, get, requireQualified, upsert };
|
||||
}
|
||||
@@ -26,8 +26,16 @@ export function buildNativeExecutionInput(input: {
|
||||
branchName: string | null;
|
||||
};
|
||||
normalizedSessionId: string | null;
|
||||
provider?: "codex" | "opencode";
|
||||
provider?: "codex" | "opencode" | "claude_managed";
|
||||
model?: string | null;
|
||||
managedProfile?: {
|
||||
profileId: string;
|
||||
anthropicAgentId: string;
|
||||
agentVersion: string;
|
||||
environmentId: string;
|
||||
betaVersion: "managed-agents-2026-04-01";
|
||||
};
|
||||
maxSessionListCostUsd?: number;
|
||||
lifecyclePolicy?: NativeExecutionInputV1["session"]["lifecyclePolicy"];
|
||||
interactionResponses?: NativeInteractionResponseEnvelope[];
|
||||
completionContract: {
|
||||
@@ -64,11 +72,20 @@ export function buildNativeExecutionInput(input: {
|
||||
},
|
||||
session: {
|
||||
normalizedSessionId: input.normalizedSessionId,
|
||||
driverKind: input.provider === "opencode" ? "opencode_server" : "codex_app_server",
|
||||
driverKind: input.provider === "opencode"
|
||||
? "opencode_server"
|
||||
: input.provider === "claude_managed"
|
||||
? "claude_managed_agents_api"
|
||||
: "codex_app_server",
|
||||
protocolVersion: 1,
|
||||
lifecyclePolicy: input.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
|
||||
},
|
||||
provider: {
|
||||
provider: input.provider === "claude_managed" ? {
|
||||
kind: "claude_managed",
|
||||
model: input.model,
|
||||
managedProfile: input.managedProfile,
|
||||
maxSessionListCostUsd: input.maxSessionListCostUsd,
|
||||
} : {
|
||||
kind: input.provider ?? "codex",
|
||||
model: input.model ?? null,
|
||||
},
|
||||
|
||||
@@ -28,12 +28,43 @@ vi.mock("@paperclipai/paperclip-runner", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
buildNativeProviderEnvironment,
|
||||
cancelNativeSession,
|
||||
executePaperclipNativeSession,
|
||||
nativeSessionFailureDisposition,
|
||||
nativeSessionFailureSourceCode,
|
||||
providerPlanMarkdown,
|
||||
} from "./native-session-executor.js";
|
||||
|
||||
describe("native provider bootstrap environment", () => {
|
||||
it("inherits the host executable and credential-home context", () => {
|
||||
expect(buildNativeProviderEnvironment({}, {
|
||||
PATH: "/opt/homebrew/bin:/usr/bin",
|
||||
HOME: "/Users/runner",
|
||||
CODEX_HOME: "/Users/runner/.codex",
|
||||
PAPERCLIP_INTERNAL_SECRET: "must-not-leak",
|
||||
})).toEqual({
|
||||
PATH: "/opt/homebrew/bin:/usr/bin",
|
||||
HOME: "/Users/runner",
|
||||
CODEX_HOME: "/Users/runner/.codex",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets explicitly configured agent env override host defaults", () => {
|
||||
expect(buildNativeProviderEnvironment({
|
||||
PATH: "/agent/bin",
|
||||
OPENAI_API_KEY: "configured-provider-key",
|
||||
}, {
|
||||
PATH: "/host/bin",
|
||||
HOME: "/Users/runner",
|
||||
})).toEqual({
|
||||
PATH: "/agent/bin",
|
||||
HOME: "/Users/runner",
|
||||
OPENAI_API_KEY: "configured-provider-key",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const execution = {
|
||||
provider: { kind: "codex", model: null },
|
||||
binding: {
|
||||
@@ -45,6 +76,30 @@ const execution = {
|
||||
completionContract: { id: "contract", sha256: "sha" },
|
||||
} as NativeExecutionInputV1;
|
||||
|
||||
describe("provider plan synchronization", () => {
|
||||
it("renders a bounded Markdown checklist without embedding provenance", () => {
|
||||
const markdown = providerPlanMarkdown({
|
||||
explanation: "Release safely",
|
||||
steps: [
|
||||
{ body: "Prepare", status: "completed" },
|
||||
{ body: "Deploy", status: "in_progress" },
|
||||
{ body: "Verify", status: "blocked" },
|
||||
],
|
||||
runId: "must-not-appear",
|
||||
providerThreadId: "native-secret",
|
||||
});
|
||||
expect(markdown).toBe([
|
||||
"Release safely",
|
||||
"",
|
||||
"- [x] Prepare",
|
||||
"- [ ] Deploy _(in progress)_",
|
||||
"- [ ] Verify _(blocked)_",
|
||||
].join("\n"));
|
||||
expect(markdown).not.toContain("must-not-appear");
|
||||
expect(markdown).not.toContain("native-secret");
|
||||
});
|
||||
});
|
||||
|
||||
function leaseDb(boundExecution: NativeExecutionInputV1 = execution): Db {
|
||||
const coordinator = {
|
||||
runId: boundExecution.binding.runId,
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
executeNativeSession,
|
||||
} from "../../vendor/paperclip-runner/index.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { heartbeatRuns, issues, nativeRunFinalizations } from "@paperclipai/db";
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { documentRevisions, heartbeatRuns, issueDocuments, issues, nativeRunFinalizations } from "@paperclipai/db";
|
||||
import { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js";
|
||||
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
||||
import { registerRunnerPrpAuthority } from "../../realtime/runner-prp-ws.js";
|
||||
@@ -25,11 +25,13 @@ import { issueRecoveryActionService } from "../issue-recovery-actions.js";
|
||||
import { persistActivity, publishActivity } from "../activity-log.js";
|
||||
import { commitNativeStatusDecision } from "./status-decision-committer.js";
|
||||
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
|
||||
import { documentService } from "../documents.js";
|
||||
import {
|
||||
NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
type NativeAuthoritativeIssueStatus,
|
||||
type NativeStatusDecision,
|
||||
} from "./status-arbiter.js";
|
||||
import { HttpError } from "../../errors.js";
|
||||
|
||||
type ActiveNativeSession = {
|
||||
session: NativeSession;
|
||||
@@ -48,6 +50,109 @@ type WarmNativeSession = {
|
||||
|
||||
const warmNativeSessions = new Map<string, WarmNativeSession>();
|
||||
|
||||
const NATIVE_PROVIDER_HOST_ENV_KEYS = [
|
||||
"PATH",
|
||||
"HOME",
|
||||
"USER",
|
||||
"LOGNAME",
|
||||
"SHELL",
|
||||
"TMPDIR",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"CODEX_HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"SystemRoot",
|
||||
"PATHEXT",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Provider bootstrap needs a small amount of host process context even when
|
||||
* the agent has no configured env. In particular, an empty environment makes
|
||||
* a bare `codex` command unresolvable. Agent-configured values remain
|
||||
* authoritative and may intentionally override the host defaults.
|
||||
*/
|
||||
export function buildNativeProviderEnvironment(
|
||||
configured: NodeJS.ProcessEnv,
|
||||
host: NodeJS.ProcessEnv = process.env,
|
||||
): NodeJS.ProcessEnv {
|
||||
const inherited = Object.fromEntries(
|
||||
NATIVE_PROVIDER_HOST_ENV_KEYS.flatMap((key) => {
|
||||
const value = host[key];
|
||||
return typeof value === "string" && value.length > 0 ? [[key, value]] : [];
|
||||
}),
|
||||
);
|
||||
return { ...inherited, ...configured };
|
||||
}
|
||||
|
||||
type PlanSynchronization = {
|
||||
eventId: string;
|
||||
planId: string;
|
||||
providerRevision: number;
|
||||
status: "synchronized" | "already_synchronized" | "conflict" | "invalid";
|
||||
documentRevision: number | null;
|
||||
currentRevisionId: string | null;
|
||||
};
|
||||
|
||||
export function providerPlanMarkdown(payload: Record<string, unknown>): string {
|
||||
const explanation = typeof payload.explanation === "string" ? payload.explanation.trim() : "";
|
||||
const steps = Array.isArray(payload.steps) ? payload.steps : [];
|
||||
const lines = steps.slice(0, 256).flatMap((value) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
||||
const step = value as Record<string, unknown>;
|
||||
const body = typeof step.body === "string" ? step.body.trim().slice(0, 4_000) : "";
|
||||
if (!body) return [];
|
||||
const status = step.status === "completed" ? "x" : " ";
|
||||
const suffix = step.status === "blocked" ? " _(blocked)_" : step.status === "in_progress" ? " _(in progress)_" : "";
|
||||
return [`- [${status}] ${body}${suffix}`];
|
||||
});
|
||||
return [explanation, lines.join("\n")].filter(Boolean).join("\n\n").slice(0, 256_000);
|
||||
}
|
||||
|
||||
async function synchronizeCompletedProviderPlan(input: {
|
||||
db: Db;
|
||||
execution: NativeExecutionInputV1;
|
||||
event: { sourceEventId: string; turnId?: string; eventType: string; payload: Record<string, unknown> };
|
||||
}): Promise<PlanSynchronization | null> {
|
||||
if (input.event.eventType !== "plan.updated" || input.event.payload.complete !== true) return null;
|
||||
const planId = typeof input.event.payload.planId === "string" ? input.event.payload.planId : "";
|
||||
const providerRevision = Number.isSafeInteger(input.event.payload.revision) ? Number(input.event.payload.revision) : 0;
|
||||
const body = providerPlanMarkdown(input.event.payload);
|
||||
if (!planId || providerRevision < 1 || !body) {
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "invalid", documentRevision: null, currentRevisionId: null };
|
||||
}
|
||||
const provenance = `runner-plan-sync:v1 run=${input.execution.binding.runId} turn=${input.event.turnId ?? "unknown"} provider=${input.execution.provider.kind} plan=${planId} revision=${providerRevision}`;
|
||||
const existingRevision = await input.db.select({ revisionNumber: documentRevisions.revisionNumber, id: documentRevisions.id })
|
||||
.from(documentRevisions)
|
||||
.innerJoin(issueDocuments, eq(issueDocuments.documentId, documentRevisions.documentId))
|
||||
.where(and(eq(issueDocuments.issueId, input.execution.binding.issueId), eq(issueDocuments.key, "plan"), eq(documentRevisions.changeSummary, provenance)))
|
||||
.orderBy(desc(documentRevisions.revisionNumber)).limit(1).then((rows) => rows[0] ?? null);
|
||||
if (existingRevision) {
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "already_synchronized", documentRevision: existingRevision.revisionNumber, currentRevisionId: existingRevision.id };
|
||||
}
|
||||
const documents = documentService(input.db);
|
||||
const current = await documents.getIssueDocumentByKey(input.execution.binding.issueId, "plan");
|
||||
try {
|
||||
const write = await documents.upsertIssueDocument({
|
||||
issueId: input.execution.binding.issueId,
|
||||
key: "plan",
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body,
|
||||
baseRevisionId: current?.latestRevisionId ?? null,
|
||||
changeSummary: provenance,
|
||||
createdByAgentId: input.execution.binding.agentId,
|
||||
createdByRunId: input.execution.binding.runId,
|
||||
});
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "synchronized", documentRevision: write.document.latestRevisionNumber, currentRevisionId: write.document.latestRevisionId };
|
||||
} catch (error) {
|
||||
if (!(error instanceof HttpError) || error.status !== 409) throw error;
|
||||
const latest = await documents.getIssueDocumentByKey(input.execution.binding.issueId, "plan");
|
||||
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "conflict", documentRevision: latest?.latestRevisionNumber ?? null, currentRevisionId: latest?.latestRevisionId ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
class SessionToolAuthorityRouter {
|
||||
#authority: PaperclipRunnerToolAuthority;
|
||||
|
||||
@@ -75,12 +180,17 @@ function nativeSessionKey(execution: NativeExecutionInputV1): string {
|
||||
}
|
||||
|
||||
function nativeSessionConfigDigest(execution: NativeExecutionInputV1): string {
|
||||
const executionLocation = execution.provider.kind === "claude_managed"
|
||||
? { executionKind: "remote_service", workspace: null }
|
||||
: {
|
||||
executionKind: "local_process",
|
||||
workspaceId: execution.binding.executionWorkspaceId,
|
||||
cwd: execution.workspace.cwd,
|
||||
};
|
||||
return `sha256:${createHash("sha256").update(JSON.stringify({
|
||||
companyId: execution.binding.companyId,
|
||||
issueId: execution.binding.issueId,
|
||||
agentId: execution.binding.agentId,
|
||||
workspaceId: execution.binding.executionWorkspaceId,
|
||||
cwd: execution.workspace.cwd,
|
||||
normalizedSessionId: nativeSessionKey(execution),
|
||||
executionLocation,
|
||||
provider: execution.provider,
|
||||
driverKind: execution.session.driverKind,
|
||||
lifecyclePolicy: execution.session.lifecyclePolicy,
|
||||
@@ -397,6 +507,8 @@ export async function executePaperclipNativeSession(input: {
|
||||
backend?: NativeSessionBackend;
|
||||
useRunnerd?: boolean;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
/** Resolved adapter env; the runner transport applies a provider allowlist before spawn. */
|
||||
runnerEnvironment?: NodeJS.ProcessEnv;
|
||||
}): Promise<AdapterExecutionResult> {
|
||||
const leaseOwner = input.leaseOwner ?? `${input.runnerInstanceId}:${randomUUID()}`;
|
||||
const leaseNow = new Date();
|
||||
@@ -434,6 +546,7 @@ export async function executePaperclipNativeSession(input: {
|
||||
return coordinator.attempt + 1;
|
||||
});
|
||||
const controlPlaneInstanceId = `${input.runnerInstanceId}:control`;
|
||||
const planSynchronizations: PlanSynchronization[] = [];
|
||||
const controlPlane = new PaperclipControlPlanePort(input.db, {
|
||||
companyId: input.execution.binding.companyId,
|
||||
issueId: input.execution.binding.issueId,
|
||||
@@ -444,9 +557,18 @@ export async function executePaperclipNativeSession(input: {
|
||||
sourceInstanceId: input.runnerInstanceId,
|
||||
controlPlaneSourceInstanceId: controlPlaneInstanceId,
|
||||
}, {
|
||||
onCommittedEvent: input.onLog
|
||||
? async (event) => input.onLog!("stdout", `${JSON.stringify({ type: "paperclip.prp.event", event })}\n`)
|
||||
: undefined,
|
||||
onCommittedEvent: async (event) => {
|
||||
if (input.onLog) await input.onLog("stdout", `${JSON.stringify({ type: "paperclip.prp.event", event })}\n`);
|
||||
const synchronization = await synchronizeCompletedProviderPlan({
|
||||
db: input.db,
|
||||
execution: input.execution,
|
||||
event: event as { sourceEventId: string; turnId?: string; eventType: string; payload: Record<string, unknown> },
|
||||
});
|
||||
if (synchronization) {
|
||||
planSynchronizations.push(synchronization);
|
||||
if (input.onLog) await input.onLog("stdout", `${JSON.stringify({ type: "paperclip.plan.synchronization", synchronization })}\n`);
|
||||
}
|
||||
},
|
||||
});
|
||||
let native: Awaited<ReturnType<typeof executeNativeSession>>;
|
||||
const lifecyclePolicy = input.execution.session?.lifecyclePolicy
|
||||
@@ -479,7 +601,7 @@ export async function executePaperclipNativeSession(input: {
|
||||
?? createNativeSessionBackend(input.execution, {
|
||||
runnerInstanceId: input.runnerInstanceId,
|
||||
onSpawn: input.onSpawn,
|
||||
environment: process.env,
|
||||
environment: input.runnerEnvironment ?? process.env,
|
||||
opencodeRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "opencode"),
|
||||
}),
|
||||
controlPlane,
|
||||
@@ -617,16 +739,19 @@ export async function executePaperclipNativeSession(input: {
|
||||
resultJson: {
|
||||
nativeResult: native.result as unknown as Record<string, unknown>,
|
||||
nativeTerminal: native.terminal as unknown as Record<string, unknown>,
|
||||
planSynchronizations,
|
||||
},
|
||||
summary: native.result.summary,
|
||||
sessionId: native.normalizedSessionId,
|
||||
sessionDisplayId: native.providerSessionId ?? native.normalizedSessionId,
|
||||
provider: input.execution.provider.kind === "opencode"
|
||||
provider: input.execution.provider.kind === "claude_managed"
|
||||
? "anthropic"
|
||||
: input.execution.provider.kind === "opencode"
|
||||
? input.execution.provider.model?.split("/", 1)[0] ?? "opencode"
|
||||
: "openai",
|
||||
model: input.execution.provider.model,
|
||||
usage: normalizeNativeUsage(native.usage),
|
||||
costUsd: numericUsageField(native.usage, ["costUsd", "cost"]),
|
||||
costUsd: numericUsageField(native.usage, ["providerCostUsd", "costUsd", "cost"]),
|
||||
usageBasis: "per_run",
|
||||
nativeFinalization: finalization,
|
||||
};
|
||||
@@ -661,6 +786,7 @@ function createRunnerdBackend(input: {
|
||||
execution: NativeExecutionInputV1;
|
||||
runnerInstanceId: string;
|
||||
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
||||
runnerEnvironment?: NodeJS.ProcessEnv;
|
||||
}): NativeSessionBackend {
|
||||
const authority = new PaperclipRunnerToolAuthority(input.db, {
|
||||
companyId: input.execution.binding.companyId,
|
||||
@@ -683,11 +809,19 @@ function createRunnerdBackend(input: {
|
||||
onSpawn: input.onSpawn,
|
||||
dynamicTools: authorityRouter.definitions(),
|
||||
dynamicToolHandler: (call) => authorityRouter.execute(call),
|
||||
environment: process.env,
|
||||
environment: input.runnerEnvironment ?? process.env,
|
||||
opencodeRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "opencode"),
|
||||
codexTransportFactory: () => createRunnerdCodexTransport({
|
||||
provider: input.execution.provider.kind,
|
||||
...(input.execution.provider.kind === "claude_managed" ? {
|
||||
managedProfile: {
|
||||
...input.execution.provider.managedProfile,
|
||||
maxSessionListCostUsd: input.execution.provider.maxSessionListCostUsd,
|
||||
model: input.execution.provider.model,
|
||||
},
|
||||
} : {}),
|
||||
stateDirectory: root,
|
||||
environment: process.env,
|
||||
environment: input.runnerEnvironment ?? process.env,
|
||||
lifecyclePolicy: input.execution.session.lifecyclePolicy,
|
||||
resumeDynamicTools: authorityRouter.definitions(),
|
||||
prpIdentity: {
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("resolveNativeRuntimeMode", () => {
|
||||
runtimeConfig: {},
|
||||
adapterConfig: { provider: "claude" },
|
||||
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
|
||||
})).toThrow(/provider must be codex or opencode/);
|
||||
})).toThrow(/provider must be codex, opencode, or claude_managed/);
|
||||
});
|
||||
|
||||
it("selects OpenCode only with a provider/model value", () => {
|
||||
|
||||
@@ -17,7 +17,11 @@ export type NativeRuntimeResolution =
|
||||
kind: "native";
|
||||
resolverVersion: typeof NATIVE_RUNTIME_RESOLVER_VERSION;
|
||||
reason: "eligible_opt_in";
|
||||
profile: { mode: "native"; backend: "codex_app_server" | "opencode_server"; protocolVersion: 1 };
|
||||
profile: {
|
||||
mode: "native";
|
||||
backend: "codex_app_server" | "opencode_server" | "claude_managed_agents_api";
|
||||
protocolVersion: 1;
|
||||
};
|
||||
authorityDecision: NativeStatusDecision;
|
||||
};
|
||||
|
||||
@@ -47,8 +51,8 @@ export function resolveNativeRuntimeMode(input: {
|
||||
const nativeRunner = record(record(input.runtimeConfig).nativeRunner);
|
||||
const runnerAdapterSelected = input.agent.adapterType === "paperclip_runner";
|
||||
const runnerProvider = record(input.adapterConfig).provider ?? "codex";
|
||||
if (runnerAdapterSelected && runnerProvider !== "codex" && runnerProvider !== "opencode") {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner provider must be codex or opencode");
|
||||
if (runnerAdapterSelected && !["codex", "opencode", "claude_managed"].includes(String(runnerProvider))) {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner provider must be codex, opencode, or claude_managed");
|
||||
}
|
||||
if (runnerAdapterSelected && runnerProvider === "opencode") {
|
||||
const model = record(input.adapterConfig).model;
|
||||
@@ -56,6 +60,21 @@ export function resolveNativeRuntimeMode(input: {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner OpenCode provider requires model in provider/model form");
|
||||
}
|
||||
}
|
||||
if (runnerAdapterSelected && runnerProvider === "claude_managed") {
|
||||
const config = record(input.adapterConfig);
|
||||
for (const key of ["managedProfileId", "anthropicAgentId", "agentVersion", "anthropicEnvironmentId", "model"]) {
|
||||
if (typeof config[key] !== "string" || String(config[key]).trim().length === 0) {
|
||||
throw new NativeRuntimeEligibilityError(`paperclip_runner Claude Agent provider requires ${key}`);
|
||||
}
|
||||
}
|
||||
if (config.managedAgentsRetentionAcknowledged !== true) {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner Claude Agent provider requires retention acknowledgement");
|
||||
}
|
||||
const cap = Number(config.maxSessionListCostUsd);
|
||||
if (!Number.isFinite(cap) || cap <= 0) {
|
||||
throw new NativeRuntimeEligibilityError("paperclip_runner Claude Agent provider requires a positive spend ceiling");
|
||||
}
|
||||
}
|
||||
const mode = runnerAdapterSelected ? "native" : nativeRunner.mode;
|
||||
if (!runnerAdapterSelected && !input.enabled) {
|
||||
const compatibility = resolveNativeCompatibilityStatus({
|
||||
@@ -90,7 +109,7 @@ export function resolveNativeRuntimeMode(input: {
|
||||
throw new NativeRuntimeEligibilityError("run must be bound to a standard issue");
|
||||
}
|
||||
if (
|
||||
(!input.workspaceId && !runnerAdapterSelected)
|
||||
(!input.workspaceId && !runnerAdapterSelected && runnerProvider !== "claude_managed")
|
||||
|| input.target?.kind && input.target.kind !== "local"
|
||||
) {
|
||||
throw new NativeRuntimeEligibilityError("a realized local workspace is required");
|
||||
@@ -109,7 +128,11 @@ export function resolveNativeRuntimeMode(input: {
|
||||
reason: "eligible_opt_in",
|
||||
profile: {
|
||||
mode: "native",
|
||||
backend: runnerProvider === "opencode" ? "opencode_server" : "codex_app_server",
|
||||
backend: runnerProvider === "opencode"
|
||||
? "opencode_server"
|
||||
: runnerProvider === "claude_managed"
|
||||
? "claude_managed_agents_api"
|
||||
: "codex_app_server",
|
||||
protocolVersion: 1,
|
||||
},
|
||||
authorityDecision: rollout,
|
||||
@@ -144,7 +167,11 @@ export function resolveHeartbeatNativeRuntimeMode(input: {
|
||||
reason: "eligible_opt_in",
|
||||
profile: {
|
||||
mode: "native",
|
||||
backend: input.persisted.driverKind === "opencode_server" ? "opencode_server" : "codex_app_server",
|
||||
backend: input.persisted.driverKind === "opencode_server"
|
||||
? "opencode_server"
|
||||
: input.persisted.driverKind === "claude_managed_agents_api"
|
||||
? "claude_managed_agents_api"
|
||||
: "codex_app_server",
|
||||
protocolVersion: 1,
|
||||
},
|
||||
authorityDecision: resolveNativeMigrationStatus({
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldRouteRecoveryToOriginalAgent } from "./service.js";
|
||||
|
||||
describe("native runner recovery routing", () => {
|
||||
it.each([
|
||||
"native_session_interrupted",
|
||||
"native_runner_process_exited",
|
||||
"provider_transport_failed",
|
||||
"provider_frame_too_large",
|
||||
] as const)("keeps %s with the original runner", (cause) => {
|
||||
expect(shouldRouteRecoveryToOriginalAgent(cause)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not change ordinary stranded-issue manager routing", () => {
|
||||
expect(shouldRouteRecoveryToOriginalAgent("stranded_assigned_issue")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -162,16 +162,34 @@ type LatestIssueRun = Pick<
|
||||
} | null;
|
||||
type SuccessfulLatestIssueRun = NonNullable<LatestIssueRun> & { status: "succeeded" };
|
||||
|
||||
type StrandedRecoveryCause =
|
||||
export type StrandedRecoveryCause =
|
||||
| "stranded_assigned_issue"
|
||||
| "process_lost"
|
||||
| "provider_quota"
|
||||
| "codex_output_inactivity_monitor"
|
||||
| "workspace_validation_failed"
|
||||
| "configuration_incomplete"
|
||||
| "native_session_interrupted"
|
||||
| "native_runner_process_exited"
|
||||
| "provider_transport_failed"
|
||||
| "provider_frame_too_large"
|
||||
| "execution_review_participant_recovery"
|
||||
| typeof SUCCESSFUL_RUN_MISSING_STATE_REASON;
|
||||
|
||||
const NATIVE_RUNNER_RECOVERY_CAUSES = new Set<StrandedRecoveryCause>([
|
||||
"native_session_interrupted",
|
||||
"native_runner_process_exited",
|
||||
"provider_transport_failed",
|
||||
"provider_frame_too_large",
|
||||
]);
|
||||
|
||||
export function shouldRouteRecoveryToOriginalAgent(cause: StrandedRecoveryCause): boolean {
|
||||
return cause === "process_lost"
|
||||
|| cause === SUCCESSFUL_RUN_MISSING_STATE_REASON
|
||||
|| cause === "codex_output_inactivity_monitor"
|
||||
|| NATIVE_RUNNER_RECOVERY_CAUSES.has(cause);
|
||||
}
|
||||
|
||||
type StrandedPreviousStatus = "todo" | "in_progress" | "in_review";
|
||||
|
||||
type SuccessfulRunHandoffRecoveryEvidence = {
|
||||
@@ -274,6 +292,9 @@ function resolveStrandedRecoveryCause(
|
||||
if (latestRun?.errorCode === "codex_output_inactivity_monitor") {
|
||||
return "codex_output_inactivity_monitor";
|
||||
}
|
||||
if (NATIVE_RUNNER_RECOVERY_CAUSES.has(latestRun?.errorCode as StrandedRecoveryCause)) {
|
||||
return latestRun!.errorCode as StrandedRecoveryCause;
|
||||
}
|
||||
return "stranded_assigned_issue";
|
||||
}
|
||||
|
||||
@@ -2578,9 +2599,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
}) {
|
||||
const originalAgentId = input.latestRun?.agentId ?? input.issue.assigneeAgentId;
|
||||
const returnOwnerAgentId = input.issue.assigneeAgentId ?? originalAgentId;
|
||||
const routeToOriginal = input.recoveryCause === "process_lost" ||
|
||||
input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON ||
|
||||
input.recoveryCause === "codex_output_inactivity_monitor";
|
||||
const routeToOriginal = shouldRouteRecoveryToOriginalAgent(input.recoveryCause);
|
||||
if (input.recoveryCause === "provider_quota") {
|
||||
const retryAgentId = await resolveInvokableRecoveryAgentId(input.issue, originalAgentId);
|
||||
if (!retryAgentId) {
|
||||
|
||||
@@ -92,4 +92,13 @@ describe("paperclip runner transcript projection", () => {
|
||||
expect(event("run.result.proposed", { summary: "Human-readable completion." }))
|
||||
.toEqual([{ kind: "assistant", ts: expect.any(String), text: "Human-readable completion.", channel: "final" }]);
|
||||
});
|
||||
|
||||
it("projects canonical provider events as structured activity instead of JSON prose", () => {
|
||||
const entries = paperclipRunnerUIAdapter.parseStdoutLine(JSON.stringify({
|
||||
type: "paperclip.prp.event",
|
||||
event: { eventType: "plan.updated", payload: { schema: "paperclip.plan.updated.v1", planId: "plan-1", revision: 1, complete: true, explanation: "Ship safely", steps: [{ stepId: "s1", body: "Validate", status: "completed" }] } },
|
||||
}), "2026-08-21T12:00:00.000Z");
|
||||
expect(entries).toEqual([expect.objectContaining({ kind: "provider_activity", family: "plan", eventType: "plan.updated", title: "Plan" })]);
|
||||
expect(entries).not.toEqual([expect.objectContaining({ kind: "assistant" })]);
|
||||
});
|
||||
});
|
||||
@@ -334,6 +334,29 @@ function parsePrpEvent(
|
||||
): TranscriptEntry[] {
|
||||
const eventType = text(event.eventType);
|
||||
const payload = record(event.payload);
|
||||
const family = eventType.startsWith("plan.") ? "plan"
|
||||
: eventType.startsWith("tool.execution.") ? "tool_execution"
|
||||
: eventType.startsWith("research.") ? "research"
|
||||
: eventType.startsWith("delegation.") ? "delegation"
|
||||
: eventType.startsWith("model.") ? "model_identity"
|
||||
: eventType.startsWith("context.") ? "context"
|
||||
: eventType.startsWith("artifact.") ? "artifact"
|
||||
: eventType.startsWith("review.") ? "review"
|
||||
: eventType.startsWith("hook.") ? "hook"
|
||||
: eventType.startsWith("memory.") ? "memory"
|
||||
: eventType.startsWith("safety.") ? "safety"
|
||||
: eventType.startsWith("terminal.") ? "terminal"
|
||||
: eventType.startsWith("wait.") ? "wait"
|
||||
: eventType.startsWith("provider.notice.") ? "provider_notice" : null;
|
||||
if (family !== null) {
|
||||
const rawStatus = text(payload.status);
|
||||
const status = rawStatus === "running" || rawStatus === "pending" || eventType.endsWith("started") || eventType.endsWith("progressed") ? "running"
|
||||
: rawStatus === "failed" || rawStatus === "denied" ? "failed"
|
||||
: rawStatus === "interrupted" || rawStatus === "cancelled" ? "interrupted"
|
||||
: eventType.endsWith("completed") || rawStatus === "completed" ? "completed" : "informational";
|
||||
const title = ({ plan: "Plan", tool_execution: "Tool execution", research: "Research", delegation: "Delegation", model_identity: "Model", context: "Context", artifact: "Artifact", review: "Review mode", hook: "Hook", memory: "Memory citation", safety: "Safety review", terminal: "Terminal input", wait: "Intentional wait", provider_notice: "Provider notice" } as const)[family];
|
||||
return [{ kind: "provider_activity", ts, family, eventType, status, title, summary: text(payload.summary, text(payload.name, text(payload.query, eventType))), payload }];
|
||||
}
|
||||
if (eventType === "session.started" || eventType === "session.resumed") {
|
||||
const context = record(payload.context);
|
||||
const model = text(context.model, text(record(payload.model).name, "Paperclip runner"));
|
||||
|
||||
@@ -1240,7 +1240,7 @@ function CopyablePreBlock({ children, className }: { children: string; className
|
||||
}
|
||||
|
||||
const TOOL_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
// Extend with specific tool icons as they become known
|
||||
paperclip_provider_activity: ClipboardList,
|
||||
};
|
||||
|
||||
function getToolIcon(toolName: string): React.ComponentType<{ className?: string }> {
|
||||
@@ -1261,6 +1261,9 @@ function IssueChatToolPart({
|
||||
isError?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (toolName === "paperclip_provider_activity") {
|
||||
return <IssueChatProviderActivity args={args} running={result === undefined} open={open} onToggle={() => setOpen((current) => !current)} />;
|
||||
}
|
||||
const rawArgsText = argsText ?? "";
|
||||
const parsedArgs = args ?? parseToolPayload(rawArgsText);
|
||||
const resultText =
|
||||
@@ -1349,6 +1352,51 @@ function IssueChatToolPart({
|
||||
);
|
||||
}
|
||||
|
||||
function IssueChatProviderActivity({
|
||||
args,
|
||||
running,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
args: unknown;
|
||||
running: boolean;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const value = typeof args === "object" && args !== null && !Array.isArray(args) ? args as Record<string, unknown> : {};
|
||||
const payload = typeof value.payload === "object" && value.payload !== null && !Array.isArray(value.payload) ? value.payload as Record<string, unknown> : {};
|
||||
const title = typeof value.title === "string" ? value.title : "Provider activity";
|
||||
const summary = typeof value.summary === "string" ? value.summary : "";
|
||||
const steps = Array.isArray(payload.steps) ? payload.steps.slice(0, 256) : [];
|
||||
const children = Array.isArray(payload.children) ? payload.children.slice(0, 64) : [];
|
||||
const sources = Array.isArray(payload.sources) ? payload.sources.slice(0, 64) : [];
|
||||
const output = typeof payload.output === "string" ? payload.output.slice(-(8 * 1024)) : "";
|
||||
const effectiveModel = typeof payload.effectiveModel === "string" ? payload.effectiveModel : null;
|
||||
const requestedModel = typeof payload.requestedModel === "string" ? payload.requestedModel : null;
|
||||
const hasDetails = steps.length > 0 || children.length > 0 || sources.length > 0 || output.length > 0 || effectiveModel !== null;
|
||||
return (
|
||||
<div className="flex gap-2 px-1" data-provider-family={String(value.family ?? "unknown")}>
|
||||
<div className="pt-1"><ClipboardList className="h-3.5 w-3.5 text-muted-foreground/50" /></div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<button type="button" className="flex w-full items-center gap-2 rounded-md py-0.5 text-left hover:bg-accent/5" onClick={onToggle} aria-expanded={open}>
|
||||
<span className="min-w-0 flex-1 truncate text-(length:--text-compact) text-muted-foreground/80">{title}{summary ? <span className="ml-1.5 text-muted-foreground/50">{summary}</span> : null}</span>
|
||||
{running ? <Loader2 className="h-3 w-3 animate-spin text-muted-foreground/50" /> : null}
|
||||
{hasDetails ? <ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground/40 transition-transform", open && "rotate-180")} /> : null}
|
||||
</button>
|
||||
{open && hasDetails ? (
|
||||
<div className="mt-1 space-y-2 rounded-md border border-border/50 bg-accent/15 p-2 text-xs">
|
||||
{steps.map((entry, index) => { const step = typeof entry === "object" && entry !== null ? entry as Record<string, unknown> : {}; return <div key={String(step.stepId ?? index)} className="flex gap-2"><span aria-hidden>{step.status === "completed" ? "✓" : step.status === "blocked" ? "!" : "○"}</span><span>{String(step.body ?? "")}</span></div>; })}
|
||||
{children.map((entry, index) => { const child = typeof entry === "object" && entry !== null ? entry as Record<string, unknown> : {}; return <div key={String(child.childId ?? index)}><span className="font-medium">{String(child.role ?? "Child agent")}</span> · {String(child.status ?? "unknown")}{child.summary ? ` — ${String(child.summary)}` : ""}</div>; })}
|
||||
{sources.map((entry, index) => { const source = typeof entry === "object" && entry !== null ? entry as Record<string, unknown> : {}; const href = typeof source.url === "string" && /^https?:\/\//.test(source.url) ? source.url : null; return <div key={String(source.sourceId ?? index)}>{href ? <a href={href} target="_blank" rel="noreferrer" className="underline">{String(source.title ?? href)}</a> : <span>{String(source.title ?? "Unavailable source")}</span>} <span className="text-muted-foreground">(provider-reported)</span></div>; })}
|
||||
{effectiveModel ? <div><span className="text-muted-foreground">Model</span> {requestedModel && requestedModel !== effectiveModel ? `${requestedModel} → ` : ""}{effectiveModel}</div> : null}
|
||||
{output ? <CopyablePreBlock className="max-h-56 overflow-auto whitespace-pre-wrap rounded bg-background/70 p-2 font-mono text-(length:--text-micro)">{output}</CopyablePreBlock> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getThreadMessageCopyText(message: ThreadMessage) {
|
||||
return message.content
|
||||
.filter((part): part is TextMessagePart => part.type === "text")
|
||||
|
||||
@@ -9,6 +9,12 @@ import { ThemeProvider } from "../../context/ThemeContext";
|
||||
import { RunTranscriptView, keyTranscriptBlocks, normalizeTranscript } from "./RunTranscriptView";
|
||||
|
||||
describe("RunTranscriptView", () => {
|
||||
it("renders provider activity semantically without dumping the payload", () => {
|
||||
const html = renderToStaticMarkup(<RunTranscriptView entries={[{ kind: "provider_activity", ts: "2026-08-21T12:00:00.000Z", family: "plan", eventType: "plan.updated", status: "completed", title: "Plan", summary: "Plan completed", payload: { steps: [{ stepId: "s1", body: "Validate schemas", status: "completed" }], hiddenSecret: "must-not-render" } }]} />);
|
||||
expect(html).toContain("Plan");
|
||||
expect(html).toContain("Plan completed");
|
||||
expect(html).not.toContain("must-not-render");
|
||||
});
|
||||
it("folds repeated tool_call status updates for the same toolUseId into one block", () => {
|
||||
const entries: TranscriptEntry[] = [
|
||||
{
|
||||
|
||||
@@ -143,6 +143,16 @@ type TranscriptBlock =
|
||||
text: string;
|
||||
detail?: string;
|
||||
}
|
||||
| {
|
||||
type: "provider_activity";
|
||||
ts: string;
|
||||
family: Extract<TranscriptEntry, { kind: "provider_activity" }>["family"];
|
||||
eventType: string;
|
||||
status: Extract<TranscriptEntry, { kind: "provider_activity" }>["status"];
|
||||
title: string;
|
||||
summary: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: "diff_group";
|
||||
ts: string;
|
||||
@@ -584,6 +594,11 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.kind === "provider_activity") {
|
||||
blocks.push({ type: "provider_activity", ts: entry.ts, family: entry.family, eventType: entry.eventType, status: entry.status, title: entry.title, summary: entry.summary, payload: entry.payload });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.kind === "tool_call") {
|
||||
const toolUseId = entry.toolUseId ?? extractToolUseId(entry.input);
|
||||
// Streaming runtimes (e.g. ACPX) re-emit the same tool call as its
|
||||
@@ -809,9 +824,34 @@ function transcriptBlockIdentity(block: TranscriptBlock): string {
|
||||
return `diff_group:${block.ts}`;
|
||||
case "event":
|
||||
return `event:${block.label}:${block.ts}`;
|
||||
case "provider_activity":
|
||||
return `provider_activity:${block.eventType}:${block.ts}`;
|
||||
}
|
||||
}
|
||||
|
||||
function TranscriptProviderActivity({ block, density }: { block: Extract<TranscriptBlock, { type: "provider_activity" }>; density: TranscriptDensity }) {
|
||||
const [open, setOpen] = useState(block.status === "running");
|
||||
const steps = Array.isArray(block.payload.steps) ? block.payload.steps.map(asRecord).filter((value): value is Record<string, unknown> => value !== null) : [];
|
||||
const children = Array.isArray(block.payload.children) ? block.payload.children.map(asRecord).filter((value): value is Record<string, unknown> => value !== null) : [];
|
||||
const sources = Array.isArray(block.payload.sources) ? block.payload.sources.map(asRecord).filter((value): value is Record<string, unknown> => value !== null) : [];
|
||||
const output = typeof block.payload.output === "string" ? block.payload.output.slice(-(8 * 1024)) : "";
|
||||
return <div className={cn("rounded-xl border p-2", block.status === "failed" ? "border-red-500/30 bg-red-500/[0.05]" : "border-border/70 bg-muted/20")} data-provider-family={block.family}>
|
||||
<button type="button" className="flex min-h-8 w-full items-center gap-2 text-left" onClick={() => setOpen((value) => !value)} aria-expanded={open}>
|
||||
<span aria-hidden="true">{block.status === "running" ? "⏳" : block.status === "failed" ? "✕" : block.status === "interrupted" ? "■" : "✓"}</span>
|
||||
<strong className={cn("font-mono", density === "compact" ? "text-(length:--text-micro)" : "text-xs")}>{block.title}</strong>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{block.summary}</span>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
{open ? <div className="mt-2 space-y-2 border-l border-border pl-5 text-xs">
|
||||
{steps.length > 0 ? <ol className="space-y-1">{steps.map((step, index) => <li key={String(step.stepId ?? index)}><span className="mr-2" aria-hidden="true">{step.status === "completed" ? "✓" : step.status === "blocked" ? "!" : "○"}</span>{String(step.body ?? "")}</li>)}</ol> : null}
|
||||
{children.length > 0 ? <ul className="space-y-1">{children.map((child, index) => <li key={String(child.childId ?? index)}><strong>{String(child.role ?? "Child agent")}</strong> · {String(child.status ?? "unknown")}<div className="text-muted-foreground">{String(child.summary ?? "")}</div></li>)}</ul> : null}
|
||||
{sources.length > 0 ? <ul className="space-y-1">{sources.map((source, index) => { const url = typeof source.url === "string" && /^https?:\/\//.test(source.url) ? source.url : null; return <li key={String(source.sourceId ?? index)}>{url ? <a className="underline" href={url} target="_blank" rel="noreferrer">{String(source.title ?? url)}</a> : String(source.title ?? "Unavailable source")} <span className="text-muted-foreground">Provider-reported</span></li>; })}</ul> : null}
|
||||
{output ? <pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded bg-background p-2 font-mono">{output}</pre> : null}
|
||||
{block.family === "model_identity" ? <div><span className="text-muted-foreground">Requested</span> {String(block.payload.requestedModel ?? "—")} · <span className="text-muted-foreground">Effective</span> {String(block.payload.effectiveModel ?? "—")}</div> : null}
|
||||
</div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign each block a stable, unique React key. Identity is position-independent
|
||||
* so earlier blocks collapsing (truncation) does not remount the survivors; a
|
||||
@@ -1665,6 +1705,9 @@ function findScrollParent(element: HTMLElement): HTMLElement | Window {
|
||||
}
|
||||
|
||||
function rawEntryContent(entry: TranscriptEntry): string {
|
||||
if (entry.kind === "provider_activity") {
|
||||
return `${entry.eventType}\n${entry.title}: ${entry.summary}`;
|
||||
}
|
||||
if (entry.kind === "tool_call") {
|
||||
return `${entry.name}\n${formatToolPayload(entry.input)}`;
|
||||
}
|
||||
@@ -1850,6 +1893,7 @@ export function RunTranscriptView({
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
)}
|
||||
{block.type === "provider_activity" && <TranscriptProviderActivity block={block} density={density} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -140,6 +140,31 @@ function createRequestConfirmation(
|
||||
}
|
||||
|
||||
describe("buildAssistantPartsFromTranscript", () => {
|
||||
it("keeps canonical provider activity structured for the task-thread widget", () => {
|
||||
const result = buildAssistantPartsFromTranscript([{
|
||||
kind: "provider_activity",
|
||||
ts: "2026-08-21T12:00:00.000Z",
|
||||
family: "plan",
|
||||
eventType: "plan.updated",
|
||||
status: "completed",
|
||||
title: "Plan",
|
||||
summary: "Plan synchronized",
|
||||
payload: { steps: [{ stepId: "s1", body: "Validate schemas", status: "completed" }] },
|
||||
}]);
|
||||
expect(result.parts).toHaveLength(1);
|
||||
expect(result.parts[0]).toMatchObject({
|
||||
type: "tool-call",
|
||||
toolName: "paperclip_provider_activity",
|
||||
args: {
|
||||
family: "plan",
|
||||
eventType: "plan.updated",
|
||||
payload: { steps: [{ body: "Validate schemas" }] },
|
||||
},
|
||||
result: { status: "completed" },
|
||||
});
|
||||
expect(JSON.stringify(result.parts)).not.toContain("reasoning");
|
||||
});
|
||||
|
||||
it("maps assistant text, reasoning, and tool activity while omitting noisy stderr", () => {
|
||||
const result = buildAssistantPartsFromTranscript([
|
||||
{ kind: "assistant", ts: "2026-04-06T12:00:00.000Z", text: "Working on it. " },
|
||||
|
||||
@@ -66,7 +66,8 @@ export interface IssueChatTranscriptEntry {
|
||||
| "stderr"
|
||||
| "system"
|
||||
| "stdout"
|
||||
| "diff";
|
||||
| "diff"
|
||||
| "provider_activity";
|
||||
ts: string;
|
||||
text?: string;
|
||||
delta?: boolean;
|
||||
@@ -85,6 +86,12 @@ export interface IssueChatTranscriptEntry {
|
||||
cachedTokens?: number;
|
||||
costUsd?: number;
|
||||
changeType?: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation";
|
||||
family?: "plan" | "tool_execution" | "research" | "delegation" | "model_identity" | "context" | "artifact" | "review" | "hook" | "memory" | "safety" | "terminal" | "wait" | "provider_notice";
|
||||
eventType?: string;
|
||||
status?: "running" | "completed" | "failed" | "interrupted" | "informational";
|
||||
title?: string;
|
||||
summary?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const ISSUE_CHAT_TRANSCRIPT_MAX_VISIBLE_ENTRIES = 30;
|
||||
@@ -683,6 +690,7 @@ function computeSegmentTimings(entries: readonly IssueChatTranscriptEntry[]): Se
|
||||
entry.kind === "tool_call" ||
|
||||
entry.kind === "tool_result" ||
|
||||
entry.kind === "diff" ||
|
||||
entry.kind === "provider_activity" ||
|
||||
(entry.kind === "result" && ((entry.isError && !!entry.errors?.length) || !!entry.text));
|
||||
const isText = entry.kind === "assistant" && !!entry.text;
|
||||
|
||||
@@ -859,6 +867,26 @@ export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTra
|
||||
orderedParts.push({ type: "text", text: entry.text });
|
||||
continue;
|
||||
}
|
||||
if (entry.kind === "provider_activity") {
|
||||
const toolCallId = `provider-activity-${index}`;
|
||||
const args = normalizeToolArgs({
|
||||
family: entry.family ?? "provider_notice",
|
||||
eventType: entry.eventType ?? "provider.notice.recorded",
|
||||
status: entry.status ?? "informational",
|
||||
title: entry.title ?? "Provider activity",
|
||||
summary: entry.summary ?? "",
|
||||
payload: entry.payload ?? {},
|
||||
});
|
||||
orderedParts.push({
|
||||
type: "tool-call",
|
||||
toolCallId,
|
||||
toolName: "paperclip_provider_activity",
|
||||
args,
|
||||
argsText: "",
|
||||
...(entry.status === "running" ? {} : { result: { status: entry.status ?? "informational" } }),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (entry.kind === "thinking" && entry.text) {
|
||||
orderedParts.push({ type: "reasoning", text: entry.text });
|
||||
continue;
|
||||
|
||||
Reference in new issue
Block a user