mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
feat(runner): add provider tracing and live activity
This commit is contained in:
1 parent
4fdcf5a141
commit
0f2b643c78
110 files changed
+69947
-10578
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# ACPX as a First-Class Paperclip Runner Provider
|
||||
|
||||
Status: implementation in progress on `PAP-16679-paperclip-runner` and the existing `paperclip-evals` working tree.
|
||||
Status: provider implementation and paid Pi/Claude qualification complete. Both full rosters have zero integration failures; remaining Claude misses are measured model behavior.
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -18,18 +18,63 @@ ACPX is pinned to `0.13.1`. Arbitrary commands, argv, agents, and model fallback
|
||||
|
||||
## Implementation checklist
|
||||
|
||||
- [ ] Add strict ACPX configuration and immutable resolved-profile state to TypeScript and Rust.
|
||||
- [ ] Build a provider-neutral `AcpxRuntimeHost` around `acpx/runtime`.
|
||||
- [ ] Add a framed Node sidecar and a distinct Rust `AcpxProvider`; never identify it as Codex.
|
||||
- [ ] Rebase the ACPX runtime patch for spawn PID, stderr, cwd, notifications, client operations, permission requests, and ephemeral spawn environment injection.
|
||||
- [ ] Isolate ACPX and agent state under the Paperclip instance runtime with `0700` directories and `0600` files.
|
||||
- [ ] Generalize the authenticated runner tool bridge and implement Pi extension and Claude MCP/permission profiles.
|
||||
- [ ] Normalize ACP updates to PRP text, tool, workspace, plan, model, usage, runtime-request, completion, and provider-notice events.
|
||||
- [ ] Support per-turn checkpoint/load and warm reuse; keep permission waits alive and block safely after provider loss.
|
||||
- [ ] Add adapter/environment configuration, Runner Lab selection/status, and the five `ax-acpx-*` scenarios.
|
||||
- [ ] Add deterministic Pi-like, Claude-like, Codex-like, malformed, and hanging fake ACP servers.
|
||||
- [ ] Add provider-aware `paperclip-evals` configs and immutable artifact/report metadata.
|
||||
- [ ] Run package, protocol, Rust, server, Lab, browser, live smoke, and paid qualification gates.
|
||||
- [x] Add strict ACPX configuration and immutable resolved-profile state to TypeScript and Rust.
|
||||
- [x] Build a provider-neutral `AcpxRuntimeHost` around `acpx/runtime`.
|
||||
- [x] Add a framed Node sidecar and a distinct Rust `AcpxProvider`; never identify it as Codex.
|
||||
- [x] Rebase the ACPX runtime patch for spawn PID, stderr, cwd, notifications, client operations, permission requests, and ephemeral spawn environment injection.
|
||||
- [x] Isolate ACPX and agent state under the Paperclip instance runtime with `0700` directories and `0600` files.
|
||||
- [x] Generalize the authenticated runner tool bridge and implement Pi extension and Claude MCP/permission profiles.
|
||||
- [x] Normalize ACP updates to PRP text, tool, workspace, plan, model, usage, runtime-request, completion, and provider-notice events.
|
||||
- [x] Support per-turn checkpoint/load and warm reuse; keep permission waits alive and block safely after provider loss.
|
||||
- [x] Add adapter/environment configuration, Runner Lab selection/status, and the five `ax-acpx-*` scenarios.
|
||||
- [x] Add deterministic host, sidecar, bridge, Pi-extension, malformed-frame, process-loss, and permission-wait coverage. Live qualified agents provide the final agent-specific conformance boundary.
|
||||
- [x] Add provider-aware `paperclip-evals` configs and immutable artifact/report metadata.
|
||||
- [x] Complete the live paid Pi and Claude roster gates with zero runner, ACPX, ACP-agent, extension/MCP, or bridge failures.
|
||||
|
||||
## Qualification ledger
|
||||
|
||||
### Pi
|
||||
|
||||
- ACPX `0.13.1`, `pi-acp@0.0.33`, and `@earendil-works/pi-coding-agent@0.84.2` resolve by exact package and executable digest.
|
||||
- The final full 35-case roster ran as `acpx-pi-qualification-03` against the same rebuilt package qualified for Claude, with zero retries and zero runner, ACPX, ACP-agent, extension, or bridge infrastructure failures.
|
||||
- Behavioral result: 35/35. The earlier `acpx-pi-qualification-02` model miss did not recur; both immutable rosters remain retained.
|
||||
- Provider-reported total cost was `$0.009493932` and the pricing-ledger estimate was `$0.005323750`. The most expensive case was `$0.000687344`, below the `$0.50` per-case hard cap.
|
||||
- Immutable attempts retain the exact requested/effective `openrouter/deepseek/deepseek-v4-flash-0731` model with `provider_verified` provenance, biller, token/cache/reasoning usage, ACPX/profile, session, separate process identities, and clean exit metadata without credentials.
|
||||
- `acpx-pi-metadata-02` independently verified clean exit, nonzero usage, and separate runnerd, sidecar, and ACP-agent process identities in the durable snapshot.
|
||||
- Runner Lab verified a cold first turn and warm second turn with the same runner PID, sidecar PID, ACPX record, and provider session, plus all five ACPX scenario deep links at desktop and 390 px.
|
||||
|
||||
### Claude
|
||||
|
||||
- Managed Claude authentication is available and the qualified ACP server completes initialization/session negotiation.
|
||||
- The model issue was an identity-layer mismatch, not a fallback: the pinned Claude ACP server accepts the canonical `claude-sonnet-5`, resolves it through SDK `ModelInfo.resolvedModel`, activates the provider selector `sonnet`, and then reports that selector in ACP status. Paperclip now reapplies the exact canonical model before the first prompt, requires the ACP configuration call to succeed, verifies the pinned reported selector, and only then normalizes `sonnet` back to `claude-sonnet-5` for durable identity, UI, billing, replay, and model history. Any other selector or reroute still fails closed.
|
||||
- Runner-owned `mcp__paperclip__*` calls are allowed through Claude ACP's local permission layer exactly once so the authenticated PRP bridge remains the authority. Built-in file/process calls still use the durable interactive approval path.
|
||||
- A pinned `@agentclientprotocol/claude-agent-acp@0.70.0` patch forwards bounded aggregate SDK token/cache usage through ACP `_meta.usage`; raw SDK/ACP payloads remain outside durable state.
|
||||
- The representative six-case smoke passed mutation, restraint, human confirmation, and workspace-control coverage with zero integration failures; `finish-task` and `block-task` were model-only misses.
|
||||
- The final full 35-case roster ran as `acpx-claude-qualification-03` with zero retries and zero runner, ACPX, Claude ACP, MCP, or bridge infrastructure failures. Behavioral result: 33/35 (94.3%). In both misses the model wrote prose claiming the requested finish/block action but made no semantic operation call, so Paperclip correctly left the task `in_progress`.
|
||||
- Provider-reported total cost was `$3.388296350` and the pricing-ledger estimate was `$1.968167100`. The most expensive case was `$0.189559100`, below the `$0.50` per-case hard cap.
|
||||
- Every final attempt records requested/effective `claude-sonnet-5` with `provider_verified` provenance, nonzero input/output/cache usage, provider cost, ACPX/profile/session identity, separate runnerd/sidecar/agent PIDs, and clean exit metadata.
|
||||
|
||||
### Qualification defect found and fixed
|
||||
|
||||
- A slow Claude workflow exposed that the eval control plane derived its connection-lease TTL from the provider turn timeout. Runnerd durably acknowledged 59 events and three semantic results, but a later shutdown command arrived after the lease expired.
|
||||
- The connection lease is now independent from the turn deadline and uses at least the production live-transport one-hour boundary. The provider timeout remains a separate classification/cancellation concern. A deterministic regression test locks this separation.
|
||||
- The affected `workflow-workspace-recovery` case passed immediately after the fix, and the subsequent complete Claude and Pi rosters both finished with zero lease or shutdown failures.
|
||||
|
||||
### Codex conformance control
|
||||
|
||||
- Managed Codex credentials are staged ephemerally into the isolated ACP home and removed on close; the source credential remains untouched and never enters ACPX records.
|
||||
- Exact model activation for `gpt-5.6-sol` succeeds.
|
||||
- The live context smoke completed and shut down cleanly, but the ACP agent ignored the offered semantic task-context tool and searched the filesystem. This is recorded as ACP-agent/model behavior, not runner infrastructure, and does not replace the native Codex provider.
|
||||
|
||||
### Verification snapshot
|
||||
|
||||
- Runner TypeScript tests: 637/637. Rust workspace tests: 78/78 library tests plus all binary/provider/process integration tests. Package build and TypeScript/Rust/browser typechecks pass.
|
||||
- SDK tests: 22/22. Scenario/Runner Lab component tests: 250/250. Server adapter/native-runtime tests: 81/81.
|
||||
- Browser: Scenario Explorer 43/43 and Runner Lab/issue-thread 174/174, including all five ACPX scenarios, desktop/mobile, keyboard, and accessibility coverage.
|
||||
- Replay goldens, TypeScript/Rust conformance parity, TypeScript/Rust replay parity, ACPX patch packaging (9/9), package boundaries, and the full offline clean-consumer pack/install dependency closure pass.
|
||||
- Paperclip Evals Python tests: 35/35. Both provider rosters validate 35/35 and their final paid attempts ran without automatic retries.
|
||||
- Final Pi/Claude artifact scans found no OpenRouter credential value, Anthropic/OpenRouter credential-shaped value, or Paperclip key assignment. No qualification runnerd, ACPX sidecar, Pi, or Claude ACP process remained afterward.
|
||||
- The tracked-import gate passes against a prospective index containing the new Claude environment module. The real working index remains untouched until the surrounding working-tree changes are committed.
|
||||
|
||||
## Security invariants
|
||||
|
||||
|
||||
+2
-1
@@ -85,7 +85,8 @@
|
||||
"patchedDependencies": {
|
||||
"embedded-postgres@18.1.0-beta.16": "patches/embedded-postgres@18.1.0-beta.16.patch",
|
||||
"acpx@0.13.1": "patches/acpx@0.13.1.patch",
|
||||
"pi-acp@0.0.33": "patches/pi-acp@0.0.33.patch"
|
||||
"pi-acp@0.0.33": "patches/pi-acp@0.0.33.patch",
|
||||
"@agentclientprotocol/claude-agent-acp@0.70.0": "patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch"
|
||||
},
|
||||
"overrides": {
|
||||
"rollup": ">=4.59.0",
|
||||
|
||||
@@ -566,7 +566,7 @@ export interface TranscriptRunArtifact {
|
||||
|
||||
export type TranscriptEntry =
|
||||
| { kind: "assistant"; ts: string; text: string; delta?: boolean; channel?: "progress" | "final" | "unknown" }
|
||||
| { kind: "thinking"; ts: string; text: string; delta?: boolean; channel?: "summary" | "detail" | "unknown" }
|
||||
| { kind: "thinking"; ts: string; text: string; delta?: boolean; lifecycle?: "started" | "completed"; channel?: "summary" | "detail" | "unknown" }
|
||||
| { kind: "user"; ts: string; text: string }
|
||||
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string; invocationId?: string; actionRequestId?: string }
|
||||
| { kind: "tool_result"; ts: string; toolUseId: string; toolName?: string; content: string; isError: boolean; delta?: boolean }
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE "provider_trace_records" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"run_id" uuid NOT NULL,
|
||||
"status" text DEFAULT 'capturing' NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"trace_ref" text NOT NULL,
|
||||
"frame_count" integer DEFAULT 0 NOT NULL,
|
||||
"byte_count" bigint DEFAULT 0 NOT NULL,
|
||||
"digest" text,
|
||||
"reason" text,
|
||||
"requested_by" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"deleted_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
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "provider_trace_records" ADD CONSTRAINT "provider_trace_records_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "provider_trace_records" ADD CONSTRAINT "provider_trace_records_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "provider_trace_records_run_unique" ON "provider_trace_records" USING btree ("run_id");--> statement-breakpoint
|
||||
CREATE INDEX "provider_trace_records_expiry_idx" ON "provider_trace_records" USING btree ("status","expires_at");--> statement-breakpoint
|
||||
CREATE INDEX "provider_trace_records_company_created_idx" ON "provider_trace_records" USING btree ("company_id","created_at");
|
||||
+41613
File diff suppressed because it is too large.
Load diff
+7
@@ -1590,6 +1590,13 @@
|
||||
"when": 1787359498156,
|
||||
"tag": "0228_late_landau",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 229,
|
||||
"version": "7",
|
||||
"when": 1787416987132,
|
||||
"tag": "0229_pink_longshot",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -111,6 +111,7 @@ export { documentAnnotationComments } from "./document_annotation_comments.js";
|
||||
export { documentAnnotationAnchorSnapshots } from "./document_annotation_anchor_snapshots.js";
|
||||
export { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
|
||||
export { providerTraceRecords } from "./provider_trace_records.js";
|
||||
export { completionContracts } from "./completion_contracts.js";
|
||||
export { nativeRunResults } from "./native_run_results.js";
|
||||
export { nativeRunFinalizations } from "./native_run_finalizations.js";
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
bigint,
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
|
||||
/** Metadata only. Exact provider bytes live in the restricted sidecar store. */
|
||||
export const providerTraceRecords = pgTable(
|
||||
"provider_trace_records",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id")
|
||||
.notNull()
|
||||
.references(() => companies.id),
|
||||
runId: uuid("run_id")
|
||||
.notNull()
|
||||
.references(() => heartbeatRuns.id, { onDelete: "cascade" }),
|
||||
status: text("status").notNull().default("capturing"),
|
||||
provider: text("provider").notNull(),
|
||||
traceRef: text("trace_ref").notNull(),
|
||||
frameCount: integer("frame_count").notNull().default(0),
|
||||
byteCount: bigint("byte_count", { mode: "number" }).notNull().default(0),
|
||||
digest: text("digest"),
|
||||
reason: text("reason"),
|
||||
requestedBy: text("requested_by").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
runUnique: uniqueIndex("provider_trace_records_run_unique").on(table.runId),
|
||||
expiryIdx: index("provider_trace_records_expiry_idx").on(
|
||||
table.status,
|
||||
table.expiresAt,
|
||||
),
|
||||
companyCreatedIdx: index("provider_trace_records_company_created_idx").on(
|
||||
table.companyId,
|
||||
table.createdAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -89,8 +89,15 @@ interface EmbeddedEvalReport {
|
||||
run: {
|
||||
model: string;
|
||||
provider: string;
|
||||
driver: string;
|
||||
providerVersion: string | null;
|
||||
runnerProvider: string;
|
||||
configuration: string;
|
||||
sessionId: string;
|
||||
providerSessionId: string | null;
|
||||
agentVersion: string | null;
|
||||
retainedSession: boolean | null;
|
||||
retainedSessionStatus: string | null;
|
||||
fixtureDigest: string;
|
||||
runnerPackageDigest: string;
|
||||
runnerdDigest: string;
|
||||
@@ -107,6 +114,7 @@ interface EmbeddedEvalReport {
|
||||
outputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
reasoningTokens: number;
|
||||
providerReportedCostNanodollars?: number;
|
||||
estimatedCostNanodollars: number;
|
||||
pricingVersion: string;
|
||||
} | null;
|
||||
@@ -411,7 +419,21 @@ export function App() {
|
||||
if (cancelled) return;
|
||||
rememberSession(response.sessionId, "cleanroom");
|
||||
setIdentity(response.identity ?? null);
|
||||
setActiveHarness(response.configuration ?? initialHarnessRef.current);
|
||||
const configuration = response.configuration ?? initialHarnessRef.current;
|
||||
setActiveHarness(configuration);
|
||||
// The server-returned immutable session configuration is authoritative
|
||||
// for both the active status and the options initially shown for the
|
||||
// next chat. Without this, a restored remote session could say Claude
|
||||
// or AgentCore in the header while leaving Codex-only fields in the
|
||||
// options form.
|
||||
setHarness(configuration);
|
||||
setManagedBudgetInput(String(
|
||||
configuration.provider === "aws_agentcore"
|
||||
? configuration.maxEstimatedSessionCostUsd ?? 1
|
||||
: configuration.provider === "claude_managed"
|
||||
? configuration.maxSessionListCostUsd ?? 1
|
||||
: 1,
|
||||
));
|
||||
setProviderRuntime(response.runtime);
|
||||
setHistoricSessionId(null);
|
||||
setSnapshot(response.view);
|
||||
@@ -758,11 +780,14 @@ export function App() {
|
||||
const adoptCleanRoom = useCallback((next: Awaited<ReturnType<typeof capabilityLiveClient.newCleanRoom>>) => {
|
||||
rememberSession(next.sessionId, "cleanroom");
|
||||
setIdentity(next.identity ?? null);
|
||||
setActiveHarness(next.configuration ?? harness);
|
||||
const configuration = next.configuration ?? harness;
|
||||
setActiveHarness(configuration);
|
||||
setHarness(configuration);
|
||||
setProviderRuntime(next.runtime);
|
||||
setHistoricSessionId(null);
|
||||
setSnapshot(next.view);
|
||||
setDevtools(null);
|
||||
setError(null);
|
||||
setActionError(null);
|
||||
setAnnouncement(
|
||||
`New clean-room chat started on ${next.view.issue.identifier}. The previous session was closed.`,
|
||||
@@ -870,6 +895,7 @@ export function App() {
|
||||
setSnapshot(live.snapshot);
|
||||
setIdentity(live.identity);
|
||||
setActiveHarness(live.configuration ?? null);
|
||||
if (live.configuration !== undefined) setHarness(live.configuration);
|
||||
setProviderRuntime(live.runtime);
|
||||
setDevtools(null);
|
||||
setHistoricSessionId(null);
|
||||
@@ -886,6 +912,7 @@ export function App() {
|
||||
setSnapshot(restored.view);
|
||||
setIdentity(restored.identity ?? archived.identity);
|
||||
setActiveHarness(restored.configuration ?? configuration);
|
||||
setHarness(restored.configuration ?? configuration);
|
||||
setProviderRuntime(restored.runtime);
|
||||
setDevtools(null);
|
||||
setHistoricSessionId(null);
|
||||
@@ -1020,10 +1047,141 @@ export function App() {
|
||||
: error}
|
||||
</p>
|
||||
{chat ? (
|
||||
<p className="pit-muted">
|
||||
The clean room only runs against the selected real provider through real runnerd, so it does not fall
|
||||
back to a fixture or a recording.
|
||||
</p>
|
||||
<>
|
||||
<p className="pit-muted">
|
||||
The clean room only runs against the selected real provider through real runnerd, so it does not fall
|
||||
back to a fixture or a recording.
|
||||
</p>
|
||||
<section className="pit-harness-picker" aria-label="Chat provider recovery options">
|
||||
<label>
|
||||
<span>Provider</span>
|
||||
<select
|
||||
data-testid="chat-provider"
|
||||
value={harness.provider}
|
||||
onChange={(event) => {
|
||||
const provider = event.target.value as CapabilityHarnessConfiguration["provider"];
|
||||
setHarness((current) => ({
|
||||
...defaultHarness(provider),
|
||||
lifecyclePolicy: current.lifecyclePolicy,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="codex">Codex</option>
|
||||
<option value="opencode">OpenCode</option>
|
||||
<option value="claude_managed">Claude Agent</option>
|
||||
<option value="aws_agentcore">AWS AgentCore</option>
|
||||
<option value="acpx">ACPX</option>
|
||||
</select>
|
||||
</label>
|
||||
{harness.provider === "acpx" ? (
|
||||
<label>
|
||||
<span>ACP agent</span>
|
||||
<select
|
||||
data-testid="chat-acpx-agent"
|
||||
value={harness.acpxAgent ?? "pi"}
|
||||
onChange={(event) => {
|
||||
const acpxAgent = event.target.value as NonNullable<CapabilityHarnessConfiguration["acpxAgent"]>;
|
||||
setHarness((current) => ({
|
||||
...current,
|
||||
acpxAgent,
|
||||
model: ACPX_AGENT_MODELS[acpxAgent],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="pi">Pi</option>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="codex">Codex (control)</option>
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<label>
|
||||
<span>Execution</span>
|
||||
<select
|
||||
data-testid="chat-lifecycle-mode"
|
||||
value={harness.lifecyclePolicy.mode}
|
||||
onChange={(event) => setHarness((current) => ({
|
||||
...current,
|
||||
lifecyclePolicy: event.target.value === "per_turn"
|
||||
? { mode: "per_turn", idleTimeoutMs: null }
|
||||
: { mode: "warm", idleTimeoutMs: 300_000 },
|
||||
}))}
|
||||
>
|
||||
<option value="warm">Warm session</option>
|
||||
<option value="per_turn">Turn by turn</option>
|
||||
</select>
|
||||
</label>
|
||||
{harness.lifecyclePolicy.mode === "warm" ? (
|
||||
<label>
|
||||
<span>Idle timeout (seconds)</span>
|
||||
<input
|
||||
data-testid="chat-idle-timeout"
|
||||
type="number"
|
||||
min={1}
|
||||
value={Math.round((harness.lifecyclePolicy.idleTimeoutMs ?? 300_000) / 1_000)}
|
||||
onChange={(event) => setHarness((current) => ({
|
||||
...current,
|
||||
lifecyclePolicy: {
|
||||
mode: "warm",
|
||||
idleTimeoutMs: Math.max(1, Number(event.target.value)) * 1_000,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{harness.provider === "aws_agentcore" ? (
|
||||
<>
|
||||
<label>
|
||||
<span>AgentCore profile</span>
|
||||
<input
|
||||
data-testid="chat-agentcore-profile"
|
||||
value={harness.agentCoreProfileId ?? ""}
|
||||
placeholder="qualified profile ID"
|
||||
onChange={(event) => setHarness((current) => ({
|
||||
...current,
|
||||
agentCoreProfileId: event.target.value,
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Estimated ceiling (USD)</span>
|
||||
<input
|
||||
data-testid="chat-agentcore-spend-cap"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value={harness.maxEstimatedSessionCostUsd ?? 1}
|
||||
onChange={(event) => setHarness((current) => ({
|
||||
...current,
|
||||
maxEstimatedSessionCostUsd: Number(event.target.value),
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
<label className="pit-harness-model">
|
||||
<span>Model</span>
|
||||
<input
|
||||
data-testid="chat-model"
|
||||
list={`chat-recovery-models-${harness.provider}`}
|
||||
value={harness.model ?? ""}
|
||||
placeholder={harness.provider === "opencode" || (harness.provider === "acpx" && harness.acpxAgent === "pi") ? "provider/model" : "model name"}
|
||||
onChange={(event) => setHarness((current) => ({ ...current, model: event.target.value }))}
|
||||
/>
|
||||
<datalist id={`chat-recovery-models-${harness.provider}`}>
|
||||
{MODEL_PRESETS[harness.provider].map((model) => <option key={model} value={model} />)}
|
||||
</datalist>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="pit-button"
|
||||
data-variant="primary"
|
||||
data-testid="chat-apply-harness"
|
||||
onClick={newChat}
|
||||
>
|
||||
Start new chat
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1319,12 +1477,12 @@ export function App() {
|
||||
</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}
|
||||
{harness.provider === "aws_agentcore" ? (
|
||||
{activeHarness?.provider === "aws_agentcore" ? (
|
||||
<p className="pit-harness-notice" role="note" data-testid="chat-agentcore-retention-notice">
|
||||
AgentCore Memory retains this chat for 90 days. Cost is a Paperclip estimate; model tokens, Runtime active time, and Memory are billed by AWS. Paperclip tools execute only in runnerd.
|
||||
</p>
|
||||
|
||||
@@ -4,7 +4,17 @@ import type { CapabilityDevtoolsSnapshot } from "../../../src/devtools";
|
||||
import { Icon, type IconName } from "./Icons";
|
||||
import { EvalAssertions, type EvalAssertion } from "./ThreadItems";
|
||||
|
||||
export type CapabilityDevtoolsTab = "eval" | "evidence" | "timeline" | "state" | "diff" | "documents" | "protocol" | "runtime" | "authority";
|
||||
export type CapabilityDevtoolsTab =
|
||||
| "eval"
|
||||
| "evidence"
|
||||
| "timeline"
|
||||
| "state"
|
||||
| "diff"
|
||||
| "documents"
|
||||
| "protocol"
|
||||
| "trace"
|
||||
| "runtime"
|
||||
| "authority";
|
||||
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
||||
|
||||
export interface EvalInspectorReport {
|
||||
@@ -13,7 +23,39 @@ export interface EvalInspectorReport {
|
||||
disposition: string;
|
||||
passed: boolean;
|
||||
checks: EvalAssertion[];
|
||||
run: { model: string; provider: string; configuration: string; sessionId: string; fixtureDigest: string; runnerPackageDigest: string; runnerdDigest: string; runnerBuild: string; startedAt: string; finishedAt: string; durationMs: number; initialRevision: number; finalRevision: number; usage: { agentTurns: number; providerRequests: number | null; inputTokens: number; outputTokens: number; cachedInputTokens: number; reasoningTokens: number; estimatedCostNanodollars: number; pricingVersion: string } | null };
|
||||
run: {
|
||||
model: string;
|
||||
provider: string;
|
||||
driver: string;
|
||||
providerVersion: string | null;
|
||||
runnerProvider: string;
|
||||
configuration: string;
|
||||
sessionId: string;
|
||||
providerSessionId: string | null;
|
||||
agentVersion: string | null;
|
||||
retainedSession: boolean | null;
|
||||
retainedSessionStatus: string | null;
|
||||
fixtureDigest: string;
|
||||
runnerPackageDigest: string;
|
||||
runnerdDigest: string;
|
||||
runnerBuild: string;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
durationMs: number;
|
||||
initialRevision: number;
|
||||
finalRevision: number;
|
||||
usage: {
|
||||
agentTurns: number;
|
||||
providerRequests: number | null;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
reasoningTokens: number;
|
||||
providerReportedCostNanodollars?: number;
|
||||
estimatedCostNanodollars: number;
|
||||
pricingVersion: string;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
function JsonTree({ value, name = "root" }: { value: Json; name?: string }) {
|
||||
@@ -25,14 +67,21 @@ function JsonTree({ value, name = "root" }: { value: Json; name?: string }) {
|
||||
: Object.entries(value);
|
||||
return (
|
||||
<details className="pit-json-node" open={name === "root"}>
|
||||
<summary>{name} <span className="pit-muted">{Array.isArray(value) ? `[${entries.length}]` : `{${entries.length}}`}</span></summary>
|
||||
<summary>
|
||||
{name}{" "}
|
||||
<span className="pit-muted">
|
||||
{Array.isArray(value) ? `[${entries.length}]` : `{${entries.length}}`}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="pit-json-children">
|
||||
{entries.map(([key, entry]) => (
|
||||
<div className="pit-json-row" key={key}>
|
||||
<span className="pit-json-key">{key}</span>
|
||||
{entry !== null && typeof entry === "object"
|
||||
? <JsonTree value={entry as Json} name={key} />
|
||||
: <JsonTree value={entry as Json} />}
|
||||
{entry !== null && typeof entry === "object" ? (
|
||||
<JsonTree value={entry as Json} name={key} />
|
||||
) : (
|
||||
<JsonTree value={entry as Json} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -40,23 +89,38 @@ function JsonTree({ value, name = "root" }: { value: Json; name?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface DiffRow { path: string; before: Json | undefined; after: Json | undefined }
|
||||
interface DiffRow {
|
||||
path: string;
|
||||
before: Json | undefined;
|
||||
after: Json | undefined;
|
||||
}
|
||||
|
||||
function diff(before: Json | undefined, after: Json | undefined, path = "state"): DiffRow[] {
|
||||
function diff(
|
||||
before: Json | undefined,
|
||||
after: Json | undefined,
|
||||
path = "state",
|
||||
): DiffRow[] {
|
||||
if (JSON.stringify(before) === JSON.stringify(after)) return [];
|
||||
if (
|
||||
before !== null && after !== null &&
|
||||
typeof before === "object" && typeof after === "object" &&
|
||||
!Array.isArray(before) && !Array.isArray(after)
|
||||
before !== null &&
|
||||
after !== null &&
|
||||
typeof before === "object" &&
|
||||
typeof after === "object" &&
|
||||
!Array.isArray(before) &&
|
||||
!Array.isArray(after)
|
||||
) {
|
||||
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
||||
return [...keys].flatMap((key) => diff(before[key], after[key], `${path}.${key}`));
|
||||
return [...keys].flatMap((key) =>
|
||||
diff(before[key], after[key], `${path}.${key}`),
|
||||
);
|
||||
}
|
||||
return [{ path, before, after }];
|
||||
}
|
||||
|
||||
function download(snapshot: CapabilityDevtoolsSnapshot) {
|
||||
const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json" });
|
||||
const blob = new Blob([JSON.stringify(snapshot, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
@@ -65,61 +129,126 @@ function download(snapshot: CapabilityDevtoolsSnapshot) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const TABS: ReadonlyArray<{ id: CapabilityDevtoolsTab; icon: IconName; label: string }> = [
|
||||
const TABS: ReadonlyArray<{
|
||||
id: CapabilityDevtoolsTab;
|
||||
icon: IconName;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: "evidence", icon: "evidence", label: "Evidence" },
|
||||
{ id: "timeline", icon: "timeline", label: "Timeline" },
|
||||
{ id: "state", icon: "state", label: "State" },
|
||||
{ id: "diff", icon: "diff", label: "Diff" },
|
||||
{ id: "documents", icon: "documents", label: "Documents" },
|
||||
{ id: "protocol", icon: "protocol", label: "Protocol" },
|
||||
{ id: "trace", icon: "protocol", label: "Provider trace" },
|
||||
{ id: "runtime", icon: "terminal", label: "Runtime" },
|
||||
{ id: "authority", icon: "authority", label: "Authority" },
|
||||
];
|
||||
|
||||
function documentsOf(state: Json): Array<{ id: string; key: string; title: string; revisions: Array<{ id: string; revision: number; body: string; changeSummary: string | null; createdAt: string }> }> {
|
||||
if (state === null || typeof state !== "object" || Array.isArray(state)) return [];
|
||||
function documentsOf(state: Json): Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
title: string;
|
||||
revisions: Array<{
|
||||
id: string;
|
||||
revision: number;
|
||||
body: string;
|
||||
changeSummary: string | null;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}> {
|
||||
if (state === null || typeof state !== "object" || Array.isArray(state))
|
||||
return [];
|
||||
const documents = state.documents;
|
||||
if (!Array.isArray(documents)) return [];
|
||||
return documents.flatMap((value) => {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return [];
|
||||
const revisions = Array.isArray(value.revisions) ? value.revisions.flatMap((candidate) => {
|
||||
if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) return [];
|
||||
return [{
|
||||
id: String(candidate.id ?? ""),
|
||||
revision: Number(candidate.revision ?? 0),
|
||||
body: String(candidate.body ?? ""),
|
||||
changeSummary: candidate.changeSummary === null ? null : String(candidate.changeSummary ?? ""),
|
||||
createdAt: String(candidate.createdAt ?? ""),
|
||||
}];
|
||||
}) : [];
|
||||
return [{ id: String(value.id ?? ""), key: String(value.key ?? ""), title: String(value.title ?? "Untitled"), revisions }];
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value))
|
||||
return [];
|
||||
const revisions = Array.isArray(value.revisions)
|
||||
? value.revisions.flatMap((candidate) => {
|
||||
if (
|
||||
candidate === null ||
|
||||
typeof candidate !== "object" ||
|
||||
Array.isArray(candidate)
|
||||
)
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
id: String(candidate.id ?? ""),
|
||||
revision: Number(candidate.revision ?? 0),
|
||||
body: String(candidate.body ?? ""),
|
||||
changeSummary:
|
||||
candidate.changeSummary === null
|
||||
? null
|
||||
: String(candidate.changeSummary ?? ""),
|
||||
createdAt: String(candidate.createdAt ?? ""),
|
||||
},
|
||||
];
|
||||
})
|
||||
: [];
|
||||
return [
|
||||
{
|
||||
id: String(value.id ?? ""),
|
||||
key: String(value.key ?? ""),
|
||||
title: String(value.title ?? "Untitled"),
|
||||
revisions,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function DevtoolsInspector({ snapshot, onFork, tab, onTabChange, evalReport }: { snapshot: CapabilityDevtoolsSnapshot; onFork: (revision: number) => void; tab: CapabilityDevtoolsTab; onTabChange: (tab: CapabilityDevtoolsTab) => void; evalReport?: EvalInspectorReport | null }) {
|
||||
export function DevtoolsInspector({
|
||||
snapshot,
|
||||
onFork,
|
||||
tab,
|
||||
onTabChange,
|
||||
evalReport,
|
||||
}: {
|
||||
snapshot: CapabilityDevtoolsSnapshot;
|
||||
onFork: (revision: number) => void;
|
||||
tab: CapabilityDevtoolsTab;
|
||||
onTabChange: (tab: CapabilityDevtoolsTab) => void;
|
||||
evalReport?: EvalInspectorReport | null;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const latest = snapshot.revisions.at(-1)!;
|
||||
const [revision, setRevision] = useState(latest.revision);
|
||||
const [following, setFollowing] = useState(true);
|
||||
const [compareRevision, setCompareRevision] = useState(snapshot.revisions[0]!.revision);
|
||||
const selected = snapshot.revisions.find((entry) => entry.revision === revision) ?? latest;
|
||||
const compared = snapshot.revisions.find((entry) => entry.revision === compareRevision) ?? snapshot.revisions[0]!;
|
||||
const [compareRevision, setCompareRevision] = useState(
|
||||
snapshot.revisions[0]!.revision,
|
||||
);
|
||||
const selected =
|
||||
snapshot.revisions.find((entry) => entry.revision === revision) ?? latest;
|
||||
const compared =
|
||||
snapshot.revisions.find((entry) => entry.revision === compareRevision) ??
|
||||
snapshot.revisions[0]!;
|
||||
const rows = useMemo(
|
||||
() => diff(compared.state as Json, selected.state as Json),
|
||||
[compared.state, selected.state],
|
||||
);
|
||||
const normalized = query.trim().toLowerCase();
|
||||
const revisions = snapshot.revisions.filter((entry) =>
|
||||
normalized.length === 0 || JSON.stringify(entry).toLowerCase().includes(normalized),
|
||||
const revisions = snapshot.revisions.filter(
|
||||
(entry) =>
|
||||
normalized.length === 0 ||
|
||||
JSON.stringify(entry).toLowerCase().includes(normalized),
|
||||
);
|
||||
const protocol = snapshot.protocol.filter((entry) =>
|
||||
normalized.length === 0 || JSON.stringify(entry).toLowerCase().includes(normalized),
|
||||
const protocol = snapshot.protocol.filter(
|
||||
(entry) =>
|
||||
normalized.length === 0 ||
|
||||
JSON.stringify(entry).toLowerCase().includes(normalized),
|
||||
);
|
||||
const documents = useMemo(
|
||||
() => documentsOf(selected.state as Json),
|
||||
[selected.state],
|
||||
);
|
||||
const documents = useMemo(() => documentsOf(selected.state as Json), [selected.state]);
|
||||
const [documentId, setDocumentId] = useState("");
|
||||
const document = documents.find((entry) => entry.id === documentId) ?? documents[0] ?? null;
|
||||
const document =
|
||||
documents.find((entry) => entry.id === documentId) ?? documents[0] ?? null;
|
||||
const [documentRevision, setDocumentRevision] = useState(0);
|
||||
const visibleDocumentRevision = document?.revisions.find((entry) => entry.revision === documentRevision) ?? document?.revisions.at(-1) ?? null;
|
||||
const visibleDocumentRevision =
|
||||
document?.revisions.find((entry) => entry.revision === documentRevision) ??
|
||||
document?.revisions.at(-1) ??
|
||||
null;
|
||||
useEffect(() => {
|
||||
if (following) setRevision(latest.revision);
|
||||
}, [following, latest.revision]);
|
||||
@@ -127,52 +256,233 @@ export function DevtoolsInspector({ snapshot, onFork, tab, onTabChange, evalRepo
|
||||
return (
|
||||
<section className="pit-devtools" data-testid="devtools-inspector">
|
||||
<div className="pit-devtools-toolbar">
|
||||
<span className="pit-devtools-brand"><Icon name="activity" /> State monitor</span>
|
||||
<input id="devtools-search" className="pit-input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="operation, entity, turn…" />
|
||||
<button className="pit-icon-button" type="button" aria-pressed={!following} onClick={() => setFollowing((value) => !value)} title={following ? "Pause live updates" : "Follow latest revision"} aria-label={following ? "Pause live updates" : "Follow latest revision"}><Icon name={following ? "pause" : "play"} /></button>
|
||||
<button className="pit-icon-button" type="button" onClick={() => download(snapshot)} title="Export redacted snapshot" aria-label="Export redacted snapshot"><Icon name="download" /></button>
|
||||
<button className="pit-button" type="button" onClick={() => onFork(revision)}><Icon name="branch" /> Fork r{revision}</button>
|
||||
<span className="pit-devtools-brand">
|
||||
<Icon name="activity" /> State monitor
|
||||
</span>
|
||||
<input
|
||||
id="devtools-search"
|
||||
className="pit-input"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="operation, entity, turn…"
|
||||
/>
|
||||
<button
|
||||
className="pit-icon-button"
|
||||
type="button"
|
||||
aria-pressed={!following}
|
||||
onClick={() => setFollowing((value) => !value)}
|
||||
title={following ? "Pause live updates" : "Follow latest revision"}
|
||||
aria-label={
|
||||
following ? "Pause live updates" : "Follow latest revision"
|
||||
}
|
||||
>
|
||||
<Icon name={following ? "pause" : "play"} />
|
||||
</button>
|
||||
<button
|
||||
className="pit-icon-button"
|
||||
type="button"
|
||||
onClick={() => download(snapshot)}
|
||||
title="Export redacted snapshot"
|
||||
aria-label="Export redacted snapshot"
|
||||
>
|
||||
<Icon name="download" />
|
||||
</button>
|
||||
<button
|
||||
className="pit-button"
|
||||
type="button"
|
||||
onClick={() => onFork(revision)}
|
||||
>
|
||||
<Icon name="branch" /> Fork r{revision}
|
||||
</button>
|
||||
</div>
|
||||
<div className="pit-devtools-tabs" role="tablist" aria-label="Developer tools">
|
||||
{([...(evalReport ? [{ id: "eval" as const, icon: "evidence" as const, label: "Eval" }] : []), ...TABS]).map(({ id, icon, label }) => (
|
||||
<button key={id} type="button" role="tab" aria-selected={tab === id} className="pit-tab" onClick={() => onTabChange(id)}><span className="pit-tab-glyph"><Icon name={icon} /></span><span>{label}</span></button>
|
||||
<div
|
||||
className="pit-devtools-tabs"
|
||||
role="tablist"
|
||||
aria-label="Developer tools"
|
||||
>
|
||||
{[
|
||||
...(evalReport
|
||||
? [
|
||||
{
|
||||
id: "eval" as const,
|
||||
icon: "evidence" as const,
|
||||
label: "Eval",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...TABS,
|
||||
].map(({ id, icon, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === id}
|
||||
className="pit-tab"
|
||||
onClick={() => onTabChange(id)}
|
||||
>
|
||||
<span className="pit-tab-glyph">
|
||||
<Icon name={icon} />
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === "eval" && evalReport ? (
|
||||
<div className="pit-devtools-pane pit-eval-inspector">
|
||||
<div className="pit-eval-summary-head"><a href="../../index.html">← Eval suite</a><strong>{evalReport.passed ? "PASS" : evalReport.disposition.replaceAll("_", " ").toUpperCase()}</strong><code>{evalReport.attemptId}</code></div>
|
||||
<div className="pit-eval-summary-head">
|
||||
<a href="../../index.html">← Eval suite</a>
|
||||
<strong>
|
||||
{evalReport.passed
|
||||
? "PASS"
|
||||
: evalReport.disposition.replaceAll("_", " ").toUpperCase()}
|
||||
</strong>
|
||||
<code>{evalReport.attemptId}</code>
|
||||
</div>
|
||||
<dl className="pit-eval-run-facts">
|
||||
<div><dt>Model</dt><dd>{evalReport.run.model.startsWith(`${evalReport.run.provider}/`) ? evalReport.run.model : `${evalReport.run.provider}/${evalReport.run.model}`}</dd></div>
|
||||
<div><dt>Configuration</dt><dd>{evalReport.run.configuration}</dd></div>
|
||||
<div><dt>Session</dt><dd>{evalReport.run.sessionId}</dd></div>
|
||||
<div><dt>Duration</dt><dd>{evalReport.run.durationMs} ms</dd></div>
|
||||
<div><dt>Fixture</dt><dd>{evalReport.run.fixtureDigest}</dd></div>
|
||||
<div><dt>State</dt><dd>r{evalReport.run.initialRevision} → r{evalReport.run.finalRevision}</dd></div>
|
||||
<div><dt>Tokens</dt><dd>{evalReport.run.usage === null ? "unknown" : `${evalReport.run.usage.inputTokens} in · ${evalReport.run.usage.outputTokens} out · ${evalReport.run.usage.cachedInputTokens} cached`}</dd></div>
|
||||
<div><dt>Agent turns</dt><dd>{evalReport.run.usage?.agentTurns ?? "unknown"}</dd></div>
|
||||
<div><dt>Provider requests</dt><dd>{evalReport.run.usage?.providerRequests ?? "unavailable"}</dd></div>
|
||||
<div><dt>Estimated cost</dt><dd>{evalReport.run.usage === null ? "unknown" : `$${(evalReport.run.usage.estimatedCostNanodollars / 1_000_000_000).toFixed(6)} · ${evalReport.run.usage.pricingVersion}`}</dd></div>
|
||||
<div><dt>Runner</dt><dd>{evalReport.run.runnerPackageDigest}</dd></div>
|
||||
<div><dt>Runner build</dt><dd>{evalReport.run.runnerBuild}</dd></div>
|
||||
<div><dt>runnerd</dt><dd>{evalReport.run.runnerdDigest}</dd></div>
|
||||
<div>
|
||||
<dt>Model</dt>
|
||||
<dd>
|
||||
{evalReport.run.model.startsWith(`${evalReport.run.provider}/`)
|
||||
? evalReport.run.model
|
||||
: `${evalReport.run.provider}/${evalReport.run.model}`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Configuration</dt>
|
||||
<dd>{evalReport.run.configuration}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Session</dt>
|
||||
<dd>{evalReport.run.sessionId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Provider session</dt>
|
||||
<dd>{evalReport.run.providerSessionId ?? "unavailable"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Driver</dt>
|
||||
<dd>
|
||||
{evalReport.run.driver}
|
||||
{evalReport.run.providerVersion
|
||||
? ` · ${evalReport.run.providerVersion}`
|
||||
: ""}
|
||||
</dd>
|
||||
</div>
|
||||
{evalReport.run.agentVersion ? (
|
||||
<div>
|
||||
<dt>Agent version</dt>
|
||||
<dd>{evalReport.run.agentVersion}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<dt>Retained session</dt>
|
||||
<dd>
|
||||
{evalReport.run.retainedSession === true
|
||||
? (evalReport.run.retainedSessionStatus ?? "retained")
|
||||
: "not applicable"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Duration</dt>
|
||||
<dd>{evalReport.run.durationMs} ms</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fixture</dt>
|
||||
<dd>{evalReport.run.fixtureDigest}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>State</dt>
|
||||
<dd>
|
||||
r{evalReport.run.initialRevision} → r
|
||||
{evalReport.run.finalRevision}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Tokens</dt>
|
||||
<dd>
|
||||
{evalReport.run.usage === null
|
||||
? "unknown"
|
||||
: `${evalReport.run.usage.inputTokens} in · ${evalReport.run.usage.outputTokens} out · ${evalReport.run.usage.cachedInputTokens} cached`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Agent turns</dt>
|
||||
<dd>{evalReport.run.usage?.agentTurns ?? "unknown"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Provider requests</dt>
|
||||
<dd>{evalReport.run.usage?.providerRequests ?? "unavailable"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Estimated cost</dt>
|
||||
<dd>
|
||||
{evalReport.run.usage === null
|
||||
? "unknown"
|
||||
: `$${(evalReport.run.usage.estimatedCostNanodollars / 1_000_000_000).toFixed(6)} · ${evalReport.run.usage.pricingVersion}`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Provider list cost</dt>
|
||||
<dd>
|
||||
{typeof evalReport.run.usage
|
||||
?.providerReportedCostNanodollars !== "number"
|
||||
? "unknown"
|
||||
: `$${(evalReport.run.usage.providerReportedCostNanodollars / 1_000_000_000).toFixed(6)}`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Runner</dt>
|
||||
<dd>{evalReport.run.runnerPackageDigest}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Runner build</dt>
|
||||
<dd>{evalReport.run.runnerBuild}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>runnerd</dt>
|
||||
<dd>{evalReport.run.runnerdDigest}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<h3>Assertions</h3><EvalAssertions assertions={evalReport.checks} />
|
||||
<h3>Assertions</h3>
|
||||
<EvalAssertions assertions={evalReport.checks} />
|
||||
</div>
|
||||
) : null}
|
||||
{tab === "timeline" ? (
|
||||
<div className="pit-devtools-list">
|
||||
{revisions.map((entry) => (
|
||||
<button type="button" className="pit-devtools-event" data-selected={entry.revision === revision} key={entry.revision} onClick={() => { setFollowing(false); setRevision(entry.revision); }}>
|
||||
<strong>r{entry.revision}</strong><span>{entry.operationId}</span><span className="pit-muted">{entry.turnId ?? "session"}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="pit-devtools-event"
|
||||
data-selected={entry.revision === revision}
|
||||
key={entry.revision}
|
||||
onClick={() => {
|
||||
setFollowing(false);
|
||||
setRevision(entry.revision);
|
||||
}}
|
||||
>
|
||||
<strong>r{entry.revision}</strong>
|
||||
<span>{entry.operationId}</span>
|
||||
<span className="pit-muted">{entry.turnId ?? "session"}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{tab === "state" ? (
|
||||
<div className="pit-devtools-pane">
|
||||
<label className="pit-card-meta" htmlFor="devtools-revision">Revision</label>
|
||||
<select id="devtools-revision" className="pit-select" value={revision} onChange={(event) => setRevision(Number(event.target.value))}>
|
||||
{snapshot.revisions.map((entry) => <option key={entry.revision} value={entry.revision}>r{entry.revision} · {entry.operationId}</option>)}
|
||||
<label className="pit-card-meta" htmlFor="devtools-revision">
|
||||
Revision
|
||||
</label>
|
||||
<select
|
||||
id="devtools-revision"
|
||||
className="pit-select"
|
||||
value={revision}
|
||||
onChange={(event) => setRevision(Number(event.target.value))}
|
||||
>
|
||||
{snapshot.revisions.map((entry) => (
|
||||
<option key={entry.revision} value={entry.revision}>
|
||||
r{entry.revision} · {entry.operationId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<JsonTree value={selected.state as Json} />
|
||||
</div>
|
||||
@@ -180,36 +490,157 @@ export function DevtoolsInspector({ snapshot, onFork, tab, onTabChange, evalRepo
|
||||
{tab === "diff" ? (
|
||||
<div className="pit-devtools-pane">
|
||||
<div className="pit-button-row">
|
||||
<select className="pit-select" aria-label="Compare from revision" value={compareRevision} onChange={(event) => setCompareRevision(Number(event.target.value))}>
|
||||
{snapshot.revisions.map((entry) => <option key={entry.revision} value={entry.revision}>from r{entry.revision}</option>)}
|
||||
<select
|
||||
className="pit-select"
|
||||
aria-label="Compare from revision"
|
||||
value={compareRevision}
|
||||
onChange={(event) =>
|
||||
setCompareRevision(Number(event.target.value))
|
||||
}
|
||||
>
|
||||
{snapshot.revisions.map((entry) => (
|
||||
<option key={entry.revision} value={entry.revision}>
|
||||
from r{entry.revision}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="pit-select" aria-label="Compare to revision" value={revision} onChange={(event) => setRevision(Number(event.target.value))}>
|
||||
{snapshot.revisions.map((entry) => <option key={entry.revision} value={entry.revision}>to r{entry.revision}</option>)}
|
||||
<select
|
||||
className="pit-select"
|
||||
aria-label="Compare to revision"
|
||||
value={revision}
|
||||
onChange={(event) => setRevision(Number(event.target.value))}
|
||||
>
|
||||
{snapshot.revisions.map((entry) => (
|
||||
<option key={entry.revision} value={entry.revision}>
|
||||
to r{entry.revision}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{rows.map((row) => <div className="pit-diff-row" key={row.path}><strong>{row.path}</strong><del>{JSON.stringify(row.before)}</del><ins>{JSON.stringify(row.after)}</ins></div>)}
|
||||
{rows.length === 0 ? <p className="pit-muted">No changes between these revisions.</p> : null}
|
||||
{rows.map((row) => (
|
||||
<div className="pit-diff-row" key={row.path}>
|
||||
<strong>{row.path}</strong>
|
||||
<del>{JSON.stringify(row.before)}</del>
|
||||
<ins>{JSON.stringify(row.after)}</ins>
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 ? (
|
||||
<p className="pit-muted">No changes between these revisions.</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{tab === "documents" ? (
|
||||
documents.length === 0 ? <div className="pit-devtools-empty"><span aria-hidden="true">▤</span><p>No documents exist at r{selected.revision}.</p></div> : (
|
||||
documents.length === 0 ? (
|
||||
<div className="pit-devtools-empty">
|
||||
<span aria-hidden="true">▤</span>
|
||||
<p>No documents exist at r{selected.revision}.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pit-document-browser">
|
||||
<div className="pit-document-sidebar">
|
||||
{documents.map((entry) => <button type="button" key={entry.id} data-selected={entry.id === document?.id} onClick={() => { setDocumentId(entry.id); setDocumentRevision(0); }}><strong>{entry.key}</strong><span>{entry.title}</span><small>{entry.revisions.length} revision{entry.revisions.length === 1 ? "" : "s"}</small></button>)}
|
||||
{documents.map((entry) => (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.id}
|
||||
data-selected={entry.id === document?.id}
|
||||
onClick={() => {
|
||||
setDocumentId(entry.id);
|
||||
setDocumentRevision(0);
|
||||
}}
|
||||
>
|
||||
<strong>{entry.key}</strong>
|
||||
<span>{entry.title}</span>
|
||||
<small>
|
||||
{entry.revisions.length} revision
|
||||
{entry.revisions.length === 1 ? "" : "s"}
|
||||
</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{document !== null && visibleDocumentRevision !== null ? (
|
||||
<article className="pit-document-viewer">
|
||||
<header><div><span className="pit-card-meta">{document.key}</span><h4>{document.title}</h4></div><select className="pit-select" aria-label="Document revision" value={visibleDocumentRevision.revision} onChange={(event) => setDocumentRevision(Number(event.target.value))}>{document.revisions.map((entry) => <option key={entry.id} value={entry.revision}>r{entry.revision}</option>)}</select></header>
|
||||
{visibleDocumentRevision.changeSummary ? <p className="pit-document-summary">{visibleDocumentRevision.changeSummary}</p> : null}
|
||||
<pre className="pit-document-body">{visibleDocumentRevision.body}</pre>
|
||||
<header>
|
||||
<div>
|
||||
<span className="pit-card-meta">{document.key}</span>
|
||||
<h4>{document.title}</h4>
|
||||
</div>
|
||||
<select
|
||||
className="pit-select"
|
||||
aria-label="Document revision"
|
||||
value={visibleDocumentRevision.revision}
|
||||
onChange={(event) =>
|
||||
setDocumentRevision(Number(event.target.value))
|
||||
}
|
||||
>
|
||||
{document.revisions.map((entry) => (
|
||||
<option key={entry.id} value={entry.revision}>
|
||||
r{entry.revision}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</header>
|
||||
{visibleDocumentRevision.changeSummary ? (
|
||||
<p className="pit-document-summary">
|
||||
{visibleDocumentRevision.changeSummary}
|
||||
</p>
|
||||
) : null}
|
||||
<pre className="pit-document-body">
|
||||
{visibleDocumentRevision.body}
|
||||
</pre>
|
||||
</article>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
{tab === "protocol" ? <div className="pit-devtools-list">{protocol.map((entry) => <details className="pit-runner-event" key={entry.id}><summary><strong>{entry.boundary}</strong> · {entry.event}</summary><JsonTree value={entry.detail as Json} /></details>)}</div> : null}
|
||||
{tab === "runtime" ? <div className="pit-devtools-pane"><JsonTree value={snapshot.runtime as Json} /></div> : null}
|
||||
{tab === "authority" ? <div className="pit-devtools-pane"><JsonTree value={snapshot.authority as Json} /></div> : null}
|
||||
{tab === "protocol" ? (
|
||||
<div className="pit-devtools-list">
|
||||
{protocol.map((entry) => (
|
||||
<details className="pit-runner-event" key={entry.id}>
|
||||
<summary>
|
||||
<strong>{entry.boundary}</strong> · {entry.event}
|
||||
</summary>
|
||||
<JsonTree value={entry.detail as Json} />
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{tab === "trace" ? (
|
||||
snapshot.providerTrace ? (
|
||||
<div className="pit-devtools-pane">
|
||||
<p>
|
||||
<strong>{snapshot.providerTrace.status}</strong> · expires{" "}
|
||||
{snapshot.providerTrace.expiresAt}
|
||||
</p>
|
||||
<h3>Raw frame → Interpretation → PRP events → Presentation</h3>
|
||||
<JsonTree
|
||||
value={
|
||||
{
|
||||
frames: snapshot.providerTrace.frames,
|
||||
interpretations: snapshot.providerTrace.interpretations,
|
||||
} as Json
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pit-devtools-empty">
|
||||
<span aria-hidden="true">◇</span>
|
||||
<p>
|
||||
Raw provider capture was off for this run. Canonical protocol
|
||||
records remain available in Protocol.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
{tab === "runtime" ? (
|
||||
<div className="pit-devtools-pane">
|
||||
<JsonTree value={snapshot.runtime as Json} />
|
||||
</div>
|
||||
) : null}
|
||||
{tab === "authority" ? (
|
||||
<div className="pit-devtools-pane">
|
||||
<JsonTree value={snapshot.authority as Json} />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -61,10 +61,14 @@ The model receives one text input containing `paperclip.skillless_task.v1`:
|
||||
- task constraints; and
|
||||
- the expected canonical result schema name.
|
||||
|
||||
The thread config explicitly disables automatic skill, app, and collaboration
|
||||
instruction blocks. The model input accepts only text, never a Codex `skill`
|
||||
input. The driver captures the returned instruction-source list and requires it
|
||||
to be empty for the skillless assertion.
|
||||
The thread config explicitly disables automatic skill and app instruction
|
||||
blocks. Codex's built-in collaboration instructions are enabled by default so
|
||||
interactive runs receive native commentary and tool preambles; a driver caller
|
||||
may explicitly disable them for a specialized deterministic fixture. This
|
||||
does not enable skills, apps, plugins, memories, or extra model-input kinds.
|
||||
The model input accepts only text, never a Codex `skill` input. The driver
|
||||
captures the returned instruction-source list and requires it to be empty for
|
||||
the skillless assertion.
|
||||
|
||||
The trusted app-server process has an allowlisted environment. It retains host
|
||||
`HOME` and `CODEX_HOME` only so the provider can authenticate. Model-issued
|
||||
|
||||
@@ -101,7 +101,9 @@ pnpm --filter @paperclipai/paperclip-runner verify:rootless
|
||||
tokens.
|
||||
- the Codex child receives an allowlisted environment without Paperclip or
|
||||
OpenAI bearer credentials;
|
||||
- automatic skill, app, and collaboration instruction blocks are disabled;
|
||||
- automatic skill and app instruction blocks are disabled, while Codex's
|
||||
built-in collaboration instructions are enabled by default and remain
|
||||
explicitly removable for controlled eval baselines;
|
||||
- direct app-server create, resume, read, turn, steer, interrupt, usage, and
|
||||
reconciliation operations preserve stable identities;
|
||||
- provider events normalize to canonical lifecycle, model, tool, file,
|
||||
|
||||
@@ -25,10 +25,18 @@ Parameters:
|
||||
Type: String
|
||||
Description: Selected foundation-model ARN; the region segment may be * for cross-region inference.
|
||||
AllowedPattern: "^arn:aws(-[^:]+)?:bedrock:[^:]*::foundation-model/.+$"
|
||||
BedrockMarketplaceProductId:
|
||||
Type: String
|
||||
Description: Exact AWS Marketplace product ID for the selected third-party model.
|
||||
AllowedPattern: "^prod-[a-z0-9]+$"
|
||||
HarnessName:
|
||||
Type: String
|
||||
Default: PaperclipAgentCoreHarness
|
||||
AllowedPattern: "^[a-zA-Z][a-zA-Z0-9_]{0,39}$"
|
||||
HarnessEndpointName:
|
||||
Type: String
|
||||
Default: paperclip
|
||||
AllowedPattern: "^[a-zA-Z][a-zA-Z0-9_]{0,47}$"
|
||||
MemoryName:
|
||||
Type: String
|
||||
Default: PaperclipAgentCoreMemory
|
||||
@@ -76,6 +84,17 @@ Resources:
|
||||
Resource:
|
||||
- !Ref BedrockModelResourceArn
|
||||
- !Ref BedrockFoundationModelResourceArn
|
||||
- Sid: ViewMarketplaceSubscriptionsForModelAccess
|
||||
Effect: Allow
|
||||
Action: aws-marketplace:ViewSubscriptions
|
||||
Resource: "*"
|
||||
- Sid: SubscribePinnedMarketplaceModel
|
||||
Effect: Allow
|
||||
Action: aws-marketplace:Subscribe
|
||||
Resource: "*"
|
||||
Condition:
|
||||
StringEquals:
|
||||
aws-marketplace:ProductId: !Ref BedrockMarketplaceProductId
|
||||
- Sid: ShortTermMemory
|
||||
Effect: Allow
|
||||
Action:
|
||||
@@ -136,6 +155,8 @@ Resources:
|
||||
MaxIterations: 8
|
||||
MaxTokens: 4096
|
||||
TimeoutSeconds: 300
|
||||
AllowedTools:
|
||||
- "@*/pc_*"
|
||||
Tools: []
|
||||
Skills: []
|
||||
EnvironmentVariables: {}
|
||||
@@ -183,6 +204,8 @@ Resources:
|
||||
- bedrock-agentcore:StopRuntimeSession
|
||||
Resource:
|
||||
- !GetAtt AgentHarness.Arn
|
||||
- !Sub ${AgentHarness.Arn}/harness-endpoint/${HarnessEndpointName}
|
||||
- !Sub ${AgentHarness.Arn}/runtime-endpoint/${HarnessEndpointName}
|
||||
- !GetAtt AgentHarness.Environment.AgentCoreRuntimeEnvironment.AgentRuntimeArn
|
||||
- Sid: ReadPinnedHarness
|
||||
Effect: Allow
|
||||
@@ -190,7 +213,9 @@ Resources:
|
||||
- bedrock-agentcore:GetHarness
|
||||
- bedrock-agentcore:GetHarnessEndpoint
|
||||
- bedrock-agentcore:ListHarnessEndpoints
|
||||
Resource: !GetAtt AgentHarness.Arn
|
||||
Resource:
|
||||
- !GetAtt AgentHarness.Arn
|
||||
- !Sub ${AgentHarness.Arn}/harness-endpoint/${HarnessEndpointName}
|
||||
- Sid: ReadAndPurgeSessionMemory
|
||||
Effect: Allow
|
||||
Action:
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"service": { "enum": ["anthropic_managed_agents", "aws_bedrock_agentcore_harness"] },
|
||||
"providerSessionId": { "type": ["string", "null"], "maxLength": 240 },
|
||||
"processId": { "type": ["integer", "null"], "minimum": 1 },
|
||||
"agentProcessId": { "type": ["integer", "null"], "minimum": 1 },
|
||||
"endpointArn": { "type": "string", "minLength": 1, "maxLength": 2048 },
|
||||
"endpointQualifier": { "type": "string", "minLength": 1, "maxLength": 240 },
|
||||
"memoryId": { "type": "string", "minLength": 1, "maxLength": 240 },
|
||||
@@ -52,7 +53,7 @@
|
||||
{
|
||||
"if": { "properties": { "provider": { "const": "acpx" } }, "required": ["provider"] },
|
||||
"then": {
|
||||
"required": ["agent", "requestedModel", "acpProtocolVersion", "agentServerPackage", "agentServerVersion", "acpxRecordId"]
|
||||
"required": ["agent", "requestedModel", "acpProtocolVersion", "agentServerPackage", "agentServerVersion", "acpxRecordId", "agentProcessId"]
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+1
@@ -1785,6 +1785,7 @@ dependencies = [
|
||||
"aws-sdk-sts",
|
||||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"base64",
|
||||
"hmac 0.12.1",
|
||||
"jsonschema",
|
||||
"reqwest",
|
||||
|
||||
@@ -14,6 +14,7 @@ hmac = "0.12"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
jsonschema = { version = "0.50", default-features = false }
|
||||
aws-config = { version = "1.11", features = ["behavior-version-latest"] }
|
||||
|
||||
@@ -11,6 +11,7 @@ hmac.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
base64.workspace = true
|
||||
reqwest.workspace = true
|
||||
jsonschema.workspace = true
|
||||
aws-config.workspace = true
|
||||
|
||||
@@ -41,6 +41,7 @@ pub struct AcpxProvider {
|
||||
last_sequence: u64,
|
||||
assistant_text: String,
|
||||
provider_requests: u64,
|
||||
agent_process_id: Option<u32>,
|
||||
}
|
||||
|
||||
impl AcpxProvider {
|
||||
@@ -71,6 +72,7 @@ impl AcpxProvider {
|
||||
last_sequence: 0,
|
||||
assistant_text: String::new(),
|
||||
provider_requests: 0,
|
||||
agent_process_id: None,
|
||||
};
|
||||
provider.request(
|
||||
GeneratedAcpxSidecarCommand::Initialize,
|
||||
@@ -98,6 +100,22 @@ impl AcpxProvider {
|
||||
"ACPX sidecar did not verify the configured effective model",
|
||||
));
|
||||
}
|
||||
if let Some(agent_process) = opened.get("agentProcess").filter(|value| value.is_object()) {
|
||||
provider.agent_process_id = agent_process
|
||||
.get("pid")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.filter(|value| *value > 0);
|
||||
provider.inbox.push_back(ProviderEvent::Notification {
|
||||
method: "acpx/process".to_owned(),
|
||||
params: json!({
|
||||
"role": "acp_agent",
|
||||
"pid": agent_process.get("pid").and_then(Value::as_u64),
|
||||
"processGroupId": agent_process.get("processGroupId").and_then(Value::as_u64),
|
||||
"startedAt": agent_process.get("startedAt").and_then(Value::as_str),
|
||||
}),
|
||||
});
|
||||
}
|
||||
provider.request(
|
||||
GeneratedAcpxSidecarCommand::RunAttach,
|
||||
json!({
|
||||
@@ -322,6 +340,10 @@ impl Provider for AcpxProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_process_id(&self) -> Option<u32> {
|
||||
self.agent_process_id
|
||||
}
|
||||
|
||||
fn session_identity(&self) -> &str {
|
||||
&self.acpx_record_id
|
||||
}
|
||||
@@ -517,7 +539,7 @@ fn validate_config(config: &AcpxProviderConfig) -> Result<(), LocalRunnerError>
|
||||
Some("@earendil-works/pi-coding-agent"),
|
||||
Some("0.84.2"),
|
||||
"openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
"sha256:5d06b5cfe819b1acd75e8d9dea4766e76a40a64b82549eda08c3835f18bdac24",
|
||||
"sha256:e806321f458baaf23aa5580324d8f90a59082066105eda69de35b1ef0c8418eb",
|
||||
),
|
||||
"claude" => (
|
||||
"@agentclientprotocol/claude-agent-acp",
|
||||
@@ -616,6 +638,13 @@ fn map_runtime_event(payload: Value, provider_requests: u64) -> ProviderEvent {
|
||||
method: "item/started".to_owned(),
|
||||
params: json!({ "item": { "type": "reasoning", "status": "inProgress" } }),
|
||||
},
|
||||
"semantic_result" => ProviderEvent::SemanticResult {
|
||||
result: payload.get("result").cloned().unwrap_or(Value::Null),
|
||||
item_id: payload
|
||||
.get("callId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned),
|
||||
},
|
||||
"status" if payload.get("tag").and_then(Value::as_str) == Some("usage_update") => {
|
||||
let breakdown = payload
|
||||
.get("breakdown")
|
||||
@@ -722,4 +751,23 @@ mod tests {
|
||||
_ => panic!("expected notification"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_runner_owned_terminal_result_without_control_plane_tool_authority() {
|
||||
let result = json!({
|
||||
"schema": "paperclip.run_result.v1",
|
||||
"reportedWorkDisposition": "done"
|
||||
});
|
||||
let event = map_runtime_event(
|
||||
json!({ "type": "semantic_result", "callId": "finish-1", "result": result }),
|
||||
1,
|
||||
);
|
||||
match event {
|
||||
ProviderEvent::SemanticResult { result, item_id } => {
|
||||
assert_eq!(result["reportedWorkDisposition"], "done");
|
||||
assert_eq!(item_id.as_deref(), Some("finish-1"));
|
||||
}
|
||||
_ => panic!("expected semantic result"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+196
-26
@@ -10,14 +10,15 @@ use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
|
||||
use aws_config::sts::AssumeRoleProvider;
|
||||
use aws_sdk_bedrockagentcore::error::SdkError;
|
||||
use aws_sdk_bedrockagentcore::types::{
|
||||
HarnessBedrockApiFormat, HarnessBedrockModelConfig, HarnessContentBlock,
|
||||
HarnessContentBlockDelta, HarnessContentBlockStart, HarnessConversationRole,
|
||||
HarnessInlineFunctionConfig, HarnessMessage, HarnessModelConfiguration, HarnessTool,
|
||||
HarnessContentBlock, HarnessContentBlockDelta, HarnessContentBlockStart,
|
||||
HarnessConversationRole, HarnessInlineFunctionConfig, HarnessMessage, HarnessTool,
|
||||
HarnessToolConfiguration, HarnessToolResultBlock, HarnessToolResultContentBlock,
|
||||
HarnessToolType, HarnessToolUseBlock, HarnessToolUseStatus, HarnessToolUseType,
|
||||
InvokeHarnessStreamOutput,
|
||||
};
|
||||
use aws_smithy_types::error::metadata::ProvideErrorMetadata;
|
||||
use aws_smithy_types::{Document, Number};
|
||||
use aws_types::region::Region;
|
||||
use jsonschema::validator_for;
|
||||
@@ -32,6 +33,13 @@ use crate::local_runner::LocalRunnerError;
|
||||
use crate::provider_bridge::{AuthorizedTool, ToolResult};
|
||||
|
||||
const MAX_AGENTCORE_TOOLS: usize = 64;
|
||||
// Harness invocations can spend substantial time starting a new runtime before
|
||||
// response headers (and therefore the event stream) are available. Allow a
|
||||
// bounded cold start, but leave enough of the eval's outer deadline for the
|
||||
// durable runner to classify the failure and service runner.shutdown cleanly.
|
||||
const AGENTCORE_INVOCATION_DELIVERY_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
#[cfg(test)]
|
||||
const AGENTCORE_INLINE_TOOL_ALLOWLIST: &str = "@*/pc_*";
|
||||
#[derive(Clone, Debug)]
|
||||
struct RemoteToolUse {
|
||||
remote_name: String,
|
||||
@@ -132,7 +140,7 @@ impl NetworkWorker {
|
||||
})
|
||||
.map_err(|_| LocalRunnerError::invalid("AgentCore network worker stopped"))?;
|
||||
reply_rx
|
||||
.recv_timeout(Duration::from_secs(45))
|
||||
.recv_timeout(AGENTCORE_INVOCATION_DELIVERY_TIMEOUT)
|
||||
.map_err(|_| {
|
||||
LocalRunnerError::invalid(
|
||||
"AgentCore invocation delivery is ambiguous and requires Memory reconciliation",
|
||||
@@ -242,17 +250,11 @@ fn network_loop(
|
||||
let actor_id = actor_id.clone();
|
||||
let events = events.clone();
|
||||
runtime.spawn(async move {
|
||||
let model = HarnessBedrockModelConfig::builder()
|
||||
.model_id(config.model.clone())
|
||||
.max_tokens(config.max_output_tokens as i32)
|
||||
.api_format(HarnessBedrockApiFormat::ConverseStream)
|
||||
.build();
|
||||
let Ok(model) = model else {
|
||||
let _ = events.send(NetworkEvent::Failure(
|
||||
"invalid AgentCore model configuration".to_owned(),
|
||||
));
|
||||
return;
|
||||
};
|
||||
// The qualified Harness version is the immutable model
|
||||
// authority. Supplying a redundant invocation override
|
||||
// changes AgentCore's authorization path and can require
|
||||
// caller-side Marketplace permissions, bypassing the
|
||||
// execution role qualified during provisioning.
|
||||
let response = client
|
||||
.invoke_harness()
|
||||
.harness_arn(config.harness_arn.clone())
|
||||
@@ -261,8 +263,11 @@ fn network_loop(
|
||||
.runtime_user_id(actor_id.clone())
|
||||
.actor_id(actor_id.clone())
|
||||
.set_messages(Some(messages))
|
||||
.model(HarnessModelConfiguration::BedrockModelConfig(model))
|
||||
.set_tools(Some(tools))
|
||||
// Invocation-scoped inline functions live behind an
|
||||
// AgentCore server namespace. Unqualified names are
|
||||
// silently withheld, so admit each authorized tool as
|
||||
// `@*/<collision-resistant pc_ name>` and nothing else.
|
||||
.set_allowed_tools(Some(allowed_tools))
|
||||
.set_skills(Some(Vec::new()))
|
||||
.max_iterations(config.max_iterations as i32)
|
||||
@@ -278,7 +283,7 @@ fn network_loop(
|
||||
let mut response = match response {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
let detail = redact_aws_error(&error.to_string());
|
||||
let detail = classify_aws_sdk_error(&error);
|
||||
let _ = reply.send(Err(detail.clone()));
|
||||
let _ = events.send(NetworkEvent::Failure(detail));
|
||||
return;
|
||||
@@ -293,9 +298,8 @@ fn network_loop(
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(error) => {
|
||||
let _ = events.send(NetworkEvent::Failure(redact_aws_error(
|
||||
&error.to_string(),
|
||||
)));
|
||||
let _ = events
|
||||
.send(NetworkEvent::Failure(classify_aws_sdk_error(&error)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -312,7 +316,10 @@ fn network_loop(
|
||||
match client
|
||||
.stop_runtime_session()
|
||||
.runtime_session_id(session_id.clone())
|
||||
.agent_runtime_arn(config.agent_runtime_arn.clone())
|
||||
// Harness-owned Runtime sessions must be addressed by
|
||||
// the Harness ARN and named Harness endpoint. AWS
|
||||
// rejects direct use of the generated Runtime ARN.
|
||||
.agent_runtime_arn(config.harness_arn.clone())
|
||||
.qualifier(config.endpoint_qualifier.clone())
|
||||
.client_token(token)
|
||||
.send()
|
||||
@@ -375,7 +382,12 @@ fn normalize_stream_event(
|
||||
if let Some((call_id, remote_name, input)) =
|
||||
tool_blocks.remove(&value.content_block_index())
|
||||
{
|
||||
match serde_json::from_str::<Value>(&input) {
|
||||
let parsed = if input.trim().is_empty() {
|
||||
Ok(json!({}))
|
||||
} else {
|
||||
serde_json::from_str::<Value>(&input)
|
||||
};
|
||||
match parsed {
|
||||
Ok(input) => {
|
||||
let _ = events.send(NetworkEvent::ToolUse {
|
||||
call_id,
|
||||
@@ -913,9 +925,12 @@ impl Provider for AwsAgentCoreHarnessProvider {
|
||||
})?;
|
||||
let tool_result = HarnessToolResultBlock::builder()
|
||||
.tool_use_id(call_id.clone())
|
||||
.content(HarnessToolResultContentBlock::Json(json_to_document(
|
||||
&delivered.result,
|
||||
)?))
|
||||
// Although the AgentCore data-plane model advertises JSON
|
||||
// result blocks, the managed Harness runtime currently
|
||||
// rejects them with `content_type=<json_> | unsupported type`.
|
||||
// Preserve the complete structured result as compact JSON in
|
||||
// the supported text variant.
|
||||
.content(encode_tool_result_content(&delivered.result)?)
|
||||
.status(if delivered.is_error {
|
||||
HarnessToolUseStatus::Error
|
||||
} else {
|
||||
@@ -1057,7 +1072,7 @@ fn encode_tools(
|
||||
.config(HarnessToolConfiguration::InlineFunction(inline))
|
||||
.build()
|
||||
.map_err(|_| LocalRunnerError::invalid("failed to build AgentCore tool"))?;
|
||||
allowed.push(remote_name);
|
||||
allowed.push(format!("@*/{remote_name}"));
|
||||
encoded.push(harness_tool);
|
||||
}
|
||||
Ok((encoded, allowed, reverse, schemas))
|
||||
@@ -1144,6 +1159,58 @@ fn redact_aws_error(value: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_aws_error_code(code: &str) -> String {
|
||||
match code {
|
||||
"AccessDeniedException" | "UnauthorizedException" => {
|
||||
"AWS AgentCore access denied".to_owned()
|
||||
}
|
||||
"ResourceNotFoundException" => "AWS AgentCore resource not found".to_owned(),
|
||||
"ThrottlingException" | "TooManyRequestsException" => {
|
||||
"AWS AgentCore request throttled".to_owned()
|
||||
}
|
||||
"ConflictException" => "AWS AgentCore resource conflict".to_owned(),
|
||||
"RequestTimeoutException" | "TimeoutException" => {
|
||||
"AWS AgentCore request timed out".to_owned()
|
||||
}
|
||||
"ValidationException" => "AWS AgentCore request validation failed".to_owned(),
|
||||
"ServiceUnavailableException" | "InternalServerException" => {
|
||||
"AWS AgentCore service unavailable".to_owned()
|
||||
}
|
||||
_ => "AWS AgentCore request failed".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_aws_sdk_error<E, R>(error: &SdkError<E, R>) -> String
|
||||
where
|
||||
E: ProvideErrorMetadata,
|
||||
{
|
||||
if let Some(code) = error
|
||||
.as_service_error()
|
||||
.and_then(ProvideErrorMetadata::code)
|
||||
{
|
||||
return classify_aws_error_code(code);
|
||||
}
|
||||
match error {
|
||||
SdkError::ConstructionFailure(_) => "AWS AgentCore request construction failed".to_owned(),
|
||||
SdkError::TimeoutError(_) => "AWS AgentCore request timed out".to_owned(),
|
||||
SdkError::DispatchFailure(context) if context.is_timeout() => {
|
||||
"AWS AgentCore request dispatch timed out".to_owned()
|
||||
}
|
||||
SdkError::DispatchFailure(context) if context.is_io() => {
|
||||
"AWS AgentCore request network I/O failed".to_owned()
|
||||
}
|
||||
SdkError::DispatchFailure(context) if context.is_user() => {
|
||||
"AWS AgentCore request was rejected before dispatch".to_owned()
|
||||
}
|
||||
SdkError::DispatchFailure(_) => {
|
||||
"AWS AgentCore credential or transport setup failed".to_owned()
|
||||
}
|
||||
SdkError::ResponseError(_) => "AWS AgentCore response decoding failed".to_owned(),
|
||||
SdkError::ServiceError(_) => redact_aws_error(&error.to_string()),
|
||||
_ => "AWS AgentCore request failed".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_resource_not_found(value: &str) -> bool {
|
||||
let lower = value.to_ascii_lowercase();
|
||||
lower.contains("resourcenotfound")
|
||||
@@ -1184,6 +1251,14 @@ fn json_to_document(value: &Value) -> Result<Document, LocalRunnerError> {
|
||||
})
|
||||
}
|
||||
|
||||
fn encode_tool_result_content(
|
||||
value: &Value,
|
||||
) -> Result<HarnessToolResultContentBlock, LocalRunnerError> {
|
||||
serde_json::to_string(value)
|
||||
.map(HarnessToolResultContentBlock::Text)
|
||||
.map_err(|_| LocalRunnerError::invalid("failed to serialize AgentCore tool result"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1211,6 +1286,22 @@ mod tests {
|
||||
assert_eq!(first, remote_tool_name("issues.comment:create"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_allowlist_is_confined_to_the_paperclip_inline_namespace() {
|
||||
let tool = AuthorizedTool {
|
||||
operation_id: "get_task_context".to_owned(),
|
||||
version: 1,
|
||||
description: "Read the assigned task context".to_owned(),
|
||||
input_schema: json!({"type":"object"}),
|
||||
response_schema: json!({"type":"object"}),
|
||||
};
|
||||
let (_, allowed, remote, _) = encode_tools(&[tool]).unwrap();
|
||||
assert_eq!(AGENTCORE_INLINE_TOOL_ALLOWLIST, "@*/pc_*");
|
||||
assert!(remote.keys().all(|name| name.starts_with("pc_")));
|
||||
assert_eq!(allowed.len(), 1);
|
||||
assert!(allowed[0].starts_with("@*/pc_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_more_than_sixty_four_tools() {
|
||||
let tool = AuthorizedTool {
|
||||
@@ -1234,6 +1325,14 @@ mod tests {
|
||||
redact_aws_error("AccessDeniedException: signed request"),
|
||||
"AWS AgentCore access denied"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_aws_error_code("ValidationException"),
|
||||
"AWS AgentCore request validation failed"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_aws_error_code("UnrecognizedFutureError"),
|
||||
"AWS AgentCore request failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1345,4 +1444,75 @@ mod tests {
|
||||
other => panic!("unexpected normalized event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eventstream_empty_tool_input_is_normalized_to_an_empty_object() {
|
||||
let (sender, receiver) = mpsc::sync_channel(4);
|
||||
let mut blocks = BTreeMap::new();
|
||||
let start = HarnessToolUseBlockStart::builder()
|
||||
.tool_use_id("tool-use-empty")
|
||||
.name("pc_get_task_context_abc123")
|
||||
.r#type(HarnessToolUseType::ToolUse)
|
||||
.build()
|
||||
.unwrap();
|
||||
normalize_stream_event(
|
||||
InvokeHarnessStreamOutput::ContentBlockStart(
|
||||
HarnessContentBlockStartEvent::builder()
|
||||
.content_block_index(1)
|
||||
.start(HarnessContentBlockStart::ToolUse(start))
|
||||
.build()
|
||||
.unwrap(),
|
||||
),
|
||||
&mut blocks,
|
||||
&sender,
|
||||
);
|
||||
normalize_stream_event(
|
||||
InvokeHarnessStreamOutput::ContentBlockDelta(
|
||||
HarnessContentBlockDeltaEvent::builder()
|
||||
.content_block_index(1)
|
||||
.delta(HarnessContentBlockDelta::ToolUse(
|
||||
HarnessToolUseBlockDelta::builder()
|
||||
.input("")
|
||||
.build()
|
||||
.unwrap(),
|
||||
))
|
||||
.build()
|
||||
.unwrap(),
|
||||
),
|
||||
&mut blocks,
|
||||
&sender,
|
||||
);
|
||||
normalize_stream_event(
|
||||
InvokeHarnessStreamOutput::ContentBlockStop(
|
||||
HarnessContentBlockStopEvent::builder()
|
||||
.content_block_index(1)
|
||||
.build()
|
||||
.unwrap(),
|
||||
),
|
||||
&mut blocks,
|
||||
&sender,
|
||||
);
|
||||
match receiver.try_recv().unwrap() {
|
||||
NetworkEvent::ToolUse {
|
||||
call_id,
|
||||
remote_name,
|
||||
input,
|
||||
} => {
|
||||
assert_eq!(call_id, "tool-use-empty");
|
||||
assert_eq!(remote_name, "pc_get_task_context_abc123");
|
||||
assert_eq!(input, json!({}));
|
||||
}
|
||||
other => panic!("unexpected normalized event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_results_use_the_harness_supported_text_content_variant() {
|
||||
let value = json!({"ok": true, "nested": {"value": 7}});
|
||||
let content = encode_tool_result_content(&value).unwrap();
|
||||
let text = content
|
||||
.as_text()
|
||||
.expect("AgentCore managed Harness requires text tool results");
|
||||
assert_eq!(serde_json::from_str::<Value>(text).unwrap(), value);
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -59,10 +59,10 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
return Err("authorized tool was not registered".into());
|
||||
}
|
||||
let config = request.pointer("/params/config").and_then(Value::as_object);
|
||||
planning_thread = config
|
||||
.and_then(|value| value.get("include_collaboration_mode_instructions"))
|
||||
.and_then(Value::as_bool)
|
||||
== Some(true);
|
||||
planning_thread = request
|
||||
.pointer("/params/permissions")
|
||||
.and_then(Value::as_str)
|
||||
== Some("paperclip-runner-workspace-read-only");
|
||||
for key in [
|
||||
"skills.include_instructions",
|
||||
"include_apps_instructions",
|
||||
|
||||
@@ -1,25 +1,232 @@
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::local_runner::LocalRunnerError;
|
||||
use crate::process_supervisor::SupervisedProcess;
|
||||
use crate::process_supervisor::{ProcessOutput, SupervisedProcess};
|
||||
use crate::provider_bridge::{AuthorizedTool, ToolResult};
|
||||
|
||||
pub const CODEX_APP_SERVER_MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
|
||||
const DEFAULT_PROVIDER_TRACE_MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
struct ProviderTraceSink {
|
||||
sender: Option<mpsc::SyncSender<String>>,
|
||||
writer: Option<thread::JoinHandle<()>>,
|
||||
writer_error: Arc<Mutex<Option<String>>>,
|
||||
provider: String,
|
||||
next_frame_id: u64,
|
||||
next_debug_sequence: u64,
|
||||
captured_bytes: usize,
|
||||
max_bytes: usize,
|
||||
truncated: bool,
|
||||
incomplete_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderTraceSink {
|
||||
fn from_environment(provider: ProviderKind) -> Option<Self> {
|
||||
let trace_path = std::env::var_os("PAPERCLIP_PROVIDER_TRACE_PATH")?;
|
||||
let max_bytes = std::env::var("PAPERCLIP_PROVIDER_TRACE_MAX_BYTES")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(DEFAULT_PROVIDER_TRACE_MAX_BYTES);
|
||||
Some(Self::at_path(trace_path.into(), max_bytes, provider))
|
||||
}
|
||||
|
||||
fn at_path(trace_path: PathBuf, max_bytes: usize, provider: ProviderKind) -> Self {
|
||||
// Debug delivery is deliberately bounded and lossy: a slow trace disk
|
||||
// may mark the trace incomplete, but it can never backpressure PRP.
|
||||
let (sender, receiver) = mpsc::sync_channel::<String>(1_024);
|
||||
let writer_error = Arc::new(Mutex::new(None));
|
||||
let thread_error = Arc::clone(&writer_error);
|
||||
let writer = thread::spawn(move || {
|
||||
let result = (|| -> std::io::Result<()> {
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&trace_path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut permissions = file.metadata()?.permissions();
|
||||
permissions.set_mode(0o600);
|
||||
fs::set_permissions(&trace_path, permissions)?;
|
||||
}
|
||||
for line in receiver {
|
||||
file.write_all(line.as_bytes())?;
|
||||
file.write_all(b"\n")?;
|
||||
}
|
||||
file.flush()
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
if let Ok(mut slot) = thread_error.lock() {
|
||||
*slot = Some(format!("trace_sidecar_write_failed:{error}"));
|
||||
}
|
||||
}
|
||||
});
|
||||
Self {
|
||||
sender: Some(sender),
|
||||
writer: Some(writer),
|
||||
writer_error,
|
||||
provider: match provider {
|
||||
ProviderKind::Codex => "codex",
|
||||
ProviderKind::Opencode => "opencode",
|
||||
ProviderKind::ClaudeManaged => "claude_managed",
|
||||
ProviderKind::AwsAgentcore => "aws_agentcore",
|
||||
ProviderKind::Acpx => "acpx",
|
||||
}
|
||||
.to_owned(),
|
||||
next_frame_id: 1,
|
||||
next_debug_sequence: 1,
|
||||
captured_bytes: 0,
|
||||
max_bytes,
|
||||
truncated: false,
|
||||
incomplete_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn send_record(&mut self, mut value: Value) {
|
||||
let Some(sender) = self.sender.as_ref().cloned() else {
|
||||
return;
|
||||
};
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
object.insert("debugChannel".to_owned(), json!("rust_native"));
|
||||
object.insert("debugSequence".to_owned(), json!(self.next_debug_sequence));
|
||||
self.next_debug_sequence += 1;
|
||||
}
|
||||
match sender.try_send(value.to_string()) {
|
||||
Ok(()) => {}
|
||||
Err(mpsc::TrySendError::Full(_)) => {
|
||||
self.incomplete_reason = Some("trace_sidecar_spool_full".to_owned());
|
||||
}
|
||||
Err(mpsc::TrySendError::Disconnected(_)) => {
|
||||
self.incomplete_reason = Some("trace_sidecar_channel_closed".to_owned());
|
||||
self.sender = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(&mut self, direction: &str, raw: &[u8]) -> Option<u64> {
|
||||
if self.captured_bytes.saturating_add(raw.len()) > self.max_bytes {
|
||||
self.truncated = true;
|
||||
return None;
|
||||
}
|
||||
let frame_id = self.next_frame_id;
|
||||
self.next_frame_id += 1;
|
||||
self.captured_bytes += raw.len();
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.to_string();
|
||||
self.send_record(json!({
|
||||
"kind": "frame",
|
||||
"schema": "paperclip.provider_trace_frame.v1",
|
||||
"frameId": frame_id,
|
||||
"timestamp": timestamp,
|
||||
"direction": direction,
|
||||
"transport": "stdio_jsonl",
|
||||
"provider": self.provider,
|
||||
"byteLength": raw.len(),
|
||||
"digest": format!("sha256:{:x}", Sha256::digest(raw)),
|
||||
"rawBase64": base64::engine::general_purpose::STANDARD.encode(raw),
|
||||
}));
|
||||
Some(frame_id)
|
||||
}
|
||||
|
||||
fn interpretation(
|
||||
&mut self,
|
||||
frame_id: u64,
|
||||
stage: &str,
|
||||
rule_id: &str,
|
||||
disposition: &str,
|
||||
emitted_event_ids: Vec<String>,
|
||||
dropped_fields: Vec<String>,
|
||||
reason: &str,
|
||||
) {
|
||||
let field_mappings = dropped_fields
|
||||
.iter()
|
||||
.map(|path| {
|
||||
json!({
|
||||
"inputPath": path,
|
||||
"action": "dropped",
|
||||
"reason": reason,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
self.send_record(json!({
|
||||
"kind": "interpretation",
|
||||
"schema": "paperclip.provider_trace_interpretation.v1",
|
||||
"frameId": frame_id,
|
||||
"stage": stage,
|
||||
"ruleId": rule_id,
|
||||
"disposition": disposition,
|
||||
"emittedEventIds": emitted_event_ids,
|
||||
"droppedFields": dropped_fields,
|
||||
"fieldMappings": field_mappings,
|
||||
"reason": reason,
|
||||
}));
|
||||
}
|
||||
|
||||
fn finish(&mut self) {
|
||||
if self.sender.is_none() && self.writer.is_none() {
|
||||
return;
|
||||
}
|
||||
let writer_reason = self
|
||||
.writer_error
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|value| value.clone());
|
||||
let reason = self.incomplete_reason.clone().or(writer_reason);
|
||||
let status = if reason.is_some() {
|
||||
"incomplete"
|
||||
} else if self.truncated {
|
||||
"truncated"
|
||||
} else {
|
||||
"complete"
|
||||
};
|
||||
let acknowledged_debug_sequence = self.next_debug_sequence.saturating_sub(1);
|
||||
self.send_record(json!({
|
||||
"kind": "trace_status",
|
||||
"status": status,
|
||||
"acknowledgedDebugSequence": acknowledged_debug_sequence,
|
||||
"reason": reason.or_else(|| self.truncated.then(|| "provider_trace_max_bytes_exceeded".to_owned())),
|
||||
}));
|
||||
self.sender.take();
|
||||
if let Some(writer) = self.writer.take() {
|
||||
let _ = writer.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProviderTraceSink {
|
||||
fn drop(&mut self) {
|
||||
self.finish();
|
||||
}
|
||||
}
|
||||
|
||||
fn default_collaboration_mode() -> String {
|
||||
"default".to_owned()
|
||||
}
|
||||
|
||||
fn skillless_thread_config(collaboration_mode: &str) -> Value {
|
||||
fn default_collaboration_mode_instructions() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn skillless_thread_config(include_collaboration_mode_instructions: bool) -> Value {
|
||||
json!({
|
||||
"skills.include_instructions": false,
|
||||
"include_apps_instructions": false,
|
||||
"include_collaboration_mode_instructions": collaboration_mode == "plan",
|
||||
"include_collaboration_mode_instructions": include_collaboration_mode_instructions,
|
||||
"features.apps": false,
|
||||
"features.plugins": false,
|
||||
"features.multi_agent": false,
|
||||
@@ -52,6 +259,8 @@ pub struct LocalProviderConfig {
|
||||
pub instructions: String,
|
||||
#[serde(default = "default_collaboration_mode")]
|
||||
pub collaboration_mode: String,
|
||||
#[serde(default = "default_collaboration_mode_instructions")]
|
||||
pub include_collaboration_mode_instructions: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
@@ -250,10 +459,13 @@ pub enum ProviderSessionIdentity {
|
||||
|
||||
pub trait Provider {
|
||||
fn kind(&self) -> ProviderKind;
|
||||
fn runtime_identity(&self) -> ProviderRuntimeIdentity;
|
||||
/// PID of a provider-owned child below the primary runtime process, when
|
||||
/// the provider has a stable child identity to report. This is metadata,
|
||||
/// never an ownership or authorization boundary.
|
||||
fn agent_process_id(&self) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
fn runtime_identity(&self) -> ProviderRuntimeIdentity;
|
||||
fn session_identity(&self) -> &str;
|
||||
fn provider_session_id(&self) -> Option<&str>;
|
||||
fn durable_session_identity(&self) -> Option<ProviderSessionIdentity> {
|
||||
@@ -262,6 +474,20 @@ pub trait Provider {
|
||||
fn durable_event_cursor(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
fn take_provider_trace_frame_id(&mut self) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
fn record_provider_trace_interpretation(
|
||||
&mut self,
|
||||
_frame_id: u64,
|
||||
_stage: &str,
|
||||
_rule_id: &str,
|
||||
_disposition: &str,
|
||||
_emitted_event_ids: Vec<String>,
|
||||
_dropped_fields: Vec<String>,
|
||||
_reason: &str,
|
||||
) {
|
||||
}
|
||||
fn configure_tools(&mut self, _tools: Vec<AuthorizedTool>) -> Result<(), LocalRunnerError> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -341,10 +567,12 @@ pub struct CodexProvider {
|
||||
thread_id: String,
|
||||
session_id: Option<String>,
|
||||
active_provider_turn_id: Option<String>,
|
||||
pending_messages: VecDeque<Value>,
|
||||
pending_messages: VecDeque<(Value, Option<u64>)>,
|
||||
pending_tool_requests: BTreeMap<String, Value>,
|
||||
collaboration_mode: String,
|
||||
collaboration_mode_payload: Option<Value>,
|
||||
trace: Option<ProviderTraceSink>,
|
||||
last_trace_frame_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl CodexProvider {
|
||||
@@ -373,12 +601,14 @@ impl CodexProvider {
|
||||
pending_tool_requests: BTreeMap::new(),
|
||||
collaboration_mode: config.collaboration_mode.clone(),
|
||||
collaboration_mode_payload: None,
|
||||
trace: ProviderTraceSink::from_environment(config.kind),
|
||||
last_trace_frame_id: None,
|
||||
};
|
||||
let initialized = provider.request("initialize", json!({
|
||||
"clientInfo": { "name": "paperclip-runnerd", "title": "Paperclip Runner", "version": "1" },
|
||||
"capabilities": { "experimentalApi": true, "requestAttestation": false }
|
||||
}))?;
|
||||
provider.process.send(&json!({"method": "initialized"}))?;
|
||||
provider.send_frame(&json!({"method": "initialized"}))?;
|
||||
let dynamic_tools = tools
|
||||
.map(|tool| {
|
||||
json!({
|
||||
@@ -408,7 +638,7 @@ impl CodexProvider {
|
||||
"approvalPolicy": "never",
|
||||
"permissions": permission_profile,
|
||||
"runtimeWorkspaceRoots": [config.cwd],
|
||||
"config": skillless_thread_config(&config.collaboration_mode),
|
||||
"config": skillless_thread_config(config.include_collaboration_mode_instructions),
|
||||
"baseInstructions": config.instructions,
|
||||
"dynamicTools": dynamic_tools,
|
||||
"experimentalRawEvents": true,
|
||||
@@ -424,7 +654,7 @@ impl CodexProvider {
|
||||
"approvalPolicy": "never",
|
||||
"permissions": permission_profile,
|
||||
"runtimeWorkspaceRoots": [config.cwd],
|
||||
"config": skillless_thread_config(&config.collaboration_mode),
|
||||
"config": skillless_thread_config(config.include_collaboration_mode_instructions),
|
||||
"baseInstructions": config.instructions,
|
||||
"dynamicTools": dynamic_tools,
|
||||
"experimentalRawEvents": true,
|
||||
@@ -567,20 +797,63 @@ impl CodexProvider {
|
||||
}
|
||||
|
||||
pub fn poll(&mut self) -> Result<Option<ProviderEvent>, LocalRunnerError> {
|
||||
let message = if let Some(message) = self.pending_messages.pop_front() {
|
||||
let (message, frame_id) = if let Some(message) = self.pending_messages.pop_front() {
|
||||
message
|
||||
} else {
|
||||
let Some(line) = self.process.receive_stdout_line(Duration::from_millis(1))? else {
|
||||
let Some(line) = self.receive_stdout_line(Duration::from_millis(1))? else {
|
||||
return if self.process.try_wait()?.is_some() {
|
||||
Ok(Some(ProviderEvent::Exited))
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
};
|
||||
serde_json::from_str(&line).map_err(|error| {
|
||||
let frame_id = self.trace_inbound(&line);
|
||||
let message = serde_json::from_str(&line).map_err(|error| {
|
||||
if let (Some(trace), Some(frame_id)) = (self.trace.as_mut(), frame_id) {
|
||||
trace.interpretation(
|
||||
frame_id,
|
||||
"rust_jsonrpc_parse",
|
||||
"codex.jsonrpc.invalid",
|
||||
"rejected",
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
"Provider frame was not valid JSON-RPC",
|
||||
);
|
||||
}
|
||||
LocalRunnerError::invalid(format!("Codex emitted invalid JSON-RPC: {error}"))
|
||||
})?
|
||||
})?;
|
||||
(message, frame_id)
|
||||
};
|
||||
self.last_trace_frame_id = frame_id;
|
||||
if let (Some(trace), Some(frame_id)) = (self.trace.as_mut(), frame_id) {
|
||||
let method = message.get("method").and_then(Value::as_str);
|
||||
let disposition = if method.is_some() {
|
||||
"mapped"
|
||||
} else {
|
||||
"ignored"
|
||||
};
|
||||
trace.interpretation(
|
||||
frame_id,
|
||||
"rust_jsonrpc_parse",
|
||||
method
|
||||
.map(|value| {
|
||||
if value == "item/tool/call" {
|
||||
"codex.tool_call"
|
||||
} else {
|
||||
"codex.notification"
|
||||
}
|
||||
})
|
||||
.unwrap_or("codex.unroutable"),
|
||||
disposition,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
if method.is_some() {
|
||||
"JSON-RPC frame parsed and routed to provider normalization"
|
||||
} else {
|
||||
"JSON-RPC frame had no routable method"
|
||||
},
|
||||
);
|
||||
}
|
||||
if message.get("id").is_some()
|
||||
&& message.get("method").and_then(Value::as_str) == Some("item/tool/call")
|
||||
{
|
||||
@@ -638,7 +911,7 @@ impl CodexProvider {
|
||||
.ok_or_else(|| {
|
||||
LocalRunnerError::invalid("Codex tool result has no pending JSON-RPC request")
|
||||
})?;
|
||||
self.process.send(&json!({
|
||||
self.send_frame(&json!({
|
||||
"id": request_id,
|
||||
"result": {
|
||||
"success": !result.is_error,
|
||||
@@ -654,25 +927,114 @@ impl CodexProvider {
|
||||
/// `Drop` remains the last-resort cleanup path, but runner shutdown must not
|
||||
/// depend on an implicit destructor firing after the durable transport loop.
|
||||
pub fn shutdown(&mut self) -> Result<(), LocalRunnerError> {
|
||||
self.process.terminate_group().map(|_| ())
|
||||
let result = self.process.terminate_group().map(|_| ());
|
||||
if let Some(trace) = self.trace.as_mut() {
|
||||
trace.finish();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn send_frame(&mut self, value: &Value) -> Result<(), LocalRunnerError> {
|
||||
if let Some(trace) = self.trace.as_mut() {
|
||||
let raw = serde_json::to_vec(value).unwrap_or_default();
|
||||
if let Some(frame_id) = trace.frame("client_to_provider", &raw) {
|
||||
trace.interpretation(
|
||||
frame_id,
|
||||
"rust_native_transport",
|
||||
"codex.jsonrpc.outbound",
|
||||
"operator_only",
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
"Outbound provider command does not enter the canonical PRP outbox",
|
||||
);
|
||||
}
|
||||
}
|
||||
self.process.send(value)
|
||||
}
|
||||
|
||||
fn trace_inbound(&mut self, line: &str) -> Option<u64> {
|
||||
self.trace
|
||||
.as_mut()
|
||||
.and_then(|trace| trace.frame("provider_to_client", line.as_bytes()))
|
||||
}
|
||||
|
||||
fn receive_stdout_line(
|
||||
&mut self,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<String>, LocalRunnerError> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Ok(None);
|
||||
}
|
||||
match self.process.recv_timeout(remaining) {
|
||||
Ok(ProcessOutput::Stdout(line)) => return Ok(Some(line)),
|
||||
Ok(ProcessOutput::Stderr(line)) => {
|
||||
if let Some(trace) = self.trace.as_mut() {
|
||||
if let Some(frame_id) = trace.frame("provider_stderr", line.as_bytes()) {
|
||||
trace.interpretation(
|
||||
frame_id,
|
||||
"rust_native_transport",
|
||||
"provider.stderr",
|
||||
"operator_only",
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
"Provider stderr is retained only in the restricted trace sidecar",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ProcessOutput::StdoutError(message)) => {
|
||||
return Err(LocalRunnerError::invalid(message))
|
||||
}
|
||||
Ok(ProcessOutput::StdoutClosed) => return Ok(None),
|
||||
Ok(ProcessOutput::StderrClosed) => {}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => return Ok(None),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
return Err(LocalRunnerError::invalid("process output channel closed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request(&mut self, method: &str, params: Value) -> Result<Value, LocalRunnerError> {
|
||||
let id = self.next_request_id;
|
||||
self.next_request_id += 1;
|
||||
self.process
|
||||
.send(&json!({"id": id, "method": method, "params": params}))?;
|
||||
self.send_frame(&json!({"id": id, "method": method, "params": params}))?;
|
||||
loop {
|
||||
let line = self
|
||||
.process
|
||||
.receive_stdout_line(Duration::from_secs(30))?
|
||||
.ok_or_else(|| {
|
||||
LocalRunnerError::invalid(format!("Codex {method} response timed out"))
|
||||
})?;
|
||||
let frame_id = self.trace_inbound(&line);
|
||||
let message: Value = serde_json::from_str(&line).map_err(|error| {
|
||||
if let (Some(trace), Some(frame_id)) = (self.trace.as_mut(), frame_id) {
|
||||
trace.interpretation(
|
||||
frame_id,
|
||||
"rust_jsonrpc_parse",
|
||||
"codex.jsonrpc.invalid",
|
||||
"rejected",
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
"Provider frame was not valid JSON-RPC",
|
||||
);
|
||||
}
|
||||
LocalRunnerError::invalid(format!("Codex emitted invalid JSON-RPC: {error}"))
|
||||
})?;
|
||||
if message.get("id").and_then(Value::as_u64) == Some(id) {
|
||||
if let (Some(trace), Some(frame_id)) = (self.trace.as_mut(), frame_id) {
|
||||
trace.interpretation(
|
||||
frame_id,
|
||||
"rust_jsonrpc_parse",
|
||||
"codex.jsonrpc.response",
|
||||
"operator_only",
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
"Matched synchronous app-server response",
|
||||
);
|
||||
}
|
||||
if let Some(error) = message.get("error") {
|
||||
return Err(LocalRunnerError::invalid(format!(
|
||||
"Codex {method} failed: {error}"
|
||||
@@ -680,7 +1042,7 @@ impl CodexProvider {
|
||||
}
|
||||
return Ok(message.get("result").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
self.pending_messages.push_back(message);
|
||||
self.pending_messages.push_back((message, frame_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -701,6 +1063,31 @@ impl Provider for CodexProvider {
|
||||
fn provider_session_id(&self) -> Option<&str> {
|
||||
self.session_id()
|
||||
}
|
||||
fn take_provider_trace_frame_id(&mut self) -> Option<u64> {
|
||||
self.last_trace_frame_id.take()
|
||||
}
|
||||
fn record_provider_trace_interpretation(
|
||||
&mut self,
|
||||
frame_id: u64,
|
||||
stage: &str,
|
||||
rule_id: &str,
|
||||
disposition: &str,
|
||||
emitted_event_ids: Vec<String>,
|
||||
dropped_fields: Vec<String>,
|
||||
reason: &str,
|
||||
) {
|
||||
if let Some(trace) = self.trace.as_mut() {
|
||||
trace.interpretation(
|
||||
frame_id,
|
||||
stage,
|
||||
rule_id,
|
||||
disposition,
|
||||
emitted_event_ids,
|
||||
dropped_fields,
|
||||
reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
fn start_turn(
|
||||
&mut self,
|
||||
message: &str,
|
||||
@@ -709,12 +1096,12 @@ impl Provider for CodexProvider {
|
||||
) -> Result<Value, LocalRunnerError> {
|
||||
CodexProvider::start_turn(self, message, cwd)
|
||||
}
|
||||
fn steer_turn(&mut self, turn_id: &str, message: &str) -> Result<Value, LocalRunnerError> {
|
||||
CodexProvider::steer_turn(self, turn_id, message)
|
||||
}
|
||||
fn interrupt_turn(&mut self, turn_id: &str) -> Result<Value, LocalRunnerError> {
|
||||
CodexProvider::interrupt_turn(self, turn_id)
|
||||
}
|
||||
fn steer_turn(&mut self, turn_id: &str, message: &str) -> Result<Value, LocalRunnerError> {
|
||||
CodexProvider::steer_turn(self, turn_id, message)
|
||||
}
|
||||
fn read(&mut self) -> Result<Value, LocalRunnerError> {
|
||||
self.read_thread()
|
||||
}
|
||||
@@ -728,3 +1115,130 @@ impl Provider for CodexProvider {
|
||||
CodexProvider::shutdown(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod provider_trace_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn collaboration_instructions_default_on_and_allow_explicit_opt_out() {
|
||||
let default_config: LocalProviderConfig = serde_json::from_value(json!({
|
||||
"kind": "codex",
|
||||
"command": "codex",
|
||||
"args": [],
|
||||
"cwd": "/workspace",
|
||||
"model": null,
|
||||
"instructions": "Run the task.",
|
||||
"collaborationMode": "default"
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(default_config.include_collaboration_mode_instructions);
|
||||
assert_eq!(
|
||||
skillless_thread_config(default_config.include_collaboration_mode_instructions)
|
||||
["include_collaboration_mode_instructions"],
|
||||
json!(true)
|
||||
);
|
||||
|
||||
let opt_out_config: LocalProviderConfig = serde_json::from_value(json!({
|
||||
"kind": "codex",
|
||||
"command": "codex",
|
||||
"args": [],
|
||||
"cwd": "/workspace",
|
||||
"model": null,
|
||||
"instructions": "Run the eval fixture.",
|
||||
"collaborationMode": "default",
|
||||
"includeCollaborationModeInstructions": false
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(!opt_out_config.include_collaboration_mode_instructions);
|
||||
assert_eq!(
|
||||
skillless_thread_config(opt_out_config.include_collaboration_mode_instructions)
|
||||
["include_collaboration_mode_instructions"],
|
||||
json!(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_trace_preserves_exact_bytes_order_digest_and_interpretation() {
|
||||
let trace_path = std::env::temp_dir().join(format!(
|
||||
"paperclip-provider-trace-{}.ndjson",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let first = br#"{"id":1,"method":"thread/start"}"#;
|
||||
let second = br#"{"method":"item/completed","params":{"item":{"type":"agentMessage"}}}"#;
|
||||
let mut trace = ProviderTraceSink::at_path(
|
||||
trace_path.clone(),
|
||||
DEFAULT_PROVIDER_TRACE_MAX_BYTES,
|
||||
ProviderKind::Codex,
|
||||
);
|
||||
|
||||
let first_id = trace.frame("client_to_provider", first).unwrap();
|
||||
let second_id = trace.frame("provider_to_client", second).unwrap();
|
||||
trace.interpretation(
|
||||
second_id,
|
||||
"rust_jsonrpc_parse",
|
||||
"codex.notification",
|
||||
"mapped",
|
||||
vec!["event_runner_000001".to_owned()],
|
||||
Vec::new(),
|
||||
"mapped for test",
|
||||
);
|
||||
trace.finish();
|
||||
|
||||
let entries = fs::read_to_string(&trace_path)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str::<Value>(line).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
let frames = entries
|
||||
.iter()
|
||||
.filter(|entry| entry["kind"] == "frame")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!((first_id, second_id), (1, 2));
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(frames[0]["rawBase64"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
first
|
||||
);
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(frames[1]["rawBase64"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
second
|
||||
);
|
||||
assert_eq!(
|
||||
frames[1]["digest"],
|
||||
format!("sha256:{:x}", Sha256::digest(second))
|
||||
);
|
||||
assert_eq!(entries[0]["debugSequence"], 1);
|
||||
assert_eq!(entries[1]["debugSequence"], 2);
|
||||
assert_eq!(entries[2]["debugSequence"], 3);
|
||||
assert_eq!(entries[2]["emittedEventIds"][0], "event_runner_000001");
|
||||
assert_eq!(entries.last().unwrap()["status"], "complete");
|
||||
assert_eq!(
|
||||
entries.last().unwrap()["acknowledgedDebugSequence"],
|
||||
entries.len() - 1
|
||||
);
|
||||
|
||||
let _ = fs::remove_file(trace_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_trace_truncation_does_not_accept_bytes_past_the_cap() {
|
||||
let trace_path = std::env::temp_dir().join(format!(
|
||||
"paperclip-provider-trace-{}.ndjson",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let mut trace = ProviderTraceSink::at_path(trace_path.clone(), 4, ProviderKind::Codex);
|
||||
assert!(trace.frame("provider_to_client", b"12345").is_none());
|
||||
trace.finish();
|
||||
|
||||
let content = fs::read_to_string(&trace_path).unwrap();
|
||||
assert!(!content.contains("rawBase64"));
|
||||
assert!(content.contains("\"status\":\"truncated\""));
|
||||
assert!(content.contains("provider_trace_max_bytes_exceeded"));
|
||||
|
||||
let _ = fs::remove_file(trace_path);
|
||||
}
|
||||
}
|
||||
@@ -641,6 +641,8 @@ fn provider_descriptor(
|
||||
"provider": provider_kind_name(value.kind),
|
||||
"driver": if value.kind == crate::codex_provider::ProviderKind::Opencode { "opencode_server" } else { "codex_app_server" },
|
||||
"model": value.model,
|
||||
"collaborationMode": value.collaboration_mode,
|
||||
"collaborationInstructions": value.include_collaboration_mode_instructions,
|
||||
"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()),
|
||||
@@ -1044,6 +1046,49 @@ fn poll_provider(
|
||||
)));
|
||||
}
|
||||
};
|
||||
let trace_frame_id = runtime.take_provider_trace_frame_id();
|
||||
let trace_first_source_seq = state.next_source_seq;
|
||||
let (trace_rule_id, trace_disposition, trace_dropped_fields, trace_reason) = match &event {
|
||||
crate::codex_provider::ProviderEvent::ToolCall { .. } => (
|
||||
"provider.tool_call",
|
||||
"mapped",
|
||||
Vec::new(),
|
||||
"Provider tool call mapped to semantic_tool.input",
|
||||
),
|
||||
crate::codex_provider::ProviderEvent::SemanticResult { .. } => (
|
||||
"provider.semantic_result",
|
||||
"mapped",
|
||||
Vec::new(),
|
||||
"Provider semantic result mapped independently from final prose",
|
||||
),
|
||||
crate::codex_provider::ProviderEvent::RuntimeRequest { .. } => (
|
||||
"provider.runtime_request",
|
||||
"mapped",
|
||||
Vec::new(),
|
||||
"Provider request mapped to runtime_request.created",
|
||||
),
|
||||
crate::codex_provider::ProviderEvent::Notification { method, .. } if matches!(
|
||||
method.as_str(),
|
||||
"turn/started" | "turn/completed" | "item/started" | "item/delta" | "item/completed" | "thread/tokenUsage/updated" | "provider/budgetReached" | "acpx/process"
|
||||
) => (
|
||||
"provider.notification.known",
|
||||
"mapped",
|
||||
Vec::new(),
|
||||
"Known provider notification mapped to canonical PRP event",
|
||||
),
|
||||
crate::codex_provider::ProviderEvent::Notification { .. } => (
|
||||
"provider.notification.unknown",
|
||||
"generic",
|
||||
vec!["params".to_owned()],
|
||||
"Unknown provider notification retained as harness.diagnostic without provider-only fields",
|
||||
),
|
||||
crate::codex_provider::ProviderEvent::Exited => (
|
||||
"provider.process.exited",
|
||||
"rejected",
|
||||
Vec::new(),
|
||||
"Provider exited before a new frame could be mapped",
|
||||
),
|
||||
};
|
||||
match event {
|
||||
crate::codex_provider::ProviderEvent::ToolCall {
|
||||
call_id,
|
||||
@@ -1231,6 +1276,20 @@ fn poll_provider(
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(frame_id) = trace_frame_id {
|
||||
let emitted_event_ids = (trace_first_source_seq..state.next_source_seq)
|
||||
.map(|source_seq| format!("event_{}_{source_seq:06}", state.runner_instance_id))
|
||||
.collect::<Vec<_>>();
|
||||
runtime.record_provider_trace_interpretation(
|
||||
frame_id,
|
||||
"rust_durable_normalization",
|
||||
trace_rule_id,
|
||||
trace_disposition,
|
||||
emitted_event_ids,
|
||||
trace_dropped_fields,
|
||||
trace_reason,
|
||||
);
|
||||
}
|
||||
if let Some(cursor) = runtime.durable_event_cursor() {
|
||||
state.provider_event_cursor = Some(cursor.to_owned());
|
||||
store.save(state)?;
|
||||
|
||||
@@ -1528,6 +1528,32 @@ fn execute_command_effect(
|
||||
1,
|
||||
"provider turn interruption requested".to_owned(),
|
||||
)),
|
||||
"request.resolve" if state.provider_config.is_some() => {
|
||||
let request_id = payload
|
||||
.get("requestId")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
DurableRunnerError::invalid("request.resolve payload.requestId is required")
|
||||
})?;
|
||||
let action = payload
|
||||
.pointer("/resolution/action")
|
||||
.or_else(|| payload.get("action"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("cancel");
|
||||
if !["accept", "accept_for_session", "decline", "cancel"].contains(&action) {
|
||||
return Ok((
|
||||
"rejected".to_owned(),
|
||||
0,
|
||||
"provider runtime request resolution action is unsupported".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok((
|
||||
"completed".to_owned(),
|
||||
0,
|
||||
format!("provider runtime request {request_id} resolution admitted"),
|
||||
))
|
||||
}
|
||||
"provider.thread.read" => Ok((
|
||||
"completed".to_owned(),
|
||||
1,
|
||||
|
||||
@@ -135,6 +135,47 @@ pub fn canonical_provider_events(method: &str, params: &Value) -> Vec<(String, V
|
||||
_ => "other",
|
||||
};
|
||||
let url = text(action.get("url"));
|
||||
let sources: Vec<Value> = if complete {
|
||||
item.get("results")
|
||||
.and_then(Value::as_array)
|
||||
.map(|results| {
|
||||
results
|
||||
.iter()
|
||||
.take(64)
|
||||
.enumerate()
|
||||
.filter_map(|(index, result)| {
|
||||
let source_url = text(result.get("url"));
|
||||
if (!source_url.starts_with("http://")
|
||||
&& !source_url.starts_with("https://"))
|
||||
|| source_url.len() > 8192
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let fallback_id = format!("{item_id}:source:{}", index + 1);
|
||||
let source_id = id(
|
||||
text(result.get("ref_id").or_else(|| result.get("refId"))),
|
||||
&fallback_id,
|
||||
);
|
||||
let title = redact_text(text(result.get("title")));
|
||||
let title: String = if title.is_empty() {
|
||||
source_url.chars().take(4000).collect()
|
||||
} else {
|
||||
title.chars().take(4000).collect()
|
||||
};
|
||||
let snippet = redact_text(text(result.get("snippet")));
|
||||
Some(json!({
|
||||
"sourceId": source_id,
|
||||
"title": title,
|
||||
"url": source_url,
|
||||
"snippet": if snippet.is_empty() { Value::Null } else { Value::String(snippet.chars().take(4000).collect()) },
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
push(
|
||||
&mut result,
|
||||
if complete {
|
||||
@@ -142,7 +183,7 @@ pub fn canonical_provider_events(method: &str, params: &Value) -> Vec<(String, V
|
||||
} 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":[]}),
|
||||
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":sources}),
|
||||
&item_id,
|
||||
);
|
||||
} else if (method == "item/started" || complete)
|
||||
@@ -332,4 +373,19 @@ mod tests {
|
||||
assert_eq!(events[0].1["available"], false);
|
||||
assert!(!events[0].1.to_string().contains("native-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_bounded_safe_web_search_sources() {
|
||||
let events = canonical_provider_events(
|
||||
"item/completed",
|
||||
&json!({"item":{"id":"web-1","type":"webSearch","results":[
|
||||
{"ref_id":"source-1","title":"Protocol notes","url":"https://example.com/prp","snippet":"Canonical event details"},
|
||||
{"ref_id":"unsafe","title":"Unsafe","url":"file:///etc/passwd"}
|
||||
]}}),
|
||||
);
|
||||
assert_eq!(events[0].0, "research.completed");
|
||||
assert_eq!(events[0].1["sources"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(events[0].1["sources"][0]["sourceId"], "source-1");
|
||||
assert_eq!(events[0].1["sources"][0]["url"], "https://example.com/prp");
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ fn codex_plan_mode_is_qualified_and_selected_on_turn_start() {
|
||||
model: None,
|
||||
instructions: "Author a plan without editing files.".to_owned(),
|
||||
collaboration_mode: "plan".to_owned(),
|
||||
include_collaboration_mode_instructions: true,
|
||||
},
|
||||
vec![test_tool()].into_iter(),
|
||||
None,
|
||||
@@ -48,6 +49,7 @@ fn codex_dynamic_tool_round_trips_through_the_rust_provider_boundary() {
|
||||
model: None,
|
||||
instructions: "Use the authorized Paperclip tools.".to_owned(),
|
||||
collaboration_mode: "default".to_owned(),
|
||||
include_collaboration_mode_instructions: true,
|
||||
},
|
||||
vec![AuthorizedTool {
|
||||
operation_id: "get_task_context".to_owned(),
|
||||
@@ -124,6 +126,7 @@ fn provider_contract_preserves_the_opencode_tag() {
|
||||
model: Some("openrouter/deepseek/deepseek-v4-flash-0731".to_owned()),
|
||||
instructions: "Use the authorized Paperclip tools.".to_owned(),
|
||||
collaboration_mode: "default".to_owned(),
|
||||
include_collaboration_mode_instructions: false,
|
||||
},
|
||||
vec![AuthorizedTool {
|
||||
operation_id: "get_task_context".to_owned(),
|
||||
@@ -151,6 +154,7 @@ fn image_like_prompt_stays_skillless_and_accepts_a_valid_one_point_two_megabyte_
|
||||
model: None,
|
||||
instructions: "Use only the authorized Paperclip tools.".to_owned(),
|
||||
collaboration_mode: "default".to_owned(),
|
||||
include_collaboration_mode_instructions: true,
|
||||
},
|
||||
vec![AuthorizedTool {
|
||||
operation_id: "get_task_context".to_owned(),
|
||||
@@ -210,6 +214,7 @@ fn codex_provider_rejects_an_event_above_the_four_megabyte_hard_limit() {
|
||||
model: None,
|
||||
instructions: "Use only the authorized Paperclip tools.".to_owned(),
|
||||
collaboration_mode: "default".to_owned(),
|
||||
include_collaboration_mode_instructions: true,
|
||||
},
|
||||
vec![AuthorizedTool {
|
||||
operation_id: "get_task_context".to_owned(),
|
||||
|
||||
@@ -25,10 +25,17 @@ test("AgentCore template has closed development/private resources and explicit c
|
||||
assert.match(source, /MaxIterations: 8/);
|
||||
assert.match(source, /MaxTokens: 4096/);
|
||||
assert.match(source, /TimeoutSeconds: 300/);
|
||||
assert.match(source, /AllowedTools:\s+- "@\*\/pc_\*"/);
|
||||
assert.match(source, /Tools: \[\]/);
|
||||
assert.match(source, /Skills: \[\]/);
|
||||
assert.doesNotMatch(source, /BedrockModelResourceArn:\s+[\s\S]{0,100}Default: "\*"/);
|
||||
assert.match(source, /BedrockFoundationModelResourceArn/);
|
||||
assert.match(source, /HarnessEndpointName/);
|
||||
assert.match(source, /\$\{AgentHarness\.Arn\}\/harness-endpoint\/\$\{HarnessEndpointName\}/);
|
||||
assert.match(source, /\$\{AgentHarness\.Arn\}\/runtime-endpoint\/\$\{HarnessEndpointName\}/);
|
||||
assert.match(source, /BedrockMarketplaceProductId/);
|
||||
assert.match(source, /Sid: ViewMarketplaceSubscriptionsForModelAccess[\s\S]*Action: aws-marketplace:ViewSubscriptions[\s\S]*Sid: SubscribePinnedMarketplaceModel[\s\S]*Action: aws-marketplace:Subscribe[\s\S]*aws-marketplace:ProductId: !Ref BedrockMarketplaceProductId/);
|
||||
assert.doesNotMatch(source, /aws-marketplace:Unsubscribe/);
|
||||
});
|
||||
|
||||
test("AgentCore wrapper is valid shell and writes only nonsecret profile metadata", async () => {
|
||||
@@ -38,6 +45,7 @@ test("AgentCore wrapper is valid shell and writes only nonsecret profile metadat
|
||||
assert.ok(generatedBlock.length > 0);
|
||||
assert.doesNotMatch(generatedBlock, /AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|Authorization|X-Amz-Signature/);
|
||||
assert.match(source, /chmod 600 "\$tmp"/);
|
||||
assert.match(source, /printf 'AWS_CONFIG_FILE=%s\\n'/);
|
||||
assert.match(source, /cloudformation deploy/);
|
||||
assert.match(source, /CAPABILITY_NAMED_IAM/);
|
||||
assert.match(source, /--\) shift ;;/);
|
||||
@@ -45,5 +53,12 @@ test("AgentCore wrapper is valid shell and writes only nonsecret profile metadat
|
||||
assert.doesNotMatch(source, /printf 'arn:%s:iam::%s:role\/%s/);
|
||||
assert.match(source, /existing_status.*ROLLBACK_COMPLETE/s);
|
||||
assert.match(source, /cloudformation wait stack-delete-complete/);
|
||||
assert.ok(source.indexOf("delete-harness-endpoint") < source.indexOf("cloudformation delete-stack"));
|
||||
assert.match(source, /--query harness\.status/);
|
||||
assert.match(source, /AgentCore tool allowlist drift/);
|
||||
assert.match(source, /--query memory\.status/);
|
||||
assert.match(source, /--query endpoint\.status/);
|
||||
assert.match(source, /o\.endpoint\?\.arn/);
|
||||
assert.match(source, /--marketplace-product-id/);
|
||||
assert.match(source, /BedrockMarketplaceProductId=\$MARKETPLACE_PRODUCT_ID/);
|
||||
assert.ok(source.indexOf("delete-harness-endpoint") < source.lastIndexOf("cloudformation delete-stack"));
|
||||
});
|
||||
@@ -15,6 +15,8 @@ DEPLOYMENT_MODE="development"
|
||||
STACK_NAME="paperclip-agentcore-development"
|
||||
ENDPOINT_NAME="paperclip"
|
||||
MODEL_ID="global.anthropic.claude-sonnet-4-6"
|
||||
MARKETPLACE_PRODUCT_ID="prod-ffvjxvh4ltq64"
|
||||
MARKETPLACE_PRODUCT_ID_EXPLICIT=false
|
||||
TRUSTED_PRINCIPAL=""
|
||||
DRY_RUN=false
|
||||
FORCE=false
|
||||
@@ -28,6 +30,7 @@ usage() {
|
||||
" --mode MODE development or private" \
|
||||
" --stack-name NAME CloudFormation stack name" \
|
||||
" --model MODEL_ID Bedrock-native model ID" \
|
||||
" --marketplace-product-id ID exact AWS Marketplace product ID for the model" \
|
||||
" --principal ARN stable IAM role/user trusted to assume runner role" \
|
||||
" --dry-run validate and print changes without deployment" \
|
||||
" --yes confirm destructive teardown" \
|
||||
@@ -42,6 +45,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--mode) DEPLOYMENT_MODE="$2"; shift 2 ;;
|
||||
--stack-name) STACK_NAME="$2"; shift 2 ;;
|
||||
--model) MODEL_ID="$2"; shift 2 ;;
|
||||
--marketplace-product-id) MARKETPLACE_PRODUCT_ID="$2"; MARKETPLACE_PRODUCT_ID_EXPLICIT=true; shift 2 ;;
|
||||
--principal) TRUSTED_PRINCIPAL="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
@@ -67,6 +71,14 @@ if [[ ! "$AWS_REGION_NAME" =~ ^[a-z]{2}(-gov)?-[a-z]+-[0-9]+$ || "$AWS_REGION_NA
|
||||
printf 'This proof of concept currently supports commercial AWS regions only.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$MODEL_ID" != "global.anthropic.claude-sonnet-4-6" && "$MARKETPLACE_PRODUCT_ID_EXPLICIT" != true ]]; then
|
||||
printf 'A custom --model requires its exact --marketplace-product-id so subscription authority remains model-scoped.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! "$MARKETPLACE_PRODUCT_ID" =~ ^prod-[a-z0-9]+$ ]]; then
|
||||
printf 'Marketplace product ID must use the AWS prod-* form.\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
aws_cli() {
|
||||
aws --profile "$AWS_PROFILE_NAME" --region "$AWS_REGION_NAME" --no-cli-pager "$@"
|
||||
@@ -93,7 +105,7 @@ stack_output() {
|
||||
wait_for_harness_status() {
|
||||
local harness_id="$1" harness_version="$2" status=""
|
||||
for _ in {1..60}; do
|
||||
status="$(aws_cli bedrock-agentcore-control get-harness --harness-id "$harness_id" --harness-version "$harness_version" --query status --output text 2>/dev/null || true)"
|
||||
status="$(aws_cli bedrock-agentcore-control get-harness --harness-id "$harness_id" --harness-version "$harness_version" --query harness.status --output text 2>/dev/null || true)"
|
||||
if [[ "$status" == "READY" ]]; then return 0; fi
|
||||
if [[ "$status" == "FAILED" || "$status" == "CREATE_FAILED" || "$status" == "UPDATE_FAILED" ]]; then
|
||||
printf 'Harness %s entered terminal status %s.\n' "$harness_id" "$status" >&2
|
||||
@@ -108,7 +120,7 @@ wait_for_harness_status() {
|
||||
wait_for_memory_status() {
|
||||
local memory_id="$1" status=""
|
||||
for _ in {1..60}; do
|
||||
status="$(aws_cli bedrock-agentcore-control get-memory --memory-id "$memory_id" --query status --output text 2>/dev/null || true)"
|
||||
status="$(aws_cli bedrock-agentcore-control get-memory --memory-id "$memory_id" --query memory.status --output text 2>/dev/null || true)"
|
||||
[[ "$status" == "ACTIVE" ]] && return 0
|
||||
[[ "$status" == "FAILED" ]] && { printf 'Memory %s entered FAILED.\n' "$memory_id" >&2; return 1; }
|
||||
sleep 5
|
||||
@@ -120,7 +132,7 @@ wait_for_memory_status() {
|
||||
wait_for_endpoint_status() {
|
||||
local harness_id="$1" endpoint_name="$2" status=""
|
||||
for _ in {1..60}; do
|
||||
status="$(aws_cli bedrock-agentcore-control get-harness-endpoint --harness-id "$harness_id" --endpoint-name "$endpoint_name" --query status --output text 2>/dev/null || true)"
|
||||
status="$(aws_cli bedrock-agentcore-control get-harness-endpoint --harness-id "$harness_id" --endpoint-name "$endpoint_name" --query endpoint.status --output text 2>/dev/null || true)"
|
||||
[[ "$status" == "READY" ]] && return 0
|
||||
[[ "$status" == "FAILED" ]] && { printf 'Harness endpoint %s entered FAILED.\n' "$endpoint_name" >&2; return 1; }
|
||||
sleep 5
|
||||
@@ -184,12 +196,21 @@ assume_runner_role() {
|
||||
|
||||
probe() {
|
||||
load_local_env
|
||||
local profile_save="$AWS_PROFILE_NAME"
|
||||
local profile_save="$AWS_PROFILE_NAME" harness_json
|
||||
assume_runner_role "$PAPERCLIP_AWS_AGENTCORE_INVOCATION_ROLE_ARN"
|
||||
AWS_PROFILE_NAME=""
|
||||
aws --region "$AWS_REGION_NAME" --no-cli-pager bedrock-agentcore-control get-harness \
|
||||
harness_json="$(aws --region "$AWS_REGION_NAME" --no-cli-pager bedrock-agentcore-control get-harness \
|
||||
--harness-id "$PAPERCLIP_AWS_AGENTCORE_HARNESS_ID" \
|
||||
--harness-version "$PAPERCLIP_AWS_AGENTCORE_HARNESS_VERSION" >/dev/null
|
||||
--harness-version "$PAPERCLIP_AWS_AGENTCORE_HARNESS_VERSION" --output json)"
|
||||
printf '%s' "$harness_json" | node -e '
|
||||
const fs = require("fs");
|
||||
const h = JSON.parse(fs.readFileSync(0, "utf8")).harness;
|
||||
const expectedModel = process.env.PAPERCLIP_AWS_AGENTCORE_MODEL;
|
||||
if (!h || h.model?.bedrockModelConfig?.modelId !== expectedModel) throw new Error("AgentCore model drift");
|
||||
if (JSON.stringify(h.allowedTools) !== JSON.stringify(["@*/pc_*"])) throw new Error("AgentCore tool allowlist drift");
|
||||
if (!Array.isArray(h.tools) || h.tools.length !== 0) throw new Error("AgentCore persistent tool drift");
|
||||
if (!Array.isArray(h.skills) || h.skills.length !== 0) throw new Error("AgentCore skill drift");
|
||||
'
|
||||
aws --region "$AWS_REGION_NAME" --no-cli-pager bedrock-agentcore-control get-harness-endpoint \
|
||||
--harness-id "$PAPERCLIP_AWS_AGENTCORE_HARNESS_ID" \
|
||||
--endpoint-name "$PAPERCLIP_AWS_AGENTCORE_ENDPOINT_QUALIFIER" >/dev/null
|
||||
@@ -245,9 +266,11 @@ provision() {
|
||||
--capabilities CAPABILITY_NAMED_IAM \
|
||||
--parameter-overrides \
|
||||
"EnvironmentName=development" "DeploymentMode=$DEPLOYMENT_MODE" \
|
||||
"HarnessEndpointName=$ENDPOINT_NAME" \
|
||||
"TrustedRunnerPrincipalArn=$TRUSTED_PRINCIPAL" "BedrockModelId=$MODEL_ID" \
|
||||
"BedrockModelResourceArn=$model_resource_arn" \
|
||||
"BedrockFoundationModelResourceArn=$foundation_model_resource_arn" \
|
||||
"BedrockMarketplaceProductId=$MARKETPLACE_PRODUCT_ID" \
|
||||
--tags paperclip:owned=true paperclip:environment=development paperclip:cost-center=runner-lab
|
||||
local harness_id harness_arn harness_version runtime_arn memory_id memory_arn role_arn
|
||||
harness_id="$(stack_output HarnessId)"
|
||||
@@ -272,7 +295,7 @@ provision() {
|
||||
wait_for_endpoint_status "$harness_id" "$ENDPOINT_NAME"
|
||||
local endpoint_json endpoint_arn
|
||||
endpoint_json="$(aws_cli bedrock-agentcore-control get-harness-endpoint --harness-id "$harness_id" --endpoint-name "$ENDPOINT_NAME" --output json)"
|
||||
endpoint_arn="$(printf '%s' "$endpoint_json" | node -e 'const fs=require("fs");const o=JSON.parse(fs.readFileSync(0,"utf8"));process.stdout.write(o.harnessEndpointArn||o.endpointArn||o.arn||"")')"
|
||||
endpoint_arn="$(printf '%s' "$endpoint_json" | node -e 'const fs=require("fs");const o=JSON.parse(fs.readFileSync(0,"utf8"));process.stdout.write(o.endpoint?.arn||o.harnessEndpointArn||o.endpointArn||o.arn||"")')"
|
||||
[[ -n "$endpoint_arn" ]] || endpoint_arn="$harness_arn/endpoint/$ENDPOINT_NAME"
|
||||
mkdir -p "$LOCAL_DIR"
|
||||
umask 077
|
||||
@@ -296,6 +319,9 @@ provision() {
|
||||
"PAPERCLIP_AWS_AGENTCORE_MODEL=$MODEL_ID" \
|
||||
"PAPERCLIP_AWS_AGENTCORE_QUALIFICATION_REVISION=aws-agentcore-harness-v1" \
|
||||
"PAPERCLIP_AWS_AGENTCORE_EVENT_EXPIRY_DAYS=90" >"$tmp"
|
||||
if [[ -n "${AWS_CONFIG_FILE:-}" ]]; then
|
||||
printf 'AWS_CONFIG_FILE=%s\n' "$AWS_CONFIG_FILE" >>"$tmp"
|
||||
fi
|
||||
chmod 600 "$tmp"
|
||||
mv "$tmp" "$ENV_FILE"
|
||||
probe
|
||||
|
||||
@@ -106,7 +106,15 @@ async function main() {
|
||||
assert(read.toolResult && typeof read.toolResult === "object", "the mock Paperclip read operation succeeded");
|
||||
|
||||
const serialized = JSON.stringify({ opened, first, second, read });
|
||||
for (const pattern of [/AWS_ACCESS_KEY_ID/i, /AWS_SECRET_ACCESS_KEY/i, /AWS_SESSION_TOKEN/i, /X-Amz-Signature/i, /Authorization/i]) {
|
||||
for (const pattern of [
|
||||
/AWS_ACCESS_KEY_ID/i,
|
||||
/AWS_SECRET_ACCESS_KEY/i,
|
||||
/AWS_SESSION_TOKEN/i,
|
||||
/X-Amz-Signature/i,
|
||||
/AWS4-HMAC-SHA256/i,
|
||||
/"Authorization"\s*:/,
|
||||
/"Proxy-Authorization"\s*:/,
|
||||
]) {
|
||||
assert(!pattern.test(serialized), `browser payload excludes ${pattern}`);
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
|
||||
@@ -16,6 +16,7 @@ const artifactsRoot = resolve(scratchRoot, "artifacts");
|
||||
const publicationRoot = process.env.PAPERCLIP_CLEAN_CONSUMER_OUTPUT_DIR === undefined
|
||||
? undefined
|
||||
: resolve(process.env.PAPERCLIP_CLEAN_CONSUMER_OUTPUT_DIR);
|
||||
const pnpmInvocation = resolvePnpmInvocation();
|
||||
await mkdir(artifactsRoot, { recursive: true });
|
||||
|
||||
try {
|
||||
@@ -92,7 +93,10 @@ try {
|
||||
|
||||
async function pack(packageRoot, destination) {
|
||||
const before = new Set(await readdir(destination));
|
||||
run("pnpm", ["pack", "--pack-destination", destination], packageRoot, { quiet: true });
|
||||
// Every package was already built in the workspace. Ignore package-local
|
||||
// prepack scripts here so installed, patched dependencies can be archived
|
||||
// without depending on their unpublished development toolchains.
|
||||
run("npm", ["pack", "--ignore-scripts", "--pack-destination", destination], packageRoot, { quiet: true });
|
||||
const created = (await readdir(destination))
|
||||
.filter((entry) => entry.endsWith(".tgz") && !before.has(entry))
|
||||
.sort();
|
||||
@@ -103,21 +107,67 @@ async function pack(packageRoot, destination) {
|
||||
}
|
||||
|
||||
async function packRunnerRuntimeDependencies(destination) {
|
||||
const ajvRoot = resolve(runnerRoot, "node_modules/ajv");
|
||||
const ajvModules = dirname(await realpath(ajvRoot));
|
||||
const packageNames = [
|
||||
"ajv",
|
||||
"fast-deep-equal",
|
||||
"fast-uri",
|
||||
"json-schema-traverse",
|
||||
"require-from-string",
|
||||
];
|
||||
const tarballs = {};
|
||||
for (const packageName of packageNames) {
|
||||
const packageRoot = packageName === "ajv" ? ajvRoot : resolve(ajvModules, packageName);
|
||||
tarballs[packageName] = await pack(packageRoot, destination);
|
||||
const runnerManifest = JSON.parse(await readFile(resolve(runnerRoot, "package.json"), "utf8"));
|
||||
const overrides = {};
|
||||
const packed = new Map();
|
||||
const queued = new Set();
|
||||
const queue = [];
|
||||
|
||||
const enqueue = async (packageRoot, overrideKey) => {
|
||||
const concreteRoot = await realpath(packageRoot);
|
||||
const manifest = JSON.parse(await readFile(resolve(concreteRoot, "package.json"), "utf8"));
|
||||
if (typeof manifest.name !== "string" || typeof manifest.version !== "string") {
|
||||
throw new Error(`Runtime dependency at ${concreteRoot} has no exact package identity`);
|
||||
}
|
||||
const identity = `${manifest.name}@${manifest.version}`;
|
||||
let tarball = packed.get(identity);
|
||||
if (tarball === undefined) {
|
||||
tarball = await pack(concreteRoot, destination);
|
||||
packed.set(identity, tarball);
|
||||
}
|
||||
overrides[overrideKey] = tarball;
|
||||
if (!queued.has(identity)) {
|
||||
queued.add(identity);
|
||||
queue.push({ root: concreteRoot, manifest });
|
||||
}
|
||||
};
|
||||
|
||||
for (const packageName of Object.keys(runnerManifest.dependencies ?? {}).sort()) {
|
||||
await enqueue(resolve(runnerRoot, "node_modules", packageName), packageName);
|
||||
}
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
const required = current.manifest.dependencies ?? {};
|
||||
const optional = current.manifest.optionalDependencies ?? {};
|
||||
const peers = current.manifest.peerDependencies ?? {};
|
||||
const optionalPeers = current.manifest.peerDependenciesMeta ?? {};
|
||||
for (const dependencyName of Object.keys({ ...required, ...optional, ...peers }).sort()) {
|
||||
const dependencyRoot = await resolveInstalledDependencyRoot(current.root, dependencyName);
|
||||
if (dependencyRoot === null) {
|
||||
if (dependencyName in optional || optionalPeers[dependencyName]?.optional === true) continue;
|
||||
throw new Error(`${current.manifest.name}@${current.manifest.version} dependency ${dependencyName} is not installed`);
|
||||
}
|
||||
await enqueue(
|
||||
dependencyRoot,
|
||||
`${current.manifest.name}@${current.manifest.version}>${dependencyName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
|
||||
async function resolveInstalledDependencyRoot(packageRoot, dependencyName) {
|
||||
let cursor = packageRoot;
|
||||
while (true) {
|
||||
const candidate = resolve(cursor, "node_modules", dependencyName);
|
||||
try {
|
||||
return await realpath(candidate);
|
||||
} catch {
|
||||
const parent = dirname(cursor);
|
||||
if (parent === cursor) return null;
|
||||
cursor = parent;
|
||||
}
|
||||
}
|
||||
return tarballs;
|
||||
}
|
||||
|
||||
async function stageRunnerdArtifact(destination) {
|
||||
@@ -136,7 +186,7 @@ async function stageRunnerdArtifact(destination) {
|
||||
|
||||
function localOverrides(tarballs) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(tarballs).map(([packageName, tarball]) => [packageName, `file:${tarball}`]),
|
||||
Object.entries(tarballs).map(([selector, tarball]) => [selector, `file:${tarball}`]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -436,10 +486,8 @@ function installAndRun(consumerRoot, extraEnv = {}) {
|
||||
|
||||
function run(command, args, cwd, { quiet = false, env = {} } = {}) {
|
||||
const usesPnpm = command === "pnpm";
|
||||
const executable = usesPnpm
|
||||
? (process.platform === "win32" ? "corepack.cmd" : "corepack")
|
||||
: command;
|
||||
const effectiveArgs = usesPnpm ? ["pnpm@9.15.4", ...args] : args;
|
||||
const executable = usesPnpm ? pnpmInvocation.executable : command;
|
||||
const effectiveArgs = usesPnpm ? [...pnpmInvocation.prefixArgs, ...args] : args;
|
||||
const result = spawnSync(executable, effectiveArgs, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
@@ -458,6 +506,29 @@ function run(command, args, cwd, { quiet = false, env = {} } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePnpmInvocation() {
|
||||
const corepack = process.platform === "win32" ? "corepack.cmd" : "corepack";
|
||||
const corepackProbe = spawnSync(corepack, ["pnpm@9.15.4", "--version"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (corepackProbe.status === 0 && corepackProbe.stdout.trim() === "9.15.4") {
|
||||
return { executable: corepack, prefixArgs: ["pnpm@9.15.4"] };
|
||||
}
|
||||
|
||||
const direct = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
const directProbe = spawnSync(direct, ["--version"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (directProbe.status === 0 && directProbe.stdout.trim() === "9.15.4") {
|
||||
return { executable: direct, prefixArgs: [] };
|
||||
}
|
||||
throw new Error(
|
||||
"Clean-consumer verification requires pnpm 9.15.4 via corepack or the active PATH",
|
||||
);
|
||||
}
|
||||
|
||||
function capture(command, args, cwd) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
|
||||
@@ -2205,8 +2205,10 @@ Do not depend on its experimental network WebSocket as the Paperclip WAN transpo
|
||||
3. Send protocol initialization.
|
||||
4. Confirm server capabilities/version.
|
||||
5. Register client-defined semantic tools if configured.
|
||||
6. Disable automatic skill, app, collaboration, plugin, memory, and
|
||||
multi-agent instruction injection in thread config.
|
||||
6. Disable automatic skill, app, plugin, memory, and multi-agent instruction
|
||||
injection in thread config. Enable Codex's built-in collaboration
|
||||
instructions by default; permit an explicit eval-only opt-out without
|
||||
changing the production default.
|
||||
7. Create or resume thread.
|
||||
8. emit `session.started` or `session.resumed`.
|
||||
9. Persist thread/session parameters in the normalized session binding.
|
||||
|
||||
@@ -3,8 +3,12 @@ import { createHash } from "node:crypto";
|
||||
import { createInterface } from "node:readline";
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
import type { AcpPermissionDecision, AcpRuntimeEvent } from "acpx/runtime";
|
||||
import type { AcpPermissionDecision, AcpRuntimeEvent, AcpRuntimeStatus } from "acpx/runtime";
|
||||
|
||||
import {
|
||||
CODEX_BLOCK_TOOL_NAME,
|
||||
CODEX_COMPLETION_TOOL_NAME,
|
||||
} from "../contracts/codex.js";
|
||||
import { AcpxRuntimeHost } from "../drivers/acpx/acpx-runtime-host.js";
|
||||
import { resolveQualifiedAcpxProfile, type QualifiedAcpxAgent } from "../drivers/acpx/qualified-profiles.js";
|
||||
import {
|
||||
@@ -19,6 +23,7 @@ import {
|
||||
type AcpxSidecarRequest,
|
||||
type AcpxSidecarResponse,
|
||||
} from "../drivers/acpx/sidecar-protocol.js";
|
||||
import { validatePrpStructuredRunResult } from "../protocol/replay-contract.js";
|
||||
|
||||
interface PendingResolution<T> {
|
||||
turnId: string;
|
||||
@@ -89,7 +94,12 @@ async function dispatch(request: AcpxSidecarRequest): Promise<Record<string, unk
|
||||
host = await openRuntime(params);
|
||||
const identity = host.identity();
|
||||
emit("runtime.process", { role: "sidecar", pid: process.pid, processGroupId: null, startedAt: new Date().toISOString() });
|
||||
return { identity, sidecarPid: process.pid, status: await host.status() };
|
||||
return {
|
||||
identity,
|
||||
sidecarPid: process.pid,
|
||||
agentProcess: host.processIdentity(),
|
||||
status: sanitizeRuntimeStatus(await host.status()),
|
||||
};
|
||||
}
|
||||
if (request.command === "run.attach") {
|
||||
requireHost();
|
||||
@@ -150,10 +160,10 @@ async function dispatch(request: AcpxSidecarRequest): Promise<Record<string, unk
|
||||
return { resolved: true };
|
||||
}
|
||||
if (request.command === "session.read") {
|
||||
return { identity: requireHost().identity(), status: await requireHost().status() };
|
||||
return { identity: requireHost().identity(), status: sanitizeRuntimeStatus(await requireHost().status()) };
|
||||
}
|
||||
if (request.command === "session.snapshot") {
|
||||
return { identity: requireHost().identity(), status: await requireHost().status(), runId, turnId, sequence };
|
||||
return { identity: requireHost().identity(), status: sanitizeRuntimeStatus(await requireHost().status()), runId, turnId, sequence };
|
||||
}
|
||||
if (request.command === "session.suspend") {
|
||||
if (turnId) throw new Error("ACPX session is not at a safe suspension point");
|
||||
@@ -182,6 +192,19 @@ async function openRuntime(params: AcpxSidecarOpenParams): Promise<AcpxRuntimeHo
|
||||
role: "acp_agent",
|
||||
...identity,
|
||||
}),
|
||||
onUsage: (usage) => {
|
||||
if (!turnId) return;
|
||||
const { cost, ...breakdown } = usage;
|
||||
emit("runtime.event", {
|
||||
type: "status",
|
||||
text: "Usage updated.",
|
||||
tag: "usage_update",
|
||||
used: usage.totalTokens ?? null,
|
||||
size: null,
|
||||
cost: cost ?? null,
|
||||
breakdown,
|
||||
});
|
||||
},
|
||||
onDiagnostic: (message) => diagnostic("acp_agent_stderr", message),
|
||||
});
|
||||
}
|
||||
@@ -201,6 +224,21 @@ async function pumpTurn(currentTurnId: string, runtimeTurn: ReturnType<AcpxRunti
|
||||
|
||||
function waitForTool(call: { tool: string; callId: string; arguments: unknown }): Promise<unknown> {
|
||||
if (!turnId || tools.has(call.callId)) throw new Error("ACPX tool call is unbound or duplicated");
|
||||
if (call.tool === CODEX_COMPLETION_TOOL_NAME || call.tool === CODEX_BLOCK_TOOL_NAME) {
|
||||
const validation = validatePrpStructuredRunResult(call.arguments);
|
||||
if (!validation.ok) throw new Error("ACPX semantic result failed PRP schema validation");
|
||||
if (
|
||||
(call.tool === CODEX_BLOCK_TOOL_NAME && validation.result.reportedWorkDisposition !== "blocked")
|
||||
|| (call.tool === CODEX_COMPLETION_TOOL_NAME && validation.result.reportedWorkDisposition === "blocked")
|
||||
) throw new Error("ACPX semantic result disposition does not match its terminal operation");
|
||||
emit("runtime.event", {
|
||||
type: "semantic_result",
|
||||
callId: call.callId,
|
||||
operationId: call.tool,
|
||||
result: validation.result,
|
||||
});
|
||||
return Promise.resolve({ accepted: true });
|
||||
}
|
||||
emit("runtime.tool_called", {
|
||||
callId: call.callId,
|
||||
operationId: call.tool,
|
||||
@@ -276,6 +314,40 @@ function sanitizeRuntimeEvent(event: AcpRuntimeEvent): Record<string, unknown> {
|
||||
return { type: "provider_notice", category: "unclassified_acp_unknown", summary: "The qualified ACP agent emitted an unclassified runtime update." };
|
||||
}
|
||||
|
||||
function sanitizeRuntimeStatus(status: AcpRuntimeStatus): Record<string, unknown> {
|
||||
const cumulative = status.usage?.cumulative;
|
||||
const cost = status.usage?.cost;
|
||||
return boundedSidecarValue({
|
||||
summary: status.summary ? safeText(status.summary).slice(0, 4000) : null,
|
||||
acpxRecordId: text(status.acpxRecordId).slice(0, 240) || null,
|
||||
backendSessionId: text(status.backendSessionId).slice(0, 240) || null,
|
||||
agentSessionId: text(status.agentSessionId).slice(0, 240) || null,
|
||||
models: {
|
||||
currentModelId: text(status.models?.currentModelId).slice(0, 240) || null,
|
||||
availableModelCount: Math.min(status.models?.availableModelIds.length ?? 0, 100_000),
|
||||
},
|
||||
usage: {
|
||||
cumulative: cumulative ? {
|
||||
inputTokens: safeNonNegativeNumber(cumulative.inputTokens),
|
||||
outputTokens: safeNonNegativeNumber(cumulative.outputTokens),
|
||||
cachedReadTokens: safeNonNegativeNumber(cumulative.cachedReadTokens),
|
||||
cachedWriteTokens: safeNonNegativeNumber(cumulative.cachedWriteTokens),
|
||||
thoughtTokens: safeNonNegativeNumber(cumulative.thoughtTokens),
|
||||
totalTokens: safeNonNegativeNumber(cumulative.totalTokens),
|
||||
} : null,
|
||||
cost: cost ? {
|
||||
amount: safeNonNegativeNumber(cost.amount),
|
||||
currency: text(cost.currency).slice(0, 16) || null,
|
||||
} : null,
|
||||
requestCount: Math.min(Object.keys(status.usage?.perRequest ?? {}).length, 100_000),
|
||||
},
|
||||
}, 32 * 1024);
|
||||
}
|
||||
|
||||
function safeNonNegativeNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
function safeOutput(value: unknown): Record<string, unknown> {
|
||||
if (value === undefined || value === null) return { output: null, outputBytes: 0, outputTruncated: false, outputDigest: null };
|
||||
const raw = typeof value === "string" ? value : JSON.stringify(value);
|
||||
|
||||
@@ -25,10 +25,18 @@ interface EvalSessionRequest {
|
||||
attemptId: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
provider?: "codex" | "opencode" | "aws_agentcore" | "acpx";
|
||||
driver?: "codex_app_server" | "opencode_server" | "aws_agentcore_harness_api" | "acpx_runtime";
|
||||
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx";
|
||||
driver?: "codex_app_server" | "opencode_server" | "claude_managed_agents_api" | "aws_agentcore_harness_api" | "acpx_runtime";
|
||||
opencodeVersion?: string;
|
||||
acpxAgent?: QualifiedAcpxAgent;
|
||||
managedProfile?: {
|
||||
profileId: string;
|
||||
anthropicAgentId: string;
|
||||
agentVersion: string;
|
||||
environmentId: string;
|
||||
betaVersion: "managed-agents-2026-04-01";
|
||||
maxSessionListCostUsd: number;
|
||||
};
|
||||
agentCoreProfile?: {
|
||||
profileId: string;
|
||||
region: string;
|
||||
@@ -51,6 +59,8 @@ interface EvalSessionRequest {
|
||||
runnerd: { path: string; sha256: string };
|
||||
limits: { turnTimeoutMs: number; maxAgentTurns: number; maxEstimatedCostNanodollars: number };
|
||||
session: CreateCapabilityLiveSessionInput;
|
||||
/** Codex collaboration/tool-preamble instructions are enabled unless explicitly false. */
|
||||
includeCollaborationModeInstructions?: boolean;
|
||||
}
|
||||
|
||||
function argument(name: string): string {
|
||||
@@ -78,6 +88,7 @@ const actualDigest = await sha256(runnerdPath);
|
||||
|
||||
function infrastructureCategory(failureClass: string): string {
|
||||
if (failureClass === "provider_turn_timeout") return "provider_lifecycle";
|
||||
if (failureClass === "provider_budget_reached") return "provider_budget";
|
||||
if (failureClass === "runner_shutdown_timeout") return "runner_lifecycle";
|
||||
if (failureClass === "runner_exit_failure") return "runner_infrastructure";
|
||||
return "eval_orchestration";
|
||||
@@ -86,6 +97,12 @@ if (actualDigest !== request.runnerd.sha256.replace(/^sha256:/, "")) {
|
||||
throw new Error(`runnerd digest mismatch: expected ${request.runnerd.sha256}, got sha256:${actualDigest}`);
|
||||
}
|
||||
|
||||
const requestedProvider = request.provider ?? "codex";
|
||||
const requestedDriver = request.driver ?? (request.provider === "opencode" ? "opencode_server" : request.provider === "claude_managed" ? "claude_managed_agents_api" : request.provider === "aws_agentcore" ? "aws_agentcore_harness_api" : request.provider === "acpx" ? "acpx_runtime" : "codex_app_server");
|
||||
const requestedProviderVersion = request.provider === "opencode" ? request.opencodeVersion ?? "1.18.17" : request.provider === "claude_managed" ? request.managedProfile?.agentVersion ?? null : request.provider === "aws_agentcore" ? request.agentCoreProfile?.harnessVersion ?? null : request.provider === "acpx" ? acpxProfile!.acpxVersion : null;
|
||||
let retainedProviderSessionId: string | null = null;
|
||||
let failedUsage: Record<string, unknown> | null = null;
|
||||
|
||||
try {
|
||||
const execution = await runDurableEvalSession({
|
||||
attemptId: request.attemptId,
|
||||
@@ -94,6 +111,7 @@ try {
|
||||
provider: request.provider ?? "codex",
|
||||
acpxAgent: request.acpxAgent,
|
||||
opencodeVersion: request.opencodeVersion,
|
||||
managedProfile: request.managedProfile,
|
||||
agentCoreProfile: request.agentCoreProfile,
|
||||
runnerBinaryPath: runnerdPath,
|
||||
seed: request.session.seed ?? {},
|
||||
@@ -103,10 +121,13 @@ try {
|
||||
explicitClaims: request.session.explicitClaims ?? [],
|
||||
turnTimeoutMs: request.limits.turnTimeoutMs,
|
||||
toolExposure: request.session.toolExposure,
|
||||
includeCollaborationModeInstructions:
|
||||
request.includeCollaborationModeInstructions,
|
||||
});
|
||||
const turn = execution.turn as Record<string, unknown>;
|
||||
const snapshot = execution.snapshot as Parameters<typeof reconcileCapabilityLiveUsage>[0];
|
||||
const reconciled = reconcileCapabilityLiveUsage(snapshot);
|
||||
retainedProviderSessionId = typeof snapshot.providerSessionId === "string" ? snapshot.providerSessionId : null;
|
||||
if ((snapshot.usageLedger ?? []).length === 0) throw new Error("completed turn omitted usage accounting");
|
||||
const estimate = estimateModelCostNanodollars(request.model, reconciled);
|
||||
const usage = {
|
||||
@@ -116,19 +137,24 @@ try {
|
||||
outputTokens: reconciled.outputTokens,
|
||||
cachedInputTokens: reconciled.cachedInputTokens,
|
||||
reasoningTokens: reconciled.reasoningTokens,
|
||||
providerReportedCostNanodollars: reconciled.costNanodollars,
|
||||
...estimate,
|
||||
};
|
||||
failedUsage = usage;
|
||||
if (usage.agentTurns > request.limits.maxAgentTurns) throw new Error("agent turn limit exceeded");
|
||||
if (usage.estimatedCostNanodollars > request.limits.maxEstimatedCostNanodollars) throw new Error("estimated cost limit exceeded");
|
||||
if (usage.providerReportedCostNanodollars > request.limits.maxEstimatedCostNanodollars) throw new Error("provider-reported cost limit exceeded");
|
||||
await writeFile(outputPath, `${JSON.stringify({
|
||||
schema: "paperclip-runner/eval-session-artifact/v1",
|
||||
attemptId: request.attemptId,
|
||||
build: PAPERCLIP_RUNNER_BUILD_METADATA,
|
||||
runnerd: { path: "[withheld]", sha256: `sha256:${actualDigest}` },
|
||||
requestedModel: request.model,
|
||||
provider: request.provider ?? "codex",
|
||||
driver: request.driver ?? (request.provider === "opencode" ? "opencode_server" : request.provider === "aws_agentcore" ? "aws_agentcore_harness_api" : request.provider === "acpx" ? "acpx_runtime" : "codex_app_server"),
|
||||
providerVersion: request.provider === "opencode" ? request.opencodeVersion ?? "1.18.17" : request.provider === "aws_agentcore" ? request.agentCoreProfile?.harnessVersion ?? null : request.provider === "acpx" ? acpxProfile!.acpxVersion : null,
|
||||
provider: requestedProvider,
|
||||
driver: requestedDriver,
|
||||
providerVersion: requestedProviderVersion,
|
||||
providerSessionId: retainedProviderSessionId,
|
||||
...(request.provider === "claude_managed" ? { managedProfile: request.managedProfile, retainedSession: retainedProviderSessionId !== null, retainedSessionStatus: retainedProviderSessionId === null ? "unknown" : "retained" } : {}),
|
||||
...(acpxProfile === null ? {} : { acpxAgent: acpxProfile.agent, acpxProfile }),
|
||||
turn,
|
||||
snapshot,
|
||||
@@ -151,6 +177,38 @@ try {
|
||||
retryable: false,
|
||||
diagnostics: {},
|
||||
};
|
||||
const failureDiagnostics = infrastructureFailure.diagnostics as Record<string, unknown>;
|
||||
const diagnosticSessionId = typeof failureDiagnostics.providerSessionId === "string"
|
||||
? failureDiagnostics.providerSessionId
|
||||
: null;
|
||||
retainedProviderSessionId ??= diagnosticSessionId;
|
||||
const failureRunDelta = failureDiagnostics.usageRunDelta;
|
||||
if (failedUsage === null && typeof failureRunDelta === "object" && failureRunDelta !== null && !Array.isArray(failureRunDelta)) {
|
||||
const runDelta = failureRunDelta as Record<string, unknown>;
|
||||
const nonNegativeNumber = (name: string): number => {
|
||||
const value = runDelta[name];
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
};
|
||||
const receipt = {
|
||||
providerCalls: 1,
|
||||
providerRequests: nonNegativeNumber("requests"),
|
||||
inputTokens: nonNegativeNumber("inputTokens"),
|
||||
outputTokens: nonNegativeNumber("outputTokens"),
|
||||
cachedInputTokens: nonNegativeNumber("cacheReadTokens"),
|
||||
reasoningTokens: nonNegativeNumber("reasoningTokens"),
|
||||
costNanodollars: Math.round(nonNegativeNumber("providerCostUsd") * 1_000_000_000),
|
||||
};
|
||||
failedUsage = {
|
||||
agentTurns: receipt.providerCalls,
|
||||
providerRequests: receipt.providerRequests,
|
||||
inputTokens: receipt.inputTokens,
|
||||
outputTokens: receipt.outputTokens,
|
||||
cachedInputTokens: receipt.cachedInputTokens,
|
||||
reasoningTokens: receipt.reasoningTokens,
|
||||
providerReportedCostNanodollars: receipt.costNanodollars,
|
||||
...estimateModelCostNanodollars(request.model, receipt),
|
||||
};
|
||||
}
|
||||
await writeFile(outputPath, `${JSON.stringify({
|
||||
schema: "paperclip-runner/eval-session-artifact/v1",
|
||||
attemptId: request.attemptId,
|
||||
@@ -159,9 +217,12 @@ try {
|
||||
build: PAPERCLIP_RUNNER_BUILD_METADATA,
|
||||
runnerd: { path: "[withheld]", sha256: `sha256:${actualDigest}` },
|
||||
requestedModel: request.model,
|
||||
provider: request.provider ?? "codex",
|
||||
driver: request.driver ?? (request.provider === "opencode" ? "opencode_server" : request.provider === "aws_agentcore" ? "aws_agentcore_harness_api" : request.provider === "acpx" ? "acpx_runtime" : "codex_app_server"),
|
||||
providerVersion: request.provider === "opencode" ? request.opencodeVersion ?? "1.18.17" : request.provider === "aws_agentcore" ? request.agentCoreProfile?.harnessVersion ?? null : request.provider === "acpx" ? acpxProfile!.acpxVersion : null,
|
||||
provider: requestedProvider,
|
||||
driver: requestedDriver,
|
||||
providerVersion: requestedProviderVersion,
|
||||
providerSessionId: retainedProviderSessionId,
|
||||
...(failedUsage === null ? {} : { usage: failedUsage }),
|
||||
...(request.provider === "claude_managed" ? { managedProfile: request.managedProfile, retainedSession: retainedProviderSessionId !== null, retainedSessionStatus: retainedProviderSessionId === null ? "unknown" : "retained" } : {}),
|
||||
...(acpxProfile === null ? {} : { acpxAgent: acpxProfile.agent, acpxProfile }),
|
||||
timing: { startedAt: evalStartedAt, finishedAt: new Date().toISOString(), durationMs: Date.now() - evalStartedAtMs },
|
||||
}, null, 2)}\n`);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Provider-neutral debug channel. It is deliberately separate from PRP event
|
||||
* delivery: its sequence and acknowledgement cannot influence run authority,
|
||||
* replay cursors, or terminal completion.
|
||||
*/
|
||||
export type ProviderTraceRecord =
|
||||
| ({ kind: "frame" } & Record<string, unknown>)
|
||||
| ({ kind: "interpretation" } & Record<string, unknown>)
|
||||
| ({ kind: "trace_status" } & Record<string, unknown>);
|
||||
|
||||
export interface ProviderTraceBatch {
|
||||
traceId: string;
|
||||
debugSequence: number;
|
||||
records: ProviderTraceRecord[];
|
||||
}
|
||||
|
||||
export interface ProviderTraceAck {
|
||||
traceId: string;
|
||||
acknowledgedDebugSequence: number;
|
||||
}
|
||||
|
||||
export interface ProviderTraceSink {
|
||||
appendBatch(batch: ProviderTraceBatch): Promise<ProviderTraceAck>;
|
||||
finish(input: {
|
||||
traceId: string;
|
||||
status: "complete" | "incomplete" | "truncated";
|
||||
reason?: string | null;
|
||||
}): Promise<void>;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,7 @@
|
||||
import type { CapabilityJsonValue, CapabilityFixtureState } from "../mock-core/capability-control-plane-types.js";
|
||||
import type {
|
||||
CapabilityJsonValue,
|
||||
CapabilityFixtureState,
|
||||
} from "../mock-core/capability-control-plane-types.js";
|
||||
import type { CapabilityLiveSessionSnapshot } from "../live/live-session.js";
|
||||
|
||||
export interface CapabilityDevtoolsRevision {
|
||||
@@ -18,6 +21,13 @@ export interface CapabilityDevtoolsProtocolRecord {
|
||||
detail: CapabilityJsonValue;
|
||||
}
|
||||
|
||||
export interface CapabilityDevtoolsProviderTrace {
|
||||
status: "capturing" | "complete" | "incomplete" | "truncated";
|
||||
expiresAt: string;
|
||||
frames: CapabilityJsonValue[];
|
||||
interpretations: CapabilityJsonValue[];
|
||||
}
|
||||
|
||||
export interface CapabilityDevtoolsSnapshot {
|
||||
schema: "paperclip.capability.devtools.v1";
|
||||
currentRevision: number;
|
||||
@@ -25,25 +35,36 @@ export interface CapabilityDevtoolsSnapshot {
|
||||
protocol: CapabilityDevtoolsProtocolRecord[];
|
||||
runtime: CapabilityJsonValue;
|
||||
authority: CapabilityJsonValue;
|
||||
/** Present only for an explicitly traced run; never populated by ordinary snapshots. */
|
||||
providerTrace?: CapabilityDevtoolsProviderTrace | null;
|
||||
}
|
||||
|
||||
const SECRET = /(bearer\s+[a-z0-9._-]+|(?:^|[^a-z0-9])(?:sk|pcp)_[a-z0-9._-]{8,}|api[_-]?key\s*[:=]|PAPERCLIP_API_KEY|OPENAI_API_KEY)/i;
|
||||
const SECRET_KEY = /(^|[_-])(authorization|api[_-]?key|token|secret|password)($|[_-])|^(apiKey|accessToken|refreshToken)$/i;
|
||||
const SECRET =
|
||||
/(bearer\s+[a-z0-9._-]+|(?:^|[^a-z0-9])(?:sk|pcp)_[a-z0-9._-]{8,}|api[_-]?key\s*[:=]|PAPERCLIP_API_KEY|OPENAI_API_KEY)/i;
|
||||
const SECRET_KEY =
|
||||
/(^|[_-])(authorization|api[_-]?key|token|secret|password)($|[_-])|^(apiKey|accessToken|refreshToken)$/i;
|
||||
|
||||
function safe(value: unknown, key = ""): CapabilityJsonValue {
|
||||
if (SECRET_KEY.test(key)) return "[redacted]";
|
||||
if (value === null || typeof value === "boolean" || typeof value === "number") return value;
|
||||
if (typeof value === "string") return SECRET.test(value) ? "[redacted]" : value;
|
||||
if (value === null || typeof value === "boolean" || typeof value === "number")
|
||||
return value;
|
||||
if (typeof value === "string")
|
||||
return SECRET.test(value) ? "[redacted]" : value;
|
||||
if (Array.isArray(value)) return value.map((entry) => safe(entry));
|
||||
if (typeof value !== "object") return String(value);
|
||||
return Object.fromEntries(Object.entries(value).map(([entryKey, entry]) => [entryKey, safe(entry, entryKey)]));
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([entryKey, entry]) => [
|
||||
entryKey,
|
||||
safe(entry, entryKey),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function parseState(serialized: string): CapabilityFixtureState {
|
||||
return JSON.parse(serialized) as CapabilityFixtureState;
|
||||
}
|
||||
|
||||
/** Field-by-field browser projection. Provider payloads and checkpoints never cross this boundary. */
|
||||
/** Field-by-field browser projection. Raw provider payloads cross only through the opt-in trace channel. */
|
||||
function publicCompanyState(serialized: string): CapabilityJsonValue {
|
||||
const state = parseState(serialized);
|
||||
return safe({
|
||||
@@ -61,10 +82,12 @@ function publicCompanyState(serialized: string): CapabilityJsonValue {
|
||||
documents: state.documents,
|
||||
interactions: state.interactions,
|
||||
approvals: state.approvals,
|
||||
artifacts: state.artifacts.map(({ contentRef: _contentRef, ...artifact }) => ({
|
||||
...artifact,
|
||||
contentRef: "[withheld]",
|
||||
})),
|
||||
artifacts: state.artifacts.map(
|
||||
({ contentRef: _contentRef, ...artifact }) => ({
|
||||
...artifact,
|
||||
contentRef: "[withheld]",
|
||||
}),
|
||||
),
|
||||
workProducts: state.workProducts,
|
||||
blockers: state.blockers,
|
||||
workspaceServices: state.workspaceServices,
|
||||
@@ -90,37 +113,52 @@ function publicCompanyState(serialized: string): CapabilityJsonValue {
|
||||
wakes: state.wakes,
|
||||
audit: state.audit,
|
||||
decisions: state.decisions,
|
||||
idempotency: state.idempotency.map(({ inputCanonical: _inputCanonical, ...record }) => record),
|
||||
idempotency: state.idempotency.map(
|
||||
({ inputCanonical: _inputCanonical, ...record }) => record,
|
||||
),
|
||||
faults: state.faults,
|
||||
});
|
||||
}
|
||||
|
||||
function boundary(kind: string): string {
|
||||
if (kind === "tool_call" || kind === "tool_result" || kind === "tool_discovery") return "semantic tool";
|
||||
if (
|
||||
kind === "tool_call" ||
|
||||
kind === "tool_result" ||
|
||||
kind === "tool_discovery"
|
||||
)
|
||||
return "semantic tool";
|
||||
if (kind === "provider_event") return "provider harness";
|
||||
if (kind === "process") return "runnerd process";
|
||||
if (kind === "interaction") return "mock control plane";
|
||||
return "live session";
|
||||
}
|
||||
|
||||
export function projectCapabilityDevtools(snapshot: CapabilityLiveSessionSnapshot): CapabilityDevtoolsSnapshot {
|
||||
const retained = snapshot.stateHistory ?? [{
|
||||
revision: parseState(snapshot.mockState).revision,
|
||||
at: snapshot.updatedAt,
|
||||
turnId: null,
|
||||
operationId: "session.current",
|
||||
state: snapshot.mockState,
|
||||
}];
|
||||
export function projectCapabilityDevtools(
|
||||
snapshot: CapabilityLiveSessionSnapshot,
|
||||
): CapabilityDevtoolsSnapshot {
|
||||
const retained = snapshot.stateHistory ?? [
|
||||
{
|
||||
revision: parseState(snapshot.mockState).revision,
|
||||
at: snapshot.updatedAt,
|
||||
turnId: null,
|
||||
operationId: "session.current",
|
||||
state: snapshot.mockState,
|
||||
},
|
||||
];
|
||||
const seed = parseState(snapshot.config.seedState);
|
||||
const history = retained[0]?.revision === seed.revision
|
||||
? retained
|
||||
: [{
|
||||
revision: seed.revision,
|
||||
at: snapshot.createdAt,
|
||||
turnId: null,
|
||||
operationId: "fixture.seed",
|
||||
state: snapshot.config.seedState,
|
||||
}, ...retained];
|
||||
const history =
|
||||
retained[0]?.revision === seed.revision
|
||||
? retained
|
||||
: [
|
||||
{
|
||||
revision: seed.revision,
|
||||
at: snapshot.createdAt,
|
||||
turnId: null,
|
||||
operationId: "fixture.seed",
|
||||
state: snapshot.config.seedState,
|
||||
},
|
||||
...retained,
|
||||
];
|
||||
return {
|
||||
schema: "paperclip.capability.devtools.v1",
|
||||
currentRevision: parseState(snapshot.mockState).revision,
|
||||
@@ -144,15 +182,18 @@ export function projectCapabilityDevtools(snapshot: CapabilityLiveSessionSnapsho
|
||||
activeTurnId: snapshot.activeTurnId,
|
||||
providerSessionId: snapshot.providerSessionId,
|
||||
providerModel: snapshot.providerModel ?? null,
|
||||
process: snapshot.process === null ? null : {
|
||||
runnerPid: snapshot.process.runnerPid,
|
||||
runnerProcessGroupId: snapshot.process.runnerProcessGroupId,
|
||||
codexPid: snapshot.process.codexPid,
|
||||
runnerExited: snapshot.process.runnerExited,
|
||||
runnerExitCode: snapshot.process.runnerExitCode,
|
||||
runnerSignal: snapshot.process.runnerSignal,
|
||||
childEnvironmentKeys: snapshot.process.childEnvironmentKeys,
|
||||
},
|
||||
process:
|
||||
snapshot.process === null
|
||||
? null
|
||||
: {
|
||||
runnerPid: snapshot.process.runnerPid,
|
||||
runnerProcessGroupId: snapshot.process.runnerProcessGroupId,
|
||||
codexPid: snapshot.process.codexPid,
|
||||
runnerExited: snapshot.process.runnerExited,
|
||||
runnerExitCode: snapshot.process.runnerExitCode,
|
||||
runnerSignal: snapshot.process.runnerSignal,
|
||||
childEnvironmentKeys: snapshot.process.childEnvironmentKeys,
|
||||
},
|
||||
network: snapshot.networkEvidence,
|
||||
attempts: snapshot.attempts ?? [],
|
||||
currentAttemptId: snapshot.currentAttemptId ?? null,
|
||||
@@ -172,5 +213,6 @@ export function projectCapabilityDevtools(snapshot: CapabilityLiveSessionSnapsho
|
||||
toolExposure: snapshot.config.toolExposure ?? "eager",
|
||||
workingDirectory: "[withheld]",
|
||||
}),
|
||||
providerTrace: null,
|
||||
};
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
type AcpxRuntimeFactory,
|
||||
type AcpxRuntimeIdentity,
|
||||
type AcpxRuntimePermissionContext,
|
||||
type AcpxRuntimeUsage,
|
||||
} from "./acpx-runtime-host.js";
|
||||
import {
|
||||
ACPX_DRIVER_KIND,
|
||||
@@ -183,6 +184,7 @@ export class AcpxRuntimeDriver implements HarnessDriver {
|
||||
},
|
||||
runtimeFactory: this.#options.runtimeFactory,
|
||||
onSpawn: this.#options.onSpawn,
|
||||
onUsage: (usage) => session?.acceptUsage(usage),
|
||||
onDiagnostic: this.#options.onDiagnostic,
|
||||
});
|
||||
try {
|
||||
@@ -443,6 +445,22 @@ class AcpxHarnessSession implements HarnessSession {
|
||||
}
|
||||
}
|
||||
|
||||
acceptUsage(usage: AcpxRuntimeUsage): void {
|
||||
const turnId = this.#activeTurnId;
|
||||
if (!turnId) return;
|
||||
this.#captureUsage({
|
||||
cumulative: {
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cachedReadTokens: usage.cachedReadTokens,
|
||||
cachedWriteTokens: usage.cachedWriteTokens,
|
||||
thoughtTokens: usage.thoughtTokens,
|
||||
totalTokens: usage.totalTokens,
|
||||
},
|
||||
cost: usage.cost,
|
||||
}, turnId, `${turnId}:usage-live:${this.#sourceSequence + 1}`);
|
||||
}
|
||||
|
||||
async read(): Promise<Record<string, unknown>> {
|
||||
return { identity: this.#host.identity(), status: await this.#host.status() };
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ describe("AcpxRuntimeHost", () => {
|
||||
await mkdir(workspace);
|
||||
let captured: AcpRuntimeOptions | null = null;
|
||||
let ensureInput: Parameters<AcpRuntime["ensureSession"]>[0] | null = null;
|
||||
const usages: unknown[] = [];
|
||||
const handle: AcpRuntimeHandle = {
|
||||
sessionKey: "session-key",
|
||||
backend: "acpx",
|
||||
@@ -77,6 +78,7 @@ describe("AcpxRuntimeHost", () => {
|
||||
DATABASE_URL: "database-must-not-pass",
|
||||
},
|
||||
dynamicToolHandler: async () => ({ ok: true }),
|
||||
onUsage: (usage) => usages.push(usage),
|
||||
runtimeFactory,
|
||||
});
|
||||
try {
|
||||
@@ -108,6 +110,14 @@ describe("AcpxRuntimeHost", () => {
|
||||
expect(JSON.stringify(childEnvironment)).not.toContain("database-must-not-pass");
|
||||
expect(JSON.stringify(ensureInput)).not.toContain("openrouter-canary-secret");
|
||||
expect(JSON.stringify(ensureInput)).not.toContain("paperclip-must-not-pass");
|
||||
options.onAgentStderr?.("[paperclip-pi-usage-v1]{\"inputTokens\":12,\"outputTokens\":3,");
|
||||
options.onAgentStderr?.("\"totalTokens\":15,\"cost\":{\"amount\":0.001,\"currency\":\"USD\"}}\n");
|
||||
expect(usages).toEqual([{
|
||||
inputTokens: 12,
|
||||
outputTokens: 3,
|
||||
totalTokens: 15,
|
||||
cost: { amount: 0.001, currency: "USD" },
|
||||
}]);
|
||||
expect((await stat(host.runtimeRoot())).mode & 0o777).toBe(0o700);
|
||||
const settingsPath = join(host.runtimeRoot(), "pi-home", "settings.json");
|
||||
expect(JSON.parse(await readFile(settingsPath, "utf8"))).toMatchObject({
|
||||
@@ -132,6 +142,218 @@ describe("AcpxRuntimeHost", () => {
|
||||
})).rejects.toThrow("requires exact model");
|
||||
});
|
||||
|
||||
it("verifies Claude's exact canonical model through its ACP selector", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-claude-model-"));
|
||||
const workspace = join(root, "workspace");
|
||||
await mkdir(workspace);
|
||||
const setCalls: Array<{ key: string; value: unknown }> = [];
|
||||
let capturedOptions: AcpRuntimeOptions | null = null;
|
||||
let externalPermissionRequests = 0;
|
||||
const host = await AcpxRuntimeHost.open({
|
||||
runtimeDirectory: root,
|
||||
normalizedSessionId: "claude-model-session",
|
||||
workingDirectory: workspace,
|
||||
agent: "claude",
|
||||
model: "claude-sonnet-5",
|
||||
environment: { PATH: process.env.PATH },
|
||||
dynamicToolHandler: async () => ({}),
|
||||
onPermissionRequest: async () => {
|
||||
externalPermissionRequests += 1;
|
||||
return { outcome: "reject_once" };
|
||||
},
|
||||
runtimeFactory: (options) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
ensureSession: async () => ({
|
||||
sessionKey: "claude-session-key",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "claude-runtime",
|
||||
acpxRecordId: "claude-record",
|
||||
backendSessionId: "claude-backend",
|
||||
agentSessionId: "claude-agent",
|
||||
}),
|
||||
startTurn: (input) => completedTurn(input.requestId),
|
||||
runTurn: async function* () {},
|
||||
getStatus: async () => ({
|
||||
acpxRecordId: "claude-record",
|
||||
backendSessionId: "claude-backend",
|
||||
agentSessionId: "claude-agent",
|
||||
models: { currentModelId: "sonnet", availableModelIds: ["default", "sonnet", "opus"] },
|
||||
}),
|
||||
setConfigOption: async (input) => { setCalls.push({ key: input.key, value: input.value }); },
|
||||
cancel: async () => {},
|
||||
close: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(setCalls).toEqual([{ key: "model", value: "claude-sonnet-5" }]);
|
||||
expect(host.identity()).toMatchObject({
|
||||
requestedModel: "claude-sonnet-5",
|
||||
effectiveModel: "claude-sonnet-5",
|
||||
});
|
||||
expect((await host.status()).models).toMatchObject({
|
||||
currentModelId: "claude-sonnet-5",
|
||||
availableModelIds: ["default", "claude-sonnet-5", "opus"],
|
||||
});
|
||||
const permissionHandler = (capturedOptions as AcpRuntimeOptions).onPermissionRequest!;
|
||||
await expect(permissionHandler({
|
||||
sessionId: "claude-agent",
|
||||
inferredKind: "other",
|
||||
raw: {
|
||||
sessionId: "claude-agent",
|
||||
toolCall: {
|
||||
toolCallId: "tool-paperclip",
|
||||
title: "mcp__paperclip__get_task_context",
|
||||
kind: "other",
|
||||
},
|
||||
options: [],
|
||||
},
|
||||
}, { signal: new AbortController().signal })).resolves.toEqual({ outcome: "allow_once" });
|
||||
expect(externalPermissionRequests).toBe(0);
|
||||
await expect(permissionHandler({
|
||||
sessionId: "claude-agent",
|
||||
inferredKind: "execute",
|
||||
raw: {
|
||||
sessionId: "claude-agent",
|
||||
toolCall: { toolCallId: "tool-bash", title: "Bash", kind: "execute" },
|
||||
options: [],
|
||||
},
|
||||
}, { signal: new AbortController().signal })).resolves.toEqual({ outcome: "reject_once" });
|
||||
expect(externalPermissionRequests).toBe(1);
|
||||
} finally {
|
||||
await host.close({ reason: "test complete" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an unverified Claude selector even after an exact model set", async () => {
|
||||
await expect(AcpxRuntimeHost.open({
|
||||
runtimeDirectory: await mkdtemp(join(tmpdir(), "paperclip-acpx-claude-drift-")),
|
||||
normalizedSessionId: "claude-model-drift",
|
||||
workingDirectory: process.cwd(),
|
||||
agent: "claude",
|
||||
model: "claude-sonnet-5",
|
||||
dynamicToolHandler: async () => ({}),
|
||||
runtimeFactory: () => ({
|
||||
ensureSession: async () => ({
|
||||
sessionKey: "claude-drift-key",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "claude-drift-runtime",
|
||||
acpxRecordId: "claude-drift-record",
|
||||
backendSessionId: "claude-drift-backend",
|
||||
agentSessionId: "claude-drift-agent",
|
||||
}),
|
||||
startTurn: (input) => completedTurn(input.requestId),
|
||||
runTurn: async function* () {},
|
||||
getStatus: async () => ({
|
||||
models: { currentModelId: "default", availableModelIds: ["default", "sonnet"] },
|
||||
}),
|
||||
setConfigOption: async () => {},
|
||||
cancel: async () => {},
|
||||
close: async () => {},
|
||||
}),
|
||||
})).rejects.toThrow("expected ACP selector sonnet");
|
||||
});
|
||||
|
||||
it("stages managed Codex auth only in the isolated home and removes it on close", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-codex-auth-"));
|
||||
const workspace = join(root, "workspace");
|
||||
const sourceAuth = join(root, "managed-auth.json");
|
||||
await mkdir(workspace);
|
||||
await writeFile(sourceAuth, JSON.stringify({ tokens: { access_token: "managed-codex-canary" } }), { mode: 0o600 });
|
||||
let isolatedAuthPath = "";
|
||||
const host = await AcpxRuntimeHost.open({
|
||||
runtimeDirectory: root,
|
||||
normalizedSessionId: "codex-session",
|
||||
workingDirectory: workspace,
|
||||
agent: "codex",
|
||||
model: "gpt-5.6-sol",
|
||||
environment: { PATH: process.env.PATH },
|
||||
managedCredentialSources: { codexAuthPath: sourceAuth },
|
||||
dynamicToolHandler: async () => ({}),
|
||||
runtimeFactory: (options) => ({
|
||||
ensureSession: async (input) => {
|
||||
const environment = (options as AcpRuntimeOptions & { spawnEnvironment?: () => Record<string, string> }).spawnEnvironment?.() ?? {};
|
||||
expect(JSON.stringify(input)).not.toContain("managed-codex-canary");
|
||||
expect(environment.CODEX_HOME).toContain("codex-home");
|
||||
isolatedAuthPath = join(environment.CODEX_HOME!, "auth.json");
|
||||
expect(await readFile(isolatedAuthPath, "utf8")).toContain("managed-codex-canary");
|
||||
return {
|
||||
sessionKey: "codex-session-key",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "codex-runtime",
|
||||
acpxRecordId: "codex-record",
|
||||
backendSessionId: "codex-backend",
|
||||
agentSessionId: "codex-agent",
|
||||
};
|
||||
},
|
||||
startTurn: (input) => completedTurn(input.requestId),
|
||||
runTurn: async function* () {},
|
||||
getStatus: async () => ({
|
||||
acpxRecordId: "codex-record",
|
||||
backendSessionId: "codex-backend",
|
||||
agentSessionId: "codex-agent",
|
||||
models: { currentModelId: "gpt-5.6-sol", availableModelIds: ["gpt-5.6-sol"] },
|
||||
}),
|
||||
cancel: async () => {},
|
||||
close: async () => {},
|
||||
}),
|
||||
});
|
||||
expect((await stat(isolatedAuthPath)).mode & 0o777).toBe(0o600);
|
||||
await host.close({ reason: "test complete" });
|
||||
await expect(readFile(isolatedAuthPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await readFile(sourceAuth, "utf8")).toContain("managed-codex-canary");
|
||||
});
|
||||
|
||||
it("retains the record but still closes Pi when backend session deletion is unsupported", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-close-"));
|
||||
const workspace = join(root, "workspace");
|
||||
await mkdir(workspace);
|
||||
const closeAttempts: boolean[] = [];
|
||||
const host = await AcpxRuntimeHost.open({
|
||||
runtimeDirectory: root,
|
||||
normalizedSessionId: "pi-close-session",
|
||||
workingDirectory: workspace,
|
||||
agent: "pi",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
environment: { PATH: process.env.PATH },
|
||||
dynamicToolHandler: async () => ({}),
|
||||
runtimeFactory: () => ({
|
||||
ensureSession: async () => ({
|
||||
sessionKey: "pi-close-key",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "pi-close-runtime",
|
||||
acpxRecordId: "pi-close-record",
|
||||
backendSessionId: "pi-close-backend",
|
||||
agentSessionId: "pi-close-agent",
|
||||
}),
|
||||
startTurn: (input) => completedTurn(input.requestId),
|
||||
runTurn: async function* () {},
|
||||
getStatus: async () => ({
|
||||
acpxRecordId: "pi-close-record",
|
||||
backendSessionId: "pi-close-backend",
|
||||
agentSessionId: "pi-close-agent",
|
||||
models: {
|
||||
currentModelId: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
availableModelIds: ["openrouter/deepseek/deepseek-v4-flash-0731"],
|
||||
},
|
||||
}),
|
||||
cancel: async () => {},
|
||||
close: async (input) => {
|
||||
closeAttempts.push(input.discardPersistentState === true);
|
||||
if (input.discardPersistentState) {
|
||||
const error = new Error("Pi does not implement session/close") as Error & { code: string };
|
||||
error.code = "ACP_BACKEND_UNSUPPORTED_CONTROL";
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(host.close({ reason: "environment probe", discardPersistentState: true })).resolves.toBeUndefined();
|
||||
expect(closeAttempts).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("rejects recovery identity drift before constructing or spawning the runtime", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-recovery-"));
|
||||
const workspace = join(root, "workspace");
|
||||
@@ -144,7 +366,7 @@ describe("AcpxRuntimeHost", () => {
|
||||
agentSessionId: "agent-1",
|
||||
requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
effectiveModel: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
profileDigest: "sha256:5d06b5cfe819b1acd75e8d9dea4766e76a40a64b82549eda08c3835f18bdac24",
|
||||
profileDigest: "sha256:e806321f458baaf23aa5580324d8f90a59082066105eda69de35b1ef0c8418eb",
|
||||
}));
|
||||
let runtimeConstructed = false;
|
||||
await expect(AcpxRuntimeHost.open({
|
||||
@@ -159,7 +381,7 @@ describe("AcpxRuntimeHost", () => {
|
||||
acpxRecordId: "record-stale",
|
||||
backendSessionId: "backend-1",
|
||||
agentSessionId: "agent-1",
|
||||
profileDigest: "sha256:5d06b5cfe819b1acd75e8d9dea4766e76a40a64b82549eda08c3835f18bdac24",
|
||||
profileDigest: "sha256:e806321f458baaf23aa5580324d8f90a59082066105eda69de35b1ef0c8418eb",
|
||||
workspaceDigest: `sha256:${createHash("sha256").update(resolve(workspace)).digest("hex")}`,
|
||||
requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
effectiveModel: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -42,6 +43,16 @@ export interface AcpxRuntimeProcessIdentity {
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export interface AcpxRuntimeUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
cachedReadTokens?: number;
|
||||
cachedWriteTokens?: number;
|
||||
thoughtTokens?: number;
|
||||
totalTokens?: number;
|
||||
cost?: { amount: number; currency: "USD" };
|
||||
}
|
||||
|
||||
export interface AcpxRuntimePermissionContext {
|
||||
request: AcpPermissionRequest;
|
||||
signal: AbortSignal;
|
||||
@@ -83,7 +94,10 @@ export interface AcpxRuntimeHostOptions {
|
||||
context: AcpxRuntimePermissionContext,
|
||||
) => Promise<AcpPermissionDecision | undefined>;
|
||||
onSpawn?: (meta: AcpxRuntimeProcessIdentity) => Promise<void>;
|
||||
onUsage?: (usage: AcpxRuntimeUsage) => void;
|
||||
onDiagnostic?: (message: string) => void;
|
||||
/** Host-only source paths; neither paths nor contents enter ACPX records. */
|
||||
managedCredentialSources?: { codexAuthPath?: string };
|
||||
}
|
||||
|
||||
export interface AcpxRuntimeIdentity {
|
||||
@@ -122,6 +136,8 @@ export class AcpxRuntimeHost {
|
||||
readonly #bridge: RunnerToolBridge;
|
||||
readonly #identity: AcpxRuntimeIdentity;
|
||||
readonly #root: string;
|
||||
readonly #ephemeralCredentialFiles: string[];
|
||||
readonly #agentProcessIdentity: AcpxRuntimeProcessIdentity | null;
|
||||
#activeTurn: AcpRuntimeTurn | null = null;
|
||||
#closed = false;
|
||||
|
||||
@@ -131,12 +147,16 @@ export class AcpxRuntimeHost {
|
||||
bridge: RunnerToolBridge;
|
||||
identity: AcpxRuntimeIdentity;
|
||||
root: string;
|
||||
ephemeralCredentialFiles: string[];
|
||||
agentProcessIdentity: AcpxRuntimeProcessIdentity | null;
|
||||
}) {
|
||||
this.#runtime = input.runtime;
|
||||
this.#handle = input.handle;
|
||||
this.#bridge = input.bridge;
|
||||
this.#identity = input.identity;
|
||||
this.#root = input.root;
|
||||
this.#ephemeralCredentialFiles = input.ephemeralCredentialFiles;
|
||||
this.#agentProcessIdentity = input.agentProcessIdentity;
|
||||
}
|
||||
|
||||
static async open(options: AcpxRuntimeHostOptions): Promise<AcpxRuntimeHost> {
|
||||
@@ -164,6 +184,9 @@ export class AcpxRuntimeHost {
|
||||
enableInstallTelemetry: false,
|
||||
})}\n`, { mode: 0o600 });
|
||||
}
|
||||
const ephemeralCredentialFiles = options.agent === "codex"
|
||||
? await stageManagedCodexCredential(agentHome, options)
|
||||
: [];
|
||||
|
||||
const command = await resolveAndVerifyCommand(profile);
|
||||
const piCommand = options.agent === "pi" ? await resolvePiRuntimeCommand(profile) : null;
|
||||
@@ -210,6 +233,7 @@ export class AcpxRuntimeHost {
|
||||
PAPERCLIP_RUNTIME_ROOT: root,
|
||||
} : {}),
|
||||
...(options.agent === "claude" ? { CLAUDE_CONFIG_DIR: agentHome } : {}),
|
||||
...(options.agent === "codex" ? { CODEX_HOME: agentHome } : {}),
|
||||
PAPERCLIP_ACPX_PROFILE: options.agent,
|
||||
}, options.agent);
|
||||
const safeSessionEnvironment = withoutCredentials(agentEnvironment);
|
||||
@@ -219,6 +243,12 @@ export class AcpxRuntimeHost {
|
||||
url: bridge.url,
|
||||
headers: [{ name: "Authorization", value: `Bearer ${bridge.secret}` }],
|
||||
}];
|
||||
const routeAgentStderr = createAgentStderrRouter(
|
||||
agentEnvironment,
|
||||
options.onUsage,
|
||||
options.onDiagnostic,
|
||||
);
|
||||
let agentProcessIdentity: AcpxRuntimeProcessIdentity | null = null;
|
||||
const runtimeOptions: SecureAcpRuntimeOptions = {
|
||||
cwd,
|
||||
spawnCwd: cwd,
|
||||
@@ -235,16 +265,27 @@ export class AcpxRuntimeHost {
|
||||
defaultAction: "escalate",
|
||||
},
|
||||
elicitationModes: ["form", "url"],
|
||||
onPermissionRequest: options.onPermissionRequest
|
||||
? (request, context) => options.onPermissionRequest!({ request, signal: context.signal })
|
||||
: async () => ({ outcome: "reject_once" }),
|
||||
onPermissionRequest: async (request, context) => {
|
||||
// Claude Code asks its ACP client to authorize every MCP invocation,
|
||||
// including calls to Paperclip's own authenticated semantic bridge.
|
||||
// Those calls must reach PRP, whose attached run catalog and schema
|
||||
// checks are the authority. Treating this duplicate provider prompt as
|
||||
// a human approval prevents even read-only semantic tools from running.
|
||||
// Built-in filesystem/process calls do not have this closed namespace
|
||||
// and continue through the durable interactive permission flow.
|
||||
if (options.agent === "claude" && isRunnerOwnedSemanticPermission(request)) {
|
||||
return { outcome: "allow_once" };
|
||||
}
|
||||
return options.onPermissionRequest
|
||||
? options.onPermissionRequest({ request, signal: context.signal })
|
||||
: { outcome: "reject_once" };
|
||||
},
|
||||
spawnEnvironment: () => ({ ...agentEnvironment }),
|
||||
onAgentStderr: (chunk) => options.onDiagnostic?.(redactDiagnostic(chunk, agentEnvironment)),
|
||||
onAgentSpawn: async (meta) => options.onSpawn?.({
|
||||
pid: meta.pid,
|
||||
processGroupId: null,
|
||||
startedAt: meta.startedAt,
|
||||
}),
|
||||
onAgentStderr: routeAgentStderr,
|
||||
onAgentSpawn: async (meta) => {
|
||||
agentProcessIdentity = { pid: meta.pid, processGroupId: null, startedAt: meta.startedAt };
|
||||
await options.onSpawn?.(agentProcessIdentity);
|
||||
},
|
||||
};
|
||||
const runtimeFactory = options.runtimeFactory ?? ((input) => createAcpRuntime(input));
|
||||
const runtime = runtimeFactory(runtimeOptions);
|
||||
@@ -261,7 +302,7 @@ export class AcpxRuntimeHost {
|
||||
env: safeSessionEnvironment,
|
||||
},
|
||||
});
|
||||
const status = await requireVerifiedModel(runtime, handle, options.model);
|
||||
const status = await requireVerifiedModel(runtime, handle, profile);
|
||||
const backendSessionId = requiredIdentity(status.backendSessionId ?? handle.backendSessionId, "backend session");
|
||||
const identity: AcpxRuntimeIdentity = {
|
||||
acpxRecordId: requiredIdentity(status.acpxRecordId ?? handle.acpxRecordId, "ACPX record"),
|
||||
@@ -285,12 +326,21 @@ export class AcpxRuntimeHost {
|
||||
effectiveModel: identity.effectiveModel,
|
||||
profileDigest: profile.commandDigest,
|
||||
}, null, 2)}\n`, { mode: 0o600 });
|
||||
return new AcpxRuntimeHost({ runtime, handle, bridge, identity, root });
|
||||
return new AcpxRuntimeHost({
|
||||
runtime,
|
||||
handle,
|
||||
bridge,
|
||||
identity,
|
||||
root,
|
||||
ephemeralCredentialFiles,
|
||||
agentProcessIdentity,
|
||||
});
|
||||
} catch (error) {
|
||||
if (handle) {
|
||||
await runtime.close({ handle, reason: "ACPX startup failed", discardPersistentState: false }).catch(() => {});
|
||||
}
|
||||
await bridge.close().catch(() => {});
|
||||
await removeEphemeralCredentialFiles(ephemeralCredentialFiles);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -300,9 +350,11 @@ export class AcpxRuntimeHost {
|
||||
}
|
||||
|
||||
async status(): Promise<AcpRuntimeStatus> {
|
||||
return this.#runtime.getStatus
|
||||
? this.#runtime.getStatus({ handle: this.#handle })
|
||||
: {};
|
||||
if (!this.#runtime.getStatus) return {};
|
||||
return normalizeQualifiedModelStatus(
|
||||
await this.#runtime.getStatus({ handle: this.#handle }),
|
||||
this.#identity.profile,
|
||||
);
|
||||
}
|
||||
|
||||
startTurn(input: { requestId: string; text: string; signal?: AbortSignal }): AcpRuntimeTurn {
|
||||
@@ -332,17 +384,102 @@ export class AcpxRuntimeHost {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
await this.#activeTurn?.cancel({ reason: input.reason }).catch(() => {});
|
||||
await this.#runtime.close({
|
||||
handle: this.#handle,
|
||||
reason: input.reason,
|
||||
discardPersistentState: input.discardPersistentState ?? false,
|
||||
}).finally(() => this.#bridge.close()).catch(async (error) => {
|
||||
try {
|
||||
const discardPersistentState = input.discardPersistentState ?? false;
|
||||
try {
|
||||
await this.#runtime.close({
|
||||
handle: this.#handle,
|
||||
reason: input.reason,
|
||||
discardPersistentState,
|
||||
});
|
||||
} catch (error) {
|
||||
// Some qualified ACP agents (currently Pi) do not implement the
|
||||
// optional session/close control required for remote destruction.
|
||||
// Still close the live owner and descendants, but retain the provider
|
||||
// record rather than leaking a process or pretending it was deleted.
|
||||
if (!discardPersistentState || runtimeErrorCode(error) !== "ACP_BACKEND_UNSUPPORTED_CONTROL") {
|
||||
throw error;
|
||||
}
|
||||
await this.#runtime.close({
|
||||
handle: this.#handle,
|
||||
reason: `${input.reason} (retaining unsupported backend record)`,
|
||||
discardPersistentState: false,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await this.#bridge.close().catch(() => {});
|
||||
throw error;
|
||||
});
|
||||
await removeEphemeralCredentialFiles(this.#ephemeralCredentialFiles);
|
||||
}
|
||||
}
|
||||
|
||||
runtimeRoot(): string { return this.#root; }
|
||||
|
||||
processIdentity(): AcpxRuntimeProcessIdentity | null {
|
||||
return this.#agentProcessIdentity === null ? null : { ...this.#agentProcessIdentity };
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeErrorCode(error: unknown): string | null {
|
||||
if (!error || typeof error !== "object" || Array.isArray(error)) return null;
|
||||
return typeof (error as { code?: unknown }).code === "string"
|
||||
? (error as { code: string }).code
|
||||
: null;
|
||||
}
|
||||
|
||||
function isRunnerOwnedSemanticPermission(request: AcpPermissionRequest): boolean {
|
||||
const raw = request.raw as unknown as Record<string, unknown>;
|
||||
const toolCall = raw.toolCall;
|
||||
if (!toolCall || typeof toolCall !== "object" || Array.isArray(toolCall)) return false;
|
||||
const call = toolCall as Record<string, unknown>;
|
||||
const meta = call._meta;
|
||||
const claudeCode = meta && typeof meta === "object" && !Array.isArray(meta)
|
||||
? (meta as Record<string, unknown>).claudeCode
|
||||
: null;
|
||||
const metadataToolName = claudeCode && typeof claudeCode === "object" && !Array.isArray(claudeCode)
|
||||
? (claudeCode as Record<string, unknown>).toolName
|
||||
: null;
|
||||
return [call.name, call.title, metadataToolName].some((value) => (
|
||||
typeof value === "string" && value.startsWith("mcp__paperclip__")
|
||||
));
|
||||
}
|
||||
|
||||
async function stageManagedCodexCredential(
|
||||
agentHome: string,
|
||||
options: AcpxRuntimeHostOptions,
|
||||
): Promise<string[]> {
|
||||
if (options.environment?.OPENAI_API_KEY || options.environment?.CODEX_API_KEY) return [];
|
||||
const source = options.managedCredentialSources?.codexAuthPath ?? join(homedir(), ".codex", "auth.json");
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await stat(source);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
if (!metadata.isFile() || metadata.size <= 0 || metadata.size > 256 * 1024) {
|
||||
throw new Error("Managed Codex credential source is not a bounded regular file");
|
||||
}
|
||||
const credential = await readFile(source);
|
||||
try {
|
||||
const parsed = JSON.parse(credential.toString("utf8")) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("invalid credential document");
|
||||
}
|
||||
} catch {
|
||||
credential.fill(0);
|
||||
throw new Error("Managed Codex credential source is malformed");
|
||||
}
|
||||
const destination = join(agentHome, "auth.json");
|
||||
try {
|
||||
await writeFile(destination, credential, { mode: 0o600 });
|
||||
await chmod(destination, 0o600);
|
||||
} finally {
|
||||
credential.fill(0);
|
||||
}
|
||||
return [destination];
|
||||
}
|
||||
|
||||
async function removeEphemeralCredentialFiles(paths: readonly string[]): Promise<void> {
|
||||
await Promise.all(paths.map((path) => rm(path, { force: true }).catch(() => {})));
|
||||
}
|
||||
|
||||
async function verifyExpectedIdentity(
|
||||
@@ -379,20 +516,49 @@ async function verifyExpectedIdentity(
|
||||
async function requireVerifiedModel(
|
||||
runtime: AcpRuntime,
|
||||
handle: AcpRuntimeHandle,
|
||||
requestedModel: string,
|
||||
profile: QualifiedAcpxProfile,
|
||||
): Promise<AcpRuntimeStatus> {
|
||||
if (!runtime.getStatus) throw new Error("ACPX agent cannot verify its effective model");
|
||||
const requestedModel = profile.qualificationModel;
|
||||
let status = await runtime.getStatus({ handle });
|
||||
if (status.models?.currentModelId !== requestedModel && runtime.setConfigOption) {
|
||||
|
||||
// A distinct reported selector is an explicit part of a pinned profile, not
|
||||
// a fuzzy alias. Re-apply the user's exact canonical model through ACP so a
|
||||
// stale/default session cannot be mistaken for the qualified model merely
|
||||
// because it currently reports the same selector.
|
||||
if (profile.reportedModelId !== requestedModel) {
|
||||
if (!runtime.setConfigOption) {
|
||||
throw new Error("ACPX agent cannot verify its canonical model through ACP config options");
|
||||
}
|
||||
await runtime.setConfigOption({ handle, key: "model", value: requestedModel });
|
||||
status = await runtime.getStatus({ handle });
|
||||
} else if (status.models?.currentModelId !== requestedModel && runtime.setConfigOption) {
|
||||
await runtime.setConfigOption({ handle, key: "model", value: requestedModel });
|
||||
status = await runtime.getStatus({ handle });
|
||||
}
|
||||
if (status.models?.currentModelId !== requestedModel) {
|
||||
if (status.models?.currentModelId !== profile.reportedModelId) {
|
||||
throw new Error(
|
||||
`ACPX effective model mismatch: requested ${requestedModel}, received ${status.models?.currentModelId ?? "unverified"}`,
|
||||
`ACPX effective model mismatch: requested ${requestedModel}, expected ACP selector ${profile.reportedModelId}, received ${status.models?.currentModelId ?? "unverified"}`,
|
||||
);
|
||||
}
|
||||
return status;
|
||||
return normalizeQualifiedModelStatus(status, profile);
|
||||
}
|
||||
|
||||
function normalizeQualifiedModelStatus(
|
||||
status: AcpRuntimeStatus,
|
||||
profile: QualifiedAcpxProfile,
|
||||
): AcpRuntimeStatus {
|
||||
if (status.models?.currentModelId !== profile.reportedModelId) return status;
|
||||
return {
|
||||
...status,
|
||||
models: {
|
||||
...status.models,
|
||||
currentModelId: profile.qualificationModel,
|
||||
availableModelIds: status.models.availableModelIds.map((modelId) => (
|
||||
modelId === profile.reportedModelId ? profile.qualificationModel : modelId
|
||||
)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveAndVerifyCommand(profile: QualifiedAcpxProfile): Promise<{ path: string; digest: string }> {
|
||||
@@ -532,10 +698,13 @@ function withoutCredentials(environment: Record<string, string>): Record<string,
|
||||
return safe;
|
||||
}
|
||||
|
||||
function redactDiagnostic(value: string, environment: Record<string, string>): string {
|
||||
function redactDiagnostic(
|
||||
value: string,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): string {
|
||||
let redacted = value;
|
||||
for (const [key, secret] of Object.entries(environment)) {
|
||||
if (!/(?:KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)$/i.test(key) || secret.length < 4) continue;
|
||||
if (!secret || !/(?:KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)$/i.test(key) || secret.length < 4) continue;
|
||||
redacted = redacted.split(secret).join("[REDACTED]");
|
||||
}
|
||||
return redacted.replace(/(key|token|secret|password|authorization)\s*[:=]\s*[^\s,}\]]+/gi, "$1=[REDACTED]").slice(-8_192);
|
||||
@@ -569,6 +738,7 @@ function profileSessionKey(
|
||||
cwd,
|
||||
agent: profile.agent,
|
||||
model,
|
||||
reportedModelId: profile.reportedModelId,
|
||||
commandDigest: profile.commandDigest,
|
||||
driverKind: profile.driverKind,
|
||||
protocolVersion: profile.protocolVersion,
|
||||
@@ -582,6 +752,59 @@ function requiredIdentity(value: string | undefined, label: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
const PI_USAGE_PREFIX = "[paperclip-pi-usage-v1]";
|
||||
|
||||
function createAgentStderrRouter(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
onUsage?: (usage: AcpxRuntimeUsage) => void,
|
||||
onDiagnostic?: (message: string) => void,
|
||||
): (chunk: string) => void {
|
||||
let pending = "";
|
||||
return (chunk) => {
|
||||
pending = `${pending}${chunk}`.slice(-128 * 1024);
|
||||
const lines = pending.split(/\r?\n/);
|
||||
pending = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith(PI_USAGE_PREFIX)) {
|
||||
const usage = parsePiUsage(line.slice(PI_USAGE_PREFIX.length));
|
||||
if (usage) onUsage?.(usage);
|
||||
else onDiagnostic?.("Pi emitted a malformed bounded usage notification.");
|
||||
continue;
|
||||
}
|
||||
if (line) onDiagnostic?.(redactDiagnostic(line, environment));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function parsePiUsage(serialized: string): AcpxRuntimeUsage | null {
|
||||
if (Buffer.byteLength(serialized) > 8 * 1024) return null;
|
||||
try {
|
||||
const value = JSON.parse(serialized) as Record<string, unknown>;
|
||||
const usage: AcpxRuntimeUsage = {};
|
||||
for (const key of [
|
||||
"inputTokens",
|
||||
"outputTokens",
|
||||
"cachedReadTokens",
|
||||
"cachedWriteTokens",
|
||||
"thoughtTokens",
|
||||
"totalTokens",
|
||||
] as const) {
|
||||
const amount = value[key];
|
||||
if (typeof amount === "number" && Number.isFinite(amount) && amount >= 0) usage[key] = amount;
|
||||
}
|
||||
const cost = value.cost;
|
||||
if (typeof cost === "object" && cost !== null && !Array.isArray(cost)) {
|
||||
const amount = (cost as Record<string, unknown>).amount;
|
||||
if (typeof amount === "number" && Number.isFinite(amount) && amount >= 0) {
|
||||
usage.cost = { amount, currency: "USD" };
|
||||
}
|
||||
}
|
||||
return Object.keys(usage).length > 0 ? usage : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function paperclipAcpSystemPrompt(): string {
|
||||
return [
|
||||
"You are running inside Paperclip Runner.",
|
||||
|
||||
@@ -16,6 +16,15 @@ export interface QualifiedAcpxProfile {
|
||||
agentRuntimeVersion: string | null;
|
||||
commandDigest: string;
|
||||
qualificationModel: string;
|
||||
/**
|
||||
* Model identifier the pinned ACP server reports after accepting the exact
|
||||
* qualification model. Most agents echo the requested model. Claude's ACP
|
||||
* server deliberately exposes its stable SDK selector (`sonnet`) while the
|
||||
* SDK resolves that selector to the canonical wire model
|
||||
* (`claude-sonnet-5`). Paperclip verifies the exact request was accepted
|
||||
* before treating this identifier as the qualified effective model.
|
||||
*/
|
||||
reportedModelId: string;
|
||||
permissionPolicy: "interactive";
|
||||
}
|
||||
|
||||
@@ -35,8 +44,9 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<Record<QualifiedAcpxAgent, Qualif
|
||||
agentServerVersion: "0.0.33",
|
||||
agentRuntimePackage: "@earendil-works/pi-coding-agent",
|
||||
agentRuntimeVersion: "0.84.2",
|
||||
commandDigest: "sha256:5d06b5cfe819b1acd75e8d9dea4766e76a40a64b82549eda08c3835f18bdac24",
|
||||
commandDigest: "sha256:e806321f458baaf23aa5580324d8f90a59082066105eda69de35b1ef0c8418eb",
|
||||
qualificationModel: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
reportedModelId: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
permissionPolicy: "interactive",
|
||||
},
|
||||
claude: {
|
||||
@@ -51,6 +61,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<Record<QualifiedAcpxAgent, Qualif
|
||||
agentRuntimeVersion: null,
|
||||
commandDigest: "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
|
||||
qualificationModel: "claude-sonnet-5",
|
||||
reportedModelId: "sonnet",
|
||||
permissionPolicy: "interactive",
|
||||
},
|
||||
codex: {
|
||||
@@ -65,6 +76,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<Record<QualifiedAcpxAgent, Qualif
|
||||
agentRuntimeVersion: null,
|
||||
commandDigest: "sha256:8c7fc8af156596668a95ce23d52309f70ad576e75bac6dc209d30378bdbb8ebe",
|
||||
qualificationModel: "gpt-5.6-sol",
|
||||
reportedModelId: "gpt-5.6-sol",
|
||||
permissionPolicy: "interactive",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Builds the complete environment visible to runnerd for a Claude Managed
|
||||
* Agent execution. Paperclip credentials and ambient application state are
|
||||
* deliberately excluded; runnerd receives only runtime basics and the
|
||||
* Anthropic credential needed by the remote provider.
|
||||
*/
|
||||
export function createSanitizedClaudeManagedEnvironment(
|
||||
environment: NodeJS.ProcessEnv | undefined,
|
||||
): NodeJS.ProcessEnv {
|
||||
const source = environment ?? process.env;
|
||||
const result: NodeJS.ProcessEnv = {};
|
||||
for (const key of [
|
||||
"PATH", "HOME", "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;
|
||||
}
|
||||
@@ -3,6 +3,20 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
export interface CodexRpcNotification {
|
||||
method: string;
|
||||
params: Record<string, unknown>;
|
||||
/** Internal-only correlation retained outside provider params and canonical PRP payloads. */
|
||||
paperclipTrace?: {
|
||||
sourceEventId: string;
|
||||
sourceEventType: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CodexTraceInterpretation {
|
||||
sourceEventId: string;
|
||||
sourceEventType: string;
|
||||
providerMethod: string;
|
||||
disposition: "mapped" | "ignored" | "rejected";
|
||||
emittedEventIds: string[];
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface CodexRpcServerRequest extends CodexRpcNotification {
|
||||
@@ -14,7 +28,10 @@ export type CodexServerRequestHandler = (
|
||||
) => Promise<Record<string, unknown>>;
|
||||
|
||||
export interface CodexAppServerTransport {
|
||||
request(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>>;
|
||||
request(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>>;
|
||||
notify(method: string, params?: Record<string, unknown>): void;
|
||||
notifications(): AsyncIterable<CodexRpcNotification>;
|
||||
setServerRequestHandler(handler: CodexServerRequestHandler): void;
|
||||
@@ -25,6 +42,8 @@ export interface CodexAppServerTransport {
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
}): Promise<void>;
|
||||
/** Records post-rehydration driver mapping without affecting run authority. */
|
||||
recordTraceInterpretation?(input: CodexTraceInterpretation): void;
|
||||
}
|
||||
|
||||
export interface CodexTransportProcessInfo {
|
||||
@@ -58,7 +77,10 @@ class BoundedAsyncQueue<T> implements AsyncIterable<T> {
|
||||
waiter.resolve({ value, done: false });
|
||||
return true;
|
||||
}
|
||||
if (this.#values.length >= this.maxValues || this.#queuedBytes + bytes > this.maxBytes) {
|
||||
if (
|
||||
this.#values.length >= this.maxValues ||
|
||||
this.#queuedBytes + bytes > this.maxBytes
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.#values.push({ value, bytes });
|
||||
@@ -115,10 +137,15 @@ class BoundedLineDecoder {
|
||||
const end = newline < 0 ? incoming.length : newline;
|
||||
const segment = incoming.subarray(offset, end);
|
||||
if (this.#buffer.length + segment.length > this.maxLineBytes) {
|
||||
this.#fail(new Error(`codex app-server line exceeded ${this.maxLineBytes} bytes`));
|
||||
this.#fail(
|
||||
new Error(
|
||||
`codex app-server line exceeded ${this.maxLineBytes} bytes`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (segment.length > 0) this.#buffer = Buffer.concat([this.#buffer, segment]);
|
||||
if (segment.length > 0)
|
||||
this.#buffer = Buffer.concat([this.#buffer, segment]);
|
||||
if (newline < 0) return;
|
||||
const line = this.#buffer;
|
||||
this.#buffer = Buffer.alloc(0);
|
||||
@@ -131,7 +158,9 @@ class BoundedLineDecoder {
|
||||
end(): void {
|
||||
if (this.#closed) return;
|
||||
if (this.#buffer.length > 0) {
|
||||
this.#fail(new Error("codex app-server ended with an unterminated JSON-RPC line"));
|
||||
this.#fail(
|
||||
new Error("codex app-server ended with an unterminated JSON-RPC line"),
|
||||
);
|
||||
}
|
||||
this.#closed = true;
|
||||
}
|
||||
@@ -185,7 +214,9 @@ export function createSanitizedCodexEnvironment(
|
||||
return environment;
|
||||
}
|
||||
|
||||
export function sanitizedEnvironmentKeys(source: NodeJS.ProcessEnv = process.env): string[] {
|
||||
export function sanitizedEnvironmentKeys(
|
||||
source: NodeJS.ProcessEnv = process.env,
|
||||
): string[] {
|
||||
return Object.keys(createSanitizedCodexEnvironment(source)).sort();
|
||||
}
|
||||
|
||||
@@ -195,10 +226,22 @@ export function redactCodexDiagnostic(message: string): string {
|
||||
.replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, "Bearer [REDACTED]")
|
||||
.replace(/Basic\s+[A-Za-z0-9+/=]+/gi, "Basic [REDACTED]")
|
||||
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, "$1[REDACTED]@")
|
||||
.replace(/([?&](?:api[_-]?key|token|secret|password)=)[^&#\s]+/gi, "$1[REDACTED]")
|
||||
.replace(/(["'](?:api[_-]?key|token|secret|password|authorization)["']\s*:\s*["'])[^"']+/gi, "$1[REDACTED]")
|
||||
.replace(/(api[_-]?key|token|secret|password)\s*[=:]\s*[^\s,;]+/gi, "$1=[REDACTED]")
|
||||
.replace(/(PAPERCLIP_API_KEY|OPENAI_API_KEY|OPENROUTER_API_KEY)=[^\s]+/g, "$1=[REDACTED]");
|
||||
.replace(
|
||||
/([?&](?:api[_-]?key|token|secret|password)=)[^&#\s]+/gi,
|
||||
"$1[REDACTED]",
|
||||
)
|
||||
.replace(
|
||||
/(["'](?:api[_-]?key|token|secret|password|authorization)["']\s*:\s*["'])[^"']+/gi,
|
||||
"$1[REDACTED]",
|
||||
)
|
||||
.replace(
|
||||
/(api[_-]?key|token|secret|password)\s*[=:]\s*[^\s,;]+/gi,
|
||||
"$1=[REDACTED]",
|
||||
)
|
||||
.replace(
|
||||
/(PAPERCLIP_API_KEY|OPENAI_API_KEY|OPENROUTER_API_KEY)=[^\s]+/g,
|
||||
"$1=[REDACTED]",
|
||||
);
|
||||
}
|
||||
|
||||
function proxyContainsCredentials(value: string): boolean {
|
||||
@@ -318,7 +361,10 @@ export class ProcessCodexAppServerTransport implements CodexAppServerTransport {
|
||||
|
||||
constructor(options: ProcessCodexTransportOptions = {}) {
|
||||
this.#onDiagnostic = options.onDiagnostic;
|
||||
this.#maxLineBytes = positiveLimit(options.maxLineBytes, DEFAULT_MAX_LINE_BYTES);
|
||||
this.#maxLineBytes = positiveLimit(
|
||||
options.maxLineBytes,
|
||||
DEFAULT_MAX_LINE_BYTES,
|
||||
);
|
||||
this.#maxPendingRequests = positiveLimit(
|
||||
options.maxPendingRequests,
|
||||
DEFAULT_MAX_PENDING_REQUESTS,
|
||||
@@ -327,7 +373,8 @@ export class ProcessCodexAppServerTransport implements CodexAppServerTransport {
|
||||
options.maxInflightServerRequests,
|
||||
DEFAULT_MAX_INFLIGHT_SERVER_REQUESTS,
|
||||
);
|
||||
this.#processGroup = options.processGroup === true && process.platform !== "win32";
|
||||
this.#processGroup =
|
||||
options.processGroup === true && process.platform !== "win32";
|
||||
this.#startedAt = new Date().toISOString();
|
||||
this.#closeGraceMs = positiveLimit(options.closeGraceMs, 1_000);
|
||||
this.#onProcess = options.onProcess;
|
||||
@@ -335,17 +382,24 @@ export class ProcessCodexAppServerTransport implements CodexAppServerTransport {
|
||||
this.#resolveExit = resolve;
|
||||
});
|
||||
this.#notifications = new BoundedAsyncQueue(
|
||||
positiveLimit(options.maxQueuedNotifications, DEFAULT_MAX_QUEUED_NOTIFICATIONS),
|
||||
positiveLimit(
|
||||
options.maxQueuedNotifications,
|
||||
DEFAULT_MAX_QUEUED_NOTIFICATIONS,
|
||||
),
|
||||
positiveLimit(
|
||||
options.maxQueuedNotificationBytes,
|
||||
DEFAULT_MAX_QUEUED_NOTIFICATION_BYTES,
|
||||
),
|
||||
);
|
||||
this.#process = spawn(options.command ?? "codex", options.args ?? ["app-server"], {
|
||||
env: options.environment ?? createSanitizedCodexEnvironment(),
|
||||
stdio: "pipe",
|
||||
detached: this.#processGroup,
|
||||
});
|
||||
this.#process = spawn(
|
||||
options.command ?? "codex",
|
||||
options.args ?? ["app-server"],
|
||||
{
|
||||
env: options.environment ?? createSanitizedCodexEnvironment(),
|
||||
stdio: "pipe",
|
||||
detached: this.#processGroup,
|
||||
},
|
||||
);
|
||||
this.#onProcess?.(this.processInfo());
|
||||
this.#stdoutDecoder = new BoundedLineDecoder(
|
||||
this.#maxLineBytes,
|
||||
@@ -355,11 +409,18 @@ export class ProcessCodexAppServerTransport implements CodexAppServerTransport {
|
||||
this.#stderrDecoder = new BoundedLineDecoder(
|
||||
MAX_DIAGNOSTIC_LINE_BYTES,
|
||||
(line) => this.#onDiagnostic?.(redactCodexDiagnostic(line)),
|
||||
() => this.#onDiagnostic?.("codex app-server diagnostic line exceeded retention limit"),
|
||||
() =>
|
||||
this.#onDiagnostic?.(
|
||||
"codex app-server diagnostic line exceeded retention limit",
|
||||
),
|
||||
);
|
||||
this.#process.stdout.on("data", (chunk: Buffer) =>
|
||||
this.#stdoutDecoder.write(chunk),
|
||||
);
|
||||
this.#process.stdout.on("data", (chunk: Buffer) => this.#stdoutDecoder.write(chunk));
|
||||
this.#process.stdout.on("end", () => this.#stdoutDecoder.end());
|
||||
this.#process.stderr.on("data", (chunk: Buffer) => this.#stderrDecoder.write(chunk));
|
||||
this.#process.stderr.on("data", (chunk: Buffer) =>
|
||||
this.#stderrDecoder.write(chunk),
|
||||
);
|
||||
this.#process.stderr.on("end", () => this.#stderrDecoder.end());
|
||||
this.#process.on("error", (error) => this.#fatal(error));
|
||||
this.#process.on("exit", (code, signal) => {
|
||||
@@ -378,11 +439,17 @@ export class ProcessCodexAppServerTransport implements CodexAppServerTransport {
|
||||
});
|
||||
}
|
||||
|
||||
request(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
if (this.#closed) return Promise.reject(new Error("codex app-server transport is closed"));
|
||||
request(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (this.#closed)
|
||||
return Promise.reject(new Error("codex app-server transport is closed"));
|
||||
if (this.#pending.size >= this.#maxPendingRequests) {
|
||||
return Promise.reject(
|
||||
new Error(`codex app-server pending request limit ${this.#maxPendingRequests} exceeded`),
|
||||
new Error(
|
||||
`codex app-server pending request limit ${this.#maxPendingRequests} exceeded`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const id = this.#nextId++;
|
||||
@@ -422,13 +489,17 @@ export class ProcessCodexAppServerTransport implements CodexAppServerTransport {
|
||||
if (firstClose) this.#signal("SIGTERM");
|
||||
const exited = await Promise.race([
|
||||
this.#exitPromise.then(() => true),
|
||||
new Promise<false>((resolve) => setTimeout(() => resolve(false), this.#closeGraceMs)),
|
||||
new Promise<false>((resolve) =>
|
||||
setTimeout(() => resolve(false), this.#closeGraceMs),
|
||||
),
|
||||
]);
|
||||
if (!exited && !this.#exited) {
|
||||
this.#signal("SIGKILL");
|
||||
await Promise.race([
|
||||
this.#exitPromise,
|
||||
new Promise<void>((resolve) => setTimeout(resolve, this.#closeGraceMs)),
|
||||
new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, this.#closeGraceMs),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -437,7 +508,7 @@ export class ProcessCodexAppServerTransport implements CodexAppServerTransport {
|
||||
processInfo(): CodexTransportProcessInfo {
|
||||
return {
|
||||
pid: this.#process.pid ?? null,
|
||||
processGroupId: this.#processGroup ? this.#process.pid ?? null : null,
|
||||
processGroupId: this.#processGroup ? (this.#process.pid ?? null) : null,
|
||||
startedAt: this.#startedAt,
|
||||
exited: this.#exited,
|
||||
exitCode: this.#exitCode,
|
||||
@@ -462,7 +533,9 @@ export class ProcessCodexAppServerTransport implements CodexAppServerTransport {
|
||||
if (this.#closed) throw new Error("codex app-server transport is closed");
|
||||
const serialized = JSON.stringify(message);
|
||||
if (Buffer.byteLength(serialized) > this.#maxLineBytes) {
|
||||
throw new Error(`outbound codex JSON-RPC line exceeded ${this.#maxLineBytes} bytes`);
|
||||
throw new Error(
|
||||
`outbound codex JSON-RPC line exceeded ${this.#maxLineBytes} bytes`,
|
||||
);
|
||||
}
|
||||
this.#process.stdin.write(`${serialized}\n`);
|
||||
}
|
||||
@@ -476,56 +549,87 @@ export class ProcessCodexAppServerTransport implements CodexAppServerTransport {
|
||||
return;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
this.#fatal(new Error("codex app-server emitted a non-object JSON-RPC message"));
|
||||
this.#fatal(
|
||||
new Error("codex app-server emitted a non-object JSON-RPC message"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const message = value;
|
||||
const hasResult = Object.hasOwn(message, "result");
|
||||
const hasError = Object.hasOwn(message, "error");
|
||||
if (typeof message.id === "number" && (hasResult || hasError)) {
|
||||
if (!Number.isSafeInteger(message.id) || message.id <= 0 || hasResult === hasError) {
|
||||
this.#fatal(new Error("codex app-server emitted a malformed JSON-RPC response"));
|
||||
if (
|
||||
!Number.isSafeInteger(message.id) ||
|
||||
message.id <= 0 ||
|
||||
hasResult === hasError
|
||||
) {
|
||||
this.#fatal(
|
||||
new Error("codex app-server emitted a malformed JSON-RPC response"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const pending = this.#pending.get(message.id);
|
||||
if (pending === undefined) {
|
||||
this.#fatal(new Error("codex app-server responded to an unknown request id"));
|
||||
this.#fatal(
|
||||
new Error("codex app-server responded to an unknown request id"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.#pending.delete(message.id);
|
||||
if (hasError) {
|
||||
pending.reject(new CodexRpcError(
|
||||
redactCodexDiagnostic(boundedJson(message.error)),
|
||||
rpcErrorCode(message.error),
|
||||
));
|
||||
pending.reject(
|
||||
new CodexRpcError(
|
||||
redactCodexDiagnostic(boundedJson(message.error)),
|
||||
rpcErrorCode(message.error),
|
||||
),
|
||||
);
|
||||
} else if (isRecord(message.result)) {
|
||||
pending.resolve(message.result);
|
||||
} else {
|
||||
const error = new Error("codex app-server response result was not an object");
|
||||
const error = new Error(
|
||||
"codex app-server response result was not an object",
|
||||
);
|
||||
pending.reject(error);
|
||||
this.#fatal(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof message.method !== "string" || message.method.length === 0 || hasResult || hasError) {
|
||||
this.#fatal(new Error("codex app-server emitted a malformed JSON-RPC message"));
|
||||
if (
|
||||
typeof message.method !== "string" ||
|
||||
message.method.length === 0 ||
|
||||
hasResult ||
|
||||
hasError
|
||||
) {
|
||||
this.#fatal(
|
||||
new Error("codex app-server emitted a malformed JSON-RPC message"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (message.params !== undefined && !isRecord(message.params)) {
|
||||
this.#fatal(new Error("codex app-server message params were not an object"));
|
||||
this.#fatal(
|
||||
new Error("codex app-server message params were not an object"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const params = message.params ?? {};
|
||||
if (message.id !== undefined) {
|
||||
if (!isValidServerRequestId(message.id)) {
|
||||
this.#fatal(new Error("codex app-server request id had an invalid type"));
|
||||
this.#fatal(
|
||||
new Error("codex app-server request id had an invalid type"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.#handleServerRequest({ id: message.id, method: message.method, params });
|
||||
this.#handleServerRequest({
|
||||
id: message.id,
|
||||
method: message.method,
|
||||
params,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!this.#notifications.push({ method: message.method, params }, bytes)) {
|
||||
this.#fatal(new Error("codex app-server notification queue limit exceeded"));
|
||||
this.#fatal(
|
||||
new Error("codex app-server notification queue limit exceeded"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,26 +637,34 @@ export class ProcessCodexAppServerTransport implements CodexAppServerTransport {
|
||||
if (this.#inflightServerRequests >= this.#maxInflightServerRequests) {
|
||||
this.#write({
|
||||
id: request.id,
|
||||
error: { code: -32_001, message: "Client request queue limit exceeded." },
|
||||
error: {
|
||||
code: -32_001,
|
||||
message: "Client request queue limit exceeded.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.#inflightServerRequests += 1;
|
||||
void this.#serverRequestHandler(request).then(
|
||||
(result) => {
|
||||
if (!this.#closed) this.#write({ id: request.id, result });
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!this.#closed) {
|
||||
this.#write({
|
||||
id: request.id,
|
||||
error: { code: -32_000, message: redactCodexDiagnostic(String(error)) },
|
||||
});
|
||||
}
|
||||
},
|
||||
).finally(() => {
|
||||
this.#inflightServerRequests -= 1;
|
||||
});
|
||||
void this.#serverRequestHandler(request)
|
||||
.then(
|
||||
(result) => {
|
||||
if (!this.#closed) this.#write({ id: request.id, result });
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!this.#closed) {
|
||||
this.#write({
|
||||
id: request.id,
|
||||
error: {
|
||||
code: -32_000,
|
||||
message: redactCodexDiagnostic(String(error)),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
this.#inflightServerRequests -= 1;
|
||||
});
|
||||
}
|
||||
|
||||
#fatal(error: Error): void {
|
||||
@@ -583,7 +695,9 @@ function positiveLimit(value: number | undefined, fallback: number): number {
|
||||
function isValidServerRequestId(value: unknown): value is string | number {
|
||||
return (
|
||||
(typeof value === "number" && Number.isSafeInteger(value)) ||
|
||||
(typeof value === "string" && value.length > 0 && Buffer.byteLength(value) <= 256)
|
||||
(typeof value === "string" &&
|
||||
value.length > 0 &&
|
||||
Buffer.byteLength(value) <= 256)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@ export * from "./contracts/native-execution.js";
|
||||
export * from "./contracts/local-runner.js";
|
||||
export * from "./contracts/durable-recovery.js";
|
||||
export * from "./contracts/codex.js";
|
||||
export * from "./contracts/provider-trace.js";
|
||||
export * from "./contracts/types.js";
|
||||
export * from "./backends/harness-driver-backend.js";
|
||||
export * from "./backends/codex-native-backend.js";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
@@ -6,6 +7,7 @@ import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createCapabilityRunnerdCodexTransport,
|
||||
createSanitizedClaudeManagedEnvironment,
|
||||
createSanitizedAwsAgentCoreEnvironment,
|
||||
defaultCapabilityRunnerdBinary,
|
||||
rehydrateRunnerdUsageNotification,
|
||||
@@ -13,9 +15,27 @@ import {
|
||||
unwrapRunnerdProviderNotifications,
|
||||
} from "./runnerd-codex-transport.js";
|
||||
|
||||
it("passes only runtime basics and the Anthropic key to a Claude Managed runner", () => {
|
||||
const sanitized = createSanitizedClaudeManagedEnvironment({
|
||||
PATH: "/bin",
|
||||
HOME: "/Users/tester",
|
||||
ANTHROPIC_API_KEY: "anthropic-canary",
|
||||
PAPERCLIP_API_KEY: "paperclip-canary",
|
||||
PAPERCLIP_API_URL: "https://paperclip.example.test",
|
||||
OPENROUTER_API_KEY: "openrouter-canary",
|
||||
AWS_SECRET_ACCESS_KEY: "aws-canary",
|
||||
});
|
||||
expect(sanitized).toEqual({
|
||||
PATH: "/bin",
|
||||
HOME: "/Users/tester",
|
||||
ANTHROPIC_API_KEY: "anthropic-canary",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes only workload-identity metadata to the AgentCore runner", () => {
|
||||
const sanitized = createSanitizedAwsAgentCoreEnvironment({
|
||||
PATH: "/bin",
|
||||
HOME: "/Users/tester",
|
||||
AWS_PROFILE: "paperclip-dev",
|
||||
AWS_REGION: "us-east-1",
|
||||
AWS_WEB_IDENTITY_TOKEN_FILE: "/var/run/token",
|
||||
@@ -28,6 +48,7 @@ it("passes only workload-identity metadata to the AgentCore runner", () => {
|
||||
});
|
||||
expect(sanitized).toEqual({
|
||||
PATH: "/bin",
|
||||
HOME: "/Users/tester",
|
||||
AWS_PROFILE: "paperclip-dev",
|
||||
AWS_REGION: "us-east-1",
|
||||
AWS_WEB_IDENTITY_TOKEN_FILE: "/var/run/token",
|
||||
@@ -36,11 +57,17 @@ it("passes only workload-identity metadata to the AgentCore runner", () => {
|
||||
});
|
||||
|
||||
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({
|
||||
expect(
|
||||
rehydrateRunnerdUsageNotification(
|
||||
{
|
||||
providerSessionId: "thread-1",
|
||||
cumulative: { inputTokens: 10 },
|
||||
runDelta: { inputTokens: 3 },
|
||||
},
|
||||
"fallback-thread",
|
||||
"turn-1",
|
||||
),
|
||||
).toMatchObject({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
tokenUsage: {
|
||||
@@ -56,27 +83,34 @@ const fakeCodex = resolve(
|
||||
);
|
||||
|
||||
it("unwraps a coalesced provider notification without losing its turn identity", () => {
|
||||
expect(unwrapRunnerdProviderNotification({
|
||||
coalescedCount: 2,
|
||||
latest: {
|
||||
method: "turn/started",
|
||||
params: { threadId: "thread-1", turn: { id: "provider-turn-1" } },
|
||||
},
|
||||
})).toEqual({
|
||||
expect(
|
||||
unwrapRunnerdProviderNotification({
|
||||
coalescedCount: 2,
|
||||
latest: {
|
||||
method: "turn/started",
|
||||
params: { threadId: "thread-1", turn: { id: "provider-turn-1" } },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
method: "turn/started",
|
||||
params: { threadId: "thread-1", turn: { id: "provider-turn-1" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("replays every provider notification from a durable coalesced batch", () => {
|
||||
expect(unwrapRunnerdProviderNotifications({
|
||||
coalescedCount: 3,
|
||||
events: [
|
||||
{ method: "item/started", params: { item: { id: "reasoning-1" } } },
|
||||
{ method: "item/reasoning/summaryTextDelta", params: { delta: "Checking the task" } },
|
||||
{ method: "item/completed", params: { item: { id: "reasoning-1" } } },
|
||||
],
|
||||
})).toEqual([
|
||||
expect(
|
||||
unwrapRunnerdProviderNotifications({
|
||||
coalescedCount: 3,
|
||||
events: [
|
||||
{ method: "item/started", params: { item: { id: "reasoning-1" } } },
|
||||
{
|
||||
method: "item/reasoning/summaryTextDelta",
|
||||
params: { delta: "Checking the task" },
|
||||
},
|
||||
{ method: "item/completed", params: { item: { id: "reasoning-1" } } },
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
expect.objectContaining({ method: "item/started" }),
|
||||
expect.objectContaining({ method: "item/reasoning/summaryTextDelta" }),
|
||||
expect.objectContaining({ method: "item/completed" }),
|
||||
@@ -91,20 +125,31 @@ it("runs the lab provider boundary through authenticated durable PRP", async ()
|
||||
});
|
||||
bundle.transport.setServerRequestHandler(async (request) => ({
|
||||
success: true,
|
||||
contentItems: [{
|
||||
type: "inputText",
|
||||
text: JSON.stringify({ ok: true, result: { task: { title: "PRP lab task" } } }),
|
||||
}],
|
||||
contentItems: [
|
||||
{
|
||||
type: "inputText",
|
||||
text: JSON.stringify({
|
||||
ok: true,
|
||||
result: { task: { title: "PRP lab task" } },
|
||||
}),
|
||||
},
|
||||
],
|
||||
}));
|
||||
try {
|
||||
await bundle.transport.request("initialize", {});
|
||||
const opened = await bundle.transport.request("thread/start", {
|
||||
cwd: tmpdir(),
|
||||
dynamicTools: [{
|
||||
name: "get_task_context",
|
||||
description: "Read the active task.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
}],
|
||||
dynamicTools: [
|
||||
{
|
||||
name: "get_task_context",
|
||||
description: "Read the active task.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(opened.thread).toMatchObject({ modelProvider: "openai" });
|
||||
await bundle.transport.request("turn/start", {
|
||||
@@ -122,7 +167,141 @@ it("runs the lab provider boundary through authenticated durable PRP", async ()
|
||||
} finally {
|
||||
await bundle.transport.close();
|
||||
}
|
||||
expect(bundle.evidence()).toMatchObject({ runnerExited: true, runnerExitCode: 0 });
|
||||
expect(bundle.evidence()).toMatchObject({
|
||||
runnerExited: true,
|
||||
runnerExitCode: 0,
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("captures exact provider frames and correlates Rust and TypeScript interpretation stages", async () => {
|
||||
const traceDirectory = await mkdtemp(
|
||||
join(tmpdir(), "runnerd-provider-trace-"),
|
||||
);
|
||||
const tracePath = join(traceDirectory, "trace.ndjson");
|
||||
const bundle = createCapabilityRunnerdCodexTransport({
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(),
|
||||
codexCommand: fakeCodex,
|
||||
codexArgs: [],
|
||||
stateDirectory: join(traceDirectory, "state"),
|
||||
environment: {
|
||||
PAPERCLIP_PROVIDER_TRACE_PATH: tracePath,
|
||||
PAPERCLIP_PROVIDER_TRACE_MAX_BYTES: String(64 * 1024 * 1024),
|
||||
},
|
||||
});
|
||||
bundle.transport.setServerRequestHandler(async () => ({
|
||||
success: true,
|
||||
contentItems: [{ type: "inputText", text: JSON.stringify({ ok: true }) }],
|
||||
}));
|
||||
try {
|
||||
await bundle.transport.request("initialize", {});
|
||||
await bundle.transport.request("thread/start", {
|
||||
cwd: tmpdir(),
|
||||
dynamicTools: [
|
||||
{
|
||||
name: "get_task_context",
|
||||
description: "Read the active task.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await bundle.transport.request("turn/start", {
|
||||
input: [{ type: "text", text: "Return a final response." }],
|
||||
});
|
||||
let persistedSequence = 0;
|
||||
for await (const notification of bundle.transport.notifications()) {
|
||||
if (notification.paperclipTrace) {
|
||||
persistedSequence += 1;
|
||||
bundle.transport.recordTraceInterpretation?.({
|
||||
sourceEventId: notification.paperclipTrace.sourceEventId,
|
||||
sourceEventType: notification.paperclipTrace.sourceEventType,
|
||||
providerMethod: notification.method,
|
||||
disposition: "mapped",
|
||||
emittedEventIds: [`runner:test:${persistedSequence}`],
|
||||
reason: "Test driver normalized the rehydrated notification",
|
||||
});
|
||||
}
|
||||
if (notification.method === "turn/completed") break;
|
||||
}
|
||||
} finally {
|
||||
await bundle.transport.close();
|
||||
}
|
||||
|
||||
const nativeEntries = (await readFile(tracePath, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
const rehydratedEntries = (await readFile(`${tracePath}.rehydration`, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
const frames = nativeEntries.filter((entry) => entry.kind === "frame");
|
||||
expect(frames.map((entry) => entry.frameId)).toEqual(
|
||||
frames.map((_, index) => index + 1),
|
||||
);
|
||||
expect(frames.map((entry) => entry.direction)).toEqual(
|
||||
expect.arrayContaining(["client_to_provider", "provider_to_client"]),
|
||||
);
|
||||
for (const frame of frames) {
|
||||
const raw = Buffer.from(String(frame.rawBase64), "base64");
|
||||
expect(raw.byteLength).toBe(frame.byteLength);
|
||||
expect(`sha256:${createHash("sha256").update(raw).digest("hex")}`).toBe(
|
||||
frame.digest,
|
||||
);
|
||||
}
|
||||
const decodedFrames = frames.map((frame) =>
|
||||
JSON.parse(Buffer.from(String(frame.rawBase64), "base64").toString("utf8")),
|
||||
) as Array<Record<string, unknown>>;
|
||||
expect(
|
||||
decodedFrames.find((frame) => frame.method === "thread/start"),
|
||||
).toMatchObject({
|
||||
params: {
|
||||
config: { include_collaboration_mode_instructions: true },
|
||||
},
|
||||
});
|
||||
const stages = new Set(
|
||||
[...nativeEntries, ...rehydratedEntries]
|
||||
.filter((entry) => entry.kind === "interpretation")
|
||||
.map((entry) => entry.stage),
|
||||
);
|
||||
expect([...stages]).toEqual(
|
||||
expect.arrayContaining([
|
||||
"rust_native_transport",
|
||||
"rust_jsonrpc_parse",
|
||||
"rust_durable_normalization",
|
||||
"typescript_runnerd_rehydration",
|
||||
"typescript_codex_driver_normalization",
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
rehydratedEntries.some(
|
||||
(entry) =>
|
||||
entry.stage === "typescript_codex_driver_normalization" &&
|
||||
Array.isArray(entry.emittedEventIds) &&
|
||||
entry.emittedEventIds.some((eventId) =>
|
||||
String(eventId).startsWith("runner:test:"),
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
for (const channel of ["rust_native", "typescript_runnerd_rehydration"]) {
|
||||
const channelEntries = [...nativeEntries, ...rehydratedEntries].filter(
|
||||
(entry) => entry.debugChannel === channel,
|
||||
);
|
||||
expect(channelEntries.map((entry) => entry.debugSequence)).toEqual(
|
||||
channelEntries.map((_, index) => index + 1),
|
||||
);
|
||||
const status = channelEntries.at(-1);
|
||||
expect(status).toMatchObject({
|
||||
kind: "trace_status",
|
||||
status: "complete",
|
||||
acknowledgedDebugSequence: channelEntries.length - 1,
|
||||
});
|
||||
}
|
||||
|
||||
await rm(traceDirectory, { recursive: true, force: true });
|
||||
}, 30_000);
|
||||
|
||||
it("steers the active provider turn through the durable PRP command path", async () => {
|
||||
@@ -211,32 +390,50 @@ it("attaches a second governed run to one warm runner and provider process", asy
|
||||
codexArgs: [],
|
||||
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 60_000 },
|
||||
});
|
||||
bundle.transport.setServerRequestHandler(async () => ({ success: true, contentItems: [] }));
|
||||
bundle.transport.setServerRequestHandler(async () => ({
|
||||
success: true,
|
||||
contentItems: [],
|
||||
}));
|
||||
try {
|
||||
await bundle.transport.request("initialize", {});
|
||||
await bundle.transport.request("thread/start", {
|
||||
cwd: tmpdir(),
|
||||
dynamicTools: [{
|
||||
name: "get_task_context",
|
||||
description: "Read the active task.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
}],
|
||||
dynamicTools: [
|
||||
{
|
||||
name: "get_task_context",
|
||||
description: "Read the active task.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const runnerPid = bundle.evidence().runnerPid;
|
||||
const providerPid = bundle.evidence().codexPid;
|
||||
const notifications = bundle.transport.notifications()[Symbol.asyncIterator]();
|
||||
const notifications = bundle.transport
|
||||
.notifications()
|
||||
[Symbol.asyncIterator]();
|
||||
const waitForCompletion = async (label: string) => {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline) {
|
||||
const next = await Promise.race([
|
||||
notifications.next(),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`${label} notification timeout`)), 1_000)),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`${label} notification timeout`)),
|
||||
1_000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
if (next.value?.method === "turn/completed") return;
|
||||
}
|
||||
throw new Error(`${label} completion timeout`);
|
||||
};
|
||||
await bundle.transport.request("turn/start", { input: [{ type: "text", text: "first run" }] });
|
||||
await bundle.transport.request("turn/start", {
|
||||
input: [{ type: "text", text: "first run" }],
|
||||
});
|
||||
await waitForCompletion("first run");
|
||||
|
||||
await bundle.transport.attachRun?.({
|
||||
@@ -244,10 +441,16 @@ it("attaches a second governed run to one warm runner and provider process", asy
|
||||
turnId: "turn-binding-second",
|
||||
itemId: "item-binding-second",
|
||||
});
|
||||
await bundle.transport.request("turn/start", { input: [{ type: "text", text: "second run" }] });
|
||||
await bundle.transport.request("turn/start", {
|
||||
input: [{ type: "text", text: "second run" }],
|
||||
});
|
||||
await waitForCompletion("second run");
|
||||
|
||||
expect(bundle.evidence()).toMatchObject({ runnerPid, codexPid: providerPid, runnerExited: false });
|
||||
expect(bundle.evidence()).toMatchObject({
|
||||
runnerPid,
|
||||
codexPid: providerPid,
|
||||
runnerExited: false,
|
||||
});
|
||||
} finally {
|
||||
await bundle.transport.close();
|
||||
}
|
||||
@@ -270,16 +473,33 @@ it("cold-restores a suspended provider session under a new run binding", async (
|
||||
stateDirectory,
|
||||
lifecyclePolicy: { mode: "per_turn" as const, idleTimeoutMs: null },
|
||||
};
|
||||
const dynamicTools = [{
|
||||
name: "get_task_context",
|
||||
description: "Read the active task.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
}];
|
||||
const first = createCapabilityRunnerdCodexTransport({ ...options, prpIdentity: baseIdentity });
|
||||
first.transport.setServerRequestHandler(async () => ({ success: true, contentItems: [] }));
|
||||
const dynamicTools = [
|
||||
{
|
||||
name: "get_task_context",
|
||||
description: "Read the active task.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
const first = createCapabilityRunnerdCodexTransport({
|
||||
...options,
|
||||
prpIdentity: baseIdentity,
|
||||
});
|
||||
first.transport.setServerRequestHandler(async () => ({
|
||||
success: true,
|
||||
contentItems: [],
|
||||
}));
|
||||
try {
|
||||
await first.transport.request("thread/start", { cwd: tmpdir(), dynamicTools });
|
||||
await first.transport.request("turn/start", { input: [{ type: "text", text: "first process" }] });
|
||||
await first.transport.request("thread/start", {
|
||||
cwd: tmpdir(),
|
||||
dynamicTools,
|
||||
});
|
||||
await first.transport.request("turn/start", {
|
||||
input: [{ type: "text", text: "first process" }],
|
||||
});
|
||||
for await (const event of first.transport.notifications()) {
|
||||
if (event.method === "turn/completed") break;
|
||||
}
|
||||
@@ -297,24 +517,36 @@ it("cold-restores a suspended provider session under a new run binding", async (
|
||||
itemId: "item-cold-restored",
|
||||
},
|
||||
});
|
||||
restored.transport.setServerRequestHandler(async () => ({ success: true, contentItems: [] }));
|
||||
restored.transport.setServerRequestHandler(async () => ({
|
||||
success: true,
|
||||
contentItems: [],
|
||||
}));
|
||||
try {
|
||||
const read = await restored.transport.request("thread/read", {});
|
||||
expect(read.thread).toMatchObject({ id: "fake-thread" });
|
||||
const durable = JSON.parse(await readFile(
|
||||
join(stateDirectory, "runner", "runner-state.json"),
|
||||
"utf8",
|
||||
)) as { providerToolBridge?: { authorized?: Record<string, unknown> } };
|
||||
expect(Object.keys(durable.providerToolBridge?.authorized ?? {})).toEqual(expect.arrayContaining([
|
||||
"get_task_context",
|
||||
"paperclip_finish",
|
||||
"paperclip_block",
|
||||
]));
|
||||
await restored.transport.request("turn/start", { input: [{ type: "text", text: "restored process" }] });
|
||||
const durable = JSON.parse(
|
||||
await readFile(
|
||||
join(stateDirectory, "runner", "runner-state.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as { providerToolBridge?: { authorized?: Record<string, unknown> } };
|
||||
expect(Object.keys(durable.providerToolBridge?.authorized ?? {})).toEqual(
|
||||
expect.arrayContaining([
|
||||
"get_task_context",
|
||||
"paperclip_finish",
|
||||
"paperclip_block",
|
||||
]),
|
||||
);
|
||||
await restored.transport.request("turn/start", {
|
||||
input: [{ type: "text", text: "restored process" }],
|
||||
});
|
||||
for await (const event of restored.transport.notifications()) {
|
||||
if (event.method === "turn/completed") break;
|
||||
}
|
||||
expect(restored.evidence()).toMatchObject({ runnerExited: false, codexPid: expect.any(Number) });
|
||||
expect(restored.evidence()).toMatchObject({
|
||||
runnerExited: false,
|
||||
codexPid: expect.any(Number),
|
||||
});
|
||||
} finally {
|
||||
await restored.transport.close();
|
||||
await rm(stateDirectory, { recursive: true, force: true });
|
||||
@@ -327,29 +559,47 @@ it("rejects the notification stream promptly when runnerd exits after accepting
|
||||
codexCommand: fakeCodex,
|
||||
codexArgs: ["--linger-after-turn-start"],
|
||||
});
|
||||
bundle.transport.setServerRequestHandler(async () => ({ success: true, contentItems: [] }));
|
||||
bundle.transport.setServerRequestHandler(async () => ({
|
||||
success: true,
|
||||
contentItems: [],
|
||||
}));
|
||||
try {
|
||||
await bundle.transport.request("initialize", {});
|
||||
await bundle.transport.request("thread/start", {
|
||||
cwd: tmpdir(),
|
||||
dynamicTools: [{
|
||||
name: "get_task_context",
|
||||
description: "Read the active task.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
}],
|
||||
dynamicTools: [
|
||||
{
|
||||
name: "get_task_context",
|
||||
description: "Read the active task.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await bundle.transport.request("turn/start", {
|
||||
input: [{ type: "text", text: "Wait for another instruction." }],
|
||||
});
|
||||
const notifications = bundle.transport.notifications()[Symbol.asyncIterator]();
|
||||
const notifications = bundle.transport
|
||||
.notifications()
|
||||
[Symbol.asyncIterator]();
|
||||
expect((await notifications.next()).value?.method).toBe("turn/started");
|
||||
const runnerPid = bundle.evidence().runnerPid;
|
||||
expect(runnerPid).not.toBeNull();
|
||||
process.kill(runnerPid!, "SIGKILL");
|
||||
await expect(Promise.race([
|
||||
notifications.next(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("notification stream hung")), 2_000)),
|
||||
])).rejects.toThrow("native_runner_process_exited");
|
||||
await expect(
|
||||
Promise.race([
|
||||
notifications.next(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("notification stream hung")),
|
||||
2_000,
|
||||
),
|
||||
),
|
||||
]),
|
||||
).rejects.toThrow("native_runner_process_exited");
|
||||
} finally {
|
||||
await bundle.transport.close();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -884,18 +884,71 @@ describe.sequential("Durable transport and recovery", () => {
|
||||
it("passes only the bootstrap capability and platform basics to the runner", () => {
|
||||
const environment = durableRecoveryInternals.runnerEnvironment("opaque-ticket", {
|
||||
PATH: "/usr/bin",
|
||||
HOME: "/Users/tester",
|
||||
AWS_PROFILE: "paperclip-dev",
|
||||
AWS_REGION: "us-east-1",
|
||||
AWS_ACCESS_KEY_ID: "must-not-cross-runner-boundary",
|
||||
AWS_SESSION_TOKEN: "must-not-cross-runner-boundary",
|
||||
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.HOME).toBe("/Users/tester");
|
||||
expect(environment.AWS_PROFILE).toBe("paperclip-dev");
|
||||
expect(environment.AWS_REGION).toBe("us-east-1");
|
||||
expect(environment.AWS_ACCESS_KEY_ID).toBeUndefined();
|
||||
expect(environment.AWS_SESSION_TOKEN).toBeUndefined();
|
||||
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("keeps the eval connection lease independent from the provider turn deadline", () => {
|
||||
expect(durableRecoveryInternals.durableEvalConnectionLeaseTtlMs(180_000))
|
||||
.toBe(60 * 60 * 1_000);
|
||||
expect(durableRecoveryInternals.durableEvalConnectionLeaseTtlMs(90 * 60 * 1_000))
|
||||
.toBe(91 * 60 * 1_000);
|
||||
});
|
||||
|
||||
it("builds the pinned remote Claude Managed eval provider without local capabilities", () => {
|
||||
const provider = durableRecoveryInternals.claudeManagedEvalProviderConfiguration(
|
||||
"claude-sonnet-5",
|
||||
{
|
||||
profileId: "profile-qualified",
|
||||
anthropicAgentId: "agent-qualified",
|
||||
agentVersion: "17",
|
||||
environmentId: "environment-qualified",
|
||||
betaVersion: "managed-agents-2026-04-01",
|
||||
maxSessionListCostUsd: 0.5,
|
||||
},
|
||||
);
|
||||
expect(provider).toEqual({
|
||||
kind: "claude_managed",
|
||||
model: "claude-sonnet-5",
|
||||
profileId: "profile-qualified",
|
||||
anthropicAgentId: "agent-qualified",
|
||||
agentVersion: "17",
|
||||
environmentId: "environment-qualified",
|
||||
betaVersion: "managed-agents-2026-04-01",
|
||||
maxSessionListCostUsd: 0.5,
|
||||
});
|
||||
expect(provider).not.toHaveProperty("cwd");
|
||||
expect(provider).not.toHaveProperty("command");
|
||||
expect(provider).not.toHaveProperty("mcpServers");
|
||||
});
|
||||
|
||||
it("recognizes a managed provider budget stop before the generic turn timeout", () => {
|
||||
expect(durableRecoveryInternals.managedProviderBudgetReached([{
|
||||
envelope: { payload: { eventType: "session.updated", payload: { status: "budget_reached" } } },
|
||||
}])).toBe(true);
|
||||
expect(durableRecoveryInternals.managedProviderBudgetReached([{
|
||||
envelope: { payload: { eventType: "session.updated", payload: { status: "budget_increased" } } },
|
||||
}])).toBe(false);
|
||||
});
|
||||
|
||||
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", {
|
||||
|
||||
@@ -399,6 +399,13 @@ export const providerDescriptorSchema = {
|
||||
],
|
||||
"minimum": 1
|
||||
},
|
||||
"agentProcessId": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 1
|
||||
},
|
||||
"endpointArn": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
@@ -564,7 +571,8 @@ export const providerDescriptorSchema = {
|
||||
"acpProtocolVersion",
|
||||
"agentServerPackage",
|
||||
"agentServerVersion",
|
||||
"acpxRecordId"
|
||||
"acpxRecordId",
|
||||
"agentProcessId"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,27 @@ import { providerDescriptorSchema } from "./generated/schema-bundle.js";
|
||||
const validate = new Ajv2020({ allErrors: true, strict: false }).compile(providerDescriptorSchema);
|
||||
|
||||
describe("provider runtime descriptor", () => {
|
||||
it("accepts ACPX sidecar and child process identities separately", () => {
|
||||
expect(validate({
|
||||
provider: "acpx",
|
||||
driver: "acpx_runtime",
|
||||
agent: "pi",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
executionKind: "local_process",
|
||||
providerVersion: "0.13.1",
|
||||
providerSessionId: "pi-session-1",
|
||||
processId: 41001,
|
||||
agentProcessId: 41002,
|
||||
acpProtocolVersion: 1,
|
||||
agentServerPackage: "pi-acp",
|
||||
agentServerVersion: "0.0.33",
|
||||
agentRuntimePackage: "@earendil-works/pi-coding-agent",
|
||||
agentRuntimeVersion: "0.84.2",
|
||||
acpxRecordId: "acpx-record-1",
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts the closed AWS AgentCore remote identity", () => {
|
||||
expect(validate({
|
||||
provider: "aws_agentcore",
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("provider-neutral events", () => {
|
||||
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: "web-1", type: "webSearch", action: { type: "search", query: "PRP" }, results: [{ ref_id: "source-1", title: "Protocol notes", url: "https://example.com/prp", snippet: "Canonical event details" }] } }],
|
||||
["item/completed", { item: { id: "child-1", type: "collabAgentToolCall", tool: "spawnAgent" } }],
|
||||
["model/rerouted", { turnId: "turn-1", fromModel: "gpt-5", toModel: "gpt-5.1", reason: "capacity" }],
|
||||
["thread/compacted", { itemId: "compact-1" }],
|
||||
@@ -61,6 +61,27 @@ describe("provider-neutral events", () => {
|
||||
expect(mapped).toHaveLength(cases.length);
|
||||
for (const event of mapped) expect(validatePrpEvent(envelope(event))).toEqual({ ok: true, event: expect.any(Object), issues: [] });
|
||||
expect(JSON.stringify(mapped.find((entry) => entry.eventType === "terminal.input.sent"))).not.toContain("secret input");
|
||||
expect(mapped.find((entry) => entry.eventType === "research.completed")?.payload.sources).toEqual([{
|
||||
sourceId: "source-1",
|
||||
title: "Protocol notes",
|
||||
url: "https://example.com/prp",
|
||||
snippet: "Canonical event details",
|
||||
}]);
|
||||
});
|
||||
|
||||
it("bounds and filters Codex research sources before they enter PRP", () => {
|
||||
const event = canonicalProviderEventsFromCodex("item/completed", {
|
||||
item: {
|
||||
id: "web-1",
|
||||
type: "webSearch",
|
||||
results: [
|
||||
{ ref_id: "good", title: "Good", url: "https://example.com/good", snippet: "x".repeat(5000) },
|
||||
{ ref_id: "unsafe", title: "Unsafe", url: "file:///etc/passwd" },
|
||||
],
|
||||
},
|
||||
})[0]!;
|
||||
expect(event.payload.sources).toEqual([expect.objectContaining({ sourceId: "good", url: "https://example.com/good", snippet: "x".repeat(4000) })]);
|
||||
expect(validatePrpEvent(envelope(event))).toEqual({ ok: true, event: expect.any(Object), issues: [] });
|
||||
});
|
||||
|
||||
it("classifies only structured OpenCode parts and never assistant prose", () => {
|
||||
|
||||
@@ -159,6 +159,22 @@ function planPayload(params: Record<string, unknown>, item: Record<string, unkno
|
||||
};
|
||||
}
|
||||
|
||||
function researchSources(item: Record<string, unknown>, researchId: string): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(item.results)) return [];
|
||||
return item.results.slice(0, 64).flatMap((value, index) => {
|
||||
const result = record(value);
|
||||
const url = text(result.url).slice(0, 8192);
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) return [];
|
||||
const fallbackId = `${researchId}:source:${index + 1}`;
|
||||
return [{
|
||||
sourceId: safeId(text(result.ref_id, text(result.refId, fallbackId)), fallbackId),
|
||||
title: text(result.title, url).slice(0, 4000),
|
||||
url,
|
||||
snippet: text(result.snippet).slice(0, 4000) || null,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
/** Maps qualified Codex app-server notifications to bounded canonical PRP events. */
|
||||
export function canonicalProviderEventsFromCodex(method: string, paramsValue: unknown): CanonicalProviderEvent[] {
|
||||
const params = record(paramsValue);
|
||||
@@ -183,7 +199,7 @@ export function canonicalProviderEventsFromCodex(method: string, paramsValue: un
|
||||
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 }];
|
||||
return [{ eventType: completed ? "research.completed" : "research.started", payload: { schema: "paperclip.research.v1", researchId: itemId, action: actionType, status: completed ? "completed" : "running", query: text(item.query, text(action.query)) || null, url: url?.startsWith("http://") || url?.startsWith("https://") ? url : null, pattern: text(action.pattern) || null, sources: completed ? researchSources(item, itemId) : [] }, itemId }];
|
||||
}
|
||||
if ((method === "item/started" || completed) && (type === "collabAgentToolCall" || type === "subAgentActivity")) {
|
||||
return [{ eventType: completed ? "delegation.completed" : "delegation.started", payload: { schema: "paperclip.delegation.v1", delegationId: itemId, action: text(item.tool, "spawnAgent").replace("spawnAgent", "spawn").replace("sendInput", "message").replace("resumeAgent", "resume").replace("closeAgent", "close"), status: completed ? "completed" : "running", children: [] }, itemId }];
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
|
||||
export {
|
||||
agentAdapterTypeSchema,
|
||||
optionalAgentAdapterTypeSchema,
|
||||
} from "./adapter-type.js";
|
||||
export { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./adapter-auth-check-code.js";
|
||||
export {
|
||||
nativeFinalizationResultSchema,
|
||||
@@ -258,7 +261,11 @@ export {
|
||||
} from "./app-definitions.js";
|
||||
export { APP_DEFINITIONS } from "./app-definitions.generated.js";
|
||||
export * from "./validators/status-card.js";
|
||||
export { appDefinitionSchema, appDefinitionsSchema, connectionMethodDefSchema } from "./validators/app-definition.js";
|
||||
export {
|
||||
appDefinitionSchema,
|
||||
appDefinitionsSchema,
|
||||
connectionMethodDefSchema,
|
||||
} from "./validators/app-definition.js";
|
||||
export {
|
||||
humanizeConnectionDisplayName,
|
||||
connectionDisplaySecondaryHint,
|
||||
@@ -1139,6 +1146,17 @@ export type {
|
||||
HeartbeatRun,
|
||||
HeartbeatRunEvent,
|
||||
HeartbeatRunStatusPhase,
|
||||
ProviderTraceDebugRequest,
|
||||
ProviderTraceDirection,
|
||||
ProviderTraceDisposition,
|
||||
ProviderTraceFieldMapping,
|
||||
ProviderTraceFieldMappingAction,
|
||||
ProviderTraceFrame,
|
||||
ProviderTraceInterpretation,
|
||||
ProviderTraceMetadata,
|
||||
ProviderTraceStatus,
|
||||
RunPresentationDecision,
|
||||
RunPresentationSource,
|
||||
AgentRuntimeState,
|
||||
AgentTaskSession,
|
||||
AgentWakeupRequest,
|
||||
@@ -1621,7 +1639,11 @@ export {
|
||||
SMOKE_RUN_TRIGGERS,
|
||||
} from "./types/smoke-lab.js";
|
||||
|
||||
export type { ServerGitInfo, ServerGitLocalChanges, ServerInfoSnapshot } from "./types/server-info.js";
|
||||
export type {
|
||||
ServerGitInfo,
|
||||
ServerGitLocalChanges,
|
||||
ServerInfoSnapshot,
|
||||
} from "./types/server-info.js";
|
||||
export {
|
||||
getClosedIsolatedExecutionWorkspaceMessage,
|
||||
isClosedIsolatedExecutionWorkspace,
|
||||
@@ -2313,8 +2335,16 @@ export {
|
||||
} from "./validators/index.js";
|
||||
|
||||
export { API_PREFIX, API } from "./api.js";
|
||||
export { normalizeAgentUrlKey, deriveAgentUrlKey, isUuidLike } from "./agent-url-key.js";
|
||||
export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from "./project-url-key.js";
|
||||
export {
|
||||
normalizeAgentUrlKey,
|
||||
deriveAgentUrlKey,
|
||||
isUuidLike,
|
||||
} from "./agent-url-key.js";
|
||||
export {
|
||||
deriveProjectUrlKey,
|
||||
normalizeProjectUrlKey,
|
||||
hasNonAsciiContent,
|
||||
} from "./project-url-key.js";
|
||||
export {
|
||||
AGENT_MENTION_SCHEME,
|
||||
PROJECT_MENTION_SCHEME,
|
||||
@@ -2476,6 +2506,7 @@ export {
|
||||
type EnvironmentCustomImageTerminalSessionToken,
|
||||
} from "./validators/environment-custom-images.js";
|
||||
export * from "./validators/skill-policy.js";
|
||||
export * from "./validators/provider-trace.js";
|
||||
export {
|
||||
FEATURE_TIERS,
|
||||
INSTANCE_FEATURE_CATALOG,
|
||||
|
||||
@@ -8,9 +8,103 @@ import type {
|
||||
WakeupRequestStatus,
|
||||
} from "../constants.js";
|
||||
|
||||
export type GitWorktreeBranchAncestryVerdict = "ancestor" | "diverged" | "unknown";
|
||||
export interface ProviderTraceDebugRequest {
|
||||
providerTrace: "raw";
|
||||
}
|
||||
|
||||
export type GitWorktreeInProgressOperation = "rebase" | "merge" | "cherry_pick" | "revert" | "bisect";
|
||||
export type ProviderTraceDirection =
|
||||
"client_to_provider" | "provider_to_client" | "provider_stderr";
|
||||
export type ProviderTraceDisposition =
|
||||
"mapped" | "generic" | "ignored" | "rejected" | "operator_only";
|
||||
|
||||
export type ProviderTraceFieldMappingAction =
|
||||
| "copied"
|
||||
| "renamed"
|
||||
| "normalized"
|
||||
| "derived"
|
||||
| "dropped"
|
||||
| "redacted";
|
||||
|
||||
export interface ProviderTraceFieldMapping {
|
||||
inputPath?: string;
|
||||
outputPath?: string;
|
||||
action: ProviderTraceFieldMappingAction;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ProviderTraceFrame {
|
||||
kind?: "frame";
|
||||
schema: "paperclip.provider_trace_frame.v1";
|
||||
debugChannel: string;
|
||||
debugSequence: number;
|
||||
frameId: number;
|
||||
timestamp: string;
|
||||
direction: ProviderTraceDirection;
|
||||
transport: string;
|
||||
provider: string;
|
||||
byteLength: number;
|
||||
digest: `sha256:${string}`;
|
||||
rawBase64: string;
|
||||
}
|
||||
|
||||
export interface ProviderTraceInterpretation {
|
||||
kind?: "interpretation";
|
||||
schema: "paperclip.provider_trace_interpretation.v1";
|
||||
debugChannel: string;
|
||||
debugSequence: number;
|
||||
frameId: number;
|
||||
stage: string;
|
||||
ruleId: string;
|
||||
disposition: ProviderTraceDisposition;
|
||||
emittedEventIds: string[];
|
||||
droppedFields: string[];
|
||||
fieldMappings?: ProviderTraceFieldMapping[];
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export type ProviderTraceStatus =
|
||||
"capturing" | "complete" | "incomplete" | "truncated" | "deleted" | "expired";
|
||||
|
||||
export interface ProviderTraceMetadata {
|
||||
schema: "paperclip.provider_trace_metadata.v1";
|
||||
id: string;
|
||||
runId: string;
|
||||
companyId: string;
|
||||
status: ProviderTraceStatus;
|
||||
provider: string;
|
||||
frameCount: number;
|
||||
byteCount: number;
|
||||
digest: `sha256:${string}` | null;
|
||||
reason: string | null;
|
||||
requestedBy: string;
|
||||
createdAt: string | Date;
|
||||
expiresAt: string | Date;
|
||||
deletedAt: string | Date | null;
|
||||
}
|
||||
|
||||
export type RunPresentationSource =
|
||||
| "existing_issue_comment"
|
||||
| "final_agent_message"
|
||||
| "semantic_result_summary"
|
||||
| "adapter_final_response"
|
||||
| "none";
|
||||
|
||||
export interface RunPresentationDecision {
|
||||
schema: "paperclip.run_presentation_decision.v1";
|
||||
resolverVersion: string;
|
||||
chosenSource: RunPresentationSource;
|
||||
sourceEventId: string | null;
|
||||
commentAction: "reuse" | "create" | "none";
|
||||
commentId: string | null;
|
||||
activityDisposition: "collapse";
|
||||
reasonCodes: string[];
|
||||
}
|
||||
|
||||
export type GitWorktreeBranchAncestryVerdict =
|
||||
"ancestor" | "diverged" | "unknown";
|
||||
|
||||
export type GitWorktreeInProgressOperation =
|
||||
"rebase" | "merge" | "cherry_pick" | "revert" | "bisect";
|
||||
|
||||
export interface GitWorktreeBranchIncoherenceEvidence {
|
||||
reason: "git_worktree_branch_incoherence";
|
||||
@@ -141,11 +235,7 @@ export type HeartbeatRunStatusPhase =
|
||||
| "run_activity";
|
||||
|
||||
export type HeartbeatRunOutputSilenceLevel =
|
||||
| "not_applicable"
|
||||
| "ok"
|
||||
| "suspicious"
|
||||
| "critical"
|
||||
| "snoozed";
|
||||
"not_applicable" | "ok" | "suspicious" | "critical" | "snoozed";
|
||||
|
||||
export interface HeartbeatRunOutputSilence {
|
||||
lastOutputAt: Date | string | null;
|
||||
|
||||
@@ -341,7 +341,15 @@ export type {
|
||||
DocumentTextRange,
|
||||
UpdateDocumentAnnotationThreadRequest,
|
||||
} from "./document-annotation.js";
|
||||
export type { Project, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
|
||||
export type {
|
||||
Project,
|
||||
ProjectBudgetSummary,
|
||||
ProjectCodebase,
|
||||
ProjectCodebaseOrigin,
|
||||
ProjectGoalRef,
|
||||
ProjectManagedByPlugin,
|
||||
ProjectWorkspace,
|
||||
} from "./project.js";
|
||||
export type {
|
||||
CompanySearchCountType,
|
||||
CompanySearchExtractIssueResult,
|
||||
@@ -832,8 +840,23 @@ export type {
|
||||
RoutineExecutionIssueOrigin,
|
||||
RoutineListItem,
|
||||
} from "./routine.js";
|
||||
export type { CostEvent, CostSummary, IssueCostSummary, CostByAgent, CostByProviderModel, CostByBiller, CostByAgentModel, CostWindowSpendRow, CostByProject } from "./cost.js";
|
||||
export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } from "./finance.js";
|
||||
export type {
|
||||
CostEvent,
|
||||
CostSummary,
|
||||
IssueCostSummary,
|
||||
CostByAgent,
|
||||
CostByProviderModel,
|
||||
CostByBiller,
|
||||
CostByAgentModel,
|
||||
CostWindowSpendRow,
|
||||
CostByProject,
|
||||
} from "./cost.js";
|
||||
export type {
|
||||
FinanceEvent,
|
||||
FinanceSummary,
|
||||
FinanceByBiller,
|
||||
FinanceByKind,
|
||||
} from "./finance.js";
|
||||
export type {
|
||||
AgentWakeupResponse,
|
||||
AgentWakeupSkipped,
|
||||
@@ -843,6 +866,17 @@ export type {
|
||||
HeartbeatRun,
|
||||
HeartbeatRunEvent,
|
||||
HeartbeatRunStatusPhase,
|
||||
ProviderTraceDebugRequest,
|
||||
ProviderTraceDirection,
|
||||
ProviderTraceDisposition,
|
||||
ProviderTraceFieldMapping,
|
||||
ProviderTraceFieldMappingAction,
|
||||
ProviderTraceFrame,
|
||||
ProviderTraceInterpretation,
|
||||
ProviderTraceMetadata,
|
||||
ProviderTraceStatus,
|
||||
RunPresentationDecision,
|
||||
RunPresentationSource,
|
||||
AgentRuntimeState,
|
||||
AgentTaskSession,
|
||||
AgentWakeupRequest,
|
||||
|
||||
@@ -59,13 +59,19 @@ export const createAgentInstructionsBundleSchema = z.object({
|
||||
const agentModelProfileConfigSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
label: z.string().trim().min(1).optional(),
|
||||
adapterConfig: adapterConfigSchema,
|
||||
// Disabled profiles created before model-profile editing may not have an
|
||||
// adapter payload yet. Keep them valid so unrelated runtime settings (such
|
||||
// as debug capture) can be updated without fabricating model configuration.
|
||||
adapterConfig: adapterConfigSchema.optional().default({}),
|
||||
}).strict();
|
||||
|
||||
export const agentRuntimeConfigSchema = z.object({
|
||||
modelProfiles: z.object({
|
||||
cheap: agentModelProfileConfigSchema.optional(),
|
||||
}).strict().optional(),
|
||||
debug: z.object({
|
||||
providerTrace: z.literal("raw").optional(),
|
||||
}).strict().optional(),
|
||||
}).catchall(z.unknown());
|
||||
|
||||
export const createAgentSchema = z.object({
|
||||
@@ -208,6 +214,9 @@ export const wakeAgentSchema = z.object({
|
||||
(value) => (value === null ? undefined : value),
|
||||
z.boolean().optional().default(false),
|
||||
),
|
||||
debug: z.object({
|
||||
providerTrace: z.literal("raw"),
|
||||
}).strict().optional(),
|
||||
});
|
||||
|
||||
export type WakeAgent = z.infer<typeof wakeAgentSchema>;
|
||||
|
||||
@@ -941,4 +941,5 @@ export {
|
||||
type RevokeToolTrustRule,
|
||||
} from "./tool-access.js";
|
||||
export * from "./skill-policy.js";
|
||||
export * from "./provider-trace.js";
|
||||
export * from "./app-definition.js";
|
||||
@@ -0,0 +1,117 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const providerTraceDirectionSchema = z.enum([
|
||||
"client_to_provider",
|
||||
"provider_to_client",
|
||||
"provider_stderr",
|
||||
]);
|
||||
|
||||
export const providerTraceDispositionSchema = z.enum([
|
||||
"mapped",
|
||||
"generic",
|
||||
"ignored",
|
||||
"rejected",
|
||||
"operator_only",
|
||||
]);
|
||||
|
||||
export const providerTraceFieldMappingSchema = z
|
||||
.object({
|
||||
inputPath: z.string().min(1).optional(),
|
||||
outputPath: z.string().min(1).optional(),
|
||||
action: z.enum([
|
||||
"copied",
|
||||
"renamed",
|
||||
"normalized",
|
||||
"derived",
|
||||
"dropped",
|
||||
"redacted",
|
||||
]),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const providerTraceStatusSchema = z.enum([
|
||||
"capturing",
|
||||
"complete",
|
||||
"incomplete",
|
||||
"truncated",
|
||||
"deleted",
|
||||
"expired",
|
||||
]);
|
||||
|
||||
const sha256DigestSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/i);
|
||||
const dateValueSchema = z.union([z.string().datetime(), z.date()]);
|
||||
|
||||
export const providerTraceFrameSchema = z
|
||||
.object({
|
||||
kind: z.literal("frame").optional(),
|
||||
schema: z.literal("paperclip.provider_trace_frame.v1"),
|
||||
debugChannel: z.string().min(1),
|
||||
debugSequence: z.number().int().positive(),
|
||||
frameId: z.number().int().positive(),
|
||||
timestamp: z.string().min(1),
|
||||
direction: providerTraceDirectionSchema,
|
||||
transport: z.string().min(1),
|
||||
provider: z.string().min(1),
|
||||
byteLength: z.number().int().nonnegative(),
|
||||
digest: sha256DigestSchema,
|
||||
rawBase64: z.string(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const providerTraceInterpretationSchema = z
|
||||
.object({
|
||||
kind: z.literal("interpretation").optional(),
|
||||
schema: z.literal("paperclip.provider_trace_interpretation.v1"),
|
||||
debugChannel: z.string().min(1),
|
||||
debugSequence: z.number().int().positive(),
|
||||
frameId: z.number().int().positive(),
|
||||
stage: z.string().min(1),
|
||||
ruleId: z.string().min(1),
|
||||
disposition: providerTraceDispositionSchema,
|
||||
emittedEventIds: z.array(z.string()),
|
||||
droppedFields: z.array(z.string()),
|
||||
fieldMappings: z.array(providerTraceFieldMappingSchema).optional(),
|
||||
reason: z.string(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const providerTraceMetadataSchema = z
|
||||
.object({
|
||||
schema: z.literal("paperclip.provider_trace_metadata.v1"),
|
||||
id: z.string().min(1),
|
||||
runId: z.string().min(1),
|
||||
companyId: z.string().min(1),
|
||||
status: providerTraceStatusSchema,
|
||||
provider: z.string().min(1),
|
||||
frameCount: z.number().int().nonnegative(),
|
||||
byteCount: z.number().int().nonnegative(),
|
||||
digest: sha256DigestSchema.nullable(),
|
||||
reason: z.string().nullable(),
|
||||
requestedBy: z.string().min(1),
|
||||
createdAt: dateValueSchema,
|
||||
expiresAt: dateValueSchema,
|
||||
deletedAt: dateValueSchema.nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const runPresentationSourceSchema = z.enum([
|
||||
"existing_issue_comment",
|
||||
"final_agent_message",
|
||||
"semantic_result_summary",
|
||||
"adapter_final_response",
|
||||
"none",
|
||||
]);
|
||||
|
||||
export const runPresentationDecisionSchema = z
|
||||
.object({
|
||||
schema: z.literal("paperclip.run_presentation_decision.v1"),
|
||||
resolverVersion: z.string().min(1),
|
||||
chosenSource: runPresentationSourceSchema,
|
||||
sourceEventId: z.string().nullable(),
|
||||
commentAction: z.enum(["reuse", "create", "none"]),
|
||||
commentId: z.string().nullable(),
|
||||
activityDisposition: z.literal("collapse"),
|
||||
reasonCodes: z.array(z.string()),
|
||||
})
|
||||
.strict();
|
||||
@@ -0,0 +1,27 @@
|
||||
diff --git a/dist/acp-agent.js b/dist/acp-agent.js
|
||||
--- a/dist/acp-agent.js
|
||||
+++ b/dist/acp-agent.js
|
||||
@@ -2681,10 +2681,20 @@
|
||||
cost: {
|
||||
amount: message.total_cost_usd,
|
||||
currency: "USD",
|
||||
},
|
||||
- ...(message.origin && {
|
||||
- _meta: { "_claude/origin": message.origin },
|
||||
- }),
|
||||
+ _meta: {
|
||||
+ ...(message.origin && { "_claude/origin": message.origin }),
|
||||
+ // ACP's core Usage shape exposes context occupancy and
|
||||
+ // cost, but not the billable input/output split. Keep the
|
||||
+ // SDK's bounded aggregate in extension metadata so ACPX can
|
||||
+ // normalize it without persisting a raw provider message.
|
||||
+ usage: {
|
||||
+ input_tokens: message.usage.input_tokens,
|
||||
+ output_tokens: message.usage.output_tokens,
|
||||
+ cache_read_input_tokens: message.usage.cache_read_input_tokens,
|
||||
+ cache_creation_input_tokens: message.usage.cache_creation_input_tokens,
|
||||
+ },
|
||||
+ },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -27,7 +27,7 @@ index ead9809cabc9f3b203f1769db3cabb8327dd758a..113b166120711705edcabaca2d63ae40
|
||||
if (params.sessionPath) args.push("--session", params.sessionPath);
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: params.cwd,
|
||||
@@ -949,6 +958,26 @@ var PiAcpSession = class {
|
||||
@@ -949,6 +958,22 @@ var PiAcpSession = class {
|
||||
switch (type) {
|
||||
case "message_update": {
|
||||
const ame = ev.assistantMessageEvent;
|
||||
@@ -36,19 +36,15 @@ index ead9809cabc9f3b203f1769db3cabb8327dd758a..113b166120711705edcabaca2d63ae40
|
||||
+ const fingerprint = JSON.stringify(usage);
|
||||
+ if (fingerprint !== this.paperclipUsageFingerprint && (usage.totalTokens ?? 0) > 0) {
|
||||
+ this.paperclipUsageFingerprint = fingerprint;
|
||||
+ this.emit({
|
||||
+ sessionUpdate: "usage_update",
|
||||
+ used: typeof usage.totalTokens === "number" ? usage.totalTokens : void 0,
|
||||
+ cost: typeof usage.cost?.total === "number" ? { amount: usage.cost.total, currency: "USD" } : void 0,
|
||||
+ _meta: { usage: {
|
||||
+ process.stderr.write(`[paperclip-pi-usage-v1]${JSON.stringify({
|
||||
+ inputTokens: typeof usage.input === "number" ? usage.input : void 0,
|
||||
+ outputTokens: typeof usage.output === "number" ? usage.output : void 0,
|
||||
+ cachedReadTokens: typeof usage.cacheRead === "number" ? usage.cacheRead : void 0,
|
||||
+ cachedWriteTokens: typeof usage.cacheWrite === "number" ? usage.cacheWrite : void 0,
|
||||
+ thoughtTokens: typeof usage.reasoning === "number" ? usage.reasoning : void 0,
|
||||
+ totalTokens: typeof usage.totalTokens === "number" ? usage.totalTokens : void 0
|
||||
+ } }
|
||||
+ });
|
||||
+ totalTokens: typeof usage.totalTokens === "number" ? usage.totalTokens : void 0,
|
||||
+ cost: typeof usage.cost?.total === "number" ? { amount: usage.cost.total, currency: "USD" } : void 0
|
||||
+ })}\n`);
|
||||
+ }
|
||||
+ }
|
||||
if (ame?.type === "text_delta" && typeof ame.delta === "string") {
|
||||
|
||||
Generated
+8
-5
@@ -20,6 +20,9 @@ overrides:
|
||||
'@lexical/utils': 0.49.0
|
||||
|
||||
patchedDependencies:
|
||||
'@agentclientprotocol/claude-agent-acp@0.70.0':
|
||||
hash: 3uuiqvasvwjam3tge5jo3jbw7a
|
||||
path: patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch
|
||||
acpx@0.13.1:
|
||||
hash: c5ivrcgwkvhn7lv7slpppkd7sy
|
||||
path: patches/acpx@0.13.1.patch
|
||||
@@ -27,7 +30,7 @@ patchedDependencies:
|
||||
hash: 55uhvnotpqyiy37rn3pqpukhei
|
||||
path: patches/embedded-postgres@18.1.0-beta.16.patch
|
||||
pi-acp@0.0.33:
|
||||
hash: nwy4ztc7fl2g2mu436sxq5sba4
|
||||
hash: v6qgcxfo3rn3zt3m56ysulht2y
|
||||
path: patches/pi-acp@0.0.33.patch
|
||||
|
||||
importers:
|
||||
@@ -446,7 +449,7 @@ importers:
|
||||
dependencies:
|
||||
'@agentclientprotocol/claude-agent-acp':
|
||||
specifier: 0.70.0
|
||||
version: 0.70.0(@anthropic-ai/sdk@0.91.1(zod@3.25.76))(@modelcontextprotocol/sdk@1.30.0(zod@3.25.76))
|
||||
version: 0.70.0(patch_hash=3uuiqvasvwjam3tge5jo3jbw7a)(@anthropic-ai/sdk@0.91.1(zod@3.25.76))(@modelcontextprotocol/sdk@1.30.0(zod@3.25.76))
|
||||
'@agentclientprotocol/codex-acp':
|
||||
specifier: 1.6.2
|
||||
version: 1.6.2
|
||||
@@ -461,7 +464,7 @@ importers:
|
||||
version: 8.20.0
|
||||
pi-acp:
|
||||
specifier: 0.0.33
|
||||
version: 0.0.33(patch_hash=nwy4ztc7fl2g2mu436sxq5sba4)
|
||||
version: 0.0.33(patch_hash=v6qgcxfo3rn3zt3m56ysulht2y)
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.61.1
|
||||
@@ -9340,7 +9343,7 @@ snapshots:
|
||||
- '@anthropic-ai/sdk'
|
||||
- '@modelcontextprotocol/sdk'
|
||||
|
||||
'@agentclientprotocol/claude-agent-acp@0.70.0(@anthropic-ai/sdk@0.91.1(zod@3.25.76))(@modelcontextprotocol/sdk@1.30.0(zod@3.25.76))':
|
||||
'@agentclientprotocol/claude-agent-acp@0.70.0(patch_hash=3uuiqvasvwjam3tge5jo3jbw7a)(@anthropic-ai/sdk@0.91.1(zod@3.25.76))(@modelcontextprotocol/sdk@1.30.0(zod@3.25.76))':
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk': 1.3.0(zod@3.25.76)
|
||||
'@anthropic-ai/claude-agent-sdk': 0.3.232(@anthropic-ai/sdk@0.91.1(zod@3.25.76))(@modelcontextprotocol/sdk@1.30.0(zod@3.25.76))(zod@3.25.76)
|
||||
@@ -16781,7 +16784,7 @@ snapshots:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
pi-acp@0.0.33(patch_hash=nwy4ztc7fl2g2mu436sxq5sba4):
|
||||
pi-acp@0.0.33(patch_hash=v6qgcxfo3rn3zt3m56ysulht2y):
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk': 0.26.0(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
|
||||
@@ -12,3 +12,9 @@ packages:
|
||||
- server
|
||||
- ui
|
||||
- cli
|
||||
|
||||
patchedDependencies:
|
||||
embedded-postgres@18.1.0-beta.16: patches/embedded-postgres@18.1.0-beta.16.patch
|
||||
acpx@0.13.1: patches/acpx@0.13.1.patch
|
||||
pi-acp@0.0.33: patches/pi-acp@0.0.33.patch
|
||||
'@agentclientprotocol/claude-agent-acp@0.70.0': patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch
|
||||
@@ -30,6 +30,10 @@ const runnerPackage = JSON.parse(
|
||||
);
|
||||
const acpxRuntimePatch = await readFile(new URL("../patches/acpx@0.13.1.patch", import.meta.url), "utf8");
|
||||
const piAcpPatch = await readFile(new URL("../patches/pi-acp@0.0.33.patch", import.meta.url), "utf8");
|
||||
const claudeAcpPatch = await readFile(
|
||||
new URL("../patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const dbPackage = JSON.parse(
|
||||
await readFile(new URL("../packages/db/package.json", import.meta.url), "utf8"),
|
||||
);
|
||||
@@ -51,6 +55,10 @@ test("published packages preserve the patched ACPX runtime", () => {
|
||||
test("Paperclip Runner pins the qualified ACPX host and Pi isolation callbacks", () => {
|
||||
assert.equal(rootPackage.pnpm.patchedDependencies["acpx@0.13.1"], "patches/acpx@0.13.1.patch");
|
||||
assert.equal(rootPackage.pnpm.patchedDependencies["pi-acp@0.0.33"], "patches/pi-acp@0.0.33.patch");
|
||||
assert.equal(
|
||||
rootPackage.pnpm.patchedDependencies["@agentclientprotocol/claude-agent-acp@0.70.0"],
|
||||
"patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch",
|
||||
);
|
||||
assert.equal(runnerPackage.dependencies.acpx, "0.13.1");
|
||||
assert.equal(runnerPackage.dependencies["pi-acp"], "0.0.33");
|
||||
assert.equal(runnerPackage.dependencies["@earendil-works/pi-coding-agent"], "0.84.2");
|
||||
@@ -61,7 +69,9 @@ test("Paperclip Runner pins the qualified ACPX host and Pi isolation callbacks",
|
||||
"onSessionNotification", "onClientOperation",
|
||||
]) assert.match(acpxRuntimePatch, new RegExp(callback));
|
||||
assert.match(piAcpPatch, /PI_ACP_PI_ARGS_JSON/);
|
||||
assert.match(piAcpPatch, /sessionUpdate: "usage_update"/);
|
||||
assert.match(piAcpPatch, /\[paperclip-pi-usage-v1\]/);
|
||||
assert.match(claudeAcpPatch, /usage: \{/);
|
||||
assert.match(claudeAcpPatch, /cache_creation_input_tokens/);
|
||||
});
|
||||
|
||||
test("published packages preserve the patched embedded-postgres runtime", () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ const mockHeartbeatService = vi.hoisted(() => ({
|
||||
getRunLogAccess: vi.fn(),
|
||||
readLog: vi.fn(),
|
||||
wakeup: vi.fn(),
|
||||
getRun: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
@@ -29,13 +30,26 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const mockRunSecretRedactionRegistry = vi.hoisted(() => ({
|
||||
redactForRun: vi.fn(async (_companyId: string, _runId: string, value: unknown) => value),
|
||||
redactForRun: vi.fn(
|
||||
async (_companyId: string, _runId: string, value: unknown) => value,
|
||||
),
|
||||
}));
|
||||
|
||||
const mockProviderTraceStore = vi.hoisted(() => ({
|
||||
inspect: vi.fn(),
|
||||
revealFrame: vi.fn(),
|
||||
download: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
listMetadataForRuns: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
const routeAgentId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||
vi.doMock("../routes/authz.js", async () =>
|
||||
vi.importActual("../routes/authz.js"),
|
||||
);
|
||||
|
||||
vi.doMock("../services/agents.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
@@ -57,6 +71,10 @@ function registerModuleMocks() {
|
||||
createRunSecretRedactionRegistry: () => mockRunSecretRedactionRegistry,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/provider-trace-store.js", () => ({
|
||||
providerTraceStore: () => mockProviderTraceStore,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
agentInstructionsService: () => ({}),
|
||||
@@ -77,7 +95,7 @@ function registerModuleMocks() {
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(),
|
||||
logActivity: mockLogActivity,
|
||||
secretService: () => ({}),
|
||||
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
|
||||
workspaceOperationService: () => ({}),
|
||||
@@ -92,21 +110,28 @@ function registerModuleMocks() {
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp(db: Record<string, unknown> = {}) {
|
||||
async function createApp(
|
||||
db: Record<string, unknown> = {},
|
||||
actor: Record<string, unknown> = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
) {
|
||||
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
|
||||
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/agents.js")>(
|
||||
"../routes/agents.js",
|
||||
),
|
||||
vi.importActual<typeof import("../middleware/index.js")>(
|
||||
"../middleware/index.js",
|
||||
),
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", agentRoutes(db as any));
|
||||
@@ -118,7 +143,8 @@ function createLiveRunsDbStub(rows: Array<Record<string, unknown>>) {
|
||||
const limit = vi.fn(async (value: number) => rows.slice(0, value));
|
||||
const orderedQuery = {
|
||||
limit,
|
||||
then: (resolve: (value: Array<Record<string, unknown>>) => unknown) => Promise.resolve(rows).then(resolve),
|
||||
then: (resolve: (value: Array<Record<string, unknown>>) => unknown) =>
|
||||
Promise.resolve(rows).then(resolve),
|
||||
};
|
||||
const query = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
@@ -139,7 +165,8 @@ async function requestApp(
|
||||
app: express.Express,
|
||||
buildRequest: (baseUrl: string) => request.Test,
|
||||
) {
|
||||
const { createServer } = await vi.importActual<typeof import("node:http")>("node:http");
|
||||
const { createServer } =
|
||||
await vi.importActual<typeof import("node:http")>("node:http");
|
||||
const server = createServer(app);
|
||||
try {
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -222,7 +249,9 @@ describe("agent live run routes", () => {
|
||||
agentId: "agent-1",
|
||||
issueId: "issue-1",
|
||||
});
|
||||
mockHeartbeatService.getActiveRunIssueSummaryForAgent.mockResolvedValue(null);
|
||||
mockHeartbeatService.getActiveRunIssueSummaryForAgent.mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
mockHeartbeatService.buildRunOutputSilence.mockResolvedValue(null);
|
||||
mockHeartbeatService.getRunLogAccess.mockResolvedValue({
|
||||
id: "run-1",
|
||||
@@ -245,17 +274,28 @@ describe("agent live run routes", () => {
|
||||
invocationSource: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
});
|
||||
mockHeartbeatService.getRun.mockResolvedValue({
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
agentId: "agent-1",
|
||||
status: "succeeded",
|
||||
});
|
||||
mockProviderTraceStore.inspect.mockResolvedValue({
|
||||
trace: null,
|
||||
entries: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a compact active run payload for issue polling", async () => {
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl).get("/api/issues/pc1a2-1295/active-run"),
|
||||
const res = await requestApp(await createApp(), (baseUrl) =>
|
||||
request(baseUrl).get("/api/issues/pc1a2-1295/active-run"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-1295");
|
||||
expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1");
|
||||
expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith(
|
||||
"run-1",
|
||||
);
|
||||
expect(res.body).toMatchObject({
|
||||
id: "run-1",
|
||||
status: "running",
|
||||
@@ -303,14 +343,17 @@ describe("agent live run routes", () => {
|
||||
issueId: "issue-1",
|
||||
});
|
||||
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl).get("/api/issues/PC1A2-1295/active-run"),
|
||||
const res = await requestApp(await createApp(), (baseUrl) =>
|
||||
request(baseUrl).get("/api/issues/PC1A2-1295/active-run"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1");
|
||||
expect(mockHeartbeatService.getActiveRunIssueSummaryForAgent).toHaveBeenCalledWith("agent-1");
|
||||
expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith(
|
||||
"run-1",
|
||||
);
|
||||
expect(
|
||||
mockHeartbeatService.getActiveRunIssueSummaryForAgent,
|
||||
).toHaveBeenCalledWith("agent-1");
|
||||
expect(res.body).toMatchObject({
|
||||
id: "run-1",
|
||||
issueId: "issue-1",
|
||||
@@ -330,9 +373,8 @@ describe("agent live run routes", () => {
|
||||
lastEventAt: new Date("2026-04-10T09:30:06.000Z"),
|
||||
}));
|
||||
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl).get("/api/issues/PC1A2-1295/active-run"),
|
||||
const res = await requestApp(await createApp(), (baseUrl) =>
|
||||
request(baseUrl).get("/api/issues/PC1A2-1295/active-run"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
@@ -350,22 +392,26 @@ describe("agent live run routes", () => {
|
||||
});
|
||||
|
||||
it("uses narrow run log metadata lookups for log polling", async () => {
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl).get("/api/heartbeat-runs/run-1/log?offset=12&limitBytes=64"),
|
||||
const res = await requestApp(await createApp(), (baseUrl) =>
|
||||
request(baseUrl).get(
|
||||
"/api/heartbeat-runs/run-1/log?offset=12&limitBytes=64",
|
||||
),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockHeartbeatService.getRunLogAccess).toHaveBeenCalledWith("run-1");
|
||||
expect(mockHeartbeatService.readLog).toHaveBeenCalledWith({
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
logStore: "local_file",
|
||||
logRef: "logs/run-1.ndjson",
|
||||
}, {
|
||||
offset: 12,
|
||||
limitBytes: 64,
|
||||
});
|
||||
expect(mockHeartbeatService.readLog).toHaveBeenCalledWith(
|
||||
{
|
||||
id: "run-1",
|
||||
companyId: "company-1",
|
||||
logStore: "local_file",
|
||||
logRef: "logs/run-1.ndjson",
|
||||
},
|
||||
{
|
||||
offset: 12,
|
||||
limitBytes: 64,
|
||||
},
|
||||
);
|
||||
expect(res.body).toEqual({
|
||||
runId: "run-1",
|
||||
store: "local_file",
|
||||
@@ -384,7 +430,9 @@ describe("agent live run routes", () => {
|
||||
triggerDetail: "manual",
|
||||
startedAt: new Date("2026-04-10T09:30:00.000Z"),
|
||||
finishedAt: null,
|
||||
createdAt: new Date(`2026-04-10T09:${String(index % 60).padStart(2, "0")}:00.000Z`),
|
||||
createdAt: new Date(
|
||||
`2026-04-10T09:${String(index % 60).padStart(2, "0")}:00.000Z`,
|
||||
),
|
||||
agentId: "agent-1",
|
||||
agentName: "Builder",
|
||||
adapterType: "codex_local",
|
||||
@@ -403,15 +451,16 @@ describe("agent live run routes", () => {
|
||||
}));
|
||||
const { db, limit } = createLiveRunsDbStub(rows);
|
||||
|
||||
const res = await requestApp(
|
||||
await createApp(db),
|
||||
(baseUrl) => request(baseUrl).get("/api/companies/company-1/live-runs"),
|
||||
const res = await requestApp(await createApp(db), (baseUrl) =>
|
||||
request(baseUrl).get("/api/companies/company-1/live-runs"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(limit).toHaveBeenCalledWith(50);
|
||||
expect(res.body).toHaveLength(50);
|
||||
expect(mockHeartbeatService.buildRunOutputSilence).toHaveBeenCalledTimes(50);
|
||||
expect(mockHeartbeatService.buildRunOutputSilence).toHaveBeenCalledTimes(
|
||||
50,
|
||||
);
|
||||
});
|
||||
|
||||
it("treats explicit zero or invalid live run limit as the capped default", async () => {
|
||||
@@ -423,7 +472,9 @@ describe("agent live run routes", () => {
|
||||
triggerDetail: "manual",
|
||||
startedAt: new Date("2026-04-10T09:30:00.000Z"),
|
||||
finishedAt: null,
|
||||
createdAt: new Date(`2026-04-10T09:${String(index % 60).padStart(2, "0")}:00.000Z`),
|
||||
createdAt: new Date(
|
||||
`2026-04-10T09:${String(index % 60).padStart(2, "0")}:00.000Z`,
|
||||
),
|
||||
agentId: "agent-1",
|
||||
agentName: "Builder",
|
||||
adapterType: "codex_local",
|
||||
@@ -442,9 +493,10 @@ describe("agent live run routes", () => {
|
||||
}));
|
||||
const { db, limit } = createLiveRunsDbStub(rows);
|
||||
|
||||
const res = await requestApp(
|
||||
await createApp(db),
|
||||
(baseUrl) => request(baseUrl).get("/api/companies/company-1/live-runs?limit=0&minCount=0"),
|
||||
const res = await requestApp(await createApp(db), (baseUrl) =>
|
||||
request(baseUrl).get(
|
||||
"/api/companies/company-1/live-runs?limit=0&minCount=0",
|
||||
),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
@@ -461,7 +513,9 @@ describe("agent live run routes", () => {
|
||||
triggerDetail: "manual",
|
||||
startedAt: new Date("2026-04-10T09:30:00.000Z"),
|
||||
finishedAt: null,
|
||||
createdAt: new Date(`2026-04-10T09:${String(index % 60).padStart(2, "0")}:00.000Z`),
|
||||
createdAt: new Date(
|
||||
`2026-04-10T09:${String(index % 60).padStart(2, "0")}:00.000Z`,
|
||||
),
|
||||
agentId: "agent-1",
|
||||
agentName: "Builder",
|
||||
adapterType: "codex_local",
|
||||
@@ -482,7 +536,9 @@ describe("agent live run routes", () => {
|
||||
const selectCalls: Array<ReturnType<typeof vi.fn>> = [];
|
||||
const db = {
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
const limitFn = vi.fn(async (value: number) => liveRows.slice(0, value));
|
||||
const limitFn = vi.fn(async (value: number) =>
|
||||
liveRows.slice(0, value),
|
||||
);
|
||||
const orderedQuery = {
|
||||
limit: limitFn,
|
||||
then: (resolve: (value: typeof liveRows) => unknown) =>
|
||||
@@ -499,9 +555,8 @@ describe("agent live run routes", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
const res = await requestApp(
|
||||
await createApp(db),
|
||||
(baseUrl) => request(baseUrl).get("/api/companies/company-1/live-runs"),
|
||||
const res = await requestApp(await createApp(db), (baseUrl) =>
|
||||
request(baseUrl).get("/api/companies/company-1/live-runs"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
@@ -518,7 +573,9 @@ describe("agent live run routes", () => {
|
||||
triggerDetail: "manual",
|
||||
startedAt: new Date("2026-04-10T09:30:00.000Z"),
|
||||
finishedAt: null,
|
||||
createdAt: new Date(`2026-04-10T09:${String(index % 60).padStart(2, "0")}:00.000Z`),
|
||||
createdAt: new Date(
|
||||
`2026-04-10T09:${String(index % 60).padStart(2, "0")}:00.000Z`,
|
||||
),
|
||||
agentId: "agent-1",
|
||||
agentName: "Builder",
|
||||
adapterType: "codex_local",
|
||||
@@ -543,7 +600,9 @@ describe("agent live run routes", () => {
|
||||
triggerDetail: "manual",
|
||||
startedAt: new Date("2026-04-09T09:30:00.000Z"),
|
||||
finishedAt: new Date("2026-04-09T09:35:00.000Z"),
|
||||
createdAt: new Date(`2026-04-09T09:${String(index % 60).padStart(2, "0")}:00.000Z`),
|
||||
createdAt: new Date(
|
||||
`2026-04-09T09:${String(index % 60).padStart(2, "0")}:00.000Z`,
|
||||
),
|
||||
agentId: "agent-1",
|
||||
agentName: "Builder",
|
||||
adapterType: "codex_local",
|
||||
@@ -581,9 +640,8 @@ describe("agent live run routes", () => {
|
||||
}),
|
||||
};
|
||||
|
||||
const res = await requestApp(
|
||||
await createApp(db),
|
||||
(baseUrl) => request(baseUrl).get("/api/companies/company-1/live-runs?minCount=4"),
|
||||
const res = await requestApp(await createApp(db), (baseUrl) =>
|
||||
request(baseUrl).get("/api/companies/company-1/live-runs?minCount=4"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
@@ -592,10 +650,11 @@ describe("agent live run routes", () => {
|
||||
});
|
||||
|
||||
it("passes scoped wake fields through the legacy heartbeat invoke route", async () => {
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl)
|
||||
.post(`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`)
|
||||
const res = await requestApp(await createApp(), (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post(
|
||||
`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`,
|
||||
)
|
||||
.send({
|
||||
reason: "issue_assigned",
|
||||
payload: {
|
||||
@@ -633,10 +692,11 @@ describe("agent live run routes", () => {
|
||||
});
|
||||
|
||||
it("calls heartbeat.wakeup with the legacy minimal shape when the body is empty", async () => {
|
||||
const res = await requestApp(
|
||||
await createApp(),
|
||||
(baseUrl) => request(baseUrl)
|
||||
.post(`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`)
|
||||
const res = await requestApp(await createApp(), (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post(
|
||||
`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`,
|
||||
)
|
||||
.send({}),
|
||||
);
|
||||
|
||||
@@ -652,4 +712,171 @@ describe("agent live run routes", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("allows implicit local administrators to opt one manual run into raw provider tracing", async () => {
|
||||
const res = await requestApp(await createApp(), (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post(
|
||||
`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`,
|
||||
)
|
||||
.send({ debug: { providerTrace: "raw" } }),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(202);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
routeAgentId,
|
||||
expect.objectContaining({
|
||||
contextSnapshot: expect.objectContaining({
|
||||
debug: { providerTrace: "raw" },
|
||||
providerTraceRequestedBy: "local-board",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks traced re-runs as explicit resumes so terminal issue context can execute", async () => {
|
||||
const issueId = "22222222-2222-4222-8222-222222222222";
|
||||
const res = await requestApp(await createApp(), (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post(`/api/agents/${routeAgentId}/wakeup?companyId=company-1`)
|
||||
.send({
|
||||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
reason: "rerun_with_provider_trace",
|
||||
payload: { issueId, taskId: issueId, taskKey: issueId },
|
||||
debug: { providerTrace: "raw" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(202);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||
routeAgentId,
|
||||
expect.objectContaining({
|
||||
contextSnapshot: expect.objectContaining({
|
||||
resumeIntent: true,
|
||||
debug: { providerTrace: "raw" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects raw provider tracing for ordinary board members", async () => {
|
||||
const res = await requestApp(
|
||||
await createApp(
|
||||
{},
|
||||
{
|
||||
type: "board",
|
||||
userId: "member-user",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
),
|
||||
(baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post(
|
||||
`/api/agents/${routeAgentId}/heartbeat/invoke?companyId=company-1`,
|
||||
)
|
||||
.send({ debug: { providerTrace: "raw" } }),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["get", "/api/companies/company-1/provider-traces?runIds=run-1"],
|
||||
["get", "/api/heartbeat-runs/run-1/provider-trace"],
|
||||
["post", "/api/heartbeat-runs/run-1/provider-trace/frames/1/reveal"],
|
||||
["get", "/api/heartbeat-runs/run-1/provider-trace/download"],
|
||||
["delete", "/api/heartbeat-runs/run-1/provider-trace"],
|
||||
] as const)(
|
||||
"requires instance administration to %s %s",
|
||||
async (method, path) => {
|
||||
const app = await createApp(
|
||||
{},
|
||||
{
|
||||
type: "board",
|
||||
userId: "member-user",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
},
|
||||
);
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)[method](path),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockHeartbeatService.getRun).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("lists trace status metadata without exposing payload contents", async () => {
|
||||
mockProviderTraceStore.listMetadataForRuns.mockResolvedValueOnce([
|
||||
{
|
||||
schema: "paperclip.provider_trace_metadata.v1",
|
||||
id: "trace-1",
|
||||
runId: "run-1",
|
||||
companyId: "company-1",
|
||||
status: "complete",
|
||||
provider: "codex",
|
||||
frameCount: 70,
|
||||
byteCount: 4096,
|
||||
digest: `sha256:${"a".repeat(64)}`,
|
||||
reason: null,
|
||||
requestedBy: "local-board",
|
||||
createdAt: new Date("2026-08-22T12:00:00.000Z"),
|
||||
expiresAt: new Date("2026-08-23T12:00:00.000Z"),
|
||||
deletedAt: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await requestApp(await createApp(), (baseUrl) =>
|
||||
request(baseUrl).get(
|
||||
"/api/companies/company-1/provider-traces?runIds=run-1",
|
||||
),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockProviderTraceStore.listMetadataForRuns).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
["run-1"],
|
||||
);
|
||||
expect(res.body[0]).not.toHaveProperty("rawBase64");
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "provider_trace.metadata_listed",
|
||||
details: expect.objectContaining({ payloadLogged: false }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns only the redacted inspection view and audits the access", async () => {
|
||||
mockProviderTraceStore.inspect.mockResolvedValue({
|
||||
trace: { id: "trace-1", status: "complete" },
|
||||
entries: [
|
||||
{
|
||||
kind: "frame",
|
||||
frameId: 1,
|
||||
parsed: { token: "[withheld]" },
|
||||
withheldPaths: ["token"],
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await requestApp(await createApp(), (baseUrl) =>
|
||||
request(baseUrl).get("/api/heartbeat-runs/run-1/provider-trace"),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body.entries[0]).not.toHaveProperty("rawBase64");
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "provider_trace.redacted_viewed",
|
||||
details: { traceId: "trace-1", rawPayloadRevealed: false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -522,6 +522,46 @@ describe.sequential("agent permission routes", () => {
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("requires instance administration to enable agent-scoped raw provider traces", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "agent-admin-user",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
||||
.patch(`/api/agents/${agentId}`)
|
||||
.send({ runtimeConfig: { debug: { providerTrace: "raw" } } }));
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows instance administrators to enable agent-scoped raw provider traces", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "instance-admin-user",
|
||||
source: "session",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [companyId],
|
||||
});
|
||||
|
||||
const res = await requestApp(app, (baseUrl) => request(baseUrl)
|
||||
.patch(`/api/agents/${agentId}`)
|
||||
.send({ runtimeConfig: { debug: { providerTrace: "raw" } } }));
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
agentId,
|
||||
expect.objectContaining({
|
||||
runtimeConfig: { debug: { providerTrace: "raw" } },
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks api key creation for authenticated company members without agent admin permission", async () => {
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
summarizeHeartbeatRunResultJson,
|
||||
buildHeartbeatRunIssueComment,
|
||||
LEGACY_WITHHELD_RUN_COMMENT,
|
||||
projectHistoricalHeartbeatRunComment,
|
||||
findHeartbeatRunCompletionComment,
|
||||
mergeHeartbeatRunResultJson,
|
||||
resolveHeartbeatRunResponse,
|
||||
} from "../services/heartbeat-run-summary.js";
|
||||
|
||||
describe("summarizeHeartbeatRunResultJson", () => {
|
||||
@@ -40,8 +43,15 @@ describe("summarizeHeartbeatRunResultJson", () => {
|
||||
|
||||
it("returns null for non-object and irrelevant payloads", () => {
|
||||
expect(summarizeHeartbeatRunResultJson(null)).toBeNull();
|
||||
expect(summarizeHeartbeatRunResultJson(["nope"] as unknown as Record<string, unknown>)).toBeNull();
|
||||
expect(summarizeHeartbeatRunResultJson({ nested: { only: "ignored" } })).toBeNull();
|
||||
expect(
|
||||
summarizeHeartbeatRunResultJson(["nope"] as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>),
|
||||
).toBeNull();
|
||||
expect(
|
||||
summarizeHeartbeatRunResultJson({ nested: { only: "ignored" } }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,7 +68,9 @@ describe("buildHeartbeatRunIssueComment", () => {
|
||||
|
||||
it("falls back to result or message when summary is missing", () => {
|
||||
expect(buildHeartbeatRunIssueComment({ result: "done" })).toBe("done");
|
||||
expect(buildHeartbeatRunIssueComment({ message: "completed" })).toBe("completed");
|
||||
expect(buildHeartbeatRunIssueComment({ message: "completed" })).toBe(
|
||||
"completed",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when there is no usable final text", () => {
|
||||
@@ -70,8 +82,7 @@ describe("buildHeartbeatRunIssueComment", () => {
|
||||
"Let me check the issue thread first. I'll fetch the latest comments and then decide what to do next.";
|
||||
const comment = buildHeartbeatRunIssueComment({ summary: narration });
|
||||
|
||||
expect(comment).not.toContain("Let me check");
|
||||
expect(comment).toContain("did not post a summary comment");
|
||||
expect(comment).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses each narration opener variant", () => {
|
||||
@@ -88,9 +99,7 @@ describe("buildHeartbeatRunIssueComment", () => {
|
||||
"Now I'll push the follow-up commit.",
|
||||
"Next, I'll re-run the suite.",
|
||||
]) {
|
||||
expect(buildHeartbeatRunIssueComment({ summary: opener })).toContain(
|
||||
"did not post a summary comment",
|
||||
);
|
||||
expect(buildHeartbeatRunIssueComment({ summary: opener })).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -101,21 +110,150 @@ describe("buildHeartbeatRunIssueComment", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("suppresses over-long fallback summaries even without a narration opener", () => {
|
||||
const comment = buildHeartbeatRunIssueComment({ summary: "x".repeat(1201) });
|
||||
expect(comment).toContain("did not post a summary comment");
|
||||
expect(comment).not.toContain("xxxx");
|
||||
it("never suppresses a response because of its length", () => {
|
||||
const summary = "x".repeat(20_000);
|
||||
expect(buildHeartbeatRunIssueComment({ summary })).toBe(summary);
|
||||
});
|
||||
|
||||
it("posts a clean, in-length summary with no narration opener normally", () => {
|
||||
const summary = "## Summary\n\n- fixed the fallback gate\n- added regression tests";
|
||||
const summary =
|
||||
"## Summary\n\n- fixed the fallback gate\n- added regression tests";
|
||||
expect(buildHeartbeatRunIssueComment({ summary })).toBe(summary);
|
||||
});
|
||||
|
||||
it("posts a summary exactly at the length cap", () => {
|
||||
const summary = "S" + "x".repeat(1199);
|
||||
expect(summary.length).toBe(1200);
|
||||
expect(buildHeartbeatRunIssueComment({ summary })).toBe(summary);
|
||||
it("uses an accepted semantic result even when it resembles narration", () => {
|
||||
const summary =
|
||||
"Let me give you the complete recipe now.\n\n" + "x".repeat(1_420);
|
||||
expect(
|
||||
buildHeartbeatRunIssueComment({
|
||||
summary: "",
|
||||
nativeResult: {
|
||||
schema: "paperclip.run_result.v1",
|
||||
reportedWorkDisposition: "done",
|
||||
summary,
|
||||
},
|
||||
}),
|
||||
).toBe(summary);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHeartbeatRunResponse", () => {
|
||||
const resultJson = {
|
||||
nativeResult: {
|
||||
schema: "paperclip.run_result.v1",
|
||||
reportedWorkDisposition: "done",
|
||||
summary: "semantic result",
|
||||
},
|
||||
};
|
||||
|
||||
it("applies comment, final-message, and semantic-result precedence", () => {
|
||||
expect(
|
||||
resolveHeartbeatRunResponse({
|
||||
resultJson,
|
||||
existingComment: { id: "comment-1", body: "posted response" },
|
||||
finalAgentMessage: {
|
||||
text: "provider response",
|
||||
sourceEventId: "event-1",
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
text: "posted response",
|
||||
decision: {
|
||||
chosenSource: "existing_issue_comment",
|
||||
commentAction: "reuse",
|
||||
commentId: "comment-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveHeartbeatRunResponse({
|
||||
resultJson,
|
||||
finalAgentMessage: {
|
||||
text: "provider response",
|
||||
sourceEventId: "event-1",
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
text: "provider response",
|
||||
decision: {
|
||||
chosenSource: "final_agent_message",
|
||||
sourceEventId: "event-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveHeartbeatRunResponse({ resultJson })).toMatchObject({
|
||||
text: "semantic result",
|
||||
decision: { chosenSource: "semantic_result_summary" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns no response instead of an artificial placeholder", () => {
|
||||
expect(resolveHeartbeatRunResponse({ resultJson: null })).toMatchObject({
|
||||
text: null,
|
||||
decision: { chosenSource: "none", commentAction: "none" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render a serialized semantic result as the final prose", () => {
|
||||
expect(
|
||||
resolveHeartbeatRunResponse({
|
||||
resultJson,
|
||||
finalAgentMessage: {
|
||||
text: JSON.stringify(resultJson.nativeResult),
|
||||
sourceEventId: "event-structured-result",
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
text: "semantic result",
|
||||
decision: { chosenSource: "semantic_result_summary" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let an empty issue comment hide an upstream response", () => {
|
||||
expect(
|
||||
resolveHeartbeatRunResponse({
|
||||
resultJson,
|
||||
existingComment: { id: "empty-comment", body: " " },
|
||||
}),
|
||||
).toMatchObject({
|
||||
text: "semantic result",
|
||||
decision: { chosenSource: "semantic_result_summary" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the exact upstream response text", () => {
|
||||
const text = "\n final response with intentional whitespace \n";
|
||||
expect(
|
||||
resolveHeartbeatRunResponse({
|
||||
resultJson,
|
||||
finalAgentMessage: { text, sourceEventId: "event-exact" },
|
||||
}).text,
|
||||
).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectHistoricalHeartbeatRunComment", () => {
|
||||
it("projects the accepted semantic response over the known placeholder", () => {
|
||||
const summary = "# Full recipe\n\n" + "ribs ".repeat(400);
|
||||
expect(
|
||||
projectHistoricalHeartbeatRunComment(LEGACY_WITHHELD_RUN_COMMENT, {
|
||||
nativeResult: {
|
||||
schema: "paperclip.run_result.v1",
|
||||
summary,
|
||||
},
|
||||
}),
|
||||
).toBe(summary);
|
||||
});
|
||||
|
||||
it("does not rewrite ordinary historical comments", () => {
|
||||
expect(
|
||||
projectHistoricalHeartbeatRunComment("Real response", {
|
||||
nativeResult: {
|
||||
schema: "paperclip.run_result.v1",
|
||||
summary: "Different response",
|
||||
},
|
||||
}),
|
||||
).toBe("Real response");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,13 +270,19 @@ describe("findHeartbeatRunCompletionComment", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(findHeartbeatRunCompletionComment([progress], resultJson)).toBeNull();
|
||||
expect(findHeartbeatRunCompletionComment([final, progress], resultJson)).toEqual(final);
|
||||
expect(
|
||||
findHeartbeatRunCompletionComment([progress], resultJson),
|
||||
).toBeNull();
|
||||
expect(
|
||||
findHeartbeatRunCompletionComment([final, progress], resultJson),
|
||||
).toEqual(final);
|
||||
});
|
||||
|
||||
it("preserves the fallback-only behavior for ordinary agent comments", () => {
|
||||
const comment = { id: "manual-comment" };
|
||||
expect(findHeartbeatRunCompletionComment([comment], { summary: "done" })).toEqual(comment);
|
||||
expect(
|
||||
findHeartbeatRunCompletionComment([comment], { summary: "done" }),
|
||||
).toEqual(comment);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,11 +298,15 @@ describe("mergeHeartbeatRunResultJson", () => {
|
||||
stderr: "",
|
||||
summary: "## Summary\n\n1. first thing\n2. second thing",
|
||||
});
|
||||
expect(buildHeartbeatRunIssueComment(merged)).toBe("## Summary\n\n1. first thing\n2. second thing");
|
||||
expect(buildHeartbeatRunIssueComment(merged)).toBe(
|
||||
"## Summary\n\n1. first thing\n2. second thing",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a result payload when only a summary exists", () => {
|
||||
expect(mergeHeartbeatRunResultJson(null, "done")).toEqual({ summary: "done" });
|
||||
expect(mergeHeartbeatRunResultJson(null, "done")).toEqual({
|
||||
summary: "done",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite an explicit summary already returned by the adapter", () => {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
assessProviderTraceEntries,
|
||||
redactProviderTraceFrame,
|
||||
} from "../services/provider-trace-store.js";
|
||||
|
||||
function frame(payload: unknown) {
|
||||
return {
|
||||
kind: "frame",
|
||||
schema: "paperclip.provider_trace_frame.v1",
|
||||
frameId: 1,
|
||||
rawBase64: Buffer.from(JSON.stringify(payload)).toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("provider trace redaction", () => {
|
||||
it("removes exact bytes and masks secret-shaped fields and values", () => {
|
||||
const redacted = redactProviderTraceFrame(
|
||||
frame({
|
||||
authorization: "Bearer exact-token-value",
|
||||
message: "sk-abcdefghijklmnopqrstuvwxyz",
|
||||
nested: { apiKey: "also-secret", safe: "visible" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(redacted).not.toHaveProperty("rawBase64");
|
||||
expect(redacted.parsed).toEqual({
|
||||
authorization: "[withheld]",
|
||||
message: "[withheld]",
|
||||
nested: { apiKey: "[withheld]", safe: "visible" },
|
||||
});
|
||||
expect(redacted.withheldPaths).toEqual([
|
||||
"authorization",
|
||||
"message",
|
||||
"nested.apiKey",
|
||||
]);
|
||||
});
|
||||
|
||||
it("withholds reasoning item content while retaining routing metadata", () => {
|
||||
const redacted = redactProviderTraceFrame(
|
||||
frame({
|
||||
item: {
|
||||
id: "reason-1",
|
||||
type: "reasoning",
|
||||
status: "completed",
|
||||
summary: ["private chain"],
|
||||
encrypted_content: "ciphertext",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(redacted.parsed).toEqual({
|
||||
item: {
|
||||
id: "reason-1",
|
||||
type: "reasoning",
|
||||
status: "completed",
|
||||
summary: "[withheld]",
|
||||
encrypted_content: "[withheld]",
|
||||
},
|
||||
});
|
||||
expect(redacted.withheldPaths).toContain("item.summary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider trace channel integrity", () => {
|
||||
function completeChannel(channel: string, raw = Buffer.from("{}")) {
|
||||
return [
|
||||
{
|
||||
kind: "frame",
|
||||
debugChannel: channel,
|
||||
debugSequence: 1,
|
||||
frameId: 1,
|
||||
rawBase64: raw.toString("base64"),
|
||||
byteLength: raw.byteLength,
|
||||
digest: `sha256:${createHash("sha256").update(raw).digest("hex")}`,
|
||||
},
|
||||
{
|
||||
kind: "trace_status",
|
||||
debugChannel: channel,
|
||||
debugSequence: 2,
|
||||
acknowledgedDebugSequence: 1,
|
||||
status: "complete",
|
||||
reason: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
it("accepts independently sequenced and acknowledged debug channels", () => {
|
||||
expect(
|
||||
assessProviderTraceEntries([
|
||||
...completeChannel("rust_native"),
|
||||
...completeChannel("typescript_runnerd_rehydration"),
|
||||
]),
|
||||
).toEqual({ status: "complete", reason: null });
|
||||
});
|
||||
|
||||
it("marks gaps, missing acknowledgements, and digest changes incomplete", () => {
|
||||
const gap = completeChannel("rust_native");
|
||||
gap[1]!.debugSequence = 3;
|
||||
expect(assessProviderTraceEntries(gap).reason).toBe(
|
||||
"trace_debug_sequence_gap:rust_native",
|
||||
);
|
||||
|
||||
const noAck = completeChannel("rust_native").slice(0, 1);
|
||||
expect(assessProviderTraceEntries(noAck).reason).toBe(
|
||||
"trace_channel_ack_missing:rust_native",
|
||||
);
|
||||
|
||||
const changed = completeChannel("rust_native");
|
||||
changed[0]!.rawBase64 = Buffer.from("changed").toString("base64");
|
||||
expect(assessProviderTraceEntries(changed).reason).toBe(
|
||||
"provider_frame_digest_mismatch",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
testEnvironment as claudeTestEnvironment,
|
||||
sessionCodec as claudeSessionCodec,
|
||||
getQuotaWindows as claudeGetQuotaWindows,
|
||||
readClaudeAuthStatus,
|
||||
getConfigSchema as getClaudeConfigSchema,
|
||||
CLAUDE_SETUP_TOKEN_COMMAND,
|
||||
parseSetupTokenPrompt,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
testEnvironment as codexTestEnvironment,
|
||||
sessionCodec as codexSessionCodec,
|
||||
getQuotaWindows as codexGetQuotaWindows,
|
||||
readCodexAuthInfo,
|
||||
getConfigSchema as getCodexConfigSchema,
|
||||
CODEX_DEVICE_LOGIN_COMMAND,
|
||||
parseDeviceLoginPrompt,
|
||||
@@ -244,11 +246,17 @@ async function testPaperclipRunnerAcpxEnvironment(
|
||||
: {};
|
||||
const environment = { ...process.env };
|
||||
for (const [key, value] of Object.entries(configuredEnv)) if (typeof value === "string") environment[key] = value;
|
||||
const managedClaudeAuth = agent === "claude" && !environment.ANTHROPIC_API_KEY && !environment.CLAUDE_CODE_OAUTH_TOKEN
|
||||
? await readClaudeAuthStatus()
|
||||
: null;
|
||||
const managedCodexAuth = agent === "codex" && !environment.OPENAI_API_KEY && !environment.CODEX_API_KEY
|
||||
? await readCodexAuthInfo()
|
||||
: null;
|
||||
const credentialReady = agent === "pi"
|
||||
? Boolean(environment.OPENROUTER_API_KEY)
|
||||
: agent === "claude"
|
||||
? Boolean(environment.ANTHROPIC_API_KEY || environment.CLAUDE_CODE_OAUTH_TOKEN)
|
||||
: Boolean(environment.OPENAI_API_KEY || environment.CODEX_API_KEY);
|
||||
? Boolean(environment.ANTHROPIC_API_KEY || environment.CLAUDE_CODE_OAUTH_TOKEN || managedClaudeAuth?.loggedIn)
|
||||
: Boolean(environment.OPENAI_API_KEY || environment.CODEX_API_KEY || managedCodexAuth);
|
||||
if (!credentialReady) {
|
||||
const required = agent === "pi"
|
||||
? "OPENROUTER_API_KEY"
|
||||
|
||||
+2821
-1477
File diff suppressed because it is too large.
Load diff
@@ -402,6 +402,9 @@ export function activityService(db: Db) {
|
||||
continuationAttempt: heartbeatRuns.continuationAttempt,
|
||||
lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt,
|
||||
nextAction: heartbeatRuns.nextAction,
|
||||
wakeCommentIds: sql<string[] | null>`${heartbeatRuns.contextSnapshot} -> 'wakeCommentIds'`,
|
||||
wakeCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`,
|
||||
contextCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'commentId'`,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import type {
|
||||
RunPresentationDecision,
|
||||
RunPresentationSource,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export const HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS = 500;
|
||||
export const HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS = 4_096;
|
||||
export const HEARTBEAT_RUN_SAFE_RESULT_JSON_MAX_BYTES = 64 * 1024;
|
||||
|
||||
function truncateSummaryText(value: unknown, maxLength = HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS) {
|
||||
function truncateSummaryText(
|
||||
value: unknown,
|
||||
maxLength = HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS,
|
||||
) {
|
||||
if (typeof value !== "string") return null;
|
||||
return value.length > maxLength ? value.slice(0, maxLength) : value;
|
||||
}
|
||||
|
||||
function readNumericField(record: Record<string, unknown>, key: string) {
|
||||
return key in record ? record[key] ?? null : undefined;
|
||||
return key in record ? (record[key] ?? null) : undefined;
|
||||
}
|
||||
|
||||
function readCommentText(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
return value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function mergeHeartbeatRunResultJson(
|
||||
@@ -48,7 +55,11 @@ export function mergeHeartbeatRunResultJson(
|
||||
export function summarizeHeartbeatRunResultJson(
|
||||
resultJson: Record<string, unknown> | null | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
if (!resultJson || typeof resultJson !== "object" || Array.isArray(resultJson)) {
|
||||
if (
|
||||
!resultJson ||
|
||||
typeof resultJson !== "object" ||
|
||||
Array.isArray(resultJson)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -61,7 +72,11 @@ export function summarizeHeartbeatRunResultJson(
|
||||
}
|
||||
}
|
||||
|
||||
const numericFieldAliases = ["total_cost_usd", "cost_usd", "costUsd"] as const;
|
||||
const numericFieldAliases = [
|
||||
"total_cost_usd",
|
||||
"cost_usd",
|
||||
"costUsd",
|
||||
] as const;
|
||||
for (const key of numericFieldAliases) {
|
||||
const value = readNumericField(resultJson, key);
|
||||
if (value !== undefined && value !== null) {
|
||||
@@ -92,39 +107,194 @@ export function summarizeHeartbeatRunResultJson(
|
||||
return Object.keys(summary).length > 0 ? summary : null;
|
||||
}
|
||||
|
||||
// The fallback comment is only posted when a run ends without the agent posting
|
||||
// its own comment via the API. In that case `resultJson.summary` can be raw
|
||||
// inter-tool narration (assistantTexts concatenated by the adapter), which must
|
||||
// never be published verbatim to the board — see BRO-1507 / BRO-1516.
|
||||
export const MAX_FALLBACK_COMMENT_CHARS = 1200;
|
||||
// An untyped adapter summary can be raw inter-tool narration (assistantTexts
|
||||
// concatenated by the adapter), which must never be published verbatim to the
|
||||
// board — see BRO-1507 / BRO-1516. Typed final messages and accepted PRP results
|
||||
// are semantic output, so they intentionally bypass this legacy safety check.
|
||||
// Apostrophes are matched as a character class so both the straight (') and
|
||||
// curly (’) forms count — agents emit either. Openers are narration phrases a
|
||||
// declarative status summary would not begin with ("Fixed X", "13/13 pass").
|
||||
const NARRATION_OPENERS =
|
||||
/^(let me\b|i['’]ll\b|i['’]m going\b|i need to\b|i can see\b|now i['’]ll\b|next,? i['’]ll\b|looking at\b|fetching\b|checking\b|first,)/i;
|
||||
const FALLBACK_WITHHELD_COMMENT =
|
||||
|
||||
export const LEGACY_WITHHELD_RUN_COMMENT =
|
||||
"Run completed. Agent did not post a summary comment this run (transcript withheld — see run log).";
|
||||
|
||||
export const RUN_PRESENTATION_RESOLVER_VERSION = "1";
|
||||
|
||||
export type RunPresentationCommentAction = "reuse" | "create" | "none";
|
||||
export type { RunPresentationDecision } from "@paperclipai/shared";
|
||||
|
||||
export interface ResolvedHeartbeatRunResponse {
|
||||
text: string | null;
|
||||
decision: RunPresentationDecision;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function readAcceptedSemanticSummary(resultJson: Record<string, unknown>) {
|
||||
const candidates = [
|
||||
record(resultJson.nativeResult),
|
||||
record(resultJson.acceptedResult),
|
||||
record(record(resultJson.semanticResult).result),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.schema !== "paperclip.run_result.v1") continue;
|
||||
const summary = readCommentText(candidate.summary);
|
||||
if (summary) return summary;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function projectHistoricalHeartbeatRunComment(
|
||||
body: string,
|
||||
resultJson: Record<string, unknown> | null | undefined,
|
||||
) {
|
||||
if (body !== LEGACY_WITHHELD_RUN_COMMENT) return body;
|
||||
return readAcceptedSemanticSummary(record(resultJson)) ?? body;
|
||||
}
|
||||
|
||||
function readMarkedAdapterFinalResponse(resultJson: Record<string, unknown>) {
|
||||
const structured = record(resultJson.finalResponse);
|
||||
if (structured.disposition === "final" || structured.final === true) {
|
||||
return (
|
||||
readCommentText(structured.text) ?? readCommentText(structured.message)
|
||||
);
|
||||
}
|
||||
if (resultJson.finalResponseDisposition === "final") {
|
||||
return (
|
||||
readCommentText(resultJson.finalResponseText) ??
|
||||
readCommentText(resultJson.finalResponse)
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isStructuredSemanticResultText(value: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return Boolean(
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
!Array.isArray(parsed) &&
|
||||
(parsed as Record<string, unknown>).schema === "paperclip.run_result.v1",
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function decision(
|
||||
chosenSource: RunPresentationSource,
|
||||
input: {
|
||||
sourceEventId?: string | null;
|
||||
commentAction: RunPresentationCommentAction;
|
||||
commentId?: string | null;
|
||||
reasonCodes: string[];
|
||||
},
|
||||
): RunPresentationDecision {
|
||||
return {
|
||||
schema: "paperclip.run_presentation_decision.v1",
|
||||
resolverVersion: RUN_PRESENTATION_RESOLVER_VERSION,
|
||||
chosenSource,
|
||||
sourceEventId: input.sourceEventId ?? null,
|
||||
commentAction: input.commentAction,
|
||||
commentId: input.commentId ?? null,
|
||||
activityDisposition: "collapse",
|
||||
reasonCodes: input.reasonCodes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve durable user-facing prose independently from the semantic run status.
|
||||
* The returned text is never truncated. Callers may persist only the bounded
|
||||
* decision record and materialize the exact text as an issue comment.
|
||||
*/
|
||||
export function resolveHeartbeatRunResponse(input: {
|
||||
resultJson: Record<string, unknown> | null | undefined;
|
||||
existingComment?: { id: string; body?: string | null } | null;
|
||||
finalAgentMessage?: { text: string; sourceEventId: string | null } | null;
|
||||
}): ResolvedHeartbeatRunResponse {
|
||||
const existingText = readCommentText(input.existingComment?.body);
|
||||
if (input.existingComment && existingText) {
|
||||
return {
|
||||
text: existingText,
|
||||
decision: decision("existing_issue_comment", {
|
||||
commentAction: "reuse",
|
||||
commentId: input.existingComment.id,
|
||||
reasonCodes: ["explicit_non_progress_comment_precedence"],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const finalAgentText = readCommentText(input.finalAgentMessage?.text);
|
||||
if (finalAgentText && !isStructuredSemanticResultText(finalAgentText)) {
|
||||
return {
|
||||
text: finalAgentText,
|
||||
decision: decision("final_agent_message", {
|
||||
sourceEventId: input.finalAgentMessage?.sourceEventId,
|
||||
commentAction: "create",
|
||||
reasonCodes: ["latest_non_empty_completed_final_agent_message"],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const resultJson = record(input.resultJson);
|
||||
const semanticSummary = readAcceptedSemanticSummary(resultJson);
|
||||
if (semanticSummary) {
|
||||
return {
|
||||
text: semanticSummary,
|
||||
decision: decision("semantic_result_summary", {
|
||||
commentAction: "create",
|
||||
reasonCodes: ["accepted_semantic_result_summary"],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const adapterFinal = readMarkedAdapterFinalResponse(resultJson);
|
||||
if (adapterFinal) {
|
||||
return {
|
||||
text: adapterFinal,
|
||||
decision: decision("adapter_final_response", {
|
||||
commentAction: "create",
|
||||
reasonCodes: ["adapter_output_marked_final"],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const legacyText =
|
||||
readCommentText(resultJson.summary) ??
|
||||
readCommentText(resultJson.result) ??
|
||||
readCommentText(resultJson.message);
|
||||
if (legacyText && !NARRATION_OPENERS.test(legacyText.trimStart())) {
|
||||
return {
|
||||
text: legacyText,
|
||||
decision: decision("adapter_final_response", {
|
||||
commentAction: "create",
|
||||
reasonCodes: ["legacy_adapter_summary_compatibility"],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: null,
|
||||
decision: decision("none", {
|
||||
commentAction: "none",
|
||||
reasonCodes: legacyText
|
||||
? ["legacy_adapter_summary_ambiguous"]
|
||||
: ["no_user_facing_response"],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHeartbeatRunIssueComment(
|
||||
resultJson: Record<string, unknown> | null | undefined,
|
||||
): string | null {
|
||||
if (!resultJson || typeof resultJson !== "object" || Array.isArray(resultJson)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text =
|
||||
readCommentText(resultJson.summary)
|
||||
?? readCommentText(resultJson.result)
|
||||
?? readCommentText(resultJson.message);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (text.length > MAX_FALLBACK_COMMENT_CHARS || NARRATION_OPENERS.test(text)) {
|
||||
return FALLBACK_WITHHELD_COMMENT;
|
||||
}
|
||||
|
||||
return text;
|
||||
return resolveHeartbeatRunResponse({ resultJson }).text;
|
||||
}
|
||||
|
||||
export function findHeartbeatRunCompletionComment<T extends { id: string }>(
|
||||
@@ -138,16 +308,20 @@ export function findHeartbeatRunCompletionComment<T extends { id: string }>(
|
||||
|
||||
const progressCommentIds = new Set<string>();
|
||||
for (const receipt of Object.values(receipts)) {
|
||||
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) continue;
|
||||
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt))
|
||||
continue;
|
||||
const receiptRecord = receipt as Record<string, unknown>;
|
||||
if (receiptRecord.operationId !== "report_progress") continue;
|
||||
const result = receiptRecord.result;
|
||||
if (!result || typeof result !== "object" || Array.isArray(result)) continue;
|
||||
if (!result || typeof result !== "object" || Array.isArray(result))
|
||||
continue;
|
||||
const commentId = (result as Record<string, unknown>).commentId;
|
||||
if (typeof commentId === "string" && commentId.length > 0) {
|
||||
progressCommentIds.add(commentId);
|
||||
}
|
||||
}
|
||||
|
||||
return comments.find((comment) => !progressCommentIds.has(comment.id)) ?? null;
|
||||
return (
|
||||
comments.find((comment) => !progressCommentIds.has(comment.id)) ?? null
|
||||
);
|
||||
}
|
||||
+8953
-5424
File diff suppressed because it is too large.
Load diff
@@ -95,6 +95,10 @@ import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallbac
|
||||
import { getRunLogStore } from "./run-log-store.js";
|
||||
import { getDefaultCompanyGoal } from "./goals.js";
|
||||
import { assertAssignableAgent } from "./agent-assignability.js";
|
||||
import {
|
||||
LEGACY_WITHHELD_RUN_COMMENT,
|
||||
projectHistoricalHeartbeatRunComment,
|
||||
} from "./heartbeat-run-summary.js";
|
||||
import { DEFAULT_INSERT_CHUNK_ROWS, insertRowsInChunks } from "./batch-insert.js";
|
||||
import type {
|
||||
ImportIssueRow,
|
||||
@@ -4427,6 +4431,37 @@ export function issueService(db: Db) {
|
||||
return enriched;
|
||||
}
|
||||
|
||||
async function projectHistoricalRunComments<
|
||||
T extends { body: string; createdByRunId: string | null },
|
||||
>(comments: T[]): Promise<T[]> {
|
||||
const runIds = [
|
||||
...new Set(
|
||||
comments.flatMap((comment) =>
|
||||
comment.createdByRunId &&
|
||||
comment.body === LEGACY_WITHHELD_RUN_COMMENT
|
||||
? [comment.createdByRunId]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
];
|
||||
if (runIds.length === 0) return comments;
|
||||
const runResults = await db
|
||||
.select({ id: heartbeatRuns.id, resultJson: heartbeatRuns.resultJson })
|
||||
.from(heartbeatRuns)
|
||||
.where(inArray(heartbeatRuns.id, runIds));
|
||||
const resultByRunId = new Map(
|
||||
runResults.map((run) => [run.id, parseObject(run.resultJson)]),
|
||||
);
|
||||
return comments.map((comment) => {
|
||||
if (!comment.createdByRunId) return comment;
|
||||
const body = projectHistoricalHeartbeatRunComment(
|
||||
comment.body,
|
||||
resultByRunId.get(comment.createdByRunId),
|
||||
);
|
||||
return body === comment.body ? comment : { ...comment, body };
|
||||
});
|
||||
}
|
||||
|
||||
async function getCurrentScheduledRetryForIssue(issueId: string, companyId: string): Promise<IssueScheduledRetryRow | null> {
|
||||
const row = await db
|
||||
.select({
|
||||
@@ -8578,7 +8613,8 @@ export function issueService(db: Db) {
|
||||
|
||||
const comments = limit ? await query.limit(limit) : await query;
|
||||
const { censorUsernameInLogs } = await instanceSettings.getGeneral();
|
||||
const enrichedComments = await enrichCommentsWithDerivedAgentAttribution(comments);
|
||||
const projectedComments = await projectHistoricalRunComments(comments);
|
||||
const enrichedComments = await enrichCommentsWithDerivedAgentAttribution(projectedComments);
|
||||
return enrichedComments.map((comment) => redactIssueComment(comment, censorUsernameInLogs));
|
||||
},
|
||||
|
||||
@@ -8618,8 +8654,14 @@ export function issueService(db: Db) {
|
||||
.where(eq(issueComments.id, commentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!comment) return null;
|
||||
const [enrichedComment] = await enrichCommentsWithDerivedAgentAttribution([comment]);
|
||||
return redactIssueComment(enrichedComment ?? comment, censorUsernameInLogs);
|
||||
const [projectedComment] = await projectHistoricalRunComments([comment]);
|
||||
const [enrichedComment] = await enrichCommentsWithDerivedAgentAttribution([
|
||||
projectedComment ?? comment,
|
||||
]);
|
||||
return redactIssueComment(
|
||||
enrichedComment ?? projectedComment ?? comment,
|
||||
censorUsernameInLogs,
|
||||
);
|
||||
},
|
||||
|
||||
removeComment: async (commentId: string) => {
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { and, eq, inArray, isNull, lt } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { providerTraceRecords } from "@paperclipai/db";
|
||||
import type {
|
||||
ProviderTraceFrame,
|
||||
ProviderTraceMetadata,
|
||||
} from "@paperclipai/shared";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
|
||||
export const PROVIDER_TRACE_MAX_BYTES = 64 * 1024 * 1024;
|
||||
export const PROVIDER_TRACE_RETENTION_MS = 24 * 60 * 60 * 1_000;
|
||||
|
||||
type TraceRow = typeof providerTraceRecords.$inferSelect;
|
||||
|
||||
function traceRoot() {
|
||||
return (
|
||||
process.env.PROVIDER_TRACE_BASE_PATH ??
|
||||
path.resolve(resolvePaperclipInstanceRoot(), "data", "provider-traces")
|
||||
);
|
||||
}
|
||||
|
||||
function tracePath(traceRef: string) {
|
||||
if (
|
||||
path.basename(traceRef) !== traceRef ||
|
||||
!/^[a-f0-9-]+\.ndjson$/.test(traceRef)
|
||||
) {
|
||||
throw new Error("invalid_provider_trace_ref");
|
||||
}
|
||||
return path.resolve(traceRoot(), traceRef);
|
||||
}
|
||||
|
||||
function rehydrationTracePath(traceRef: string) {
|
||||
return `${tracePath(traceRef)}.rehydration`;
|
||||
}
|
||||
|
||||
function asMetadata(row: TraceRow): ProviderTraceMetadata {
|
||||
return {
|
||||
schema: "paperclip.provider_trace_metadata.v1",
|
||||
id: row.id,
|
||||
runId: row.runId,
|
||||
companyId: row.companyId,
|
||||
status: row.status as ProviderTraceMetadata["status"],
|
||||
provider: row.provider,
|
||||
frameCount: row.frameCount,
|
||||
byteCount: row.byteCount,
|
||||
digest: row.digest as ProviderTraceMetadata["digest"],
|
||||
reason: row.reason,
|
||||
requestedBy: row.requestedBy,
|
||||
createdAt: row.createdAt,
|
||||
expiresAt: row.expiresAt,
|
||||
deletedAt: row.deletedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
const SECRET_KEY =
|
||||
/(authorization|cookie|credential|password|passwd|secret|token|api[_-]?key|private[_-]?key)/i;
|
||||
const REASONING_KEY =
|
||||
/(^|[_-])(analysis|reasoning|chain[_-]?of[_-]?thought)($|[_-])/i;
|
||||
const SECRET_VALUE =
|
||||
/(?:\bBearer\s+[A-Za-z0-9._~+/=-]{8,}|\bsk-[A-Za-z0-9_-]{12,}|-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----)/i;
|
||||
|
||||
function redactParsed(
|
||||
value: unknown,
|
||||
pathParts: string[] = [],
|
||||
withheld: string[] = [],
|
||||
): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item, index) =>
|
||||
redactParsed(item, [...pathParts, String(index)], withheld),
|
||||
);
|
||||
}
|
||||
if (typeof value === "string" && SECRET_VALUE.test(value)) {
|
||||
withheld.push(pathParts.join(".") || "$raw");
|
||||
return "[withheld]";
|
||||
}
|
||||
if (!value || typeof value !== "object") return value;
|
||||
const source = value as Record<string, unknown>;
|
||||
const reasoningObject =
|
||||
source.type === "reasoning" || source.type === "analysis";
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(source)) {
|
||||
const childPath = [...pathParts, key];
|
||||
if (
|
||||
SECRET_KEY.test(key) ||
|
||||
REASONING_KEY.test(key) ||
|
||||
(reasoningObject && key !== "type" && key !== "id" && key !== "status")
|
||||
) {
|
||||
output[key] = "[withheld]";
|
||||
withheld.push(childPath.join("."));
|
||||
continue;
|
||||
}
|
||||
output[key] = redactParsed(child, childPath, withheld);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function redactedFrame(entry: Record<string, unknown>) {
|
||||
const rawBase64 = typeof entry.rawBase64 === "string" ? entry.rawBase64 : "";
|
||||
const withheldPaths: string[] = [];
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = redactParsed(
|
||||
JSON.parse(Buffer.from(rawBase64, "base64").toString("utf8")),
|
||||
[],
|
||||
withheldPaths,
|
||||
);
|
||||
} catch {
|
||||
withheldPaths.push("$raw");
|
||||
}
|
||||
const { rawBase64: _raw, ...safe } = entry;
|
||||
return {
|
||||
...safe,
|
||||
parsed,
|
||||
withheldPaths,
|
||||
exactAvailable: rawBase64.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function redactProviderTraceFrame(entry: Record<string, unknown>) {
|
||||
return redactedFrame(entry);
|
||||
}
|
||||
|
||||
async function readTraceBytes(traceRef: string) {
|
||||
const nativeBytes = await fs.readFile(tracePath(traceRef));
|
||||
const rehydrationBytes = await fs
|
||||
.readFile(rehydrationTracePath(traceRef))
|
||||
.catch(() => Buffer.alloc(0));
|
||||
if (rehydrationBytes.byteLength === 0) return nativeBytes;
|
||||
const separator =
|
||||
nativeBytes.byteLength > 0 && nativeBytes.at(-1) !== 0x0a
|
||||
? Buffer.from("\n")
|
||||
: Buffer.alloc(0);
|
||||
return Buffer.concat([nativeBytes, separator, rehydrationBytes]);
|
||||
}
|
||||
|
||||
async function readEntries(traceRef: string) {
|
||||
const content = (await readTraceBytes(traceRef)).toString("utf8");
|
||||
return content.split("\n").flatMap((line) => {
|
||||
if (!line.trim()) return [];
|
||||
try {
|
||||
return [record(JSON.parse(line))];
|
||||
} catch {
|
||||
return [
|
||||
{
|
||||
kind: "trace_status",
|
||||
status: "incomplete",
|
||||
reason: "invalid_sidecar_record",
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function assessProviderTraceEntries(
|
||||
entries: Record<string, unknown>[],
|
||||
options: {
|
||||
invalidRecordCount?: number;
|
||||
requiredChannels?: string[];
|
||||
} = {},
|
||||
): { status: "complete" | "incomplete" | "truncated"; reason: string | null } {
|
||||
if ((options.invalidRecordCount ?? 0) > 0) {
|
||||
return { status: "incomplete", reason: "invalid_sidecar_record" };
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.kind !== "frame") continue;
|
||||
const encoded =
|
||||
typeof entry.rawBase64 === "string" ? entry.rawBase64 : null;
|
||||
const byteLength = Number(entry.byteLength);
|
||||
const digest = typeof entry.digest === "string" ? entry.digest : null;
|
||||
if (encoded === null || !Number.isSafeInteger(byteLength) || !digest) {
|
||||
return { status: "incomplete", reason: "invalid_frame_metadata" };
|
||||
}
|
||||
const raw = Buffer.from(encoded, "base64");
|
||||
if (
|
||||
raw.toString("base64") !== encoded ||
|
||||
raw.byteLength !== byteLength ||
|
||||
`sha256:${createHash("sha256").update(raw).digest("hex")}` !== digest
|
||||
) {
|
||||
return { status: "incomplete", reason: "provider_frame_digest_mismatch" };
|
||||
}
|
||||
}
|
||||
|
||||
const channels = new Set(options.requiredChannels ?? []);
|
||||
for (const entry of entries) {
|
||||
if (typeof entry.debugChannel === "string")
|
||||
channels.add(entry.debugChannel);
|
||||
}
|
||||
for (const channel of channels) {
|
||||
const channelEntries = entries.filter(
|
||||
(entry) => entry.debugChannel === channel,
|
||||
);
|
||||
if (channelEntries.length === 0) {
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `trace_channel_missing:${channel}`,
|
||||
};
|
||||
}
|
||||
for (let index = 0; index < channelEntries.length; index += 1) {
|
||||
if (Number(channelEntries[index]?.debugSequence) !== index + 1) {
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `trace_debug_sequence_gap:${channel}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
const terminal = channelEntries.at(-1)!;
|
||||
if (terminal.kind !== "trace_status") {
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `trace_channel_ack_missing:${channel}`,
|
||||
};
|
||||
}
|
||||
if (
|
||||
Number(terminal.acknowledgedDebugSequence) !==
|
||||
Number(terminal.debugSequence) - 1
|
||||
) {
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `trace_channel_ack_invalid:${channel}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const statuses = entries.filter((entry) => entry.kind === "trace_status");
|
||||
const incomplete = statuses.find((entry) => entry.status === "incomplete");
|
||||
if (incomplete) {
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason:
|
||||
typeof incomplete.reason === "string"
|
||||
? incomplete.reason
|
||||
: "trace_channel_incomplete",
|
||||
};
|
||||
}
|
||||
const truncated = statuses.find((entry) => entry.status === "truncated");
|
||||
if (truncated) {
|
||||
return {
|
||||
status: "truncated",
|
||||
reason:
|
||||
typeof truncated.reason === "string"
|
||||
? truncated.reason
|
||||
: "provider_trace_max_bytes_exceeded",
|
||||
};
|
||||
}
|
||||
if (statuses.length === 0) {
|
||||
return { status: "incomplete", reason: "trace_terminal_ack_missing" };
|
||||
}
|
||||
return { status: "complete", reason: null };
|
||||
}
|
||||
|
||||
export function providerTraceStore(db: Db) {
|
||||
async function cleanupExpired(now = new Date()) {
|
||||
const expired = await db
|
||||
.select()
|
||||
.from(providerTraceRecords)
|
||||
.where(
|
||||
and(
|
||||
lt(providerTraceRecords.expiresAt, now),
|
||||
isNull(providerTraceRecords.deletedAt),
|
||||
),
|
||||
);
|
||||
for (const row of expired) {
|
||||
await fs.unlink(tracePath(row.traceRef)).catch(() => undefined);
|
||||
await fs
|
||||
.unlink(rehydrationTracePath(row.traceRef))
|
||||
.catch(() => undefined);
|
||||
await db
|
||||
.update(providerTraceRecords)
|
||||
.set({
|
||||
status: "expired",
|
||||
deletedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(providerTraceRecords.id, row.id));
|
||||
await logActivity(db, {
|
||||
companyId: row.companyId,
|
||||
actorType: "system",
|
||||
actorId: "provider-trace-expiry",
|
||||
runId: row.runId,
|
||||
action: "provider_trace.expired",
|
||||
entityType: "heartbeat_run",
|
||||
entityId: row.runId,
|
||||
details: {
|
||||
traceId: row.id,
|
||||
expiredAt: now.toISOString(),
|
||||
payloadLogged: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
return expired;
|
||||
}
|
||||
|
||||
async function prepare(input: {
|
||||
runId: string;
|
||||
companyId: string;
|
||||
provider: string;
|
||||
requestedBy: string;
|
||||
}) {
|
||||
await cleanupExpired().catch(() => []);
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(providerTraceRecords)
|
||||
.where(eq(providerTraceRecords.runId, input.runId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing)
|
||||
return {
|
||||
metadata: asMetadata(existing),
|
||||
path: tracePath(existing.traceRef),
|
||||
};
|
||||
|
||||
await fs.mkdir(traceRoot(), { recursive: true, mode: 0o700 });
|
||||
const traceRef = `${randomUUID()}.ndjson`;
|
||||
const filePath = tracePath(traceRef);
|
||||
const handle = await fs.open(filePath, "wx", 0o600);
|
||||
await handle.close();
|
||||
await fs.chmod(filePath, 0o600);
|
||||
const expiresAt = new Date(Date.now() + PROVIDER_TRACE_RETENTION_MS);
|
||||
const row = await db
|
||||
.insert(providerTraceRecords)
|
||||
.values({
|
||||
runId: input.runId,
|
||||
companyId: input.companyId,
|
||||
status: "capturing",
|
||||
provider: input.provider,
|
||||
traceRef,
|
||||
requestedBy: input.requestedBy,
|
||||
expiresAt,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
if (!row) throw new Error("provider_trace_metadata_create_failed");
|
||||
return { metadata: asMetadata(row), path: filePath };
|
||||
}
|
||||
|
||||
async function getByRun(runId: string, companyId?: string) {
|
||||
const where = companyId
|
||||
? and(
|
||||
eq(providerTraceRecords.runId, runId),
|
||||
eq(providerTraceRecords.companyId, companyId),
|
||||
)
|
||||
: eq(providerTraceRecords.runId, runId);
|
||||
return db
|
||||
.select()
|
||||
.from(providerTraceRecords)
|
||||
.where(where)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function listMetadataForRuns(companyId: string, runIds: string[]) {
|
||||
if (runIds.length === 0) return [];
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(providerTraceRecords)
|
||||
.where(
|
||||
and(
|
||||
eq(providerTraceRecords.companyId, companyId),
|
||||
inArray(providerTraceRecords.runId, runIds.slice(0, 100)),
|
||||
),
|
||||
);
|
||||
return rows.map(asMetadata);
|
||||
}
|
||||
|
||||
async function finalize(
|
||||
runId: string,
|
||||
companyId: string,
|
||||
failureReason?: string | null,
|
||||
) {
|
||||
const row = await getByRun(runId, companyId);
|
||||
if (!row || row.deletedAt) return null;
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = await readTraceBytes(row.traceRef);
|
||||
} catch {
|
||||
return db
|
||||
.update(providerTraceRecords)
|
||||
.set({
|
||||
status: "incomplete",
|
||||
reason: failureReason ?? "trace_sidecar_missing",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(providerTraceRecords.id, row.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
let invalidRecordCount = 0;
|
||||
const entries = bytes
|
||||
.toString("utf8")
|
||||
.split("\n")
|
||||
.flatMap((line) => {
|
||||
try {
|
||||
return line.trim() ? [record(JSON.parse(line))] : [];
|
||||
} catch {
|
||||
invalidRecordCount += 1;
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const frames = entries.filter((entry) => entry.kind === "frame");
|
||||
const integrity = assessProviderTraceEntries(entries, {
|
||||
invalidRecordCount,
|
||||
requiredChannels: ["rust_native", "typescript_runnerd_rehydration"],
|
||||
});
|
||||
const terminalStatus = failureReason ? "incomplete" : integrity.status;
|
||||
return db
|
||||
.update(providerTraceRecords)
|
||||
.set({
|
||||
status: terminalStatus,
|
||||
frameCount: frames.length,
|
||||
byteCount: frames.reduce(
|
||||
(total, frame) => total + (Number(frame.byteLength) || 0),
|
||||
0,
|
||||
),
|
||||
digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
|
||||
reason: failureReason ?? integrity.reason,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(providerTraceRecords.id, row.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function inspect(runId: string, companyId: string) {
|
||||
const row = await getByRun(runId, companyId);
|
||||
if (!row || row.deletedAt) return { trace: null, entries: [] };
|
||||
const entries = await readEntries(row.traceRef).catch(() => []);
|
||||
return {
|
||||
trace: asMetadata(row),
|
||||
entries: entries.map((entry) =>
|
||||
entry.kind === "frame" ? redactedFrame(entry) : entry,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function revealFrame(
|
||||
runId: string,
|
||||
companyId: string,
|
||||
frameId: number,
|
||||
) {
|
||||
const row = await getByRun(runId, companyId);
|
||||
if (!row || row.deletedAt) return null;
|
||||
const entry = (await readEntries(row.traceRef)).find(
|
||||
(candidate) =>
|
||||
candidate.kind === "frame" && Number(candidate.frameId) === frameId,
|
||||
);
|
||||
return entry
|
||||
? (entry as unknown as ProviderTraceFrame & { kind: "frame" })
|
||||
: null;
|
||||
}
|
||||
|
||||
async function download(runId: string, companyId: string) {
|
||||
const row = await getByRun(runId, companyId);
|
||||
if (!row || row.deletedAt) return null;
|
||||
return { row, bytes: await readTraceBytes(row.traceRef) };
|
||||
}
|
||||
|
||||
async function remove(runId: string, companyId: string) {
|
||||
const row = await getByRun(runId, companyId);
|
||||
if (!row || row.deletedAt) return null;
|
||||
await fs.unlink(tracePath(row.traceRef)).catch(() => undefined);
|
||||
await fs.unlink(rehydrationTracePath(row.traceRef)).catch(() => undefined);
|
||||
const now = new Date();
|
||||
return db
|
||||
.update(providerTraceRecords)
|
||||
.set({
|
||||
status: "deleted",
|
||||
deletedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(providerTraceRecords.id, row.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
return {
|
||||
prepare,
|
||||
finalize,
|
||||
inspect,
|
||||
revealFrame,
|
||||
download,
|
||||
remove,
|
||||
getByRun,
|
||||
listMetadataForRuns,
|
||||
cleanupExpired,
|
||||
};
|
||||
}
|
||||
@@ -83,6 +83,35 @@ describe("paperclip runner transcript projection", () => {
|
||||
.toEqual([{ kind: "thinking", ts: expect.any(String), text: "Detailed trace", delta: true, channel: "detail" }]);
|
||||
});
|
||||
|
||||
it("preserves empty reasoning lifecycle events as real thinking activity", () => {
|
||||
const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine;
|
||||
const event = (eventType: string) => parse(JSON.stringify({
|
||||
type: "paperclip.prp.event",
|
||||
event: {
|
||||
eventType,
|
||||
itemId: "reason-empty",
|
||||
payload: {
|
||||
kind: "reasoning",
|
||||
channel: "summary",
|
||||
item: { id: "reason-empty", type: "reasoning", text: "" },
|
||||
},
|
||||
},
|
||||
}), "2026-08-21T12:00:00.000Z");
|
||||
|
||||
expect(event("item.started")).toEqual([expect.objectContaining({
|
||||
kind: "thinking",
|
||||
text: "",
|
||||
lifecycle: "started",
|
||||
channel: "summary",
|
||||
})]);
|
||||
expect(event("item.completed")).toEqual([expect.objectContaining({
|
||||
kind: "thinking",
|
||||
text: "",
|
||||
lifecycle: "completed",
|
||||
channel: "summary",
|
||||
})]);
|
||||
});
|
||||
|
||||
it("never exposes the structured task result envelope as final-response prose", () => {
|
||||
const parse = paperclipRunnerUIAdapter.createStdoutParser!().parseLine;
|
||||
const event = (eventType: string, payload: Record<string, unknown>, itemId = "result-1") => parse(JSON.stringify({
|
||||
@@ -99,13 +128,36 @@ describe("paperclip runner transcript projection", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
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" })]);
|
||||
it("projects every canonical provider family as structured activity instead of JSON prose", () => {
|
||||
const cases = [
|
||||
["plan.updated", "plan", { complete: true, explanation: "Ship safely" }],
|
||||
["tool.execution.completed", "tool_execution", { status: "completed", name: "tests" }],
|
||||
["research.completed", "research", { status: "completed", query: "PRP" }],
|
||||
["delegation.completed", "delegation", { status: "completed", action: "spawn" }],
|
||||
["model.route.changed", "model_identity", { provider: "claude", requestedModel: "claude", effectiveModel: "Claude Sonnet" }],
|
||||
["context.compacted", "context", { reason: "window" }],
|
||||
["artifact.generated", "artifact", { status: "completed", reference: "image.png" }],
|
||||
["review.mode.changed", "review", { state: "entered" }],
|
||||
["hook.completed", "hook", { status: "completed", event: "post-tool" }],
|
||||
["memory.citation.referenced", "memory", { label: "Decision" }],
|
||||
["safety.review.completed", "safety", { status: "completed", decision: "allowed" }],
|
||||
["terminal.input.sent", "terminal", { byteCount: 1 }],
|
||||
["wait.completed", "wait", { status: "completed", reason: "timer" }],
|
||||
["provider.notice.recorded", "provider_notice", { summary: "Provider warning" }],
|
||||
] as const;
|
||||
|
||||
for (const [eventType, family, payload] of cases) {
|
||||
const entries = paperclipRunnerUIAdapter.parseStdoutLine(JSON.stringify({
|
||||
type: "paperclip.prp.event",
|
||||
event: { eventType, payload },
|
||||
}), "2026-08-21T12:00:00.000Z");
|
||||
expect(entries, eventType).toEqual([expect.objectContaining({
|
||||
kind: "provider_activity",
|
||||
family,
|
||||
eventType,
|
||||
})]);
|
||||
expect(entries, eventType).not.toEqual([expect.objectContaining({ kind: "assistant" })]);
|
||||
}
|
||||
|
||||
const modelRoute = paperclipRunnerUIAdapter.parseStdoutLine(JSON.stringify({
|
||||
type: "paperclip.prp.event",
|
||||
|
||||
@@ -234,15 +234,13 @@ function parseItemEvent(
|
||||
if (type === "reasoning") {
|
||||
const value = itemText(item, payload);
|
||||
if (phase === "completed") state.itemChannels.delete(id);
|
||||
if (value && !state.reasoningDeltaItemIds.has(id)) return [{
|
||||
return [{
|
||||
kind: "thinking",
|
||||
ts,
|
||||
text: value,
|
||||
text: value && !state.reasoningDeltaItemIds.has(id) ? value : "",
|
||||
lifecycle: phase,
|
||||
channel: resolvedChannel === "detail" ? "detail" : resolvedChannel === "summary" ? "summary" : "unknown",
|
||||
}];
|
||||
return phase === "started"
|
||||
? [{ kind: "system", ts, text: "Reasoning started" }]
|
||||
: [];
|
||||
}
|
||||
if (type === "commandexecution") {
|
||||
const entries = commandEntries(event, item, phase, ts);
|
||||
|
||||
@@ -27,6 +27,9 @@ export interface RunForIssue {
|
||||
continuationAttempt?: number;
|
||||
lastUsefulActionAt?: string | null;
|
||||
nextAction?: string | null;
|
||||
wakeCommentIds?: string[] | null;
|
||||
wakeCommentId?: string | null;
|
||||
contextCommentId?: string | null;
|
||||
contextSnapshot?: Record<string, unknown> | null;
|
||||
environment?: {
|
||||
id: string;
|
||||
|
||||
+126
-40
@@ -94,6 +94,7 @@ export interface AgentWakeRequest {
|
||||
payload?: Record<string, unknown> | null;
|
||||
idempotencyKey?: string | null;
|
||||
forceFreshSession?: boolean;
|
||||
debug?: { providerTrace: "raw" };
|
||||
}
|
||||
|
||||
function withCompanyScope(path: string, companyId?: string) {
|
||||
@@ -103,14 +104,20 @@ function withCompanyScope(path: string, companyId?: string) {
|
||||
}
|
||||
|
||||
function agentPath(id: string, companyId?: string, suffix = "") {
|
||||
return withCompanyScope(`/agents/${encodeURIComponent(id)}${suffix}`, companyId);
|
||||
return withCompanyScope(
|
||||
`/agents/${encodeURIComponent(id)}${suffix}`,
|
||||
companyId,
|
||||
);
|
||||
}
|
||||
|
||||
export const agentsApi = {
|
||||
list: (companyId: string) => api.get<Agent[]>(`/companies/${companyId}/agents`),
|
||||
list: (companyId: string) =>
|
||||
api.get<Agent[]>(`/companies/${companyId}/agents`),
|
||||
org: (companyId: string) => api.get<OrgNode[]>(`/companies/${companyId}/org`),
|
||||
listConfigurations: (companyId: string) =>
|
||||
api.get<Record<string, unknown>[]>(`/companies/${companyId}/agent-configurations`),
|
||||
api.get<Record<string, unknown>[]>(
|
||||
`/companies/${companyId}/agent-configurations`,
|
||||
),
|
||||
get: async (id: string, companyId?: string) => {
|
||||
try {
|
||||
return await api.get<AgentDetail>(agentPath(id, companyId));
|
||||
@@ -131,30 +138,50 @@ export const agentsApi = {
|
||||
|
||||
const agents = await api.get<Agent[]>(`/companies/${companyId}/agents`);
|
||||
const matches = agents.filter(
|
||||
(agent) => agent.status !== "terminated" && normalizeAgentUrlKey(agent.urlKey) === urlKey,
|
||||
(agent) =>
|
||||
agent.status !== "terminated" &&
|
||||
normalizeAgentUrlKey(agent.urlKey) === urlKey,
|
||||
);
|
||||
if (matches.length !== 1) throw error;
|
||||
return api.get<AgentDetail>(agentPath(matches[0]!.id, companyId));
|
||||
}
|
||||
},
|
||||
getConfiguration: (id: string, companyId?: string) =>
|
||||
api.get<Record<string, unknown>>(agentPath(id, companyId, "/configuration")),
|
||||
api.get<Record<string, unknown>>(
|
||||
agentPath(id, companyId, "/configuration"),
|
||||
),
|
||||
listConfigRevisions: (id: string, companyId?: string) =>
|
||||
api.get<AgentConfigRevision[]>(agentPath(id, companyId, "/config-revisions")),
|
||||
api.get<AgentConfigRevision[]>(
|
||||
agentPath(id, companyId, "/config-revisions"),
|
||||
),
|
||||
getConfigRevision: (id: string, revisionId: string, companyId?: string) =>
|
||||
api.get<AgentConfigRevision>(agentPath(id, companyId, `/config-revisions/${revisionId}`)),
|
||||
rollbackConfigRevision: (id: string, revisionId: string, companyId?: string) =>
|
||||
api.post<Agent>(agentPath(id, companyId, `/config-revisions/${revisionId}/rollback`), {}),
|
||||
api.get<AgentConfigRevision>(
|
||||
agentPath(id, companyId, `/config-revisions/${revisionId}`),
|
||||
),
|
||||
rollbackConfigRevision: (
|
||||
id: string,
|
||||
revisionId: string,
|
||||
companyId?: string,
|
||||
) =>
|
||||
api.post<Agent>(
|
||||
agentPath(id, companyId, `/config-revisions/${revisionId}/rollback`),
|
||||
{},
|
||||
),
|
||||
create: (companyId: string, data: Record<string, unknown>) =>
|
||||
api.post<Agent>(`/companies/${companyId}/agents`, data),
|
||||
hire: (companyId: string, data: Record<string, unknown>) =>
|
||||
api.post<AgentHireResponse>(`/companies/${companyId}/agent-hires`, data),
|
||||
update: (id: string, data: Record<string, unknown>, companyId?: string) =>
|
||||
api.patch<Agent>(agentPath(id, companyId), data),
|
||||
updatePermissions: (id: string, data: AgentPermissionUpdate, companyId?: string) =>
|
||||
api.patch<AgentDetail>(agentPath(id, companyId, "/permissions"), data),
|
||||
updatePermissions: (
|
||||
id: string,
|
||||
data: AgentPermissionUpdate,
|
||||
companyId?: string,
|
||||
) => api.patch<AgentDetail>(agentPath(id, companyId, "/permissions"), data),
|
||||
instructionsBundle: (id: string, companyId?: string) =>
|
||||
api.get<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle")),
|
||||
api.get<AgentInstructionsBundle>(
|
||||
agentPath(id, companyId, "/instructions-bundle"),
|
||||
),
|
||||
updateInstructionsBundle: (
|
||||
id: string,
|
||||
data: {
|
||||
@@ -164,28 +191,61 @@ export const agentsApi = {
|
||||
clearLegacyPromptTemplate?: boolean;
|
||||
},
|
||||
companyId?: string,
|
||||
) => api.patch<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle"), data),
|
||||
) =>
|
||||
api.patch<AgentInstructionsBundle>(
|
||||
agentPath(id, companyId, "/instructions-bundle"),
|
||||
data,
|
||||
),
|
||||
instructionsFile: (id: string, relativePath: string, companyId?: string) =>
|
||||
api.get<AgentInstructionsFileDetail>(
|
||||
agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`),
|
||||
agentPath(
|
||||
id,
|
||||
companyId,
|
||||
`/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`,
|
||||
),
|
||||
),
|
||||
saveInstructionsFile: (
|
||||
id: string,
|
||||
data: { path: string; content: string; clearLegacyPromptTemplate?: boolean },
|
||||
data: {
|
||||
path: string;
|
||||
content: string;
|
||||
clearLegacyPromptTemplate?: boolean;
|
||||
},
|
||||
companyId?: string,
|
||||
) => api.put<AgentInstructionsFileDetail>(agentPath(id, companyId, "/instructions-bundle/file"), data),
|
||||
deleteInstructionsFile: (id: string, relativePath: string, companyId?: string) =>
|
||||
api.delete<AgentInstructionsBundle>(
|
||||
agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`),
|
||||
) =>
|
||||
api.put<AgentInstructionsFileDetail>(
|
||||
agentPath(id, companyId, "/instructions-bundle/file"),
|
||||
data,
|
||||
),
|
||||
pause: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/pause"), {}),
|
||||
resume: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/resume"), {}),
|
||||
deleteInstructionsFile: (
|
||||
id: string,
|
||||
relativePath: string,
|
||||
companyId?: string,
|
||||
) =>
|
||||
api.delete<AgentInstructionsBundle>(
|
||||
agentPath(
|
||||
id,
|
||||
companyId,
|
||||
`/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`,
|
||||
),
|
||||
),
|
||||
pause: (id: string, companyId?: string) =>
|
||||
api.post<Agent>(agentPath(id, companyId, "/pause"), {}),
|
||||
resume: (id: string, companyId?: string) =>
|
||||
api.post<Agent>(agentPath(id, companyId, "/resume"), {}),
|
||||
clearError: (id: string, companyId?: string) =>
|
||||
api.post<ClearAgentErrorResponse>(agentPath(id, companyId, "/clear-error"), {}),
|
||||
approve: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/approve"), {}),
|
||||
terminate: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/terminate"), {}),
|
||||
remove: (id: string, companyId?: string) => api.delete<{ ok: true }>(agentPath(id, companyId)),
|
||||
listKeys: (id: string, companyId?: string) => api.get<AgentKey[]>(agentPath(id, companyId, "/keys")),
|
||||
api.post<ClearAgentErrorResponse>(
|
||||
agentPath(id, companyId, "/clear-error"),
|
||||
{},
|
||||
),
|
||||
approve: (id: string, companyId?: string) =>
|
||||
api.post<Agent>(agentPath(id, companyId, "/approve"), {}),
|
||||
terminate: (id: string, companyId?: string) =>
|
||||
api.post<Agent>(agentPath(id, companyId, "/terminate"), {}),
|
||||
remove: (id: string, companyId?: string) =>
|
||||
api.delete<{ ok: true }>(agentPath(id, companyId)),
|
||||
listKeys: (id: string, companyId?: string) =>
|
||||
api.get<AgentKey[]>(agentPath(id, companyId, "/keys")),
|
||||
skills: (id: string, companyId?: string) =>
|
||||
api.get<AgentSkillSnapshot>(agentPath(id, companyId, "/skills")),
|
||||
syncSkills: (
|
||||
@@ -193,17 +253,33 @@ export const agentsApi = {
|
||||
desiredSkills: Array<string | AgentDesiredSkillEntry>,
|
||||
mode: AgentSkillAssignmentMode,
|
||||
companyId?: string,
|
||||
) => api.post<AgentSkillSnapshot>(agentPath(id, companyId, "/skills/sync"), { desiredSkills, mode }),
|
||||
createKey: (id: string, name: string, companyId?: string, scope?: AgentApiKeyScope) =>
|
||||
api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), { name, ...(scope ? { scope } : {}) }),
|
||||
) =>
|
||||
api.post<AgentSkillSnapshot>(agentPath(id, companyId, "/skills/sync"), {
|
||||
desiredSkills,
|
||||
mode,
|
||||
}),
|
||||
createKey: (
|
||||
id: string,
|
||||
name: string,
|
||||
companyId?: string,
|
||||
scope?: AgentApiKeyScope,
|
||||
) =>
|
||||
api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), {
|
||||
name,
|
||||
...(scope ? { scope } : {}),
|
||||
}),
|
||||
revokeKey: (agentId: string, keyId: string, companyId?: string) =>
|
||||
api.delete<{ ok: true }>(agentPath(agentId, companyId, `/keys/${encodeURIComponent(keyId)}`)),
|
||||
api.delete<{ ok: true }>(
|
||||
agentPath(agentId, companyId, `/keys/${encodeURIComponent(keyId)}`),
|
||||
),
|
||||
runtimeState: (id: string, companyId?: string) =>
|
||||
api.get<AgentRuntimeState>(agentPath(id, companyId, "/runtime-state")),
|
||||
taskSessions: (id: string, companyId?: string) =>
|
||||
api.get<AgentTaskSession[]>(agentPath(id, companyId, "/task-sessions")),
|
||||
resetSession: (id: string, taskKey?: string | null, companyId?: string) =>
|
||||
api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }),
|
||||
api.post<void>(agentPath(id, companyId, "/runtime-state/reset-session"), {
|
||||
taskKey: taskKey ?? null,
|
||||
}),
|
||||
adapterModels: (
|
||||
companyId: string,
|
||||
type: string,
|
||||
@@ -211,7 +287,8 @@ export const agentsApi = {
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.refresh) params.set("refresh", "1");
|
||||
if (options?.environmentId) params.set("environmentId", options.environmentId);
|
||||
if (options?.environmentId)
|
||||
params.set("environmentId", options.environmentId);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return api.get<AdapterModel[]>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models${query}`,
|
||||
@@ -239,11 +316,8 @@ export const agentsApi = {
|
||||
),
|
||||
invoke: (id: string, companyId?: string, data: AgentWakeRequest = {}) =>
|
||||
api.post<HeartbeatRun>(agentPath(id, companyId, "/heartbeat/invoke"), data),
|
||||
wakeup: (
|
||||
id: string,
|
||||
data: AgentWakeRequest,
|
||||
companyId?: string,
|
||||
) => api.post<AgentWakeupResponse>(agentPath(id, companyId, "/wakeup"), data),
|
||||
wakeup: (id: string, data: AgentWakeRequest, companyId?: string) =>
|
||||
api.post<AgentWakeupResponse>(agentPath(id, companyId, "/wakeup"), data),
|
||||
loginWithClaude: (id: string, companyId?: string) =>
|
||||
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
|
||||
startAdapterAuthLogin: (
|
||||
@@ -255,11 +329,19 @@ export const agentsApi = {
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions`,
|
||||
data,
|
||||
),
|
||||
getAdapterAuthLoginStatus: (companyId: string, type: string, sessionId: string) =>
|
||||
getAdapterAuthLoginStatus: (
|
||||
companyId: string,
|
||||
type: string,
|
||||
sessionId: string,
|
||||
) =>
|
||||
api.get<AdapterAuthSessionOwnerResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/${encodeURIComponent(sessionId)}`,
|
||||
),
|
||||
cancelAdapterAuthLogin: (companyId: string, type: string, sessionId: string) =>
|
||||
cancelAdapterAuthLogin: (
|
||||
companyId: string,
|
||||
type: string,
|
||||
sessionId: string,
|
||||
) =>
|
||||
api.post<AdapterAuthSessionOwnerResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/${encodeURIComponent(sessionId)}/cancel`,
|
||||
{},
|
||||
@@ -294,7 +376,11 @@ export const agentsApi = {
|
||||
api.get<ClaudeSetupTokenSessionPrompt>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}/prompt`,
|
||||
),
|
||||
submitClaudeSetupTokenBrowserCode: (companyId: string, sessionId: string, browserCode: string) =>
|
||||
submitClaudeSetupTokenBrowserCode: (
|
||||
companyId: string,
|
||||
sessionId: string,
|
||||
browserCode: string,
|
||||
) =>
|
||||
api.post<ClaudeSetupTokenSessionResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}/code`,
|
||||
{ browserCode } satisfies SubmitBrowserCodeRequest,
|
||||
|
||||
+90
-18
@@ -3,6 +3,8 @@ import type {
|
||||
HeartbeatRunEvent,
|
||||
InstanceSchedulerHeartbeatAgent,
|
||||
WorkspaceOperation,
|
||||
ProviderTraceFrame,
|
||||
ProviderTraceMetadata,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
@@ -96,14 +98,26 @@ export interface HeartbeatRunListOptions {
|
||||
summary?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderTraceInspection {
|
||||
trace: ProviderTraceMetadata | null;
|
||||
entries: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export const heartbeatsApi = {
|
||||
list: (companyId: string, agentId?: string, limit?: number, options: HeartbeatRunListOptions = {}) => {
|
||||
list: (
|
||||
companyId: string,
|
||||
agentId?: string,
|
||||
limit?: number,
|
||||
options: HeartbeatRunListOptions = {},
|
||||
) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (agentId) searchParams.set("agentId", agentId);
|
||||
if (limit) searchParams.set("limit", String(limit));
|
||||
if (options.summary) searchParams.set("summary", "true");
|
||||
const qs = searchParams.toString();
|
||||
return api.get<HeartbeatRun[]>(`/companies/${companyId}/heartbeat-runs${qs ? `?${qs}` : ""}`);
|
||||
return api.get<HeartbeatRun[]>(
|
||||
`/companies/${companyId}/heartbeat-runs${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
get: (runId: string) => api.get<HeartbeatRun>(`/heartbeat-runs/${runId}`),
|
||||
events: (runId: string, afterSeq = 0, limit = 200) =>
|
||||
@@ -111,30 +125,83 @@ export const heartbeatsApi = {
|
||||
`/heartbeat-runs/${runId}/events?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=${encodeURIComponent(String(limit))}`,
|
||||
),
|
||||
log: (runId: string, offset = 0, limitBytes = 256000) =>
|
||||
api.get<{ runId: string; store: string; logRef: string; content: string; nextOffset?: number }>(
|
||||
api.get<{
|
||||
runId: string;
|
||||
store: string;
|
||||
logRef: string;
|
||||
content: string;
|
||||
nextOffset?: number;
|
||||
}>(
|
||||
`/heartbeat-runs/${runId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`,
|
||||
),
|
||||
workspaceOperations: (runId: string) =>
|
||||
api.get<WorkspaceOperation[]>(`/heartbeat-runs/${runId}/workspace-operations`),
|
||||
workspaceOperationLog: (operationId: string, offset = 0, limitBytes = 256000) =>
|
||||
api.get<{ operationId: string; store: string; logRef: string; content: string; nextOffset?: number }>(
|
||||
api.get<WorkspaceOperation[]>(
|
||||
`/heartbeat-runs/${runId}/workspace-operations`,
|
||||
),
|
||||
providerTrace: (runId: string) =>
|
||||
api.get<ProviderTraceInspection>(`/heartbeat-runs/${runId}/provider-trace`),
|
||||
providerTraceMetadata: (companyId: string, runIds: string[]) => {
|
||||
const params = new URLSearchParams({ runIds: runIds.slice(0, 100).join(",") });
|
||||
return api.get<ProviderTraceMetadata[]>(
|
||||
`/companies/${companyId}/provider-traces?${params.toString()}`,
|
||||
);
|
||||
},
|
||||
revealProviderTraceFrame: (runId: string, frameId: number) =>
|
||||
api.post<ProviderTraceFrame>(
|
||||
`/heartbeat-runs/${runId}/provider-trace/frames/${frameId}/reveal`,
|
||||
{},
|
||||
),
|
||||
deleteProviderTrace: (runId: string) =>
|
||||
api.delete<{ ok: true }>(`/heartbeat-runs/${runId}/provider-trace`),
|
||||
downloadProviderTrace: async (runId: string): Promise<Blob> => {
|
||||
const response = await fetch(
|
||||
`/api/heartbeat-runs/${runId}/provider-trace/download`,
|
||||
{
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/x-ndjson" },
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
throw new Error(
|
||||
body?.error ?? `Trace download failed: ${response.status}`,
|
||||
);
|
||||
}
|
||||
return response.blob();
|
||||
},
|
||||
workspaceOperationLog: (
|
||||
operationId: string,
|
||||
offset = 0,
|
||||
limitBytes = 256000,
|
||||
) =>
|
||||
api.get<{
|
||||
operationId: string;
|
||||
store: string;
|
||||
logRef: string;
|
||||
content: string;
|
||||
nextOffset?: number;
|
||||
}>(
|
||||
`/workspace-operations/${operationId}/log?offset=${encodeURIComponent(String(offset))}&limitBytes=${encodeURIComponent(String(limitBytes))}`,
|
||||
),
|
||||
cancel: (runId: string) => api.post<void>(`/heartbeat-runs/${runId}/cancel`, {}),
|
||||
cancel: (runId: string) =>
|
||||
api.post<void>(`/heartbeat-runs/${runId}/cancel`, {}),
|
||||
resolveRuntimeRequest: (input: {
|
||||
runId: string;
|
||||
requestId: string;
|
||||
turnId: string;
|
||||
requestKind: RuntimeRequestKind;
|
||||
resolution: RuntimeRequestResolution;
|
||||
}) => api.post<{ accepted: true; commandId: string }>(
|
||||
`/heartbeat-runs/${input.runId}/runtime-requests/${encodeURIComponent(input.requestId)}/resolve`,
|
||||
{
|
||||
turnId: input.turnId,
|
||||
requestKind: input.requestKind,
|
||||
resolution: input.resolution,
|
||||
},
|
||||
),
|
||||
}) =>
|
||||
api.post<{ accepted: true; commandId: string }>(
|
||||
`/heartbeat-runs/${input.runId}/runtime-requests/${encodeURIComponent(input.requestId)}/resolve`,
|
||||
{
|
||||
turnId: input.turnId,
|
||||
requestKind: input.requestKind,
|
||||
resolution: input.resolution,
|
||||
},
|
||||
),
|
||||
recordWatchdogDecision: (input: WatchdogDecisionInput) =>
|
||||
api.post(`/heartbeat-runs/${input.runId}/watchdog-decisions`, {
|
||||
decision: input.decision,
|
||||
@@ -154,12 +221,17 @@ export const heartbeatsApi = {
|
||||
if (typeof options === "number") {
|
||||
searchParams.set("minCount", String(options));
|
||||
} else if (options) {
|
||||
if (options.minCount) searchParams.set("minCount", String(options.minCount));
|
||||
if (options.minCount)
|
||||
searchParams.set("minCount", String(options.minCount));
|
||||
if (options.limit) searchParams.set("limit", String(options.limit));
|
||||
}
|
||||
const qs = searchParams.toString();
|
||||
return api.get<LiveRunForIssue[]>(`/companies/${companyId}/live-runs${qs ? `?${qs}` : ""}`);
|
||||
return api.get<LiveRunForIssue[]>(
|
||||
`/companies/${companyId}/live-runs${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
listInstanceSchedulerAgents: () =>
|
||||
api.get<InstanceSchedulerHeartbeatAgent[]>("/instance/scheduler-heartbeats"),
|
||||
api.get<InstanceSchedulerHeartbeatAgent[]>(
|
||||
"/instance/scheduler-heartbeats",
|
||||
),
|
||||
};
|
||||
@@ -27,7 +27,9 @@ const mockAgentsApi = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
||||
<a href={to} {...props}>{children}</a>
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
@@ -103,11 +105,16 @@ describe("AgentActionButtons", () => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") as unknown as ReturnType<typeof vi.fn>;
|
||||
invalidateQueries = vi.spyOn(
|
||||
queryClient,
|
||||
"invalidateQueries",
|
||||
) as unknown as ReturnType<typeof vi.fn>;
|
||||
mockAgentsApi.clearError.mockResolvedValue(makeAgent({ status: "idle" }));
|
||||
mockAgentsApi.pause.mockResolvedValue(makeAgent({ status: "paused" }));
|
||||
mockAgentsApi.resume.mockResolvedValue(makeAgent({ status: "idle" }));
|
||||
mockAgentsApi.terminate.mockResolvedValue(makeAgent({ status: "terminated" }));
|
||||
mockAgentsApi.terminate.mockResolvedValue(
|
||||
makeAgent({ status: "terminated" }),
|
||||
);
|
||||
mockAgentsApi.invoke.mockResolvedValue({ id: "run-1" });
|
||||
mockAgentsApi.resetSession.mockResolvedValue(undefined);
|
||||
});
|
||||
@@ -125,11 +132,19 @@ describe("AgentActionButtons", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function render(agent: Agent, props: Partial<ComponentProps<typeof AgentActionButtons>> = {}) {
|
||||
function render(
|
||||
agent: Agent,
|
||||
props: Partial<ComponentProps<typeof AgentActionButtons>> = {},
|
||||
) {
|
||||
root ??= createRoot(container);
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentActionButtons agent={agent} companyId="company-1" runLabel="Run Heartbeat" {...props} />
|
||||
<AgentActionButtons
|
||||
agent={agent}
|
||||
companyId="company-1"
|
||||
runLabel="Run Heartbeat"
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
@@ -143,7 +158,11 @@ describe("AgentActionButtons", () => {
|
||||
expect(container.textContent).toContain("Run Heartbeat");
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[aria-label="Open actions for Alpha Agent"]')?.click();
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Open actions for Alpha Agent"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
@@ -155,18 +174,39 @@ describe("AgentActionButtons", () => {
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[aria-label="Clear error and return agent to idle"]')?.click();
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Clear error and return agent to idle"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockAgentsApi.clearError).toHaveBeenCalledWith("agent-1", "company-1");
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "agent-1"] });
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "alpha"] });
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "runtime-state", "agent-1"] });
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "task-sessions", "agent-1"] });
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "company-1"] });
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["live-runs", "company-1"] });
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["heartbeats", "company-1", "agent-1"] });
|
||||
expect(mockAgentsApi.clearError).toHaveBeenCalledWith(
|
||||
"agent-1",
|
||||
"company-1",
|
||||
);
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["agents", "detail", "agent-1"],
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["agents", "detail", "alpha"],
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["agents", "runtime-state", "agent-1"],
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["agents", "task-sessions", "agent-1"],
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["agents", "company-1"],
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["live-runs", "company-1"],
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["heartbeats", "company-1", "agent-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the normal pause action for non-error agents", async () => {
|
||||
@@ -177,18 +217,43 @@ describe("AgentActionButtons", () => {
|
||||
expect(container.textContent).not.toContain("Clear error");
|
||||
});
|
||||
|
||||
it("starts an administrator-selected run with raw provider tracing", async () => {
|
||||
render(makeAgent(), { canRunWithProviderTrace: true });
|
||||
await flushReact();
|
||||
|
||||
const traceButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Run with provider trace"),
|
||||
);
|
||||
expect(traceButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
traceButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockAgentsApi.invoke).toHaveBeenCalledWith("agent-1", "company-1", {
|
||||
debug: { providerTrace: "raw" },
|
||||
});
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/agents/alpha/runs/run-1");
|
||||
});
|
||||
|
||||
it("calls the terminate success handler after terminating an agent", async () => {
|
||||
const onTerminateSuccess = vi.fn();
|
||||
render(makeAgent(), { onTerminateSuccess });
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[aria-label="Open actions for Alpha Agent"]')?.click();
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Open actions for Alpha Agent"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const terminateButton = Array.from(document.body.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Terminate"));
|
||||
const terminateButton = Array.from(
|
||||
document.body.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("Terminate"));
|
||||
expect(terminateButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
@@ -196,14 +261,25 @@ describe("AgentActionButtons", () => {
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockAgentsApi.terminate).toHaveBeenCalledWith("agent-1", "company-1");
|
||||
expect(onTerminateSuccess).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: "agent-1",
|
||||
status: "terminated",
|
||||
}));
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "agent-1"] });
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "detail", "alpha"] });
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["agents", "company-1"] });
|
||||
expect(mockAgentsApi.terminate).toHaveBeenCalledWith(
|
||||
"agent-1",
|
||||
"company-1",
|
||||
);
|
||||
expect(onTerminateSuccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "agent-1",
|
||||
status: "terminated",
|
||||
}),
|
||||
);
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["agents", "detail", "agent-1"],
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["agents", "detail", "alpha"],
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ["agents", "company-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not terminate when navigation away from a dirty detail page is rejected", async () => {
|
||||
@@ -212,12 +288,17 @@ describe("AgentActionButtons", () => {
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[aria-label="Open actions for Alpha Agent"]')?.click();
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Open actions for Alpha Agent"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const terminateButton = Array.from(document.body.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Terminate"));
|
||||
const terminateButton = Array.from(
|
||||
document.body.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("Terminate"));
|
||||
await act(async () => {
|
||||
terminateButton?.click();
|
||||
});
|
||||
@@ -229,10 +310,15 @@ describe("AgentActionButtons", () => {
|
||||
|
||||
it("rechecks navigation when the form becomes dirty while termination is pending", async () => {
|
||||
let resolveTermination!: (agent: Agent) => void;
|
||||
mockAgentsApi.terminate.mockReturnValue(new Promise((resolve) => {
|
||||
resolveTermination = resolve;
|
||||
}));
|
||||
const onBeforeNavigate = vi.fn().mockReturnValueOnce(true).mockReturnValue(false);
|
||||
mockAgentsApi.terminate.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveTermination = resolve;
|
||||
}),
|
||||
);
|
||||
const onBeforeNavigate = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValue(false);
|
||||
const onTerminateSuccess = vi.fn();
|
||||
const agent = makeAgent();
|
||||
render(agent, {
|
||||
@@ -243,11 +329,16 @@ describe("AgentActionButtons", () => {
|
||||
await flushReact();
|
||||
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[aria-label="Open actions for Alpha Agent"]')?.click();
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Open actions for Alpha Agent"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
await flushReact();
|
||||
const terminateButton = Array.from(document.body.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Terminate"));
|
||||
const terminateButton = Array.from(
|
||||
document.body.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("Terminate"));
|
||||
await act(async () => {
|
||||
terminateButton?.click();
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
CheckCircle2,
|
||||
Bug,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -82,7 +83,12 @@ export function PauseResumeButton({
|
||||
}) {
|
||||
if (isPaused) {
|
||||
return (
|
||||
<Button variant="outline" size={size} onClick={onResume} disabled={disabled}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size={size}
|
||||
onClick={onResume}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Resume</span>
|
||||
</Button>
|
||||
@@ -139,13 +145,20 @@ export async function loadDuplicateInstructionsBundle(
|
||||
for (const summary of bundle.files) {
|
||||
const path = duplicateInstructionFilePath(bundle, summary);
|
||||
if (!path) continue;
|
||||
const file = await agentsApi.instructionsFile(agentId, summary.path, companyId);
|
||||
const file = await agentsApi.instructionsFile(
|
||||
agentId,
|
||||
summary.path,
|
||||
companyId,
|
||||
);
|
||||
files[path] = file.content;
|
||||
}
|
||||
|
||||
const entryFile = Object.prototype.hasOwnProperty.call(files, bundle.entryFile)
|
||||
const entryFile = Object.prototype.hasOwnProperty.call(
|
||||
files,
|
||||
bundle.entryFile,
|
||||
)
|
||||
? bundle.entryFile
|
||||
: Object.keys(files)[0] ?? "AGENTS.md";
|
||||
: (Object.keys(files)[0] ?? "AGENTS.md");
|
||||
return Object.keys(files).length > 0 ? { entryFile, files } : null;
|
||||
}
|
||||
|
||||
@@ -165,6 +178,7 @@ export function AgentActionButtons({
|
||||
workActionsDisabled = false,
|
||||
workActionsDisabledReason,
|
||||
navigateToRunOnInvoke = true,
|
||||
canRunWithProviderTrace = false,
|
||||
hasPendingNavigationChanges = false,
|
||||
onBeforeNavigate,
|
||||
onActionError,
|
||||
@@ -184,6 +198,8 @@ export function AgentActionButtons({
|
||||
workActionsDisabled?: boolean;
|
||||
workActionsDisabledReason?: string;
|
||||
navigateToRunOnInvoke?: boolean;
|
||||
/** Instance administrators may opt one manual run into short-lived raw provider capture. */
|
||||
canRunWithProviderTrace?: boolean;
|
||||
/** Whether the caller currently has an unsaved draft that navigation would discard. */
|
||||
hasPendingNavigationChanges?: boolean;
|
||||
/** Return false to stop an action whose success would navigate away. */
|
||||
@@ -220,12 +236,16 @@ export function AgentActionButtons({
|
||||
pendingNavigationChangesRef.current = hasPendingNavigationChanges;
|
||||
beforeNavigateRef.current = onBeforeNavigate;
|
||||
|
||||
function confirmNavigationStart(startedDirtyRef: React.MutableRefObject<boolean>) {
|
||||
function confirmNavigationStart(
|
||||
startedDirtyRef: React.MutableRefObject<boolean>,
|
||||
) {
|
||||
startedDirtyRef.current = pendingNavigationChangesRef.current;
|
||||
return beforeNavigateRef.current?.() !== false;
|
||||
}
|
||||
|
||||
function confirmLateNavigationChanges(startedDirtyRef: React.MutableRefObject<boolean>) {
|
||||
function confirmLateNavigationChanges(
|
||||
startedDirtyRef: React.MutableRefObject<boolean>,
|
||||
) {
|
||||
return (
|
||||
!pendingNavigationChangesRef.current ||
|
||||
startedDirtyRef.current ||
|
||||
@@ -250,26 +270,49 @@ export function AgentActionButtons({
|
||||
);
|
||||
|
||||
const invalidateAgent = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(canonicalAgentRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agent.id) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.agents.detail(agent.id),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.agents.detail(canonicalAgentRef),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.agents.runtimeState(agent.id),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.agents.taskSessions(agent.id),
|
||||
});
|
||||
if (resolvedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.agents.list(resolvedCompanyId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.liveRuns(resolvedCompanyId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id),
|
||||
});
|
||||
}
|
||||
}, [agent.id, canonicalAgentRef, queryClient, resolvedCompanyId]);
|
||||
|
||||
const agentAction = useMutation({
|
||||
mutationFn: async (action: "invoke" | "pause" | "resume" | "clear_error" | "approve" | "terminate") => {
|
||||
mutationFn: async (
|
||||
action:
|
||||
"invoke" | "pause" | "resume" | "clear_error" | "approve" | "terminate",
|
||||
) => {
|
||||
switch (action) {
|
||||
case "invoke": return agentsApi.invoke(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "pause": return agentsApi.pause(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "resume": return agentsApi.resume(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "clear_error": return agentsApi.clearError(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "approve": return agentsApi.approve(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "terminate": return agentsApi.terminate(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "invoke":
|
||||
return agentsApi.invoke(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "pause":
|
||||
return agentsApi.pause(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "resume":
|
||||
return agentsApi.resume(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "clear_error":
|
||||
return agentsApi.clearError(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "approve":
|
||||
return agentsApi.approve(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "terminate":
|
||||
return agentsApi.terminate(agent.id, resolvedCompanyId ?? undefined);
|
||||
}
|
||||
},
|
||||
onSuccess: (data, action) => {
|
||||
@@ -279,9 +322,17 @@ export function AgentActionButtons({
|
||||
if (!confirmLateNavigationChanges(agentActionStartedDirtyRef)) return;
|
||||
onTerminateSuccess?.(data as Agent);
|
||||
}
|
||||
if (action === "invoke" && navigateToRunOnInvoke && data && typeof data === "object" && "id" in data) {
|
||||
if (
|
||||
action === "invoke" &&
|
||||
navigateToRunOnInvoke &&
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
"id" in data
|
||||
) {
|
||||
if (!confirmLateNavigationChanges(agentActionStartedDirtyRef)) return;
|
||||
navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`);
|
||||
navigate(
|
||||
`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -289,17 +340,44 @@ export function AgentActionButtons({
|
||||
},
|
||||
});
|
||||
|
||||
const providerTraceAction = useMutation({
|
||||
mutationFn: () =>
|
||||
agentsApi.invoke(agent.id, resolvedCompanyId ?? undefined, {
|
||||
debug: { providerTrace: "raw" },
|
||||
}),
|
||||
onSuccess: (run) => {
|
||||
onActionError?.(null);
|
||||
invalidateAgent();
|
||||
if (navigateToRunOnInvoke) {
|
||||
if (!confirmLateNavigationChanges(agentActionStartedDirtyRef)) return;
|
||||
navigate(`/agents/${canonicalAgentRef}/runs/${run.id}`);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
reportError(
|
||||
err instanceof Error ? err.message : "Failed to start traced run",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const duplicateAgent = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!resolvedCompanyId) {
|
||||
throw new Error("Agent is not ready to duplicate");
|
||||
}
|
||||
const instructionsBundle = await loadDuplicateInstructionsBundle(agent.id, resolvedCompanyId);
|
||||
const instructionsBundle = await loadDuplicateInstructionsBundle(
|
||||
agent.id,
|
||||
resolvedCompanyId,
|
||||
);
|
||||
const payload = buildDuplicateAgentPayload(agent, instructionsBundle);
|
||||
try {
|
||||
return await agentsApi.create(resolvedCompanyId, payload);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 409 && error.message.includes("requires board approval")) {
|
||||
if (
|
||||
error instanceof ApiError &&
|
||||
error.status === 409 &&
|
||||
error.message.includes("requires board approval")
|
||||
) {
|
||||
const hire = await agentsApi.hire(resolvedCompanyId, payload);
|
||||
return hire.agent;
|
||||
}
|
||||
@@ -309,16 +387,27 @@ export function AgentActionButtons({
|
||||
onSuccess: async (createdAgent) => {
|
||||
onActionError?.(null);
|
||||
if (resolvedCompanyId) {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.agents.list(resolvedCompanyId),
|
||||
});
|
||||
}
|
||||
pushToast({ title: "Agent duplicated", body: createdAgent.name, tone: "success" });
|
||||
pushToast({
|
||||
title: "Agent duplicated",
|
||||
body: createdAgent.name,
|
||||
tone: "success",
|
||||
});
|
||||
if (!confirmLateNavigationChanges(duplicateStartedDirtyRef)) return;
|
||||
navigate(`/agents/${agentRouteRef(createdAgent)}/dashboard`);
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to duplicate agent";
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to duplicate agent";
|
||||
onActionError?.(message);
|
||||
pushToast({ title: "Could not duplicate agent", body: message, tone: "error" });
|
||||
pushToast({
|
||||
title: "Could not duplicate agent",
|
||||
body: message,
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -332,25 +421,50 @@ export function AgentActionButtons({
|
||||
}, [agent.name, duplicateAgent]);
|
||||
|
||||
const resetTaskSession = useMutation({
|
||||
mutationFn: () => agentsApi.resetSession(agent.id, null, resolvedCompanyId ?? undefined),
|
||||
mutationFn: () =>
|
||||
agentsApi.resetSession(agent.id, null, resolvedCompanyId ?? undefined),
|
||||
onSuccess: () => {
|
||||
onActionError?.(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agent.id) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.agents.runtimeState(agent.id),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.agents.taskSessions(agent.id),
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
reportError(err instanceof Error ? err.message : "Failed to reset session");
|
||||
reportError(
|
||||
err instanceof Error ? err.message : "Failed to reset session",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const isPendingApproval = agent.status === "pending_approval";
|
||||
const disabled = actionsDisabled || agentAction.isPending;
|
||||
const assignAndRunDisabled = disabled || isPendingApproval || workActionsDisabled;
|
||||
const pauseResumeDisabled = disabled || isPendingApproval || (isPaused && workActionsDisabled);
|
||||
const disabled =
|
||||
actionsDisabled || agentAction.isPending || providerTraceAction.isPending;
|
||||
const assignAndRunDisabled =
|
||||
disabled || isPendingApproval || workActionsDisabled;
|
||||
const pauseResumeDisabled =
|
||||
disabled || isPendingApproval || (isPaused && workActionsDisabled);
|
||||
const clearErrorDisabled = disabled;
|
||||
const runtimeConfig = agent.runtimeConfig as Record<string, unknown> | null;
|
||||
const runtimeDebug =
|
||||
runtimeConfig && typeof runtimeConfig.debug === "object" && runtimeConfig.debug !== null
|
||||
? (runtimeConfig.debug as Record<string, unknown>)
|
||||
: null;
|
||||
const persistentProviderTrace = runtimeDebug?.providerTrace === "raw";
|
||||
|
||||
return (
|
||||
<div className={className ?? "flex items-center gap-1 sm:gap-2 shrink-0"}>
|
||||
{persistentProviderTrace ? (
|
||||
<span
|
||||
className="hidden items-center gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs font-medium text-amber-800 dark:text-amber-200 lg:inline-flex"
|
||||
title="Exact provider traffic will be captured for future runs and retained for up to 24 hours."
|
||||
>
|
||||
<Bug className="h-3.5 w-3.5" />
|
||||
Raw tracing on
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size={size}
|
||||
@@ -363,13 +477,36 @@ export function AgentActionButtons({
|
||||
</Button>
|
||||
<RunButton
|
||||
onClick={() => {
|
||||
if (navigateToRunOnInvoke && !confirmNavigationStart(agentActionStartedDirtyRef)) return;
|
||||
if (
|
||||
navigateToRunOnInvoke &&
|
||||
!confirmNavigationStart(agentActionStartedDirtyRef)
|
||||
)
|
||||
return;
|
||||
agentAction.mutate("invoke");
|
||||
}}
|
||||
disabled={assignAndRunDisabled}
|
||||
label={runLabel}
|
||||
size={size}
|
||||
/>
|
||||
{canRunWithProviderTrace && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size={size}
|
||||
onClick={() => {
|
||||
if (
|
||||
navigateToRunOnInvoke &&
|
||||
!confirmNavigationStart(agentActionStartedDirtyRef)
|
||||
)
|
||||
return;
|
||||
providerTraceAction.mutate();
|
||||
}}
|
||||
disabled={assignAndRunDisabled}
|
||||
title="Capture exact provider traffic for this run (expires after 24 hours)"
|
||||
>
|
||||
<Bug className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Run with provider trace</span>
|
||||
</Button>
|
||||
)}
|
||||
{isError ? (
|
||||
<ClearErrorButton
|
||||
onClick={() => agentAction.mutate("clear_error")}
|
||||
@@ -379,7 +516,11 @@ export function AgentActionButtons({
|
||||
) : (
|
||||
<PauseResumeButton
|
||||
isPaused={isPaused}
|
||||
onPause={() => (pauseConfirm ? setPauseConfirmOpen(true) : agentAction.mutate("pause"))}
|
||||
onPause={() =>
|
||||
pauseConfirm
|
||||
? setPauseConfirmOpen(true)
|
||||
: agentAction.mutate("pause")
|
||||
}
|
||||
onResume={() => agentAction.mutate("resume")}
|
||||
disabled={pauseResumeDisabled}
|
||||
size={size}
|
||||
@@ -411,7 +552,11 @@ export function AgentActionButtons({
|
||||
{children}
|
||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label={`Open actions for ${agent.name}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={`Open actions for ${agent.name}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -432,7 +577,11 @@ export function AgentActionButtons({
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
void copyTextToClipboard(agent.id).catch(() => {
|
||||
pushToast({ title: "Copy failed", body: "Clipboard access is unavailable.", tone: "error" });
|
||||
pushToast({
|
||||
title: "Copy failed",
|
||||
body: "Clipboard access is unavailable.",
|
||||
tone: "error",
|
||||
});
|
||||
});
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
@@ -455,7 +604,11 @@ export function AgentActionButtons({
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
if (onTerminateSuccess && !confirmNavigationStart(agentActionStartedDirtyRef)) return;
|
||||
if (
|
||||
onTerminateSuccess &&
|
||||
!confirmNavigationStart(agentActionStartedDirtyRef)
|
||||
)
|
||||
return;
|
||||
agentAction.mutate("terminate");
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FolderOpen, Heart, ChevronDown, X, Copy, Check, ExternalLink, Loader2, TriangleAlert } from "lucide-react";
|
||||
import { FolderOpen, Heart, ChevronDown, X, Copy, Check, ExternalLink, Loader2, TriangleAlert, Bug } from "lucide-react";
|
||||
import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils";
|
||||
import { copyTextToClipboard } from "../lib/clipboard";
|
||||
import { resolveAdapterTestEnvironmentId } from "../lib/adapter-test-environment";
|
||||
@@ -127,6 +127,7 @@ const emptyOverlay: AgentConfigOverlay = {
|
||||
identity: {},
|
||||
adapterConfig: {},
|
||||
heartbeat: {},
|
||||
debug: {},
|
||||
runtime: {},
|
||||
};
|
||||
|
||||
@@ -143,6 +144,7 @@ function isOverlayDirty(o: AgentConfigOverlay): boolean {
|
||||
o.adapterType !== undefined ||
|
||||
Object.keys(o.adapterConfig).length > 0 ||
|
||||
Object.keys(o.heartbeat).length > 0 ||
|
||||
Object.keys(o.debug).length > 0 ||
|
||||
Object.keys(o.runtime).length > 0 ||
|
||||
o.modelProfiles?.cheap !== undefined
|
||||
);
|
||||
@@ -340,7 +342,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
|
||||
const isDirty = !isCreate && isOverlayDirty(overlay);
|
||||
|
||||
type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "runtime";
|
||||
type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "debug" | "runtime";
|
||||
|
||||
/** Read effective value: overlay if dirty, else original */
|
||||
function eff<T>(group: RecordOverlayGroup, field: string, original: T): T {
|
||||
@@ -426,6 +428,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};
|
||||
const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record<string, unknown>) : {};
|
||||
const heartbeat = !isCreate ? ((runtimeConfig.heartbeat ?? {}) as Record<string, unknown>) : {};
|
||||
const debug = !isCreate ? ((runtimeConfig.debug ?? {}) as Record<string, unknown>) : {};
|
||||
|
||||
const adapterType = isCreate
|
||||
? props.values.adapterType
|
||||
@@ -1904,6 +1907,46 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ---- Debugging ---- */}
|
||||
{!isCreate ? (
|
||||
<div className={cn(!cards && "border-b border-border")}>
|
||||
{cards ? (
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||
<Bug className="h-3 w-3" /> Debugging
|
||||
</h3>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-4 py-2 text-xs font-medium text-muted-foreground">
|
||||
<Bug className="h-3 w-3" /> Debugging
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"border-amber-500/25 bg-amber-500/5",
|
||||
cards
|
||||
? "rounded-lg border p-4"
|
||||
: "mx-4 mb-4 rounded-md border px-3 py-3",
|
||||
)}
|
||||
>
|
||||
<ToggleField
|
||||
label="Capture raw provider traces"
|
||||
hint="Stores exact provider traffic for every future run until disabled. Traces may contain sensitive prompts and tool arguments, are administrator-only, and expire after 24 hours."
|
||||
checked={eff<unknown>("debug", "providerTrace", debug.providerTrace) === "raw"}
|
||||
onChange={(enabled) =>
|
||||
mark("debug", "providerTrace", enabled ? "raw" : undefined)
|
||||
}
|
||||
/>
|
||||
{eff<unknown>("debug", "providerTrace", debug.providerTrace) === "raw" ? (
|
||||
<div className="mt-3 flex items-start gap-2 rounded-md border border-amber-500/20 bg-background/60 px-3 py-2 text-xs text-amber-900 dark:text-amber-100">
|
||||
<Bug className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Raw tracing is on for future runs. Paperclip keeps at most 64 MiB per run and automatically deletes it after 24 hours.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -528,7 +528,11 @@ describe("IssueRunLedger", () => {
|
||||
expect(container.textContent).not.toContain("Continue monitoring");
|
||||
expect(container.textContent).not.toContain("Snooze 1h");
|
||||
expect(container.textContent).not.toContain("Mark false positive");
|
||||
expect(container.querySelectorAll("button")).toHaveLength(0);
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("button")).filter((button) =>
|
||||
/continue|snooze|false positive/i.test(button.textContent ?? ""),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(onWatchdogDecision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import type { ActivityEvent, Issue, Agent } from "@paperclipai/shared";
|
||||
import { isResponsibleUserDenialCode, responsibleUserLabel } from "@paperclipai/shared";
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import type { ActivityEvent, Issue, Agent, ProviderTraceMetadata } from "@paperclipai/shared";
|
||||
import {
|
||||
isResponsibleUserDenialCode,
|
||||
responsibleUserLabel,
|
||||
} from "@paperclipai/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@/lib/router";
|
||||
import { accessApi, type CurrentBoardAccess } from "../api/access";
|
||||
import { activityApi, type RunForIssue, type RunLivenessState } from "../api/activity";
|
||||
import {
|
||||
activityApi,
|
||||
type RunForIssue,
|
||||
type RunLivenessState,
|
||||
} from "../api/activity";
|
||||
import { ApiError } from "../api/client";
|
||||
import {
|
||||
heartbeatsApi,
|
||||
@@ -20,6 +27,12 @@ import { describeRunRetryState } from "../lib/runRetryState";
|
||||
import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold";
|
||||
import { SourceResolvedFoldBadge } from "./SourceResolvedFoldBadge";
|
||||
import { ResponsibleUserDenialNotice } from "./ResponsibleUserDenialNotice";
|
||||
import { RunnerInspector } from "./RunnerInspector";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import {
|
||||
ProviderTraceStatusBadge,
|
||||
runRequestedProviderTrace,
|
||||
} from "./ProviderTraceStatusBadge";
|
||||
|
||||
type IssueRunLedgerProps = {
|
||||
issueId: string;
|
||||
@@ -47,6 +60,8 @@ type IssueRunLedgerContentProps = {
|
||||
canRecordWatchdogDecisions?: boolean;
|
||||
watchdogDecisionError?: string | null;
|
||||
onWatchdogDecision?: (input: WatchdogDecisionInput) => void;
|
||||
onRerunWithTrace?: (run: RunForIssue) => void;
|
||||
providerTraceMetadata?: ReadonlyMap<string, ProviderTraceMetadata>;
|
||||
};
|
||||
|
||||
type LedgerRun = RunForIssue & {
|
||||
@@ -109,7 +124,8 @@ const LIVENESS_COPY: Record<RunLivenessState, LivenessCopy> = {
|
||||
needs_followup: {
|
||||
label: "Needs follow-up",
|
||||
tone: "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300",
|
||||
description: "Run produced useful output but did not prove concrete progress.",
|
||||
description:
|
||||
"Run produced useful output but did not prove concrete progress.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -134,14 +150,18 @@ const MISSING_LIVENESS_COPY: LivenessCopy = {
|
||||
const TERMINAL_CHILD_STATUSES = new Set<Issue["status"]>(["done", "cancelled"]);
|
||||
const ACTIVE_RUN_STATUSES = new Set(["queued", "running"]);
|
||||
|
||||
type RunOutputSilenceLevel = NonNullable<ActiveRunForIssue["outputSilence"]>["level"];
|
||||
type RunOutputSilenceLevel = NonNullable<
|
||||
ActiveRunForIssue["outputSilence"]
|
||||
>["level"];
|
||||
|
||||
type RunOutputSilenceCopy = {
|
||||
label: string;
|
||||
tone: string;
|
||||
};
|
||||
|
||||
const RUN_OUTPUT_SILENCE_COPY: Partial<Record<RunOutputSilenceLevel, RunOutputSilenceCopy>> = {
|
||||
const RUN_OUTPUT_SILENCE_COPY: Partial<
|
||||
Record<RunOutputSilenceLevel, RunOutputSilenceCopy>
|
||||
> = {
|
||||
suspicious: {
|
||||
label: "Silence watch",
|
||||
tone: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
@@ -157,12 +177,15 @@ const RUN_OUTPUT_SILENCE_COPY: Partial<Record<RunOutputSilenceLevel, RunOutputSi
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
return typeof value === "string" && value.trim().length > 0
|
||||
? value.trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
interface ModelProfileSummary {
|
||||
@@ -208,7 +231,10 @@ function readNumber(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function formatDuration(start: string | Date | null | undefined, end: string | Date | null | undefined) {
|
||||
function formatDuration(
|
||||
start: string | Date | null | undefined,
|
||||
end: string | Date | null | undefined,
|
||||
) {
|
||||
if (!start) return null;
|
||||
const startMs = new Date(start).getTime();
|
||||
const endMs = end ? new Date(end).getTime() : Date.now();
|
||||
@@ -217,7 +243,8 @@ function formatDuration(start: string | Date | null | undefined, end: string | D
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (minutes < 60) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||
if (minutes < 60)
|
||||
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
||||
@@ -228,7 +255,9 @@ function toIsoString(value: string | Date | null | undefined) {
|
||||
return value instanceof Date ? value.toISOString() : value;
|
||||
}
|
||||
|
||||
function liveRunToLedgerRun(run: LiveRunForIssue | ActiveRunForIssue): LedgerRun {
|
||||
function liveRunToLedgerRun(
|
||||
run: LiveRunForIssue | ActiveRunForIssue,
|
||||
): LedgerRun {
|
||||
return {
|
||||
runId: run.id,
|
||||
status: run.status,
|
||||
@@ -258,7 +287,12 @@ function mergeRuns(
|
||||
byId.set(
|
||||
run.id,
|
||||
existing
|
||||
? { ...existing, isLive: true, agentName: run.agentName, outputSilence: run.outputSilence }
|
||||
? {
|
||||
...existing,
|
||||
isLive: true,
|
||||
agentName: run.agentName,
|
||||
outputSilence: run.outputSilence,
|
||||
}
|
||||
: liveRunToLedgerRun(run),
|
||||
);
|
||||
}
|
||||
@@ -292,11 +326,15 @@ function isActiveRun(run: Pick<LedgerRun, "status" | "isLive">) {
|
||||
return run.isLive || ACTIVE_RUN_STATUSES.has(run.status);
|
||||
}
|
||||
|
||||
function runSummary(run: LedgerRun, agentMap: ReadonlyMap<string, Pick<Agent, "name">>) {
|
||||
function runSummary(
|
||||
run: LedgerRun,
|
||||
agentMap: ReadonlyMap<string, Pick<Agent, "name">>,
|
||||
) {
|
||||
const agentName = compactAgentName(run, agentMap);
|
||||
if (run.status === "running") return `Running now by ${agentName}`;
|
||||
if (run.status === "queued") return `Queued for ${agentName}`;
|
||||
if (run.status === "scheduled_retry") return `Automatic retry scheduled for ${agentName}`;
|
||||
if (run.status === "scheduled_retry")
|
||||
return `Automatic retry scheduled for ${agentName}`;
|
||||
return `${statusLabel(run.status)} by ${agentName}`;
|
||||
}
|
||||
|
||||
@@ -312,19 +350,27 @@ function stopReasonLabel(run: RunForIssue) {
|
||||
const timeoutFired = result?.timeoutFired === true;
|
||||
const effectiveTimeoutSec = readNumber(result?.effectiveTimeoutSec);
|
||||
const timeoutText =
|
||||
effectiveTimeoutSec && effectiveTimeoutSec > 0 ? `${effectiveTimeoutSec}s timeout` : null;
|
||||
effectiveTimeoutSec && effectiveTimeoutSec > 0
|
||||
? `${effectiveTimeoutSec}s timeout`
|
||||
: null;
|
||||
|
||||
if (timeoutFired || stopReason === "timeout") {
|
||||
return timeoutText ? `timeout (${timeoutText})` : "timeout";
|
||||
}
|
||||
if (stopReason === "max_turns_exhausted" || stopReason === "turn_limit_exhausted") return "max turns exhausted";
|
||||
if (
|
||||
stopReason === "max_turns_exhausted" ||
|
||||
stopReason === "turn_limit_exhausted"
|
||||
)
|
||||
return "max turns exhausted";
|
||||
if (stopReason === "budget_paused") return "budget paused";
|
||||
if (stopReason === "cancelled") return "cancelled";
|
||||
if (stopReason === "paused") return "paused by board";
|
||||
if (stopReason === "process_lost") return "process lost";
|
||||
if (stopReason === "unmanaged_background_task_stopped") return "unmanaged background task stopped";
|
||||
if (stopReason === "unmanaged_background_task_stopped")
|
||||
return "unmanaged background task stopped";
|
||||
if (stopReason === "adapter_failed") return "adapter failed";
|
||||
if (stopReason === "completed") return timeoutText ? `completed (${timeoutText})` : "completed";
|
||||
if (stopReason === "completed")
|
||||
return timeoutText ? `completed (${timeoutText})` : "completed";
|
||||
return timeoutText;
|
||||
}
|
||||
|
||||
@@ -341,7 +387,10 @@ function lastUsefulActionLabel(run: LedgerRun) {
|
||||
if (run.status === "scheduled_retry") return "Waiting for next attempt";
|
||||
if (run.lastUsefulActionAt) return relativeTime(run.lastUsefulActionAt);
|
||||
if (isActiveRun(run)) return "No action recorded yet";
|
||||
if (run.livenessState === "plan_only" || run.livenessState === "needs_followup") {
|
||||
if (
|
||||
run.livenessState === "plan_only" ||
|
||||
run.livenessState === "needs_followup"
|
||||
) {
|
||||
return "No concrete action";
|
||||
}
|
||||
if (run.livenessState === "empty_response") return "No useful output";
|
||||
@@ -359,21 +408,31 @@ function hasExhaustedContinuation(run: RunForIssue) {
|
||||
}
|
||||
|
||||
function childIssueSummary(childIssues: Issue[]) {
|
||||
const active = childIssues.filter((issue) => !TERMINAL_CHILD_STATUSES.has(issue.status));
|
||||
const active = childIssues.filter(
|
||||
(issue) => !TERMINAL_CHILD_STATUSES.has(issue.status),
|
||||
);
|
||||
const done = childIssues.filter((issue) => issue.status === "done").length;
|
||||
const cancelled = childIssues.filter((issue) => issue.status === "cancelled").length;
|
||||
const cancelled = childIssues.filter(
|
||||
(issue) => issue.status === "cancelled",
|
||||
).length;
|
||||
return { active, done, cancelled, total: childIssues.length };
|
||||
}
|
||||
|
||||
function compactAgentName(run: LedgerRun, agentMap: ReadonlyMap<string, Pick<Agent, "name">>) {
|
||||
return run.agentName ?? agentMap.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
|
||||
function compactAgentName(
|
||||
run: LedgerRun,
|
||||
agentMap: ReadonlyMap<string, Pick<Agent, "name">>,
|
||||
) {
|
||||
return (
|
||||
run.agentName ?? agentMap.get(run.agentId)?.name ?? run.agentId.slice(0, 8)
|
||||
);
|
||||
}
|
||||
|
||||
function formatSilenceAge(ms: number | null | undefined) {
|
||||
if (!ms || ms <= 0) return null;
|
||||
const totalMinutes = Math.floor(ms / 60_000);
|
||||
if (totalMinutes < 1) return "under 1 minute";
|
||||
if (totalMinutes < 60) return `${totalMinutes} minute${totalMinutes === 1 ? "" : "s"}`;
|
||||
if (totalMinutes < 60)
|
||||
return `${totalMinutes} minute${totalMinutes === 1 ? "" : "s"}`;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
if (minutes === 0) return `${hours} hour${hours === 1 ? "" : "s"}`;
|
||||
@@ -385,13 +444,19 @@ function canBoardRecordWatchdogDecision(
|
||||
boardAccess: CurrentBoardAccess | undefined,
|
||||
) {
|
||||
if (!boardAccess) return false;
|
||||
if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true;
|
||||
if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin)
|
||||
return true;
|
||||
|
||||
const membership = boardAccess.memberships?.find(
|
||||
(item) => item.companyId === companyId && item.status === "active",
|
||||
);
|
||||
if (!membership) return boardAccess.companyIds.includes(companyId) && !boardAccess.memberships;
|
||||
return membership.membershipRole !== "viewer" && membership.membershipRole !== null;
|
||||
if (!membership)
|
||||
return (
|
||||
boardAccess.companyIds.includes(companyId) && !boardAccess.memberships
|
||||
);
|
||||
return (
|
||||
membership.membershipRole !== "viewer" && membership.membershipRole !== null
|
||||
);
|
||||
}
|
||||
|
||||
function watchdogDecisionErrorMessage(error: unknown) {
|
||||
@@ -416,7 +481,9 @@ export function IssueRunLedger({
|
||||
}: IssueRunLedgerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
const [watchdogDecisionError, setWatchdogDecisionError] = useState<string | null>(null);
|
||||
const [watchdogDecisionError, setWatchdogDecisionError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const { data: boardAccess } = useQuery({
|
||||
queryKey: queryKeys.access.currentBoardAccess,
|
||||
queryFn: () => accessApi.getCurrentBoardAccess(),
|
||||
@@ -425,7 +492,8 @@ export function IssueRunLedger({
|
||||
const { data: runs } = useQuery({
|
||||
queryKey: queryKeys.issues.runs(issueId),
|
||||
queryFn: () => activityApi.runsForIssue(issueId),
|
||||
refetchInterval: hasLiveRuns || issueStatus === "in_progress" ? 5000 : false,
|
||||
refetchInterval:
|
||||
hasLiveRuns || issueStatus === "in_progress" ? 5000 : false,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueId),
|
||||
});
|
||||
const { data: liveRuns } = useQuery({
|
||||
@@ -433,28 +501,53 @@ export function IssueRunLedger({
|
||||
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
|
||||
enabled: hasLiveRuns,
|
||||
refetchInterval: 3000,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(issueId),
|
||||
placeholderData:
|
||||
keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(issueId),
|
||||
});
|
||||
const { data: activeRun = null } = useQuery({
|
||||
queryKey: queryKeys.issues.activeRun(issueId),
|
||||
queryFn: () => heartbeatsApi.activeRunForIssue(issueId),
|
||||
enabled: hasLiveRuns || issueStatus === "in_progress",
|
||||
refetchInterval: hasLiveRuns ? false : 3000,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<ActiveRunForIssue | null>(issueId),
|
||||
placeholderData: keepPreviousDataForSameQueryTail<ActiveRunForIssue | null>(
|
||||
issueId,
|
||||
),
|
||||
});
|
||||
const traceRunIds = useMemo(
|
||||
() => (runs ?? []).slice(0, 100).map((run) => run.runId),
|
||||
[runs],
|
||||
);
|
||||
const canInspectProviderTrace =
|
||||
boardAccess?.source === "local_implicit" || boardAccess?.isInstanceAdmin === true;
|
||||
const { data: providerTraceRows } = useQuery({
|
||||
queryKey: queryKeys.providerTraceMetadata(companyId, traceRunIds),
|
||||
queryFn: () => heartbeatsApi.providerTraceMetadata(companyId, traceRunIds),
|
||||
enabled: canInspectProviderTrace && traceRunIds.length > 0,
|
||||
retry: false,
|
||||
});
|
||||
const providerTraceMetadata = useMemo(
|
||||
() => new Map((providerTraceRows ?? []).map((trace) => [trace.runId, trace])),
|
||||
[providerTraceRows],
|
||||
);
|
||||
const watchdogDecision = useMutation({
|
||||
mutationFn: (input: WatchdogDecisionInput) => heartbeatsApi.recordWatchdogDecision(input),
|
||||
mutationFn: (input: WatchdogDecisionInput) =>
|
||||
heartbeatsApi.recordWatchdogDecision(input),
|
||||
onMutate: () => {
|
||||
setWatchdogDecisionError(null);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setWatchdogDecisionError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.activeRun(issueId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.liveRuns(issueId),
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = watchdogDecisionErrorMessage(error);
|
||||
const dedupeSuffix = error instanceof ApiError ? String(error.status) : "error";
|
||||
const dedupeSuffix =
|
||||
error instanceof ApiError ? String(error.status) : "error";
|
||||
setWatchdogDecisionError(message);
|
||||
pushToast({
|
||||
title: "Watchdog decision not recorded",
|
||||
@@ -464,6 +557,48 @@ export function IssueRunLedger({
|
||||
});
|
||||
},
|
||||
});
|
||||
const rerunWithTrace = useMutation({
|
||||
mutationFn: async (run: RunForIssue) => {
|
||||
const context = asRecord(run.contextSnapshot);
|
||||
const payload: Record<string, unknown> = {};
|
||||
for (const key of ["issueId", "taskId", "taskKey"] as const) {
|
||||
const value = readString(context?.[key]);
|
||||
if (value) payload[key] = value;
|
||||
}
|
||||
const result = await agentsApi.wakeup(
|
||||
run.agentId,
|
||||
{
|
||||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
reason: "rerun_with_provider_trace",
|
||||
payload,
|
||||
debug: { providerTrace: "raw" },
|
||||
},
|
||||
companyId,
|
||||
);
|
||||
if (!("id" in result))
|
||||
throw new Error(result.message ?? "Trace re-run was skipped.");
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.runs(issueId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.liveRuns(issueId),
|
||||
});
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Trace re-run not started",
|
||||
body:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Paperclip could not start the trace re-run.",
|
||||
tone: "error",
|
||||
dedupeKey: `provider-trace-rerun:${issueId}`,
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<IssueRunLedgerContent
|
||||
@@ -477,9 +612,14 @@ export function IssueRunLedger({
|
||||
renderActivityEvent={renderActivityEvent}
|
||||
resolveUserLabel={resolveUserLabel}
|
||||
pendingWatchdogDecision={watchdogDecision.variables?.decision ?? null}
|
||||
canRecordWatchdogDecisions={canBoardRecordWatchdogDecision(companyId, boardAccess)}
|
||||
canRecordWatchdogDecisions={canBoardRecordWatchdogDecision(
|
||||
companyId,
|
||||
boardAccess,
|
||||
)}
|
||||
watchdogDecisionError={watchdogDecisionError}
|
||||
onWatchdogDecision={(input) => watchdogDecision.mutate(input)}
|
||||
onRerunWithTrace={(run) => rerunWithTrace.mutate(run)}
|
||||
providerTraceMetadata={providerTraceMetadata}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -498,14 +638,29 @@ export function IssueRunLedgerContent({
|
||||
canRecordWatchdogDecisions = true,
|
||||
watchdogDecisionError,
|
||||
onWatchdogDecision,
|
||||
onRerunWithTrace,
|
||||
providerTraceMetadata = new Map(),
|
||||
}: IssueRunLedgerContentProps) {
|
||||
const ledgerRuns = useMemo(() => mergeRuns(runs, liveRuns, activeRun), [activeRun, liveRuns, runs]);
|
||||
const [inspectedRun, setInspectedRun] = useState<LedgerRun | null>(null);
|
||||
const ledgerRuns = useMemo(
|
||||
() => mergeRuns(runs, liveRuns, activeRun),
|
||||
[activeRun, liveRuns, runs],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (inspectedRun || typeof window === "undefined") return;
|
||||
const requestedRunId = new URLSearchParams(window.location.search).get("inspectRun");
|
||||
if (!requestedRunId) return;
|
||||
const requestedRun = ledgerRuns.find((run) => run.runId === requestedRunId);
|
||||
if (requestedRun) setInspectedRun(requestedRun);
|
||||
}, [inspectedRun, ledgerRuns]);
|
||||
const latestRun = ledgerRuns[0] ?? null;
|
||||
const latestSilentRun = useMemo(
|
||||
() =>
|
||||
ledgerRuns.find((run) =>
|
||||
isActiveRun(run)
|
||||
&& (run.outputSilence?.level === "critical" || run.outputSilence?.level === "suspicious"),
|
||||
ledgerRuns.find(
|
||||
(run) =>
|
||||
isActiveRun(run) &&
|
||||
(run.outputSilence?.level === "critical" ||
|
||||
run.outputSilence?.level === "suspicious"),
|
||||
) ?? null,
|
||||
[ledgerRuns],
|
||||
);
|
||||
@@ -526,9 +681,10 @@ export function IssueRunLedgerContent({
|
||||
items.push({
|
||||
kind: "activity",
|
||||
id: event.id,
|
||||
timestamp: event.createdAt instanceof Date
|
||||
? event.createdAt.toISOString()
|
||||
: String(event.createdAt),
|
||||
timestamp:
|
||||
event.createdAt instanceof Date
|
||||
? event.createdAt.toISOString()
|
||||
: String(event.createdAt),
|
||||
event,
|
||||
});
|
||||
}
|
||||
@@ -546,7 +702,9 @@ export function IssueRunLedgerContent({
|
||||
<section className="space-y-3" aria-label="Task run ledger">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Run ledger</h3>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Run ledger
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{latestRun
|
||||
? runSummary(latestRun, agentMap)
|
||||
@@ -583,9 +741,13 @@ export function IssueRunLedgerContent({
|
||||
to={`/issues/${child.identifier ?? child.id}`}
|
||||
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-md border border-border bg-background px-2 py-1 text-(length:--text-micro) hover:bg-accent/40"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-muted-foreground">{child.identifier ?? child.id.slice(0, 8)}</span>
|
||||
<span className="shrink-0 font-mono text-muted-foreground">
|
||||
{child.identifier ?? child.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="truncate">{child.title}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{statusLabel(child.status)}</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{statusLabel(child.status)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
{children.active.length > 4 ? (
|
||||
@@ -614,7 +776,9 @@ export function IssueRunLedgerContent({
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
Latest active run has been silent for{" "}
|
||||
{formatSilenceAge(latestSilentRun.outputSilence.silenceAgeMs) ?? "an extended period"}.
|
||||
{formatSilenceAge(latestSilentRun.outputSilence.silenceAgeMs) ??
|
||||
"an extended period"}
|
||||
.
|
||||
{latestSilentRun.outputSilence.evaluationIssueIdentifier ? (
|
||||
<>
|
||||
{" "}
|
||||
@@ -624,8 +788,8 @@ export function IssueRunLedgerContent({
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
{latestSilentRun.outputSilence.evaluationIssueIdentifier}
|
||||
</Link>
|
||||
{" "}for recovery context.
|
||||
</Link>{" "}
|
||||
for recovery context.
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
@@ -638,8 +802,10 @@ export function IssueRunLedgerContent({
|
||||
onWatchdogDecision({
|
||||
runId: latestSilentRun.runId,
|
||||
decision: "continue",
|
||||
evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null,
|
||||
})}
|
||||
evaluationIssueId:
|
||||
latestSilentRun.outputSilence?.evaluationIssueId ?? null,
|
||||
})
|
||||
}
|
||||
disabled={pendingWatchdogDecision != null}
|
||||
>
|
||||
Continue monitoring
|
||||
@@ -651,10 +817,14 @@ export function IssueRunLedgerContent({
|
||||
onWatchdogDecision({
|
||||
runId: latestSilentRun.runId,
|
||||
decision: "snooze",
|
||||
evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null,
|
||||
snoozedUntil: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
evaluationIssueId:
|
||||
latestSilentRun.outputSilence?.evaluationIssueId ?? null,
|
||||
snoozedUntil: new Date(
|
||||
Date.now() + 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
reason: "Snoozed from issue run ledger",
|
||||
})}
|
||||
})
|
||||
}
|
||||
disabled={pendingWatchdogDecision != null}
|
||||
>
|
||||
Snooze 1h
|
||||
@@ -666,9 +836,11 @@ export function IssueRunLedgerContent({
|
||||
onWatchdogDecision({
|
||||
runId: latestSilentRun.runId,
|
||||
decision: "dismissed_false_positive",
|
||||
evaluationIssueId: latestSilentRun.outputSilence?.evaluationIssueId ?? null,
|
||||
evaluationIssueId:
|
||||
latestSilentRun.outputSilence?.evaluationIssueId ?? null,
|
||||
reason: "Dismissed from issue run ledger",
|
||||
})}
|
||||
})
|
||||
}
|
||||
disabled={pendingWatchdogDecision != null}
|
||||
>
|
||||
Mark false positive
|
||||
@@ -693,7 +865,11 @@ export function IssueRunLedgerContent({
|
||||
<div className="space-y-1.5">
|
||||
{feedItems.slice(0, 20).map((item) => {
|
||||
if (item.kind === "activity") {
|
||||
return <div key={`activity:${item.id}`}>{renderActivityEvent?.(item.event)}</div>;
|
||||
return (
|
||||
<div key={`activity:${item.id}`}>
|
||||
{renderActivityEvent?.(item.event)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const run = item.run;
|
||||
const liveness = livenessCopyForRun(run);
|
||||
@@ -706,8 +882,12 @@ export function IssueRunLedgerContent({
|
||||
const onBehalfOfLabel = run.responsibleUserId
|
||||
? responsibleUserLabel(resolveUserLabel?.(run.responsibleUserId))
|
||||
: null;
|
||||
const denialCode = isResponsibleUserDenialCode(run.errorCode) ? run.errorCode : null;
|
||||
const sourceResolvedFold = readSourceResolvedWatchdogFold(run.resultJson);
|
||||
const denialCode = isResponsibleUserDenialCode(run.errorCode)
|
||||
? run.errorCode
|
||||
: null;
|
||||
const sourceResolvedFold = readSourceResolvedWatchdogFold(
|
||||
run.resultJson,
|
||||
);
|
||||
return (
|
||||
<article
|
||||
key={`run:${run.runId}`}
|
||||
@@ -728,7 +908,8 @@ export function IssueRunLedgerContent({
|
||||
className="min-w-0 max-w-full truncate text-muted-foreground"
|
||||
title={`Acting on behalf of ${onBehalfOfLabel}`}
|
||||
>
|
||||
on behalf of <span className="text-foreground">{onBehalfOfLabel}</span>
|
||||
on behalf of{" "}
|
||||
<span className="text-foreground">{onBehalfOfLabel}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span className="rounded-md border border-border px-1.5 py-0.5 text-(length:--text-micro) capitalize text-muted-foreground">
|
||||
@@ -740,6 +921,11 @@ export function IssueRunLedgerContent({
|
||||
live
|
||||
</span>
|
||||
) : null}
|
||||
<ProviderTraceStatusBadge
|
||||
trace={providerTraceMetadata.get(run.runId)}
|
||||
requested={runRequestedProviderTrace(run.contextSnapshot)}
|
||||
showOff
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-md border px-1.5 py-0.5 text-(length:--text-micro) font-medium",
|
||||
@@ -755,7 +941,9 @@ export function IssueRunLedgerContent({
|
||||
</span>
|
||||
) : null}
|
||||
{continuation ? (
|
||||
<span className="text-(length:--text-micro) text-muted-foreground">{continuation}</span>
|
||||
<span className="text-(length:--text-micro) text-muted-foreground">
|
||||
{continuation}
|
||||
</span>
|
||||
) : null}
|
||||
{retryState ? (
|
||||
<span
|
||||
@@ -767,7 +955,8 @@ export function IssueRunLedgerContent({
|
||||
{retryState.badgeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{run.outputSilence && RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level] ? (
|
||||
{run.outputSilence &&
|
||||
RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level] ? (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-md border px-1.5 py-0.5 text-(length:--text-micro) font-medium",
|
||||
@@ -780,11 +969,12 @@ export function IssueRunLedgerContent({
|
||||
{(() => {
|
||||
const profile = modelProfileForRun(run);
|
||||
if (!profile) return null;
|
||||
const label = profile.applied === profile.requested
|
||||
? `Profile: ${profile.requested}`
|
||||
: profile.applied
|
||||
? `Profile: ${profile.requested} → ${profile.applied}`
|
||||
: `Profile: ${profile.requested} (unavailable)`;
|
||||
const label =
|
||||
profile.applied === profile.requested
|
||||
? `Profile: ${profile.requested}`
|
||||
: profile.applied
|
||||
? `Profile: ${profile.requested} → ${profile.applied}`
|
||||
: `Profile: ${profile.requested} (unavailable)`;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -798,7 +988,16 @@ export function IssueRunLedgerContent({
|
||||
);
|
||||
})()}
|
||||
{sourceResolvedFold ? <SourceResolvedFoldBadge /> : null}
|
||||
<span className="ml-auto shrink-0">{relativeTime(item.timestamp)}</span>
|
||||
<span className="ml-auto shrink-0">
|
||||
{relativeTime(item.timestamp)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-border px-1.5 py-0.5 text-(length:--text-micro) text-foreground hover:bg-accent/40"
|
||||
onClick={() => setInspectedRun(run)}
|
||||
>
|
||||
Inspect run
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
||||
@@ -819,7 +1018,9 @@ export function IssueRunLedgerContent({
|
||||
{retryState ? (
|
||||
<div className="rounded-md border border-border/70 bg-accent/20 px-2 py-2 text-xs leading-5 text-muted-foreground">
|
||||
{retryState.detail ? <p>{retryState.detail}</p> : null}
|
||||
{retryState.secondary ? <p>{retryState.secondary}</p> : null}
|
||||
{retryState.secondary ? (
|
||||
<p>{retryState.secondary}</p>
|
||||
) : null}
|
||||
{retryState.retryOfRunId ? (
|
||||
<p>
|
||||
Retry of{" "}
|
||||
@@ -836,14 +1037,20 @@ export function IssueRunLedgerContent({
|
||||
|
||||
{(() => {
|
||||
const profile = modelProfileForRun(run);
|
||||
if (!profile?.fallbackReason || profile.applied === profile.requested) return null;
|
||||
if (
|
||||
!profile?.fallbackReason ||
|
||||
profile.applied === profile.requested
|
||||
)
|
||||
return null;
|
||||
return (
|
||||
<p className="min-w-0 break-words text-(length:--text-micro) leading-5 text-amber-700 dark:text-amber-300">
|
||||
{profile.requested === "cheap"
|
||||
? "Cheap profile fell back to primary"
|
||||
: `${profile.requested} profile unavailable`}
|
||||
{": "}
|
||||
<span className="font-mono">{profile.fallbackReason}</span>
|
||||
<span className="font-mono">
|
||||
{profile.fallbackReason}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
@@ -857,14 +1064,22 @@ export function IssueRunLedgerContent({
|
||||
{denialCode ? (
|
||||
<ResponsibleUserDenialNotice
|
||||
code={denialCode}
|
||||
userName={run.responsibleUserId ? resolveUserLabel?.(run.responsibleUserId) : null}
|
||||
userName={
|
||||
run.responsibleUserId
|
||||
? resolveUserLabel?.(run.responsibleUserId)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{run.nextAction ? (
|
||||
<div className="min-w-0 rounded-md bg-accent/40 px-2 py-1.5 text-xs leading-5">
|
||||
<span className="font-medium text-foreground">Next action: </span>
|
||||
<span className="break-words text-muted-foreground">{run.nextAction}</span>
|
||||
<span className="font-medium text-foreground">
|
||||
Next action:{" "}
|
||||
</span>
|
||||
<span className="break-words text-muted-foreground">
|
||||
{run.nextAction}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
@@ -877,6 +1092,22 @@ export function IssueRunLedgerContent({
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{inspectedRun ? (
|
||||
<RunnerInspector
|
||||
runId={inspectedRun.runId}
|
||||
run={inspectedRun}
|
||||
open
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) setInspectedRun(null);
|
||||
}}
|
||||
onRerunWithTrace={
|
||||
!["queued", "running"].includes(inspectedRun.status) &&
|
||||
onRerunWithTrace
|
||||
? () => onRerunWithTrace(inspectedRun)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ProviderTraceMetadata } from "@paperclipai/shared";
|
||||
import { Bug, CircleOff } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function runRequestedProviderTrace(
|
||||
contextSnapshot: Record<string, unknown> | null | undefined,
|
||||
) {
|
||||
if (!contextSnapshot) return false;
|
||||
const debug = contextSnapshot.debug;
|
||||
return (
|
||||
typeof debug === "object" &&
|
||||
debug !== null &&
|
||||
!Array.isArray(debug) &&
|
||||
(debug as Record<string, unknown>).providerTrace === "raw"
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderTraceStatusBadge({
|
||||
trace,
|
||||
requested = false,
|
||||
showOff = false,
|
||||
className,
|
||||
}: {
|
||||
trace?: ProviderTraceMetadata | null;
|
||||
requested?: boolean;
|
||||
showOff?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const status = trace?.status;
|
||||
const expired = trace
|
||||
? new Date(trace.expiresAt).getTime() <= Date.now()
|
||||
: false;
|
||||
const label = expired
|
||||
? "Trace expired"
|
||||
: status === "capturing"
|
||||
? "Raw tracing enabled"
|
||||
: status === "complete"
|
||||
? "Trace captured"
|
||||
: status === "incomplete"
|
||||
? "Trace incomplete"
|
||||
: status === "truncated"
|
||||
? "Trace truncated"
|
||||
: status === "expired"
|
||||
? "Trace expired"
|
||||
: status === "deleted"
|
||||
? "Trace deleted"
|
||||
: requested
|
||||
? "Trace requested"
|
||||
: showOff
|
||||
? "Trace off"
|
||||
: null;
|
||||
if (!label) return null;
|
||||
const warning =
|
||||
status === "incomplete" ||
|
||||
status === "truncated" ||
|
||||
status === "expired" ||
|
||||
status === "deleted" ||
|
||||
expired;
|
||||
const Icon = label === "Trace off" ? CircleOff : Bug;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-(length:--text-micro) font-medium",
|
||||
label === "Trace off" || label === "Trace deleted" || label === "Trace expired"
|
||||
? "border-border bg-background text-muted-foreground"
|
||||
: warning
|
||||
? "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300"
|
||||
: "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-200",
|
||||
className,
|
||||
)}
|
||||
title={
|
||||
trace
|
||||
? `${trace.frameCount} frames · ${trace.byteCount} bytes · expires ${new Date(trace.expiresAt).toLocaleString()}`
|
||||
: requested
|
||||
? "This run requested sensitive provider-frame capture."
|
||||
: "Raw provider-frame capture was disabled for this run."
|
||||
}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RunnerInspector } from "./RunnerInspector";
|
||||
|
||||
const accessMock = vi.hoisted(() => vi.fn());
|
||||
const eventsMock = vi.hoisted(() => vi.fn());
|
||||
const traceMock = vi.hoisted(() => vi.fn());
|
||||
const revealMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/access", () => ({
|
||||
accessApi: { getCurrentBoardAccess: accessMock },
|
||||
}));
|
||||
|
||||
vi.mock("@/api/heartbeats", () => ({
|
||||
heartbeatsApi: {
|
||||
events: eventsMock,
|
||||
providerTrace: traceMock,
|
||||
revealProviderTraceFrame: revealMock,
|
||||
downloadProviderTrace: vi.fn(),
|
||||
deleteProviderTrace: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/sheet", () => ({
|
||||
Sheet: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
||||
open ? createElement("div", null, children) : null,
|
||||
SheetContent: ({ children }: { children: ReactNode }) =>
|
||||
createElement("div", null, children),
|
||||
SheetDescription: ({ children }: { children: ReactNode }) =>
|
||||
createElement("p", null, children),
|
||||
SheetHeader: ({ children }: { children: ReactNode }) =>
|
||||
createElement("header", null, children),
|
||||
SheetTitle: ({ children }: { children: ReactNode }) =>
|
||||
createElement("h2", null, children),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/select", () => ({
|
||||
Select: ({ children }: { children: ReactNode }) =>
|
||||
createElement("div", null, children),
|
||||
SelectContent: ({ children }: { children: ReactNode }) =>
|
||||
createElement("div", null, children),
|
||||
SelectItem: ({ children }: { children: ReactNode }) =>
|
||||
createElement("span", null, children),
|
||||
SelectTrigger: ({ children }: { children: ReactNode }) =>
|
||||
createElement("button", { type: "button" }, children),
|
||||
SelectValue: ({ placeholder }: { placeholder?: string }) =>
|
||||
createElement("span", null, placeholder),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/scroll-area", () => ({
|
||||
ScrollArea: ({ children }: { children: ReactNode }) =>
|
||||
createElement("div", null, children),
|
||||
}));
|
||||
|
||||
(
|
||||
globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flush() {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
flushSync(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
describe("RunnerInspector", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, "", "/");
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
accessMock.mockResolvedValue({
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
eventsMock.mockResolvedValue([]);
|
||||
traceMock.mockResolvedValue({ trace: null, entries: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps canonical inspection available when raw capture was disabled", async () => {
|
||||
const rerun = vi.fn();
|
||||
flushSync(() =>
|
||||
root.render(
|
||||
<RunnerInspector
|
||||
runId="run-1"
|
||||
run={{ status: "succeeded", resultJson: null }}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onRerunWithTrace={rerun}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
"Correlate exact provider traffic with every interpretation stage",
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
"Raw provider capture was off for this run.",
|
||||
);
|
||||
const rerunButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Re-run with provider trace"),
|
||||
);
|
||||
flushSync(() => rerunButton?.click());
|
||||
expect(rerun).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shows redacted frames by default and warns before exact reveal", async () => {
|
||||
traceMock.mockResolvedValue({
|
||||
trace: {
|
||||
id: "trace-1",
|
||||
runId: "run-1",
|
||||
companyId: "company-1",
|
||||
status: "complete",
|
||||
provider: "codex",
|
||||
frameCount: 1,
|
||||
byteCount: 31,
|
||||
digest: `sha256:${"a".repeat(64)}`,
|
||||
reason: null,
|
||||
requestedBy: "local-admin",
|
||||
createdAt: "2026-08-22T12:00:00.000Z",
|
||||
expiresAt: "2026-08-23T12:00:00.000Z",
|
||||
deletedAt: null,
|
||||
schema: "paperclip.provider_trace_metadata.v1",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
kind: "frame",
|
||||
frameId: 1,
|
||||
direction: "provider_to_client",
|
||||
byteLength: 31,
|
||||
parsed: { method: "item/completed", authorization: "[withheld]" },
|
||||
withheldPaths: ["authorization"],
|
||||
},
|
||||
],
|
||||
});
|
||||
revealMock.mockResolvedValue({
|
||||
schema: "paperclip.provider_trace_frame.v1",
|
||||
frameId: 1,
|
||||
timestamp: "1",
|
||||
direction: "provider_to_client",
|
||||
transport: "stdio_jsonl",
|
||||
provider: "codex",
|
||||
byteLength: 31,
|
||||
digest: `sha256:${"b".repeat(64)}`,
|
||||
rawBase64: btoa(JSON.stringify({ method: "item/completed" })),
|
||||
});
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
flushSync(() =>
|
||||
root.render(
|
||||
<RunnerInspector
|
||||
runId="run-1"
|
||||
run={{ status: "succeeded", resultJson: null }}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("Withheld paths: authorization");
|
||||
expect(container.textContent).not.toContain("rawBase64");
|
||||
const reveal = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Reveal exact frame"),
|
||||
);
|
||||
flushSync(() => reveal?.click());
|
||||
await flush();
|
||||
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining("may contain prompts"),
|
||||
);
|
||||
expect(revealMock).toHaveBeenCalledWith("run-1", 1);
|
||||
});
|
||||
|
||||
it("correlates a Codex web search through every stage to canonical PRP and presentation", async () => {
|
||||
eventsMock.mockResolvedValue([
|
||||
{
|
||||
id: 91,
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
agentId: "agent-1",
|
||||
seq: 42,
|
||||
eventType: "research.completed",
|
||||
stream: "stdout",
|
||||
level: "info",
|
||||
color: null,
|
||||
message: null,
|
||||
payload: {
|
||||
prpEvent: {
|
||||
eventType: "research.completed",
|
||||
sourceEventId: "event_runner_000001",
|
||||
sourceSequence: 42,
|
||||
payload: { query: "best bbq sauce", status: "completed" },
|
||||
},
|
||||
},
|
||||
createdAt: "2026-08-22T12:00:01.000Z",
|
||||
},
|
||||
]);
|
||||
traceMock.mockResolvedValue({
|
||||
trace: {
|
||||
id: "trace-1",
|
||||
runId: "run-1",
|
||||
companyId: "company-1",
|
||||
status: "complete",
|
||||
provider: "codex",
|
||||
frameCount: 1,
|
||||
byteCount: 512,
|
||||
digest: `sha256:${"a".repeat(64)}`,
|
||||
reason: null,
|
||||
requestedBy: "local-admin",
|
||||
createdAt: "2026-08-22T12:00:00.000Z",
|
||||
expiresAt: "2026-08-23T12:00:00.000Z",
|
||||
deletedAt: null,
|
||||
schema: "paperclip.provider_trace_metadata.v1",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
kind: "frame",
|
||||
frameId: 27,
|
||||
timestamp: "1787400001000",
|
||||
direction: "provider_to_client",
|
||||
byteLength: 512,
|
||||
digest: `sha256:${"b".repeat(64)}`,
|
||||
parsed: {
|
||||
method: "item/completed",
|
||||
params: {
|
||||
item: {
|
||||
id: "search-1",
|
||||
type: "webSearch",
|
||||
query: "best bbq sauce",
|
||||
results: [{ title: "Sauce guide", url: "https://example.com" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
withheldPaths: [],
|
||||
},
|
||||
{
|
||||
kind: "interpretation",
|
||||
frameId: 27,
|
||||
debugChannel: "rust_native",
|
||||
debugSequence: 1,
|
||||
stage: "rust_jsonrpc_parse",
|
||||
ruleId: "codex.notification",
|
||||
disposition: "mapped",
|
||||
emittedEventIds: [],
|
||||
droppedFields: [],
|
||||
reason: "Parsed Codex notification",
|
||||
},
|
||||
{
|
||||
kind: "interpretation",
|
||||
frameId: 27,
|
||||
debugChannel: "rust_native",
|
||||
debugSequence: 2,
|
||||
stage: "rust_durable_normalization",
|
||||
ruleId: "provider.notification.known",
|
||||
disposition: "mapped",
|
||||
emittedEventIds: ["event_runner_000001"],
|
||||
droppedFields: ["params.item.results"],
|
||||
fieldMappings: [
|
||||
{
|
||||
inputPath: "params.item.query",
|
||||
outputPath: "payload.query",
|
||||
action: "copied",
|
||||
reason: "Preserved the search query",
|
||||
},
|
||||
{
|
||||
inputPath: "params.item.results",
|
||||
action: "dropped",
|
||||
reason: "Raw provider results are not part of this semantic event",
|
||||
},
|
||||
],
|
||||
reason: "Normalized provider web search",
|
||||
},
|
||||
{
|
||||
kind: "interpretation",
|
||||
frameId: 27,
|
||||
debugChannel: "typescript_runnerd_rehydration",
|
||||
debugSequence: 1,
|
||||
stage: "typescript_runnerd_rehydration",
|
||||
ruleId: "runnerd.rehydrate.research.completed",
|
||||
disposition: "mapped",
|
||||
emittedEventIds: ["event_runner_000001"],
|
||||
droppedFields: [],
|
||||
reason: "Rehydrated canonical event",
|
||||
},
|
||||
{
|
||||
kind: "interpretation",
|
||||
frameId: 27,
|
||||
debugChannel: "typescript_runnerd_rehydration",
|
||||
debugSequence: 2,
|
||||
stage: "typescript_codex_driver_normalization",
|
||||
ruleId: "codex_driver.normalize.item/completed",
|
||||
disposition: "mapped",
|
||||
emittedEventIds: ["event_runner_000001"],
|
||||
droppedFields: [],
|
||||
reason: "Emitted canonical PRP event",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
flushSync(() =>
|
||||
root.render(
|
||||
<RunnerInspector
|
||||
runId="run-1"
|
||||
run={{ status: "succeeded", resultJson: null }}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("webSearch");
|
||||
expect(container.textContent).toContain("rust_jsonrpc_parse");
|
||||
expect(container.textContent).toContain("rust_durable_normalization");
|
||||
expect(container.textContent).toContain("typescript_runnerd_rehydration");
|
||||
expect(container.textContent).toContain("typescript_codex_driver_normalization");
|
||||
expect(container.textContent).toContain("params.item.results");
|
||||
expect(container.textContent).toContain("payload.query");
|
||||
expect(container.textContent).toContain("research.completed");
|
||||
expect(container.textContent).toContain("Production surface preview");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,979 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type {
|
||||
HeartbeatRunEvent,
|
||||
ProviderTraceFieldMapping,
|
||||
ProviderTraceFrame,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
ArrowRight,
|
||||
Braces,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleOff,
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileJson2,
|
||||
Layers3,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { heartbeatsApi, type ProviderTraceInspection } from "@/api/heartbeats";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { parsePaperclipRunnerStdoutLine } from "@/adapters/paperclip-runner";
|
||||
import { TaskChatProtocolCard } from "@/components/task-chat/TaskChatProtocolCard";
|
||||
import type { TaskChatProtocolItem } from "@/components/task-chat/task-chat-model";
|
||||
import { transcriptToTaskChatItems } from "@/components/task-chat/transcript-adapter";
|
||||
import { TASK_PROTOCOL_EVENT_SURFACE_REGISTRY } from "@/components/task-chat/task-protocol-surfaces";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
|
||||
type TraceEntry = Record<string, unknown>;
|
||||
type InspectorView = "overview" | "pipeline" | "trace";
|
||||
|
||||
type TraceOperation = {
|
||||
key: string;
|
||||
frames: TraceEntry[];
|
||||
interpretations: TraceEntry[];
|
||||
events: HeartbeatRunEvent[];
|
||||
title: string;
|
||||
subtitle: string;
|
||||
itemType: string;
|
||||
nativeMethods: string[];
|
||||
directions: string[];
|
||||
dispositions: string[];
|
||||
prpTypes: string[];
|
||||
visible: boolean;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const VIEW_OPTIONS: Array<{
|
||||
value: InspectorView;
|
||||
label: string;
|
||||
icon: typeof Layers3;
|
||||
}> = [
|
||||
{ value: "overview", label: "Overview", icon: Layers3 },
|
||||
{ value: "pipeline", label: "Pipeline", icon: ArrowRight },
|
||||
{ value: "trace", label: "Exact trace", icon: FileJson2 },
|
||||
];
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function scalar(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return "";
|
||||
}
|
||||
|
||||
function unique(values: string[]) {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (value < 1_024) return `${value} B`;
|
||||
if (value < 1_024 * 1_024) return `${(value / 1_024).toFixed(1)} KiB`;
|
||||
return `${(value / (1_024 * 1_024)).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const href = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = href;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(href);
|
||||
}
|
||||
|
||||
function decodeExactFrame(rawBase64: string): unknown {
|
||||
const decoded = atob(rawBase64);
|
||||
try {
|
||||
return JSON.parse(decoded);
|
||||
} catch {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
||||
function statusVariant(status: string | undefined) {
|
||||
if (status === "complete") return "secondary" as const;
|
||||
if (status === "incomplete" || status === "truncated")
|
||||
return "destructive" as const;
|
||||
return "outline" as const;
|
||||
}
|
||||
|
||||
function traceBadgeLabel(status: string, expiresAt: string | Date) {
|
||||
if (status === "capturing") return "Raw trace enabled";
|
||||
if (status === "incomplete") return "Incomplete";
|
||||
if (status === "truncated") return "Truncated";
|
||||
if (status === "deleted") return "Deleted";
|
||||
if (status === "expired") return "Expired";
|
||||
const remainingMs = new Date(expiresAt).getTime() - Date.now();
|
||||
if (!Number.isFinite(remainingMs) || remainingMs <= 0) return "Expired";
|
||||
const hours = Math.max(1, Math.ceil(remainingMs / (60 * 60 * 1_000)));
|
||||
return `Expires in ${hours}h`;
|
||||
}
|
||||
|
||||
function frameParsed(entry: TraceEntry) {
|
||||
return record(entry.parsed);
|
||||
}
|
||||
|
||||
function frameMethod(entry: TraceEntry) {
|
||||
const parsed = frameParsed(entry);
|
||||
return text(parsed.method) || text(record(parsed.params).method);
|
||||
}
|
||||
|
||||
function frameItem(entry: TraceEntry) {
|
||||
const parsed = frameParsed(entry);
|
||||
const params = record(parsed.params);
|
||||
return record(params.item ?? parsed.item);
|
||||
}
|
||||
|
||||
function frameItemType(entry: TraceEntry) {
|
||||
const parsed = frameParsed(entry);
|
||||
const params = record(parsed.params);
|
||||
return text(frameItem(entry).type) || text(params.itemType) || text(parsed.type);
|
||||
}
|
||||
|
||||
function eventPrp(event: HeartbeatRunEvent) {
|
||||
const payload = record(event.payload);
|
||||
const prpEvent = record(payload.prpEvent);
|
||||
return Object.keys(prpEvent).length > 0 ? prpEvent : payload;
|
||||
}
|
||||
|
||||
function eventSourceId(event: HeartbeatRunEvent) {
|
||||
return text(eventPrp(event).sourceEventId);
|
||||
}
|
||||
|
||||
function eventTimestamp(event: HeartbeatRunEvent) {
|
||||
const value = new Date(event.createdAt).getTime();
|
||||
return Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function frameTimestamp(frame: TraceEntry) {
|
||||
const raw = scalar(frame.timestamp);
|
||||
const numeric = Number(raw);
|
||||
if (Number.isFinite(numeric)) {
|
||||
return numeric < 10_000_000_000 ? numeric * 1_000 : numeric;
|
||||
}
|
||||
const parsed = Date.parse(raw);
|
||||
return Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function visibilityDecision(event: HeartbeatRunEvent) {
|
||||
const registration = TASK_PROTOCOL_EVENT_SURFACE_REGISTRY[event.eventType];
|
||||
if (!registration) {
|
||||
return {
|
||||
visible: false,
|
||||
surface: "run_debug",
|
||||
container: "Runner Inspector",
|
||||
state: "hidden",
|
||||
action: "none",
|
||||
reasonCode: "presentation_surface_unregistered",
|
||||
reason: "No production surface registration exists for this event type.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
visible: registration.disposition !== "debug-only",
|
||||
surface: registration.surface,
|
||||
container:
|
||||
registration.disposition === "inline"
|
||||
? "primary run turn"
|
||||
: "collapsed activity",
|
||||
state: registration.disposition,
|
||||
action:
|
||||
registration.disposition === "inline"
|
||||
? "render"
|
||||
: registration.disposition === "folded"
|
||||
? "fold"
|
||||
: "operator only",
|
||||
reasonCode: `presentation_${registration.disposition}`,
|
||||
reason: registration.rationale,
|
||||
};
|
||||
}
|
||||
|
||||
function interpretationEventIds(entry: TraceEntry) {
|
||||
return Array.isArray(entry.emittedEventIds)
|
||||
? entry.emittedEventIds.map(String)
|
||||
: [];
|
||||
}
|
||||
|
||||
function frameCorrelationTokens(frame: TraceEntry, interpretations: TraceEntry[]) {
|
||||
const parsed = frameParsed(frame);
|
||||
const params = record(parsed.params);
|
||||
const item = frameItem(frame);
|
||||
const tokens = interpretations.flatMap(interpretationEventIds).map((id) => `event:${id}`);
|
||||
const rpcId = scalar(parsed.id);
|
||||
if (rpcId) tokens.push(`rpc:${rpcId}`);
|
||||
for (const id of [item.id, params.itemId, parsed.itemId]) {
|
||||
const value = scalar(id);
|
||||
if (value) tokens.push(`item:${value}`);
|
||||
}
|
||||
return unique(tokens);
|
||||
}
|
||||
|
||||
function buildOperations(
|
||||
frames: TraceEntry[],
|
||||
interpretations: TraceEntry[],
|
||||
events: HeartbeatRunEvent[],
|
||||
): TraceOperation[] {
|
||||
const sortedFrames = [...frames].sort(
|
||||
(left, right) => Number(left.frameId) - Number(right.frameId),
|
||||
);
|
||||
const frameById = new Map(sortedFrames.map((frame) => [Number(frame.frameId), frame]));
|
||||
const parent = new Map([...frameById.keys()].map((id) => [id, id]));
|
||||
const find = (id: number): number => {
|
||||
const current = parent.get(id) ?? id;
|
||||
if (current === id) return id;
|
||||
const root = find(current);
|
||||
parent.set(id, root);
|
||||
return root;
|
||||
};
|
||||
const union = (left: number, right: number) => {
|
||||
const leftRoot = find(left);
|
||||
const rightRoot = find(right);
|
||||
if (leftRoot !== rightRoot) parent.set(rightRoot, leftRoot);
|
||||
};
|
||||
const tokenOwner = new Map<string, number>();
|
||||
for (const frame of sortedFrames) {
|
||||
const frameId = Number(frame.frameId);
|
||||
const stages = interpretations.filter((entry) => Number(entry.frameId) === frameId);
|
||||
for (const token of frameCorrelationTokens(frame, stages)) {
|
||||
const owner = tokenOwner.get(token);
|
||||
if (owner === undefined) tokenOwner.set(token, frameId);
|
||||
else union(owner, frameId);
|
||||
}
|
||||
}
|
||||
|
||||
const groupedFrames = new Map<number, TraceEntry[]>();
|
||||
for (const frame of sortedFrames) {
|
||||
const root = find(Number(frame.frameId));
|
||||
groupedFrames.set(root, [...(groupedFrames.get(root) ?? []), frame]);
|
||||
}
|
||||
const claimedEvents = new Set<number>();
|
||||
const operations: TraceOperation[] = [];
|
||||
for (const groupFrames of groupedFrames.values()) {
|
||||
const frameIds = new Set(groupFrames.map((frame) => Number(frame.frameId)));
|
||||
const stages = interpretations.filter((entry) => frameIds.has(Number(entry.frameId)));
|
||||
const emittedIds = new Set(stages.flatMap(interpretationEventIds));
|
||||
const groupEvents = events.filter((event) => emittedIds.has(eventSourceId(event)));
|
||||
groupEvents.forEach((event) => claimedEvents.add(event.id));
|
||||
const methods = unique(groupFrames.map(frameMethod));
|
||||
const itemTypes = unique(groupFrames.map(frameItemType));
|
||||
const title = itemTypes.at(-1) || methods.at(-1) || text(groupFrames[0]?.direction) || "Provider frame";
|
||||
const firstFrame = groupFrames[0];
|
||||
const lastFrame = groupFrames.at(-1);
|
||||
const range = firstFrame === lastFrame
|
||||
? `frame ${firstFrame?.frameId}`
|
||||
: `frames ${firstFrame?.frameId}–${lastFrame?.frameId}`;
|
||||
operations.push({
|
||||
key: `frames:${firstFrame?.frameId}`,
|
||||
frames: groupFrames,
|
||||
interpretations: stages,
|
||||
events: groupEvents,
|
||||
title,
|
||||
subtitle: `${range} · ${groupEvents.length} PRP event${groupEvents.length === 1 ? "" : "s"}`,
|
||||
itemType: itemTypes.at(-1) ?? "",
|
||||
nativeMethods: methods,
|
||||
directions: unique(groupFrames.map((frame) => text(frame.direction))),
|
||||
dispositions: unique(stages.map((entry) => text(entry.disposition))),
|
||||
prpTypes: unique(groupEvents.map((event) => event.eventType)),
|
||||
visible: groupEvents.some((event) => visibilityDecision(event).visible),
|
||||
timestamp: Math.min(...groupFrames.map(frameTimestamp)),
|
||||
});
|
||||
}
|
||||
for (const event of events) {
|
||||
if (claimedEvents.has(event.id)) continue;
|
||||
const decision = visibilityDecision(event);
|
||||
operations.push({
|
||||
key: `event:${event.id}`,
|
||||
frames: [],
|
||||
interpretations: [],
|
||||
events: [event],
|
||||
title: event.eventType,
|
||||
subtitle: `PRP ${eventSourceId(event) || `event ${event.seq}`} · no raw correlation`,
|
||||
itemType: "",
|
||||
nativeMethods: [],
|
||||
directions: [],
|
||||
dispositions: [],
|
||||
prpTypes: [event.eventType],
|
||||
visible: decision.visible,
|
||||
timestamp: eventTimestamp(event),
|
||||
});
|
||||
}
|
||||
return operations.sort((left, right) => left.timestamp - right.timestamp);
|
||||
}
|
||||
|
||||
async function loadAllRunEvents(runId: string) {
|
||||
const events: HeartbeatRunEvent[] = [];
|
||||
let afterSeq = 0;
|
||||
for (;;) {
|
||||
const page = await heartbeatsApi.events(runId, afterSeq, 1_000);
|
||||
events.push(...page);
|
||||
if (page.length < 1_000) return events;
|
||||
const nextSeq = page.at(-1)?.seq ?? afterSeq;
|
||||
if (nextSeq <= afterSeq) return events;
|
||||
afterSeq = nextSeq;
|
||||
}
|
||||
}
|
||||
|
||||
function jsonMatches(value: unknown, query: string): boolean {
|
||||
if (!query) return true;
|
||||
try {
|
||||
return JSON.stringify(value).toLowerCase().includes(query.toLowerCase());
|
||||
} catch {
|
||||
return String(value).toLowerCase().includes(query.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
function JsonPrimitive({ value }: { value: unknown }) {
|
||||
if (typeof value === "string") return <span className="text-amber-700 dark:text-amber-300">"{value}"</span>;
|
||||
if (typeof value === "number") return <span className="text-sky-700 dark:text-sky-300">{value}</span>;
|
||||
if (typeof value === "boolean") return <span className="text-violet-700 dark:text-violet-300">{String(value)}</span>;
|
||||
if (value === null) return <span className="text-muted-foreground">null</span>;
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
|
||||
function JsonNode({
|
||||
label,
|
||||
value,
|
||||
path,
|
||||
depth,
|
||||
query,
|
||||
}: {
|
||||
label?: string;
|
||||
value: unknown;
|
||||
path: string;
|
||||
depth: number;
|
||||
query: string;
|
||||
}) {
|
||||
const expandable = value !== null && typeof value === "object";
|
||||
const entries = Array.isArray(value)
|
||||
? value.map((child, index) => [String(index), child] as const)
|
||||
: Object.entries(record(value));
|
||||
const large = entries.length > 8;
|
||||
const [expanded, setExpanded] = useState(depth < 1 && !large);
|
||||
const forcedOpen = Boolean(query) && jsonMatches(value, query);
|
||||
const isOpen = expandable && (expanded || forcedOpen);
|
||||
const visibleEntries = query
|
||||
? entries.filter(([key, child]) =>
|
||||
key.toLowerCase().includes(query.toLowerCase()) || jsonMatches(child, query),
|
||||
)
|
||||
: entries;
|
||||
const copyValue = () => {
|
||||
const serialized = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||
void copyTextToClipboard(serialized ?? String(value));
|
||||
};
|
||||
return (
|
||||
<div className={cn(depth > 0 && "border-l border-border/60 pl-3")}>
|
||||
<div className="group flex min-h-6 items-start gap-1 font-mono text-xs leading-6">
|
||||
{expandable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setExpanded(!isOpen)}
|
||||
aria-label={isOpen ? "Collapse JSON value" : "Expand JSON value"}
|
||||
>
|
||||
{isOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
{label !== undefined ? <span className="text-cyan-700 dark:text-cyan-300">{label}:</span> : null}
|
||||
{expandable ? (
|
||||
<span className="text-muted-foreground">
|
||||
{Array.isArray(value) ? `[${entries.length}]` : `{${entries.length}}`}
|
||||
</span>
|
||||
) : (
|
||||
<JsonPrimitive value={value} />
|
||||
)}
|
||||
<span className="ml-auto hidden items-center gap-0.5 group-hover:flex">
|
||||
<button
|
||||
type="button"
|
||||
title="Copy JSON path"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() => void copyTextToClipboard(path)}
|
||||
>
|
||||
<Braces className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Copy value"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={copyValue}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<div>
|
||||
{visibleEntries.map(([key, child]) => (
|
||||
<JsonNode
|
||||
key={`${path}.${key}`}
|
||||
label={key}
|
||||
value={child}
|
||||
path={Array.isArray(value) ? `${path}[${key}]` : `${path}.${key}`}
|
||||
depth={depth + 1}
|
||||
query={query}
|
||||
/>
|
||||
))}
|
||||
{query && visibleEntries.length === 0 ? (
|
||||
<p className="pl-5 text-xs text-muted-foreground">No fields match.</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonExplorer({ value, label = "Search this JSON" }: { value: unknown; label?: string }) {
|
||||
const [query, setQuery] = useState("");
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border border-border bg-muted/20">
|
||||
<div className="relative border-b border-border bg-background/60">
|
||||
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={label}
|
||||
className="h-8 border-0 bg-transparent pl-8 text-xs shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-auto p-3">
|
||||
<JsonNode value={value} path="$" depth={0} query={query.trim()} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fieldMappings(entry: TraceEntry): ProviderTraceFieldMapping[] {
|
||||
const explicit = Array.isArray(entry.fieldMappings)
|
||||
? entry.fieldMappings
|
||||
.map(record)
|
||||
.filter((mapping) => text(mapping.action))
|
||||
.map((mapping) => ({
|
||||
inputPath: text(mapping.inputPath) || undefined,
|
||||
outputPath: text(mapping.outputPath) || undefined,
|
||||
action: text(mapping.action) as ProviderTraceFieldMapping["action"],
|
||||
reason: text(mapping.reason) || undefined,
|
||||
}))
|
||||
: [];
|
||||
const knownDrops = new Set(explicit.filter((mapping) => mapping.action === "dropped").map((mapping) => mapping.inputPath));
|
||||
const legacy = Array.isArray(entry.droppedFields)
|
||||
? entry.droppedFields
|
||||
.map(String)
|
||||
.filter((path) => !knownDrops.has(path))
|
||||
.map((path) => ({
|
||||
inputPath: path,
|
||||
action: "dropped" as const,
|
||||
reason: text(entry.reason) || "Field was not carried into the next stage",
|
||||
}))
|
||||
: [];
|
||||
return [...explicit, ...legacy];
|
||||
}
|
||||
|
||||
function mappingTone(action: ProviderTraceFieldMapping["action"]) {
|
||||
if (action === "dropped" || action === "redacted") return "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300";
|
||||
if (action === "derived") return "border-violet-500/30 bg-violet-500/10 text-violet-700 dark:text-violet-300";
|
||||
return "border-cyan-500/30 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300";
|
||||
}
|
||||
|
||||
function InterpretationStage({ entry, last }: { entry: TraceEntry; last: boolean }) {
|
||||
const mappings = fieldMappings(entry);
|
||||
return (
|
||||
<div className="relative grid grid-cols-(--gtc-runner-inspector-stage) gap-3">
|
||||
<div className="relative flex justify-center">
|
||||
{!last ? <span className="absolute bottom-(--sz-neg-1_25rem) top-4 w-px bg-border" /> : null}
|
||||
<span className={cn(
|
||||
"relative mt-1 h-3 w-3 rounded-full border-2 bg-background",
|
||||
entry.disposition === "rejected" ? "border-red-500" : entry.disposition === "ignored" ? "border-muted-foreground" : "border-cyan-500",
|
||||
)} />
|
||||
</div>
|
||||
<div className="mb-4 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border/70 px-3 py-2">
|
||||
<code className="text-xs font-semibold">{text(entry.stage)}</code>
|
||||
<Badge variant="outline" className="capitalize">{text(entry.disposition)}</Badge>
|
||||
<code className="ml-auto text-(length:--text-nano) text-muted-foreground">{text(entry.ruleId)}</code>
|
||||
</div>
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">{text(entry.reason)}</p>
|
||||
{mappings.length > 0 ? (
|
||||
<div className="overflow-x-auto border-t border-border/70">
|
||||
<table className="w-full min-w-(--sz-36rem) text-left text-xs">
|
||||
<thead className="bg-muted/30 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-1.5 font-medium">Action</th>
|
||||
<th className="px-3 py-1.5 font-medium">Provider path</th>
|
||||
<th className="px-3 py-1.5 font-medium">Output path</th>
|
||||
<th className="px-3 py-1.5 font-medium">Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mappings.map((mapping, index) => (
|
||||
<tr key={`${mapping.inputPath}:${mapping.outputPath}:${index}`} className="border-t border-border/50 align-top">
|
||||
<td className="px-3 py-2">
|
||||
<span className={cn("rounded border px-1.5 py-0.5 text-(length:--text-nano)", mappingTone(mapping.action))}>{mapping.action}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono">{mapping.inputPath ?? "—"}</td>
|
||||
<td className="px-3 py-2 font-mono">{mapping.outputPath ?? "—"}</td>
|
||||
<td className="max-w-xs px-3 py-2 text-muted-foreground">{mapping.reason ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="border-t border-border/70 px-3 py-2 text-(length:--text-nano) text-muted-foreground">No field-level mapping was recorded at this stage.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function typedPrpFields(event: HeartbeatRunEvent) {
|
||||
const prp = eventPrp(event);
|
||||
const payload = record(prp.payload);
|
||||
const item = record(payload.item);
|
||||
return [
|
||||
["Event type", event.eventType],
|
||||
["Source event", text(prp.sourceEventId)],
|
||||
["Sequence", scalar(prp.sourceSequence) || String(event.seq)],
|
||||
["Item type", text(item.type) || text(payload.kind)],
|
||||
["Status", text(payload.status) || text(item.status)],
|
||||
["Query", text(payload.query) || text(item.query)],
|
||||
].filter(([, value]) => Boolean(value));
|
||||
}
|
||||
|
||||
function ProductionSurfacePreview({ event, runId }: { event: HeartbeatRunEvent; runId: string }) {
|
||||
const prp = eventPrp(event);
|
||||
const ts = new Date(event.createdAt).toISOString();
|
||||
const entries = parsePaperclipRunnerStdoutLine(
|
||||
JSON.stringify({ type: "paperclip.prp.event", event: prp }),
|
||||
ts,
|
||||
);
|
||||
const item = transcriptToTaskChatItems(entries, {
|
||||
runId,
|
||||
agentName: "Runner",
|
||||
running: false,
|
||||
}).find((candidate): candidate is TaskChatProtocolItem => candidate.kind === "protocol");
|
||||
if (item) return <TaskChatProtocolCard item={item} />;
|
||||
const decision = visibilityDecision(event);
|
||||
const VisibleIcon = decision.visible ? Eye : EyeOff;
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-card/60 px-3 py-2.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<VisibleIcon className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{event.eventType}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Production surface: {decision.surface} · {decision.action} in {decision.container}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, detail }: { label: string; value: string | number; detail?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card px-4 py-3">
|
||||
<p className="text-(length:--text-nano) font-medium uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold tracking-tight">{value}</p>
|
||||
{detail ? <p className="mt-1 text-xs text-muted-foreground">{detail}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RunnerInspector({
|
||||
runId,
|
||||
run,
|
||||
open,
|
||||
onOpenChange,
|
||||
onRerunWithTrace,
|
||||
}: {
|
||||
runId: string;
|
||||
run?: { resultJson: Record<string, unknown> | null; status: string } | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onRerunWithTrace?: () => void;
|
||||
}) {
|
||||
const [inspection, setInspection] = useState<ProviderTraceInspection | null>(null);
|
||||
const [events, setEvents] = useState<HeartbeatRunEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [view, setView] = useState<InspectorView>("pipeline");
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [selectedFrameId, setSelectedFrameId] = useState<number | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [direction, setDirection] = useState("all");
|
||||
const [nativeMethod, setNativeMethod] = useState("all");
|
||||
const [disposition, setDisposition] = useState("all");
|
||||
const [prpType, setPrpType] = useState("all");
|
||||
const [visibility, setVisibility] = useState("all");
|
||||
const [revealed, setRevealed] = useState<Record<number, ProviderTraceFrame>>({});
|
||||
const [canInspectRaw, setCanInspectRaw] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const requestedView = params.get("traceView");
|
||||
if (requestedView === "overview" || requestedView === "pipeline" || requestedView === "trace") setView(requestedView);
|
||||
const requestedOperation = params.get("traceOperation");
|
||||
if (requestedOperation) setSelectedKey(requestedOperation);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.all([accessApi.getCurrentBoardAccess(), loadAllRunEvents(runId)])
|
||||
.then(async ([boardAccess, nextEvents]) => {
|
||||
if (!active) return;
|
||||
const canRaw = boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin;
|
||||
setCanInspectRaw(canRaw);
|
||||
const trace = canRaw
|
||||
? await heartbeatsApi.providerTrace(runId)
|
||||
: ({ trace: null, entries: [] } satisfies ProviderTraceInspection);
|
||||
if (!active) return;
|
||||
setInspection(trace);
|
||||
setEvents(nextEvents);
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (!active) return;
|
||||
setError(cause instanceof Error ? cause.message : "Runner inspection failed");
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, runId]);
|
||||
|
||||
const entries = inspection?.entries ?? [];
|
||||
const frames = useMemo(() => entries.filter((entry) => entry.kind === "frame"), [entries]);
|
||||
const interpretations = useMemo(() => entries.filter((entry) => entry.kind === "interpretation"), [entries]);
|
||||
const capturedProviders = unique(frames.map((frame) => text(frame.provider)));
|
||||
const operations = useMemo(
|
||||
() => buildOperations(frames, interpretations, events),
|
||||
[events, frames, interpretations],
|
||||
);
|
||||
const filteredOperations = useMemo(
|
||||
() => operations.filter((operation) => {
|
||||
if (query.trim() && !jsonMatches(operation, query.trim())) return false;
|
||||
if (direction !== "all" && !operation.directions.includes(direction)) return false;
|
||||
if (nativeMethod !== "all" && !operation.nativeMethods.includes(nativeMethod)) return false;
|
||||
if (disposition !== "all" && !operation.dispositions.includes(disposition)) return false;
|
||||
if (prpType !== "all" && !operation.prpTypes.includes(prpType)) return false;
|
||||
if (visibility === "visible" && !operation.visible) return false;
|
||||
if (visibility === "hidden" && operation.visible) return false;
|
||||
return true;
|
||||
}),
|
||||
[direction, disposition, nativeMethod, operations, prpType, query, visibility],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filteredOperations.length) return;
|
||||
if (!selectedKey || !filteredOperations.some((operation) => operation.key === selectedKey)) {
|
||||
const preferred =
|
||||
filteredOperations.find(
|
||||
(operation) => operation.itemType && operation.events.length > 0,
|
||||
) ??
|
||||
filteredOperations.find(
|
||||
(operation) => operation.frames.length > 0 && operation.events.length > 0,
|
||||
) ??
|
||||
filteredOperations[0]!;
|
||||
setSelectedKey(preferred.key);
|
||||
}
|
||||
}, [filteredOperations, selectedKey]);
|
||||
|
||||
const selectedOperation = filteredOperations.find((operation) => operation.key === selectedKey) ?? filteredOperations[0] ?? null;
|
||||
useEffect(() => {
|
||||
if (!selectedOperation) return;
|
||||
if (!selectedOperation.frames.some((frame) => Number(frame.frameId) === selectedFrameId)) {
|
||||
const preferred = [...selectedOperation.frames].reverse().find((frame) => frameMethod(frame).includes("completed")) ?? selectedOperation.frames.at(-1);
|
||||
setSelectedFrameId(preferred ? Number(preferred.frameId) : null);
|
||||
}
|
||||
}, [selectedFrameId, selectedOperation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selectedOperation) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("inspectRun", runId);
|
||||
params.set("traceView", view);
|
||||
params.set("traceOperation", selectedOperation.key);
|
||||
const next = `${window.location.pathname}?${params.toString()}${window.location.hash}`;
|
||||
window.history.replaceState(window.history.state, "", next);
|
||||
}, [open, runId, selectedOperation, view]);
|
||||
|
||||
const selectedFrame = selectedOperation?.frames.find((frame) => Number(frame.frameId) === selectedFrameId) ?? selectedOperation?.frames.at(-1) ?? null;
|
||||
const selectedInterpretations = selectedFrame
|
||||
? selectedOperation?.interpretations.filter((entry) => Number(entry.frameId) === Number(selectedFrame.frameId)) ?? []
|
||||
: selectedOperation?.interpretations ?? [];
|
||||
const selectedEvents = selectedOperation?.events ?? [];
|
||||
const presentationDecision = record(record(run?.resultJson).presentationDecision);
|
||||
const visibleEventCount = events.filter((event) => visibilityDecision(event).visible).length;
|
||||
const ignoredCount = interpretations.filter((entry) => entry.disposition === "ignored").length;
|
||||
const dispositionCounts = interpretations.reduce<Record<string, number>>((counts, entry) => {
|
||||
const key = text(entry.disposition) || "unknown";
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
|
||||
const selectOperation = (operation: TraceOperation) => {
|
||||
setSelectedKey(operation.key);
|
||||
setView("pipeline");
|
||||
};
|
||||
|
||||
const reveal = async (frameId: number) => {
|
||||
if (!window.confirm("This exact provider frame may contain prompts, tool arguments, secrets, or reasoning. Reveal it now?")) return;
|
||||
const frame = await heartbeatsApi.revealProviderTraceFrame(runId, frameId);
|
||||
setRevealed((current) => ({ ...current, [frameId]: frame }));
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete("inspectRun");
|
||||
params.delete("traceView");
|
||||
params.delete("traceOperation");
|
||||
const suffix = params.toString();
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
`${window.location.pathname}${suffix ? `?${suffix}` : ""}${window.location.hash}`,
|
||||
);
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||
<SheetContent
|
||||
className="gap-0 p-0"
|
||||
style={{ width: "min(98vw, 90rem)", maxWidth: "none" }}
|
||||
aria-describedby="runner-inspector-description"
|
||||
>
|
||||
<SheetHeader className="border-b border-border pr-12">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SheetTitle>Runner Inspector</SheetTitle>
|
||||
{inspection?.trace ? (
|
||||
<>
|
||||
<Badge variant={statusVariant(inspection.trace.status)}>
|
||||
{traceBadgeLabel(inspection.trace.status, inspection.trace.expiresAt)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{capturedProviders.join(", ") || inspection.trace.provider}
|
||||
</Badge>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<SheetDescription id="runner-inspector-description">
|
||||
Correlate exact provider traffic with every interpretation stage, canonical PRP event, and production surface.
|
||||
</SheetDescription>
|
||||
<div className="flex gap-1 pt-1" role="tablist" aria-label="Runner inspector views">
|
||||
{VIEW_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === option.value}
|
||||
onClick={() => setView(option.value)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition-colors",
|
||||
view === option.value ? "border-foreground text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" /> {option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{error ? <div className="m-4 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">{error}</div> : null}
|
||||
{!loading && !error && canInspectRaw === false ? (
|
||||
<div className="m-4 rounded-md border border-border bg-muted/20 p-4">
|
||||
<p className="font-medium">Raw provider traces require an instance administrator.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Canonical PRP events and presentation decisions remain inspectable below.</p>
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !error && canInspectRaw !== false && !inspection?.trace ? (
|
||||
<div className="m-4 flex items-start justify-between gap-4 rounded-md border border-border bg-muted/20 p-4">
|
||||
<div>
|
||||
<p className="font-medium">Raw provider capture was off for this run.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Canonical PRP events and persisted presentation decisions remain available below.</p>
|
||||
</div>
|
||||
{onRerunWithTrace && canInspectRaw ? (
|
||||
<Button size="sm" onClick={onRerunWithTrace}><RefreshCw className="mr-1.5 h-4 w-4" />Re-run with provider trace</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{view === "overview" ? (
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-6 p-5">
|
||||
{loading ? <p className="text-sm text-muted-foreground">Loading run pipeline…</p> : null}
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<StatCard label="Provider" value={capturedProviders.join(", ") || inspection?.trace?.provider || "PRP only"} detail={inspection?.trace ? `captured through ${inspection.trace.provider}` : "capture was off"} />
|
||||
<StatCard label="Raw frames" value={frames.length} detail={inspection?.trace ? formatBytes(inspection.trace.byteCount) : "no exact bytes"} />
|
||||
<StatCard label="Operations" value={operations.length} detail="correlated groups" />
|
||||
<StatCard label="PRP events" value={events.length} detail={`${visibleEventCount} visible`} />
|
||||
<StatCard label="Mappings" value={interpretations.length} detail={`${ignoredCount} ignored`} />
|
||||
<StatCard label="Run status" value={run?.status ?? "unknown"} detail={traceBadgeLabel(inspection?.trace?.status ?? "expired", inspection?.trace?.expiresAt ?? new Date(0))} />
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<section className="rounded-lg border border-border bg-card p-4">
|
||||
<h3 className="text-sm font-semibold">Interpretation outcomes</h3>
|
||||
<div className="mt-3 space-y-2">
|
||||
{Object.entries(dispositionCounts).length ? Object.entries(dispositionCounts).map(([label, count]) => (
|
||||
<div key={label} className="flex items-center gap-3 text-sm">
|
||||
<Badge variant="outline" className="w-24 justify-center capitalize">{label}</Badge>
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-cyan-500" style={{ width: `${Math.max(4, (count / Math.max(1, interpretations.length)) * 100)}%` }} /></div>
|
||||
<span className="w-8 text-right font-mono text-xs">{count}</span>
|
||||
</div>
|
||||
)) : <p className="text-sm text-muted-foreground">No interpretation records were persisted.</p>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-lg border border-border bg-card p-4">
|
||||
<h3 className="text-sm font-semibold">Recent correlated operations</h3>
|
||||
<div className="mt-2 divide-y divide-border/70">
|
||||
{operations.slice(-6).reverse().map((operation) => (
|
||||
<button key={operation.key} type="button" onClick={() => selectOperation(operation)} className="flex w-full items-center gap-3 py-2 text-left hover:text-primary">
|
||||
<span className={cn("h-2 w-2 rounded-full", operation.events.length ? "bg-cyan-500" : "bg-muted-foreground")} />
|
||||
<span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{operation.title}</span><span className="block truncate text-xs text-muted-foreground">{operation.subtitle}</span></span>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section className="rounded-lg border border-amber-500/25 bg-amber-500/5 p-4">
|
||||
<div className="flex gap-3"><ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-amber-700 dark:text-amber-300" /><div><h3 className="text-sm font-semibold">Sensitive debug data</h3><p className="mt-1 text-sm text-muted-foreground">Parsed frames are redacted on the server. Exact reveals and downloads are administrator-only, warned, audited, and automatically expire.</p></div></div>
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : null}
|
||||
|
||||
{view === "pipeline" ? (
|
||||
<>
|
||||
<div className="grid gap-2 border-b border-border p-3 sm:grid-cols-2 xl:grid-cols-(--gtc-runner-inspector-filters)">
|
||||
<div className="relative"><Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /><Input className="pl-8" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search operations, fields, and events" /></div>
|
||||
<Select value={direction} onValueChange={setDirection}><SelectTrigger><SelectValue placeholder="Direction" /></SelectTrigger><SelectContent><SelectItem value="all">All directions</SelectItem><SelectItem value="client_to_provider">Client → provider</SelectItem><SelectItem value="provider_to_client">Provider → client</SelectItem><SelectItem value="provider_stderr">Provider stderr</SelectItem></SelectContent></Select>
|
||||
<Select value={nativeMethod} onValueChange={setNativeMethod}><SelectTrigger><SelectValue placeholder="Native method" /></SelectTrigger><SelectContent><SelectItem value="all">All native methods</SelectItem>{unique(frames.map(frameMethod)).sort().map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectContent></Select>
|
||||
<Select value={disposition} onValueChange={setDisposition}><SelectTrigger><SelectValue placeholder="Mapping" /></SelectTrigger><SelectContent><SelectItem value="all">All mappings</SelectItem>{["mapped", "generic", "ignored", "rejected", "operator_only"].map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectContent></Select>
|
||||
<Select value={prpType} onValueChange={setPrpType}><SelectTrigger><SelectValue placeholder="PRP type" /></SelectTrigger><SelectContent><SelectItem value="all">All PRP types</SelectItem>{unique(events.map((event) => event.eventType)).sort().map((value) => <SelectItem key={value} value={value}>{value}</SelectItem>)}</SelectContent></Select>
|
||||
<Select value={visibility} onValueChange={setVisibility}><SelectTrigger><SelectValue placeholder="Visibility" /></SelectTrigger><SelectContent><SelectItem value="all">Visible + hidden</SelectItem><SelectItem value="visible">Visible</SelectItem><SelectItem value="hidden">Hidden</SelectItem></SelectContent></Select>
|
||||
</div>
|
||||
<div className="grid min-h-0 flex-1 lg:grid-cols-(--gtc-runner-inspector-pipeline)">
|
||||
<ScrollArea className="border-r border-border">
|
||||
<div className="p-2">
|
||||
{loading ? <p className="p-3 text-sm text-muted-foreground">Loading run pipeline…</p> : null}
|
||||
{!loading && filteredOperations.length === 0 ? <p className="p-3 text-sm text-muted-foreground">No operations match these filters.</p> : null}
|
||||
{filteredOperations.map((operation) => (
|
||||
<button key={operation.key} type="button" onClick={() => setSelectedKey(operation.key)} className="mb-1 w-full rounded-lg border border-transparent px-3 py-2.5 text-left hover:bg-muted/50 data-[selected=true]:border-border data-[selected=true]:bg-muted" data-selected={selectedOperation?.key === operation.key}>
|
||||
<span className="flex items-center gap-2"><span className={cn("h-2 w-2 shrink-0 rounded-full", operation.events.length ? operation.visible ? "bg-cyan-500" : "bg-violet-500" : "bg-muted-foreground")} /><span className="min-w-0 flex-1 truncate text-sm font-medium">{operation.title}</span>{operation.frames.length > 1 ? <Badge variant="outline" className="px-1.5 text-(length:--text-nano)">{operation.frames.length} frames</Badge> : null}</span>
|
||||
<span className="mt-1 block truncate pl-4 text-(length:--text-nano) text-muted-foreground">{operation.subtitle}</span>
|
||||
<span className="mt-1.5 flex flex-wrap gap-1 pl-4">{operation.prpTypes.slice(0, 3).map((type) => <span key={type} className="rounded bg-background px-1.5 py-0.5 font-mono text-(length:--text-nano) text-muted-foreground">{type}</span>)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<ScrollArea>
|
||||
{selectedOperation ? (
|
||||
<div className="space-y-6 p-5">
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-muted/20 px-4 py-3">
|
||||
<strong className="text-sm">{selectedOperation.title}</strong><ArrowRight className="h-3.5 w-3.5 text-muted-foreground" /><span className="text-xs text-muted-foreground">{selectedOperation.frames.length} raw frame{selectedOperation.frames.length === 1 ? "" : "s"}</span><ArrowRight className="h-3.5 w-3.5 text-muted-foreground" /><span className="text-xs text-muted-foreground">{selectedOperation.interpretations.length} mapping stage{selectedOperation.interpretations.length === 1 ? "" : "s"}</span><ArrowRight className="h-3.5 w-3.5 text-muted-foreground" /><span className="text-xs text-muted-foreground">{selectedEvents.length} PRP event{selectedEvents.length === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
<section>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2"><span className="flex h-6 w-6 items-center justify-center rounded-full bg-amber-500/10 text-xs font-semibold text-amber-800 dark:text-amber-200">1</span><h3 className="text-sm font-semibold">Provider frames</h3><span className="text-xs text-muted-foreground">server-redacted by default</span></div>
|
||||
{selectedOperation.frames.length > 1 ? <div className="mb-2 flex flex-wrap gap-1">{selectedOperation.frames.map((frame) => <button key={Number(frame.frameId)} type="button" onClick={() => setSelectedFrameId(Number(frame.frameId))} className={cn("rounded-md border px-2 py-1 font-mono text-xs", Number(frame.frameId) === Number(selectedFrame?.frameId) ? "border-amber-500/50 bg-amber-500/10 text-foreground" : "border-border text-muted-foreground hover:bg-muted")}>#{String(frame.frameId)} {frameMethod(frame) || text(frame.direction)}</button>)}</div> : null}
|
||||
{selectedFrame ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"><Badge variant="outline">frame {String(selectedFrame.frameId)}</Badge><span>{text(selectedFrame.direction).replaceAll("_", " ")}</span><span>{formatBytes(Number(selectedFrame.byteLength) || 0)}</span><code className="truncate">{text(selectedFrame.digest)}</code></div>
|
||||
<JsonExplorer value={selectedFrame.parsed} />
|
||||
{Array.isArray(selectedFrame.withheldPaths) && selectedFrame.withheldPaths.length > 0 ? <div className="flex items-start gap-2 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs text-muted-foreground"><ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-700 dark:text-amber-300" /><span>Withheld paths: {selectedFrame.withheldPaths.join(", ")}</span></div> : null}
|
||||
{canInspectRaw ? <Button size="sm" variant="outline" onClick={() => void reveal(Number(selectedFrame.frameId))}><Eye className="mr-1.5 h-4 w-4" />Reveal exact frame</Button> : null}
|
||||
{revealed[Number(selectedFrame.frameId)] ? <div className="rounded-md border border-red-500/30 p-2"><p className="mb-2 flex items-center gap-1.5 text-xs font-medium text-red-700 dark:text-red-300"><ShieldAlert className="h-3.5 w-3.5" />Exact unredacted frame</p><JsonExplorer value={decodeExactFrame(revealed[Number(selectedFrame.frameId)]!.rawBase64)} label="Search exact frame" /></div> : null}
|
||||
</div>
|
||||
) : <p className="text-sm text-muted-foreground">This PRP event has no recoverable raw frame.</p>}
|
||||
</section>
|
||||
<section>
|
||||
<div className="mb-3 flex items-center gap-2"><span className="flex h-6 w-6 items-center justify-center rounded-full bg-cyan-500/10 text-xs font-semibold text-cyan-800 dark:text-cyan-200">2</span><h3 className="text-sm font-semibold">Interpretation stages</h3></div>
|
||||
{selectedInterpretations.length ? selectedInterpretations.map((entry, index) => <InterpretationStage key={`${entry.debugChannel}:${entry.debugSequence}:${index}`} entry={entry} last={index === selectedInterpretations.length - 1} />) : <p className="text-sm text-muted-foreground">No interpretation stage was recorded for this frame.</p>}
|
||||
</section>
|
||||
<section>
|
||||
<div className="mb-3 flex items-center gap-2"><span className="flex h-6 w-6 items-center justify-center rounded-full bg-violet-500/10 text-xs font-semibold text-violet-800 dark:text-violet-200">3</span><h3 className="text-sm font-semibold">Canonical PRP events</h3></div>
|
||||
{selectedEvents.length ? selectedEvents.map((event) => (
|
||||
<div key={event.id} className="mb-3 overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border/70 px-3 py-2"><code className="text-xs font-semibold">{event.eventType}</code><Badge variant="outline">seq {event.seq}</Badge><button type="button" className="ml-auto text-xs text-primary underline-offset-4 hover:underline" onClick={() => { setVisibility(visibilityDecision(event).visible ? "visible" : "hidden"); setSelectedKey(selectedOperation.key); }}>Why isn’t this visible?</button></div>
|
||||
<dl className="grid gap-x-4 gap-y-1 px-3 py-2 text-xs sm:grid-cols-(--gtc-runner-inspector-fields)">{typedPrpFields(event).map(([label, value]) => <div key={label} className="contents"><dt className="text-muted-foreground">{label}</dt><dd className="min-w-0 break-words font-mono">{value}</dd></div>)}</dl>
|
||||
<details className="border-t border-border/70"><summary className="flex cursor-pointer list-none items-center gap-1 px-3 py-2 text-xs font-medium text-muted-foreground"><ChevronDown className="h-3.5 w-3.5" />Canonical event JSON</summary><div className="p-3 pt-0"><JsonExplorer value={eventPrp(event)} /></div></details>
|
||||
</div>
|
||||
)) : <p className="text-sm text-muted-foreground">This provider operation emitted no canonical PRP events.</p>}
|
||||
</section>
|
||||
<section>
|
||||
<div className="mb-3 flex items-center gap-2"><span className="flex h-6 w-6 items-center justify-center rounded-full bg-emerald-500/10 text-xs font-semibold text-emerald-800 dark:text-emerald-200">4</span><h3 className="text-sm font-semibold">Production presentation</h3></div>
|
||||
{selectedEvents.length ? selectedEvents.map((event) => {
|
||||
const decision = visibilityDecision(event);
|
||||
return <div key={event.id} className="mb-4 grid gap-3 xl:grid-cols-(--gtc-runner-inspector-presentation)"><dl className="grid grid-cols-(--gtc-runner-inspector-fields) gap-x-3 gap-y-1 rounded-lg border border-border p-3 text-sm"><dt className="text-muted-foreground">Visible</dt><dd className="flex items-center gap-1.5">{decision.visible ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <CircleOff className="h-3.5 w-3.5 text-muted-foreground" />}{decision.visible ? "yes" : "no"}</dd><dt className="text-muted-foreground">Surface</dt><dd>{decision.surface}</dd><dt className="text-muted-foreground">Container</dt><dd>{decision.container}</dd><dt className="text-muted-foreground">State</dt><dd>{decision.state}</dd><dt className="text-muted-foreground">Action</dt><dd>{decision.action}</dd><dt className="text-muted-foreground">Reason</dt><dd>{decision.reason}</dd><dt className="text-muted-foreground">Reason code</dt><dd><code className="text-xs">{decision.reasonCode}</code></dd></dl><div><p className="mb-2 text-(length:--text-nano) font-medium uppercase tracking-wide text-muted-foreground">Production surface preview</p><ProductionSurfacePreview event={event} runId={runId} /></div></div>;
|
||||
}) : <p className="text-sm text-muted-foreground">No presentation surface was emitted for this operation.</p>}
|
||||
{Object.keys(presentationDecision).length > 0 ? <details className="rounded-lg border border-border"><summary className="cursor-pointer px-3 py-2 text-xs font-medium text-muted-foreground">Resolved final response decision</summary><div className="p-3 pt-0"><JsonExplorer value={presentationDecision} /></div></details> : null}
|
||||
</section>
|
||||
</div>
|
||||
) : <div className="p-5 text-sm text-muted-foreground">Select a correlated operation to inspect its pipeline.</div>}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{view === "trace" ? (
|
||||
<div className="grid min-h-0 flex-1 md:grid-cols-(--gtc-runner-inspector-trace)">
|
||||
<ScrollArea className="border-r border-border"><div className="p-2">{frames.length ? [...frames].sort((left, right) => Number(left.frameId) - Number(right.frameId)).map((frame) => <button key={Number(frame.frameId)} type="button" onClick={() => { const operation = operations.find((candidate) => candidate.frames.some((candidateFrame) => Number(candidateFrame.frameId) === Number(frame.frameId))); if (operation) setSelectedKey(operation.key); setSelectedFrameId(Number(frame.frameId)); }} className={cn("mb-1 w-full rounded-md border px-3 py-2 text-left", Number(frame.frameId) === Number(selectedFrame?.frameId) ? "border-amber-500/40 bg-amber-500/10" : "border-transparent hover:bg-muted/50")}><span className="flex items-center gap-2 text-sm font-medium"><span className="font-mono text-xs text-muted-foreground">#{String(frame.frameId)}</span><span className="truncate">{frameMethod(frame) || text(frame.direction)}</span></span><span className="mt-1 block text-(length:--text-nano) text-muted-foreground">{text(frame.direction).replaceAll("_", " ")} · {formatBytes(Number(frame.byteLength) || 0)}</span></button>) : <p className="p-3 text-sm text-muted-foreground">No raw frames were captured for this run.</p>}</div></ScrollArea>
|
||||
<ScrollArea><div className="space-y-3 p-5">{selectedFrame ? <><div className="flex flex-wrap items-center gap-2"><h3 className="text-sm font-semibold">Exact trace frame #{String(selectedFrame.frameId)}</h3><Badge variant="outline">{frameMethod(selectedFrame) || text(selectedFrame.direction)}</Badge></div><JsonExplorer value={selectedFrame.parsed} />{canInspectRaw ? <Button size="sm" variant="outline" onClick={() => void reveal(Number(selectedFrame.frameId))}><Eye className="mr-1.5 h-4 w-4" />Reveal exact frame</Button> : null}{revealed[Number(selectedFrame.frameId)] ? <JsonExplorer value={decodeExactFrame(revealed[Number(selectedFrame.frameId)]!.rawBase64)} label="Search exact frame" /> : null}</> : <p className="text-sm text-muted-foreground">Select a frame from the chronological trace.</p>}</div></ScrollArea>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{inspection?.trace ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border p-3">
|
||||
<p className="text-xs text-muted-foreground">{inspection.trace.frameCount} frames · {formatBytes(inspection.trace.byteCount)} · expires {new Date(inspection.trace.expiresAt).toLocaleString()}</p>
|
||||
<div className="flex gap-2"><Button size="sm" variant="outline" onClick={async () => { if (!window.confirm("Download the exact raw trace? It may contain sensitive prompts, tool arguments, and provider-only fields.")) return; downloadBlob(await heartbeatsApi.downloadProviderTrace(runId), `provider-trace-${runId}.ndjson`); }}><Download className="mr-1.5 h-4 w-4" />Download exact trace</Button><Button size="sm" variant="destructive" onClick={async () => { if (!window.confirm("Delete this raw trace immediately? This cannot be undone.")) return; await heartbeatsApi.deleteProviderTrace(runId); setInspection({ trace: null, entries: [] }); }}><Trash2 className="mr-1.5 h-4 w-4" />Delete trace</Button></div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import type { IssueQueuedCommentQueue } from "@paperclipai/shared";
|
||||
import { TaskChatThread } from "./TaskChatThread";
|
||||
import type { IssueQueuedCommentQueue } from "@paperclipai/shared";
|
||||
|
||||
const transcriptState = vi.hoisted(() => ({ transcriptByRun: new Map() }));
|
||||
const sidebarState = vi.hoisted(() => ({ isMobile: false }));
|
||||
@@ -117,6 +117,49 @@ describe("TaskChatThread draft pass-through", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskChatThread causal comment ordering", () => {
|
||||
it("places a queued input where its consuming run starts", () => {
|
||||
const comment = (
|
||||
id: string,
|
||||
body: string,
|
||||
createdAt: string,
|
||||
authorType: "user" | "agent",
|
||||
extra: Record<string, unknown> = {},
|
||||
) => ({
|
||||
id,
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType,
|
||||
authorAgentId: authorType === "agent" ? "agent-1" : null,
|
||||
authorUserId: authorType === "user" ? "user-1" : null,
|
||||
body,
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(createdAt),
|
||||
updatedAt: new Date(createdAt),
|
||||
...extra,
|
||||
});
|
||||
render(<TaskChatThread
|
||||
comments={[
|
||||
comment("initial", "Initial recipe request", "2026-08-22T12:00:00.000Z", "user"),
|
||||
comment("queued", "What about BBQ sauce?", "2026-08-22T12:01:00.000Z", "user", {
|
||||
consumedByRunId: "run-2",
|
||||
conversationAnchorAt: "2026-08-22T12:03:00.000Z",
|
||||
conversationAnchorSequence: 0,
|
||||
}),
|
||||
comment("reply-1", "Pork shoulder recipe", "2026-08-22T12:02:00.000Z", "agent", { runId: "run-1" }),
|
||||
comment("reply-2", "BBQ sauce recipe", "2026-08-22T12:04:00.000Z", "agent", { runId: "run-2" }),
|
||||
]}
|
||||
onAdd={async () => {}}
|
||||
/>);
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text.indexOf("Initial recipe request")).toBeLessThan(text.indexOf("Pork shoulder recipe"));
|
||||
expect(text.indexOf("Pork shoulder recipe")).toBeLessThan(text.indexOf("What about BBQ sauce?"));
|
||||
expect(text.indexOf("What about BBQ sauce?")).toBeLessThan(text.indexOf("BBQ sauce recipe"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskChatThread native-runner failures", () => {
|
||||
it("replaces an empty failed transcript with actionable retry feedback and keeps the composer enabled", () => {
|
||||
render(
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
} from "react";
|
||||
import { IssueChatThread } from "@/components/IssueChatThread";
|
||||
import {
|
||||
useLiveRunTranscripts,
|
||||
@@ -40,7 +48,10 @@ import {
|
||||
import { shouldHideInteractionCard } from "@/lib/issue-thread-interactions";
|
||||
import { TaskChatBubbleActions } from "@/components/task-chat/TaskChatBubbleActions";
|
||||
import type { FeedbackVoteValue } from "@paperclipai/shared";
|
||||
import { TaskChatThreadView, taskChatContentKey } from "@/components/task-chat/TaskChatThreadView";
|
||||
import {
|
||||
TaskChatThreadView,
|
||||
taskChatContentKey,
|
||||
} from "@/components/task-chat/TaskChatThreadView";
|
||||
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
|
||||
import { TaskChatQueuedMessages } from "@/components/task-chat/TaskChatQueuedMessages";
|
||||
import { useWindowAutoFollow } from "@/components/task-chat/useWindowAutoFollow";
|
||||
@@ -102,8 +113,11 @@ function isRunnerResponseComment(params: {
|
||||
if (comment.runId === runId) return true;
|
||||
if (comment.runId || !finalText) return false;
|
||||
const commentAtMs = toMs(comment.createdAt);
|
||||
if (runStartedAtMs != null && commentAtMs < runStartedAtMs - 5_000) return false;
|
||||
return normalizedMessageText(comment.body) === normalizedMessageText(finalText);
|
||||
if (runStartedAtMs != null && commentAtMs < runStartedAtMs - 5_000)
|
||||
return false;
|
||||
return (
|
||||
normalizedMessageText(comment.body) === normalizedMessageText(finalText)
|
||||
);
|
||||
}
|
||||
|
||||
// PAP-462 B4: backstop for how long the just-settled run's transcript stays
|
||||
@@ -112,6 +126,45 @@ function isRunnerResponseComment(params: {
|
||||
// well within this as soon as the settled turn/comment lands.
|
||||
const SETTLING_TAIL_MAX_MS = 15_000;
|
||||
const EMPTY_LIVE_ISSUE_IDS: ReadonlySet<string> = new Set<string>();
|
||||
const LEGACY_WITHHELD_RUN_COMMENT =
|
||||
"Run completed. Agent did not post a summary comment this run (transcript withheld — see run log).";
|
||||
|
||||
function acceptedSemanticResultSummary(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const resultJson = value as Record<string, unknown>;
|
||||
const semanticResult = resultJson.semanticResult;
|
||||
const candidates = [
|
||||
resultJson.nativeResult,
|
||||
resultJson.acceptedResult,
|
||||
semanticResult &&
|
||||
typeof semanticResult === "object" &&
|
||||
!Array.isArray(semanticResult)
|
||||
? (semanticResult as Record<string, unknown>).result
|
||||
: null,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
|
||||
continue;
|
||||
const result = candidate as Record<string, unknown>;
|
||||
if (result.schema !== "paperclip.run_result.v1") continue;
|
||||
if (typeof result.summary === "string" && result.summary.trim())
|
||||
return result.summary;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvedWithoutUserFacingResponse(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const decision = (value as Record<string, unknown>).presentationDecision;
|
||||
return Boolean(
|
||||
decision &&
|
||||
typeof decision === "object" &&
|
||||
!Array.isArray(decision) &&
|
||||
(decision as Record<string, unknown>).schema ===
|
||||
"paperclip.run_presentation_decision.v1" &&
|
||||
(decision as Record<string, unknown>).chosenSource === "none",
|
||||
);
|
||||
}
|
||||
|
||||
export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
|
||||
|
||||
@@ -297,21 +350,33 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
}, [paperclipQueue, queuedEdit]);
|
||||
|
||||
const liveWorkLinks = useMemo(
|
||||
() => issueStatus === "blocked" && blockerAttention?.state === "covered"
|
||||
? resolveTaskChatLiveWork(blockedBy, liveIssueIds ?? EMPTY_LIVE_ISSUE_IDS, blockerAttention.terminalBlocker)
|
||||
: null,
|
||||
[blockedBy, blockerAttention?.state, blockerAttention?.terminalBlocker, issueStatus, liveIssueIds],
|
||||
() =>
|
||||
issueStatus === "blocked" && blockerAttention?.state === "covered"
|
||||
? resolveTaskChatLiveWork(
|
||||
blockedBy,
|
||||
liveIssueIds ?? EMPTY_LIVE_ISSUE_IDS,
|
||||
blockerAttention.terminalBlocker,
|
||||
)
|
||||
: null,
|
||||
[
|
||||
blockedBy,
|
||||
blockerAttention?.state,
|
||||
blockerAttention?.terminalBlocker,
|
||||
issueStatus,
|
||||
liveIssueIds,
|
||||
],
|
||||
);
|
||||
|
||||
const blockerLinks = useMemo(
|
||||
() => issueStatus === "blocked" && !liveWorkLinks
|
||||
? resolveTaskChatBlockers(
|
||||
blockedBy,
|
||||
blockerAttention?.terminalBlockerIssueId,
|
||||
blockerAttention?.directBlockerIssueId,
|
||||
blockerAttention?.terminalBlocker,
|
||||
)
|
||||
: null,
|
||||
() =>
|
||||
issueStatus === "blocked" && !liveWorkLinks
|
||||
? resolveTaskChatBlockers(
|
||||
blockedBy,
|
||||
blockerAttention?.terminalBlockerIssueId,
|
||||
blockerAttention?.directBlockerIssueId,
|
||||
blockerAttention?.terminalBlocker,
|
||||
)
|
||||
: null,
|
||||
[
|
||||
blockedBy,
|
||||
blockerAttention?.directBlockerIssueId,
|
||||
@@ -322,20 +387,21 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
],
|
||||
);
|
||||
|
||||
const threadHeaderWithBlockers = threadHeader || blockerLinks || liveWorkLinks ? (
|
||||
<>
|
||||
{threadHeader}
|
||||
{liveWorkLinks ? (
|
||||
<TaskChatLiveWorkLinks liveWork={liveWorkLinks} placement="top" />
|
||||
) : blockerLinks ? (
|
||||
<TaskChatBlockerLinks
|
||||
directBlocker={blockerLinks.directBlocker}
|
||||
ultimateBlocker={blockerLinks.ultimateBlocker}
|
||||
placement="top"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : undefined;
|
||||
const threadHeaderWithBlockers =
|
||||
threadHeader || blockerLinks || liveWorkLinks ? (
|
||||
<>
|
||||
{threadHeader}
|
||||
{liveWorkLinks ? (
|
||||
<TaskChatLiveWorkLinks liveWork={liveWorkLinks} placement="top" />
|
||||
) : blockerLinks ? (
|
||||
<TaskChatBlockerLinks
|
||||
directBlocker={blockerLinks.directBlocker}
|
||||
ultimateBlocker={blockerLinks.ultimateBlocker}
|
||||
placement="top"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
const bottomBlockerLinks = liveWorkLinks ? (
|
||||
<TaskChatLiveWorkLinks liveWork={liveWorkLinks} placement="bottom" />
|
||||
@@ -348,19 +414,45 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
) : null;
|
||||
|
||||
const linkedRunMetaById = useMemo(() => {
|
||||
const map = new Map<string, NonNullable<TaskChatThreadProps["linkedRuns"]>[number]>();
|
||||
const map = new Map<
|
||||
string,
|
||||
NonNullable<TaskChatThreadProps["linkedRuns"]>[number]
|
||||
>();
|
||||
for (const run of linkedRuns ?? []) map.set(run.runId, run);
|
||||
return map;
|
||||
}, [linkedRuns]);
|
||||
|
||||
// Historical placeholder comments remain untouched in storage. Projection
|
||||
// prefers the accepted semantic result so runs such as DOT-20 recover their
|
||||
// real upstream response without a data migration.
|
||||
const projectedComments = useMemo(
|
||||
() =>
|
||||
comments.map((comment) => {
|
||||
if (comment.body !== LEGACY_WITHHELD_RUN_COMMENT || !comment.runId)
|
||||
return comment;
|
||||
const summary = acceptedSemanticResultSummary(
|
||||
linkedRunMetaById.get(comment.runId)?.resultJson,
|
||||
);
|
||||
return summary ? { ...comment, body: summary } : comment;
|
||||
}),
|
||||
[comments, linkedRunMetaById],
|
||||
);
|
||||
|
||||
const commentItems = useMemo(
|
||||
() => commentsToTaskChatItems(comments, {
|
||||
() =>
|
||||
commentsToTaskChatItems(projectedComments, {
|
||||
agentMap,
|
||||
userLabelMap,
|
||||
currentUserId,
|
||||
issueAssigneeAgentId,
|
||||
}),
|
||||
[
|
||||
projectedComments,
|
||||
agentMap,
|
||||
userLabelMap,
|
||||
currentUserId,
|
||||
issueAssigneeAgentId,
|
||||
}),
|
||||
[comments, agentMap, userLabelMap, currentUserId, issueAssigneeAgentId],
|
||||
],
|
||||
);
|
||||
|
||||
// Every run we might need a transcript for (history + live), deduped by id.
|
||||
@@ -410,12 +502,17 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// canonical comment and settled activity are both ready. This state is
|
||||
// established in a layout effect so the live lane never disappears for a
|
||||
// paint between `liveRun` ending and the persistence handoff beginning.
|
||||
const [settlingRun, setSettlingRun] = useState<{ id: string; startedAtMs: number | null } | null>(null);
|
||||
const [settlingRun, setSettlingRun] = useState<{
|
||||
id: string;
|
||||
startedAtMs: number | null;
|
||||
} | null>(null);
|
||||
const prevLiveRunRef = useRef<typeof liveRun>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (liveRun) {
|
||||
prevLiveRunRef.current = liveRun;
|
||||
setSettlingRun((current) => (current && current.id !== liveRun.id ? null : current));
|
||||
setSettlingRun((current) =>
|
||||
current && current.id !== liveRun.id ? null : current,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const prev = prevLiveRunRef.current;
|
||||
@@ -423,24 +520,34 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
if (!prev) return;
|
||||
setSettlingRun({
|
||||
id: prev.id,
|
||||
startedAtMs: (prev.startedAt ? toMs(prev.startedAt) : null) ?? toMs(prev.createdAt),
|
||||
startedAtMs:
|
||||
(prev.startedAt ? toMs(prev.startedAt) : null) ?? toMs(prev.createdAt),
|
||||
});
|
||||
}, [liveRun]);
|
||||
|
||||
const heldPaperclipRunnerRunId = liveRun?.adapterType === "paperclip_runner"
|
||||
? liveRun.id
|
||||
: settlingRun && runs.find((run) => run.id === settlingRun.id)?.adapterType === "paperclip_runner"
|
||||
? settlingRun.id
|
||||
: null;
|
||||
const heldPaperclipRunnerStartedAtMs = heldPaperclipRunnerRunId === liveRun?.id
|
||||
? (liveRun.startedAt ? toMs(liveRun.startedAt) : null) ?? toMs(liveRun.createdAt)
|
||||
: settlingRun?.startedAtMs ?? null;
|
||||
const heldPaperclipRunnerRunId =
|
||||
liveRun?.adapterType === "paperclip_runner"
|
||||
? liveRun.id
|
||||
: settlingRun &&
|
||||
runs.find((run) => run.id === settlingRun.id)?.adapterType ===
|
||||
"paperclip_runner"
|
||||
? settlingRun.id
|
||||
: null;
|
||||
const heldPaperclipRunnerStartedAtMs =
|
||||
heldPaperclipRunnerRunId === liveRun?.id
|
||||
? ((liveRun.startedAt ? toMs(liveRun.startedAt) : null) ??
|
||||
toMs(liveRun.createdAt))
|
||||
: (settlingRun?.startedAtMs ?? null);
|
||||
const heldPaperclipRunnerFinalText = useMemo(() => {
|
||||
if (!heldPaperclipRunnerRunId) return null;
|
||||
const entries = transcriptByRun.get(heldPaperclipRunnerRunId) ?? [];
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = entries[index];
|
||||
if (entry.kind === "assistant" && entry.channel === "final" && entry.text.trim()) {
|
||||
if (
|
||||
entry.kind === "assistant" &&
|
||||
entry.channel === "final" &&
|
||||
entry.text.trim()
|
||||
) {
|
||||
return entry.text;
|
||||
}
|
||||
}
|
||||
@@ -475,10 +582,17 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// legacy buildIssueChatMessages), so plan-mode confirmation/question cards
|
||||
// land where they happened in the conversation.
|
||||
const orderedEntries = useMemo(() => {
|
||||
const entries: { ms: number; order: number; id: string; item: TaskChatItem }[] = [];
|
||||
const entries: {
|
||||
ms: number;
|
||||
order: number;
|
||||
id: string;
|
||||
item: TaskChatItem;
|
||||
}[] = [];
|
||||
// commentsToTaskChatItems skips deleted comments — mirror its filter so the
|
||||
// two lists stay index-aligned.
|
||||
const visibleComments = comments.filter((comment) => !comment.deletedAt);
|
||||
const visibleComments = projectedComments.filter(
|
||||
(comment) => !comment.deletedAt,
|
||||
);
|
||||
visibleComments.forEach((comment, index) => {
|
||||
if (queuedCommentIds.has(comment.id)) return;
|
||||
// The live/settling runner lane already renders this run's final answer.
|
||||
@@ -492,10 +606,20 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
runStartedAtMs: heldPaperclipRunnerStartedAtMs,
|
||||
finalText: heldPaperclipRunnerFinalText,
|
||||
})
|
||||
) return;
|
||||
)
|
||||
return;
|
||||
const item = commentItems[index];
|
||||
if (!item) return;
|
||||
entries.push({ ms: toMs(comment.createdAt), order: 1, id: item.id, item });
|
||||
entries.push({
|
||||
// A held/queued input belongs where the runner actually consumes it,
|
||||
// not where the user first submitted it while another turn was live.
|
||||
ms: comment.conversationAnchorAt
|
||||
? toMs(comment.conversationAnchorAt)
|
||||
: toMs(comment.createdAt),
|
||||
order: 1 + Math.min(comment.conversationAnchorSequence ?? 0, 999) / 1000,
|
||||
id: item.id,
|
||||
item,
|
||||
});
|
||||
});
|
||||
for (const interaction of interactions ?? []) {
|
||||
// Withdrawn/superseded confirmations are retracted calls to action — drop
|
||||
@@ -544,7 +668,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
resourceKind: "document",
|
||||
title: documentDisplayTitle(document),
|
||||
subtitle: `Document · rev ${document.latestRevisionNumber}`,
|
||||
href: buildDocumentAnnotationHash({ documentKey: document.key, threadId: null, commentId: null }),
|
||||
href: buildDocumentAnnotationHash({
|
||||
documentKey: document.key,
|
||||
threadId: null,
|
||||
commentId: null,
|
||||
}),
|
||||
timestamp: new Date(document.updatedAt).toISOString(),
|
||||
document,
|
||||
},
|
||||
@@ -563,14 +691,23 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
surface: "resource",
|
||||
resourceKind: "deliverable",
|
||||
title: workProduct.title,
|
||||
subtitle: [workProduct.type.replaceAll("_", " "), workProduct.status, workProduct.reviewState !== "none" ? workProduct.reviewState : null].filter(Boolean).join(" · "),
|
||||
subtitle: [
|
||||
workProduct.type.replaceAll("_", " "),
|
||||
workProduct.status,
|
||||
workProduct.reviewState !== "none" ? workProduct.reviewState : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
href: safeResourceHref(href),
|
||||
timestamp: new Date(workProduct.createdAt).toISOString(),
|
||||
workProduct,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const attachment of selectAgentArtifactAttachments(attachments, workProducts)) {
|
||||
for (const attachment of selectAgentArtifactAttachments(
|
||||
attachments,
|
||||
workProducts,
|
||||
)) {
|
||||
const id = `resource:attachment:${attachment.id}`;
|
||||
entries.push({
|
||||
ms: toMs(attachment.createdAt),
|
||||
@@ -608,13 +745,32 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
return entries.sort(
|
||||
(a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id),
|
||||
);
|
||||
}, [comments, commentItems, interactions, documents, workProducts, attachments, timelineEvents, linkedRuns, liveRuns, planDocument, heldPaperclipRunnerRunId, heldPaperclipRunnerStartedAtMs, heldPaperclipRunnerFinalText, queuedCommentIds]);
|
||||
}, [
|
||||
comments,
|
||||
projectedComments,
|
||||
commentItems,
|
||||
interactions,
|
||||
documents,
|
||||
workProducts,
|
||||
attachments,
|
||||
timelineEvents,
|
||||
linkedRuns,
|
||||
liveRuns,
|
||||
planDocument,
|
||||
heldPaperclipRunnerRunId,
|
||||
heldPaperclipRunnerStartedAtMs,
|
||||
heldPaperclipRunnerFinalText,
|
||||
queuedCommentIds,
|
||||
]);
|
||||
|
||||
// Boolean gate (stable across the host's per-render brief objects) so the
|
||||
// heavy assembly memo doesn't recompute on every parent render.
|
||||
const hasBrief = Boolean(issueBrief);
|
||||
|
||||
const { items, settledRunIds } = useMemo<{ items: TaskChatItem[]; settledRunIds: Set<string> }>(() => {
|
||||
const { items, settledRunIds } = useMemo<{
|
||||
items: TaskChatItem[];
|
||||
settledRunIds: Set<string>;
|
||||
}>(() => {
|
||||
// Runs whose settled turn made it into the assembled thread — the live tail
|
||||
// hands off to this as its "the settled turn has rendered" signal (PAP-462
|
||||
// B4), so the transcript stays mounted through the settle gap without ever
|
||||
@@ -626,7 +782,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// landed as the run's comment bubble, interstitial updates are ephemeral
|
||||
// (live-line only), and thinking stays in the run log / classic
|
||||
// transcript — "Worked · N tools" expands to exactly the tool rows.
|
||||
const settledTurns: { turn: TaskChatTurnItem; anchorCommentId: string | null; startMs: number }[] = [];
|
||||
const settledTurns: {
|
||||
turn: TaskChatTurnItem;
|
||||
anchorCommentId: string | null;
|
||||
startMs: number;
|
||||
}[] = [];
|
||||
// Raw summary inputs per turn id, so back-to-back same-agent runs can
|
||||
// coalesce into one "Worked" row in the final pass (PAP-362).
|
||||
const turnMergeMetaById = new Map<string, SettledTurnMergeMeta>();
|
||||
@@ -642,9 +802,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const retryDetail = meta?.scheduledRetryAt
|
||||
? "Retry scheduled automatically."
|
||||
: "You can retry this message now.";
|
||||
const detail = code === "provider_frame_too_large"
|
||||
? `Provider output exceeded the safe limit. ${retryDetail}`
|
||||
: `The runner stopped before returning an answer (${code}). ${retryDetail}`;
|
||||
const detail =
|
||||
code === "provider_frame_too_large"
|
||||
? `Provider output exceeded the safe limit. ${retryDetail}`
|
||||
: `The runner stopped before returning an answer (${code}). ${retryDetail}`;
|
||||
const id = `${source.id}:failure`;
|
||||
entriesWithFailures.push({
|
||||
ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt),
|
||||
@@ -658,11 +819,30 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
detail,
|
||||
},
|
||||
});
|
||||
} else if (!lastCommentIdByRun.has(source.id)) {
|
||||
settledRunIds.add(source.id);
|
||||
const id = `${source.id}:terminal-notice`;
|
||||
entriesWithFailures.push({
|
||||
ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt),
|
||||
order: 3,
|
||||
id,
|
||||
item: {
|
||||
id,
|
||||
kind: "marker",
|
||||
variant: "turn_boundary",
|
||||
label: "Run completed",
|
||||
detail: "The runner returned no user-facing response.",
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const started = meta?.startedAt ? new Date(meta.startedAt).getTime() : NaN;
|
||||
const finished = meta?.finishedAt ? new Date(meta.finishedAt).getTime() : NaN;
|
||||
const started = meta?.startedAt
|
||||
? new Date(meta.startedAt).getTime()
|
||||
: NaN;
|
||||
const finished = meta?.finishedAt
|
||||
? new Date(meta.finishedAt).getTime()
|
||||
: NaN;
|
||||
const durationMs =
|
||||
Number.isFinite(started) && Number.isFinite(finished)
|
||||
? Math.max(0, finished - started)
|
||||
@@ -677,7 +857,27 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
? paperclipRunnerHistoryItems(parsed)
|
||||
: parsed,
|
||||
);
|
||||
if (children.length === 0 && source.adapterType !== "paperclip_runner") continue;
|
||||
if (children.length === 0 && source.adapterType !== "paperclip_runner")
|
||||
continue;
|
||||
if (
|
||||
source.status === "succeeded" &&
|
||||
!lastCommentIdByRun.has(source.id) &&
|
||||
resolvedWithoutUserFacingResponse(meta?.resultJson)
|
||||
) {
|
||||
const id = `${source.id}:terminal-notice`;
|
||||
entriesWithFailures.push({
|
||||
ms: toMs(meta?.finishedAt ?? meta?.startedAt ?? meta?.createdAt),
|
||||
order: 3,
|
||||
id,
|
||||
item: {
|
||||
id,
|
||||
kind: "marker",
|
||||
variant: "turn_boundary",
|
||||
label: "Run completed",
|
||||
detail: "The runner returned no user-facing response.",
|
||||
},
|
||||
});
|
||||
}
|
||||
settledRunIds.add(source.id);
|
||||
const failed = source.status !== "succeeded";
|
||||
const startSlotRaw = meta?.startedAt ?? meta?.createdAt;
|
||||
@@ -704,7 +904,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
startMs: startSlotRaw ? toMs(startSlotRaw) : Number.POSITIVE_INFINITY,
|
||||
});
|
||||
}
|
||||
settledTurns.sort((a, b) => (a.startMs < b.startMs ? -1 : a.startMs > b.startMs ? 1 : 0));
|
||||
settledTurns.sort((a, b) =>
|
||||
a.startMs < b.startMs ? -1 : a.startMs > b.startMs ? 1 : 0,
|
||||
);
|
||||
|
||||
const turnsByAnchor = new Map<string, TaskChatTurnItem[]>();
|
||||
const unanchored: { turn: TaskChatTurnItem; startMs: number }[] = [];
|
||||
@@ -725,7 +927,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
entriesWithFailures.sort(
|
||||
(a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id),
|
||||
);
|
||||
const out = assembleThreadItems(entriesWithFailures, turnsByAnchor, unanchored);
|
||||
const out = assembleThreadItems(
|
||||
entriesWithFailures,
|
||||
turnsByAnchor,
|
||||
unanchored,
|
||||
);
|
||||
|
||||
// PAP-362: two runs replying back-to-back (same agent, nothing but the
|
||||
// agent's own bubbles between) fold into ONE "Worked" row below the last
|
||||
@@ -737,29 +943,45 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// every assembly/merge pass, so nothing can ever sort above it.
|
||||
return {
|
||||
items: prependIssueBrief(
|
||||
attachSettledTurns(coalesceSettledTurns(out, turnMergeMetaById), turnMergeMetaById),
|
||||
attachSettledTurns(
|
||||
coalesceSettledTurns(out, turnMergeMetaById),
|
||||
turnMergeMetaById,
|
||||
),
|
||||
hasBrief,
|
||||
),
|
||||
settledRunIds,
|
||||
};
|
||||
}, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun, hasBrief]);
|
||||
}, [
|
||||
orderedEntries,
|
||||
runs,
|
||||
liveRun,
|
||||
transcriptByRun,
|
||||
linkedRunMetaById,
|
||||
lastCommentIdByRun,
|
||||
hasBrief,
|
||||
]);
|
||||
|
||||
// Hand off once the settled turn or its reply comment is in the thread; a
|
||||
// stopped run that yields neither is released by the backstop timeout so the
|
||||
// tail never lingers indefinitely.
|
||||
const settlingIsPaperclipRunner = settlingRun != null &&
|
||||
runs.find((run) => run.id === settlingRun.id)?.adapterType === "paperclip_runner";
|
||||
const settlingHasComment = settlingRun != null && comments.some((comment) =>
|
||||
settlingIsPaperclipRunner
|
||||
? isRunnerResponseComment({
|
||||
comment,
|
||||
runId: settlingRun.id,
|
||||
runStartedAtMs: settlingRun.startedAtMs,
|
||||
finalText: heldPaperclipRunnerFinalText,
|
||||
})
|
||||
: comment.runId === settlingRun.id && !comment.deletedAt,
|
||||
);
|
||||
const settledRunRendered = settlingRun != null &&
|
||||
const settlingIsPaperclipRunner =
|
||||
settlingRun != null &&
|
||||
runs.find((run) => run.id === settlingRun.id)?.adapterType ===
|
||||
"paperclip_runner";
|
||||
const settlingHasComment =
|
||||
settlingRun != null &&
|
||||
comments.some((comment) =>
|
||||
settlingIsPaperclipRunner
|
||||
? isRunnerResponseComment({
|
||||
comment,
|
||||
runId: settlingRun.id,
|
||||
runStartedAtMs: settlingRun.startedAtMs,
|
||||
finalText: heldPaperclipRunnerFinalText,
|
||||
})
|
||||
: comment.runId === settlingRun.id && !comment.deletedAt,
|
||||
);
|
||||
const settledRunRendered =
|
||||
settlingRun != null &&
|
||||
(settlingIsPaperclipRunner
|
||||
? settlingHasComment && settledRunIds.has(settlingRun.id)
|
||||
: settlingHasComment || settledRunIds.has(settlingRun.id));
|
||||
@@ -769,7 +991,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
setSettlingRun(null);
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => setSettlingRun(null), SETTLING_TAIL_MAX_MS);
|
||||
const timer = window.setTimeout(
|
||||
() => setSettlingRun(null),
|
||||
SETTLING_TAIL_MAX_MS,
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [settlingRun, settledRunRendered]);
|
||||
|
||||
@@ -780,10 +1005,16 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
settlingRun != null &&
|
||||
!settledRunRendered &&
|
||||
(transcriptByRun.get(settlingRun.id)?.length ?? 0) > 0;
|
||||
const tailRunId = liveRun ? liveRun.id : showSettlingTail ? settlingRun!.id : null;
|
||||
const tailRunId = liveRun
|
||||
? liveRun.id
|
||||
: showSettlingTail
|
||||
? settlingRun!.id
|
||||
: null;
|
||||
const tailStreaming = Boolean(liveRun);
|
||||
const tailEntries = tailRunId ? (transcriptByRun.get(tailRunId) ?? []) : [];
|
||||
const tailAdapterType = tailRunId ? runs.find((run) => run.id === tailRunId)?.adapterType : null;
|
||||
const tailAdapterType = tailRunId
|
||||
? runs.find((run) => run.id === tailRunId)?.adapterType
|
||||
: null;
|
||||
const paperclipRunnerTail = tailAdapterType === "paperclip_runner";
|
||||
const tailContentKey = tailEntries.reduce((total, entry) => {
|
||||
if ("text" in entry) return total + entry.text.length;
|
||||
@@ -803,13 +1034,16 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// while the pill's own second-tick keeps the elapsed readout moving. Through
|
||||
// the settle gap the run reads terminal, so the pill lands on its "Worked"
|
||||
// state instead of flipping back to a spinner.
|
||||
const settlingFinishedAt = settlingRun ? linkedRunMetaById.get(settlingRun.id)?.finishedAt : undefined;
|
||||
const settlingFinishedAt = settlingRun
|
||||
? linkedRunMetaById.get(settlingRun.id)?.finishedAt
|
||||
: undefined;
|
||||
const tailStatus = liveRun
|
||||
? liveRun.status
|
||||
: runs.find((run) => run.id === settlingRun?.id)?.status ?? "succeeded";
|
||||
: (runs.find((run) => run.id === settlingRun?.id)?.status ?? "succeeded");
|
||||
const tailStartedAtMs = liveRun
|
||||
? (liveRun.startedAt ? toMs(liveRun.startedAt) : null) ?? toMs(liveRun.createdAt)
|
||||
: settlingRun?.startedAtMs ?? null;
|
||||
? ((liveRun.startedAt ? toMs(liveRun.startedAt) : null) ??
|
||||
toMs(liveRun.createdAt))
|
||||
: (settlingRun?.startedAtMs ?? null);
|
||||
const tailFinishedAtMs = liveRun
|
||||
? liveRun.finishedAt
|
||||
? toMs(liveRun.finishedAt)
|
||||
@@ -832,10 +1066,12 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// pill's own second-tick keeps the elapsed readout moving. `running` follows
|
||||
// tailStreaming: true while live, false through the settle gap — so the tail
|
||||
// renders identically either side of the run finishing.
|
||||
const tailAgentName = liveRun?.agentName ?? linkedRunMetaById.get(tailRunId ?? "")?.agentName;
|
||||
const tailAgentId = liveRun?.agentId
|
||||
?? linkedRunMetaById.get(tailRunId ?? "")?.agentId
|
||||
?? issueAssigneeAgentId;
|
||||
const tailAgentName =
|
||||
liveRun?.agentName ?? linkedRunMetaById.get(tailRunId ?? "")?.agentName;
|
||||
const tailAgentId =
|
||||
liveRun?.agentId ??
|
||||
linkedRunMetaById.get(tailRunId ?? "")?.agentId ??
|
||||
issueAssigneeAgentId;
|
||||
const tailAgent = tailAgentId ? agentMap?.get(tailAgentId) : undefined;
|
||||
const visibleTailAgentName = tailAgentName ?? tailAgent?.name ?? null;
|
||||
const visibleTailAgentIcon = tailAgent?.icon ?? null;
|
||||
@@ -852,47 +1088,66 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[tailRunId, tailContentKey, tailStreaming, tailAgentName],
|
||||
);
|
||||
const pendingRuntimeRequest = tailItems.find(
|
||||
(item): item is TaskChatRuntimeRequestItem => item.kind === "protocol"
|
||||
&& item.surface === "runtime_request"
|
||||
&& item.status === "pending",
|
||||
) ?? null;
|
||||
const handleRuntimeRequestDecision = useCallback(async (
|
||||
item: TaskChatRuntimeRequestItem,
|
||||
decision: TaskChatRuntimeRequestDecision,
|
||||
) => {
|
||||
if (!paperclipRunnerTail || item.runId !== tailRunId || !item.turnId || !item.requestKind) {
|
||||
throw new Error("This runtime request is no longer attached to the active Paperclip runner turn.");
|
||||
}
|
||||
let resolution: RuntimeRequestResolution;
|
||||
if (decision.action !== "submit") {
|
||||
resolution = decision;
|
||||
} else if (item.requestKind === "user_input") {
|
||||
resolution = {
|
||||
action: "submit",
|
||||
answers: Object.fromEntries(
|
||||
Object.entries(decision.values).map(([field, value]) => [field, { answers: [value] }]),
|
||||
),
|
||||
};
|
||||
} else if (item.requestKind === "elicitation") {
|
||||
resolution = { action: "submit", content: decision.values };
|
||||
} else {
|
||||
throw new Error("This runtime permission does not accept submitted form data.");
|
||||
}
|
||||
await heartbeatsApi.resolveRuntimeRequest({
|
||||
runId: item.runId,
|
||||
requestId: item.requestId,
|
||||
turnId: item.turnId,
|
||||
requestKind: item.requestKind,
|
||||
resolution,
|
||||
});
|
||||
}, [paperclipRunnerTail, tailRunId]);
|
||||
const runtimeComposerDisabledReason = composerDisabledReason
|
||||
?? (paperclipRunnerTail && pendingRuntimeRequest
|
||||
const pendingRuntimeRequest =
|
||||
tailItems.find(
|
||||
(item): item is TaskChatRuntimeRequestItem =>
|
||||
item.kind === "protocol" &&
|
||||
item.surface === "runtime_request" &&
|
||||
item.status === "pending",
|
||||
) ?? null;
|
||||
const handleRuntimeRequestDecision = useCallback(
|
||||
async (
|
||||
item: TaskChatRuntimeRequestItem,
|
||||
decision: TaskChatRuntimeRequestDecision,
|
||||
) => {
|
||||
if (
|
||||
!paperclipRunnerTail ||
|
||||
item.runId !== tailRunId ||
|
||||
!item.turnId ||
|
||||
!item.requestKind
|
||||
) {
|
||||
throw new Error(
|
||||
"This runtime request is no longer attached to the active Paperclip runner turn.",
|
||||
);
|
||||
}
|
||||
let resolution: RuntimeRequestResolution;
|
||||
if (decision.action !== "submit") {
|
||||
resolution = decision;
|
||||
} else if (item.requestKind === "user_input") {
|
||||
resolution = {
|
||||
action: "submit",
|
||||
answers: Object.fromEntries(
|
||||
Object.entries(decision.values).map(([field, value]) => [
|
||||
field,
|
||||
{ answers: [value] },
|
||||
]),
|
||||
),
|
||||
};
|
||||
} else if (item.requestKind === "elicitation") {
|
||||
resolution = { action: "submit", content: decision.values };
|
||||
} else {
|
||||
throw new Error(
|
||||
"This runtime permission does not accept submitted form data.",
|
||||
);
|
||||
}
|
||||
await heartbeatsApi.resolveRuntimeRequest({
|
||||
runId: item.runId,
|
||||
requestId: item.requestId,
|
||||
turnId: item.turnId,
|
||||
requestKind: item.requestKind,
|
||||
resolution,
|
||||
});
|
||||
},
|
||||
[paperclipRunnerTail, tailRunId],
|
||||
);
|
||||
const runtimeComposerDisabledReason =
|
||||
composerDisabledReason ??
|
||||
(paperclipRunnerTail && pendingRuntimeRequest
|
||||
? "Resolve the runtime request before sending another message."
|
||||
: undefined);
|
||||
const assigneeUsesPaperclipRunner = Boolean(
|
||||
issueAssigneeAgentId && agentMap?.get(issueAssigneeAgentId)?.adapterType === "paperclip_runner",
|
||||
issueAssigneeAgentId &&
|
||||
agentMap?.get(issueAssigneeAgentId)?.adapterType === "paperclip_runner",
|
||||
);
|
||||
const [runnerSubmissionPending, setRunnerSubmissionPending] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -900,7 +1155,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
}, [tailRunId]);
|
||||
useEffect(() => {
|
||||
if (!runnerSubmissionPending || tailRunId) return;
|
||||
const timeout = window.setTimeout(() => setRunnerSubmissionPending(false), 10_000);
|
||||
const timeout = window.setTimeout(
|
||||
() => setRunnerSubmissionPending(false),
|
||||
10_000,
|
||||
);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [runnerSubmissionPending, tailRunId]);
|
||||
const handleThreadAdd = useCallback(
|
||||
@@ -915,11 +1173,16 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
},
|
||||
[assigneeUsesPaperclipRunner, onAdd],
|
||||
);
|
||||
const optimisticRunnerStartup = assigneeUsesPaperclipRunner && !tailRunId && (
|
||||
runnerSubmissionPending || commentItems.some(
|
||||
(item) => item.kind === "message" && item.author === "human" && item.optimistic === "pending",
|
||||
)
|
||||
);
|
||||
const optimisticRunnerStartup =
|
||||
assigneeUsesPaperclipRunner &&
|
||||
!tailRunId &&
|
||||
(runnerSubmissionPending ||
|
||||
commentItems.some(
|
||||
(item) =>
|
||||
item.kind === "message" &&
|
||||
item.author === "human" &&
|
||||
item.optimistic === "pending",
|
||||
));
|
||||
|
||||
// Feedback votes keyed by the comment they target (targetType
|
||||
// "issue_comment"), mirroring IssueChatThread — the redesign attaches the
|
||||
@@ -955,14 +1218,20 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
/>
|
||||
);
|
||||
},
|
||||
[onVote, feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl],
|
||||
[
|
||||
onVote,
|
||||
feedbackVoteByTargetId,
|
||||
feedbackDataSharingPreference,
|
||||
feedbackTermsUrl,
|
||||
],
|
||||
);
|
||||
|
||||
const renderQueuedAction = useCallback(
|
||||
(item: TaskChatMessageItem) => {
|
||||
if (paperclipQueue) return null;
|
||||
const runId = item.queueTargetRunId;
|
||||
if (item.optimistic !== "queued" || !runId || !onInterruptQueued) return null;
|
||||
if (item.optimistic !== "queued" || !runId || !onInterruptQueued)
|
||||
return null;
|
||||
|
||||
const isInterrupting = interruptingQueuedRunId === runId;
|
||||
return (
|
||||
@@ -1022,12 +1291,17 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col", !isMobile && "h-(--tc-thread-max-h) min-h-0 flex-1")}
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
!isMobile && "h-(--tc-thread-max-h) min-h-0 flex-1",
|
||||
)}
|
||||
data-testid="task-chat-thread"
|
||||
>
|
||||
<div className={cn("flex flex-col", !isMobile && "min-h-0 flex-1")}>
|
||||
{items.length === 0 && !tailRunId ? (
|
||||
<div className={isMobile ? undefined : "min-h-0 flex-1 overflow-y-auto"}>
|
||||
<div
|
||||
className={isMobile ? undefined : "min-h-0 flex-1 overflow-y-auto"}
|
||||
>
|
||||
{threadHeaderWithBlockers ? (
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-6 px-4 pt-4"
|
||||
@@ -1036,7 +1310,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
{threadHeaderWithBlockers}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="px-3 py-10 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
{bottomBlockerLinks ? (
|
||||
<div className="mx-auto w-full max-w-(--tc-shell-max-w) px-4 pb-4">
|
||||
{bottomBlockerLinks}
|
||||
@@ -1049,48 +1325,57 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
header={threadHeaderWithBlockers}
|
||||
onRuntimeRequestDecision={handleRuntimeRequestDecision}
|
||||
renderInteraction={renderInteraction}
|
||||
renderBrief={issueBrief ? () => <TaskChatDescriptionBubble brief={issueBrief} /> : undefined}
|
||||
renderBrief={
|
||||
issueBrief
|
||||
? () => <TaskChatDescriptionBubble brief={issueBrief} />
|
||||
: undefined
|
||||
}
|
||||
renderMessageActions={renderMessageActions}
|
||||
renderQueuedAction={renderQueuedAction}
|
||||
tail={tailRunId || optimisticRunnerStartup || bottomBlockerLinks ? (
|
||||
<>
|
||||
{tailRunId || optimisticRunnerStartup ? (
|
||||
<div data-testid="task-chat-live-transcript">
|
||||
{paperclipRunnerTail || optimisticRunnerStartup ? (
|
||||
<TaskChatRunnerTurn
|
||||
runId={tailRunId}
|
||||
agentName={visibleTailAgentName}
|
||||
agentIcon={visibleTailAgentIcon}
|
||||
items={tailItems}
|
||||
status={optimisticRunnerStartup ? "queued" : tailStatus}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
toolSummary={tailToolSummary}
|
||||
onRuntimeRequestDecision={handleRuntimeRequestDecision}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TaskChatLiveRunPill
|
||||
status={tailStatus}
|
||||
tail={
|
||||
tailRunId || optimisticRunnerStartup || bottomBlockerLinks ? (
|
||||
<>
|
||||
{tailRunId || optimisticRunnerStartup ? (
|
||||
<div data-testid="task-chat-live-transcript">
|
||||
{paperclipRunnerTail || optimisticRunnerStartup ? (
|
||||
<TaskChatRunnerTurn
|
||||
runId={tailRunId}
|
||||
agentName={visibleTailAgentName}
|
||||
agentIcon={visibleTailAgentIcon}
|
||||
items={tailItems}
|
||||
status={
|
||||
optimisticRunnerStartup ? "queued" : tailStatus
|
||||
}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
toolSummary={tailToolSummary}
|
||||
/>
|
||||
<TaskChatLiveTail
|
||||
items={tailItems}
|
||||
emptyMessage={
|
||||
tailStatus === "queued"
|
||||
? "Waiting to start..."
|
||||
: "Waiting for transcript..."
|
||||
onRuntimeRequestDecision={
|
||||
handleRuntimeRequestDecision
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{bottomBlockerLinks}
|
||||
</>
|
||||
) : null}
|
||||
) : (
|
||||
<>
|
||||
<TaskChatLiveRunPill
|
||||
status={tailStatus}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
toolSummary={tailToolSummary}
|
||||
/>
|
||||
<TaskChatLiveTail
|
||||
items={tailItems}
|
||||
emptyMessage={
|
||||
tailStatus === "queued"
|
||||
? "Waiting to start..."
|
||||
: "Waiting for transcript..."
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{bottomBlockerLinks}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
contentKey={threadContentKey}
|
||||
scroll={!isMobile}
|
||||
/>
|
||||
|
||||
@@ -90,43 +90,4 @@ describe("TaskChatLiveRunPill", () => {
|
||||
expect(pill?.textContent).toContain("ran 1 command");
|
||||
});
|
||||
|
||||
it("keeps the paperclip runner Working header static", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TaskChatLiveRunPill
|
||||
status="running"
|
||||
startedAtMs={Date.now() - 2_000}
|
||||
toolSummary={null}
|
||||
runnerStyle
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Workingfor 2s");
|
||||
expect(container.querySelector(".shimmer-text")).toBeNull();
|
||||
});
|
||||
|
||||
it("only exposes the runner disclosure caret after the run settles", () => {
|
||||
const render = (status: string) => act(() => {
|
||||
root.render(
|
||||
<TaskChatLiveRunPill
|
||||
status={status}
|
||||
startedAtMs={1_000}
|
||||
finishedAtMs={status === "succeeded" ? 3_000 : null}
|
||||
toolSummary={null}
|
||||
runnerStyle
|
||||
expanded={false}
|
||||
onToggle={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
render("running");
|
||||
expect(container.querySelector('[data-testid="task-chat-live-run-pill"]')?.tagName).toBe("DIV");
|
||||
expect(container.querySelector('[data-testid="task-chat-live-run-pill"] svg')).toBeNull();
|
||||
|
||||
render("succeeded");
|
||||
expect(container.querySelector('[data-testid="task-chat-live-run-pill"]')?.tagName).toBe("BUTTON");
|
||||
expect(container.querySelector('[data-testid="task-chat-live-run-pill"] svg')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronRight, Loader2 } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { TranscriptEntry } from "../../adapters";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSecondTick } from "@/hooks/useSecondTick";
|
||||
@@ -47,9 +47,6 @@ export function TaskChatLiveRunPill({
|
||||
startedAtMs,
|
||||
finishedAtMs,
|
||||
toolSummary,
|
||||
runnerStyle = false,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
status: string;
|
||||
/** Run start (startedAt, falling back to createdAt) in ms, or null if unknown. */
|
||||
@@ -57,10 +54,6 @@ export function TaskChatLiveRunPill({
|
||||
/** Run finish in ms once terminal; drives the settled elapsed readout. */
|
||||
finishedAtMs?: number | null;
|
||||
toolSummary: string | null;
|
||||
/** Codex-style new-runner header: no spinner, whole-second compact duration. */
|
||||
runnerStyle?: boolean;
|
||||
expanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
}) {
|
||||
const active = !isTerminalRunStatus(status);
|
||||
// One shared page-wide ticker drives the live elapsed readout, matching the
|
||||
@@ -70,43 +63,11 @@ export function TaskChatLiveRunPill({
|
||||
const elapsedMs =
|
||||
startedAtMs == null ? null : (active ? Date.now() : finishedAtMs ?? Date.now()) - startedAtMs;
|
||||
const elapsed = elapsedMs != null
|
||||
? runnerStyle
|
||||
? `${Math.max(0, Math.floor(elapsedMs / 1000))}s`
|
||||
: formatDurationWords(elapsedMs)
|
||||
? formatDurationWords(elapsedMs)
|
||||
: null;
|
||||
const verb = active ? "Working" : "Worked";
|
||||
const suffix = elapsed ? `for ${elapsed}` : null;
|
||||
|
||||
if (runnerStyle) {
|
||||
const content = (
|
||||
<>
|
||||
<span className="text-sm font-medium text-foreground/80">{verb}</span>
|
||||
{suffix ? <span className="text-sm text-muted-foreground">{suffix}</span> : null}
|
||||
{!active ? (
|
||||
<ChevronRight
|
||||
className={cn("ml-auto h-3.5 w-3.5 text-muted-foreground transition-transform", expanded && "rotate-90")}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
return onToggle && !active ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full min-w-0 items-center gap-1.5 border-b border-border/70 px-1 py-2 text-left transition-colors hover:text-foreground"
|
||||
data-testid="task-chat-live-run-pill"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-1.5 border-b border-border/70 px-1 py-2" data-testid="task-chat-live-run-pill">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-2.5 px-1 py-2"
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { MemoryRouter } from "@/lib/router";
|
||||
import type { TaskChatProtocolItem, TaskChatProviderActivityItem } from "./task-chat-model";
|
||||
import { TaskChatProtocolActivityRow } from "./TaskChatProtocolActivityRow";
|
||||
|
||||
describe("TaskChatProtocolActivityRow", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function render(item: TaskChatProtocolItem) {
|
||||
act(() => root.render(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider>
|
||||
<TaskChatProtocolActivityRow item={item} />
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>,
|
||||
));
|
||||
}
|
||||
|
||||
it("renders research as one compact line with expandable sources", () => {
|
||||
const item: TaskChatProviderActivityItem = {
|
||||
id: "research",
|
||||
kind: "protocol",
|
||||
surface: "provider_activity",
|
||||
family: "research",
|
||||
eventType: "research.completed",
|
||||
status: "completed",
|
||||
title: "Research",
|
||||
summary: "smoked pork shoulder",
|
||||
details: [{ label: "Query", value: "smoked pork shoulder", mono: true }],
|
||||
steps: [],
|
||||
links: [{ label: "Smoking guide", href: "https://example.com/smoking", description: "Temperature targets" }],
|
||||
children: [],
|
||||
};
|
||||
render(item);
|
||||
|
||||
const row = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-protocol-activity-row"] button');
|
||||
expect(container.querySelector("article")).toBeNull();
|
||||
expect(row?.textContent).toContain("Searched the web");
|
||||
expect(row?.textContent).toContain("smoked pork shoulder");
|
||||
expect(row?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(row?.getAttribute("aria-controls")).toMatch(/^task-chat-protocol-activity-/);
|
||||
|
||||
act(() => row?.click());
|
||||
|
||||
expect(row?.getAttribute("aria-expanded")).toBe("true");
|
||||
const detail = container.querySelector('[data-testid="task-chat-protocol-activity-detail"]');
|
||||
expect(detail?.id).toBe(row?.getAttribute("aria-controls"));
|
||||
expect(detail?.textContent).toContain("Smoking guide");
|
||||
});
|
||||
|
||||
it("keeps workspace files and diffs inside the row detail", () => {
|
||||
render({
|
||||
id: "workspace",
|
||||
kind: "protocol",
|
||||
surface: "workspace_change",
|
||||
changeSetId: "change-1",
|
||||
revision: 1,
|
||||
source: "runner_verified",
|
||||
complete: true,
|
||||
files: [{ path: "ui/src/App.tsx", operation: "modify", previousPath: null, additions: 1, deletions: 1, binary: false, diff: "-old\n+new" }],
|
||||
totals: { files: 1, additions: 1, deletions: 1 },
|
||||
patchArtifactRef: null,
|
||||
});
|
||||
|
||||
const row = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-protocol-activity-row"] button');
|
||||
expect(row?.textContent).toContain("Edited files");
|
||||
act(() => row?.click());
|
||||
expect(container.textContent).toContain("ui/src/App.tsx");
|
||||
expect(container.textContent).toContain("+new");
|
||||
});
|
||||
|
||||
it("renders durable resources as direct compact links", () => {
|
||||
render({
|
||||
id: "resource",
|
||||
kind: "protocol",
|
||||
surface: "resource",
|
||||
resourceKind: "document",
|
||||
title: "Implementation notes",
|
||||
subtitle: "markdown · ready",
|
||||
href: "/documents/notes",
|
||||
});
|
||||
|
||||
const link = container.querySelector<HTMLAnchorElement>('[data-testid="task-chat-protocol-activity-row"]');
|
||||
expect(link?.getAttribute("href")).toBe("/documents/notes");
|
||||
expect(link?.textContent).toContain("Added a document");
|
||||
expect(link?.textContent).toContain("Implementation notes");
|
||||
});
|
||||
|
||||
it("does not make an informational row focusable when it has no details", () => {
|
||||
render({
|
||||
id: "notice",
|
||||
kind: "protocol",
|
||||
surface: "provider_activity",
|
||||
family: "provider_notice",
|
||||
eventType: "provider.notice.recorded",
|
||||
status: "informational",
|
||||
title: "Provider notice",
|
||||
summary: "Retrying automatically",
|
||||
details: [],
|
||||
steps: [],
|
||||
links: [],
|
||||
children: [],
|
||||
});
|
||||
|
||||
const row = container.querySelector('[data-testid="task-chat-protocol-activity-row"]');
|
||||
expect(row?.querySelector("button")).toBeNull();
|
||||
expect(row?.querySelector('[aria-expanded]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { WorkspaceFileLink } from "@/components/WorkspaceFileLink";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
TaskChatProtocolItem,
|
||||
TaskChatProtocolStep,
|
||||
TaskChatProviderActivityItem,
|
||||
TaskChatWorkspaceChangeItem,
|
||||
TaskChatWorkspaceFileItem,
|
||||
} from "./task-chat-model";
|
||||
import {
|
||||
protocolActivityIsRunning,
|
||||
protocolActivityLabel,
|
||||
protocolActivityPresentation,
|
||||
} from "./task-chat-activity-presentation";
|
||||
|
||||
function StatusIcon({ status }: { status: "running" | "completed" | "failed" | "interrupted" | "informational" }) {
|
||||
if (status === "running") return <Loader2 className="h-3.5 w-3.5 animate-spin text-(--status-agent-running)" aria-hidden />;
|
||||
if (status === "failed" || status === "interrupted") return <X className="h-3.5 w-3.5 text-destructive" aria-hidden />;
|
||||
if (status === "completed") return <Check className="h-3.5 w-3.5 text-(--status-task-icon-done)" aria-hidden />;
|
||||
return <Circle className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />;
|
||||
}
|
||||
|
||||
function stepStatusIcon(status: TaskChatProtocolStep["status"]) {
|
||||
if (status === "in_progress") return <Loader2 className="h-3 w-3 animate-spin text-(--status-agent-running)" aria-hidden />;
|
||||
if (status === "completed") return <Check className="h-3 w-3 text-(--status-task-icon-done)" aria-hidden />;
|
||||
if (status === "blocked" || status === "failed") return <X className="h-3 w-3 text-destructive" aria-hidden />;
|
||||
return <Circle className="h-3 w-3 text-muted-foreground" aria-hidden />;
|
||||
}
|
||||
|
||||
function ProviderDetails({ item }: { item: TaskChatProviderActivityItem }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
{item.steps.length > 0 ? (
|
||||
<ol className="flex flex-col gap-1" aria-label="Plan steps">
|
||||
{item.steps.map((step) => (
|
||||
<li className="flex min-w-0 items-start gap-2" key={step.id}>
|
||||
<span className="mt-0.5 shrink-0">{stepStatusIcon(step.status)}</span>
|
||||
<span className={cn("min-w-0", step.status === "completed" && "text-muted-foreground line-through")}>{step.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : null}
|
||||
{item.links.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1.5" aria-label="Research sources">
|
||||
{item.links.map((link) => (
|
||||
<li key={link.href}>
|
||||
<a className="inline-flex min-w-0 items-center gap-1 font-medium text-primary hover:underline" href={link.href} target="_blank" rel="noreferrer">
|
||||
<span className="truncate">{link.label}</span>
|
||||
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden />
|
||||
</a>
|
||||
{link.description ? <p className="mt-0.5 text-muted-foreground">{link.description}</p> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{item.children.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1.5" aria-label="Delegated agents">
|
||||
{item.children.map((child) => (
|
||||
<li className="flex min-w-0 flex-col gap-0.5" key={child.id}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="font-medium text-foreground">{child.title}</span>
|
||||
<span className="capitalize text-muted-foreground">{child.status}</span>
|
||||
{child.metadata ? <span className="min-w-0 truncate font-mono text-(length:--text-micro)">{child.metadata}</span> : null}
|
||||
</span>
|
||||
{child.summary ? <span className="text-muted-foreground">{child.summary}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{item.details.length > 0 ? (
|
||||
<dl className="grid gap-x-3 gap-y-1 sm:grid-cols-(--gtc-2)">
|
||||
{item.details.map((detail) => (
|
||||
<div className="contents" key={`${detail.label}:${detail.value}`}>
|
||||
<dt className="text-muted-foreground">{detail.label}</dt>
|
||||
<dd className={cn("min-w-0 break-words text-foreground", detail.mono && "font-mono")}>{detail.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
{item.output ? (
|
||||
<pre className="max-h-(--sz-64) overflow-auto whitespace-pre-wrap rounded-sm bg-muted/50 p-2 font-mono text-(length:--text-micro) text-foreground">{item.output}</pre>
|
||||
) : null}
|
||||
{item.outputTruncated ? <p className="text-muted-foreground">Output truncated to 8 KiB.</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceChangeDetails({ item }: { item: TaskChatWorkspaceChangeItem }) {
|
||||
return item.files.length > 0 ? (
|
||||
<ul className="flex min-w-0 flex-col gap-2" aria-label="Changed files">
|
||||
{item.files.map((file) => (
|
||||
<li className="flex min-w-0 flex-col gap-1" key={`${file.operation}:${file.path}`}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-foreground">
|
||||
{file.previousPath ? `${file.previousPath} → ${file.path}` : file.path}
|
||||
</span>
|
||||
<span className="shrink-0 capitalize text-muted-foreground">{file.operation.replaceAll("_", " ")}</span>
|
||||
{file.additions != null || file.deletions != null ? (
|
||||
<span className="shrink-0 font-mono text-(length:--text-micro) text-muted-foreground">
|
||||
{file.additions == null ? "" : `+${file.additions}`} {file.deletions == null ? "" : `−${file.deletions}`}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{file.diff ? <pre className="max-h-(--sz-64) overflow-auto whitespace-pre-wrap rounded-sm bg-muted/50 p-2 font-mono text-(length:--text-micro) text-foreground">{file.diff}</pre> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <p className="text-muted-foreground">No changed-file details were reported.</p>;
|
||||
}
|
||||
|
||||
function WorkspaceFileDetails({ item }: { item: TaskChatWorkspaceFileItem }) {
|
||||
const workspaceFileRef = {
|
||||
path: item.path,
|
||||
resourceKind: "file" as const,
|
||||
line: item.line,
|
||||
column: null,
|
||||
raw: item.path,
|
||||
};
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<WorkspaceFileLink workspaceFileRef={workspaceFileRef} label={item.path} />
|
||||
{item.preview ? (
|
||||
item.presentation === "document" ? (
|
||||
<div className="max-h-(--sz-64) overflow-auto rounded-sm bg-muted/30 p-2 text-foreground"><MarkdownBody>{item.preview}</MarkdownBody></div>
|
||||
) : (
|
||||
<pre className="max-h-(--sz-64) overflow-auto whitespace-pre-wrap rounded-sm bg-muted/50 p-2 font-mono text-(length:--text-micro) text-foreground">{item.preview}</pre>
|
||||
)
|
||||
) : <p className="text-muted-foreground">Preview unavailable.</p>}
|
||||
{item.previewTruncated ? <p className="text-muted-foreground">Preview truncated by the runner.</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function detailContent(item: TaskChatProtocolItem): ReactNode | null {
|
||||
switch (item.surface) {
|
||||
case "provider_activity": {
|
||||
const expandable = item.details.length > 0 || item.steps.length > 0 || item.links.length > 0 || item.children.length > 0 || Boolean(item.output) || Boolean(item.outputTruncated);
|
||||
return expandable ? <ProviderDetails item={item} /> : null;
|
||||
}
|
||||
case "workspace_change": return <WorkspaceChangeDetails item={item} />;
|
||||
case "workspace_file": return <WorkspaceFileDetails item={item} />;
|
||||
case "resource":
|
||||
case "runtime_request":
|
||||
case "run_result":
|
||||
case "run_terminal":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function itemStatus(item: TaskChatProtocolItem): "running" | "completed" | "failed" | "interrupted" | "informational" {
|
||||
if (item.surface === "provider_activity") return item.status;
|
||||
if (item.surface === "workspace_change") return item.complete ? "completed" : "running";
|
||||
return "completed";
|
||||
}
|
||||
|
||||
export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const detailId = `task-chat-protocol-activity-${useId().replaceAll(":", "")}`;
|
||||
const presentation = protocolActivityPresentation(item);
|
||||
if (!presentation) return null;
|
||||
const detail = detailContent(item);
|
||||
const expandable = detail !== null;
|
||||
const Icon = presentation.icon;
|
||||
const label = protocolActivityLabel(item, presentation);
|
||||
const active = protocolActivityIsRunning(item);
|
||||
const status = itemStatus(item);
|
||||
const row = (
|
||||
<>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden data-testid="task-chat-protocol-activity-icon" />
|
||||
<span className={cn("shrink-0 font-medium", active && "shimmer-text shimmer-text-muted")}>{label}{active ? "…" : ""}</span>
|
||||
{presentation.detail ? <span className="min-w-0 truncate font-mono text-(length:--text-micro)">{presentation.detail}</span> : null}
|
||||
<span className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{expandable ? (
|
||||
<ChevronRight
|
||||
className={cn("tc-hover-disclosure-caret h-3 w-3 transition-[opacity,transform] group-hover/activity:opacity-80 group-focus-visible/activity:opacity-80", open ? "rotate-90 opacity-80" : "opacity-0")}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<StatusIcon status={status} />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (item.surface === "resource" && item.href) {
|
||||
return (
|
||||
<a className="group/activity -mx-1.5 flex min-h-6 min-w-0 items-center gap-2 rounded-sm px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground" href={item.href} data-testid="task-chat-protocol-activity-row">
|
||||
{row}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const activityRow = expandable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
aria-controls={detailId}
|
||||
className="group/activity -mx-1.5 flex min-h-6 min-w-0 items-center gap-2 rounded-sm px-1.5 py-1 text-left text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{row}
|
||||
</button>
|
||||
) : (
|
||||
<div className="group/activity -mx-1.5 flex min-h-6 min-w-0 items-center gap-2 rounded-sm px-1.5 py-1 text-muted-foreground">
|
||||
{row}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col text-xs" data-testid="task-chat-protocol-activity-row" data-activity-family={item.surface === "provider_activity" ? item.family : item.surface}>
|
||||
{activityRow}
|
||||
{open && detail ? (
|
||||
<div id={detailId} className="ml-2.5 mt-0.5 border-l-2 border-border py-1 pl-2.5" data-testid="task-chat-protocol-activity-detail">
|
||||
{detail}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { MemoryRouter } from "@/lib/router";
|
||||
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
|
||||
import type { TaskChatProtocolItem, TaskChatRuntimeRequestDecision } from "./task-chat-model";
|
||||
import type { TaskChatProtocolItem, TaskChatProviderActivityFamily, TaskChatRuntimeRequestDecision } from "./task-chat-model";
|
||||
|
||||
function renderCard(root: Root, item: TaskChatProtocolItem, onDecision?: (decision: TaskChatRuntimeRequestDecision) => void | Promise<void>) {
|
||||
flushSync(() => root.render(
|
||||
@@ -54,6 +54,31 @@ describe("TaskChatProtocolCard", () => {
|
||||
expect(container.textContent).toContain("Render widgets");
|
||||
});
|
||||
|
||||
it("renders a structured history card for every provider family", () => {
|
||||
const families = [
|
||||
"plan", "tool_execution", "research", "delegation", "model_identity", "context", "artifact",
|
||||
"review", "hook", "memory", "safety", "terminal", "wait", "provider_notice",
|
||||
] satisfies TaskChatProviderActivityFamily[];
|
||||
for (const family of families) {
|
||||
renderCard(root, {
|
||||
id: `provider-${family}`,
|
||||
kind: "protocol",
|
||||
surface: "provider_activity",
|
||||
family,
|
||||
eventType: `${family}.fixture`,
|
||||
status: "completed",
|
||||
title: `Visible ${family}`,
|
||||
summary: `Summary ${family}`,
|
||||
details: [{ label: "Reference", value: `${family}-1` }],
|
||||
links: [],
|
||||
children: [],
|
||||
steps: [],
|
||||
});
|
||||
expect(container.querySelector(`[data-testid="task-chat-provider-${family}"]`), family).not.toBeNull();
|
||||
expect(container.textContent, family).toContain(`Visible ${family}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens a workspace diff review dialog", async () => {
|
||||
renderCard(root, {
|
||||
id: "workspace",
|
||||
|
||||
@@ -4,8 +4,15 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { MemoryRouter } from "@/lib/router";
|
||||
import { TaskChatRunnerTurn } from "./TaskChatRunnerTurn";
|
||||
import type { TaskChatItem, TaskChatRuntimeRequestDecision, TaskChatRuntimeRequestItem } from "./task-chat-model";
|
||||
import type {
|
||||
TaskChatItem,
|
||||
TaskChatProviderActivityFamily,
|
||||
TaskChatProviderActivityItem,
|
||||
TaskChatRuntimeRequestDecision,
|
||||
TaskChatRuntimeRequestItem,
|
||||
} from "./task-chat-model";
|
||||
|
||||
describe("TaskChatRunnerTurn", () => {
|
||||
let container: HTMLDivElement;
|
||||
@@ -28,16 +35,21 @@ describe("TaskChatRunnerTurn", () => {
|
||||
runId = "run-1",
|
||||
onRuntimeRequestDecision?: (item: TaskChatRuntimeRequestItem, decision: TaskChatRuntimeRequestDecision) => void,
|
||||
) => act(() => root.render(
|
||||
<ThemeProvider>
|
||||
<TaskChatRunnerTurn runId={runId} agentName="Runner" items={items} status={status} startedAtMs={Date.now() - 2_000} toolSummary={null} onRuntimeRequestDecision={onRuntimeRequestDecision} />
|
||||
</ThemeProvider>,
|
||||
<MemoryRouter>
|
||||
<ThemeProvider>
|
||||
<TaskChatRunnerTurn runId={runId} agentName="Runner" items={items} status={status} startedAtMs={Date.now() - 2_000} onRuntimeRequestDecision={onRuntimeRequestDecision} />
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>,
|
||||
));
|
||||
|
||||
it("shows immediate Thinking before the first runner event", () => {
|
||||
render([], "queued");
|
||||
expect(container.textContent).toContain("Thinking");
|
||||
expect(container.textContent).toContain("Runner");
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"] svg')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-runner-disclosure-caret"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity-icon"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent).toBe("Thinking");
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.tagName).toBe("DIV");
|
||||
expect(container.textContent).not.toContain("Working");
|
||||
expect(container.textContent).not.toContain("Waiting for transcript");
|
||||
const identity = container.querySelector('[data-testid="task-chat-agent-identity"]');
|
||||
@@ -59,14 +71,141 @@ describe("TaskChatRunnerTurn", () => {
|
||||
expect(container.querySelector('[data-testid="task-chat-runner-identity-row"]')?.classList.contains("pt-2")).toBe(true);
|
||||
});
|
||||
|
||||
it("streams the final response in its durable slot and hides current activity", () => {
|
||||
it("shows canonical web research as the current Codex-style activity", () => {
|
||||
render([{
|
||||
id: "research-1",
|
||||
kind: "protocol",
|
||||
surface: "provider_activity",
|
||||
family: "research",
|
||||
eventType: "research.started",
|
||||
status: "running",
|
||||
title: "Research",
|
||||
summary: "site:openai.com model guide GPT-5.4",
|
||||
details: [{ label: "Query", value: "site:openai.com model guide GPT-5.4" }],
|
||||
steps: [],
|
||||
links: [],
|
||||
children: [],
|
||||
}]);
|
||||
|
||||
const activity = container.querySelector('[data-testid="task-chat-current-activity"]');
|
||||
expect(activity?.textContent).toContain("Searching the web");
|
||||
expect(activity?.textContent).toContain("site:openai.com model guide GPT-5.4");
|
||||
expect(activity?.getAttribute("data-activity-family")).toBe("research");
|
||||
});
|
||||
|
||||
it("has a purpose-built current-activity presentation for every provider family", () => {
|
||||
const cases: Array<{
|
||||
family: TaskChatProviderActivityFamily;
|
||||
eventType: string;
|
||||
status: TaskChatProviderActivityItem["status"];
|
||||
expected: string;
|
||||
details?: TaskChatProviderActivityItem["details"];
|
||||
}> = [
|
||||
{ family: "plan", eventType: "plan.updated", status: "running", expected: "Updating the plan" },
|
||||
{ family: "tool_execution", eventType: "tool.execution.started", status: "running", expected: "Running a tool" },
|
||||
{ family: "research", eventType: "research.started", status: "running", expected: "Searching the web", details: [{ label: "Action", value: "search" }] },
|
||||
{ family: "delegation", eventType: "delegation.started", status: "running", expected: "Starting a subagent", details: [{ label: "Action", value: "spawn" }] },
|
||||
{ family: "model_identity", eventType: "model.route.changed", status: "informational", expected: "Switched models" },
|
||||
{ family: "context", eventType: "context.compacted", status: "completed", expected: "Compacted context" },
|
||||
{ family: "artifact", eventType: "artifact.generated", status: "running", expected: "Generating an artifact" },
|
||||
{ family: "review", eventType: "review.mode.changed", status: "informational", expected: "Entered review mode", details: [{ label: "State", value: "entered" }] },
|
||||
{ family: "hook", eventType: "hook.started", status: "running", expected: "Running a hook" },
|
||||
{ family: "memory", eventType: "memory.citation.referenced", status: "informational", expected: "Referenced memory" },
|
||||
{ family: "safety", eventType: "safety.review.started", status: "running", expected: "Reviewing safety" },
|
||||
{ family: "terminal", eventType: "terminal.input.sent", status: "informational", expected: "Sent terminal input" },
|
||||
{ family: "wait", eventType: "wait.started", status: "running", expected: "Waiting" },
|
||||
{ family: "provider_notice", eventType: "provider.notice.recorded", status: "informational", expected: "Provider notice" },
|
||||
];
|
||||
|
||||
expect(cases.map((entry) => entry.family)).toEqual([
|
||||
"plan", "tool_execution", "research", "delegation", "model_identity", "context", "artifact",
|
||||
"review", "hook", "memory", "safety", "terminal", "wait", "provider_notice",
|
||||
] satisfies TaskChatProviderActivityFamily[]);
|
||||
|
||||
for (const entry of cases) {
|
||||
render([{
|
||||
id: `provider-${entry.family}`,
|
||||
kind: "protocol",
|
||||
surface: "provider_activity",
|
||||
family: entry.family,
|
||||
eventType: entry.eventType,
|
||||
status: entry.status,
|
||||
title: entry.family,
|
||||
details: entry.details ?? [],
|
||||
steps: [],
|
||||
links: [],
|
||||
children: [],
|
||||
}]);
|
||||
const activity = container.querySelector('[data-testid="task-chat-current-activity"]');
|
||||
expect(activity?.getAttribute("data-activity-family"), entry.family).toBe(entry.family);
|
||||
expect(activity?.textContent, entry.family).toContain(entry.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows provider failures and interruptions instead of a successful past-tense label", () => {
|
||||
const provider = (status: "failed" | "interrupted"): TaskChatProviderActivityItem => ({
|
||||
id: `research-${status}`,
|
||||
kind: "protocol",
|
||||
surface: "provider_activity",
|
||||
family: "research",
|
||||
eventType: "research.completed",
|
||||
status,
|
||||
title: "Research",
|
||||
details: [],
|
||||
steps: [],
|
||||
links: [],
|
||||
children: [],
|
||||
});
|
||||
render([provider("failed")]);
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent).toContain("Web search failed");
|
||||
render([provider("interrupted")]);
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent).toContain("Web search stopped");
|
||||
});
|
||||
|
||||
it("surfaces workspace changes and file references as current activity", () => {
|
||||
render([{
|
||||
id: "workspace-change",
|
||||
kind: "protocol",
|
||||
surface: "workspace_change",
|
||||
changeSetId: "change-1",
|
||||
revision: 1,
|
||||
source: "harness_reported",
|
||||
complete: false,
|
||||
files: [],
|
||||
totals: { files: 2, additions: 3, deletions: 1 },
|
||||
patchArtifactRef: null,
|
||||
}]);
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent).toContain("Editing files");
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent).toContain("2 files");
|
||||
|
||||
render([{
|
||||
id: "workspace-file",
|
||||
kind: "protocol",
|
||||
surface: "workspace_file",
|
||||
referenceId: "file-1",
|
||||
source: "runner_verified",
|
||||
path: "ui/src/App.tsx",
|
||||
displayName: "App.tsx",
|
||||
mediaType: "text/typescript",
|
||||
presentation: "code",
|
||||
line: 42,
|
||||
preview: null,
|
||||
previewTruncated: false,
|
||||
}]);
|
||||
const activity = container.querySelector('[data-testid="task-chat-current-activity"]');
|
||||
expect(activity?.textContent).toContain("Referenced a file");
|
||||
expect(activity?.textContent).toContain("ui/src/App.tsx:42");
|
||||
});
|
||||
|
||||
it("streams the final response in its durable slot and settles the disclosure", () => {
|
||||
render([
|
||||
{ id: "p1", kind: "message", author: "agent", text: "Checking.", interstitial: true, channel: "progress" },
|
||||
{ id: "f1", kind: "message", author: "agent", authorName: "Runner", text: "Completed successfully.", channel: "final", streaming: true },
|
||||
]);
|
||||
], "succeeded");
|
||||
expect(container.querySelector('[data-testid="task-chat-final-response"]')?.textContent).toContain("Completed successfully.");
|
||||
expect(container.querySelectorAll('[data-testid="task-chat-agent-avatar"]')).toHaveLength(1);
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent).toContain("Worked for");
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("keeps final text mounted through a transient replay gap", () => {
|
||||
@@ -101,7 +240,7 @@ describe("TaskChatRunnerTurn", () => {
|
||||
{ id: "complete", kind: "marker", variant: "turn_boundary", label: "Turn completed" },
|
||||
]);
|
||||
|
||||
expect(container.textContent).toContain("Paperclip_finish");
|
||||
expect(container.textContent).not.toContain("Paperclip_finish");
|
||||
expect(container.textContent).toContain("Interrupted");
|
||||
expect(container.textContent).not.toContain("Session started");
|
||||
expect(container.textContent).not.toContain("Turn started");
|
||||
@@ -133,4 +272,62 @@ describe("TaskChatRunnerTurn", () => {
|
||||
await act(async () => button?.click());
|
||||
expect(onDecision).toHaveBeenCalledWith(expect.objectContaining({ requestId: "request-1" }), { action: "accept" });
|
||||
});
|
||||
|
||||
it("expands live semantic activity from the current activity row", () => {
|
||||
render([
|
||||
{ id: "thinking-empty", kind: "thinking", lines: [], streaming: false, lifecycleOnly: true },
|
||||
{ id: "commentary", kind: "message", author: "agent", text: "Checking the current implementation.", interstitial: true, channel: "progress" },
|
||||
{ id: "tool", kind: "tool", name: "Read", rawName: "read_file", target: "ui/src/App.tsx", status: "completed", detail: "export function App() {}" },
|
||||
]);
|
||||
|
||||
const disclosure = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-current-activity"]');
|
||||
expect(disclosure?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(container.querySelector('[data-testid="task-chat-runner-disclosure-caret"]')).not.toBeNull();
|
||||
expect(container.querySelectorAll('[data-testid="task-chat-thinking"]')).toHaveLength(0);
|
||||
|
||||
act(() => disclosure?.click());
|
||||
|
||||
expect(disclosure?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(container.querySelector('[data-testid="task-chat-runner-activity-list"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-progress-update"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-activity-commentary"]')?.textContent).toContain("Checking the current implementation.");
|
||||
expect(container.textContent).toContain("ui/src/App.tsx");
|
||||
});
|
||||
|
||||
it("collapses an open activity list when the run becomes terminal", () => {
|
||||
const items: TaskChatItem[] = [{ id: "tool", kind: "tool", name: "Search", rawName: "web_search", target: "smoked pork shoulder", status: "in_progress" }];
|
||||
render(items);
|
||||
const disclosure = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-current-activity"]');
|
||||
act(() => disclosure?.click());
|
||||
expect(disclosure?.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
render([{ ...items[0], kind: "tool", status: "completed" } as TaskChatItem], "succeeded");
|
||||
|
||||
const settled = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-current-activity"]');
|
||||
expect(settled?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(settled?.textContent).toContain("Worked for");
|
||||
expect(container.querySelector(".tc-turn-fold")?.getAttribute("data-folded")).toBe("true");
|
||||
});
|
||||
|
||||
it("resets the disclosure choice when a different run takes over", () => {
|
||||
const items: TaskChatItem[] = [{ id: "tool", kind: "tool", name: "Read", rawName: "read_file", target: "ui/src/App.tsx", status: "completed" }];
|
||||
render(items, "running", "run-1");
|
||||
act(() => container.querySelector<HTMLButtonElement>('[data-testid="task-chat-current-activity"]')?.click());
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
render(items, "running", "run-2");
|
||||
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(container.querySelector(".tc-turn-fold")?.getAttribute("data-folded")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders a failed terminal disclosure without treating prose as status authority", () => {
|
||||
render([
|
||||
{ id: "final", kind: "message", author: "agent", text: "I finished the draft.", channel: "final" },
|
||||
{ id: "tool", kind: "tool", name: "Command", rawName: "bash", target: "pnpm test", status: "failed" },
|
||||
], "failed");
|
||||
|
||||
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent).toContain("Stopped after");
|
||||
expect(container.querySelector('[data-testid="task-chat-final-response"]')?.textContent).toContain("I finished the draft.");
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,32 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useId, useRef, useState, type ComponentType, type SVGProps } from "react";
|
||||
import { Check, ChevronRight, OctagonX, X } from "lucide-react";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { useSecondTick } from "@/hooks/useSecondTick";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskChatItem, TaskChatMessageItem, TaskChatRuntimeRequestDecision, TaskChatRuntimeRequestItem, TaskChatThinkingItem, TaskChatToolItem } from "./task-chat-model";
|
||||
import type {
|
||||
TaskChatItem,
|
||||
TaskChatMessageItem,
|
||||
TaskChatMarkerItem,
|
||||
TaskChatProtocolItem,
|
||||
TaskChatRuntimeRequestDecision,
|
||||
TaskChatRuntimeRequestItem,
|
||||
TaskChatThinkingItem,
|
||||
TaskChatToolItem,
|
||||
} from "./task-chat-model";
|
||||
import { TaskChatAgentIdentity } from "./TaskChatBubble";
|
||||
import { TaskChatLiveRunPill } from "./TaskChatLiveRunPill";
|
||||
import { TaskChatLiveTail } from "./TaskChatLiveTail";
|
||||
import { TaskChatProtocolActivityRow } from "./TaskChatProtocolActivityRow";
|
||||
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
|
||||
import { paperclipRunnerHistoryItems } from "./transcript-adapter";
|
||||
import { TaskChatThinking } from "./TaskChatThinking";
|
||||
import { TaskChatToolCard } from "./TaskChatToolCard";
|
||||
import {
|
||||
protocolActivityIsRunning,
|
||||
protocolActivityLabel,
|
||||
protocolActivityPresentation,
|
||||
} from "./task-chat-activity-presentation";
|
||||
import {
|
||||
isTerminalRunStatus,
|
||||
paperclipRunnerActivityItems,
|
||||
} from "./transcript-adapter";
|
||||
import { toolTaxonomy } from "./tool-taxonomy";
|
||||
|
||||
function lastOf<T extends TaskChatItem>(items: readonly TaskChatItem[], predicate: (item: TaskChatItem) => item is T): T | undefined {
|
||||
@@ -17,26 +37,173 @@ function lastOf<T extends TaskChatItem>(items: readonly TaskChatItem[], predicat
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function CurrentActivity({ items }: { items: readonly TaskChatItem[] }) {
|
||||
const activity = lastOf<TaskChatThinkingItem | TaskChatToolItem>(
|
||||
items,
|
||||
(item): item is TaskChatThinkingItem | TaskChatToolItem => item.kind === "thinking" || item.kind === "tool",
|
||||
);
|
||||
if (!activity || activity.kind === "thinking") {
|
||||
return (
|
||||
<div className="px-1 py-1.5 text-sm text-muted-foreground" data-testid="task-chat-current-activity">
|
||||
<span className="shimmer-text shimmer-text-muted">Thinking</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const taxonomy = toolTaxonomy(activity.rawName ?? activity.name);
|
||||
const Icon = taxonomy.icon;
|
||||
const active = activity.status === "pending" || activity.status === "in_progress";
|
||||
function isHeadlineProtocolActivity(item: TaskChatItem): item is TaskChatProtocolItem {
|
||||
return item.kind === "protocol" && protocolActivityPresentation(item) !== null;
|
||||
}
|
||||
|
||||
function formatCompactDuration(ms: number | null): string | null {
|
||||
if (ms == null || !Number.isFinite(ms)) return null;
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
function terminalStatusFailed(status: string): boolean {
|
||||
return status === "failed"
|
||||
|| status === "cancelled"
|
||||
|| status === "timed_out"
|
||||
|| status === "interrupted";
|
||||
}
|
||||
|
||||
function RunnerActivityTimeline({ items }: { items: readonly TaskChatItem[] }) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 px-1 py-1.5 text-sm text-muted-foreground" data-testid="task-chat-current-activity">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className={cn("shrink-0", active && "shimmer-text shimmer-text-muted")}>{taxonomy.verbLabel}</span>
|
||||
{activity.target ? <span className="min-w-0 truncate font-mono text-xs">{activity.target}</span> : null}
|
||||
<div className="relative ml-4 min-w-0 pl-3">
|
||||
<span className="absolute inset-y-1 left-0 w-px bg-border/70" aria-hidden data-testid="task-chat-runner-activity-rail" />
|
||||
<ol className="flex min-w-0 flex-col gap-2 py-1" aria-label="Run activity" data-testid="task-chat-runner-activity-list">
|
||||
{items.map((item) => (
|
||||
<li className="min-w-0" key={item.id} data-activity-item-id={item.id}>
|
||||
{item.kind === "message" ? (
|
||||
<div className="tc-enter-cot-line min-w-0 px-1 text-sm text-foreground/90" data-testid="task-chat-activity-commentary">
|
||||
<MarkdownBody softBreaks linkIssueReferences>{item.text}</MarkdownBody>
|
||||
</div>
|
||||
) : item.kind === "thinking" ? (
|
||||
<TaskChatThinking item={item} defaultOpen={false} rowClassName="px-0" />
|
||||
) : item.kind === "tool" ? (
|
||||
<TaskChatToolCard item={item} />
|
||||
) : item.kind === "marker" ? (
|
||||
<RunnerActivityMarker item={item} />
|
||||
) : item.kind === "protocol" ? (
|
||||
<TaskChatProtocolActivityRow item={item} />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunnerActivityMarker({ item }: { item: TaskChatMarkerItem }) {
|
||||
return (
|
||||
<div className="flex min-h-6 min-w-0 items-center gap-2 py-1 text-xs text-destructive">
|
||||
<OctagonX className="h-3.5 w-3.5 shrink-0" aria-hidden data-testid="task-chat-marker-icon" />
|
||||
<span className="shrink-0 font-medium">{item.label}</span>
|
||||
{item.detail ? <span className="min-w-0 truncate text-muted-foreground">{item.detail}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunnerActivityDisclosure({
|
||||
items,
|
||||
status,
|
||||
startedAtMs,
|
||||
finishedAtMs,
|
||||
open,
|
||||
onToggle,
|
||||
controlsId,
|
||||
}: {
|
||||
items: readonly TaskChatItem[];
|
||||
status: string;
|
||||
startedAtMs: number | null;
|
||||
finishedAtMs?: number | null;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
controlsId: string;
|
||||
}) {
|
||||
const terminal = isTerminalRunStatus(status);
|
||||
useSecondTick(!terminal && startedAtMs != null);
|
||||
const elapsedMs = startedAtMs == null
|
||||
? null
|
||||
: Math.max(0, (terminal ? finishedAtMs ?? Date.now() : Date.now()) - startedAtMs);
|
||||
const elapsed = formatCompactDuration(elapsedMs);
|
||||
const activity = lastOf<TaskChatThinkingItem | TaskChatToolItem | TaskChatProtocolItem>(
|
||||
items,
|
||||
(item): item is TaskChatThinkingItem | TaskChatToolItem | TaskChatProtocolItem =>
|
||||
item.kind === "thinking" || item.kind === "tool" || isHeadlineProtocolActivity(item),
|
||||
);
|
||||
|
||||
let Icon: ComponentType<SVGProps<SVGSVGElement>> | null = null;
|
||||
let label = "Thinking";
|
||||
let detail: string | undefined;
|
||||
let active = !terminal;
|
||||
let family: string | undefined;
|
||||
if (terminal) {
|
||||
const failed = terminalStatusFailed(status);
|
||||
Icon = failed ? X : Check;
|
||||
label = failed ? "Stopped" : "Worked";
|
||||
active = false;
|
||||
} else if (activity?.kind === "tool") {
|
||||
const taxonomy = toolTaxonomy(activity.rawName ?? activity.name);
|
||||
Icon = taxonomy.icon;
|
||||
label = taxonomy.verbLabel;
|
||||
detail = activity.target;
|
||||
active = activity.status === "pending" || activity.status === "in_progress";
|
||||
} else if (activity?.kind === "protocol") {
|
||||
const presentation = protocolActivityPresentation(activity);
|
||||
if (presentation) {
|
||||
Icon = presentation.icon;
|
||||
label = protocolActivityLabel(activity, presentation);
|
||||
detail = presentation.detail;
|
||||
active = protocolActivityIsRunning(activity);
|
||||
family = activity.surface === "provider_activity" ? activity.family : activity.surface;
|
||||
}
|
||||
}
|
||||
|
||||
const semanticLabel = terminal && elapsed
|
||||
? `${label} ${terminalStatusFailed(status) ? "after" : "for"} ${elapsed}`
|
||||
: label;
|
||||
const showLiveEllipsis = active && Icon !== null;
|
||||
const expandable = items.length > 0;
|
||||
const content = (
|
||||
<>
|
||||
{Icon ? (
|
||||
<Icon className={cn("h-3.5 w-3.5 shrink-0", active && "text-(--status-agent-running)")} aria-hidden data-testid="task-chat-current-activity-icon" />
|
||||
) : (
|
||||
<span className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
)}
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span
|
||||
className={cn("shrink-0 font-medium", active && "shimmer-text shimmer-text-muted")}
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
data-testid="task-chat-current-activity-label"
|
||||
>
|
||||
{semanticLabel}{showLiveEllipsis ? "…" : ""}
|
||||
</span>
|
||||
{!terminal && elapsed ? <span className="shrink-0 font-mono text-(length:--text-micro)" aria-hidden>· {elapsed}</span> : null}
|
||||
{detail ? <span className="min-w-0 truncate font-mono text-(length:--text-micro)">{detail}</span> : null}
|
||||
</span>
|
||||
{expandable ? (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"tc-hover-disclosure-caret h-3.5 w-3.5 shrink-0 transition-[opacity,transform] group-hover/disclosure:opacity-80 group-focus-visible/disclosure:opacity-80",
|
||||
open ? "rotate-90 opacity-80" : "opacity-0",
|
||||
)}
|
||||
aria-hidden
|
||||
data-testid="task-chat-runner-disclosure-caret"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return expandable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={controlsId}
|
||||
aria-label={`${open ? "Collapse" : "Expand"} run activity: ${semanticLabel}`}
|
||||
className="group/disclosure flex min-h-8 w-full min-w-0 items-center gap-2 rounded-sm py-1.5 pl-7 pr-1.5 text-left text-sm text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:bg-muted/60 focus-visible:text-foreground focus-visible:outline-none"
|
||||
data-testid="task-chat-current-activity"
|
||||
data-activity-family={family}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex min-h-8 min-w-0 items-center gap-2 py-1.5 pl-7 pr-1 text-sm text-muted-foreground" data-testid="task-chat-current-activity" data-activity-family={family}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +216,6 @@ export function TaskChatRunnerTurn({
|
||||
status,
|
||||
startedAtMs,
|
||||
finishedAtMs,
|
||||
toolSummary,
|
||||
onRuntimeRequestDecision,
|
||||
}: {
|
||||
/** Stable identity used to clear replay-latched final text for the next turn. */
|
||||
@@ -60,88 +226,72 @@ export function TaskChatRunnerTurn({
|
||||
status: string;
|
||||
startedAtMs: number | null;
|
||||
finishedAtMs?: number | null;
|
||||
toolSummary: string | null;
|
||||
onRuntimeRequestDecision?: (
|
||||
item: TaskChatRuntimeRequestItem,
|
||||
decision: TaskChatRuntimeRequestDecision,
|
||||
) => void | Promise<void>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const historyItems = paperclipRunnerHistoryItems(items);
|
||||
const terminal = isTerminalRunStatus(status);
|
||||
const [disclosure, setDisclosure] = useState({ runId, terminal, open: false });
|
||||
if (disclosure.runId !== runId || disclosure.terminal !== terminal) {
|
||||
setDisclosure({ runId, terminal, open: terminal ? false : disclosure.runId === runId && disclosure.open });
|
||||
}
|
||||
const open = disclosure.runId === runId && disclosure.terminal === terminal ? disclosure.open : false;
|
||||
const controlsId = `task-chat-run-activity-${useId().replaceAll(":", "")}`;
|
||||
const activityItems = paperclipRunnerActivityItems(items);
|
||||
const pendingRuntimeRequest = lastOf<TaskChatRuntimeRequestItem>(
|
||||
historyItems,
|
||||
items,
|
||||
(item): item is TaskChatRuntimeRequestItem => item.kind === "protocol"
|
||||
&& item.surface === "runtime_request"
|
||||
&& item.status === "pending",
|
||||
);
|
||||
const visibleHistoryItems = pendingRuntimeRequest
|
||||
? historyItems.filter((item) => item.id !== pendingRuntimeRequest.id)
|
||||
: historyItems;
|
||||
const progress = lastOf<TaskChatMessageItem>(items, (item): item is TaskChatMessageItem => item.kind === "message" && Boolean(item.interstitial));
|
||||
const progress = lastOf<TaskChatMessageItem>(activityItems, (item): item is TaskChatMessageItem => item.kind === "message");
|
||||
const observedFinal = lastOf<TaskChatMessageItem>(items, (item): item is TaskChatMessageItem => item.kind === "message" && item.channel === "final");
|
||||
// A reconnect/replay can briefly rebuild the transcript without the final
|
||||
// item (or with an earlier, shorter prefix). Once durable answer text is on
|
||||
// screen it must be monotonic for the lifetime of this mounted turn.
|
||||
const finalRef = useRef<{ runId?: string | null; item?: TaskChatMessageItem }>({ runId });
|
||||
if (finalRef.current.runId !== runId) {
|
||||
finalRef.current = { runId };
|
||||
}
|
||||
if (finalRef.current.runId !== runId) finalRef.current = { runId };
|
||||
if (observedFinal && (!finalRef.current.item || observedFinal.text.length >= finalRef.current.item.text.length)) {
|
||||
finalRef.current.item = observedFinal;
|
||||
}
|
||||
const final = finalRef.current.item;
|
||||
|
||||
if (status === "queued") {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 py-1" data-testid="task-chat-runner-turn" data-phase="startup">
|
||||
{agentName ? <TaskChatAgentIdentity agentName={agentName} agentIcon={agentIcon} /> : null}
|
||||
<CurrentActivity items={items} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col" data-testid="task-chat-runner-turn">
|
||||
<TaskChatLiveRunPill
|
||||
status={status}
|
||||
startedAtMs={startedAtMs}
|
||||
finishedAtMs={finishedAtMs}
|
||||
toolSummary={toolSummary}
|
||||
runnerStyle
|
||||
expanded={open}
|
||||
onToggle={() => setOpen((value) => !value)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col" data-testid="task-chat-runner-turn" data-phase={status === "queued" ? "startup" : undefined}>
|
||||
{agentName ? (
|
||||
<div className="pt-2" data-testid="task-chat-runner-identity-row">
|
||||
<div className={status === "queued" ? "pb-1" : "pb-1 pt-2"} data-testid="task-chat-runner-identity-row">
|
||||
<TaskChatAgentIdentity agentName={agentName} agentIcon={agentIcon} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="tc-turn-fold" data-folded={open ? "false" : "true"} aria-hidden={!open}>
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
<TaskChatLiveTail
|
||||
items={visibleHistoryItems}
|
||||
excludeFinal
|
||||
onRuntimeRequestDecision={onRuntimeRequestDecision}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!open && progress ? (
|
||||
{!open && progress && !final && !terminal ? (
|
||||
<div className="flex min-w-0 flex-col py-1">
|
||||
<div className="tc-enter-cot-line min-w-0 px-1 py-1.5 text-sm text-foreground/90" data-testid="task-chat-progress-update">
|
||||
<MarkdownBody softBreaks linkIssueReferences>{progress.text}</MarkdownBody>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<RunnerActivityDisclosure
|
||||
items={activityItems}
|
||||
status={status}
|
||||
startedAtMs={startedAtMs}
|
||||
finishedAtMs={finishedAtMs}
|
||||
open={open}
|
||||
onToggle={() => setDisclosure((current) => ({ ...current, open: !current.open }))}
|
||||
controlsId={controlsId}
|
||||
/>
|
||||
<div id={controlsId} className="tc-turn-fold" data-folded={open ? "false" : "true"} aria-hidden={!open}>
|
||||
<div>
|
||||
<div className="py-1">
|
||||
<RunnerActivityTimeline items={activityItems} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{pendingRuntimeRequest ? (
|
||||
<div className="py-1">
|
||||
<TaskChatProtocolCard
|
||||
item={pendingRuntimeRequest}
|
||||
onRuntimeRequestDecision={onRuntimeRequestDecision}
|
||||
/>
|
||||
<TaskChatProtocolCard item={pendingRuntimeRequest} onRuntimeRequestDecision={onRuntimeRequestDecision} />
|
||||
</div>
|
||||
) : !open && !final ? <CurrentActivity items={items} /> : null}
|
||||
) : null}
|
||||
{final ? (
|
||||
<div className="tc-enter-bubble w-full" data-testid="task-chat-final-response">
|
||||
<div className="break-words px-1 py-2 text-sm text-foreground" data-testid="task-chat-agent-bubble">
|
||||
|
||||
@@ -5,12 +5,32 @@ import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import type { TaskChatThinkingItem } from "./task-chat-model";
|
||||
|
||||
/** Provider-authored reasoning summaries and deltas; never reconstructed hidden reasoning. */
|
||||
export function TaskChatThinking({ item }: { item: TaskChatThinkingItem }) {
|
||||
const [open, setOpen] = useState(item.streaming || !item.collapsed);
|
||||
export function TaskChatThinking({
|
||||
item,
|
||||
defaultOpen,
|
||||
rowClassName,
|
||||
}: {
|
||||
item: TaskChatThinkingItem;
|
||||
/** Activity timelines keep reasoning as one compact row until requested. */
|
||||
defaultOpen?: boolean;
|
||||
/** Optional alignment override for a containing activity rail. */
|
||||
rowClassName?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen ?? (item.streaming || !item.collapsed));
|
||||
const body = item.lines.join("\n").trim();
|
||||
if (!body) return null;
|
||||
const baseLabel = item.channel === "detail" ? "Reasoning detail" : "Reasoning";
|
||||
const label = item.streaming ? `${baseLabel}…` : item.summaryLabel ?? baseLabel;
|
||||
const label = item.streaming
|
||||
? body ? `${baseLabel}…` : "Thinking…"
|
||||
: item.summaryLabel ?? (body ? baseLabel : "Thought");
|
||||
|
||||
if (!body) {
|
||||
return (
|
||||
<div className={cn("flex min-w-0 items-center gap-1.5 px-1 py-0.5 text-xs text-muted-foreground", rowClassName)} data-testid="task-chat-thinking">
|
||||
<Brain className={cn("h-3.5 w-3.5 shrink-0", item.streaming && "text-(--status-agent-running)")} aria-hidden data-testid="task-chat-thinking-icon" />
|
||||
<span className={cn(item.streaming && "shimmer-text shimmer-text-muted")}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col text-xs" data-testid="task-chat-thinking">
|
||||
@@ -18,11 +38,11 @@ export function TaskChatThinking({ item }: { item: TaskChatThinkingItem }) {
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="group flex min-w-0 items-center gap-1.5 rounded-sm px-1 py-0.5 text-left text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className={cn("group/thinking flex min-w-0 items-center gap-1.5 rounded-sm px-1 py-0.5 text-left text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", rowClassName)}
|
||||
>
|
||||
<Brain className={cn("h-3.5 w-3.5 shrink-0", item.streaming && "text-(--status-agent-running)")} aria-hidden />
|
||||
<Brain className={cn("h-3.5 w-3.5 shrink-0", item.streaming && "text-(--status-agent-running)")} aria-hidden data-testid="task-chat-thinking-icon" />
|
||||
<span>{label}</span>
|
||||
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open && "rotate-90")} aria-hidden />
|
||||
<ChevronRight className={cn("tc-hover-disclosure-caret h-3 w-3 shrink-0 transition-[opacity,transform] group-hover/thinking:opacity-80 group-focus-visible/thinking:opacity-80", open ? "rotate-90 opacity-80" : "opacity-0")} aria-hidden />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="ml-2.5 mt-1 border-l-2 border-border pl-2.5 text-muted-foreground">
|
||||
|
||||
@@ -41,7 +41,7 @@ export function TaskChatToolCard({ item }: { item: TaskChatToolItem }) {
|
||||
expandable ? "cursor-pointer transition-colors hover:bg-muted/60 hover:text-foreground" : "cursor-default",
|
||||
)}
|
||||
>
|
||||
<RowIcon className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<RowIcon className="h-3.5 w-3.5 shrink-0" aria-hidden data-testid="task-chat-tool-icon" />
|
||||
<span className="shrink-0 font-medium leading-none">{item.name}</span>
|
||||
{item.target ? (
|
||||
<span className="min-w-0 truncate font-mono text-(length:--text-micro) leading-none">{item.target}</span>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
BookOpen,
|
||||
Bot,
|
||||
Box,
|
||||
Clock3,
|
||||
Database,
|
||||
FileCheck2,
|
||||
FilePenLine,
|
||||
FileText,
|
||||
GitBranch,
|
||||
ListChecks,
|
||||
PackageCheck,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
TerminalSquare,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
TaskChatMaterializedResourceItem,
|
||||
TaskChatProtocolItem,
|
||||
TaskChatProviderActivityItem,
|
||||
TaskChatWorkspaceChangeItem,
|
||||
TaskChatWorkspaceFileItem,
|
||||
} from "./task-chat-model";
|
||||
|
||||
export interface TaskChatActivityPresentation {
|
||||
icon: LucideIcon;
|
||||
runningLabel: string;
|
||||
completedLabel: string;
|
||||
failedLabel?: string;
|
||||
interruptedLabel?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
function providerDetail(item: TaskChatProviderActivityItem, ...labels: string[]): string | undefined {
|
||||
return item.details.find((entry) => labels.includes(entry.label))?.value;
|
||||
}
|
||||
|
||||
export function providerActivityPresentation(item: TaskChatProviderActivityItem): TaskChatActivityPresentation {
|
||||
const detail = item.summary
|
||||
?? providerDetail(item, "Query", "URL", "Target", "Name", "Reference", "Reason", "Summary");
|
||||
switch (item.family) {
|
||||
case "research": {
|
||||
const action = providerDetail(item, "Action")?.toLowerCase();
|
||||
if (action === "open_page") {
|
||||
return { icon: Search, runningLabel: "Opening a web page", completedLabel: "Opened a web page", failedLabel: "Couldn’t open the web page", interruptedLabel: "Stopped opening the web page", detail };
|
||||
}
|
||||
if (action === "find_in_page") {
|
||||
return { icon: Search, runningLabel: "Searching the page", completedLabel: "Searched the page", failedLabel: "Page search failed", interruptedLabel: "Page search stopped", detail };
|
||||
}
|
||||
return { icon: Search, runningLabel: "Searching the web", completedLabel: "Searched the web", failedLabel: "Web search failed", interruptedLabel: "Web search stopped", detail };
|
||||
}
|
||||
case "tool_execution":
|
||||
return { icon: TerminalSquare, runningLabel: "Running a tool", completedLabel: "Ran a tool", failedLabel: "Tool failed", interruptedLabel: "Tool stopped", detail };
|
||||
case "plan":
|
||||
return { icon: ListChecks, runningLabel: "Updating the plan", completedLabel: "Updated the plan", failedLabel: "Plan update failed", interruptedLabel: "Plan update stopped", detail };
|
||||
case "delegation": {
|
||||
const action = providerDetail(item, "Action")?.toLowerCase();
|
||||
if (action === "message") return { icon: Users, runningLabel: "Messaging a subagent", completedLabel: "Messaged a subagent", failedLabel: "Subagent message failed", interruptedLabel: "Subagent message stopped", detail };
|
||||
if (action === "resume") return { icon: Users, runningLabel: "Resuming a subagent", completedLabel: "Resumed a subagent", failedLabel: "Couldn’t resume the subagent", interruptedLabel: "Subagent resume stopped", detail };
|
||||
if (action === "close") return { icon: Users, runningLabel: "Closing a subagent", completedLabel: "Closed a subagent", failedLabel: "Couldn’t close the subagent", interruptedLabel: "Subagent close stopped", detail };
|
||||
if (action === "wait") return { icon: Users, runningLabel: "Waiting for subagents", completedLabel: "Finished waiting for subagents", failedLabel: "Subagent wait failed", interruptedLabel: "Stopped waiting for subagents", detail };
|
||||
return { icon: Users, runningLabel: "Starting a subagent", completedLabel: "Started a subagent", failedLabel: "Subagent start failed", interruptedLabel: "Subagent start stopped", detail };
|
||||
}
|
||||
case "model_identity":
|
||||
return item.eventType === "model.route.changed"
|
||||
? { icon: Bot, runningLabel: "Switching models", completedLabel: "Switched models", failedLabel: "Model switch failed", interruptedLabel: "Model switch stopped", detail }
|
||||
: { icon: Bot, runningLabel: "Verifying the model", completedLabel: "Verified the model", failedLabel: "Model verification failed", interruptedLabel: "Model verification stopped", detail };
|
||||
case "context":
|
||||
return { icon: Database, runningLabel: "Compacting context", completedLabel: "Compacted context", failedLabel: "Context compaction failed", interruptedLabel: "Context compaction stopped", detail };
|
||||
case "artifact": {
|
||||
const viewed = item.eventType === "artifact.viewed";
|
||||
return viewed
|
||||
? { icon: Box, runningLabel: "Viewing an artifact", completedLabel: "Viewed an artifact", failedLabel: "Couldn’t view the artifact", interruptedLabel: "Artifact view stopped", detail }
|
||||
: { icon: Box, runningLabel: "Generating an artifact", completedLabel: "Generated an artifact", failedLabel: "Artifact generation failed", interruptedLabel: "Artifact generation stopped", detail };
|
||||
}
|
||||
case "review": {
|
||||
const state = providerDetail(item, "State")?.toLowerCase();
|
||||
return state === "exited"
|
||||
? { icon: FileCheck2, runningLabel: "Leaving review mode", completedLabel: "Left review mode", failedLabel: "Couldn’t leave review mode", interruptedLabel: "Review-mode change stopped", detail }
|
||||
: { icon: FileCheck2, runningLabel: "Entering review mode", completedLabel: "Entered review mode", failedLabel: "Couldn’t enter review mode", interruptedLabel: "Review-mode change stopped", detail };
|
||||
}
|
||||
case "hook":
|
||||
return { icon: GitBranch, runningLabel: "Running a hook", completedLabel: "Ran a hook", failedLabel: "Hook failed", interruptedLabel: "Hook stopped", detail };
|
||||
case "memory":
|
||||
return { icon: BookOpen, runningLabel: "Checking memory", completedLabel: "Referenced memory", failedLabel: "Memory lookup failed", interruptedLabel: "Memory lookup stopped", detail };
|
||||
case "safety":
|
||||
return { icon: ShieldCheck, runningLabel: "Reviewing safety", completedLabel: "Reviewed safety", failedLabel: "Safety review failed", interruptedLabel: "Safety review stopped", detail };
|
||||
case "terminal":
|
||||
return { icon: TerminalSquare, runningLabel: "Sending terminal input", completedLabel: "Sent terminal input", failedLabel: "Terminal input failed", interruptedLabel: "Terminal input stopped", detail };
|
||||
case "wait":
|
||||
return { icon: Clock3, runningLabel: "Waiting", completedLabel: "Finished waiting", failedLabel: "Wait failed", interruptedLabel: "Wait stopped", detail };
|
||||
case "provider_notice":
|
||||
return { icon: AlertTriangle, runningLabel: "Provider notice", completedLabel: "Provider notice", failedLabel: "Provider error", interruptedLabel: "Provider notice", detail };
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceChangePresentation(item: TaskChatWorkspaceChangeItem): TaskChatActivityPresentation {
|
||||
const count = item.totals.files || item.files.length;
|
||||
const detail = count > 0 ? `${count} ${count === 1 ? "file" : "files"}` : undefined;
|
||||
return { icon: FilePenLine, runningLabel: "Editing files", completedLabel: "Edited files", detail };
|
||||
}
|
||||
|
||||
function workspaceFilePresentation(item: TaskChatWorkspaceFileItem): TaskChatActivityPresentation {
|
||||
return {
|
||||
icon: FileText,
|
||||
runningLabel: "Referencing a file",
|
||||
completedLabel: "Referenced a file",
|
||||
detail: item.line == null ? item.path : `${item.path}:${item.line}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resourcePresentation(item: TaskChatMaterializedResourceItem): TaskChatActivityPresentation {
|
||||
return {
|
||||
icon: item.resourceKind === "document" ? FileText : PackageCheck,
|
||||
runningLabel: "Saving a resource",
|
||||
completedLabel: item.resourceKind === "document" ? "Added a document" : "Added a deliverable",
|
||||
detail: item.title,
|
||||
};
|
||||
}
|
||||
|
||||
export function protocolActivityPresentation(item: TaskChatProtocolItem): TaskChatActivityPresentation | null {
|
||||
switch (item.surface) {
|
||||
case "provider_activity": return providerActivityPresentation(item);
|
||||
case "workspace_change": return workspaceChangePresentation(item);
|
||||
case "workspace_file": return workspaceFilePresentation(item);
|
||||
case "resource": return resourcePresentation(item);
|
||||
case "runtime_request":
|
||||
case "run_result":
|
||||
case "run_terminal":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function protocolActivityIsRunning(item: TaskChatProtocolItem): boolean {
|
||||
if (item.surface === "provider_activity") return item.status === "running";
|
||||
if (item.surface === "workspace_change") return !item.complete;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function protocolActivityLabel(item: TaskChatProtocolItem, presentation: TaskChatActivityPresentation): string {
|
||||
if (item.surface !== "provider_activity") {
|
||||
return protocolActivityIsRunning(item) ? presentation.runningLabel : presentation.completedLabel;
|
||||
}
|
||||
if (item.status === "failed") return presentation.failedLabel ?? `${presentation.completedLabel} · failed`;
|
||||
if (item.status === "interrupted") return presentation.interruptedLabel ?? `${presentation.completedLabel} · interrupted`;
|
||||
return item.status === "running" ? presentation.runningLabel : presentation.completedLabel;
|
||||
}
|
||||
@@ -137,6 +137,8 @@ export interface TaskChatThinkingItem {
|
||||
summaryLabel?: string;
|
||||
/** Provider-emitted reasoning surface; never synthesized by the UI. */
|
||||
channel?: "summary" | "detail" | "unknown";
|
||||
/** A real provider reasoning lifecycle with no provider-authored text. */
|
||||
lifecycleOnly?: boolean;
|
||||
}
|
||||
|
||||
/** A tool invocation row (ACP tool_call / tool_call_update). */
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranscriptEntry } from "@/adapters";
|
||||
import {
|
||||
assembleThreadItems,
|
||||
attachSettledTurns,
|
||||
buildActivityPhases,
|
||||
buildMergedTurnSummary,
|
||||
buildTurnSummary,
|
||||
coalesceSettledTurns,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
flattenSelfTalk,
|
||||
isNestableLiveChild,
|
||||
ISSUE_BRIEF_ITEM_ID,
|
||||
paperclipRunnerActivityItems,
|
||||
paperclipRunnerHistoryItems,
|
||||
prependIssueBrief,
|
||||
settledRunChildren,
|
||||
@@ -200,6 +202,102 @@ describe("transcriptToTaskChatItems protocol surfaces", () => {
|
||||
expect(file && file.surface === "workspace_file" ? file.preview?.length : 0).toBe(8 * 1024);
|
||||
expect(file && file.surface === "workspace_file" ? file.previewTruncated : false).toBe(true);
|
||||
});
|
||||
|
||||
it("turns canonical research sources into safe links and exposes notice metadata", () => {
|
||||
const items = transcriptToTaskChatItems([
|
||||
{ kind: "provider_activity", ts: TS, family: "research", eventType: "research.completed", status: "completed", title: "Research", summary: "PRP", payload: { researchId: "research-1", sources: [{ title: "Protocol notes", url: "https://example.com/prp", snippet: "Canonical event details" }, { title: "Unsafe", url: "file:///etc/passwd" }] } },
|
||||
{ kind: "provider_activity", ts: TS, family: "provider_notice", eventType: "provider.notice.recorded", status: "informational", title: "Provider notice", summary: "Update configuration", payload: { noticeId: "notice-1", severity: "warning", category: "config", scope: "environment", recoverable: true, userActionable: false } },
|
||||
], opts);
|
||||
const research = items.find((item) => item.kind === "protocol" && item.surface === "provider_activity" && item.family === "research");
|
||||
const notice = items.find((item) => item.kind === "protocol" && item.surface === "provider_activity" && item.family === "provider_notice");
|
||||
expect(research?.kind === "protocol" && research.surface === "provider_activity" ? research.links : []).toEqual([{
|
||||
label: "Protocol notes",
|
||||
href: "https://example.com/prp",
|
||||
description: "Canonical event details",
|
||||
}]);
|
||||
expect(notice?.kind === "protocol" && notice.surface === "provider_activity" ? notice.details : []).toEqual(expect.arrayContaining([
|
||||
{ label: "Severity", value: "warning", mono: false },
|
||||
{ label: "Category", value: "config", mono: false },
|
||||
{ label: "Scope", value: "environment", mono: false },
|
||||
{ label: "Recoverable", value: "Yes", mono: false },
|
||||
{ label: "User Actionable", value: "No", mono: false },
|
||||
]));
|
||||
});
|
||||
|
||||
it("retains every canonical provider family for live and settled rendering", () => {
|
||||
const families = [
|
||||
"plan", "tool_execution", "research", "delegation", "model_identity", "context", "artifact",
|
||||
"review", "hook", "memory", "safety", "terminal", "wait", "provider_notice",
|
||||
] as const;
|
||||
const entries = families.map((family, index): TranscriptEntry => ({
|
||||
kind: "provider_activity",
|
||||
ts: new Date(Date.parse(TS) + index).toISOString(),
|
||||
family,
|
||||
eventType: `${family}.fixture`,
|
||||
status: index % 2 === 0 ? "running" : "completed",
|
||||
title: family,
|
||||
summary: `Visible ${family}`,
|
||||
payload: { [`${family}Id`]: `${family}-1` },
|
||||
}));
|
||||
const items = transcriptToTaskChatItems(entries, { ...opts, running: true });
|
||||
expect(items.map((item) => item.kind === "protocol" && item.surface === "provider_activity" ? item.family : null))
|
||||
.toEqual(families);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildActivityPhases provider summaries", () => {
|
||||
it("includes canonical provider work in the folded activity summary", () => {
|
||||
const provider = (id: string, family: "research" | "hook"): TaskChatItem => ({
|
||||
id,
|
||||
kind: "protocol",
|
||||
surface: "provider_activity",
|
||||
family,
|
||||
eventType: `${family}.completed`,
|
||||
status: "completed",
|
||||
title: family,
|
||||
details: [],
|
||||
steps: [],
|
||||
links: [],
|
||||
children: [],
|
||||
});
|
||||
const phases = buildActivityPhases([
|
||||
{ id: "finish", kind: "tool", name: "Paperclip_finish", status: "completed" },
|
||||
provider("research-1", "research"),
|
||||
provider("research-2", "research"),
|
||||
provider("research-3", "research"),
|
||||
provider("hook-1", "hook"),
|
||||
{
|
||||
id: "workspace",
|
||||
kind: "protocol",
|
||||
surface: "workspace_change",
|
||||
changeSetId: "change-1",
|
||||
revision: 1,
|
||||
source: "runner_verified",
|
||||
complete: true,
|
||||
files: [],
|
||||
totals: { files: 2, additions: 1, deletions: 1 },
|
||||
patchArtifactRef: null,
|
||||
},
|
||||
], false);
|
||||
expect(phases[0]?.summary).toBe("Called 1 tool, Searched 3 times, Ran a hook, Changed 2 files");
|
||||
});
|
||||
|
||||
it("counts canonical tool execution when no native tool row exists", () => {
|
||||
const phases = buildActivityPhases([{
|
||||
id: "provider-tool",
|
||||
kind: "protocol",
|
||||
surface: "provider_activity",
|
||||
family: "tool_execution",
|
||||
eventType: "tool.execution.completed",
|
||||
status: "completed",
|
||||
title: "Tool execution",
|
||||
details: [],
|
||||
steps: [],
|
||||
links: [],
|
||||
children: [],
|
||||
}], false);
|
||||
expect(phases[0]?.summary).toBe("Ran a tool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTurnSummary tool counting", () => {
|
||||
@@ -448,6 +546,21 @@ describe("settledRunChildren (PAP-361)", () => {
|
||||
});
|
||||
|
||||
describe("paperclip runner semantic channels", () => {
|
||||
it("turns empty reasoning lifecycle frames into a visible thinking item", () => {
|
||||
const parsed = transcriptToTaskChatItems([
|
||||
{ kind: "thinking", ts: "2026-08-21T12:00:00.000Z", text: "", lifecycle: "started", channel: "summary" },
|
||||
{ kind: "thinking", ts: "2026-08-21T12:00:02.000Z", text: "", lifecycle: "completed", channel: "summary" },
|
||||
], { runId: "run-empty-reasoning", running: true });
|
||||
|
||||
expect(parsed).toEqual([expect.objectContaining({
|
||||
kind: "thinking",
|
||||
lines: [],
|
||||
streaming: false,
|
||||
lifecycleOnly: true,
|
||||
summaryLabel: "Thought for 2s",
|
||||
})]);
|
||||
});
|
||||
|
||||
it("keeps progress/final messages and reasoning summary/detail in distinct items", () => {
|
||||
const parsed = transcriptToTaskChatItems([
|
||||
{ kind: "assistant", ts: TS, text: "Checking.", channel: "progress", delta: true },
|
||||
@@ -482,6 +595,61 @@ describe("paperclip runner semantic channels", () => {
|
||||
]);
|
||||
expect(items).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("projects one semantic activity row per useful logical item", () => {
|
||||
const items: TaskChatItem[] = [
|
||||
{ id: "progress", kind: "message", author: "agent", text: "Looking this up.", interstitial: true, channel: "progress" },
|
||||
{ id: "final", kind: "message", author: "agent", text: "Finished.", channel: "final" },
|
||||
{ id: "empty-thinking", kind: "thinking", lines: [], lifecycleOnly: true },
|
||||
{ id: "thinking", kind: "thinking", lines: ["Provider-authored summary"] },
|
||||
{ id: "tool", kind: "tool", name: "Search", rawName: "web_search", status: "completed" },
|
||||
{ id: "finish", kind: "tool", name: "Paperclip_finish", rawName: "paperclip_finish", status: "completed" },
|
||||
{ id: "session", kind: "marker", variant: "session_start", label: "Session started" },
|
||||
{ id: "interrupt", kind: "marker", variant: "interrupted", label: "Interrupted" },
|
||||
{ id: "usage", kind: "usage", usage: { used: 20, size: 100 } },
|
||||
{
|
||||
id: "research",
|
||||
kind: "protocol",
|
||||
surface: "provider_activity",
|
||||
family: "research",
|
||||
eventType: "research.completed",
|
||||
status: "completed",
|
||||
title: "Research",
|
||||
details: [],
|
||||
steps: [],
|
||||
links: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: "result",
|
||||
kind: "protocol",
|
||||
surface: "run_result",
|
||||
disposition: "done",
|
||||
summary: "Finished.",
|
||||
objectiveSatisfied: true,
|
||||
verification: [],
|
||||
remainingWork: [],
|
||||
blocker: null,
|
||||
artifacts: [],
|
||||
},
|
||||
{
|
||||
id: "terminal",
|
||||
kind: "protocol",
|
||||
surface: "run_terminal",
|
||||
turnState: "completed",
|
||||
runState: "succeeded",
|
||||
disposition: "done",
|
||||
},
|
||||
];
|
||||
|
||||
expect(paperclipRunnerActivityItems(items).map((item) => item.id)).toEqual([
|
||||
"progress",
|
||||
"thinking",
|
||||
"tool",
|
||||
"interrupt",
|
||||
"research",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMergedTurnSummary (PAP-362)", () => {
|
||||
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user