Files
PaperClipAI/server/src/services/native-runtime/historical-task-questions.test.ts
T
Dotta 46760cc588 fix(chat): honor dismissal for all composer requests (#17218)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task conversations show requests in the feed and above the message
composer.
> - Earlier fixes kept dismissed questions in history, but excluded
completion reviews and other request kinds.
> - Closing those forms left a pending badge, and the server could still
demand a response.
> - This pull request saves human dismissal and removes the request from
composer reminders and ordinary completion gates.
> - People can continue the conversation and reopen the request from
history.

## Linked Issues or Issue Description

Related work: #15229 and #15392.

**What happened?**

Dismissing a completion review closed its form but left it counted as
pending. It could return after reload, block a retry, or cause the agent
to demand another response. Raw runtime requests could also lose their
only usable control when dismissed.

**Expected behavior**

Dismissal leaves an accessible request in the feed and clears the
composer form and pending count. It persists after reload. It does not
submit an answer, approve an action, or wake the agent.

**Steps to reproduce**

1. Open a task with a pending completion review or another composer
request.
2. Dismiss the request, then reload the task.
3. Check that the composer stays clear and the feed entry can reopen the
request.

## What Changed

- Add nullable dismissal fields and a human-only dismissal endpoint with
company, private-task access, resolver, and locked review-policy checks.
- Persist saved-request dismissal on the server. Keep browser
persistence for raw runtime requests and existing question dismissals.
- Keep confirmations, choices, item verdicts, suggested tasks, and
runtime requests accessible in the feed.
- Exclude dismissed ordinary requests from completion and retry gates.
Tell agents not to repeat deferred requests.
- Preserve tool, secret, connection, plan, company-policy, and
configured review authorization gates. Expire deferred non-question
requests when their task completes.
- Preserve the selected form when another dismissal finishes saving.
Keep reopened reviews visible through delayed comment refreshes.
- Add a completion-review Storybook test drive and update design, API,
database, and capability documentation.

## Verification

- `pnpm -r typecheck`: passed. The final UI changes also passed the UI
typecheck.
- `pnpm build`: passed. The final UI changes also passed the UI
production build.
- Focused UI and transcript tests: 550 passed across nine files after
merging the current base branch.
- Focused database, completion, and prompt tests: 105 passed across
three files, including two review-policy regressions that failed before
the fix. The tests reject a review requester excluded by current policy,
preserve the pending gate on denial, and permit an independent reviewer
to defer the request.
- Route regression suite: 81 passed, including denial before reading or
mutating a private-task request.
- `pnpm check:token-gates`: passed.
- Map and Storybook structural checks: passed; 30 map/catalog script
tests passed.
- Browser: used the production task-thread components in the
completion-review fixture. Dismissed, reloaded, reopened from the feed,
and dismissed again. No pending badge or composer takeover remained.
Story callbacks simulate API saves; separate server tests cover
persistence and permissions.
- `pnpm test:run`: stopped after the general-server group reported one
failure in `workspace-runtime.test.ts`: "returns a bounded structured
conflict and exposes only same-company workspace references". The same
test passed in isolation (1 passed, 178 skipped). The cause of the
full-suite failure is not established. The full PR CI suite subsequently
passed on e243cbb001; its two authorization review findings are fixed in
9e1b4478b3. Fresh [full
CI](https://github.com/paperclipai/paperclip/actions/runs/39871944846)
passes on 9e1b4478b3, including the previously failing server and
browser shards. All 54 executed checks passed; two optional Storybook
deployment/visual jobs were skipped. The legacy security status also
passed. Fresh Greptile review is 5/5, the security scan passes, and no
review threads remain open. This full CI run settles the merge gate; the
earlier local workspace-suite failure remains recorded above because its
cause was not established.
- Additional verification: the original failing browser journey passed
locally after the delayed-refresh fix. The expanded page-reload case
also passes on 9e1b4478b3 (52.7s): dismiss, reload, reopen, approve, and
receive the actual tool failure in the continuing task. It exposed a
fixture reading an accepted request before tool execution finished; the
fixture now waits for a stored execution result. The first reload
attempt stopped on a still-loading page at the default five-second
assertion; the reload now uses the suite’s navigation allowance. A
controlled server-restart run timed out waiting for server health before
the UI assertions. Local route-suite runs timed out during concurrent
module loading; sequential imports passed all 81 tests, including the
privacy regression.
- Initial verification failures: a missing SDK build and a story fixture
type error were fixed. PostgreSQL bootstrap then failed because the host
had exhausted its semaphore allowance. After explicit operator approval,
64 verified abandoned PostgreSQL semaphore sets were reclaimed; no live
process was stopped. The database regressions then passed. The
raw-runtime regression exposed a missing feed control; the
implementation now preserves that entry and the regression passes.

## Risks

- Apply the additive database migration before the new server code. It
adds two nullable columns and does not change existing decisions.
- A dismissal is shared task attention state. Only a human with task
read access and current resolver/review authority can persist it. Review
policy is checked under the task lock. The operation is idempotent and
records the dismissing user.
- Raw runtime dismissal remains specific to a browser. Saved interaction
dismissal persists across browsers.
- A deferred review is not an approval. Tests retain protected-action
and configured review gates.
- The browser walkthrough uses a fixture. This branch has not been
deployed to a live instance.

## Model Used

OpenAI Codex, GPT-6, with code execution and browser tools. The exact
backend model ID and context-window size are not exposed in this
session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-10-22 18:37:36 -05:00

341 lines
24 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
activityLog, agents, agentWakeupRequests, approvals, companies, completionContracts, createDb, heartbeatRuns, issueApprovals,
issueComments, issueQuestionResponseDeliveries, issueThreadInteractions, issueWorkProducts, issues, statusDecisions,
} from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { readTaskQuestionContext } from "../issue-question-context.js";
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
import { nativeCompletionFeedback } from "./native-completion-feedback.js";
import { finalizeNativeRun, pendingNativeGovernance } from "./native-run-finalizer.js";
import { PaperclipControlPlanePort } from "./paperclip-control-plane-port.js";
import { CONTROL_PLANE_CONFORMANCE_RESULT, CONTROL_PLANE_CONFORMANCE_TERMINAL } from "../../vendor/paperclip-runner/testing.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
import { PaperclipRunnerSemanticAuthority } from "./runner-semantic-authority.js";
import type { PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js";
const questionTime = new Date("2026-01-01T12:00:00Z");
const messageTime = new Date("2026-01-01T12:01:00Z");
const done: PrpStructuredRunResult = {
schema: "paperclip.run_result.v1", reportedWorkDisposition: "done", summary: "Configuration verified.",
completionClaim: { contractRevision: "test", objectiveSatisfied: true, criteria: [], remainingWork: [] },
evidence: [], verification: [], attentionRequests: [], artifacts: [],
};
describe("historical task questions", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("historical-task-questions-");
db = createDb(temporary.connectionString);
}, 90_000);
afterAll(async () => { await temporary?.cleanup(); });
async function fixture(conversationMode = false) {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Question history", issuePrefix: `Q${companyId.slice(0, 8)}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", adapterType: "paperclip_runner", status: "active" });
await db.insert(issues).values({ id: issueId, companyId, title: "Verify configuration", status: "in_progress",
assigneeAgentId: agentId, ...(conversationMode ? { conversationAgentId: agentId,
conversationUserId: "board-user", conversationState: "active" as const } : {}) });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issueId, status: "running",
runtimeMode: "native", contextSnapshot: { issueId } });
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
const [question] = await db.insert(issueThreadInteractions).values({
companyId, issueId, kind: "ask_user_questions", status: "pending", sourceRunId: runId,
createdByAgentId: agentId, effectiveResolverPolicy: "human_only", title: "Enable configuration",
createdAt: questionTime, payload: { version: 1, supersedeOnUserComment: false,
questions: [{ id: "configuration", prompt: "Which configuration action did you complete?",
selectionMode: "single", required: true, options: [{ id: "configured", label: "Configured" }] }] },
}).returning();
return { companyId, agentId, issueId, runId, question, conversationMode };
}
type Fixture = Awaited<ReturnType<typeof fixture>>;
async function message(f: Fixture, changes: Partial<typeof issueComments.$inferInsert> = {}) {
const [comment] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId,
authorType: "user", authorUserId: "board-user", body: "Figure it out and verify it.", createdAt: messageTime, ...changes }).returning();
return comment;
}
const gate = (f: Fixture) => pendingNativeGovernance({ db, ...f, executionState: null });
it.each([false, true])("keeps old questions answerable without reminders or completion gates (chat=%s)", async conversationMode => {
const f = await fixture(conversationMode);
await message(f);
expect(await gate(f)).toBeNull();
const feedback = await nativeCompletionFeedback(db, f.runId, done);
expect(feedback).not.toContain("waiting for a response");
expect(feedback).not.toContain("Tell the user");
expect(feedback).not.toContain(f.question.id);
const blockedFeedback = await nativeCompletionFeedback(db, f.runId, {
...done, reportedWorkDisposition: "blocked",
completionClaim: { ...done.completionClaim, objectiveSatisfied: false },
blocker: { reasonCode: "missing_access", reason: "Missing test environment access",
owner: { name: "Environment owner", kind: "user" }, unblockAction: "Grant test access", scope: "task_wide" },
});
expect(blockedFeedback).toContain("Explain why work cannot continue");
expect(blockedFeedback).not.toContain(f.question.id);
const authority = new PaperclipRunnerToolAuthority(db, f);
expect(await authority.execute({ tool: "get_task_context", callId: "context", arguments: {} }))
.toMatchObject({ taskQuestionContext: { questions: [{ id: f.question.id, historical: true }], truncated: false } });
const semantic = new PaperclipRunnerSemanticAuthority(db, f);
expect(await semantic.dispatch({ callId: "context", operationId: "get_task_context", input: {},
correlation: { runId: f.runId, normalizedSessionId: randomUUID(), turnId: "turn", itemId: "item" } }))
.toMatchObject({ ok: true, value: { taskQuestionContext: { questions: [{ id: f.question.id, historical: true }] } } });
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, f.question.id)))
.toEqual([expect.objectContaining({ status: "pending", result: null, resolvedAt: null, payload: f.question.payload })]);
});
it.each(["ask_user_questions", "request_confirmation", "request_checkbox_confirmation", "request_item_verdicts", "suggest_tasks"])("defers a human-dismissed %s without resolving it or waking the agent", async kind => {
const f = await fixture();
const payload = kind === "ask_user_questions" ? f.question.payload
: kind === "request_confirmation" ? { version: 1, prompt: "Provide app configuration", target: { type: "custom", key: "native_completion_review" } }
: kind === "request_checkbox_confirmation" ? { version: 1, prompt: "Choose items", options: [{ id: "one", label: "One" }] }
: kind === "request_item_verdicts" ? { version: 1, prompt: "Review items", items: [{ id: "one", label: "One" }] }
: { version: 1, tasks: [{ clientKey: "one", title: "One" }] };
await db.update(issueThreadInteractions).set({ kind, payload: payload as typeof f.question.payload }).where(eq(issueThreadInteractions.id, f.question.id));
const service = issueThreadInteractionService(db);
const task = { id: f.issueId, companyId: f.companyId };
expect(await gate(f)).not.toBeNull();
await expect(service.dismissInteraction(task, f.question.id, { agentId: f.agentId })).rejects.toMatchObject({ status: 403 });
await expect(service.dismissInteraction({ ...task, companyId: randomUUID() }, f.question.id, { userId: "board-user" })).rejects.toMatchObject({ status: 404 });
const dismissed = await service.dismissInteraction(task, f.question.id, { userId: "board-user" });
expect(dismissed).toMatchObject({ status: "pending", dismissedByUserId: "board-user", resolvedAt: null, result: null });
expect((await service.dismissInteraction(task, f.question.id, { userId: "board-user" })).dismissedAt).toEqual(dismissed.dismissedAt);
expect(await gate(f)).toBeNull();
expect(await nativeCompletionFeedback(db, f.runId, done)).not.toContain("Tell the user");
expect((await readTaskQuestionContext(db, f)).questions).toEqual([expect.objectContaining({ id: f.question.id, historical: true, dismissedAt: dismissed.dismissedAt })]);
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toEqual([]);
});
it("keeps a dismissed protected request gated without demanding another reminder", async () => {
const f = await fixture();
await db.update(issueThreadInteractions).set({ kind: "request_confirmation", effectiveResolverPolicySource: "company_cap",
payload: { version: 1, prompt: "Approve deployment?" } }).where(eq(issueThreadInteractions.id, f.question.id));
await issueThreadInteractionService(db).dismissInteraction({ id: f.issueId, companyId: f.companyId }, f.question.id, { userId: "board-user" });
expect(await gate(f)).toEqual({ kind: "interaction", id: f.question.id });
const feedback = await nativeCompletionFeedback(db, f.runId, done);
expect(feedback).toContain("Dismissal does not authorize");
expect(feedback).not.toContain("Tell the user");
});
it.each(["request_confirmation", "request_checkbox_confirmation"])("enforces the current issue review policy when dismissing a %s", async kind => {
const f = await fixture();
await db.update(issues).set({ status: "in_review", reviewPolicy: "not_creator", createdByUserId: "original-author" })
.where(eq(issues.id, f.issueId));
await db.update(issueThreadInteractions).set({ kind, createdByAgentId: null, createdByUserId: "review-requester",
payload: kind === "request_confirmation"
? { version: 1, prompt: "Review completed work", target: { type: "custom", key: "native_completion_review" } }
: { version: 1, prompt: "Review completed work", options: [{ id: "verified", label: "Verified" }] },
}).where(eq(issueThreadInteractions.id, f.question.id));
await db.insert(activityLog).values({ companyId: f.companyId, actorType: "user", actorId: "review-requester",
action: "issue.updated", entityType: "issue", entityId: f.issueId,
details: { status: "in_review", _previous: { status: "in_progress" }, reviewInteractionId: f.question.id },
});
const service = issueThreadInteractionService(db);
const staleTask = { id: f.issueId, companyId: f.companyId, status: "in_progress", reviewPolicy: "anyone" };
await expect(service.dismissInteraction(staleTask, f.question.id, { userId: "review-requester" }))
.rejects.toMatchObject({ status: 403, details: { code: "review_policy_denied" } });
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, f.question.id)))[0])
.toMatchObject({ dismissedAt: null, status: "pending", resolvedAt: null, result: null });
expect(await gate(f)).toEqual({ kind: "interaction", id: f.question.id });
expect(await service.dismissInteraction(staleTask, f.question.id, { userId: "original-author" }))
.toMatchObject({ dismissedByUserId: "original-author", status: "pending", resolvedAt: null, result: null });
expect(await gate(f)).toBeNull();
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId))).toEqual([]);
});
it("keeps a real current question gated without commanding another reminder", async () => {
const f = await fixture();
expect(await gate(f)).toEqual({ kind: "interaction", id: f.question.id });
const feedback = await nativeCompletionFeedback(db, f.runId, done);
expect(feedback).toContain("Reassess whether");
expect(feedback).toContain("Withdraw the question");
expect(feedback).not.toContain("Tell the user");
});
it.each(["current", "newer-message", "dismissed-question", "dismissed-review"])("commits the correct native task status (%s)", async mode => {
const f = await fixture();
const historical = mode !== "current";
if (mode === "newer-message") await message(f);
if (mode === "dismissed-review") {
await db.update(issueThreadInteractions).set({ kind: "request_confirmation", payload: { version: 1,
prompt: "Provide app configuration", target: { type: "custom", key: "native_completion_review" } } })
.where(eq(issueThreadInteractions.id, f.question.id));
}
if (mode.startsWith("dismissed-")) {
await issueThreadInteractionService(db).dismissInteraction({ id: f.issueId, companyId: f.companyId }, f.question.id, { userId: "board-user" });
}
const contractId = randomUUID(), sessionId = randomUUID(), runnerId = randomUUID();
const contractSha256 = "historical-question-contract";
await db.insert(completionContracts).values({ id: contractId, companyId: f.companyId, issueId: f.issueId,
revision: 1, schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test", risk: "standard",
completionAuthority: "server_arbiter", incompleteCriteriaPolicy: "preserve_non_terminal",
contractJson: { revision: "standalone-v1", objective: "Verify configuration", criteria: [{ id: "objective", requirement: "Verify configuration" }] },
canonicalSha256: contractSha256, createdByActorType: "system", createdByActorId: "test" });
await db.update(heartbeatRuns).set({ completionContractId: contractId, completionContractSha256: contractSha256,
nativeSessionId: sessionId, runnerInstanceId: runnerId }).where(eq(heartbeatRuns.id, f.runId));
const [verified] = await db.insert(issueWorkProducts).values({ companyId: f.companyId, issueId: f.issueId,
type: "artifact", provider: "test", title: "Configuration verification", status: "completed", reviewState: "approved" }).returning();
const evidenceRef = `work_product:${verified.id}`;
const verifiedResult: PrpStructuredRunResult = { ...CONTROL_PLANE_CONFORMANCE_RESULT,
completionClaim: { ...CONTROL_PLANE_CONFORMANCE_RESULT.completionClaim,
criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [evidenceRef] }] },
evidence: [{ kind: "work_product", ref: evidenceRef }],
verification: [{ commandOrCheck: "Configuration verification", status: "passed", artifactRef: evidenceRef }] };
const port = new PaperclipControlPlanePort(db, { ...f, sessionId, completionContractId: contractId,
completionContractSha256: contractSha256, sourceInstanceId: runnerId, controlPlaneSourceInstanceId: randomUUID() });
await port.openRun({ identity: { ...f, sessionId }, backendKind: "mock", sourceInstanceId: runnerId });
await port.completeRun({ result: verifiedResult, terminal: CONTROL_PLANE_CONFORMANCE_TERMINAL,
callerResultId: randomUUID() });
await finalizeNativeRun({ db, runId: f.runId, workspaceFinalizeStatus: "succeeded" });
expect(await db.select().from(issues).where(eq(issues.id, f.issueId)))
.toEqual([expect.objectContaining({ status: historical ? "done" : "in_review" })]);
expect(await db.select().from(statusDecisions).where(eq(statusDecisions.issueId, f.issueId)))
.toEqual([expect.objectContaining({ reasonCode: historical ? "completion_contract_satisfied" : "governed_gate_pending" })]);
if (mode === "dismissed-review") {
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, f.question.id)))[0]).toMatchObject({ status: "expired", dismissedByUserId: "board-user" });
} else if (historical) {
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, f.question.id)))
.toEqual([expect.objectContaining({ status: "pending", result: null, resolvedAt: null })]);
const answered = await issueThreadInteractionService(db).answerQuestions(
{ id: f.issueId, companyId: f.companyId, status: "done" }, f.question.id,
{ answers: [{ questionId: "configuration", optionIds: ["configured"] }] }, { userId: "board-user" });
expect(answered).toMatchObject({ status: "answered", resolvedByUserId: "board-user",
result: { answers: [{ questionId: "configuration", optionIds: ["configured"] }] } });
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("done");
expect(await db.select().from(issueQuestionResponseDeliveries).where(eq(issueQuestionResponseDeliveries.issueId, f.issueId)))
.toEqual([]);
expect(await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId)))
.toEqual([]);
}
});
it("keeps a new input blocker active after the earlier question becomes historical", async () => {
const f = await fixture();
await message(f);
const [current] = await db.insert(issueThreadInteractions).values({ ...f.question, id: randomUUID(),
title: "Select the remaining test environment", createdAt: new Date("2026-01-01T12:02:00Z") }).returning();
expect(await gate(f)).toEqual({ kind: "interaction", id: current.id });
expect(await nativeCompletionFeedback(db, f.runId, done)).toContain(current.id);
expect((await readTaskQuestionContext(db, f)).questions).toEqual([
expect.objectContaining({ id: current.id, historical: false }),
expect.objectContaining({ id: f.question.id, historical: true }),
]);
});
it.each(["agent", "run", "derived_agent", "derived_run", "derived_source", "system", "system_notice", "deleted", "untrusted", "earlier", "same_time", "other_issue", "other_company"])
("does not mistake a %s comment for newer human direction", async kind => {
const f = await fixture();
const changes: Partial<typeof issueComments.$inferInsert> = {};
if (kind === "agent") changes.authorAgentId = f.agentId;
if (kind === "run") changes.createdByRunId = f.runId;
if (kind === "derived_agent") changes.derivedAuthorAgentId = f.agentId;
if (kind === "derived_run") changes.derivedCreatedByRunId = f.runId;
if (kind === "derived_source") changes.derivedAuthorSource = "run_log_comment_post";
if (kind === "system") changes.authorType = "system";
if (kind === "system_notice") changes.presentation = { kind: "system_notice", tone: "neutral",
title: "Automation update", detailsDefaultOpen: false };
if (kind === "deleted") changes.deletedAt = messageTime;
if (kind === "untrusted") changes.sourceTrust = { preset: "low_trust_review", disposition: "quarantined" };
if (kind === "earlier") changes.createdAt = new Date("2026-01-01T11:59:00Z");
if (kind === "same_time") changes.createdAt = questionTime;
if (kind === "other_issue") {
const [other] = await db.insert(issues).values({ companyId: f.companyId, title: "Other task" }).returning();
changes.issueId = other.id;
}
if (kind === "other_company") {
const other = await fixture(); changes.companyId = other.companyId; changes.issueId = other.issueId;
}
await message(f, changes);
expect(await gate(f)).toEqual({ kind: "interaction", id: f.question.id });
expect((await readTaskQuestionContext(db, f)).questions[0].historical).toBe(false);
});
it("compares database timestamp precision rather than rounded JavaScript dates", async () => {
const f = await fixture(), comment = await message(f);
await db.update(issueThreadInteractions).set({ createdAt: sql`'2026-01-01 12:00:00.000001+00'::timestamptz` })
.where(eq(issueThreadInteractions.id, f.question.id));
await db.update(issueComments).set({ createdAt: sql`'2026-01-01 12:00:00.000002+00'::timestamptz` })
.where(eq(issueComments.id, comment.id));
expect(await gate(f)).toBeNull();
});
it.each(["request_confirmation", "request_checkbox_confirmation", "connection_intent", "request_item_verdicts"])
("preserves a %s gate after a newer human message", async kind => {
const f = await fixture();
await db.update(issueThreadInteractions).set({ kind }).where(eq(issueThreadInteractions.id, f.question.id));
await message(f);
expect(await gate(f)).toEqual({ kind: "interaction", id: f.question.id });
expect(await nativeCompletionFeedback(db, f.runId, done)).toContain("Tell the user");
});
it("preserves linked approvals and configured review stages", async () => {
const f = await fixture(); await message(f);
const [approval] = await db.insert(approvals).values({ companyId: f.companyId, type: "request_board_approval",
status: "pending", payload: { title: "Approve action" } }).returning();
await db.insert(issueApprovals).values({ companyId: f.companyId, issueId: f.issueId, approvalId: approval.id });
expect(await gate(f)).toEqual({ kind: "approval", id: approval.id });
expect(await nativeCompletionFeedback(db, f.runId, done)).toContain("waiting for approval");
expect(await pendingNativeGovernance({ db, ...f, executionState: { status: "pending" } }))
.toEqual({ kind: "execution_stage", id: f.runId });
});
it.each(["toolAction", "secretProposal", "connectionAuthorization"])
("preserves a governed %s payload even on a question-shaped request", async key => {
const f = await fixture(true);
await db.update(issueThreadInteractions).set({ sourceRunId: null,
payload: sql`${issueThreadInteractions.payload} || jsonb_build_object(${key}::text, '{}'::jsonb)` })
.where(eq(issueThreadInteractions.id, f.question.id));
await message(f);
expect(await gate(f)).toEqual({ kind: "interaction", id: f.question.id });
expect(await nativeCompletionFeedback(db, f.runId, done)).toContain("Tell the user");
expect((await readTaskQuestionContext(db, f)).questions).toEqual([]);
});
it("does not make a task question historical merely because it belongs to an earlier run", async () => {
const f = await fixture();
await db.update(issueThreadInteractions).set({ sourceRunId: null }).where(eq(issueThreadInteractions.id, f.question.id));
expect(await gate(f)).toEqual({ kind: "interaction", id: f.question.id });
expect((await readTaskQuestionContext(db, f)).questions[0].historical).toBe(false);
});
it.each([false, true])("expires questions on cancellation and rejects answers with stale task state (historical=%s)", async historical => {
const f = await fixture();
if (historical) await message(f);
await db.update(issues).set({ status: "cancelled" }).where(eq(issues.id, f.issueId));
const service = issueThreadInteractionService(db);
await expect(service.answerQuestions({ id: f.issueId, companyId: f.companyId, status: "in_progress" },
f.question.id, { answers: [{ questionId: "configuration", optionIds: ["configured"] }] },
{ userId: "board-user" })).rejects.toMatchObject({ status: 409, details: { code: "interaction_issue_closed" } });
expect(await service.expirePendingInteractionsForTerminalIssue({ id: f.issueId, companyId: f.companyId, status: "cancelled" }))
.toEqual([expect.objectContaining({ id: f.question.id, status: "expired" })]);
});
it("keeps current questions and governed confirmations closed on completion", async () => {
const f = await fixture();
const [confirmation] = await db.insert(issueThreadInteractions).values({ companyId: f.companyId, issueId: f.issueId,
kind: "request_confirmation", status: "pending", payload: { version: 1, prompt: "Approve publication?",
target: { type: "custom", key: "publish" }, supersedeOnUserComment: false, rejectRequiresReason: false } }).returning();
await db.update(issues).set({ status: "done" }).where(eq(issues.id, f.issueId));
const service = issueThreadInteractionService(db);
await expect(service.answerQuestions({ id: f.issueId, companyId: f.companyId, status: "in_progress" },
f.question.id, { answers: [{ questionId: "configuration", optionIds: ["configured"] }] },
{ userId: "board-user" })).rejects.toMatchObject({ status: 409, details: { code: "interaction_issue_closed" } });
expect(await service.expirePendingInteractionsForTerminalIssue({ id: f.issueId, companyId: f.companyId, status: "done" }))
.toEqual(expect.arrayContaining([
expect.objectContaining({ id: f.question.id, status: "expired" }),
expect.objectContaining({ id: confirmation.id, status: "expired" }),
]));
});
it("keeps Agent Chat's previous-turn exception aligned in context and completion feedback", async () => {
const f = await fixture(true);
await db.update(issueThreadInteractions).set({ sourceRunId: null }).where(eq(issueThreadInteractions.id, f.question.id));
expect(await gate(f)).toBeNull();
expect(await nativeCompletionFeedback(db, f.runId, done)).not.toContain("current question remains unanswered");
expect((await readTaskQuestionContext(db, f)).questions[0].historical).toBe(true);
});
});