mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The chat-feature evals measure whether agents complete real chat requests through the product > - The chat catalog eval harness and its repairs live on the `chat-eval/master-candidate` branch, not on master > - Today's work made 139 of 242 chat evals pass in verified single-case live runs. Each product fix landed on master as its own pull request > - The eval-side changes (setups, graders, fault seams, evidence capture) still exist only on the candidate > - This pull request lands the harness on master. Master stays the source of truth for product behavior > - The benefit is one tree: evals run against master commits, and product fixes reach the evals as soon as they merge ## Linked Issues or Issue Description Refs #19441, Refs #19599, Refs #20270, Refs #20334, Refs #20342, Refs #20344, Refs #20354, Refs #20360, Refs #20373, Refs #20382, Refs #20409 **What existing behavior does this improve?** The chat catalog eval harness and the product it evaluates live on a long-running branch outside master. Eval fixes do not reach master, so master-based eval runs use old setups and graders. **Subsystem affected** `tests/runner-e2e` chat catalog harness, plus a small set of product changes that exist only on the candidate. **Proposed change** Land the harness on master. Every product fix from this eval work already merged to master as its own pull request. Conflicts keep master's product behavior. ## What Changed - Most of the diff (808 of 851 files) is under `tests/runner-e2e/`: chat-catalog domains, scenarios, descriptors, the TestOps manifest, private evidence tooling, and their tests. - Graders now check saved state, setup literals and rendered or clicked links. Many keyword and regex checks on free-form replies are removed. Each changed grader has a bumped `oracleVersion` and calibration tests (a valid reply, a reworded valid reply, and broken variants that must fail). - The shared link helper `hasLink` no longer treats a list link (for example `/COM/issues?view=mine`) as a link to every task without a URL key. - Setup fixes build real state through supported product routes: routine history, agent grants through the board grant route, budget holds with a real cap, and a storage-layer archive fault for `inbox-search.archive-partial-failure`. - Browser boundary journeys (sign-in, chat-disabled, onboarding) now write the standard pass evidence files. - Retargeted cases, approved by the maintainer: `agent-chat.chat-disabled` (Conference Room gate), `agents.restore-instructions` (no saved instruction history), `access.manage-membership` (member permission edits stay board-only). - The private evidence trusted files (`manual-private-handoff.ts`, `manual-private-retention.ts`, `manual-private-report-assets.ts`) are byte-identical to master. - Product files outside `tests/` (43 files, +1,328 −49) carry candidate-only changes that master does not yet have. Most are tests. The main code changes are in `request-human-input.ts`, `heartbeat-run-summary.ts`, `native-completion-feedback.ts`, `native-run-finalizer.ts`, `openapi.ts`, `secrets.ts` and `IssueChatLongThreadPerf.tsx`. ## Verification - `pnpm vitest run chat-catalog-domains chat-catalog-contract.test.ts chat-catalog-consistency.test.ts unit-launcher.test.mjs --testTimeout=180000` in `tests/runner-e2e`: 269 files pass, 3,550 tests pass. - `node --test chat-full-catalog-testops-manifest.test.mjs`: 10 pass. - `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`: no change. - `tsc` for `server` and `tests/runner-e2e`: clean. - Live single-case verification: 139 of 242 cases passed on paid runs, and a person read the saved evidence for each. These are single-case runs, not one full-cohort run. ## Risks - Large diff. Most of it is test-only, but the 43 non-test files change product code paths. Review those files first. - Some graders are now less strict on reply wording. A person must read replies for meaning on those cases. - 42 cases need disposable outside accounts (Notion, Slack, GitHub, Daytona and others) and stay blocked. `access.invite-teammate` stays failing until local agent isolation lands, because the runner's durable state keeps a live invite token. - The paid workflow compares the trusted private-evidence files with master. They are unchanged, so this risk is low. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution, in Claude Code. Subagents used the same model. ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
262 lines
20 KiB
TypeScript
262 lines
20 KiB
TypeScript
import { pollUntil } from "../api.js";
|
|
import { remainingIdentityObservationBudget } from "./identity-support.js";
|
|
import type { ChatCatalogCase, ChatCatalogContext } from "../chat-catalog-contract.js";
|
|
import { makeDefinition, fixtureWorker } from "./internal-definition.js";
|
|
import { checks, companyPath, entity, hasLink, observation, observeInternal, project, sameTask, task, type Entity, type InternalObservation } from "./internal-support.js";
|
|
import type { DocumentRevision, IssueAttachment } from "../../../packages/shared/src/types/issue.js";
|
|
|
|
interface InventoryOwner extends Entity { companyId?: string; lifecycleState?: string; lifecycleVersion?: number; lifecycleHolds?: string[]; lifecycleError?: unknown }
|
|
export interface InventoryOwnerPause { before: InventoryOwner; requested: InventoryOwner }
|
|
export function inventoryOwnerWasReady(owner: InventoryOwner, companyId: string, id: string) {
|
|
return owner.id === id && owner.companyId === companyId && owner.lifecycleState === "ready"
|
|
&& ["idle", "active", "running"].includes(owner.status ?? "") && !owner.lifecycleError
|
|
&& Number.isSafeInteger(owner.lifecycleVersion) && owner.lifecycleVersion! >= 0 && Number.isSafeInteger(owner.lifecycleVersion! + 2)
|
|
&& Array.isArray(owner.lifecycleHolds) && owner.lifecycleHolds.length === 0;
|
|
}
|
|
export function inventoryOwnerPauseRetained(receipt: InventoryOwnerPause | undefined, current: InventoryOwner, companyId: string, id: string) {
|
|
if (!receipt || !inventoryOwnerWasReady(receipt.before, companyId, id)) return false;
|
|
const { before, requested } = receipt;
|
|
const held = (row: InventoryOwner) => row.id === id && row.companyId === companyId && row.status === "paused" && !row.lifecycleError
|
|
&& Array.isArray(row.lifecycleHolds) && row.lifecycleHolds.length === 1 && row.lifecycleHolds[0] === "manual";
|
|
return held(requested) && held(current) && current.lifecycleState === "paused" && current.lifecycleVersion === before.lifecycleVersion! + 2
|
|
&& (requested.lifecycleState === "pausing" && requested.lifecycleVersion === before.lifecycleVersion! + 1
|
|
|| requested.lifecycleState === "paused" && requested.lifecycleVersion === current.lifecycleVersion);
|
|
}
|
|
export interface InventoryDeliverable { id: string; href: string; body: string }
|
|
export type InventoryObservation = InternalObservation & { sourceIssueId?: string; deliverables?: InventoryDeliverable[] };
|
|
const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
/** Where a task is presented: a rendered chat link, or a task link/identifier inside a linked run deliverable. */
|
|
function presentations(o: InventoryObservation, row: Entity) {
|
|
const found: Array<{ doc: number; at: number; text: string }> = [];
|
|
const tokens = [row.id, row.identifier].filter((value): value is string => !!value);
|
|
const tokenAt = (text: string) => {
|
|
for (const token of tokens) {
|
|
const match = new RegExp(`(?<![A-Za-z0-9-])${escapeRegExp(token)}(?![A-Za-z0-9])`).exec(text);
|
|
if (match) return match.index;
|
|
}
|
|
return -1;
|
|
};
|
|
// In chat a task counts only when the conversation rendered a working link to it: an explicit
|
|
// Markdown link (its href is in the reply) or a bare identifier the product auto-linked.
|
|
const href = o.links.find(link => hasLink({ ...o, links: [link] }, "issues", row));
|
|
const chatAt = href ? (o.reply.includes(href) ? o.reply.indexOf(href) : tokenAt(o.reply)) : -1;
|
|
if (chatAt >= 0) found.push({ doc: 0, at: chatAt, text: o.reply });
|
|
(o.deliverables ?? []).forEach((file, index) => {
|
|
const at = tokenAt(file.body);
|
|
if (at >= 0) found.push({ doc: index + 1, at, text: file.body });
|
|
});
|
|
return found;
|
|
}
|
|
/** A stated count: not digits inside a task identifier (RUN-491) or an element of a number list (RUN-1, 4, 7). */
|
|
const statesCount = (text: string, count: number) => new RegExp(`(?<![\\w-]|,\\s?)${count}(?![\\w-]|,)`).test(text);
|
|
/**
|
|
* The labels an item sits under: its own line, then each enclosing label of higher rank (a bold line
|
|
* outranks plain lines, a heading outranks bold lines and deeper headings). The walk stops at the
|
|
* first label naming an owner, so "## Riley" then "**Blocked (167)**: RUN-1" groups RUN-1 under Riley,
|
|
* while "**Unassigned — 18 blocked:** RUN-18" below "**Morgan — 166 blocked:**" stays its own group.
|
|
*/
|
|
function groupContext(text: string, at: number, ownerLabels: readonly string[]) {
|
|
const lineStart = text.lastIndexOf("\n", at) + 1; const lineEnd = text.indexOf("\n", at);
|
|
const line = text.slice(lineStart, lineEnd < 0 ? undefined : lineEnd);
|
|
const rank = (candidate: string) => { const heading = /^\s*(#{1,6})\s+/.exec(candidate);
|
|
return heading ? heading[1]!.length : /^\s*\*\*/.test(candidate) ? 7 : 8; };
|
|
const namesOwner = (candidate: string) => ownerLabels.some(label => candidate.toLowerCase().includes(label));
|
|
const chain = [line]; let current = rank(line);
|
|
for (const prior of text.slice(0, lineStart).split("\n").reverse()) {
|
|
if (namesOwner(chain[chain.length - 1]!) || current === 1) break;
|
|
if (rank(prior) < current) { chain.push(prior); current = rank(prior); }
|
|
}
|
|
return chain.join(" ").toLowerCase();
|
|
}
|
|
export function gradeOpenInventory(value: unknown) {
|
|
const c = checks(); const o = observation(value) as InventoryObservation | undefined;
|
|
c.add("independent-inventory", !!o && Array.isArray(o.state.originalTasks), "The independently saved, settled multi-page inventory is required.");
|
|
if (!o || !Array.isArray(o.state.originalTasks)) return c.result();
|
|
const originals = o.state.originalTasks as Entity[]; const p = entity(o.state, "project");
|
|
const open = originals.filter(row => row.projectId === p.id && !["done", "cancelled"].includes(row.status ?? ""));
|
|
const placed = new Map(open.map(row => [row.id, presentations(o, row)] as const));
|
|
const shown = open.filter(row => placed.get(row.id)!.length > 0);
|
|
const excluded = originals.filter(row => !open.includes(row));
|
|
const linkedDeliverables = (o.deliverables ?? []).every(file => o.links.includes(file.href) && o.reply.includes(file.href));
|
|
c.add("real-more-than-default-page", open.length > 500 && open.some(row => row.status === "blocked") && open.some(row => row.status !== "blocked")
|
|
&& originals.every(row => sameTask(row, o.tasks.find(now => now.id === row.id))),
|
|
"More than the production default 500 settled open rows exist, mixing blocked and other work, and the read-only answer preserves every saved task.");
|
|
c.add("full-or-explicitly-bounded", linkedDeliverables && (shown.length === open.length || shown.length > 0
|
|
&& statesCount(o.reply, shown.length) && statesCount(o.reply, open.length)),
|
|
"Every open task is presented (in chat or a deliverable linked from the reply), or the reply states its actual shown count and the full open count.");
|
|
// Naming a closed task is fine only where its line states that it is closed (its actual status), never as open work.
|
|
const statesClosed = (row: Entity, at: { text: string; at: number }) => {
|
|
const lineStart = at.text.lastIndexOf("\n", at.at) + 1; const lineEnd = at.text.indexOf("\n", at.at);
|
|
const line = [row.id, row.identifier].filter((token): token is string => !!token)
|
|
.reduce((text, token) => text.split(token).join(" "), at.text.slice(lineStart, lineEnd < 0 ? undefined : lineEnd).replace(/\]\([^)]*\)/g, "]"));
|
|
return row.status === "done" ? /\b(?:done|completed?)\b/i.test(line) : row.status === "cancelled" && /\bcancell?ed\b/i.test(line);
|
|
};
|
|
c.add("terminal-and-other-project-excluded", excluded.every(row => presentations(o, row).every(at => row.projectId === p.id && statesClosed(row, at))),
|
|
"Completed, cancelled and other-project work is not presented as open work.");
|
|
const owners = o.state.ownerLabels as Record<string, string>;
|
|
const named = Object.entries(owners ?? {}).filter(([key]) => key !== "unassigned").map(([, label]) => label.toLowerCase());
|
|
const allLabels = Object.values(owners ?? {}).map(label => label.toLowerCase());
|
|
let correctlyGrouped = !!owners;
|
|
for (const row of shown) for (const at of placed.get(row.id)!) {
|
|
const context = groupContext(at.text, at.at, allLabels); const key = row.assigneeAgentId ?? "unassigned";
|
|
const owner = owners?.[key]?.toLowerCase();
|
|
if (!owner) correctlyGrouped = false;
|
|
else if (key === "unassigned" ? named.some(label => context.includes(label)) : !context.includes(owner)) correctlyGrouped = false;
|
|
}
|
|
c.add("actual-single-owner-groups", shown.length > 0 && correctlyGrouped, "Each presented item sits under its actual single assignee, and unassigned items under no named owner.");
|
|
c.add("blocked-first-within-owner", Object.keys(owners ?? {}).every(owner => {
|
|
const group = shown.filter(row => (row.assigneeAgentId ?? "unassigned") === owner);
|
|
const docs = new Set(group.flatMap(row => placed.get(row.id)!.map(at => at.doc)));
|
|
return [...docs].every(doc => {
|
|
const at = (row: Entity) => placed.get(row.id)!.find(place => place.doc === doc)?.at;
|
|
const blocked = group.filter(row => row.status === "blocked").map(at).filter((v): v is number => v !== undefined);
|
|
const other = group.filter(row => row.status !== "blocked").map(at).filter((v): v is number => v !== undefined);
|
|
return !blocked.length || !other.length || Math.max(...blocked) < Math.min(...other);
|
|
});
|
|
}), "Blocked work precedes other open items within every owner group.");
|
|
// The chat answer itself (its comment and linked deliverable on the conversation) and run plumbing are not work changes.
|
|
c.add("read-only-audit", !!o.sourceIssueId && !o.activity.some(row => row.agentId === o.leadId && (row.action === "issue.created"
|
|
|| ["issue", "project", "goal", "agent"].includes(String((row as Entity & { entityType?: string }).entityType))
|
|
&& (row as Entity & { entityId?: string }).entityId !== o.sourceIssueId && row.action !== "issue.read_marked")),
|
|
"Organizing the answer creates no control-plane work changes outside the conversation.");
|
|
return c.result();
|
|
}
|
|
export async function observeOpenInventory(ctx: ChatCatalogContext, state: Record<string, unknown>): Promise<InventoryObservation> {
|
|
const o = await observeInternal(ctx, state); const source = ctx.issue();
|
|
const runIds = new Set(o.runs.map(run => run.id)); const deliverables: InventoryDeliverable[] = [];
|
|
if (source) for (const file of await ctx.input.api.get<IssueAttachment[]>(`/api/issues/${source.id}/attachments`)) {
|
|
const href = o.links.find(link => new URL(link, o.origin).pathname === `/api/attachments/${file.id}/content`);
|
|
if (!href || file.createdByAgentId !== o.leadId || !runIds.has(file.originatingRunId ?? "") || file.byteSize > 1024 * 1024) continue;
|
|
deliverables.push({ id: file.id, href, body: (await ctx.input.api.getAttachmentContent(file.id)).toString("utf8") });
|
|
}
|
|
// A document on this conversation, linked from the reply as /<prefix>/issues/<conversation>#document-<key>,
|
|
// counts when its current revision was written by the answering agent in this conversation's run.
|
|
if (source) for (const href of o.links) {
|
|
const url = new URL(href, o.origin); const key = /^#document-(.+)$/.exec(url.hash)?.[1];
|
|
const parts = url.pathname.split("/").map(decodeURIComponent);
|
|
if (url.origin !== new URL(o.origin).origin || !key || parts.length !== 4 || parts[2] !== "issues"
|
|
|| ![source.id, source.identifier].includes(parts[3])) continue;
|
|
const endpoint = `/api/issues/${source.id}/documents/${encodeURIComponent(decodeURIComponent(key))}`;
|
|
const doc = await ctx.input.api.get<{ id: string; latestRevisionId?: string | null }>(endpoint).catch(() => undefined);
|
|
const revisions = doc ? await ctx.input.api.get<DocumentRevision[]>(`${endpoint}/revisions`).catch(() => []) : [];
|
|
const latest = revisions.find(revision => revision.id === doc?.latestRevisionId);
|
|
if (!doc || !latest || latest.createdByAgentId !== o.leadId || !runIds.has(latest.createdByRunId ?? "") || latest.body.length > 1024 * 1024) continue;
|
|
deliverables.push({ id: doc.id, href, body: latest.body });
|
|
}
|
|
return { ...o, sourceIssueId: source?.id, deliverables };
|
|
}
|
|
async function settledInventory(ctx: ChatCatalogContext, ids: readonly string[], deadlineAt: number) {
|
|
// Product recovery may legitimately re-status seeded work (for example, assigned to a paused owner).
|
|
// Record the inventory only after it stops changing, so the answer is judged against current records.
|
|
const read = async () => {
|
|
const rows = await ctx.input.api.getRunObservation<Entity[]>(`${companyPath(ctx)}/issues?includeAgentCreatedTasks=true&limit=1000`);
|
|
return ids.map(id => rows.find(row => row.id === id)!);
|
|
};
|
|
let last = await read(); let stableSince = Date.now();
|
|
while (Date.now() - stableSince < 25_000) {
|
|
if (Date.now() + 2_000 >= deadlineAt) throw new Error("Seeded inventory did not settle within the observation budget");
|
|
await new Promise(resolve => setTimeout(resolve, 2_000));
|
|
const next = await read();
|
|
if (next.some(row => !row) || next.some((row, index) => !sameTask(last[index]!, row))) { last = next; stableSince = Date.now(); }
|
|
}
|
|
if (last.some(row => !row)) throw new Error("Seeded inventory rows are missing");
|
|
return last;
|
|
}
|
|
/** Deterministic integer mixing, so owners and statuses follow no arithmetic pattern. */
|
|
function inventoryMix(value: number) {
|
|
let x = Math.imul(value + 0x9e3779b9, 0x85ebca6b) >>> 0;
|
|
x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35) >>> 0;
|
|
return (x ^ (x >>> 16)) >>> 0;
|
|
}
|
|
// Real inventories cannot be summarized as "every 3rd task"; an even, shuffled
|
|
// owner split keeps exactly 167 rows per group without a periodic rule.
|
|
const INVENTORY_OWNER_RANK = (() => {
|
|
const order = Array.from({ length: 501 }, (_, index) => index).sort((a, b) => inventoryMix(a) - inventoryMix(b) || a - b);
|
|
const rank = new Array<number>(501); order.forEach((index, position) => { rank[index] = position; }); return rank;
|
|
})();
|
|
export function openInventoryTaskSeed(index: number, workerId: string, secondWorkerId: string) {
|
|
const slot = (INVENTORY_OWNER_RANK[index] ?? index) % 3;
|
|
const owner = slot === 0 ? workerId : slot === 1 ? secondWorkerId : null;
|
|
const roll = inventoryMix(index + 1_000);
|
|
return {
|
|
title: `Launch work ${String(index + 1).padStart(3, "0")}`,
|
|
status: roll % 7 === 0 ? "blocked" : roll % 2 === 0 && owner ? "in_progress" : "todo",
|
|
assigneeAgentId: owner,
|
|
description: "Existing launch work. This inventory fixture records current state and must not trigger execution.",
|
|
};
|
|
}
|
|
function openInventory(): ChatCatalogCase {
|
|
const base = makeDefinition("tasks.organize-open-work", async ctx => {
|
|
const p = await project(ctx); const other = await project(ctx, "Experiments"); const worker = await fixtureWorker(ctx);
|
|
const secondWorker = await fixtureWorker(ctx);
|
|
await ctx.input.api.patch(`/api/agents/${secondWorker.id}`, { name: "Riley" });
|
|
// Existing ownership is prerequisite state. Pause both owners before seeding
|
|
// actionable statuses so preparing an inventory never starts provider work.
|
|
remainingIdentityObservationBudget(ctx);
|
|
const deadlineAt = ctx.observationDeadlineAt!;
|
|
const companyId = ctx.input.fixtures.company.id;
|
|
const ownerPauseReceipts: InventoryOwnerPause[] = [];
|
|
if ((await ctx.allRuns()).length) throw new Error("Inventory prerequisites unexpectedly admitted work");
|
|
for (const id of [worker.id, secondWorker.id]) {
|
|
const before = await ctx.input.api.get<InventoryOwner>(`/api/agents/${id}`);
|
|
if (!inventoryOwnerWasReady(before, companyId, id)) throw new Error("Inventory owner was not genuinely ready before manual pause");
|
|
const requested = await ctx.input.api.post<InventoryOwner>(`/api/agents/${id}/pause`);
|
|
const receipt = { before, requested }; ownerPauseReceipts.push(receipt);
|
|
await pollUntil({ label: "Actual inventory owner manual pause completes", deadlineAt,
|
|
load: () => ctx.input.api.get<InventoryOwner>(`/api/agents/${id}`),
|
|
accept: row => Date.now() < deadlineAt && inventoryOwnerPauseRetained(receipt, row, companyId, id),
|
|
reject: row => row.lifecycleError ? "Inventory owner pause failed" : undefined });
|
|
}
|
|
const originalTasks: Entity[] = [];
|
|
for (let index = 0; index < 501; index++) {
|
|
const { title, ...fields } = openInventoryTaskSeed(index, worker.id, secondWorker.id);
|
|
originalTasks.push(await task(ctx, title, p.id, fields));
|
|
}
|
|
originalTasks.push(await task(ctx, "Completed launch work", p.id, { status: "done" }), await task(ctx, "Cancelled launch work", p.id, { status: "cancelled" }),
|
|
await task(ctx, "Unrelated experiment", other.id));
|
|
remainingIdentityObservationBudget(ctx);
|
|
const settled = await settledInventory(ctx, originalTasks.map(row => row.id), deadlineAt);
|
|
originalTasks.splice(0, originalTasks.length, ...settled);
|
|
if ((await ctx.allRuns()).length) throw new Error("Seeding the paused-owner inventory unexpectedly admitted work");
|
|
return { project: p, originalTasks, workerIds: [worker.id, secondWorker.id], ownerPauseReceipts, ownerLabels: { [worker.id]: "Morgan", [secondWorker.id]: "Riley", unassigned: "Unassigned" } };
|
|
}, gradeOpenInventory);
|
|
return { ...base, observe: observeOpenInventory, oracleVersion: "paperclip.chat-catalog.internal.organize-open-work.v6",
|
|
sourceFiles: [...base.sourceFiles, "chat-catalog-domains/internal-task-inventory.ts"],
|
|
// Seeding 504 tasks under paused owners takes about 2.75 minutes of the attempt; the agent keeps about 12 minutes.
|
|
attemptTimeoutMs: 900_000,
|
|
setupPolicy: { readiness: "preserve-scenario", invariantCheckIds: ["inventory-owners-paused", "lead-ready", "inventory-settled"] },
|
|
async verifySetup(ctx, state) {
|
|
const c = checks();
|
|
const lead = await ctx.input.api.get<Entity & { lifecycleState: string }>(`/api/agents/${ctx.input.fixtures.agent.id}`);
|
|
const ids = state.workerIds as string[];
|
|
const owners = await Promise.all(ids.map(id => ctx.input.api.get<InventoryOwner>(`/api/agents/${id}`)));
|
|
const receipts = state.ownerPauseReceipts as InventoryOwnerPause[] | undefined;
|
|
c.add("inventory-owners-paused", ids.length === 2 && new Set(ids).size === 2 && receipts?.length === 2
|
|
&& owners.every((row, index) => inventoryOwnerPauseRetained(receipts[index], row, ctx.input.fixtures.company.id, ids[index]!)),
|
|
"Both actual owners were ready before manual pause and retain their exact completed paused lifecycle, version and manual hold; no provider work is admitted.");
|
|
c.add("lead-ready", lead.lifecycleState === "ready" && ["idle", "active"].includes(lead.status ?? ""), "The CEO remains ready for the canonical inventory query.");
|
|
const current = await ctx.input.api.getRunObservation<Entity[]>(`${companyPath(ctx)}/issues?includeAgentCreatedTasks=true&limit=1000`);
|
|
const originals = Array.isArray(state.originalTasks) ? state.originalTasks as Entity[] : [];
|
|
c.add("inventory-settled", originals.length > 0 && originals.every(row => sameTask(row, current.find(now => now.id === row.id))),
|
|
"The recorded inventory is still the current task state when the query is sent.");
|
|
return c.result();
|
|
},
|
|
async verifyResult(ctx, state, value) {
|
|
const o = observation(value)! as InventoryObservation; const shown = o.tasks.filter(row => hasLink(o, "issues", row));
|
|
if (!shown.length) {
|
|
// The inventory was delivered as a linked file: require that each deliverable link was rendered in this conversation.
|
|
const files = o.deliverables ?? [];
|
|
if (!files.length || files.some(file => !o.renderedLinkEvidence?.some(link => link.href === file.href))) throw new Error("No rendered task or inventory deliverable link");
|
|
return;
|
|
}
|
|
const representatives = new Map<string, Entity>();
|
|
for (const row of shown) {
|
|
const key = `${row.assigneeAgentId ?? "unassigned"}:${row.status === "blocked" ? "blocked" : "other"}`;
|
|
if (!representatives.has(key)) representatives.set(key, row);
|
|
}
|
|
await base.verifyResult(ctx, state, { ...o, tasks: [...representatives.values()], projects: [], goals: [] });
|
|
},
|
|
};
|
|
}
|
|
export const taskInventoryCases: readonly ChatCatalogCase[] = [openInventory()];
|