mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent Chat uses native workers to run Claude and Codex conversations. > - A worker crash leaves a cleanup hold because its provider did not acknowledge suspension. > - A new user message must not reuse that unverified session or repeat old tool calls. > - The existing continuation path can preserve history and start a fresh session, but local cleanup ownership remained held. > - This pull request verifies the stopped local owners and releases only their cleanup hold for a new user turn. ## Linked Issues or Issue Description Refs #13775. **What happened?** After a native worker crashed, both providers retained cleanup quarantine. A saved plan survived, but the conversation could not produce another answer. **Expected behavior** Once the old worker and provider process groups have stopped, a new user message can continue in a fresh session with the saved work and prior action history. **Steps to reproduce** Run the opt-in `agent-chat-qualification` suite with case `worker-crash-retry` on native Codex and native Claude. The fixture saves a plan, kills the exact worker through a Linux pidfd, releases a local read-only brief, and sends a new message. ## What Changed - Verify the exact local worker stop receipt, provider identity receipts, released leases, and retained state before retiring a native cleanup hold. - Recheck process liveness and state before admission. Keep the old run and durable session files intact. - Use the existing explicit conversation continuation path. Generic Retry remains blocked for cleanup quarantine, including on the old failed-run marker after a successful continuation. - Extend the live oracle to require a successful fresh session, correct predecessor context, unchanged plan, one original message, and one answer containing a reference introduced after the crash. - Add physical-proof and database-backed admission tests. Document the precise qualification scope. ## Verification - Live Product E2E: **2/2 passed**, **2/2 cleanup passed**, with real native `gpt-5.6-sol` and `claude-sonnet-5`, Chromium, server, database, and public APIs. - Core recovery proof source: `3592b04c2bc76e23795fcdf964720e38a409dc4d`. Suite definition version 8: `9867367994d81a0c726956d91f2c7fddab6417a12f41b5cef3f7e62f3be417da`. - [Core recovery campaign](https://github.com/paperclipai/paperclip/actions/runs/35741746990) · [Public evidence report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-35741746990-1/). - Both cells verify the real crash boundary, blocked generic Retry, unchanged saved plan, one original prompt, one fresh successor with predecessor context, and one run-attributed answer containing the post-crash reference. Billing coverage is partial because the crashed runs did not report complete usage; missing cost is not zero cost. - [Master baseline](https://github.com/paperclipai/paperclip/actions/runs/35737364443): both providers stopped at quarantine, with cleanup passing. - [Follow-up baseline without the fix](https://github.com/paperclipai/paperclip/actions/runs/35738638866): Codex produced a complete red result. Claude reached the same error, but its artifact upload was canceled. - [Complete Claude baseline with the same version-8 definition](https://github.com/paperclipai/paperclip/actions/runs/35740555177): red at cleanup quarantine, cleanup passed, source `6479a90c5754044356b39a9278b9a3e92ce8e55e`. - Earlier candidate attempts remain retained: [first](https://github.com/paperclipai/paperclip/actions/runs/35738449214) passed Codex and found a Claude fixture wait race; [second](https://github.com/paperclipai/paperclip/actions/runs/35740408065) exposed the normalized session-open receipt mismatch. Both corrections are in the final source. - Targeted server suites: 570 passed before the final two additional receipt regression cases. The physical-proof suite, including those cases, passed 46/46. Eval oracle and catalog: 40 passed. Server and Product E2E typechecks passed. - **All 54 PR checks passed on final head `db6f775df`**, including repository typecheck, build, tests, browser shards, and canary dry run. The canary job required one retry after its runner received a shutdown signal. On the earlier core proof head, two timing-sensitive tests passed in isolation and on a single CI retry. - Final UI regression checks: 5 passed; UI typecheck and token gates passed. Updated eval oracle/catalog: 40 passed; eval typecheck passed. - Final version-9 two-provider campaign: **2/2 passed, 2/2 cleanup passed**, including the browser assertion that the quarantined historical run never regains Try again. [Final campaign](https://github.com/paperclipai/paperclip/actions/runs/35747416013), source `db6f775dfff405e1514ec02fedb0450d42c7dad2`, definition hash `bf5abf1cc45cb6dad4e082fbf818b8fa0f4d8c282776a7e98762b22919eadab7`. [Final public evidence report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-35747416013-1/). Final screenshots and retained state inspected for both providers; cost coverage remains partial. ## Risks - Missing or conflicting stop evidence keeps the conversation blocked. This change does not kill an unverified process. - This qualifies new user input after local worker loss. It does not enable automatic replay, exact-session recovery, remote crash recovery, or native onboarding defaults. - Old action outcomes remain part of the continuation. A process exit is not proof that an action did not happen. - No schema migration or production prompt change. ## Model Used OpenAI Codex, GPT-6. The runtime does not expose a more specific model identifier or context-window size. Used reasoning, repository inspection, code editing, shell tools, and test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
249 lines
19 KiB
TypeScript
249 lines
19 KiB
TypeScript
import { expect } from "@playwright/test";
|
|
import { execFileSync } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { resolveDefaultAgentWorkspaceDir } from "../../server/src/home-paths.js";
|
|
import { prepareChatBrief } from "./chat-stories.js";
|
|
import { mutableIssueSnapshot } from "./chat-hardening.js";
|
|
import { sendChatMessage, readChatOutputDocument, type ChatFlowInput, type ChatIssue, type ChatRun } from "./chat-flow.js";
|
|
|
|
type Row = Record<string, any>;
|
|
type Context = {
|
|
input: ChatFlowInput; marker: string; issue(): ChatIssue;
|
|
allRuns(): Promise<ChatRun[]>; comments(): Promise<Row[]>; idle(count: number): Promise<void>;
|
|
refreshIssue(): Promise<void>; expectedStops: Map<string, string>;
|
|
};
|
|
|
|
export function assertWorkerIdentity(run: Row, command: string, environment: string) {
|
|
expect(environment).toBe("local");
|
|
expect(run).toMatchObject({ status: "running", runtimeMode: "native" });
|
|
expect(Number.isSafeInteger(run.processPid) && run.processPid > 1).toBe(true);
|
|
expect(run.processPid).not.toBe(process.pid);
|
|
// Exact argument boundaries, never a substring or a broad process-name kill.
|
|
expect(command.trim().split(/\s+/)).toEqual(expect.arrayContaining(["--run-id", run.id]));
|
|
expect(command).toMatch(new RegExp(`(?:^|\\s)--run-id ${run.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\s|$)`));
|
|
}
|
|
|
|
export function assertActiveHandoff(e: {
|
|
before: Row; after: Row; oldRun: ChatRun; boundary: ChatRun; runs: ChatRun[];
|
|
successorId: string; planBefore: Row; planAfter: Row; draft: Row; draftAfter: Row; draftRevisions: Row[]; output: Row;
|
|
reference: string; audit: Row[]; taskIds: string[];
|
|
}) {
|
|
expect(e.boundary).toMatchObject({ id: e.oldRun.id, status: "running", agentId: e.before.assigneeAgentId });
|
|
expect(e.after).toMatchObject({ id: e.before.id, status: "done", assigneeAgentId: e.successorId, description: e.before.description, projectId: e.before.projectId });
|
|
expect(e.taskIds).toEqual([e.before.id]);
|
|
expect(e.oldRun).toMatchObject({ status: "cancelled", errorCode: "issue_reassigned" });
|
|
const workers = e.runs.filter(r => r.contextSnapshot?.issueId === e.before.id);
|
|
expect(workers).toHaveLength(2);
|
|
const successor = workers.find(r => r.agentId === e.successorId)!;
|
|
expect(successor).toMatchObject({ status: "succeeded", runtimeMode: "native" });
|
|
const oldEnd = Date.parse((e.oldRun as Row).finishedAt);
|
|
expect(Number.isFinite(oldEnd)).toBe(true);
|
|
expect(Date.parse(successor.startedAt!)).toBeGreaterThanOrEqual(oldEnd);
|
|
expect(e.planAfter).toEqual(e.planBefore);
|
|
expect(e.draft.body).toContain(e.reference);
|
|
expect(e.draftAfter.id).toBe(e.draft.id);
|
|
// Continuing a draft may create a new revision. Preservation means its exact
|
|
// saved revision remains retrievable, not that useful progress is forbidden.
|
|
expect(e.draftRevisions.find(r => r.id === e.draft.latestRevisionId)?.body).toBe(e.draft.body);
|
|
expect(e.output.body).toContain(e.reference);
|
|
expect(e.output.updatedByAgentId ?? e.output.createdByAgentId).toBe(e.successorId);
|
|
expect(e.audit.filter(a => a.action === "issue.reassigned")).toHaveLength(1);
|
|
expect(e.audit.find(a => a.action === "issue.reassigned")?.details).toMatchObject({ source: "paperclip_runner_protocol" });
|
|
}
|
|
|
|
export function assertCrashRecovered(e: {
|
|
boundary: Row; failed: Row; runs: ChatRun[]; issueId: string; prompt: string;
|
|
comments: Row[]; reference: string; marker: string; planBefore: Row; planAfter: Row;
|
|
}) {
|
|
expect(e.boundary.status).toBe("running");
|
|
expect(e.failed).toMatchObject({ id: e.boundary.id, status: "failed", runtimeMode: "native" });
|
|
expect(e.runs).toHaveLength(2);
|
|
expect(e.runs.find(r => r.id === e.failed.id)).toMatchObject({ status: "failed", contextSnapshot: { issueId: e.issueId } });
|
|
const retry = e.runs.find(r => r.id !== e.failed.id)!;
|
|
expect(retry).toMatchObject({ agentId: e.failed.agentId, status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: e.issueId } });
|
|
expect(retry.contextSnapshot).toMatchObject({ previousRunId: e.failed.id, forceFreshSession: true });
|
|
expect((retry as Row).nativeSessionId).not.toBe(e.failed.nativeSessionId);
|
|
expect((retry as Row).nativeSessionId).toBeTruthy();
|
|
expect(e.comments.filter(c => !c.authorAgentId && c.body === e.prompt)).toHaveLength(1);
|
|
const replies = e.comments.filter(c => c.authorAgentId && c.body.includes(e.marker));
|
|
expect(replies).toHaveLength(1);
|
|
expect(replies[0]).toMatchObject({ createdByRunId: retry.id });
|
|
expect(replies[0]!.body).toContain(e.reference);
|
|
expect(e.planBefore.body).toContain(e.marker);
|
|
expect(e.planAfter).toEqual(e.planBefore);
|
|
}
|
|
|
|
async function brief(input: ChatFlowInput, agentId: string) {
|
|
const workspace = resolveDefaultAgentWorkspaceDir(agentId);
|
|
const relative = path.relative(path.dirname(input.workspacePath), workspace);
|
|
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Crash fixture escaped isolated instance");
|
|
return prepareChatBrief(workspace, input.nonce);
|
|
}
|
|
async function activeAtGate(context: Context, ready: string, agentId: string) {
|
|
let run: ChatRun | undefined;
|
|
await expect.poll(async () => {
|
|
if (await readFile(ready, "utf8").catch(() => "") !== "waiting") return false;
|
|
run = (await context.allRuns()).find(r => r.agentId === agentId && r.status === "running");
|
|
return Boolean(run);
|
|
}, { timeout: 150_000 }).toBe(true);
|
|
return run!;
|
|
}
|
|
|
|
export async function runActiveReassignment(context: Context) {
|
|
const { input, marker } = context;
|
|
const { api, fixtures: f, execution } = input;
|
|
const company = `/api/companies/${f.company.id}`;
|
|
const config = execution.profile.buildAgent({ environmentId: f.environment.id, environmentFixtureId: "local", workspacePath: input.workspacePath, secretRefs: f.secretRefs, executionId: input.nonce });
|
|
const first = await api.post<Row>(`${company}/agents`, { ...config, name: "Riley Original", role: "engineer", reportsTo: f.agent.id });
|
|
const second = await api.post<Row>(`${company}/agents`, { ...config, name: "Morgan Successor", role: "engineer", reportsTo: f.agent.id });
|
|
const wait = await brief(input, first.id);
|
|
const reference = `REFERENCE${randomUUID().replaceAll("-", "")}`;
|
|
const workerInstructions = `First save a draft Paperclip document on the assigned task containing the reference from its plan. Then run node ${wait.scriptPath} and wait for the brief before finishing. Do not finish before the command returns.`;
|
|
const savedInstructions = await api.request.put(`/api/agents/${first.id}/instructions-bundle/file`, {
|
|
data: { path: "AGENTS.md", content: workerInstructions },
|
|
});
|
|
expect(savedInstructions.ok()).toBe(true);
|
|
expect(await api.get(`/api/agents/${first.id}/instructions-bundle/file?path=AGENTS.md`)).toMatchObject({ content: workerInstructions });
|
|
const task = await api.post<Row>(`${company}/issues`, { title: `Launch checklist ${input.nonce}`, status: "todo", assigneeAgentId: first.id,
|
|
description: `Write a short launch checklist as a Paperclip document on this existing task. Use the saved plan and preserve any draft. Include its reference and ${marker} in the final checklist, then complete this task.`,
|
|
initialPlan: `Welcome beginners on Friday at a free meetup. Reference: ${reference}` });
|
|
try {
|
|
const boundary = await activeAtGate(context, wait.ready, first.id);
|
|
const planBefore = await api.get<Row>(`/api/issues/${task.id}/documents/plan`);
|
|
const draft = await readChatOutputDocument(api, task.id, reference);
|
|
await input.evidence("chat-active-reassignment-boundary.json", { task, boundary, planBefore, draft, successorId: second.id });
|
|
// Only this positively observed run may be cancelled. Every other failure remains fatal.
|
|
context.expectedStops.set(boundary.id, "cancelled");
|
|
await sendChatMessage(input.page, `Reassign the currently running task ${task.identifier} from Riley Original to Morgan Successor now. Stop Riley's active execution as part of the handoff. Preserve the task, its description, saved plan, and draft; Morgan should complete the checklist on the same task. Do not create replacement work. Explain the handoff here.`);
|
|
await context.idle(3);
|
|
const e = { before: task, after: await api.get<Row>(`/api/issues/${task.id}`), boundary,
|
|
oldRun: await api.get<ChatRun>(`/api/heartbeat-runs/${boundary.id}`), runs: await context.allRuns(), successorId: second.id,
|
|
planBefore, planAfter: await api.get<Row>(`/api/issues/${task.id}/documents/plan`), draft,
|
|
draftRevisions: await api.get<Row[]>(`/api/issues/${task.id}/documents/${encodeURIComponent(draft.key)}/revisions`),
|
|
draftAfter: await api.get<Row>(`/api/issues/${task.id}/documents/${encodeURIComponent(draft.key)}`),
|
|
output: await readChatOutputDocument(api, task.id, marker), reference,
|
|
audit: await api.get<Row[]>(`/api/issues/${task.id}/activity`), taskIds: (await api.get<Row[]>(`${company}/issues`)).map(t => t.id) };
|
|
await input.evidence("chat-active-reassignment.json", e);
|
|
assertActiveHandoff(e);
|
|
} finally {
|
|
await writeFile(wait.gate, reference);
|
|
await input.evidence("chat-reassignment-final.json", { task: await api.get(`/api/issues/${task.id}`), runs: await context.allRuns() });
|
|
}
|
|
}
|
|
|
|
export async function runWorkerCrash(context: Context) {
|
|
const { input, marker } = context;
|
|
const wait = await brief(input, input.fixtures.agent.id);
|
|
const reference = `REFERENCE${randomUUID().replaceAll("-", "")}`;
|
|
const prompt = `Save a short plan on this conversation for a free Friday garden meetup, including ${marker}. If that plan already exists, preserve it without rewriting it. Then run node ${wait.scriptPath} to wait for my brief. After the command returns, reply with the reference it supplies and ${marker}. This is discussion only; do not create projects or execution tasks.`;
|
|
try {
|
|
await sendChatMessage(input.page, prompt);
|
|
const boundary = await activeAtGate(context, wait.ready, input.fixtures.agent.id) as ChatRun & Row;
|
|
await context.refreshIssue();
|
|
const planBefore = await input.api.get<Row>(`/api/issues/${context.issue().id}/documents/plan`);
|
|
if (process.platform !== "linux") throw new Error("Worker-crash qualification requires Linux pidfd support");
|
|
const faultHelper = path.join(import.meta.dirname, "worker-fault.py");
|
|
const processIdentity = JSON.parse(execFileSync("python3", [faultHelper, "inspect", String(boundary.processPid), boundary.id], { encoding: "utf8" }));
|
|
const command = execFileSync("ps", ["-p", String(boundary.processPid), "-o", "command="], { encoding: "utf8" });
|
|
assertWorkerIdentity(boundary, command, input.execution.environment.id);
|
|
await input.evidence("chat-worker-fault.json", { boundary, planBefore, processIdentity, fault: "SIGKILL through verified Linux pidfd", recovery: "new user message after verified provider cleanup" });
|
|
const fault = JSON.parse(execFileSync("python3", [faultHelper, "kill", String(boundary.processPid), boundary.id, processIdentity.startTicks], { encoding: "utf8" }));
|
|
expect(fault.signalled).toBe(true);
|
|
await input.evidence("chat-worker-fault-delivered.json", fault);
|
|
context.expectedStops.set(boundary.id, "failed");
|
|
await expect.poll(async () => (await input.api.get<ChatRun>(`/api/heartbeat-runs/${boundary.id}`)).status, { timeout: 120_000 }).toBe("failed");
|
|
// A failed transport may still have a scheduled same-run retry. Wait for
|
|
// recovery classification before treating it as available for a user Retry.
|
|
let failed: Row = {};
|
|
await expect.poll(async () => {
|
|
failed = await input.api.get<Row>(`/api/heartbeat-runs/${boundary.id}`);
|
|
return failed.status === "failed" && failed.finishedAt && failed.nativePhase === "terminal_failure" &&
|
|
["failed", "recovery_needed"].includes(failed.execution?.phase);
|
|
}, { timeout: 120_000 }).toBe(true);
|
|
await input.evidence("chat-worker-settled-failure.json", failed);
|
|
await input.capture("worker-failed", "Worker loss before a fresh user turn", "worker-failed.png");
|
|
await writeFile(wait.gate, reference);
|
|
if (failed.errorCode === "native_session_cleanup_quarantined") {
|
|
// Preserve the red qualification result, but verify the stop is honest:
|
|
// the UI/API must not offer an attempt which cannot pass cleanup admission.
|
|
await input.page.reload({ waitUntil: "domcontentloaded" });
|
|
await expect(input.page.getByTestId("task-chat-composer-input")).toBeVisible();
|
|
await expect(input.page.getByRole("status", { name: "Task recovery" }).getByRole("link", { name: "Inspect run" })).toBeVisible();
|
|
await expect(input.page.getByRole("button", { name: /^(Retry|Try again)$/ })).toHaveCount(0);
|
|
const refused = await input.api.request.post(`/api/agents/${input.fixtures.agent.id}/wakeup`, {
|
|
data: { failedRunId: failed.id, reason: "retry_failed_run" },
|
|
});
|
|
expect(refused.status()).toBe(409);
|
|
expect(await context.allRuns()).toHaveLength(1);
|
|
expect(await input.api.get(`/api/issues/${context.issue().id}/documents/plan`)).toEqual(planBefore);
|
|
await input.evidence("chat-worker-quarantine.json", { failed, retryStatus: refused.status(), savedPlanPreserved: true,
|
|
usableRecovery: false, classification: "product recovery boundary; no provider retry was admitted" });
|
|
await input.capture("worker-quarantined", "Worker recovery requires reconciliation", "worker-quarantined.png");
|
|
// Reconcile the fixture's known outcomes through ordinary user input.
|
|
// The saved plan is verified above; the only interrupted command reads a
|
|
// local brief. The new provider must discover its reference from the file.
|
|
await sendChatMessage(input.page, `The prior worker failed. The plan is already saved and must remain unchanged. The interrupted command only waited for my local brief, which is now available. Continue in a fresh conversation turn: read the brief using node ${wait.scriptPath}, then reply with its reference and ${marker}. Do not create tasks or projects.`);
|
|
}
|
|
else {
|
|
throw new Error(`worker_crash_unexpected_recovery_boundary: ${failed.errorCode}`);
|
|
}
|
|
await context.idle(2);
|
|
const e = { boundary, failed, runs: await context.allRuns(), issueId: context.issue().id,
|
|
prompt, comments: await context.comments(), reference, marker, planBefore,
|
|
planAfter: await input.api.get<Row>(`/api/issues/${context.issue().id}/documents/plan`) };
|
|
await input.evidence("chat-worker-recovery.json", e);
|
|
assertCrashRecovered(e);
|
|
// A fresh success must not re-enable Retry on the quarantined historical run.
|
|
await expect(input.page.getByTestId("task-chat-run-failed-try-again")).toHaveCount(0);
|
|
expect(await input.api.get(`/api/companies/${input.fixtures.company.id}/issues`)).toEqual([]);
|
|
} finally {
|
|
await writeFile(wait.gate, reference);
|
|
await input.evidence("chat-worker-final.json", { runs: await context.allRuns(), comments: await context.comments().catch(error => ({ readError: String(error) })) });
|
|
}
|
|
}
|
|
|
|
export function assertAnswerFacts(reply: string, expected: Row) {
|
|
const body = reply.trim().replace(/^```(?:json)?\s*/, "").replace(/\s*```$/, "");
|
|
const answer = JSON.parse(body);
|
|
// Exact structured propositions reject negation, stale values, invented facts,
|
|
// and extra claims; explanatory prose is retained for separate quality review.
|
|
expect(answer.facts).toEqual(expected);
|
|
expect(typeof answer.explanation).toBe("string");
|
|
expect(answer.explanation.trim().length).toBeGreaterThan(40);
|
|
}
|
|
|
|
export async function runAnswerQuality(context: Context) {
|
|
const { input } = context;
|
|
const { api, fixtures: f } = input;
|
|
const base = `/api/companies/${f.company.id}`;
|
|
const blocked = await api.post<Row>(`${base}/issues`, { title: "Venue confirmation", status: "blocked", description: "Confirm where our Friday launch will be held." });
|
|
const deferred = await api.post<Row>(`${base}/issues`, { title: "Print invitations", status: "backlog", description: "Only start after the venue is confirmed." });
|
|
const stale = `BUDGET${randomUUID().replaceAll("-", "")}`;
|
|
const current = `VENUE${randomUUID().replaceAll("-", "")}`;
|
|
await api.post(`/api/issues/${blocked.id}/comments`, { body: `Earlier obstacle: ${stale}. Budget was missing.` });
|
|
await api.post(`/api/issues/${blocked.id}/comments`, { body: `Budget is resolved. The only current blocker is ${current}: venue confirmation. No confirmed venue or attendance count is recorded.` });
|
|
const before = await Promise.all([blocked, deferred].map(t => api.get<ChatIssue>(`/api/issues/${t.id}`)));
|
|
const prompts = [
|
|
`Read ${blocked.identifier} and ${deferred.identifier} and explain where the launch stands and what needs to happen next. Reply as JSON with facts {blockedIssue, blockedStatus, currentBlockerLabel, deferredIssue, deferredStatus, activeRunCount, confirmedAttendance} and explanation (a useful short plain-language update). Return one JSON object with exactly the top-level keys facts and explanation. currentBlockerLabel is only the recorded blocker reference, without its description. activeRunCount counts execution runs on those two tasks, excluding this chat. Use null for any unknown attendance count. Do not change or start work.`,
|
|
`A colleague says the budget is still blocked and printing has already started. Are those claims supported by the current records? Also, can you tell me the confirmed venue and guaranteed launch date? Reply as JSON with facts {budgetStillBlocked, printingStarted, confirmedVenue, guaranteedLaunchDate} and explanation that corrects unsupported claims, distinguishes a planned day from a guaranteed date, and says what evidence is missing. Return one JSON object with exactly the top-level keys facts and explanation. Use null for unknown facts. Do not change or start work.`,
|
|
];
|
|
const expected = [
|
|
{ blockedIssue: blocked.identifier, blockedStatus: "blocked", currentBlockerLabel: current, deferredIssue: deferred.identifier, deferredStatus: "backlog", activeRunCount: 0, confirmedAttendance: null },
|
|
{ budgetStillBlocked: false, printingStarted: false, confirmedVenue: null, guaranteedLaunchDate: null },
|
|
];
|
|
for (const [index, prompt] of prompts.entries()) {
|
|
await sendChatMessage(input.page, prompt);
|
|
await context.idle(index + 1);
|
|
const reply = (await context.comments()).filter(c => c.authorAgentId).at(-1)?.body ?? "";
|
|
await input.evidence(`chat-answer-quality-${index + 1}.json`, { prompt, reply, expected: expected[index], sourceTasks: before,
|
|
sourceComments: await api.get(`/api/issues/${blocked.id}/comments?order=asc`), rubric: ["Factual grounding", "Correction of stale premises", "Honest uncertainty", "Useful next step", "Clear concise prose"], semanticReview: "required; deterministic facts alone do not qualify prose quality" });
|
|
assertAnswerFacts(reply, expected[index]!);
|
|
}
|
|
const after = await Promise.all([blocked, deferred].map(t => api.get<ChatIssue>(`/api/issues/${t.id}`)));
|
|
expect(after.map(mutableIssueSnapshot)).toEqual(before.map(mutableIssueSnapshot));
|
|
expect((await api.get<Row[]>(`${base}/issues`)).map(t => t.id).sort()).toEqual([blocked.id, deferred.id].sort());
|
|
expect((await context.allRuns()).every(r => r.contextSnapshot?.issueId === context.issue().id)).toBe(true);
|
|
}
|