diff --git a/tests/runner-e2e/chat-catalog-domains/internal-distinct-fixtures.test.ts b/tests/runner-e2e/chat-catalog-domains/internal-distinct-fixtures.test.ts new file mode 100644 index 00000000000..33c96cc0997 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/internal-distinct-fixtures.test.ts @@ -0,0 +1,9 @@ +import { expect, it } from "vitest"; +import type { ChatCatalogContext } from "../chat-catalog-contract.js"; +import { verifyDistinctTaskFixtures } from "./internal-distinct-fixtures.js"; +const originals = () => [{ id: "launch-task", companyId: "company", title: "Landing page", projectId: "launch", status: "backlog" }, + { id: "experiment-task", companyId: "company", title: "Landing page", projectId: "experiments", status: "backlog" }]; +function context(rows = originals()) { return { input: { api: { get: async (path: string) => rows.find(row => path.endsWith(`/${row.id}`)) }, fixtures: { company: { id: "company" } }, evidence: async () => undefined } } as unknown as ChatCatalogContext; } +it("accepts genuinely distinct saved same-title tasks in the declared projects", async () => expect(await verifyDistinctTaskFixtures(context(), originals())).toEqual(originals())); +it("rejects public-create title reuse before sending the canonical query", async () => { const rows = originals(); rows[1] = rows[0]!; await expect(verifyDistinctTaskFixtures(context(rows), rows)).rejects.toThrow(/aliased/); }); +it("rejects a saved task in the wrong project or company", async () => { const rows = originals(); rows[1]!.projectId = "launch"; await expect(verifyDistinctTaskFixtures(context(rows), originals())).rejects.toThrow(/project/); const foreign = originals(); foreign[1]!.companyId = "foreign"; await expect(verifyDistinctTaskFixtures(context(foreign), originals())).rejects.toThrow(/identity/); }); diff --git a/tests/runner-e2e/chat-catalog-domains/internal-distinct-fixtures.ts b/tests/runner-e2e/chat-catalog-domains/internal-distinct-fixtures.ts new file mode 100644 index 00000000000..3cbd67d3324 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/internal-distinct-fixtures.ts @@ -0,0 +1,13 @@ +import type { ChatCatalogContext } from "../chat-catalog-contract.js"; +import { type Entity, sameTask } from "./internal-support.js"; + +/** Same-title tasks are intentional context here, not repeated creation requests. */ +export async function verifyDistinctTaskFixtures(ctx: ChatCatalogContext, expected: readonly Entity[]) { + if (new Set(expected.map(row => row.id)).size !== expected.length) throw new Error("Distinct prerequisite tasks aliased to one public-create result"); + const saved = await Promise.all(expected.map(row => ctx.input.api.get(`/api/issues/${row.id}`))); + if (!saved.every((row, index) => row.companyId === ctx.input.fixtures.company.id && sameTask(expected[index]!, row))) + throw new Error("Saved prerequisite task identity, title or project differs from the declared context"); + await ctx.input.evidence("chat-catalog-distinct-prerequisite-tasks.json", { companyId: ctx.input.fixtures.company.id, + tasks: saved.map(row => ({ id: row.id, title: row.title, projectId: row.projectId, status: row.status, priority: row.priority })) }); + return saved; +} diff --git a/tests/runner-e2e/chat-catalog-domains/internal-task-creation.ts b/tests/runner-e2e/chat-catalog-domains/internal-task-creation.ts index dc169dfa6e4..07b7536b177 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-task-creation.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-task-creation.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { verifyDistinctTaskFixtures } from "./internal-distinct-fixtures.js"; import type { ChatCatalogCase, ChatCatalogContext } from "../chat-catalog-contract.js"; import type { ChatRun, ChatIssue } from "../chat-flow.js"; import { dropChatSendAcknowledgement } from "../lost-send.js"; @@ -104,7 +105,8 @@ function creationDefinition(scenarioId: string): ChatCatalogCase { if (scenarioId === "projects.project-work") { priorTasks.push(await task(ctx, "Signup copy", p.id)); priorTasks.push(await task(ctx, "Product brief", p.id, { status: "done" })); - const other = await project(ctx, "Experiments"); priorTasks.push(await task(ctx, "Signup copy", other.id)); + const other = await project(ctx, "Experiments"); priorTasks.push(await task(ctx, "Signup copy", other.id, { allowDuplicate: true })); + await verifyDistinctTaskFixtures(ctx, priorTasks); } } if (scenarioId === "tasks.website-without-project") interception = await dropChatSendAcknowledgement(ctx.input.page, ctx.scenario.query); diff --git a/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts b/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts index 0d0bcf04a3e..d2c443f95a6 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts @@ -1,4 +1,5 @@ import { withEditOwnership } from "./internal-edit-ownership.js"; +import { verifyDistinctTaskFixtures } from "./internal-distinct-fixtures.js"; import { delegatedTaskCases } from "./internal-task-creation.js"; import { taskPrivacyCases } from "./internal-privacy.js"; import { taskInventoryCases } from "./internal-task-inventory.js"; @@ -102,10 +103,13 @@ export const tasksCases: readonly ChatCatalogCase[] = [ function ambiguousTask(): ChatCatalogCase { const base = define("tasks.ambiguous-task", async ctx => { const p = await project(ctx); const otherProject = await project(ctx, "Experiments"); - const t = await task(ctx, "Landing page", p.id); const otherTask = await task(ctx, "Landing page", otherProject.id); + const t = await task(ctx, "Landing page", p.id); + // Public creation defaults to company-wide recent-title reuse, regardless of project. + const otherTask = await task(ctx, "Landing page", otherProject.id, { allowDuplicate: true }); + await verifyDistinctTaskFixtures(ctx, [t, otherTask]); return { project: p, task: t, otherProject, otherTask }; }); - return { ...base, expectedRunCount: 2, expectedHumanMessageCount: 2, attemptTimeoutMs: 420_000, + return { ...base, sourceFiles: [...base.sourceFiles, "chat-catalog-domains/internal-distinct-fixtures.ts"], expectedRunCount: 2, expectedHumanMessageCount: 2, attemptTimeoutMs: 420_000, async act(ctx, state) { const first = await pollUntil({ label: "Task ambiguity clarification", deadlineAt: Date.now() + 150_000, load: () => observeInternal(ctx, state), accept: o => o.runs.length === 1 && o.runs.every(row => row.status === "succeeded")