Carry qualified cards repair through routine status work

Preserve the complete routine status slice and additive retention ownership.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-31 09:30:58 -05:00
commit 508115ef70
22 files changed
+598 -50

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
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
separate. `native-staged-attachment-recovery.ts` validates private source/target
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
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
+1 -1
View File
@@ -159,7 +159,7 @@ eviction, skipped/repeated hours, half-hour DST, date-line changes, rollback,
strict boundaries, and actual twenty-card source delivery followed by quiet.
Refresh decisions use the card's PostgreSQL row revision as a private compare-and-swap fence. If a card changes during watched-source reads, the owner rereads it and reconsiders the current policy, for at most three attempts. A replacement future interval deadline remains unchanged; a replacement manual policy does not run automatically. The same fence protects no-change, paused-policy and generation claims. Existing issue idempotency, generation ownership and losing-claim cancellation still apply. The row revision is not exposed through card APIs or shared scheduling contracts.
Refresh decisions use the card's PostgreSQL row revision as a private compare-and-swap fence. If a card changes during watched-source reads, the owner rereads it and reconsiders the current policy, for at most three attempts. A replacement future interval deadline remains unchanged; a replacement manual policy does not run automatically. The same fence protects no-change, paused-policy and generation claims. Refresh task idempotency includes that captured revision: concurrent requests for the same revision still share one task, while a later revision gets an independent task even when its watched fingerprint is unchanged. A superseded refresh can therefore cancel its losing task without cancelling a newer revision's not-yet-claimed task. Compile task identity is unchanged. The row revision is not exposed through card APIs or shared scheduling contracts.
Company source hints do not preempt a future deadline owned by a failed evaluation (`retry`). Membership and policy deadlines remain eligible for source-driven rechecks, and actual due retries still run. An explicit policy edit can replace the retry through the existing card mutation path. Real PostgreSQL regressions hold the actual search while committing an ordinary policy edit, then verify the saved interval still fires. Persistent search failures stay quiet through issue hints and restart, retry at their saved deadline, and recover through an ordinary policy edit.
+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.
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
@@ -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" });
});
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(
@@ -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,
@@ -41,7 +41,6 @@ function nativeQuestion() {
description: "Deploy directly to production.",
},
],
custom: true,
},
{
id: "regions",
+66 -1
View File
@@ -2,7 +2,7 @@ import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import {
activityLog,
@@ -638,6 +638,71 @@ describeEmbeddedPostgres("status card routes", () => {
expect(refreshIssue).toMatchObject({ status: "cancelled" });
});
it("keeps the current refresh writable after an older revision loses its claim", async () => {
const company = await seedCompany();
const summarizer = await seedSummarizer(company.id);
const service = statusCardService(db);
const realIssues = issueService(db);
await realIssues.create(company.id, { title: "Watched blocker", status: "blocked", priority: "medium" });
const card = await service.create(company.id, {
interestPrompt: "Revision race", titlePinned: false,
refreshPolicy: { ...defaultStatusCardRefreshPolicy, mode: "interval", intervalMinutes: 15 },
}, { agentId: null, userId: "board-user" });
await db.update(statusCards).set({
state: "active", fingerprint: {},
queries: [{ scope: "issues", status: ["blocked"], sort: "updated", limit: 20, offset: 0 }] as typeof card.queries,
}).where(eq(statusCards.id, card.id));
const oldGate = Promise.withResolvers<void>();
const currentGate = Promise.withResolvers<void>();
let oldIssueId: string | undefined;
let currentIssueId: string | undefined;
const blockedIssues = (which: "old" | "current") => ({
...realIssues,
update: async (...args: Parameters<typeof realIssues.update>) => {
const result = await realIssues.update(...args);
if (args[1].description) {
if (which === "old") { oldIssueId = args[0]; await oldGate.promise; }
else { currentIssueId = args[0]; await currentGate.promise; }
}
return result;
},
});
const oldRefresh = statusCardService(db, { issuesSvc: blockedIssues("old") })
.requestRefresh(card.id, { trigger: "interval" });
let currentRefresh: ReturnType<typeof service.requestRefresh> | undefined;
try {
await vi.waitFor(() => expect(oldIssueId).toBeTruthy());
// Ordinary policy editing changes the card revision without changing the
// watched fingerprint. Both refreshes have created their task before the
// old loser cleans up; the current refresh has not yet claimed the card.
await service.update((await service.getById(card.id))!, {
refreshPolicy: defaultStatusCardRefreshPolicy,
}, { agentId: null, userId: "board-user" });
currentRefresh = statusCardService(db, { issuesSvc: blockedIssues("current") })
.requestRefresh(card.id, { trigger: "manual" });
await vi.waitFor(() => expect(currentIssueId).toBeTruthy());
oldGate.resolve();
await oldRefresh;
currentGate.resolve();
const current = await currentRefresh;
const savedIssue = await realIssues.getById(current.generatingIssue!.id);
expect(savedIssue).toMatchObject({ status: "todo" });
expect(current.generatingIssue!.id).not.toBe(oldIssueId);
const run = await seedRun(company.id, summarizer.id);
await db.update(issues).set({ checkoutRunId: run.id }).where(eq(issues.id, savedIssue!.id));
await service.writeSummary(card.id, {
generationIssueId: savedIssue!.id, markdown: "Current revision summary",
changeSummary: "Summarized the current revision", model: "test",
}, { agentId: summarizer.id, runId: run.id });
expect(await service.getById(card.id)).toMatchObject({ generatingIssueId: null });
expect(await realIssues.getById(oldIssueId!)).toMatchObject({ status: "cancelled" });
} finally {
oldGate.resolve(); currentGate.resolve();
await Promise.allSettled([oldRefresh, ...(currentRefresh ? [currentRefresh] : [])]);
}
});
it("finalizes cancelled generation tasks as failed ledger entries", async () => {
const company = await seedCompany();
await enableStatusCards();
@@ -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 { and, asc, eq, gt, inArray, isNotNull, isNull, notExists, or, sql } from "drizzle-orm";
import { heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, statusDecisions, statusDecisionEffects } from "@paperclipai/db";
import { and, asc, eq, exists, gt, inArray, isNotNull, isNull, notExists, or, sql } from "drizzle-orm";
import { heartbeatRunEvents, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, statusDecisions, statusDecisionEffects } from "@paperclipai/db";
import type { NativeResponsePresentationScope } from "../native-response-presentation-notifications.js";
export function nativeResponsePresentationCandidates(scope?: NativeResponsePresentationScope, after?: string, runId?: string) {
@@ -30,7 +30,24 @@ export function nativeResponsePresentationCandidates(scope?: NativeResponsePrese
sql`${nativeRunResults.resultJson} #>> '{terminal,turnTerminalState}' = 'completed'`,
or(
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} #>> '{terminal,reportedWorkDisposition}' = 'done'`),
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 { findSupersededExternalReviewRequests } from "./superseded-external-review-requests.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 { randomUUID } from "node:crypto";
import { preserveNativeWorkspaceExportLease } from "./native-workspace-export-resume.js";
@@ -747,6 +747,7 @@ async function recoverCommittedNativeChatResponseInScope(
): Promise<boolean> {
let agentId: string | null = null;
let materialized = false;
let conversationReply = false;
const entered = beginRunPhase("response.transaction.entry");
try {
materialized = await measureRunPhase("response.transaction", () => db.transaction(async (transaction) => {
@@ -789,6 +790,7 @@ async function recoverCommittedNativeChatResponseInScope(
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
responsibleUserId: heartbeatRuns.responsibleUserId,
nativeIssueId: heartbeatRuns.nativeIssueId,
nativeSessionId: heartbeatRuns.nativeSessionId,
completionContractId: heartbeatRuns.completionContractId,
@@ -803,6 +805,8 @@ async function recoverCommittedNativeChatResponseInScope(
contextSnapshot: sql<Record<string, unknown> | null>`case when ${heartbeatRuns.contextSnapshot} is null then null
else jsonb_build_object(
'skipIssueComment', ${heartbeatRuns.contextSnapshot}->'skipIssueComment',
'issueId', ${heartbeatRuns.contextSnapshot}->'issueId',
'conversationSessionGeneration', ${heartbeatRuns.contextSnapshot}->'conversationSessionGeneration',
'source', ${heartbeatRuns.contextSnapshot}->'source',
'externalChatContinuation', ${heartbeatRuns.contextSnapshot}->'externalChatContinuation',
'paperclipWake', jsonb_build_object('externalInteractionContinuation',
@@ -909,12 +913,16 @@ async function recoverCommittedNativeChatResponseInScope(
agentId = run.agentId;
return true;
}
// Copyback can be owned by reconciliation before the live heartbeat
// reaches its presentation step. Recover a completed internal task's
// exact final reply from the same accepted turn, without rerunning work
// or granting external-chat publication authority.
const conversation = isConversation(issue);
const currentConversation = conversation && decision.reasonCode === "conversation_turn_finished"
&& issue.conversationAgentId === run.agentId && issue.conversationUserId === run.responsibleUserId
&& 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 (
!isConversation(issue) &&
(!conversation || currentConversation) &&
run.status === "succeeded" &&
record(run.contextSnapshot).skipIssueComment !== true &&
!isExternalChatPresentationContext(run.contextSnapshot) &&
@@ -929,6 +937,9 @@ async function recoverCommittedNativeChatResponseInScope(
eq(heartbeatRunEvents.companyId, input.companyId),
eq(heartbeatRunEvents.runId, run.id),
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,payload,kind}' = 'agentMessage'`,
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,channel}' = 'final'`,
@@ -963,6 +974,7 @@ async function recoverCommittedNativeChatResponseInScope(
})}::jsonb`, updatedAt: new Date(),
}).where(eq(heartbeatRuns.id, run.id));
agentId = run.agentId;
conversationReply = conversation;
return true;
}
if (
@@ -1039,12 +1051,14 @@ async function recoverCommittedNativeChatResponseInScope(
} finally {
entered();
}
if (materialized && agentId)
if (materialized && agentId) {
if (conversationReply) await settleConversationTurnForRun(db, input.companyId, input.runId);
publishChatPublicationCommitSignal({
...input,
agentId,
eventType: "run.presentation.resolved",
});
}
return materialized;
}
@@ -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<typeof import("node:fs")>();
@@ -7863,6 +7864,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",
@@ -7888,6 +7910,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 } } });
@@ -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,
+5 -1
View File
@@ -753,7 +753,11 @@ export function statusCardService(
createdByAgentId: input.actor?.agentId ?? null,
createdByUserId: input.actor?.userId ?? null,
hiddenAt: now,
idempotencyKey: `status-card-update:${card.id}:${fingerprintHash}`,
// Claim ownership is revision-scoped too. A superseded refresh may cancel
// its unclaimed task; it must not share that task with a newer revision
// which has the same watched fingerprint but has not claimed the card yet.
// Concurrent refreshes of this revision still deduplicate normally.
idempotencyKey: `status-card-update:${card.id}:${revision}:${fingerprintHash}`,
onDeduplicated: (reason) => { deduplicated = reason === "idempotency_key"; },
});
const reopened = deduplicated && TERMINAL_ISSUE_STATUSES.has(created.status)
+4 -1
View File
@@ -467,7 +467,10 @@ test("plan approval hands the preserved revision to an assigned project task", a
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 completion = page.getByText("Delegated work completed:", { exact: false });
await expect(completion.getByRole("link").and(page.locator(
`a[href="/${f.company.issuePrefix}/issues/${task.identifier}"]`,
))).toBeVisible();
const plan = await json(
await request.get(`/api/issues/${task.id}/documents/plan`),
);
@@ -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' })}`);
@@ -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;
@@ -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;
}
/** A normal flex footer beside the message scroller, on desktop and mobile. */
export function TaskChatComposerDock({ children, mobile, streamlined, concealed = false }: TaskChatComposerDockProps) {
const dockRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const dock = dockRef.current;
const thread = dock?.closest<HTMLElement>('[data-testid="task-chat-thread"]');
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`);
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(dock);
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
observer?.observe(dock);
return () => {
observer.disconnect();
dock.removeEventListener("wheel", forwardGapWheel);
observer?.disconnect();
thread.style.removeProperty("--task-composer-dock-height");
};
}, []);
@@ -21,6 +21,14 @@ function customPropertyValue(name: string): string {
}
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", () => {
const block = cssBlock(
'.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-question-answer] { background: var(--card); }
.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"] {
flex-shrink: 0;
background: transparent;
background: var(--background);
backdrop-filter: none;
pointer-events: none;
pointer-events: auto;
}
.coordinator-workspace [data-testid="task-chat-composer-dock"] > * { pointer-events: auto; }
@media (min-width: 768px) {
.coordinator-workspace [data-testid="task-chat-thread"] { position: relative; }
.coordinator-workspace [data-testid="task-chat-composer-dock"] { position: absolute; inset-inline: 0; bottom: 0; }