mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 11:13:44 +02:00
## Thinking Path > - Paperclip manages AI agents and records their durable work outcomes. > - Product E2E checks those outcomes through the browser and public API. > - A successful long run can emit more than 1,000 durable events. > - The harness read one page and missed the later completion evidence. > - This pull request reads every page before it checks runtime invariants. > - Invalid or incomplete capture still fails. A missing page cannot produce a pass. ## Linked Issues or Issue Description Related: #13882 (Grok qualification). A duplicate search found no existing event-pagination fix. **What happened?** The local structured-question case in [campaign 36071063537](https://github.com/paperclipai/paperclip/actions/runs/36071063537) completed the task and passed its six outcome matchers. It failed native runtime invariants because the capture contained exactly 1,000 events. The last captured event preceded the run's completion by more than a minute. The API caps each response at 1,000 rows. The harness did not request the next page. **Expected behavior** Read the complete durable event stream through the public API before checking semantic-result and terminal-event counts. Reject incomplete or malformed evidence. **Steps to reproduce** 1. Complete a native task that emits more than 1,000 durable events. 2. Place the semantic-result and terminal events after row 1,000. 3. Capture the run with the Product E2E harness. 4. Before this fix, the invariant checker sees only the first page. **Paperclip version or commit** Observed at `4196a4cd76db434854b679035e4146c7f69689ce`. The same single-page capture exists on master. The original failed result remains unchanged; missing historical tail evidence is not reconstructed or graded as a pass. ## What Changed - Add a bounded event collector that advances through the public `afterSeq` cursor. - Use it for task success/failure evidence and shared chat run evidence. - Reject invalid pages, missing or non-increasing sequence numbers, repeated cursors, failed later requests, and an exhausted page limit. - Test completion events beyond the first page, exact page boundaries, and malformed evidence. - Correct the existing Everyday catalog test from 38 to the maintained 47 cells. The suite stays explicit-only. - Document the complete-capture requirement and its bound. ## Verification - Product harness typecheck passes. - The full credential-free harness suite passed 477 tests. After adding the chat integration regression, all 34 chat evidence tests pass. - Seventeen pagination tests cover the valid tail and malformed-evidence cases. - `git diff --check` passes. - [Full repository CI](https://github.com/paperclipai/paperclip/actions/runs/36080683422) passes at `aed6f79c089226be79e75dcf390969561ec4f787`: 52 successful checks and two intentional skips, including Rust, typecheck, build, server tests, and browser shards. Greptile gives this exact head 5/5 with no findings. No local Docker, Rust build, browser suite, or model invocation was used for this change. - [Live Grok requalification](https://github.com/paperclipai/paperclip/actions/runs/36080870743) is running on combined source `1b0551bb7c8de3c54f4bee64dbe2c88328b3645e`, with the runner, scheduler, and evidence fixes. Its full credential-free harness passes all 493 tests and typecheck. Live results are pending; the original campaign remains a failed measurement. ## Risks Long runs need more read-only API requests and larger private evidence files. Capture stops with an explicit error after 100 full pages. This changes neither production APIs nor provider behavior. It does not relax an invariant or change a historical grade. ## Model Used OpenAI GPT-6 through Codex, with repository tools and code execution. The exact serving identifier 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 #` 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>
430 lines
18 KiB
TypeScript
430 lines
18 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import type { AskUserQuestionsPayload } from "../../packages/shared/src/types/issue.js";
|
|
import {
|
|
assertChatBacklogCreation,
|
|
assertChatHandoff,
|
|
assertChatReassignment,
|
|
assertChatTaskHandoff,
|
|
chatQuestionPresentation,
|
|
chatRunFailure,
|
|
chatTaskCompletionFailure,
|
|
createChatIdleFailureDetector,
|
|
collectChatRunEvidence,
|
|
readRunningChatLog,
|
|
readChatOutputDocument,
|
|
isChatClarificationReply,
|
|
assertChatExecutionOutput,
|
|
isResetRun,
|
|
type ChatIssue,
|
|
type ChatRun,
|
|
} from "./chat-flow.js";
|
|
import type { RunnerApi } from "./api.js";
|
|
import { chatMarker } from "./chat-cases.js";
|
|
import { runnerMatrix } from "./catalog.js";
|
|
import { isPublicRunnerScreenshotRoute } from "./screenshot-policy.js";
|
|
import { classifyFailure, shouldRetryFailure } from "./failure-classifier.js";
|
|
|
|
const source: ChatIssue = {
|
|
id: "chat",
|
|
companyId: "co",
|
|
title: "Chat",
|
|
status: "in_review",
|
|
assigneeAgentId: "agent",
|
|
};
|
|
const task: ChatIssue = {
|
|
...source,
|
|
id: "work",
|
|
parentId: null,
|
|
projectId: "project",
|
|
};
|
|
const plan = {
|
|
body: "# Relevant plan",
|
|
latestRevisionId: "revision",
|
|
updatedAt: "2026-09-11T10:00:00Z",
|
|
};
|
|
const run: ChatRun = {
|
|
id: "run",
|
|
companyId: "co",
|
|
agentId: "agent",
|
|
status: "succeeded",
|
|
startedAt: "2026-09-11T10:00:01Z",
|
|
};
|
|
describe("chat acceptance contracts", () => {
|
|
it("accepts concrete information requests without requiring question punctuation", () => {
|
|
expect(isChatClarificationReply("What is the club name?")).toBe(true);
|
|
expect(
|
|
isChatClarificationReply(
|
|
"Before assigning the welcome-note work, please share:\n\n1. Club name and intended readers.\n2. Format, length, and tone.\n3. Required details, sender, and deadline.",
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
isChatClarificationReply("Tell me the intended audience and format."),
|
|
).toBe(true);
|
|
expect(
|
|
isChatClarificationReply(
|
|
"Thanks — before assigning the welcome-note drafting work, I need a compact brief covering:\n\n- Club and audience: club name and intended readers.\n- Purpose: welcome or next steps.\n- Required content: dates, links, and contacts.\n- Voice: tone and sender.\n- Delivery constraints: format, length, and deadline.\n- Examples or policies: existing notes and approval requirements.",
|
|
),
|
|
).toBe(true);
|
|
expect(isChatClarificationReply("I'll need your details about the audience and format.")).toBe(true);
|
|
expect(isChatClarificationReply("We need some information about the club and intended readers.")).toBe(true);
|
|
expect(isChatClarificationReply("I needed a compact brief before I assigned the work.")).toBe(false);
|
|
expect(isChatClarificationReply("I need a compact brief:")).toBe(false);
|
|
expect(isChatClarificationReply("I need information.")).toBe(false);
|
|
expect(isChatClarificationReply("I need to create the task and write the note.")).toBe(false);
|
|
expect(isChatClarificationReply("Please share:")).toBe(false);
|
|
expect(isChatClarificationReply("Please share.")).toBe(false);
|
|
expect(
|
|
isChatClarificationReply("Asked the user clarifying questions about their club."),
|
|
).toBe(false);
|
|
expect(
|
|
isChatClarificationReply("I created the task and started writing the welcome note."),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("rejects superseded plan requirements in executed output, independently of plan history", () => {
|
|
expect(() => assertChatExecutionOutput("Welcome CHAT123.", "CHAT123", "DRAFT123")).not.toThrow();
|
|
expect(() => assertChatExecutionOutput("Welcome DRAFT123 and CHAT123.", "CHAT123", "DRAFT123")).toThrow();
|
|
expect(() => assertChatExecutionOutput("Welcome DRAFT123.", "CHAT123", "DRAFT123")).toThrow();
|
|
});
|
|
|
|
it("keeps chat markers literal across rich-text and Markdown boundaries", () => {
|
|
for (const prefix of ["CHAT", "DRAFT", "OLDCONTEXT"] as const) {
|
|
expect(chatMarker(prefix, "abc123-1")).toBe(`${prefix}abc1231`);
|
|
expect(chatMarker(prefix, "abc_123-1")).toMatch(/^[a-zA-Z0-9]+$/);
|
|
}
|
|
expect(chatMarker("OLDCONTEXT", "one-1")).not.toBe(
|
|
chatMarker("CHAT", "one-1"),
|
|
);
|
|
expect(chatMarker("CHAT", "one-1")).not.toBe(chatMarker("CHAT", "two-1"));
|
|
});
|
|
it("covers existing workflows and native reassignment on the chosen local profiles", () => {
|
|
const matrix = runnerMatrix.filter(
|
|
(cell) => cell.suite.id === "agent-chat",
|
|
);
|
|
expect(matrix).toHaveLength(28);
|
|
expect(new Set(matrix.map((cell) => cell.profile.id))).toEqual(
|
|
new Set([
|
|
"legacy-codex",
|
|
"legacy-claude",
|
|
"runner-codex",
|
|
"runner-acpx-claude",
|
|
]),
|
|
);
|
|
expect(new Set(matrix.map((cell) => cell.task.id)).size).toBe(8);
|
|
expect(
|
|
matrix.every(
|
|
(cell) =>
|
|
cell.environment.id === "local" &&
|
|
cell.task.expectedTerminalState.issue === "in_review",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
it("rejects missing plan, chat children, wrong assignments, and execution before the plan", () => {
|
|
expect(() => assertChatHandoff(task, plan, [run], source)).not.toThrow();
|
|
for (const invalid of [
|
|
{ ...task, parentId: "chat" },
|
|
{ ...task, projectId: null },
|
|
{ ...task, assigneeAgentId: "other" },
|
|
])
|
|
expect(() => assertChatHandoff(invalid, plan, [run], source)).toThrow();
|
|
expect(() =>
|
|
assertChatHandoff(task, { ...plan, body: "" }, [run], source),
|
|
).toThrow();
|
|
expect(() =>
|
|
assertChatHandoff(
|
|
task,
|
|
{ ...plan, updatedAt: "2026-09-11T10:00:02Z" },
|
|
[run],
|
|
source,
|
|
),
|
|
).toThrow();
|
|
expect(() => assertChatHandoff(task, plan, [], source)).toThrow();
|
|
});
|
|
it("requires a plan for plan handoff, while direct requests need only normal task assignment", () => {
|
|
expect(() => assertChatTaskHandoff(task, [run], source)).not.toThrow();
|
|
expect(() =>
|
|
assertChatHandoff(task, { ...plan, body: "" }, [run], source),
|
|
).toThrow();
|
|
expect(() =>
|
|
assertChatTaskHandoff({ ...task, projectId: null }, [run], source),
|
|
).toThrow();
|
|
});
|
|
it.each(["project-description", "welcome-note", "output"])("finds committed %s output without accepting a copied plan or a claim", async (key) => {
|
|
const output = {
|
|
...plan,
|
|
id: "description-doc",
|
|
issueId: "work",
|
|
key,
|
|
body: "A completed description with CHAT123.",
|
|
createdByAgentId: "agent",
|
|
};
|
|
const get = vi.fn(async (path: string) => {
|
|
if (path === "/api/issues/work/documents")
|
|
return [{ key: "plan" }, { key }];
|
|
if (path === `/api/issues/work/documents/${key}`)
|
|
return output;
|
|
throw new Error(`Unexpected document read: ${path}`);
|
|
});
|
|
const api = { get } as Pick<RunnerApi, "get">;
|
|
await expect(readChatOutputDocument(api, "work", "CHAT123")).resolves.toBe(
|
|
output,
|
|
);
|
|
await expect(readChatOutputDocument(api, "work", "WRONG123")).rejects.toThrow(
|
|
"no non-plan output document",
|
|
);
|
|
get.mockImplementation(async () => [{ key: "plan" }]);
|
|
await expect(readChatOutputDocument(api, "work", "CHAT123")).rejects.toThrow(
|
|
"document keys: plan",
|
|
);
|
|
});
|
|
it("uses durable free-text labels, multi-selection, and the supplied submit label", () => {
|
|
const payload: AskUserQuestionsPayload = {
|
|
version: 1,
|
|
submitLabel: "Send brief",
|
|
questions: [
|
|
{
|
|
id: "audience",
|
|
prompt: "Who is it for?",
|
|
selectionMode: "multi",
|
|
required: true,
|
|
options: [
|
|
{ id: "members", label: "New members" },
|
|
{
|
|
id: "custom",
|
|
label: "Another audience or occasion",
|
|
freeText: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const presentation = chatQuestionPresentation(payload);
|
|
expect(presentation.submitLabel).toBe("Send brief");
|
|
expect(presentation.questions[0]).toMatchObject({
|
|
answerMode: "multi_select",
|
|
customAnswer: { enabled: true, label: "Another audience or occasion" },
|
|
});
|
|
const nativePayload: AskUserQuestionsPayload = {
|
|
...payload,
|
|
questionSet: {
|
|
schema: "paperclip.question_set.v1",
|
|
submitLabel: "Continue",
|
|
questions: [
|
|
{
|
|
id: "audience",
|
|
prompt: "Who is it for?",
|
|
required: true,
|
|
answerMode: "text",
|
|
},
|
|
],
|
|
},
|
|
};
|
|
expect(chatQuestionPresentation(nativePayload)).toBe(
|
|
nativePayload.questionSet,
|
|
);
|
|
});
|
|
it("retains reset events without requesting a provider log, and does not hide missing real logs", async () => {
|
|
const get = vi.fn().mockResolvedValue([{ seq: 1, type: "session_reset" }]);
|
|
const reset = { ...run, resultJson: { conversationReset: true } };
|
|
await expect(collectChatRunEvidence({ get }, reset)).resolves.toEqual({
|
|
runId: run.id,
|
|
log: null,
|
|
events: [{ seq: 1, type: "session_reset" }],
|
|
});
|
|
expect(get.mock.calls).toEqual([
|
|
[`/api/heartbeat-runs/${run.id}/events?afterSeq=0&limit=1000`],
|
|
]);
|
|
get.mockRejectedValue(new Error("Run log not found"));
|
|
await expect(collectChatRunEvidence({ get }, run)).rejects.toThrow(
|
|
"Run log not found",
|
|
);
|
|
});
|
|
it("retains the tail of a long chat run for invariant checks", async () => {
|
|
const firstPage = Array.from({ length: 1000 }, (_, i) => ({ seq: i + 1, eventType: "item.delta" }));
|
|
const tail = { seq: 1001, eventType: "run.terminal" };
|
|
const get = vi.fn().mockResolvedValueOnce({ content: "fixture log" })
|
|
.mockResolvedValueOnce(firstPage).mockResolvedValueOnce([tail]);
|
|
const evidence = await collectChatRunEvidence({ get }, run);
|
|
expect(evidence.events).toEqual([...firstPage, tail]);
|
|
expect(get.mock.calls).toEqual([
|
|
[`/api/heartbeat-runs/${run.id}/log?limitBytes=1048576`],
|
|
[`/api/heartbeat-runs/${run.id}/events?afterSeq=0&limit=1000`],
|
|
[`/api/heartbeat-runs/${run.id}/events?afterSeq=1000&limit=1000`],
|
|
]);
|
|
});
|
|
it("retains events for an unstarted dependency-blocked wake without asking for a nonexistent log", async () => {
|
|
const get = vi.fn().mockResolvedValue([]);
|
|
const suppressed = { ...run, status: "cancelled", errorCode: "issue_dependencies_blocked", startedAt: null };
|
|
expect((await collectChatRunEvidence({ get }, suppressed)).log).toBeNull();
|
|
expect(get).toHaveBeenCalledTimes(1);
|
|
get.mockRejectedValue(new Error("Run log not found"));
|
|
await expect(collectChatRunEvidence({ get }, { ...suppressed, startedAt: "2026-09-18T00:00:00Z" })).rejects.toThrow("Run log not found");
|
|
await expect(collectChatRunEvidence({ get }, { ...suppressed, errorCode: "provider_transport_failed" })).rejects.toThrow("Run log not found");
|
|
});
|
|
it("waits for a newly running provider's log file without swallowing server failures", async () => {
|
|
const get = vi.fn().mockResolvedValue({ status: () => 404 });
|
|
const api = { request: { get } } as unknown as Pick<RunnerApi, "request">;
|
|
await expect(readRunningChatLog(api, "starting")).resolves.toBeUndefined();
|
|
get.mockResolvedValue({
|
|
status: () => 200,
|
|
ok: () => true,
|
|
json: async () => ({ content: "streamed reply" }),
|
|
});
|
|
await expect(readRunningChatLog(api, "running")).resolves.toBe(
|
|
"streamed reply",
|
|
);
|
|
get.mockResolvedValue({ status: () => 500, ok: () => false });
|
|
await expect(readRunningChatLog(api, "broken")).rejects.toThrow(
|
|
"log returned 500",
|
|
);
|
|
});
|
|
it("fails terminal execution errors without preempting active retries or recovery", () => {
|
|
const failed = {
|
|
...run,
|
|
status: "failed",
|
|
error: "provider rejected request",
|
|
};
|
|
expect(chatTaskCompletionFailure(task, [failed])).toContain(
|
|
"provider rejected request",
|
|
);
|
|
expect(
|
|
chatTaskCompletionFailure(task, [failed, { ...run, status: "queued" }]),
|
|
).toBeUndefined();
|
|
expect(
|
|
chatTaskCompletionFailure({ ...task, scheduledRetry: { id: "retry" } }, [
|
|
failed,
|
|
]),
|
|
).toBeUndefined();
|
|
expect(
|
|
chatTaskCompletionFailure(
|
|
{ ...task, activeRecoveryAction: { id: "recovery" } },
|
|
[failed],
|
|
),
|
|
).toBeUndefined();
|
|
});
|
|
it("fails stable contradictory idle states promptly without paid retries or transient false alarms", () => {
|
|
const detect = createChatIdleFailureDetector(3);
|
|
const settled = {
|
|
resolved: true,
|
|
status: "blocked",
|
|
conversationState: "waiting",
|
|
providerRunCount: 3,
|
|
activeRuns: [] as string[],
|
|
};
|
|
expect(detect(settled)).toBeUndefined();
|
|
expect(detect({ ...settled, activeRuns: ["running"] })).toBeUndefined();
|
|
expect(detect(settled)).toBeUndefined();
|
|
const failure = detect(settled);
|
|
expect(failure).toContain("chat_idle_state_invariant");
|
|
expect(classifyFailure(failure)).toBe("candidate_failure");
|
|
expect(shouldRetryFailure(classifyFailure(failure))).toBe(false);
|
|
expect(detect({ ...settled, status: "in_review" })).toBeUndefined();
|
|
expect(detect(settled)).toBeUndefined();
|
|
expect(detect({ ...settled, providerRunCount: 2 })).toBeUndefined();
|
|
expect(detect({ ...settled, status: "in_progress" })).toBeUndefined();
|
|
expect(detect(settled)).toBeUndefined();
|
|
});
|
|
it("fails promptly on terminal provider failures while permitting only expected cancellations", () => {
|
|
expect(chatRunFailure([run])).toBeUndefined();
|
|
expect(chatRunFailure([{ ...run, status: "running" }])).toBeUndefined();
|
|
expect(
|
|
chatRunFailure([
|
|
{
|
|
...run,
|
|
status: "failed",
|
|
errorCode: "permission_denied",
|
|
error: "sandbox unavailable",
|
|
},
|
|
]),
|
|
).toContain("run run failed (permission_denied): sandbox unavailable");
|
|
expect(chatRunFailure([{ ...run, status: "cancelled" }])).toContain(
|
|
"cancelled",
|
|
);
|
|
expect(
|
|
chatRunFailure([{ ...run, status: "cancelled" }], true),
|
|
).toBeUndefined();
|
|
});
|
|
it("separates reset control runs from provider runs without treating failures as resets", () => {
|
|
expect(isResetRun(run)).toBe(false);
|
|
expect(isResetRun({ ...run, status: "failed" })).toBe(false);
|
|
expect(
|
|
isResetRun({ ...run, contextSnapshot: { conversationReset: true } }),
|
|
).toBe(true);
|
|
});
|
|
it("only publishes screenshots of the exact disposable chat", () => {
|
|
const target = {
|
|
issuePrefix: "E2E",
|
|
issueId: "chat",
|
|
issueIdentifier: null,
|
|
chatAgentId: "fixture-agent",
|
|
};
|
|
expect(
|
|
isPublicRunnerScreenshotRoute(
|
|
"http://127.0.0.1:3199/E2E/chats/fixture-agent",
|
|
target,
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
isPublicRunnerScreenshotRoute(
|
|
"http://127.0.0.1:3199/E2E/chats/another-agent",
|
|
target,
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
isPublicRunnerScreenshotRoute(
|
|
"https://example.com/E2E/chats/fixture-agent",
|
|
target,
|
|
),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
|
|
describe("reassignment outcome oracle", () => {
|
|
const evidence = () => ({ readyId: "ready", queuedId: "queued", teammateId: "riley",
|
|
tasks: [{ id: "ready", companyId: "co", title: "Ready", status: "done", assigneeAgentId: "riley" }, { id: "queued", companyId: "co", title: "Later", status: "backlog", assigneeAgentId: "riley" }],
|
|
runs: [{ id: "successor", companyId: "co", agentId: "riley", status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: "ready" } }],
|
|
audit: [{ action: "issue.reassigned", details: { source: "paperclip_runner_protocol" } }], outputBody: "Launch CHECK123", marker: "CHECK123",
|
|
});
|
|
it("accepts persisted ownership and exactly one successful successor", () => {
|
|
expect(() => assertChatReassignment(evidence())).not.toThrow();
|
|
});
|
|
it.each(["owner", "duplicate", "missing-run", "missing-audit", "backlog-started", "missing-output"])("rejects %s evidence", defect => {
|
|
const data = evidence();
|
|
if (defect === "owner") data.tasks[0]!.assigneeAgentId = "old";
|
|
if (defect === "duplicate") data.tasks.push({ ...data.tasks[0]!, id: "replacement" });
|
|
if (defect === "missing-run") data.runs = [];
|
|
if (defect === "missing-audit") data.audit = [];
|
|
if (defect === "backlog-started") data.runs.push({ ...data.runs[0]!, id: "early", contextSnapshot: { issueId: "queued" } });
|
|
if (defect === "missing-output") data.outputBody = "I reassigned it";
|
|
expect(() => assertChatReassignment(data)).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("backlog creation outcome oracle", () => {
|
|
const evidence = () => ({
|
|
tasks: [{ id: "held", companyId: "co", title: "Later", status: "backlog", parentId: null, assigneeAgentId: "planner" }],
|
|
runs: [] as ChatRun[], ownerId: "planner", marker: "PLAN123",
|
|
plan: { body: "Three steps PLAN123", latestRevisionId: "revision-1", updatedAt: "2026-09-19T00:00:00Z" },
|
|
activity: [{ action: "issue.created", details: { status: "backlog", source: "paperclip_runner_protocol" } }],
|
|
});
|
|
it("accepts one planned backlog task with no execution", () => {
|
|
expect(() => assertChatBacklogCreation(evidence())).not.toThrow();
|
|
});
|
|
it("does not mistake the creating conversation for task execution", () => {
|
|
const data = evidence();
|
|
data.runs.push({ id: "creator", companyId: "co", agentId: "planner", status: "succeeded", contextSnapshot: { issueId: "conversation" } });
|
|
expect(() => assertChatBacklogCreation(data)).not.toThrow();
|
|
});
|
|
it.each(["duplicate", "status", "started-then-stopped", "corrected-after-creation", "owner", "plan"])("rejects %s", defect => {
|
|
const data = evidence();
|
|
if (defect === "duplicate") data.tasks.push({ ...data.tasks[0]!, id: "duplicate" });
|
|
if (defect === "status") data.tasks[0]!.status = "todo";
|
|
if (defect === "started-then-stopped") data.runs.push({ id: "early", companyId: "co", agentId: "planner", status: "cancelled", contextSnapshot: { issueId: "held" } });
|
|
if (defect === "corrected-after-creation") data.activity[0]!.details.status = "todo";
|
|
if (defect === "owner") data.tasks[0]!.assigneeAgentId = "other";
|
|
if (defect === "plan") data.plan.body = "I saved a plan";
|
|
expect(() => assertChatBacklogCreation(data)).toThrow();
|
|
});
|
|
});
|