From ab51cabbbd82902e2f44cde8ebc1a8091b7c2f75 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:18:15 -0500 Subject: [PATCH 1/7] test: await async outcomes and drive saved deadlines deterministically (#18416) Reviewed and verified at ab0b15d234ef9bfbb3728fa97a5e2fe9a8d0c1de. Co-Authored-By: Paperclip --- ...heartbeat-stale-queue-invalidation.test.ts | 25 +++++++++++++++---- .../__tests__/skill-version-retention.test.ts | 23 ++++++++++++++--- tests/e2e/agent-chat-projects.spec.ts | 3 +++ tests/e2e/fixtures/agent-chat.mjs | 4 +++ ui/src/hooks/useTaskPluginPanels.test.tsx | 12 +++++++-- 5 files changed, 56 insertions(+), 11 deletions(-) diff --git a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts index 65191c24d2f..f1447508ed5 100644 --- a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts +++ b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts @@ -1796,8 +1796,22 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => { const interactionId = randomUUID(); const wakeCommentId = randomUUID(); let claimedIssue: { status: string; executionRunId: string | null } | null = null; + let resumedRunId: string | undefined; + let signalAdapterStarted!: () => void; + const adapterStarted = new Promise(resolve => { signalAdapterStarted = resolve; }); + const execute = mockAdapterExecute.getMockImplementation()!; + mockAdapterExecute.mockImplementation(async (...args: unknown[]) => { + if ((args[0] as { runId: string }).runId === resumedRunId) { + signalAdapterStarted(); + // A claimed continuation can finish after the old terminal poll. + if (interactionStatus === "rejected" && interactionKind === "connection_intent") { + await new Promise(resolve => setTimeout(resolve, 3100)); + } + } + return execute(); + }); afterContinuationDispatchCheck = async ({ runId: checkedRunId, issueId: checkedIssueId }) => { - if (checkedIssueId !== issueId) return; + if (checkedIssueId !== issueId || checkedRunId !== resumedRunId) return; claimedIssue = await db .select({ status: issues.status, executionRunId: issues.executionRunId }) .from(issues) @@ -1831,13 +1845,14 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => { contextExtras: { interactionId, interactionKind, interactionStatus, wakeCommentId, originCommentIds: [wakeCommentId], interactionResolvedAt: new Date().toISOString(), mutation: "interaction", source: `${interactionKind}.resolved`, forceFreshSession: true } }); + resumedRunId = runId; await heartbeat.resumeQueuedRuns(); - expect(await waitForCondition(async () => (await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)))[0]?.status === "succeeded")).toBe(true); - // Terminal status precedes completion bookkeeping. Drain those writes before - // afterEach truncates the fixture, otherwise PostgreSQL can deadlock. + // Adapter entry guarantees this exact execution is registered. Await its + // owning completion barrier before terminal assertions and fixture cleanup. + await adapterStarted; await heartbeat.waitForRunExecutionDrain(runId); + expect((await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)))[0]?.status).toBe("succeeded"); expect(countExecuteCallsForRun(runId)).toBe(1); - await waitForCondition(async () => claimedIssue !== null); expect(claimedIssue).toEqual({ status: "in_progress", executionRunId: runId }); }); }); diff --git a/server/src/__tests__/skill-version-retention.test.ts b/server/src/__tests__/skill-version-retention.test.ts index a84adff3042..d88985b23d3 100644 --- a/server/src/__tests__/skill-version-retention.test.ts +++ b/server/src/__tests__/skill-version-retention.test.ts @@ -83,6 +83,7 @@ describeEmbeddedPostgres("skill version snapshot retention", () => { let coordinator: ReturnType | undefined; let worker: ReturnType | undefined; let admitted = true; + const passes = new Set>(); afterEach(async () => { worker?.unsubscribe(); await coordinator?.stop(); @@ -160,12 +161,20 @@ describeEmbeddedPostgres("skill version snapshot retention", () => { async function startRetention( graceMs = 20_000, service = skillVersionRetentionService(db, { graceMs }), - advanceAutomatically = true, + clock: boolean | { shouldAdvanceTime?: boolean; now?: number } = true, ) { + const clockOptions = typeof clock === "boolean" ? { shouldAdvanceTime: clock } : clock; + const advanceAutomatically = clockOptions.shouldAdvanceTime ?? true; await Promise.all(Array.from({ length: 6 }, () => db.execute(sql`select 1`))); - vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: advanceAutomatically }); + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"], shouldAdvanceTime: advanceAutomatically, ...clockOptions }); coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => admitted, canReconcile: () => true, onError: error => { throw error; } }); + const register = coordinator.register.bind(coordinator); + coordinator.register = (queue, task) => register(queue, { ...task, run: signal => { + const pass = task.run(signal); passes.add(pass); + void pass.then(() => passes.delete(pass), () => passes.delete(pass)); + return pass; + } }); worker = registerSkillVersionRetentionWork(coordinator, service, () => admitted); if (advanceAutomatically) await worker.ready; else await waitForIo(worker.ready); @@ -194,6 +203,7 @@ describeEmbeddedPostgres("skill version snapshot retention", () => { for (;;) { if (process.hrtime.bigint() >= deadline) throw new Error("Retention work did not settle within 10000ms"); if (idleWorkSnapshot().active > 0) { + if (passes.size) await waitForIo(Promise.all([...passes])); await vi.advanceTimersByTimeAsync(0); await realDelay(0); continue; @@ -631,7 +641,6 @@ describeEmbeddedPostgres("skill version snapshot retention", () => { it("keeps directory pages moving while another company's grace deadline is due", async () => { const dueCompany = await createCompany(), pagedCompany = await createCompany(); const dueSkill = await createSkill(dueCompany, 2), pagedSkill = await createSkill(pagedCompany, 1); - const expiring = await writeSnapshot(dueCompany, dueSkill.skillId, dueSkill.versionIds[0]!, 0); const parent = path.join(resolveCompanySkillVersionsRoot(pagedCompany), pagedSkill.skillId); await fs.mkdir(parent, { recursive: true }); for (let index = 0; index < 410; index++) await fs.mkdir(path.join(parent, `operator-${index}`)); @@ -642,7 +651,13 @@ describeEmbeddedPostgres("skill version snapshot retention", () => { if (id === pagedCompany) pageTimes.push(Date.now()); return prune(id, options); }; - await startRetention(5000, service, false); await settledAtDeadlines(); + const expiring = await writeSnapshot(dueCompany, dueSkill.skillId, dueSkill.versionIds[0]!, 0); + const accessedAt = (await lastUsedAt(skillVersionCachePaths(dueCompany, dueSkill.skillId, dueSkill.versionIds[0]!).access))!; + await startRetention(5000, service, { shouldAdvanceTime: false, now: accessedAt }); + expect(await exists(expiring)).toBe(true); + // Keep virtual time fixed during real I/O; measure scheduler fairness, + // independent of filesystem latency and CI CPU scheduling. + await settledAtDeadlines(); expect(pageTimes.length).toBeGreaterThan(5); expect(Math.max(...pageTimes.slice(1).map((at, index) => at - pageTimes[index]!))).toBeLessThan(3000); expect(await exists(expiring)).toBe(false); await quiet(); diff --git a/tests/e2e/agent-chat-projects.spec.ts b/tests/e2e/agent-chat-projects.spec.ts index 994df38746c..89ec94ca54c 100644 --- a/tests/e2e/agent-chat-projects.spec.ts +++ b/tests/e2e/agent-chat-projects.spec.ts @@ -459,12 +459,15 @@ test("plan approval hands the preserved revision to an assigned project task", a { timeout: 60_000 }, ) .toBe(1); + // Task completion is delivered back to the chat as another real turn. + await idle(request, f.chatPath, 3); const task = ( await json(await request.get(`/api/companies/${f.company.id}/issues`)) )[0]; expect(task.parentId).toBeNull(); expect(task.projectId).toBeTruthy(); expect(task.assigneeAgentId).toBe(f.agent.id); + await expect(page.getByText(`Delegated work completed: ${task.identifier}`, { exact: true })).toBeVisible(); const plan = await json( await request.get(`/api/issues/${task.id}/documents/plan`), ); diff --git a/tests/e2e/fixtures/agent-chat.mjs b/tests/e2e/fixtures/agent-chat.mjs index acc5b25c789..2cbb19557a3 100644 --- a/tests/e2e/fixtures/agent-chat.mjs +++ b/tests/e2e/fixtures/agent-chat.mjs @@ -44,6 +44,10 @@ if (!task.conversationAgentId) { }); process.exit(0); } +if (ctx.wakeReason === "chat_task_completed") { + await comment(`Delegated work completed: ${ctx.chatCompletionUpdates.map((update) => update.identifier).join(", ")}`); + process.exit(0); +} const comments = await api(`/issues/${task.id}/comments?order=asc`); const current = comments.find((c) => c.id === ctx.wakeCommentId) ?? diff --git a/ui/src/hooks/useTaskPluginPanels.test.tsx b/ui/src/hooks/useTaskPluginPanels.test.tsx index c013137a057..bec7ca5b66c 100644 --- a/ui/src/hooks/useTaskPluginPanels.test.tsx +++ b/ui/src/hooks/useTaskPluginPanels.test.tsx @@ -143,9 +143,17 @@ it("discovers a panel started after the task is already open, then hides it on s get.mockRejectedValue(new Error("Access denied")); await refresh(); expect(current).toEqual([]); }); it("clears automatic activation when the run ends even if its view is still ready", async () => { - await render(); expect(current).not.toEqual([]); + await render(); + await vi.waitFor(async () => { + await act(async () => {}); + expect(current).not.toEqual([]); + }); await act(async () => { client.setQueryData(queryKeys.issues.liveRuns("task"), []); }); - await render(); expect(current).toEqual([]); + await render(); + await vi.waitFor(async () => { + await act(async () => {}); + expect(current).toEqual([]); + }); }); it("does not carry an old task's ready panel to a new task or disabled plugin", async () => { await render(); expect(current).not.toEqual([]); From 12d8c3043ea3f385dec12e34dacac246d8b6a487 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:25:16 -0500 Subject: [PATCH 2/7] fix(native): fence retries when replaying an audited Stop (#18457) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native runs keep Stop intent and retry state in durable records. > - A provider error can leave a failed retry with an earlier acknowledged Stop. > - The replay path returned the existing receipt before it closed that retry. > - This pull request applies the existing retry fence to the validated replay. > - The same Stop can settle without another provider cancellation or a new receipt. ## Linked Issues or Issue Description Refs #14733 for the related caller-bound Stop contract. This change covers a later retry that already has a Stop acknowledgement. The older CI-only #14743 is not a production delivery PR. **What happened?** A failed native run can have a retryable coordinator and an earlier audited Stop. A repeat Stop returns the old acknowledgement without moving the coordinator to `terminal_failure`. The final failed-to-cancelled transition requires that fence, so the Stop cannot settle through that path. **Expected behavior** A valid replay closes the same retry and keeps the original intent and acknowledgement. It does not send a second provider cancellation. Physical stop and workspace cleanup still require their own evidence. **Steps to reproduce** 1. Store a failed native run with a `retryable_failure` coordinator. 2. Store a matching run-scoped Stop with a dispatched acknowledgement and audit IDs. 3. Call `cancelNativeSession` for that same run and inspect the coordinator. **Paperclip version or commit** Reproduced on public master `f2db909393de124caf6a5380a52d4e0e1d621661`. **Deployment mode** Core native execution. The regression uses the real cancellation executor with controlled persistence and provider boundaries. ## What Changed - Reuse the existing guarded coordinator fence for a validated Stop replay. - Test repeated replay, unchanged receipts, one fence, and no second provider cancellation. - Reject foreign company, run, issue, scope, effect, and missing-audit receipts before any write. - Check that an acknowledged Stop with an unconfirmed remote lease still fails the physical-stop guard. - Document the separate retry fence and physical-cleanup requirements. ## Verification - RED on the selected public base: the new executor case returned success but left the coordinator in `retryable_failure` with a future retry. One case executed and failed for that assertion. - GREEN: 62 focused tests passed across `native-session-executor.test.ts` and `native-cancellation-request.test.ts`. These include ownership, caller conflict, changed-phase, and receipt controls. - Command from `server/`: `node ../node_modules/vitest/vitest.mjs run src/services/native-runtime/native-session-executor.test.ts src/services/native-runtime/native-cancellation-request.test.ts -t 'native session cancellation|caller-bound native cancellation|failed native retry caller admission'`. - Canonical Linux CI passed all 1,018 executor cases and all 32 caller-cancellation cases. The exact file completion lines are in [server shard 2](https://github.com/paperclipai/paperclip/actions/runs/41203989386/job/124545366271) and [server shard 10](https://github.com/paperclipai/paperclip/actions/runs/41203989386/job/124545366219). - Node 24.22.2 and Vitest 5.0.3. Map contract checks and `git diff --check` pass. - Local dependencies are linked from an existing checkout. Full local TypeScript validation reports older Runner/shared declarations that lack current Kimi and continuity APIs. The changed ranges have no diagnostics. The canonical clean CI Typecheck + Release Registry gate passed. - A broader two-file run was stopped when an unchanged integration case tried to start embedded Postgres and hit the host listen restriction. It is not a passing full-suite result. No host settings or other services were changed. - Repository-wide `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build` were not completed locally. Current-head CI passed against clean source and dependencies. - Fresh [Greptile review](https://github.com/paperclipai/paperclip/pull/18457#issuecomment-6543772487) on `dbc1c1a5b71450194d568725592969660864cb5a`: 5/5 with no actionable findings. The review-thread inventory has no threads and no pagination. Full [current-head CI](https://github.com/paperclipai/paperclip/actions/runs/41203989386) passed: 59 terminal checks, 55 successful, 3 skipped, and 1 neutral. No failure or pending check remains. Merge status is CLEAN. No live recovery, rollout, or physical termination is claimed. ## Risks - A wrong or stale Stop must not close another retry. Existing run/coordinator locks, caller checks, receipt binding, and the final compare-and-set remain in place. - This fix changes logical retry settlement only. Provider termination, workspace preservation, lease cleanup, and continuation gates remain separate. - The regression controls persistence and provider boundaries. It does not establish the physical state of a remote provider. ## Model Used - OpenAI GPT-6 through Codex, with reasoning, code editing, and tool use. The session does not expose an exact model build ID or context-window capacity. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub references) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- doc/execution-semantics.md | 4 ++ .../native-session-executor.test.ts | 55 +++++++++++++++++++ .../native-runtime/native-session-executor.ts | 44 +++++++++------ 3 files changed, 85 insertions(+), 18 deletions(-) diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 9409d0d608e..17ee6c4f445 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -330,6 +330,10 @@ persisted cancellations of parked retries through the normal guarded cancellatio path if the original owner stops or its final database write fails. They preserve the original cancellation request and never start a provider turn for that work. +Replaying an audited native Stop also fences a later same-run retry. It preserves +the original intent and acknowledgement. That retry fence does not prove that +the provider stopped or that workspace cleanup finished. + Recovery instructions in a wake describe only an active or escalated recovery action. Resolving or cancelling that action removes its repair instructions from subsequent wakes, so the original worker can resume the task. This prompt change diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index fea3271f66d..59d7a94e408 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -99,6 +99,7 @@ import { buildNativeHeartbeatPreparationSpans, createNativeRunTrace } from "./na import { NativeRunnerOwnershipUnverifiedError } from "./native-runner-ownership.js"; import type { AdapterRuntimeEvent } from "../../adapters/index.js"; import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils"; +import { acknowledgedNativeStopExecutionHasStopped, isAcknowledgedNativeStop } from "../acknowledged-native-stop.js"; vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); @@ -7852,6 +7853,27 @@ describe("native session cancellation", () => { expect(state.persistActivity).not.toHaveBeenCalled(); }); + it.each([ + { companyId: "another-company" }, { runId: "another-run" }, { issueId: "another-issue" }, + { scope: "issue" }, { effects: ["cancel_issue"] }, { intentAuditId: null }, + ])("does not fence a failed retry for a foreign or stale Stop receipt %j", async changed => { + const intent = { schema: "paperclip.native-cancellation.v1", intentId: "native-cancellation:earlier-stop", + ...execution.binding, scope: "run", reasonCode: "cancellation_run_only", effects: ["release_run_resources"], + dispatchState: "acknowledged", dispatched: true, intentAuditId: "earlier-intent-audit", + acknowledgementAuditId: "earlier-ack-audit", ...changed }; + const coordinator = { runId: execution.binding.runId, assessmentId: null, phase: "retryable_failure" }; + const persistence = cancellationDb({ status: "failed", coordinator, resultJson: { nativeCancellation: intent } }); + const before = structuredClone(persistence.getResultJson()); + state.cancel.mockClear(); + state.persistActivity.mockClear(); + await expect(cancelNativeSession(execution.binding.runId, "retry Stop", { db: persistence.db, scope: "run" })).rejects.toThrow(); + expect(persistence.updates).toEqual([]); + expect(coordinator.phase).toBe("retryable_failure"); + expect(persistence.getResultJson()).toEqual(before); + expect(state.cancel).not.toHaveBeenCalled(); + expect(state.persistActivity).not.toHaveBeenCalled(); + }); + it("does not mistake a recovered running attempt for the failed retry that reserved Stop", async () => { const id = "11111111-1111-4111-8111-111111111111"; const persistence = cancellationDb({ status: "running", @@ -7877,6 +7899,39 @@ describe("native session cancellation", () => { expect(persistence.getResultJsonUpdateCount()).toBe(writes); }); + it("fences a failed retry whose earlier Stop is already acknowledged without changing its receipts", async () => { + const intent = { schema: "paperclip.native-cancellation.v1", intentId: "native-cancellation:earlier-stop", + ...execution.binding, scope: "run", reasonCode: "cancellation_run_only", + effects: ["release_run_resources"], dispatchState: "acknowledged", dispatched: true, + intentAuditId: "earlier-intent-audit", acknowledgementAuditId: "earlier-ack-audit", + acknowledgedAt: "2026-01-01T00:00:00.000Z", decisionId: null, priorCoordinatorDecisionId: null }; + const coordinator = { runId: execution.binding.runId, assessmentId: null, phase: "retryable_failure", + nextAttemptAt: new Date(Date.now() + 30_000) }; + const persistence = cancellationDb({ status: "failed", coordinator, + resultJson: { nativeCancellation: intent, executionCancellation: { state: "requested", requestedAt: "later-stop" } } }); + const before = structuredClone(persistence.getResultJson()); + state.cancel.mockClear(); + state.persistActivity.mockClear(); + for (let attempt = 0; attempt < 2; attempt++) { + await expect(cancelNativeSession(execution.binding.runId, "retry Stop", { db: persistence.db, scope: "run" })) + .resolves.toMatchObject({ dispatched: true, auditId: intent.intentAuditId }); + expect(coordinator).toMatchObject({ phase: "terminal_failure", failureCode: "native_retry_cancelled", nextAttemptAt: null }); + expect(persistence.getResultJson()).toEqual(before); + } + expect(persistence.updates.filter(update => update.table === nativeRunFinalizations)).toHaveLength(1); + expect(persistence.getResultJsonUpdateCount()).toBe(0); + expect(state.cancel).not.toHaveBeenCalled(); + expect(state.persistActivity).not.toHaveBeenCalled(); + const stopped = { id: execution.binding.runId, companyId: execution.binding.companyId, + nativeIssueId: execution.binding.issueId, status: "cancelled", processPid: null, processGroupId: null, + resultJson: { ...persistence.getResultJson(), cancelledByActorType: "user", cancelledByUserId: "operator" } }; + const lease = { provider: "daytona", providerLeaseId: "unconfirmed-provider-resource", status: "active", + releasedAt: null, cleanupStatus: null, metadata: {} }; + const leaseDb = { select: () => ({ from: () => ({ where: async () => [lease] }) }) } as unknown as Db; + expect(isAcknowledgedNativeStop(stopped)).toBe(true); + await expect(acknowledgedNativeStopExecutionHasStopped(leaseDb, stopped)).resolves.toBe(false); + }); + it("uses the reserved caller UUID and permits same-intent and default retries", async () => { const id = "11111111-1111-4111-8111-111111111111"; const persistence = cancellationDb({ resultJson: { startupCancellation: { cancellationRequestId: id } } }); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 604bff67972..37abb1b88be 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -7541,6 +7541,27 @@ export async function cancelNativeSession( runId, companyId: cancellationContext.companyId, issueId: cancellationContext.issueId, resultJson, requestId: requestedId, scope: options.scope ?? "run", }, coordinator)) throw conflict("Native retry is no longer cancellable"); + const fenceRetryCancellation = async () => { + if (coordinator.phase !== "retryable_failure") return; + await tx + .update(nativeRunFinalizations) + .set({ + phase: "terminal_failure", + nextAttemptAt: null, + leaseOwner: null, + leaseExpiresAt: null, + recoveryState: "blocked", + failureCode: "native_retry_cancelled", + updatedAt: new Date(), + }) + .where( + and( + eq(nativeRunFinalizations.runId, runId), + eq(nativeRunFinalizations.companyId, cancellationContext.companyId), + eq(nativeRunFinalizations.phase, "retryable_failure"), + ), + ); + }; const existing = record(resultJson.nativeCancellation); const existingIntentId = typeof existing.intentId === "string" && existing.intentId.length > 0 @@ -7564,6 +7585,10 @@ export async function cancelNativeSession( : null; if (!existingAuditId) throw new Error("native_cancellation_intent_audit_missing"); + if (retryCancellation && coordinator.phase === "retryable_failure") { + await notifyNativeResultCommit(tx, { companyId: cancellationContext.companyId, runId }); + await fenceRetryCancellation(); + } return { intentId: existingIntentId, auditId: existingAuditId, @@ -7643,24 +7668,7 @@ export async function cancelNativeSession( // Cancellation is also an authority fence for a durable retry whose // preceding provider has already failed. No in-memory session is needed. await notifyNativeResultCommit(tx, { companyId: cancellationContext.companyId, runId }); - await tx - .update(nativeRunFinalizations) - .set({ - phase: "terminal_failure", - nextAttemptAt: null, - leaseOwner: null, - leaseExpiresAt: null, - recoveryState: "blocked", - failureCode: "native_retry_cancelled", - updatedAt: new Date(), - }) - .where( - and( - eq(nativeRunFinalizations.runId, runId), - eq(nativeRunFinalizations.companyId, cancellationContext.companyId), - eq(nativeRunFinalizations.phase, "retryable_failure"), - ), - ); + await fenceRetryCancellation(); intentPublication = activity.publication; return { intentId, From 8bcc5a01edd5d87540401521e6f30d3df43e1848 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:27:33 -0500 Subject: [PATCH 3/7] fix(runner): preserve OpenCode written-answer defaults (#18456) ## Thinking Path > - Paperclip lets people answer questions while native agent work is active. > - Runner drivers translate provider question forms and answers into the shared contract. > - Pinned OpenCode 1.18.34 enables written answers when `custom` is omitted. > - The driver required explicit `true`, so it removed that input from ordinary native questions. > - The same branch could override a native opt-out or add a separate custom field to a text-only question. > - This pull request preserves the native default, explicit opt-out, original choices, and exact answer delivery. ## Linked Issues or Issue Description **What happened?** A native OpenCode question with choices and no `custom` field loses its written-answer control in Paperclip. The pinned provider enables that control by default. A legacy alias can also override native `custom:false`. A question with no choices and `custom:true` is rejected because the driver adds a separate custom field to text mode. **Expected behavior** Follow the [pinned OpenCode question contract](https://github.com/anomalyco/opencode/blob/v1.18.34/packages/schema/src/v1/question.ts). Preserve the written-answer default for choices. Honor native `custom:false`. Keep optionless questions text-only. Preserve every supplied choice and return the exact written answer to the same native request. **Steps to reproduce** Use the normalizer regression or the local OpenCode driver fixture. Emit a native question with choices and omit `custom`. The unfixed driver omits `customAnswer`; its existing written-answer roundtrip assertion fails. The separate negative controls reproduce native opt-out precedence and text-only normalization errors. **Paperclip version or commit** Reproduced on main `f2db909393de124caf6a5380a52d4e0e1d621661`, with pinned `opencode-ai@1.18.34`. ## What Changed - Apply the native `custom` flag first, then the legacy alias, then the pinned default of `true`. - Add a separate custom field only when choices exist. Keep optionless questions text-only. - Add default, opt-out, alias, and exact response validation regressions. Make the driver fixture omit the optional native flag and retain its exact custom-answer roundtrip assertion. - Document the pinned native behavior. ## Verification - Before the fix, the real normalizer fails three targeted assertions: omitted custom input, native opt-out precedence, and optionless text with `custom:true`. - The local driver subprocess regression also fails before the fix. Its native request omits `custom`, and the driver drops the expected written-answer control. - After the fix, all **79 tests in three files pass**: the new nine-case normalizer suite, the full 65-case OpenCode driver suite, and the five question-set response tests. The driver sends the unchanged written answer in the provider's ordered answer array and records one terminal request resolution. - Runner TypeScript check passes. A first attempt lacked the isolated eval-kernel declarations; building that local dependency resolved the setup gap. Locked dependencies were installed from the existing cache with scripts disabled. - Diff checks pass. Exact-head [canonical CI run 41203866644](https://github.com/paperclipai/paperclip/actions/runs/41203866644) passes build, typecheck, release registry, Runner checks, server tests, and all eight E2E shards at `72b21207e8590b78e24fe2cef4e3f6c374be53a4`. All current PR checks are terminal with no failures. Storybook is skipped for this scope and Paperclip Review is neutral. Full repository checks were not repeated locally for this narrow provider translation change. - [Fresh Greptile review](https://github.com/paperclipai/paperclip/pull/18456#issuecomment-6543742512) gives 5/5 on that exact head with no actionable findings. All review threads are addressed. GitHub reports `MERGEABLE` and `CLEAN`. - Local tests use a controlled provider fixture. They do not establish a paid model pass or attribute a deployment incident without its original native arguments. No QA run, runtime deployment, protected pin, journey oracle, prompt, budget, run bound, or model setting changed. ## Risks Native OpenCode choices now expose the written-answer control that the provider already enables by default. Explicit native opt-out wins over the legacy alias, and disabled custom responses remain rejected. Existing choice IDs and labels remain unchanged. Deployed users need the repaired Runner artifact through the normal delivery path. Local driver proof does not replace deployed journey qualification. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, source inspection, and code execution. The host does not expose the exact underlying model version or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with available version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs; none repairs this native default - [x] I have described the issue in-PR following the bug template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented risks - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before merge Co-authored-by: Paperclip --- packages/paperclip-runner/README.md | 6 +++ .../opencode-question-normalization.test.ts | 37 +++++++++++++++++++ .../opencode/opencode-server-driver.test.ts | 2 +- .../opencode/opencode-server-driver.ts | 3 +- .../test/fixtures/fake-opencode-server.mjs | 1 - 5 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 packages/paperclip-runner/src/drivers/opencode/opencode-question-normalization.test.ts diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 24808690a98..02271ebc583 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -485,6 +485,12 @@ stable free-text option IDs. Legacy callers remain supported. Calls that supply both forms must describe the same complete form; partial forms remain invalid. The native recovery bridge uses the same projection for answer delivery. +OpenCode 1.18.34 native questions allow a written answer by default when `custom` +is omitted. The driver preserves that default and explicit `custom:false`; the +native flag takes precedence over the legacy `allowCustom` alias. Choice options +remain intact. Questions without options remain text-only, without a separate +custom-answer field. See the [pinned native question contract](https://github.com/anomalyco/opencode/blob/v1.18.34/packages/schema/src/v1/question.ts). + The server validates canonical answer constraints before persistence. Regex matching runs in isolated workers with a one-second deadline and at most four active workers. A timeout or capacity error leaves the question pending. The diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-question-normalization.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-question-normalization.test.ts new file mode 100644 index 00000000000..9969fc30c0b --- /dev/null +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-question-normalization.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { parsePaperclipQuestionResponse } from "../../contracts/question-set.js"; +import { normalizeOpenCodeQuestionSet } from "./opencode-server-driver.js"; + +const question = { id: "reference", header: "Reference", question: "Which reference should appear in the note?", + options: [{ label: "No reference", description: "Do not include a reference" }] }; +const response = { schema: "paperclip.question_response.v1", answers: { reference: { customText: "Use AMBERexact." } } }; + +describe("OpenCode native written-answer defaults", () => { + it.each([ + { name: "omitted native default", fields: {}, enabled: true }, + { name: "explicit native true", fields: { custom: true }, enabled: true }, + { name: "explicit native false", fields: { custom: false }, enabled: false }, + { name: "legacy alias true", fields: { allowCustom: true }, enabled: true }, + { name: "legacy alias false", fields: { allowCustom: false }, enabled: false }, + { name: "native opt-out takes precedence", fields: { custom: false, allowCustom: true }, enabled: false }, + ])("preserves $name without changing the supplied choice", ({ fields, enabled }) => { + const set = normalizeOpenCodeQuestionSet([{ ...question, ...fields }]); + expect(set.questions[0]).toMatchObject({ id: "reference", prompt: question.question, answerMode: "single_select", + options: [{ id: "option-1", label: "No reference", description: "Do not include a reference" }] }); + if (enabled) { + expect(set.questions[0].customAnswer).toEqual({ enabled: true, label: "Other", placeholder: "Enter another answer" }); + expect(parsePaperclipQuestionResponse(set, response)).toEqual(response); + } else { + expect(set.questions[0]).not.toHaveProperty("customAnswer"); + expect(() => parsePaperclipQuestionResponse(set, response)).toThrow("custom answers are not enabled"); + } + }); + it.each([undefined, true, false])("keeps a question without choices text-only (custom=%s)", custom => { + const set = normalizeOpenCodeQuestionSet([{ ...question, options: [], ...(custom === undefined ? {} : { custom }) }]); + expect(set.questions[0]).toMatchObject({ id: "reference", answerMode: "text" }); + expect(set.questions[0]).not.toHaveProperty("options"); + expect(set.questions[0]).not.toHaveProperty("customAnswer"); + expect(parsePaperclipQuestionResponse(set, { ...response, answers: { reference: { text: "Use AMBERexact." } } })) + .toEqual({ ...response, answers: { reference: { text: "Use AMBERexact." } } }); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index f89dc89059c..58af69bad4b 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -1799,7 +1799,7 @@ describe("OpenCodeServerDriver", () => { await recovered!.session!.close({ reason: "recovery-test" }); }); - it("normalizes native question.asked events and replies with ordered OpenCode answer arrays", async () => { + it("preserves omitted native custom defaults and replies with ordered OpenCode answer arrays", async () => { await chmod(fixture, 0o755); const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-question-")); const workspace = await mkdtemp( diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index 45fb78ccefc..6576fe894e7 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -2654,6 +2654,7 @@ export function normalizeOpenCodeQuestionSet( ? { description: text(option.description).slice(0, 4_000) } : {}), })); + const custom = question.custom ?? question.allowCustom ?? true; return { id: openCodeQuestionId(question, index), ...(text(question.header) @@ -2674,7 +2675,7 @@ export function normalizeOpenCodeQuestionSet( ? "multi_select" : "single_select", ...(options.length > 0 ? { options } : {}), - ...(question.custom === true || question.allowCustom === true + ...(options.length > 0 && custom === true ? { customAnswer: { enabled: true, diff --git a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs index 3b03d4d52e2..38da64e96a1 100644 --- a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs +++ b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs @@ -41,7 +41,6 @@ function nativeQuestion() { description: "Deploy directly to production.", }, ], - custom: true, }, { id: "regions", From 47f81e850b14cc209a7cd5681accab860caefe55 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:54:03 -0500 Subject: [PATCH 4/7] fix(testops): verify unexpanded result job runs (#18462) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Chat feature evaluations send their results to TestOps. > - The final reader checks the result against its current GitHub run. > - A real result omitted the nested job object and had no presentation links. > - The reader rejected that passed result after a successful upload. > - This change joins the embedded job-run ID to the separate job-run response. > - The reader keeps the source, case, status, and attachment checks. ## Linked Issues or Issue Description Refs #18394 and #18322. **What happened?** The native chat test and cleanup passed. TestOps stored a passed result and three attachments. The final read-only step failed with `chat_testops_upload_mismatch`. The result response had a job-run ID, launch ID, and current GitHub run URL. It did not include the nested job object. Its presentation links were empty. **Expected behavior** The reader must accept that response when the separate job-run response proves the exact job, launch, and current GitHub run. It must reject conflicting identity or source data. **Steps to reproduce** 1. Return a valid result with an unexpanded job run and empty links. 2. Return the matching full job run from the existing job-run endpoint. 3. Call `verifyChatUpload`. The old reader rejects the result. **Paperclip version or commit** Observed on c2971badde04b602fa9fe698015e1f3653b39e6b. The fix starts from ab51cabbbd. **Deployment mode** GitHub Actions and AWS. This defect is in the TestOps report reader. ## What Changed - Join the exact embedded job-run ID to the separately checked job-run response. - Accept empty presentation links. Reject conflicting known run and commit links when present. - Reject conflicting embedded job fields. - Add regressions for the observed response and wrong job, run, launch, and source data. ## Verification - Three new tests fail against the parent reader. - `node --test tests/runner-e2e/chat-feature-testops-api.test.mjs`: 47 passed. - `node --test tests/runner-e2e/testops-chat-reporter.test.mjs`: 14 passed. - Both changed files pass `node --check`. `git diff --check` passes. - Local checks used Node 26.4. Normal CI must check the workflow runtime. - Full local typecheck, build, app, database, SDK, and provider runs were not run. This two-file MJS fix uses pure tests and mocks. - Normal CI [41205375203](https://github.com/paperclipai/paperclip/actions/runs/41205375203), attempt 1, passed on c7b5bf3238. Both required checks passed. Greptile is 5/5. The optional automated coverage note has a recorded disposition and is resolved. - The original feature verdict, backend pass, and failed final reader step remain separate. This change does not regrade that run. ## Risks - Empty presentation links do not prove a server commit link. Source identity comes from the trusted current GitHub context, selection, and bound export. - The separate job-run response remains required. All case, status, attachment, and deadline checks remain active. - The new reader has not yet run against the live service. CI and a later authorized run must qualify delivery. ## Model Used OpenAI Codex, `gpt-6.1-sol`, with reasoning, source inspection, and code execution. The host does not expose the context-window limit. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- tests/runner-e2e/chat-feature-testops-api.mjs | 10 +++-- .../chat-feature-testops-api.test.mjs | 40 +++++++++++++++++-- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/tests/runner-e2e/chat-feature-testops-api.mjs b/tests/runner-e2e/chat-feature-testops-api.mjs index 704dd5842a7..9b2b1ddfc3f 100644 --- a/tests/runner-e2e/chat-feature-testops-api.mjs +++ b/tests/runner-e2e/chat-feature-testops-api.mjs @@ -379,9 +379,13 @@ export async function verifyChatUpload(api, env, selected, exported, { wait = ms const result = await get(`/api/testresult/${row.id}`); if (result.id !== row.id || result.projectId !== 1 || result.launchId !== selected.launchId || result.testCaseId !== selected.caseId || result.fullName !== selected.fullName || result.jobRun?.id !== Number(selected.allureJobRunId) - || result.jobRun?.job?.id !== selected.jobId || result.jobRun?.launchId !== selected.launchId || result.jobRun?.url !== runUrl - || !Array.isArray(result.links) || !result.links.some(link => link.url === runUrl) - || !result.links.some(link => link.url === sourceUrl)) fail('chat_testops_upload_mismatch'); + || result.jobRun?.launchId !== selected.launchId || result.jobRun?.url !== runUrl + || result.jobRun.job != null && (result.jobRun.job.id !== run.job.id + || result.jobRun.job.type != null && result.jobRun.job.type !== run.job.type + || result.jobRun.job.url != null && !publicJobUrl(result.jobRun.job.url, env)) + || !Array.isArray(result.links) || result.links.some(link => typeof link.url === 'string' + && (link.url.startsWith('https://github.com/paperclipai/paperclip/actions/runs/') && link.url !== runUrl + || link.url.startsWith('https://github.com/paperclipai/paperclip/commit/') && link.url !== sourceUrl))) fail('chat_testops_upload_mismatch'); if (!['passed', 'failed', 'broken', 'skipped', 'unknown'].includes(result.status)) fail('chat_testops_upload_mismatch'); if (result.status !== 'unknown' && result.status !== exported.status) fail('chat_testops_upload_mismatch'); const attached = await get(`/api/testresult/attachment?${new URLSearchParams({ testResultId: String(row.id), page: '0', size: '100' })}`); diff --git a/tests/runner-e2e/chat-feature-testops-api.test.mjs b/tests/runner-e2e/chat-feature-testops-api.test.mjs index 4422a6e0d0b..9b2bc7c74f7 100644 --- a/tests/runner-e2e/chat-feature-testops-api.test.mjs +++ b/tests/runner-e2e/chat-feature-testops-api.test.mjs @@ -322,11 +322,11 @@ const uploadReceipt = { status: 'passed', cleanup: 'passed', caseId: 91, campaig { name: 'Execution provenance and checks', contentType: 'application/json', contentLength: 300 }, { name: 'Sanitized Product E2E record and accounting', contentType: 'application/json', contentLength: 800 }] }; const uploadRun = { id: 9, job: { id: 7, type: 'github' }, launchId: 10, url: 'https://github.com/paperclipai/paperclip/actions/runs/123', stage: 'finished' }; -function uploadApi(change = {}, absent = 0) { +function uploadApi(change = {}, absent = 0, runChange = {}) { let polls = 0; return async (route, method) => { assert.equal(method, 'GET'); - if (route === '/api/jobrun/9') return uploadRun; + if (route === '/api/jobrun/9') return { ...uploadRun, ...runChange }; if (route === '/api/launch/10') return { id: 10, projectId: 1 }; if (route.startsWith('/api/testresult?')) { polls++; return { content: polls <= absent ? [] : [{ id: 45, testCaseId: 91 }], totalElements: polls <= absent ? 0 : 1, totalPages: polls <= absent ? 0 : 1 }; } if (route === '/api/testresult/45') return { id: 45, projectId: 1, launchId: 10, testCaseId: 91, fullName, jobRun: uploadRun, status: 'passed', @@ -348,11 +348,45 @@ test('readback verifies exact current launch/case/source/status and safe attachm test('readback rejects wrong launch, case, current jobrun, source and contradictory result status', async () => { const { verifyChatUpload } = await import('./chat-feature-testops-api.mjs'); - for (const change of [{ launchId: 11 }, { testCaseId: 92 }, { jobRun: { ...uploadRun, id: 11 } }, { links: [] }, { status: 'failed' }]) { + for (const change of [{ launchId: 11 }, { testCaseId: 92 }, { jobRun: { ...uploadRun, id: 11 } }, { links: [{ url: 'https://github.com/paperclipai/paperclip/commit/' + 'b'.repeat(40) }] }, { status: 'failed' }]) { await assert.rejects(verifyChatUpload(uploadApi(change), uploadContext, uploadSelection, uploadReceipt), /upload_mismatch/); } }); +test('readback joins an unexpanded result jobrun to the authoritative jobrun response', async () => { + const { verifyChatUpload } = await import('./chat-feature-testops-api.mjs'); + const { job, ...embedded } = uploadRun; + const result = await verifyChatUpload(uploadApi({ jobRun: embedded }), uploadContext, uploadSelection, uploadReceipt); + assert.equal(result.resultId, 45); assert.equal(result.jobId, 7); assert.equal(result.verification, 'VERIFIED'); +}); + +test('readback accepts protected empty presentation links while retaining the source-bound current run', async () => { + const { verifyChatUpload } = await import('./chat-feature-testops-api.mjs'); + const { job, ...embedded } = uploadRun; + const result = await verifyChatUpload(uploadApi({ jobRun: embedded, links: [] }), uploadContext, uploadSelection, uploadReceipt); + assert.equal(result.targetSha, uploadContext.GITHUB_SHA); assert.equal(result.runId, '123'); + assert.equal(result.resultId, 45); assert.equal(result.verification, 'VERIFIED'); +}); + +test('readback rejects authoritative or embedded identity contradictions and a foreign source selection', async () => { + const { verifyChatUpload } = await import('./chat-feature-testops-api.mjs'); + const { job, ...embedded } = uploadRun; + for (const runChange of [{ job: { id: 8, type: 'github' } }, { job: { id: 7, type: 'gitlab' } }, + { url: 'https://github.com/paperclipai/paperclip/actions/runs/124' }, { launchId: 11 }]) { + await assert.rejects(verifyChatUpload(uploadApi({ jobRun: embedded, links: [] }, 0, runChange), uploadContext, uploadSelection, uploadReceipt), /jobrun_mismatch/); + } + for (const change of [{ jobRun: { ...embedded, id: 11 } }, { jobRun: { ...embedded, job: { id: 8 } } }, + { jobRun: { ...embedded, job: { id: 7, type: 'gitlab' } } }, { jobRun: { ...embedded, job: { id: 7, url: 'https://github.com/other/repo' } } }, + { jobRun: { ...embedded, launchId: 11 } }, { jobRun: { ...embedded, url: 'https://github.com/paperclipai/paperclip/actions/runs/124' } }, + { links: [{ url: 'https://github.com/paperclipai/paperclip/actions/runs/124' }] }, + { links: [{ url: 'https://github.com/paperclipai/paperclip/commit/' + 'b'.repeat(40) }] }]) { + await assert.rejects(verifyChatUpload(uploadApi(change), uploadContext, uploadSelection, uploadReceipt), /upload_mismatch/); + } + for (const selection of [{ ...uploadSelection, targetSha: 'b'.repeat(40) }, { ...uploadSelection, workflowSha: 'b'.repeat(40) }]) { + await assert.rejects(verifyChatUpload(uploadApi({ jobRun: embedded, links: [] }), uploadContext, selection, uploadReceipt), /upload_mismatch/); + } +}); + test('readback missing result or screenshot metadata is bounded unverified, never a fabricated pass', async () => { const { verifyChatUpload } = await import('./chat-feature-testops-api.mjs'); let waits = 0; From f46c8071fba4d8e27fcb6c9ae600f252fabee90d Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:59:41 -0500 Subject: [PATCH 5/7] fix(ui): make the Coordinator composer footer opaque (#18455) ## Thinking Path > - Paperclip helps people manage AI agents and their work. > - The Coordinator workspace shows a task transcript and a message composer. > - The composer footer can cover part of the transcript on desktop. > - The footer has a transparent background, so text shows through accessory panels and gaps. > - This pull request gives the footer a solid background from the active theme. > - Users can read the composer controls without text from the transcript behind them. ## Linked Issues or Issue Description **What happened?** The Coordinator composer footer lets transcript text show through the area above the message field. **Expected behavior** The full footer has a solid surface in light and dark themes. **Steps to reproduce** 1. Enable the Coordinator workspace. 2. Open a task with enough messages to scroll. 3. Show a composer accessory, such as queued messages or a pending question. 4. Scroll the transcript behind the footer on desktop. **Paperclip version or commit** Base commit: f2db909393de124caf6a5380a52d4e0e1d621661. **Deployment mode** Built from source. The change affects the Coordinator workspace. Related work: https://github.com/paperclipai/paperclip/pull/17983 added the workspace. Searches for an opaque or transparent composer fix found no duplicate. ## What Changed - Use `var(--background)` for the full Coordinator composer footer. - Keep the outer footer square so transcript text cannot show at its corners. - Block clicks through the footer gaps. Forward wheel input from these gaps to the transcript. - Keep editor scrolling, zoom input, and the classic workspace behavior. - Add regression tests for the footer surface, wheel input, and canceled forwarded events. - Keep wheel modifier keys. Use the font size to calculate horizontal distance for line-mode input. - Remove the old comment that describes a transparent footer. ## Verification - Passed: 173 tests in `TaskChatComposerDock`, `TaskChatComposerStyles`, `TaskChatComposer`, `TaskChatQueuedMessages`, and `TaskMessageScroller`. - The surface and wheel tests fail on the previous implementation and pass with this change. - The latest wheel tests reproduce lost modifier keys and excessive horizontal distance on the prior commit. The cancellation test fails when the cancellation guard is removed. - Passed: `pnpm check:token-gates` and `git diff --check`. - Passed: local Chromium checks with the production dock and built UI CSS in light and dark themes at desktop and mobile sizes. The checks cover solid corners, blocked clicks in footer gaps, wheel input, message entry, and horizontal overflow. - The browser fixture reproduces the hidden-control click when the old pointer rules are restored. These are local fixture checks, not a deployed acceptance result. - Passed: `pnpm --filter @paperclipai/ui typecheck` and `pnpm --filter @paperclipai/ui build`. - Full local `pnpm -r typecheck` and `pnpm build` could not finish because Cargo is absent from this environment. The full local test run was stopped without a complete result after these missing prerequisites were confirmed. Full CI now passes on the current commit. - Passed: [full CI](https://github.com/paperclipai/paperclip/actions/runs/41205893876) on commit `37feaef28`. Greptile and Paperclip both gave this commit 5/5. Both review threads are resolved. - Paperclip review found hidden clicks through footer gaps and exposed corners. This change addresses both findings. The first CI quality review required a regression test. This change adds those tests. The later review requested a cancellation test and better horizontal wheel handling. This change addresses both items. ## Risks - Low risk. The CSS selector applies only inside the Coordinator workspace. - The footer now captures clicks across its full area. Wheel input in empty footer space still scrolls the transcript. - The dock forwards wheel input to the transcript handler so earlier-history loading still receives that input. - No API, data, or migration changes. ## Model Used - OpenAI Codex, based on GPT-6 (`gpt-6`), with reasoning, tool use, and code execution. The runtime does not expose its context window. The model ID for the original saved CSS patch is not retained in the task evidence. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../task-chat/TaskChatComposerDock.test.tsx | 117 ++++++++++++++++++ .../task-chat/TaskChatComposerDock.tsx | 28 ++++- .../task-chat/TaskChatComposerStyles.test.ts | 8 ++ ui/src/index.css | 6 +- 4 files changed, 150 insertions(+), 9 deletions(-) create mode 100644 ui/src/components/task-chat/TaskChatComposerDock.test.tsx diff --git a/ui/src/components/task-chat/TaskChatComposerDock.test.tsx b/ui/src/components/task-chat/TaskChatComposerDock.test.tsx new file mode 100644 index 00000000000..68ba2e5623c --- /dev/null +++ b/ui/src/components/task-chat/TaskChatComposerDock.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TaskChatComposerDock } from "./TaskChatComposerDock"; + +describe("TaskChatComposerDock", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + flushSync(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + }); + + function render(coordinator = true) { + const onTranscriptWheel = vi.fn(); + flushSync(() => root.render( +
+
+
+ +