From 547cdf59c3e76458ecec4690a693b567cb431a0e Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 9 Oct 2026 07:03:00 -0500 Subject: [PATCH 1/3] test(ui): handle delayed announcements for both sidebar links Co-Authored-By: Paperclip --- tests/e2e/chat-adapters-ui-messaging.spec.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/e2e/chat-adapters-ui-messaging.spec.ts b/tests/e2e/chat-adapters-ui-messaging.spec.ts index 685133dbc6..d4a060b7ef 100644 --- a/tests/e2e/chat-adapters-ui-messaging.spec.ts +++ b/tests/e2e/chat-adapters-ui-messaging.spec.ts @@ -1364,14 +1364,16 @@ test.describe("Exact failed chat run retry", () => { afterSwitchSelectedCompany: await page.evaluate(() => localStorage.getItem("paperclip.selectedCompanyId")), }, null, 2)); await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/dashboard$`)); - // The announcement waits for its feed and a settle timer. It can appear - // after the dashboard URL settles, while the sidebar click is retrying. - // Dismiss it through the normal control when it actually blocks the link. - await expect(async () => { - if (await dismissAnnouncement.isVisible()) await dismissAnnouncement.click(); - await page.getByRole("link", { name: "Agents", exact: true }).first().click({ timeout: 1_000 }); - }).toPass({ timeout: 10_000 }); - await page.getByRole("link", { name: "Maya", exact: true }).first().click(); + // The feed can settle after either navigation. Dismiss the actual card + // through its normal control if it blocks either sidebar link. + const clickSidebarLink = async (name: string) => { + await expect(async () => { + if (await dismissAnnouncement.isVisible()) await dismissAnnouncement.click({ timeout: 1_000 }); + await page.getByRole("link", { name, exact: true }).first().click({ timeout: 1_000 }); + }).toPass({ timeout: 10_000 }); + }; + await clickSidebarLink("Agents"); + await clickSidebarLink("Maya"); await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/agents/maya(?:/overview)?$`)); await expect(page.getByText(foreignTitle, { exact: true })).toHaveCount(0); await Promise.race([ From f01a5f02f8b4cdb539b2d40107191689fd9f50ee Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 9 Oct 2026 07:08:06 -0500 Subject: [PATCH 2/3] fix(hermes): bound question responses and await native steering identity Co-Authored-By: Paperclip --- .../src/drivers/acpx/codex-runtime-adapter.ts | 23 +++++++++++++- .../drivers/acpx/hermes-extensions.test.ts | 30 +++++++++++++++++-- .../src/drivers/acpx/profile-extensions.ts | 24 ++++++++++++++- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts index 51a0d542d7..b1508e9d02 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.ts @@ -87,6 +87,8 @@ interface AcpxRuntimeGoalState { interface AcpxRuntimeExtensionTurn { nativeTurnToken?: string; + nativeTurnReady: Promise; + acknowledgeNativeTurn(): void; requestId: string; sessionId: string; controller: AbortController; @@ -352,6 +354,7 @@ export async function openQualifiedAcpxRuntime( if (options.profile.agent === "hermes" && method === "_hermes/turn_started") { if (!active || !ownsExtensionTurn(active, params) || params.version !== 1 || typeof params.turnToken !== "string" || !/^[0-9a-f-]{36}$/.test(params.turnToken) || active.nativeTurnToken) throw new Error("Invalid Hermes turn binding"); active.nativeTurnToken = params.turnToken; + active.acknowledgeNativeTurn(); return; } if (!extensionNotifications.has(method) || !active?.onNotification || !ownsExtensionTurn(active, params)) return; @@ -1045,7 +1048,11 @@ function runtimePort( await abortableExtensionResult(active.promptStarted, active.signal); active.signal.throwIfAborted(); if (extensionBoundary.active !== active) throw new Error("ACPX extension control turn expired"); - if (method === "_hermes/steer" && !active.nativeTurnToken) throw new Error("Hermes has not acknowledged an active turn"); + if (method === "_hermes/steer") { + await waitForHermesNativeTurn(active); + active.signal.throwIfAborted(); + if (extensionBoundary.active !== active) throw new Error("ACPX extension control turn expired"); + } const response = await abortableExtensionResult(runtime.requestExtension!({ handle, method, params: { sessionId: identity.backendSessionId, message: text, ...(method === "_hermes/steer" ? { version: 1, turnToken: active.nativeTurnToken } : {}), @@ -1392,7 +1399,10 @@ function runtimePort( if (extensionBoundary.active) throw new Error("ACPX runtime already has an active turn"); const approval = new AbortController(); const controller = new AbortController(); + let acknowledgeNativeTurn!: () => void; + const nativeTurnReady = new Promise(resolve => { acknowledgeNativeTurn = resolve; }); const extensionTurn: AcpxRuntimeExtensionTurn = { + nativeTurnReady, acknowledgeNativeTurn, requestId: input.requestId, sessionId: identity.backendSessionId, controller, signal: input.signal ? AbortSignal.any([controller.signal, input.signal, approval.signal]) : AbortSignal.any([controller.signal, approval.signal]), promptStarted: Promise.resolve(), @@ -2025,6 +2035,17 @@ async function abortableExtensionResult(result: Promise, signal: AbortSign finally { if (rejectAbort) signal.removeEventListener("abort", rejectAbort); } } +async function waitForHermesNativeTurn(active: AcpxRuntimeExtensionTurn): Promise { + if (active.nativeTurnToken) return; + let timer: ReturnType | undefined; + try { + await abortableExtensionResult(Promise.race([active.nativeTurnReady, new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error("Hermes native turn acknowledgement timed out")), 5_000); + timer.unref(); + })]), active.signal); + } finally { if (timer) clearTimeout(timer); } +} + function goalCapabilityFromAcpMessage( message: unknown, ): AcpxRuntimeGoalCapability | null { diff --git a/packages/paperclip-runner/src/drivers/acpx/hermes-extensions.test.ts b/packages/paperclip-runner/src/drivers/acpx/hermes-extensions.test.ts index e6ae3da9de..265dccafc6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/hermes-extensions.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/hermes-extensions.test.ts @@ -30,11 +30,35 @@ describe("Hermes native extensions", () => { const response = (value: string) => ({ action: "submit" as const, response: { schema: "paperclip.question_response.v1" as const, answers: { q0: answerMode === "text" ? { text: value } : { customText: value } }, } }); - expect(result.input.resolve(response("😀".repeat(32_768)))).toMatchObject({ outcome: "answered" }); - for (const value of ["x".repeat(65_537), "x".repeat(70_000), "😀".repeat(32_769)]) { - expect(() => result.input.resolve(response(value))).toThrow("at most 65536 characters"); + const limit = result.input.questionSet.questions[0]!.textValidation!.maxLength!; + expect(limit).toBeLessThan(65_536); + expect(result.input.resolve(response("😀".repeat(Math.floor(limit / 2))))).toMatchObject({ outcome: "answered" }); + for (const value of ["x".repeat(limit + 1), "x".repeat(70_000), "😀".repeat(Math.floor(limit / 2) + 1)]) { + expect(() => result.input.resolve(response(value))).toThrow(`at most ${limit} characters`); } }); + it.each(["text", "single_select", "multi_select"])("keeps all accepted %s answers inside ACPX's encoded response bound", async answerMode => { + const result = await adapter().request("_hermes/ask_questions", { version: 1, sessionId: "session", input: { + schema: "paperclip.question_set.v1", questions: Array.from({ length: 5 }, (_, i) => ({ + id: `q${i}` + "\u0000".repeat(158), prompt: "Why?", required: true, answerMode, + textValidation: { maxLength: 65_536 }, + ...(answerMode === "text" ? {} : { options: Array.from({ length: 4 }, (_, j) => ({ + id: `o${j}` + "\u0000".repeat(158), label: `Choice ${j}`, + })), customAnswer: { enabled: true } }), + })), + } }); + if (!("input" in result)) throw new Error("Missing canonical question form"); + const answers = Object.fromEntries(result.input.questionSet.questions.map(question => [question.id, { + selectedOptionIds: answerMode === "multi_select" ? question.options!.map(option => option.id) : [], + [answerMode === "text" ? "text" : "customText"]: "\u0000".repeat(question.textValidation!.maxLength!), + }])); + const response = result.input.resolve({ action: "submit", response: { schema: "paperclip.question_response.v1", answers } }); + expect(Buffer.byteLength(JSON.stringify(response))).toBeLessThanOrEqual(256 * 1024); + const oversized = Object.fromEntries(result.input.questionSet.questions.map(question => [question.id, { + [answerMode === "text" ? "text" : "customText"]: "x".repeat(60_000), + }])); + expect(() => result.input.resolve({ action: "submit", response: { schema: "paperclip.question_response.v1", answers: oversized } })).toThrow("at most"); + }); it("preserves estimated-cost provenance without inventing billed cost", async () => { const events = await adapter().notification("_hermes/usage", { version: 1, sessionId: "session", tokens: "reported", cost: "estimated", estimatedUsd: 0.012 }); events.forEach(validateAcpxRichEvent); diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts b/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts index 9bbf64099c..4922a042d4 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-extensions.ts @@ -33,6 +33,28 @@ export interface AcpxProfileExtensionContext { turnId: string; } +/** ACPX 0.13.1 bounds the complete encoded extension response at 256 KiB. + * Reserve its fixed fields and every selectable ID, then budget text for the + * worst JSON escape (six bytes per JavaScript string code unit). The persisted + * form and response validator must share this limit before a human submits. + */ +function boundedHermesQuestionSet(value: unknown): PaperclipQuestionSet { + const input = parsePaperclipQuestionSet(value); + const largestEmptyAnswers = Object.fromEntries(input.questions.map(question => [question.id, { + selectedOptionIds: (question.options ?? []).map(option => option.id), + ...(question.answerMode === "text" ? { text: "" } + : question.customAnswer?.enabled ? { customText: "" } : {}), + }])); + const overhead = Buffer.byteLength(JSON.stringify({ outcome: "answered", answers: largestEmptyAnswers })); + const maxLength = Math.min(65_536, Math.floor((256 * 1024 - overhead) / (6 * input.questions.length))); + if (maxLength < 1) throw new Error("Hermes question answers exceed the encoded response limit"); + return parsePaperclipQuestionSet({ ...input, questions: input.questions.map(question => ({ + ...question, textValidation: { ...question.textValidation, + maxLength: Math.min(question.textValidation?.maxLength ?? maxLength, maxLength), + }, + })) }); +} + /** Provider branches install their closed, pinned adapters here after qualification research. */ export function createAcpxProfileExtensionAdapter( agent: QualifiedAcpxAgent, @@ -42,7 +64,7 @@ export function createAcpxProfileExtensionAdapter( if (agent === "hermes") return { async request(method, params) { if (method !== "_hermes/ask_questions" || params.version !== 1 || params.sessionId !== context.sessionId) throw new Error("Unsupported Hermes question extension"); - const questionSet = parsePaperclipQuestionSet(params.input); + const questionSet = boundedHermesQuestionSet(params.input); return { input: { method, questionSet, resolve(resolution) { From 9794f657b7a2b5a56d2b29f37b755c4fa7cbb62c Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 9 Oct 2026 07:03:00 -0500 Subject: [PATCH 3/3] test(hermes): cover early steering ownership and timeout Co-Authored-By: Paperclip --- .../acpx/codex-runtime-adapter.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts index 50656bdfb3..7a8bcdb9d6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-runtime-adapter.test.ts @@ -29,6 +29,65 @@ const HANDLE: AcpRuntimeHandle = { }; describe("Codex ACPX runtime adapter", () => { + async function pendingHermesSteering() { + const pending = pendingExtensionTurn("turn-1"); + const runtime = Object.assign(fakeRuntime(), { requestExtension: vi.fn(async ({ params }) => ({ + accepted: true, turnToken: params.turnToken, + })) }); + vi.mocked(runtime.startTurn).mockReturnValue(pending.turn); + let created!: AcpRuntimeOptions & { onAgentInitialize?: (result: unknown) => void }; + const options = openOptions(fakeCommand()); options.profile = { ...options.profile, agent: "hermes" }; + const port = await openCodexAcpxRuntime(options, { + createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; }, + }); + created.onAgentInitialize!({ agentCapabilities: { _meta: { paperclipHermes: { version: 1, steering: true } } } }); + const turn = port.startTurn({ text: "Work", requestId: "turn-1" }); + await turn.promptStarted; + const delivery = port.steerActiveTurn!("Correction", "turn-1").then(() => null, error => error); + await Promise.resolve(); await Promise.resolve(); + return { pending, runtime, created, port, turn, delivery }; + } + + it("holds early Hermes steering until the owning native turn is acknowledged", async () => { + const fixture = await pendingHermesSteering(); + const { pending, runtime, created, port, turn, delivery } = fixture; + expect(runtime.requestExtension).not.toHaveBeenCalled(); + const identity = { version: 1, sessionId: "backend-1", turnToken: "00000000-0000-0000-0000-000000000001" }; + expect(() => created.onExtensionNotification!("_hermes/turn_started", { ...identity, sessionId: "foreign" })).toThrow("binding"); + expect(runtime.requestExtension).not.toHaveBeenCalled(); + created.onExtensionNotification!("_hermes/turn_started", identity); + await expect(delivery).resolves.toBeNull(); + expect(runtime.requestExtension).toHaveBeenCalledExactlyOnceWith({ handle: HANDLE, method: "_hermes/steer", + params: { sessionId: "backend-1", version: 1, turnToken: identity.turnToken, message: "Correction" }, sessionMode: "persistent" }); + expect(runtime.startTurn).toHaveBeenCalledTimes(1); + pending.settle(); await turn.result; await port.close({ reason: "early steering acknowledged" }); + }); + + it.each(["cancel", "closeStream", "settle"] as const)("expires early Hermes steering when the owning turn ends by %s", async ending => { + const { pending, runtime, port, turn, delivery } = await pendingHermesSteering(); + if (ending === "settle") { pending.settle(); await turn.result; } + else await turn[ending](); + const failure = await delivery; + expect(failure).toBeInstanceOf(Error); + expect(failure.message).toMatch(ending === "cancel" ? /cancelled/ : ending === "closeStream" ? /stream closed/ : /expired/); + expect(runtime.requestExtension).not.toHaveBeenCalled(); + if (ending !== "settle") { pending.settle(); await turn.result; } + await port.close({ reason: "early control owner ended" }); + }); + + it("bounds the native turn acknowledgement wait for early Hermes steering", async () => { + vi.useFakeTimers(); + try { + const { pending, runtime, port, turn, delivery } = await pendingHermesSteering(); + await vi.advanceTimersByTimeAsync(5_000); + const failure = await delivery; + expect(failure).toBeInstanceOf(Error); + expect(failure.message).toContain("native turn acknowledgement timed out"); + expect(runtime.requestExtension).not.toHaveBeenCalled(); + pending.settle(); await turn.result; await port.close({ reason: "native acknowledgement absent" }); + } finally { vi.useRealTimers(); } + }); + it("accepts only the owning Hermes terminal usage after Stop and before prompt settlement", async () => { const pending = pendingExtensionTurn("turn-1"); const runtime = fakeRuntime(); vi.mocked(runtime.startTurn).mockReturnValue(pending.turn);