Merge current master native chat recovery into deferred receipts

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* commit '11b49125db5ea1b274e69faf9d1abe057cdbb551':
  fix(chat): recover committed native Agent Chat replies (#18465)
  perf(heartbeat): drive retry promotion from durable deadlines (#18387)
  fix(ui): make the Coordinator composer footer opaque (#18455)
  fix(testops): verify unexpanded result job runs (#18462)
  fix(runner): preserve OpenCode written-answer defaults (#18456)
  fix(native): fence retries when replaying an audited Stop (#18457)
  test: await async outcomes and drive saved deadlines deterministically (#18416)
This commit is contained in:
DottaandPaperclip committed 2026-10-31 09:22:28 -05:00
commit d6d0b67e8a
18 files changed
+522 -46

No files matched your search

+7
View File
@@ -2660,6 +2660,13 @@ An upgrade readiness report contains preliminary native evidence, not permission
to remove a deployment volume. Keep the checkpoint pool bounded and measure all to remove a deployment volume. Keep the checkpoint pool bounded and measure all
pools when qualifying database cost; the current size of two is a default. pools when qualifying database cost; the current size of two is a default.
Committed native Agent Chat turns recover their unanswered final reply through
the existing response presentation worker. Recovery verifies the accepted result
and final turn, agent, responsible user, native session and conversation generation.
It stores the exact final message before the normal conversation settlement moves
the chat to waiting. Later user input remains active. Recovery does not run the
agent again, replace a selected reply or restore a deleted reply.
For retained Codex event tails, keep source drain/accounting and target admission For retained Codex event tails, keep source drain/accounting and target admission
separate. `native-staged-attachment-recovery.ts` validates private source/target separate. `native-staged-attachment-recovery.ts` validates private source/target
header evidence; `native-recovery-usage-delivery.ts` binds the source's accounting header evidence; `native-recovery-usage-delivery.ts` binds the source's accounting
+4
View File
@@ -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 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. 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 Recovery instructions in a wake describe only an active or escalated recovery
action. Resolving or cancelling that action removes its repair instructions from action. Resolving or cancelling that action removes its repair instructions from
subsequent wakes, so the original worker can resume the task. This prompt change subsequent wakes, so the original worker can resume the task. This prompt change
+6
View File
@@ -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. both forms must describe the same complete form; partial forms remain invalid.
The native recovery bridge uses the same projection for answer delivery. 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 The server validates canonical answer constraints before persistence. Regex
matching runs in isolated workers with a one-second deadline and at most four 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 active workers. A timeout or capacity error leaves the question pending. The
@@ -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." } } });
});
});
@@ -1799,7 +1799,7 @@ describe("OpenCodeServerDriver", () => {
await recovered!.session!.close({ reason: "recovery-test" }); 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); await chmod(fixture, 0o755);
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-question-")); const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-question-"));
const workspace = await mkdtemp( const workspace = await mkdtemp(
@@ -2654,6 +2654,7 @@ export function normalizeOpenCodeQuestionSet(
? { description: text(option.description).slice(0, 4_000) } ? { description: text(option.description).slice(0, 4_000) }
: {}), : {}),
})); }));
const custom = question.custom ?? question.allowCustom ?? true;
return { return {
id: openCodeQuestionId(question, index), id: openCodeQuestionId(question, index),
...(text(question.header) ...(text(question.header)
@@ -2674,7 +2675,7 @@ export function normalizeOpenCodeQuestionSet(
? "multi_select" ? "multi_select"
: "single_select", : "single_select",
...(options.length > 0 ? { options } : {}), ...(options.length > 0 ? { options } : {}),
...(question.custom === true || question.allowCustom === true ...(options.length > 0 && custom === true
? { ? {
customAnswer: { customAnswer: {
enabled: true, enabled: true,
@@ -41,7 +41,6 @@ function nativeQuestion() {
description: "Deploy directly to production.", description: "Deploy directly to production.",
}, },
], ],
custom: true,
}, },
{ {
id: "regions", id: "regions",
@@ -0,0 +1,149 @@
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { agents, authUsers, companies, completionContracts, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issues,
nativeRunFinalizations, nativeRunResults, type Db } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { resolveExternalTestDatabaseUrl } from "../../__tests__/helpers/external-test-database.js";
import { NativeRunCoordinatorStore } from "./native-run-coordinator-store.js";
import { finalizeNativeRun, recoverCommittedNativeChatResponse } from "./native-run-finalizer.js";
import { nativeResponsePresentationWork } from "./native-response-presentation-work.js";
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_AGENT_CHAT_RESPONSE_TEST_DATABASE_URL");
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
let db: Db;
beforeAll(async () => {
if (!external) database = await startEmbeddedPostgresTestDatabase("agent-chat-response-");
db = createDb(external ?? database!.connectionString);
}, 60_000);
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
async function fixture(conversation = true) {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
const sessionId = randomUUID(), runnerId = randomUUID(), userId = `fixture-${randomUUID()}`, wakeId = randomUUID();
await db.insert(authUsers).values({ id: userId, name: "Conversation owner", email: `${userId}@example.test`,
createdAt: new Date("2020-01-01T00:00:00Z"), updatedAt: new Date("2020-01-01T00:00:00Z") });
await db.insert(companies).values({ id: companyId, name: "Reply recovery fixture", issuePrefix: companyId.slice(0, 8) });
await db.insert(agents).values({ id: agentId, companyId, name: "Conversation agent", adapterType: "paperclip_runner" });
await db.insert(issues).values({ id: issueId, companyId, title: "Conversation reply", status: "in_progress", assigneeAgentId: agentId,
...(conversation ? { conversationAgentId: agentId, conversationUserId: userId, conversationState: "active", conversationSessionGeneration: 0 } : {}) });
await db.insert(issueComments).values({ id: wakeId, companyId, issueId, authorUserId: userId, body: "Hire one teammate and tell me when ready.",
createdAt: new Date("2020-01-01T00:00:00Z") });
const [contract] = await db.insert(completionContracts).values({ companyId, issueId, revision: 1,
schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test", risk: "standard", completionAuthority: "server_arbiter",
incompleteCriteriaPolicy: "preserve_non_terminal", contractJson: { revision: "test", objective: "Answer the user", criteria: [] },
canonicalSha256: "fixture-conversation-contract", createdByActorType: "system", createdByActorId: "test" }).returning();
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issueId, nativeSessionId: sessionId,
runnerInstanceId: runnerId, completionContractId: contract.id, completionContractSha256: contract.canonicalSha256,
status: "running", runtimeMode: "native", responsibleUserId: userId,
contextSnapshot: { issueId, source: "issue.comment", wakeCommentId: wakeId, ...(conversation ? { conversationSessionGeneration: 0 } : {}) } });
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
const port = new NativeRunCoordinatorStore(db, { companyId, issueId, runId, agentId, normalizedSessionId: sessionId,
runnerSourceInstanceId: runnerId, completionContractId: contract.id, completionContractSha256: contract.canonicalSha256,
completionContractRevision: "test", completionContractCriterionIds: [] });
const reply = "The teammate is hired and ready for your next instruction.";
const prpEvent = { runId, normalizedSessionId: sessionId, turnId: "hire-turn", sourceEventId: "fixture-final-answer",
payload: { kind: "agentMessage", channel: "final", providerPhase: "final", text: reply } };
await db.insert(heartbeatRunEvents).values({ companyId, runId, agentId, seq: 170, eventType: "item.completed", payload: { prpEvent } });
await port.completeRun({ result: { schema: "paperclip.run_result.v1", reportedWorkDisposition: "done",
summary: "A completed hire, with its final message preserved separately.", completionClaim: { contractRevision: "test",
objectiveSatisfied: true, criteria: [], remainingWork: [] }, evidence: [], verification: [], attentionRequests: [], artifacts: [] },
terminal: { schema: "paperclip.prp.terminal.v1", runTerminalState: "succeeded", turnTerminalState: "completed", reportedWorkDisposition: "done" }, turnId: "hire-turn" });
await finalizeNativeRun({ db, runId, workspaceFinalizeStatus: "succeeded", projectRunStatus: false });
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date("2020-01-01T00:02:00Z") }).where(eq(heartbeatRuns.id, runId));
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId)))[0].phase).toBe("committed");
const input = { companyId, issueId, runId };
return { ...input, input, agentId, userId, wakeId, prpEvent, reply };
}
const comments = (runId: string) => db.select().from(issueComments).where(eq(issueComments.createdByRunId, runId));
const issue = async (issueId: string) => (await db.select().from(issues).where(eq(issues.id, issueId)))[0];
it("finds a committed final Agent Chat turn, persists the exact reply once and only then settles waiting", async () => {
const f = await fixture();
expect(await comments(f.runId)).toEqual([]);
expect(await issue(f.issueId)).toMatchObject({ status: "in_progress", conversationState: "active" });
const work = nativeResponsePresentationWork(db), entries = await work.load(f.input);
expect(entries).toEqual([expect.objectContaining({ id: f.runId })]);
await expect(work.check(entries[0])).resolves.toBeNull();
expect(await comments(f.runId)).toEqual([expect.objectContaining({ body: f.reply, authorAgentId: f.agentId, createdByRunId: f.runId })]);
expect(await issue(f.issueId)).toMatchObject({ status: "in_review", conversationState: "waiting" });
expect(await work.load(f.input)).toEqual([]);
await expect(recoverCommittedNativeChatResponse(db, f.input)).resolves.toBe(false);
expect(await comments(f.runId)).toHaveLength(1);
});
it("keeps a later user message actionable after recovering the earlier exact reply", async () => {
const f = await fixture();
await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorUserId: f.userId, body: "Another instruction",
createdAt: new Date("2020-01-01T00:01:00Z") });
await expect(recoverCommittedNativeChatResponse(db, f.input)).resolves.toBe(true);
expect(await comments(f.runId)).toHaveLength(1);
expect(await issue(f.issueId)).toMatchObject({ status: "in_progress", conversationState: "active" });
});
it("does not duplicate a reply under concurrent recovery", async () => {
const f = await fixture();
expect((await Promise.all([recoverCommittedNativeChatResponse(db, f.input), recoverCommittedNativeChatResponse(db, f.input)])).sort())
.toEqual([false, true]);
expect(await comments(f.runId)).toHaveLength(1);
});
it.each(["skip", "generation", "agent", "user", "execution", "waiting", "turn", "event-run", "session", "channel", "kind", "digest", "partial"])(
"keeps %s mismatches unchanged instead of publishing an unauthorized or incomplete reply", async fault => {
const f = await fixture();
if (["skip", "generation"].includes(fault)) await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId,
source: "issue.comment", wakeCommentId: f.wakeId, conversationSessionGeneration: fault === "generation" ? 1 : 0,
...(fault === "skip" ? { skipIssueComment: true } : {}) } }).where(eq(heartbeatRuns.id, f.runId));
if (fault === "user") await db.update(heartbeatRuns).set({ responsibleUserId: "another-user" }).where(eq(heartbeatRuns.id, f.runId));
if (fault === "agent") {
const other = randomUUID(); await db.insert(agents).values({ id: other, companyId: f.companyId, name: "Other agent" });
await db.update(issues).set({ conversationAgentId: other, assigneeAgentId: other }).where(eq(issues.id, f.issueId));
}
if (fault === "execution") {
const other = randomUUID(); await db.insert(heartbeatRuns).values({ id: other, companyId: f.companyId, agentId: f.agentId });
await db.update(issues).set({ executionRunId: other }).where(eq(issues.id, f.issueId));
}
if (fault === "waiting") await db.update(issues).set({ executionRunId: null, status: "in_review", conversationState: "waiting" }).where(eq(issues.id, f.issueId));
if (["turn", "event-run", "session", "channel", "kind"].includes(fault)) {
const event = structuredClone(f.prpEvent);
if (fault === "turn") event.turnId = "foreign-turn";
if (fault === "event-run") event.runId = randomUUID();
if (fault === "session") event.normalizedSessionId = randomUUID();
if (fault === "channel") event.payload.channel = "analysis";
if (fault === "kind") event.payload.kind = "reasoning";
await db.update(heartbeatRunEvents).set({ payload: { prpEvent: event } }).where(and(eq(heartbeatRunEvents.runId, f.runId), eq(heartbeatRunEvents.seq, 170)));
}
if (fault === "digest") await db.update(nativeRunResults).set({ canonicalSha256: "invalid-digest" }).where(eq(nativeRunResults.runId, f.runId));
if (fault === "partial") await db.update(nativeRunFinalizations).set({ phase: "copying_back" }).where(eq(nativeRunFinalizations.runId, f.runId));
const before = await issue(f.issueId);
await expect(recoverCommittedNativeChatResponse(db, f.input)).resolves.toBe(false);
expect(await comments(f.runId)).toEqual([]); expect(await issue(f.issueId)).toEqual(before);
},
);
it.each([false, true])("preserves a selected reply without replacing or resurrecting it (deleted=%s)", async deleted => {
const f = await fixture();
const [saved] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorAgentId: f.agentId,
createdByRunId: f.runId, body: "Already selected", ...(deleted ? { deletedAt: new Date("2020-01-01T00:03:00Z") } : {}) }).returning();
await db.update(heartbeatRuns).set({ resultJson: { presentationDecision: { commentId: saved.id, commentAction: "create" } } }).where(eq(heartbeatRuns.id, f.runId));
await expect(recoverCommittedNativeChatResponse(db, f.input)).resolves.toBe(false);
expect(await comments(f.runId)).toEqual([saved]);
});
it("never crosses company scope and keeps ordinary internal task response recovery intact", async () => {
const f = await fixture(false);
await expect(recoverCommittedNativeChatResponse(db, { ...f.input, companyId: randomUUID() })).resolves.toBe(false);
const work = nativeResponsePresentationWork(db), entries = await work.load(f.input);
expect(entries).toHaveLength(1); await expect(work.check(entries[0])).resolves.toBeNull();
expect(await comments(f.runId)).toEqual([expect.objectContaining({ body: f.reply })]);
expect((await issue(f.issueId)).conversationAgentId).toBeNull();
});
it("rolls back a partial reply write without changing the waiting state or consuming recovery", async () => {
const f = await fixture(), abort = new Error("fixture-commit-aborted");
const failing = new Proxy(db, { get(target, key) { return key === "transaction"
? (callback: Parameters<Db["transaction"]>[0]) => db.transaction(async tx => { await callback(tx); throw abort; })
: Reflect.get(target, key); } });
await expect(recoverCommittedNativeChatResponse(failing, f.input)).rejects.toBe(abort);
expect(await comments(f.runId)).toEqual([]);
expect(await issue(f.issueId)).toMatchObject({ status: "in_progress", conversationState: "active" });
await expect(recoverCommittedNativeChatResponse(db, f.input)).resolves.toBe(true);
expect(await comments(f.runId)).toHaveLength(1);
});
});
@@ -1,6 +1,6 @@
import { QueryBuilder } from "drizzle-orm/pg-core"; import { QueryBuilder } from "drizzle-orm/pg-core";
import { and, asc, eq, gt, inArray, isNotNull, isNull, notExists, or, sql } from "drizzle-orm"; import { and, asc, eq, exists, gt, inArray, isNotNull, isNull, notExists, or, sql } from "drizzle-orm";
import { heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, statusDecisions, statusDecisionEffects } from "@paperclipai/db"; import { heartbeatRunEvents, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, statusDecisions, statusDecisionEffects } from "@paperclipai/db";
import type { NativeResponsePresentationScope } from "../native-response-presentation-notifications.js"; import type { NativeResponsePresentationScope } from "../native-response-presentation-notifications.js";
export function nativeResponsePresentationCandidates(scope?: NativeResponsePresentationScope, after?: string, runId?: string) { export function nativeResponsePresentationCandidates(scope?: NativeResponsePresentationScope, after?: string, runId?: string) {
@@ -30,7 +30,24 @@ export function nativeResponsePresentationCandidates(scope?: NativeResponsePrese
sql`${nativeRunResults.resultJson} #>> '{terminal,turnTerminalState}' = 'completed'`, sql`${nativeRunResults.resultJson} #>> '{terminal,turnTerminalState}' = 'completed'`,
or( or(
and(eq(heartbeatRuns.status, "succeeded"), and(eq(heartbeatRuns.status, "succeeded"),
sql`(coalesce(${issues.conversationAgentId}::text, '') = '' or coalesce(${issues.conversationUserId}, '') = '')`, or(sql`(coalesce(${issues.conversationAgentId}::text, '') = '' or coalesce(${issues.conversationUserId}, '') = '')`,
and(eq(statusDecisions.reasonCode, "conversation_turn_finished"),
eq(issues.conversationAgentId, heartbeatRuns.agentId), eq(issues.conversationUserId, heartbeatRuns.responsibleUserId),
eq(issues.status, "in_progress"), eq(issues.conversationState, "active"),
or(isNull(issues.executionRunId), eq(issues.executionRunId, heartbeatRuns.id)),
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${issues.id}::text`,
sql`jsonb_typeof(${heartbeatRuns.contextSnapshot}->'conversationSessionGeneration') = 'number'`,
sql`${heartbeatRuns.contextSnapshot}->>'conversationSessionGeneration' = ${issues.conversationSessionGeneration}::text`,
exists(db.select({ id: heartbeatRunEvents.id }).from(heartbeatRunEvents).where(and(
eq(heartbeatRunEvents.companyId, heartbeatRuns.companyId), eq(heartbeatRunEvents.runId, heartbeatRuns.id),
eq(heartbeatRunEvents.agentId, heartbeatRuns.agentId), eq(heartbeatRunEvents.eventType, "item.completed"),
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,runId}' = ${heartbeatRuns.id}::text`,
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,normalizedSessionId}' = ${heartbeatRuns.nativeSessionId}::text`,
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,turnId}' = ${nativeRunResults.turnId}`,
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,kind}' = 'agentMessage'`,
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,channel}' = 'final'`,
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,text}' ~ '[^[:space:]]'`,
))))),
sql`${nativeRunResults.resultJson} #>> '{result,reportedWorkDisposition}' = 'done'`, sql`${nativeRunResults.resultJson} #>> '{result,reportedWorkDisposition}' = 'done'`,
sql`${nativeRunResults.resultJson} #>> '{terminal,reportedWorkDisposition}' = 'done'`), sql`${nativeRunResults.resultJson} #>> '{terminal,reportedWorkDisposition}' = 'done'`),
and(sql`${nativeRunResults.resultJson} #>> '{result,reportedWorkDisposition}' = 'yielded'`, and(sql`${nativeRunResults.resultJson} #>> '{result,reportedWorkDisposition}' = 'yielded'`,
@@ -29,7 +29,7 @@ import { readPersistedNativeFailureEvidence } from "./native-provider-failure-ev
import { dismissAutomaticCompletionReviews } from "./automatic-completion-reviews.js"; import { dismissAutomaticCompletionReviews } from "./automatic-completion-reviews.js";
import { findSupersededExternalReviewRequests } from "./superseded-external-review-requests.js"; import { findSupersededExternalReviewRequests } from "./superseded-external-review-requests.js";
import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-review-participant.js"; import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-review-participant.js";
import { conversationNativeDecision, isConversation } from "../agent-conversations.js"; import { conversationNativeDecision, isConversation, settleConversationTurnForRun } from "../agent-conversations.js";
import { issueTreeControlService } from "../issue-tree-control.js"; import { issueTreeControlService } from "../issue-tree-control.js";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { preserveNativeWorkspaceExportLease } from "./native-workspace-export-resume.js"; import { preserveNativeWorkspaceExportLease } from "./native-workspace-export-resume.js";
@@ -747,6 +747,7 @@ async function recoverCommittedNativeChatResponseInScope(
): Promise<boolean> { ): Promise<boolean> {
let agentId: string | null = null; let agentId: string | null = null;
let materialized = false; let materialized = false;
let conversationReply = false;
const entered = beginRunPhase("response.transaction.entry"); const entered = beginRunPhase("response.transaction.entry");
try { try {
materialized = await measureRunPhase("response.transaction", () => db.transaction(async (transaction) => { materialized = await measureRunPhase("response.transaction", () => db.transaction(async (transaction) => {
@@ -789,6 +790,7 @@ async function recoverCommittedNativeChatResponseInScope(
id: heartbeatRuns.id, id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId, companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId, agentId: heartbeatRuns.agentId,
responsibleUserId: heartbeatRuns.responsibleUserId,
nativeIssueId: heartbeatRuns.nativeIssueId, nativeIssueId: heartbeatRuns.nativeIssueId,
nativeSessionId: heartbeatRuns.nativeSessionId, nativeSessionId: heartbeatRuns.nativeSessionId,
completionContractId: heartbeatRuns.completionContractId, completionContractId: heartbeatRuns.completionContractId,
@@ -803,6 +805,8 @@ async function recoverCommittedNativeChatResponseInScope(
contextSnapshot: sql<Record<string, unknown> | null>`case when ${heartbeatRuns.contextSnapshot} is null then null contextSnapshot: sql<Record<string, unknown> | null>`case when ${heartbeatRuns.contextSnapshot} is null then null
else jsonb_build_object( else jsonb_build_object(
'skipIssueComment', ${heartbeatRuns.contextSnapshot}->'skipIssueComment', 'skipIssueComment', ${heartbeatRuns.contextSnapshot}->'skipIssueComment',
'issueId', ${heartbeatRuns.contextSnapshot}->'issueId',
'conversationSessionGeneration', ${heartbeatRuns.contextSnapshot}->'conversationSessionGeneration',
'source', ${heartbeatRuns.contextSnapshot}->'source', 'source', ${heartbeatRuns.contextSnapshot}->'source',
'externalChatContinuation', ${heartbeatRuns.contextSnapshot}->'externalChatContinuation', 'externalChatContinuation', ${heartbeatRuns.contextSnapshot}->'externalChatContinuation',
'paperclipWake', jsonb_build_object('externalInteractionContinuation', 'paperclipWake', jsonb_build_object('externalInteractionContinuation',
@@ -909,12 +913,16 @@ async function recoverCommittedNativeChatResponseInScope(
agentId = run.agentId; agentId = run.agentId;
return true; return true;
} }
// Copyback can be owned by reconciliation before the live heartbeat const conversation = isConversation(issue);
// reaches its presentation step. Recover a completed internal task's const currentConversation = conversation && decision.reasonCode === "conversation_turn_finished"
// exact final reply from the same accepted turn, without rerunning work && issue.conversationAgentId === run.agentId && issue.conversationUserId === run.responsibleUserId
// or granting external-chat publication authority. && issue.status === "in_progress" && issue.conversationState === "active"
&& (!issue.executionRunId || issue.executionRunId === run.id)
&& run.contextSnapshot?.issueId === issue.id
&& typeof run.contextSnapshot.conversationSessionGeneration === "number"
&& run.contextSnapshot.conversationSessionGeneration === issue.conversationSessionGeneration;
if ( if (
!isConversation(issue) && (!conversation || currentConversation) &&
run.status === "succeeded" && run.status === "succeeded" &&
record(run.contextSnapshot).skipIssueComment !== true && record(run.contextSnapshot).skipIssueComment !== true &&
!isExternalChatPresentationContext(run.contextSnapshot) && !isExternalChatPresentationContext(run.contextSnapshot) &&
@@ -929,6 +937,9 @@ async function recoverCommittedNativeChatResponseInScope(
eq(heartbeatRunEvents.companyId, input.companyId), eq(heartbeatRunEvents.companyId, input.companyId),
eq(heartbeatRunEvents.runId, run.id), eq(heartbeatRunEvents.runId, run.id),
eq(heartbeatRunEvents.eventType, "item.completed"), eq(heartbeatRunEvents.eventType, "item.completed"),
conversation ? and(eq(heartbeatRunEvents.agentId, run.agentId),
sql`${heartbeatRunEvents.payload}->'prpEvent'->>'runId' = ${run.id}`,
sql`${heartbeatRunEvents.payload}->'prpEvent'->>'normalizedSessionId' = ${run.nativeSessionId}`) : undefined,
sql`${heartbeatRunEvents.payload}->'prpEvent'->>'turnId' = ${accepted.turnId}`, sql`${heartbeatRunEvents.payload}->'prpEvent'->>'turnId' = ${accepted.turnId}`,
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,kind}' = 'agentMessage'`, sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,kind}' = 'agentMessage'`,
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,channel}' = 'final'`, sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,channel}' = 'final'`,
@@ -963,6 +974,7 @@ async function recoverCommittedNativeChatResponseInScope(
})}::jsonb`, updatedAt: new Date(), })}::jsonb`, updatedAt: new Date(),
}).where(eq(heartbeatRuns.id, run.id)); }).where(eq(heartbeatRuns.id, run.id));
agentId = run.agentId; agentId = run.agentId;
conversationReply = conversation;
return true; return true;
} }
if ( if (
@@ -1039,12 +1051,14 @@ async function recoverCommittedNativeChatResponseInScope(
} finally { } finally {
entered(); entered();
} }
if (materialized && agentId) if (materialized && agentId) {
if (conversationReply) await settleConversationTurnForRun(db, input.companyId, input.runId);
publishChatPublicationCommitSignal({ publishChatPublicationCommitSignal({
...input, ...input,
agentId, agentId,
eventType: "run.presentation.resolved", eventType: "run.presentation.resolved",
}); });
}
return materialized; return materialized;
} }
@@ -99,6 +99,7 @@ import { buildNativeHeartbeatPreparationSpans, createNativeRunTrace } from "./na
import { NativeRunnerOwnershipUnverifiedError } from "./native-runner-ownership.js"; import { NativeRunnerOwnershipUnverifiedError } from "./native-runner-ownership.js";
import type { AdapterRuntimeEvent } from "../../adapters/index.js"; import type { AdapterRuntimeEvent } from "../../adapters/index.js";
import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils"; import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
import { acknowledgedNativeStopExecutionHasStopped, isAcknowledgedNativeStop } from "../acknowledged-native-stop.js";
vi.mock("node:fs", async (importOriginal) => { vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>(); const actual = await importOriginal<typeof import("node:fs")>();
@@ -7852,6 +7853,27 @@ describe("native session cancellation", () => {
expect(state.persistActivity).not.toHaveBeenCalled(); 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 () => { it("does not mistake a recovered running attempt for the failed retry that reserved Stop", async () => {
const id = "11111111-1111-4111-8111-111111111111"; const id = "11111111-1111-4111-8111-111111111111";
const persistence = cancellationDb({ status: "running", const persistence = cancellationDb({ status: "running",
@@ -7877,6 +7899,39 @@ describe("native session cancellation", () => {
expect(persistence.getResultJsonUpdateCount()).toBe(writes); 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 () => { it("uses the reserved caller UUID and permits same-intent and default retries", async () => {
const id = "11111111-1111-4111-8111-111111111111"; const id = "11111111-1111-4111-8111-111111111111";
const persistence = cancellationDb({ resultJson: { startupCancellation: { cancellationRequestId: id } } }); const persistence = cancellationDb({ resultJson: { startupCancellation: { cancellationRequestId: id } } });
@@ -7541,6 +7541,27 @@ export async function cancelNativeSession(
runId, companyId: cancellationContext.companyId, issueId: cancellationContext.issueId, runId, companyId: cancellationContext.companyId, issueId: cancellationContext.issueId,
resultJson, requestId: requestedId, scope: options.scope ?? "run", resultJson, requestId: requestedId, scope: options.scope ?? "run",
}, coordinator)) throw conflict("Native retry is no longer cancellable"); }, 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 existing = record(resultJson.nativeCancellation);
const existingIntentId = const existingIntentId =
typeof existing.intentId === "string" && existing.intentId.length > 0 typeof existing.intentId === "string" && existing.intentId.length > 0
@@ -7564,6 +7585,10 @@ export async function cancelNativeSession(
: null; : null;
if (!existingAuditId) if (!existingAuditId)
throw new Error("native_cancellation_intent_audit_missing"); throw new Error("native_cancellation_intent_audit_missing");
if (retryCancellation && coordinator.phase === "retryable_failure") {
await notifyNativeResultCommit(tx, { companyId: cancellationContext.companyId, runId });
await fenceRetryCancellation();
}
return { return {
intentId: existingIntentId, intentId: existingIntentId,
auditId: existingAuditId, auditId: existingAuditId,
@@ -7643,24 +7668,7 @@ export async function cancelNativeSession(
// Cancellation is also an authority fence for a durable retry whose // Cancellation is also an authority fence for a durable retry whose
// preceding provider has already failed. No in-memory session is needed. // preceding provider has already failed. No in-memory session is needed.
await notifyNativeResultCommit(tx, { companyId: cancellationContext.companyId, runId }); await notifyNativeResultCommit(tx, { companyId: cancellationContext.companyId, runId });
await tx await fenceRetryCancellation();
.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"),
),
);
intentPublication = activity.publication; intentPublication = activity.publication;
return { return {
intentId, intentId,
@@ -379,9 +379,13 @@ export async function verifyChatUpload(api, env, selected, exported, { wait = ms
const result = await get(`/api/testresult/${row.id}`); const result = await get(`/api/testresult/${row.id}`);
if (result.id !== row.id || result.projectId !== 1 || result.launchId !== selected.launchId || result.testCaseId !== selected.caseId 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.fullName !== selected.fullName || result.jobRun?.id !== Number(selected.allureJobRunId)
|| result.jobRun?.job?.id !== selected.jobId || result.jobRun?.launchId !== selected.launchId || result.jobRun?.url !== runUrl || result.jobRun?.launchId !== selected.launchId || result.jobRun?.url !== runUrl
|| !Array.isArray(result.links) || !result.links.some(link => link.url === runUrl) || result.jobRun.job != null && (result.jobRun.job.id !== run.job.id
|| !result.links.some(link => link.url === sourceUrl)) fail('chat_testops_upload_mismatch'); || 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 (!['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'); 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' })}`); const attached = await get(`/api/testresult/attachment?${new URLSearchParams({ testResultId: String(row.id), page: '0', size: '100' })}`);
@@ -322,11 +322,11 @@ const uploadReceipt = { status: 'passed', cleanup: 'passed', caseId: 91, campaig
{ name: 'Execution provenance and checks', contentType: 'application/json', contentLength: 300 }, { name: 'Execution provenance and checks', contentType: 'application/json', contentLength: 300 },
{ name: 'Sanitized Product E2E record and accounting', contentType: 'application/json', contentLength: 800 }] }; { 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' }; 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; let polls = 0;
return async (route, method) => { return async (route, method) => {
assert.equal(method, 'GET'); 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 === '/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.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', 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 () => { test('readback rejects wrong launch, case, current jobrun, source and contradictory result status', async () => {
const { verifyChatUpload } = await import('./chat-feature-testops-api.mjs'); 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/); 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 () => { test('readback missing result or screenshot metadata is bounded unverified, never a fabricated pass', async () => {
const { verifyChatUpload } = await import('./chat-feature-testops-api.mjs'); const { verifyChatUpload } = await import('./chat-feature-testops-api.mjs');
let waits = 0; let waits = 0;
@@ -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(
<div className={coordinator ? "coordinator-workspace" : undefined}>
<div data-testid="task-chat-thread">
<div data-testid="task-chat-scroller" onWheel={onTranscriptWheel} />
<TaskChatComposerDock mobile={false} streamlined>
<textarea aria-label="Message" />
</TaskChatComposerDock>
</div>
</div>,
));
const dock = container.querySelector<HTMLDivElement>('[data-testid="task-chat-composer-dock"]')!;
const scroller = container.querySelector<HTMLDivElement>('[data-testid="task-chat-scroller"]')!;
const scrollBy = vi.fn();
scroller.scrollBy = scrollBy;
Object.defineProperty(scroller, "clientHeight", { value: 400 });
Object.defineProperty(scroller, "clientWidth", { value: 600 });
vi.spyOn(window, "getComputedStyle").mockReturnValue({ lineHeight: "24px", fontSize: String(16) } as CSSStyleDeclaration);
return { dock, scroller, scrollBy, onTranscriptWheel };
}
it.each([
[WheelEvent.DOM_DELTA_PIXEL, 30],
[WheelEvent.DOM_DELTA_LINE, 720],
[WheelEvent.DOM_DELTA_PAGE, 12000],
])("scrolls the transcript from a footer gap with delta mode %i", (deltaMode, top) => {
const { dock, scrollBy, onTranscriptWheel } = render();
const event = new WheelEvent("wheel", { deltaY: -30, deltaMode, bubbles: true, cancelable: true });
dock.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(scrollBy).toHaveBeenCalledWith({ top: -top, left: 0, behavior: "instant" });
expect(onTranscriptWheel).toHaveBeenCalledOnce();
expect(onTranscriptWheel.mock.calls[0][0].deltaY).toBe(-30);
});
it("does not scroll again when a transcript listener cancels the forwarded wheel", () => {
const { dock, scroller, scrollBy, onTranscriptWheel } = render();
scroller.addEventListener("wheel", event => event.preventDefault());
const event = new WheelEvent("wheel", { deltaY: -30, bubbles: true, cancelable: true });
dock.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(onTranscriptWheel).toHaveBeenCalledOnce();
expect(onTranscriptWheel.mock.calls[0][0].defaultPrevented).toBe(true);
expect(scrollBy).not.toHaveBeenCalled();
});
it.each([
[WheelEvent.DOM_DELTA_PIXEL, 3],
[WheelEvent.DOM_DELTA_LINE, 24],
[WheelEvent.DOM_DELTA_PAGE, 1800],
])("preserves modifiers and horizontal wheel distance with delta mode %i", (deltaMode, left) => {
const { dock, scrollBy, onTranscriptWheel } = render();
const event = new WheelEvent("wheel", {
deltaX: 3, deltaMode, shiftKey: true, altKey: true, metaKey: true, bubbles: true, cancelable: true,
});
dock.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(scrollBy).toHaveBeenCalledWith({ left, top: 0, behavior: "instant" });
expect(onTranscriptWheel.mock.calls[0][0]).toMatchObject({
deltaX: 3, deltaMode, shiftKey: true, altKey: true, metaKey: true, ctrlKey: false,
});
});
it("keeps editor scrolling and zoom gestures with their original targets", () => {
const { dock, scrollBy, onTranscriptWheel } = render();
const editorEvent = new WheelEvent("wheel", { deltaY: 30, bubbles: true, cancelable: true });
const zoomEvent = new WheelEvent("wheel", { deltaY: 30, ctrlKey: true, bubbles: true, cancelable: true });
dock.querySelector("textarea")!.dispatchEvent(editorEvent);
dock.dispatchEvent(zoomEvent);
expect(editorEvent.defaultPrevented).toBe(false);
expect(zoomEvent.defaultPrevented).toBe(false);
expect(scrollBy).not.toHaveBeenCalled();
expect(onTranscriptWheel).not.toHaveBeenCalled();
});
it("preserves the classic workspace wheel behavior", () => {
const { dock, scrollBy } = render(false);
const event = new WheelEvent("wheel", { deltaY: 30, bubbles: true, cancelable: true });
dock.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(scrollBy).not.toHaveBeenCalled();
});
});
@@ -8,20 +8,38 @@ interface TaskChatComposerDockProps {
concealed?: boolean; concealed?: boolean;
} }
/** A normal flex footer beside the message scroller, on desktop and mobile. */
export function TaskChatComposerDock({ children, mobile, streamlined, concealed = false }: TaskChatComposerDockProps) { export function TaskChatComposerDock({ children, mobile, streamlined, concealed = false }: TaskChatComposerDockProps) {
const dockRef = useRef<HTMLDivElement>(null); const dockRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
const dock = dockRef.current; const dock = dockRef.current;
const thread = dock?.closest<HTMLElement>('[data-testid="task-chat-thread"]'); const thread = dock?.closest<HTMLElement>('[data-testid="task-chat-thread"]');
if (!dock || !thread || !thread.closest(".coordinator-workspace")) return; if (!dock || !thread || !thread.closest(".coordinator-workspace")) return;
const forwardGapWheel = (event: WheelEvent) => {
if (event.target !== dock || event.ctrlKey || event.defaultPrevented) return;
const scroller = thread.querySelector<HTMLElement>('[data-testid="task-chat-scroller"]');
if (!scroller) return;
event.preventDefault();
const { deltaX, deltaY, deltaMode, altKey, ctrlKey, metaKey, shiftKey } = event;
if (!scroller.dispatchEvent(new WheelEvent("wheel", {
deltaX, deltaY, deltaMode, altKey, ctrlKey, metaKey, shiftKey, bubbles: true, cancelable: true,
}))) return;
const style = window.getComputedStyle(scroller);
const fontSize = Number.parseFloat(style.fontSize);
const lineHeight = Number.parseFloat(style.lineHeight) || fontSize;
const verticalUnit = deltaMode === WheelEvent.DOM_DELTA_LINE ? lineHeight
: deltaMode === WheelEvent.DOM_DELTA_PAGE ? scroller.clientHeight : 1;
const horizontalUnit = deltaMode === WheelEvent.DOM_DELTA_LINE ? fontSize / 2
: deltaMode === WheelEvent.DOM_DELTA_PAGE ? scroller.clientWidth : 1;
scroller.scrollBy({ left: deltaX * horizontalUnit, top: deltaY * verticalUnit, behavior: "instant" });
};
dock.addEventListener("wheel", forwardGapWheel, { passive: false });
const measure = () => thread.style.setProperty("--task-composer-dock-height", `${dock.getBoundingClientRect().height}px`); const measure = () => thread.style.setProperty("--task-composer-dock-height", `${dock.getBoundingClientRect().height}px`);
measure(); measure();
if (typeof ResizeObserver === "undefined") return; const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
const observer = new ResizeObserver(measure); observer?.observe(dock);
observer.observe(dock);
return () => { return () => {
observer.disconnect(); dock.removeEventListener("wheel", forwardGapWheel);
observer?.disconnect();
thread.style.removeProperty("--task-composer-dock-height"); thread.style.removeProperty("--task-composer-dock-height");
}; };
}, []); }, []);
@@ -21,6 +21,14 @@ function customPropertyValue(name: string): string {
} }
describe("task-chat composer styles", () => { describe("task-chat composer styles", () => {
it("covers transcript text and blocks clicks across the Coordinator footer", () => {
const block = cssBlock('.coordinator-workspace [data-testid="task-chat-composer-dock"]');
expect(block).toContain("background: var(--background)");
expect(block).toContain("pointer-events: auto");
expect(block).not.toContain("border-radius");
});
it("wraps long placeholders within the composer instead of clipping them", () => { it("wraps long placeholders within the composer instead of clipping them", () => {
const block = cssBlock( const block = cssBlock(
'.paperclip-task-chat-composer .paperclip-mdxeditor [class*="_placeholder_"]', '.paperclip-task-chat-composer .paperclip-mdxeditor [class*="_placeholder_"]',
+2 -4
View File
@@ -5018,14 +5018,12 @@ html:not(.dark) .coordinator-sidebar {
.coordinator-workspace [data-conversation-text="human"] { background: var(--bubble-human-bg); color: var(--bubble-human-fg); } .coordinator-workspace [data-conversation-text="human"] { background: var(--bubble-human-bg); color: var(--bubble-human-fg); }
.coordinator-workspace [data-question-answer] { background: var(--card); } .coordinator-workspace [data-question-answer] { background: var(--card); }
.dark .coordinator-workspace [data-question-answer] { background: var(--muted); } .dark .coordinator-workspace [data-question-answer] { background: var(--muted); }
/* Only the composer surface obscures history; its surrounding dock is clear. */
.coordinator-workspace [data-testid="task-chat-composer-dock"] { .coordinator-workspace [data-testid="task-chat-composer-dock"] {
flex-shrink: 0; flex-shrink: 0;
background: transparent; background: var(--background);
backdrop-filter: none; backdrop-filter: none;
pointer-events: none; pointer-events: auto;
} }
.coordinator-workspace [data-testid="task-chat-composer-dock"] > * { pointer-events: auto; }
@media (min-width: 768px) { @media (min-width: 768px) {
.coordinator-workspace [data-testid="task-chat-thread"] { position: relative; } .coordinator-workspace [data-testid="task-chat-thread"] { position: relative; }
.coordinator-workspace [data-testid="task-chat-composer-dock"] { position: absolute; inset-inline: 0; bottom: 0; } .coordinator-workspace [data-testid="task-chat-composer-dock"] { position: absolute; inset-inline: 0; bottom: 0; }