From a978d1bc54d9048fb98a4929b0b2195bde707835 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 14:30:34 -0500 Subject: [PATCH] fix(runner): preserve canonical reasoning delta privacy Co-Authored-By: Paperclip --- .../src/live/runnerd-codex-transport.test.ts | 30 +++++++++++++++++++ .../src/live/runnerd-codex-transport.ts | 22 ++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 95dffebac31..08b79ca7dcb 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -32,6 +32,7 @@ import type { PrpTerminalState, } from "../protocol/replay-contract.js"; import { executeNativeSession } from "../native-session-runtime.js"; +import { redactCapabilityEvidenceData } from "./evidence-redaction.js"; import { NativeSessionCloseUnrecoverableError } from "../contracts/native-session-backend.js"; import { parsePaperclipQuestionSet } from "../contracts/question-set.js"; import { ACPX_CREDENTIAL_BINDING_ENV, createAcpxCredentialBinding, createAcpxSidecarHostEnvironment } from "../drivers/acpx/environment.js"; @@ -1922,6 +1923,35 @@ it("binds a durable semantic result to the active provider turn", () => { }); }); +it.each(["summary", "detail"])("preserves canonical reasoning %s deltas without publishing assistant text", (channel) => { + // Exact shape emitted by the Rust ACPX projector, with synthetic private text. + const payload = { provider: "acpx", itemId: "reason-1", kind: "reasoning", channel, + providerMethod: "runtime.event", text: "PRIVATE_REASONING_SENTINEL" }; + const method = runnerdCanonicalNotificationMethod("item.delta", payload); + expect(method).toBe(channel === "detail" ? "item/reasoning/textDelta" : "item/reasoning/summaryTextDelta"); + const params = rehydrateRunnerdDeltaNotification(payload, "provider-thread", "provider-turn"); + expect(params).toMatchObject({ threadId: "provider-thread", turnId: "provider-turn", itemId: "reason-1" }); + const evidence = redactCapabilityEvidenceData("provider_event", { method, params }); + expect(evidence).toEqual({ event: "reasoning_delta" }); + expect(JSON.stringify(evidence)).not.toContain("PRIVATE_REASONING_SENTINEL"); +}); + +it("classifies each coalesced canonical delta without mixing reasoning into assistant output", () => { + const notifications = expandRunnerdCanonicalNotifications("item/agentMessage/delta", { + coalescedCount: 3, + events: [ + { kind: "reasoning", channel: "summary", text: "PRIVATE_REASONING_SENTINEL" }, + { kind: "agentMessage", channel: "progress", text: "Visible progress" }, + { kind: "reasoning", channel: "detail", text: "PRIVATE_DETAIL_SENTINEL" }, + ], + }, "item.delta"); + expect(notifications.map((entry) => entry.method)).toEqual([ + "item/reasoning/summaryTextDelta", "item/agentMessage/delta", "item/reasoning/textDelta", + ]); + expect(notifications.filter((entry) => entry.method === "item/agentMessage/delta").map((entry) => entry.params.text)) + .toEqual(["Visible progress"]); +}); + it("restores provider identity and streamed text from a canonical delta", () => { expect(rehydrateRunnerdDeltaNotification({ text: "Reading Gmail", itemId: "message-1", turnId: "controller-turn" }, "root-thread", "provider-turn")) .toMatchObject({ threadId: "root-thread", turnId: "provider-turn", delta: "Reading Gmail", itemId: "message-1" }); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 4323214bdb0..ca83ca1221b 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -1325,10 +1325,21 @@ export function unseenRunnerdCommittedEvents< export function expandRunnerdCanonicalNotifications( method: string, input: unknown, + eventType?: string, ): Array<{ method: string; params: Record }> { const payload = record(input); - if (!Array.isArray(payload.events)) return [{ method, params: payload }]; - return payload.events.map((event) => ({ method, params: record(event) })); + const events = Array.isArray(payload.events) ? payload.events : [payload]; + return events.map((event) => { + const params = record(event); + // A coalesced envelope can contain both reasoning and visible messages. + // Classify each payload after expansion, never using the envelope's kind. + return { + method: eventType === "item.delta" + ? runnerdCanonicalNotificationMethod(eventType, params) ?? method + : method, + params, + }; + }); } export function runnerdCanonicalNotificationMethod( @@ -1341,6 +1352,11 @@ export function runnerdCanonicalNotificationMethod( if (eventType === "session.goal.snapshot" && payload.goal === null) { return undefined; } + if (eventType === "item.delta" && payload.kind === "reasoning") { + return payload.channel === "detail" + ? "item/reasoning/textDelta" + : "item/reasoning/summaryTextDelta"; + } return ( { "turn.started": "turn/started", @@ -6007,7 +6023,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { : event.eventType === "provider.event" ? unwrapRunnerdProviderNotifications(eventPayload) : canonicalMethod - ? expandRunnerdCanonicalNotifications(canonicalMethod, eventPayload) + ? expandRunnerdCanonicalNotifications(canonicalMethod, eventPayload, event.eventType) : []; for (const payload of notifications) { const method = payload.method;