diff --git a/tests/runner-e2e/chat-catalog-domains/internal-task-inventory.ts b/tests/runner-e2e/chat-catalog-domains/internal-task-inventory.ts new file mode 100644 index 00000000000..000ab8f06b1 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/internal-task-inventory.ts @@ -0,0 +1,80 @@ +import type { ChatCatalogCase } from "../chat-catalog-contract.js"; +import { makeDefinition, fixtureWorker } from "./internal-definition.js"; +import { checks, entity, hasLink, observation, project, sameTask, task, type Entity } from "./internal-support.js"; + +export function gradeOpenInventory(value: unknown) { + const c = checks(); const o = observation(value); + c.add("independent-inventory", !!o && Array.isArray(o.state.originalTasks), "The independently saved 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 shown = open.filter(row => hasLink(o, "issues", row)); + const excluded = originals.filter(row => !open.includes(row)); + c.add("real-more-than-default-page", open.length > 500 && originals.every(row => sameTask(row, o.tasks.find(now => now.id === row.id))), + "More than the production default 500 rows exist and the read-only answer preserves every saved task."); + c.add("full-or-explicitly-bounded", shown.length === open.length || shown.length > 0 + && /\b(?:partial|first|limited|remaining|more results|not exhaustive|not all)\b/i.test(o.reply) + && new RegExp(`\\b${shown.length}\\b`).test(o.reply) && new RegExp(`\\b${open.length}\\b`).test(o.reply), + "All open work is linked, or the answer explicitly states its actual shown count and full inventory bound."); + c.add("terminal-and-other-project-excluded", excluded.every(row => !hasLink(o, "issues", row)), "Completed, cancelled and other-project work is excluded."); + const owners = o.state.ownerLabels as Record; + const positions = new Map(); + let correctlyGrouped = !!owners; + for (const row of shown) { + const href = o.links.find(link => hasLink({ ...o, links: [link] }, "issues", row))!; + const at = o.reply.indexOf(href); positions.set(row.id, at); + if (at < 0) { correctlyGrouped = false; continue; } + const lineStart = o.reply.lastIndexOf("\n", at) + 1; + const lineEnd = o.reply.indexOf("\n", at); + const line = o.reply.slice(lineStart, lineEnd < 0 ? undefined : lineEnd); + const prior = o.reply.slice(0, lineStart).split("\n").reverse().find(text => /^\s*(?:#{1,6}\s+|\*\*)/.test(text)) ?? ""; + const owner = owners[row.assigneeAgentId ?? "unassigned"]; + if (!owner || !`${line} ${prior}`.toLowerCase().includes(owner.toLowerCase())) correctlyGrouped = false; + } + c.add("actual-single-owner-groups", shown.length > 0 && correctlyGrouped, "Each rendered item is grouped under its actual single assignee or the unassigned group."); + c.add("blocked-first-within-owner", Object.keys(owners ?? {}).every(owner => { + const group = shown.filter(row => (row.assigneeAgentId ?? "unassigned") === owner); + const blocked = group.filter(row => row.status === "blocked").map(row => positions.get(row.id) ?? -1); + const other = group.filter(row => row.status !== "blocked").map(row => positions.get(row.id) ?? -1); + return !blocked.length || !other.length || Math.max(...blocked) < Math.min(...other); + }), "Blocked work precedes other open items within every owner group."); + c.add("read-only-audit", !o.activity.some(row => row.agentId === o.leadId && row.action !== "issue.read_marked"), "Organizing the answer creates no control-plane work changes."); + return c.result(); +} +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. + await ctx.input.api.post(`/api/agents/${worker.id}/pause`); + await ctx.input.api.post(`/api/agents/${secondWorker.id}/pause`); + for (const id of [worker.id, secondWorker.id]) { + if ((await ctx.input.api.get(`/api/agents/${id}`)).status !== "paused") throw new Error("Inventory owner pause did not persist"); + } + const originalTasks: Entity[] = []; + for (let index = 0; index < 501; index++) { + const owner = index % 3 === 0 ? worker.id : index % 3 === 1 ? secondWorker.id : null; + originalTasks.push(await task(ctx, `Launch work ${String(index + 1).padStart(3, "0")}`, p.id, { + status: index % 7 === 0 ? "blocked" : index % 2 === 0 ? "in_progress" : "todo", assigneeAgentId: owner, + description: "Existing launch work. This inventory fixture records current state and must not trigger execution.", + })); + } + 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)); + return { project: p, originalTasks, ownerLabels: { [worker.id]: "Morgan", [secondWorker.id]: "Riley", unassigned: "Unassigned" } }; + }, gradeOpenInventory); + return { ...base, sourceFiles: [...base.sourceFiles, "chat-catalog-domains/internal-task-inventory.ts"], attemptTimeoutMs: 600_000, + async verifyResult(ctx, state, value) { + const o = observation(value)!; const shown = o.tasks.filter(row => hasLink(o, "issues", row)); + const representatives = new Map(); + 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()]; diff --git a/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts b/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts index 407961a2843..7245e0bee2e 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts @@ -1,5 +1,6 @@ import { delegatedTaskCases } from "./internal-task-creation.js"; import { taskPrivacyCases } from "./internal-privacy.js"; +import { taskInventoryCases } from "./internal-task-inventory.js"; import type { ChatCatalogCase } from "../chat-catalog-contract.js"; import { project, task, goal, putDocument, entity, observation, checks, hasLink, sameTask, type Entity } from "./internal-support.js"; import { makeDefinition, retryDefinition, fixtureWorker } from "./internal-definition.js"; @@ -58,6 +59,7 @@ const define = (id: string, setup: ChatCatalogCase["setup"]) => makeDefinition(i export const tasksCases: readonly ChatCatalogCase[] = [ ...delegatedTaskCases, ...taskPrivacyCases, + ...taskInventoryCases, ambiguousTask(), define("tasks.edit-properties", async ctx => { const p = await project(ctx); const worker = await fixtureWorker(ctx); diff --git a/tests/runner-e2e/chat-catalog-domains/internal.test.ts b/tests/runner-e2e/chat-catalog-domains/internal.test.ts index 44cb0f99f5b..91e9f1f2782 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal.test.ts @@ -14,6 +14,11 @@ function event(action: string, entityId: string, details?: Record ({ id: `task-${index}`, projectId: "p", title: `Launch ${index}`, status: index < 2 ? "blocked" : "todo", assigneeAgentId: null })); + o.state.originalTasks = structuredClone(o.tasks); o.state.ownerLabels = { unassigned: "Unassigned" }; + o.links = o.tasks.map(row => `/ACORN/issues/${row.id}`); o.reply = "## Unassigned\n" + o.tasks.map(row => `- [${row.title}](/ACORN/issues/${row.id})`).join("\n"); break; + } case "inbox-search.show-my-work": case "inbox-search.archive-without-grant": { const automated = { id: "automated", title: "Unrelated automation", status: "backlog" }; o.tasks.push(automated); @@ -138,6 +143,7 @@ function valid(scenario: string): InternalObservation { return o; } const corrupt: Record void> = { + "tasks.organize-open-work": o => { o.reply = o.reply.replace("## Unassigned", "## Morgan"); }, "inbox-search.show-my-work": o => { o.reply = "You have 7 unread inbox items; I reviewed all of them."; }, "inbox-search.archive-without-grant": o => { o.activity.push(event("issue.inbox_archived", "task")); }, "tasks.share-private-task": o => { Object.assign(o, { observerTaskStatus: 200 }); }, @@ -318,3 +324,19 @@ it("rejects correct editor attribution for only one of the two actual changes", const o = valid("activity.audit-change"); o.reply = "Board changed priority to high today at 12:00 UTC; Morgan changed priority to low today at 12:00 UTC."; expect(gradeInternal(o).passed).toBe(false); }); +describe("multi-page open inventory calibration", () => { + it("accepts a clearly bounded partial inventory with its actual shown and total counts", () => { + const o = valid("tasks.organize-open-work"); o.links = o.links.slice(0, 10); + o.reply = "Partial results: first 10 of 501 open tasks.\n## Unassigned\n" + o.tasks.slice(0, 10).map(row => `- [${row.title}](/ACORN/issues/${row.id})`).join("\n"); + expect(gradeInternal(o).passed).toBe(true); + }); + it("rejects a truncated inventory silently claimed as all", () => { + const o = valid("tasks.organize-open-work"); o.links = o.links.slice(0, 10); + expect(gradeInternal(o).passed).toBe(false); + }); + it("rejects a blocked item placed after an ordinary item for the same owner", () => { + const o = valid("tasks.organize-open-work"); + o.reply = "## Unassigned\n" + [...o.tasks.slice(2), ...o.tasks.slice(0, 2)].map(row => `- [${row.title}](/ACORN/issues/${row.id})`).join("\n"); + expect(gradeInternal(o).passed).toBe(false); + }); +});