Files
PaperClipAI/tests/runner-e2e/chat-catalog-domains/internal-task-inventory.ts
T
f4337bb5fb test(evals): land the chat catalog eval harness on master (#19614)
## 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>
2026-11-10 08:02:11 -06:00

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()];