import { afterEach, expect, spyOn, test } from "bun:test"; import { ulid } from "@chopin/dialect"; import { type DocumentRoom, documentTools } from "./tools"; import { forgetWorkspaces, rememberCheckout } from "../harness/atomic/workspace"; import { Admission } from "../auth/admission"; import { Sessions } from "../auth/session"; import * as Chat from "../chat/service"; import * as room from "../plan/room"; import * as Service from "../plan/service"; import * as Store from "../questions/store"; import { openPlan } from "../testing/plan"; import type { HostedAuth } from "../auth/routes"; import type { SeedState } from "../testing/plan"; import type { Config } from "../config"; import type { Socket } from "../wire"; const WIDGET = "01K0N4TR8K7JGM4R1J7PW4R8YJ"; const QUESTION = "01K0N4V4E7Y6P4MJ5WD8XZF3B2"; const OPTION = "01K0N4W3B7P27CBAEC7A8C8WEA"; const SECOND_WIDGET = "01K0N4X2M5R8T3VQ7YB6ZC4DEF"; const SECOND_QUESTION = "01K0N4Y2M5R8T3VQ7YB6ZC4DEF"; const SECOND_OPTION = "01K0N4Z2M5R8T3VQ7YB6ZC4DEF"; const SOURCE = `# Title The renderer caches tiles for 60 seconds. The second paragraph. `; const SECOND_QUESTIONNAIRE = ` `; let plans: Awaited>[] = []; afterEach(async () => { for (let plan of plans) await Service.close(plan); plans = []; }); async function opened(source: string, state: SeedState = {}) { let context = await openPlan(source, state); plans.push(context.plan); return context; } function fixtureTools({ room, ...dependencies }: Omit & { room: string }) { let context = { room: { id: room, ...dependencies }, repository: { id: "R_test" } }; return Object.entries(documentTools).map(([name, item]) => ({ name, parameters: item.inputSchema, handler: (input: unknown, call: { toolCallId: string; [key: string]: unknown }) => item.execute!(input as never, { context, toolCallId: call.toolCallId, messages: [], } as never), })); } test("document tool names and schemas remain available to the Planner", async () => { expect(Object.keys(documentTools)).toEqual([ "list_investigations", "read_investigation", "propose_investigation", "read_plan", "read_reference", "list_background_jobs", "read_background_job", "create_research_workspace", "edit_plan", "ask", "read_implementation_graph", "edit_implementation_graph", "anchor_plan", ]); expect(documentTools.edit_plan.inputSchema).toBeDefined(); }); test("document tools use the room and repository supplied with each call", async () => { let first = await opened("First document.\n"); let second = await opened("Second document.\n"); let events: string[] = []; let makeRoom = (fixture: typeof first, id: string): DocumentRoom => ({ id, plan: fixture.plan, server: fixture.server, persist: () => Service.persist(fixture.plan), exclusive: action => Service.exclusive(fixture.plan, action), async publish() { events.push(`${id}:publish`); }, anchors() { events.push(`${id}:anchors`); }, changes() { events.push(`${id}:changes`); }, jobs: { list: (room: string) => ({ room }), get: (room: string, jobId: string) => ({ room, jobId }), } as unknown as DocumentRoom["jobs"], readReference: async (referenceId, repositoryId) => ({ room: id, referenceId, repositoryId }), createResearch: async question => ({ workspaceId: `${id}:${question}`, state: "pending", stage: "queued", }), }); let a = makeRoom(first, "first"); let b = makeRoom(second, "second"); let call = async ( name: "read_plan" | "read_implementation_graph" | "list_background_jobs", room: DocumentRoom, ) => { let result = await documentTools[name].execute!({}, { context: { room }, toolCallId: "call", messages: [], }); if (typeof result !== "string") throw new Error("document tool did not return text"); return JSON.parse(result); }; expect((await call("read_plan", a)).source).toContain("First document."); expect((await call("read_plan", b)).source).toContain("Second document."); expect((await call("read_plan", a)).document.id).toBe(first.channel.id); expect((await call("read_plan", b)).document.id).toBe(second.channel.id); expect((await call("read_implementation_graph", a)).source).toContain("First document."); expect((await call("read_implementation_graph", b)).source).toContain("Second document."); expect(await call("list_background_jobs", a)).toEqual({ room: "first" }); expect(await call("list_background_jobs", b)).toEqual({ room: "second" }); let job = await documentTools.read_background_job.execute!({ id: "job-1" }, { context: { room: b }, toolCallId: "job", messages: [], }); if (typeof job !== "string") throw new Error("job tool did not return text"); expect(JSON.parse(job)).toEqual({ room: "second", jobId: "job-1" }); let referenceId = ulid(); let reference = await documentTools.read_reference.execute!({ id: referenceId }, { context: { room: a, repository: { id: "R_first" } }, toolCallId: "ref", messages: [], }); if (typeof reference !== "string") throw new Error("reference tool did not return text"); expect(JSON.parse(reference)).toEqual({ room: "first", referenceId, repositoryId: "R_first" }); let research = await documentTools.create_research_workspace.execute!( { question: "Exact brief" }, { context: { room: b }, toolCallId: "research", messages: [], }, ); if (typeof research !== "string") throw new Error("research tool did not return text"); expect(JSON.parse(research).workspaceId).toBe("second:Exact brief"); let result = await documentTools.edit_plan.execute!({ revision: second.plan.revision, operations: [{ op: "replace", index: 0, source: "Changed second document.\n" }], }, { context: { room: b }, toolCallId: "edit", messages: [] }); if (typeof result !== "string") throw new Error("edit_plan did not return text"); expect(JSON.parse(result)).toMatchObject({ ok: true, document: { id: second.channel.id } }); expect(room.project(first.plan.document)).toBe("First document.\n"); expect(room.project(second.plan.document)).toBe("Changed second document.\n"); expect(events).toEqual(["second:publish", "second:changes", "second:anchors"]); }); test("read_plan and edit_plan name the room's document as read_document does", async () => { let { channel, now, plan, server, storage } = await opened("Identified document.\n"); let tools = fixtureTools({ plan, server, room: channel.id, persist: () => Service.persist(plan), exclusive: action => Service.exclusive(plan, action), async publish() {}, anchors() {}, changes() {}, }); let call = async (name: "read_plan" | "edit_plan", input: unknown) => { let result = await tools.find(tool => tool.name === name)!.handler(input, { toolCallId: name, }); if (typeof result !== "string") throw new Error(`${name} did not return text`); return JSON.parse(result); }; let identity = { id: channel.id, url: "/documents/owner/repository/test-plan" }; expect((await call("read_plan", {})).document).toEqual(identity); rememberCheckout(channel.id, process.cwd()); try { expect((await call("read_plan", {})).document).toEqual(identity); } finally { forgetWorkspaces(); } await storage.channels.rename({ id: channel.id, title: "Renamed plan", now }); let renamed = { id: channel.id, url: "/documents/owner/repository/renamed-plan" }; let read = await call("read_plan", {}); expect(read.document).toEqual(renamed); let edited = await call("edit_plan", { revision: read.revision, operations: [{ op: "replace", index: 0, source: "Edited document.\n" }], }); expect(edited).toMatchObject({ ok: true, document: renamed }); let stale = await call("edit_plan", { revision: read.revision + 1, operations: [{ op: "replace", index: 0, source: "Stale edit.\n" }], }); expect(stale).toMatchObject({ ok: false, reason: "stale", document: renamed }); }); test("read_plan and edit_plan give a child document its nested canonical URL", async () => { let { channel, plan, server } = await opened("Child document.\n", { parent: "Parent plan" }); let tools = fixtureTools({ plan, server, room: channel.id, persist: () => Service.persist(plan), exclusive: action => Service.exclusive(plan, action), async publish() {}, anchors() {}, changes() {}, }); let call = async (name: "read_plan" | "edit_plan", input: unknown) => JSON.parse( (await tools.find(tool => tool.name === name)!.handler(input, { toolCallId: name, })) as string, ); let identity = { id: channel.id, url: "/documents/owner/repository/parent-plan/children/test-plan", }; let read = await call("read_plan", {}); expect(read.document).toEqual(identity); let edited = await call("edit_plan", { revision: read.revision, operations: [{ op: "replace", index: 0, source: "Edited child.\n" }], }); expect(edited).toMatchObject({ ok: true, document: identity }); }); test("create_research_workspace validates one question and waits for immediate research start", async () => { let { plan, server } = await opened("Research context.\n"); let committed = Promise.withResolvers<{ workspaceId: string; state: "pending"; stage: "queued"; }>(); let questions: string[] = []; let createResearch = fixtureTools({ plan, server, room: "test", persist: () => Service.persist(plan), exclusive: action => Service.exclusive(plan, action), async publish() {}, anchors() {}, changes() {}, createResearch: question => { questions.push(question); return committed.promise; }, }).find(tool => tool.name === "create_research_workspace"); if (!createResearch?.handler) throw new Error("create_research_workspace is missing"); expect((createResearch.parameters as { jsonSchema: unknown }).jsonSchema).toEqual({ type: "object", properties: { question: { type: "string", minLength: 1, maxLength: 4_096 } }, required: ["question"], additionalProperties: false, }); let call = async (raw: unknown): Promise => { let response = await createResearch.handler!(raw as never, { sessionId: "session", toolCallId: "call", toolName: "create_research_workspace", arguments: raw as never, }); if (typeof response !== "string") throw new Error("research tool returned no text"); return response; }; let question = "Which public release evidence supports adopting version 3?"; let response = call({ question }); let settled = false; void response.then(() => settled = true); await Promise.resolve(); expect(questions).toEqual([question]); expect(settled).toBe(false); let result = { workspaceId: "workspace-1", state: "pending" as const, stage: "queued" as const, }; committed.resolve(result); expect(JSON.parse(await response)).toEqual(result); let error = spyOn(console, "error").mockImplementation(() => {}); try { for ( let invalid of [ {}, { question: "" }, { question: 1 }, { question: "valid", extra: true }, { question: "x".repeat(4_097) }, ] ) { expect(await call(invalid)).toStartWith("Error:"); } } finally { error.mockRestore(); } expect(questions).toEqual([question]); }); test("read_reference accepts only ids made available by the active chat session", async () => { let { plan, server } = await opened("Reference context.\n"); let available = ulid(); let reads: string[] = []; let readReference = fixtureTools({ plan, server, room: "test", persist: () => Service.persist(plan), exclusive: action => Service.exclusive(plan, action), async publish() {}, anchors() {}, changes() {}, readReference: async id => { if (id !== available) throw new Error("reference is not available in this Planner session"); reads.push(id); return { id, source: "untrusted" }; }, }).find(tool => tool.name === "read_reference"); if (!readReference?.handler) throw new Error("read_reference is missing"); let call = (raw: unknown) => readReference.handler!(raw as never, { sessionId: "session", toolCallId: "call", toolName: "read_reference", arguments: raw as never, }); let error = spyOn(console, "error").mockImplementation(() => {}); try { expect(await call({ id: available })).toContain("untrusted"); expect(await call({ id: ulid() })).toContain("Error: reference is not available"); expect(await call({ id: "arbitrary" })).toContain("Error: reference id is invalid"); expect(await call({ id: available, channelId: "another-room" })).toContain( "Error: read_reference accepts only", ); } finally { error.mockRestore(); } expect(reads).toEqual([available]); }); test("anchor_plan waits for decision placement before persisting and broadcasting anchors", async () => { let { plan, server } = await opened(SOURCE, { revision: 1, questions: [{ id: WIDGET, status: "answered", resolver: "ana", definition: { questions: [{ id: QUESTION, header: "Cache", question: "How long do we cache?", multiple: false, options: [{ id: OPTION, label: "60 seconds", description: "" }], }], }, answers: { [QUESTION]: "60 seconds" }, }], }); let published: unknown[] = []; let anchors = 0; let started = Promise.withResolvers(); let release = Promise.withResolvers(); let finished = Promise.withResolvers(); let persisted = false; let anchorPlan = fixtureTools({ plan, server, room: "test", persist: async () => { persisted = true; await Service.persist(plan); }, exclusive: action => Service.exclusive(plan, action), publish: async mutation => { started.resolve(); await release.promise; await Service.publish(plan, server, "test", mutation); published.push(mutation); finished.resolve(); }, anchors: () => anchors++, changes() {}, }).find(tool => tool.name === "anchor_plan"); if (!anchorPlan) throw new Error("anchor_plan is missing"); if (!anchorPlan.handler) throw new Error("anchor_plan has no handler"); let digest = room.digests(plan.document)[1]!; let args = { revision: plan.revision, anchors: [{ widget: WIDGET, question: QUESTION, blocks: [{ index: 1, digest }] }], }; let pending = anchorPlan.handler(args, { sessionId: "session", toolCallId: "call", toolName: "anchor_plan", arguments: args, }); await started.promise; let beforeCommit = { persisted, anchors }; release.resolve(); let response = await pending; await finished.promise; expect(beforeCommit).toEqual({ persisted: false, anchors: 0 }); if (typeof response !== "string") throw new Error("anchor_plan returned no text"); let result = JSON.parse(response); expect(result.ok).toBe(true); expect(published).toHaveLength(1); expect(anchors).toBe(1); let source = room.project(plan.document); expect(source.indexOf("The renderer caches tiles for 60 seconds.")) .toBeLessThan(source.indexOf(` { let { channel, plan, server } = await opened("The plan is ready.\n"); (plan as typeof plan & { claiming: boolean }).claiming = true; let editPlan = fixtureTools({ plan, server, room: "test", persist: () => Service.persist(plan), exclusive: action => Service.exclusive(plan, action), async publish() {}, anchors() {}, changes() {}, }).find(tool => tool.name === "edit_plan"); if (!editPlan?.handler) throw new Error("edit_plan has no handler"); let args = { revision: plan.revision, operations: [{ op: "replace", index: 0, source: "The plan was changed.\n" }], }; let response = await editPlan.handler(args, { sessionId: "session", toolCallId: "call", toolName: "edit_plan", arguments: args, }); expect(JSON.parse(response as string)).toEqual({ ok: false, reason: "locked", document: { id: channel.id, url: "/documents/owner/repository/test-plan" }, }); expect(room.project(plan.document)).toBe("The plan is ready.\n"); }); test("anchor_plan keeps same-block decisions in original ask order", async () => { let { plan, server } = await opened( SOURCE.replace( ` Service.persist(plan), exclusive: action => Service.exclusive(plan, action), async publish() {}, anchors() {}, changes() {}, }).find(tool => tool.name === "anchor_plan"); if (!anchorPlan?.handler) throw new Error("anchor_plan has no handler"); let digest = room.digests(plan.document)[1]!; let args = { revision: plan.revision, anchors: [ { widget: SECOND_WIDGET, question: SECOND_QUESTION, blocks: [{ index: 1, digest }] }, { widget: WIDGET, question: QUESTION, blocks: [{ index: 1, digest }] }, ], }; await anchorPlan.handler(args, { sessionId: "session", toolCallId: "call", toolName: "anchor_plan", arguments: args, }); let source = room.project(plan.document); let prose = source.indexOf("The renderer caches tiles for 60 seconds."); let first = source.indexOf(` { let published: unknown[] = []; let { broadcasts, plan, server } = await opened("Related prose.\n"); let anchors = 0; let created = Promise.withResolvers(); let ask = fixtureTools({ plan, server, room: "test", persist: () => Service.persist(plan), exclusive: action => Service.exclusive(plan, action), publish: async mutation => { published.push(mutation); }, anchors: () => { anchors++; created.resolve(); }, changes() {}, }).find(tool => tool.name === "ask"); if (!ask?.handler) throw new Error("ask has no handler"); let digest = room.digests(plan.document)[0]!; let args = { revision: plan.revision, questions: [{ header: "Rollout", question: "How should we deploy?", multiple: false, options: [{ label: "Canary", description: "Limit exposure." }], blocks: [{ index: 0, digest }], }], }; let response = ask.handler(args, { sessionId: "session", toolCallId: "call", toolName: "ask", arguments: args, }); await created.promise; let source = room.project(plan.document); expect(source.indexOf("Related prose.")).toBeLessThan(source.indexOf(" frame.kind === "plan:update")).toBe(true); expect(anchors).toBe(1); let record = [...plan.records.values()][0]!; let claimed = Store.claimCancel(plan.questions, record.id, "test"); if (!claimed.ok) throw new Error("could not resolve question"); Store.commit(plan.questions, claimed.claim); let result = await response; expect(typeof result).toBe("string"); expect(JSON.parse(result as string).outcomes).toEqual([ { status: "cancelled", cancelled_by: "test" }, ]); }); test("a stale ask does not announce an anchor snapshot", async () => { let { plan, server } = await opened("Related prose.\n"); let anchors = 0; let ask = fixtureTools({ plan, server, room: "test", persist: () => Service.persist(plan), exclusive: action => Service.exclusive(plan, action), async publish() {}, anchors: () => anchors++, changes() {}, }).find(tool => tool.name === "ask"); if (!ask?.handler) throw new Error("ask has no handler"); let digest = room.digests(plan.document)[0]!; let args = { revision: plan.revision + 1, questions: [{ header: "Rollout", question: "How should we deploy?", multiple: false, options: [{ label: "Canary", description: "Limit exposure." }], blocks: [{ index: 0, digest }], }], }; await ask.handler(args, { sessionId: "session", toolCallId: "call", toolName: "ask", arguments: args, }); expect(plan.records.size).toBe(0); expect(anchors).toBe(0); }); test("ask refuses to create a questionnaire while implementation is active", async () => { let { plan, server } = await opened("Related prose.\n"); let anchors = 0; let ask = fixtureTools({ plan, server, room: "test", persist: () => Service.persist(plan), exclusive: action => Service.exclusive(plan, action), async publish() {}, anchors: () => anchors++, changes() {}, }).find(tool => tool.name === "ask"); if (!ask?.handler) throw new Error("ask is missing"); let digest = room.digests(plan.document)[0]!; let args = { revision: plan.revision, questions: [{ header: "Rollout", question: "How should we deploy?", multiple: false, options: [{ label: "Canary", description: "Limit exposure." }], blocks: [{ index: 0, digest }], }], }; plan.execution = { id: "run-1" } as never; let response = await ask.handler(args, { sessionId: "session", toolCallId: "call", toolName: "ask", arguments: args, }); expect(JSON.parse(response as string)).toEqual({ ok: false, reason: "locked" }); expect(plan.records.size).toBe(0); expect(anchors).toBe(0); }); test("planner graph edits draft a revision without changing plan prose", async () => { let { plan, server } = await opened("Prepare the implementation.\n"); // The graph tool is called from inside the planner turn that `chat:send` // started. That turn is busy by definition; readiness must not mistake it // for a competing request. plan.chat.busy = true; plan.chat.turn = { id: "turn", handle: "ana", started: 1, entryOffset: 0, responded: false }; let before = room.project(plan.document); let graph = fixtureTools({ plan, server, room: "test", persist: () => Service.persist(plan), exclusive: action => Service.exclusive(plan, action), async publish() {}, anchors() {}, changes() {}, }).find(tool => tool.name === "edit_implementation_graph"); if (!graph?.handler) throw new Error("edit_implementation_graph is missing"); let args = { plan_revision: plan.revision, graph_revision: 0, operations: [{ op: "add", task: { id: "graph-tools", title: "Give the planner constrained graph tools", context: "The shared plan is ready for implementation preparation.", goal: "Create a draft implementation graph beside the plan.", acceptance: [ "A draft graph is stored against the plan revision.", "The planner does not change plan.mdx.", ], dependsOn: [], }, }], }; let response = await graph.handler(args, { sessionId: "session", toolCallId: "call", toolName: "edit_implementation_graph", arguments: args, }); if (typeof response !== "string") throw new Error("graph tool returned no text"); expect(JSON.parse(response)).toMatchObject({ ok: true, graph: { versions: [{ state: "draft", planRevision: plan.revision }] }, }); expect(plan.graph?.versions[0]?.definition.tasks.map(task => task.id)).toEqual(["graph-tools"]); expect(room.project(plan.document)).toBe(before); let stale = await graph.handler({ ...args, graph_revision: 0 }, { sessionId: "session", toolCallId: "later", toolName: "edit_implementation_graph", arguments: args, }); expect(JSON.parse(stale as string)).toEqual({ ok: false, reason: "stale-graph" }); expect(room.project(plan.document)).toBe(before); }); test("chat-started tools retain only the current member request provenance", async () => { let { plan, server, storage, channel } = await opened("Prepare the implementation.\n"); let now = new Date(); let tools = fixtureTools({ plan, server, room: "test", persist: () => Service.persist(plan), exclusive: action => Service.exclusive(plan, action), async publish() {}, anchors() {}, changes() {}, }); let graph = tools.find(tool => tool.name === "edit_implementation_graph"); if (!graph?.handler) throw new Error("edit_implementation_graph is missing"); let args = { plan_revision: plan.revision, graph_revision: 0, operations: [{ op: "add", task: { id: "chat-graph", title: "Draft from the chat turn", context: "The planner turn began from the room conversation.", goal: "Create a graph while the planner is busy.", acceptance: ["The graph is drafted.", "The chat turn can complete."], dependsOn: [], }, }], }; let response: string | undefined; let firstStarted = Promise.withResolvers(); let continueFirst = Promise.withResolvers(); let sent = Promise.withResolvers(); let activeRequests: Array = []; let researchRequests: Array>[0]> = []; let researchResponses: string[] = []; let destroyed = 0; let researchTool: ReturnType[number] | undefined; let key = new Uint8Array(32).fill(7); let sessions = new Sessions(storage, true, () => now); let claimant = await sessions.issue("U_test", { accessToken: "gho_test", accessExpiresIn: 28_800, refreshToken: "ghr_test", refreshExpiresIn: 15_897_600, }); let queuedClaimant = await sessions.issue("U_test", { accessToken: "gho_queued", accessExpiresIn: 28_800, refreshToken: "ghr_queued", refreshExpiresIn: 15_897_600, }); let repository = { id: "R_test", owner: "owner", name: "repository", defaultBranch: "main" }; let config = { origin: "https://test", appSlug: "chopin-test", clientId: "id", clientSecret: "secret", encryptionKey: key, }; let github = { async repositoryAccess() { return { ...repository, fullName: "owner/repository", private: true, url: "", permissions: { pull: true, push: true, admin: false }, }; }, } as never; let auth: HostedAuth = { config, storage, github, admission: new Admission(config, github, () => now.getTime()), sessions, clock: () => now, }; let ownership = await Chat.resolveOwner(auth, repository, channel.id, claimant.id); let activeOwner = () => Promise.resolve({ channelId: channel.id, token: "gho_test", repository, ownerSessionId: claimant.id, ownerGeneration: ownership.ownership.generation, credentialRevision: ownership.owner.access.revision, expiresAt: ownership.owner.access.expiresAt, signal: new AbortController().signal, currentToken: () => "gho_test", revalidate: async () => true, release() {}, }); let context: Chat.Room = { chat: plan.chat, plan, server, room: channel.id, config: { agent: true } as Config, auth, claimantSessionId: claimant.id, repository, persist: () => Service.persist(plan), activeOwner, openPlannerSession: async () => ({ ok: true, value: { stream: async (_prompt, _signal) => ({ fullStream: (async function*() { let active = plan.chat.activeRequest; activeRequests.push(active ? { ...active } : undefined); let turn = activeRequests.length; if (turn === 1) { firstStarted.resolve(); await continueFirst.promise; let result = await graph.handler!(args, { toolCallId: "call" }); if (typeof result !== "string") throw new Error("graph tool returned no text"); response = result; } else { if (!researchTool?.handler) throw new Error("research tool is missing"); let question = "Which public release evidence supports adopting version 3?"; let attempts = turn === 2 ? 2 : 1; for (let attempt = 0; attempt < attempts; attempt++) { let result = await researchTool.handler({ question }, { toolCallId: `research-${turn}-${attempt}`, }); if (typeof result !== "string") throw new Error("research tool returned no text"); researchResponses.push(result); } } yield { type: "finish" }; if (turn === 3) sent.resolve(); })(), }) as never, async destroy() { destroyed++; }, }, }), createResearch: async request => { researchRequests.push(request); return { workspaceId: "workspace-1", state: "pending", stage: "queued", }; }, }; let { id: _id, ...documentContext } = Chat.documentRoom(context); researchTool = fixtureTools({ ...documentContext, room: context.room }) .find(tool => tool.name === "create_research_workspace"); await Chat.send( context, { data: { handle: "ana", principalId: "U_test" }, send() {} } as unknown as Socket, { kind: "chat:send", rid: "request", requestId: crypto.randomUUID(), text: "prepare implementation", to: "planner", ts: 0, }, ); let running = plan.chat.running; await firstStarted.promise; let queuedContext = { ...context, claimantSessionId: queuedClaimant.id }; await Chat.send( queuedContext, { data: { handle: "bob", principalId: "U_bob" }, send() {} } as unknown as Socket, { kind: "chat:send", rid: "queued", requestId: crypto.randomUUID(), text: "@chopin start research on version 3 adoption", to: "planner", ts: 0, }, ); let queuedEntryId = plan.chat.waiting[0]!.id; await Chat.instruct( context, "ana", "Act on the accepted comment.", "@ana accepted a comment.", ); let error = spyOn(console, "error").mockImplementation(() => {}); continueFirst.resolve(); await sent.promise; await running; if (!researchTool?.handler) throw new Error("research tool is missing"); let stale = await researchTool.handler({ question: "Search again" }, { sessionId: "session", toolCallId: "research-stale", toolName: "create_research_workspace", arguments: { question: "Search again" }, }); if (typeof stale !== "string") throw new Error("research tool returned no text"); researchResponses.push(stale); error.mockRestore(); expect(JSON.parse(response ?? "")).toMatchObject({ ok: true, graph: { versions: [{ state: "draft", planRevision: 0 }] }, }); expect(plan.graph?.versions[0]?.definition.tasks.map(task => task.id)).toEqual(["chat-graph"]); expect(activeRequests).toHaveLength(3); expect(activeRequests[0]).toMatchObject({ entryId: plan.chat.entries.find(entry => entry.text === "prepare implementation")?.id, userId: "U_test", handle: "ana", text: "prepare implementation", claimantSessionId: claimant.id, lifecycle: 0, }); expect(activeRequests[1]).toMatchObject({ entryId: queuedEntryId, userId: "U_bob", handle: "bob", text: "start research on version 3 adoption", claimantSessionId: queuedClaimant.id, lifecycle: 0, }); expect(activeRequests[0]?.turnId).not.toBe(activeRequests[1]?.turnId); expect(activeRequests[2]).toBeUndefined(); expect(researchRequests).toEqual([ { entryId: queuedEntryId, userId: "U_bob", handle: "bob", text: "start research on version 3 adoption", question: "start research on version 3 adoption", }, { entryId: queuedEntryId, userId: "U_bob", handle: "bob", text: "start research on version 3 adoption", question: "start research on version 3 adoption", }, ]); expect(researchResponses.slice(0, 2).map(value => JSON.parse(value))).toEqual([ expect.objectContaining({ workspaceId: "workspace-1", state: "pending", stage: "queued" }), expect.objectContaining({ workspaceId: "workspace-1", state: "pending", stage: "queued" }), ]); expect(researchResponses[2]).toContain( "Error: research workspaces require the explicit member message", ); expect(researchResponses[3]).toContain( "Error: research workspaces require the explicit member message", ); expect(plan.chat.activeRequest).toBeUndefined(); let referenceId = ulid(); let referenceReads: unknown[] = []; plan.chat.referenceCache.set(referenceId, { id: referenceId } as never); context.references = { read: async (input: unknown) => { referenceReads.push(input); return { source: "reference" }; }, } as never; let reference = await documentTools.read_reference.execute!({ id: referenceId }, { context: { room: Chat.documentRoom(context), repository: { id: "R_call" } }, toolCallId: "reference", messages: [], }); expect(reference).toContain("reference"); expect(referenceReads).toMatchObject([{ channelId: channel.id, repositoryId: "R_call", reference: { id: referenceId }, }]); }); test("planner graph edits name readiness blockers before changing a graph", async () => { let { plan, server } = await opened("Prepare the implementation.\n"); plan.records.set("open", { id: "open", status: "open", definition: { questions: [{ id: "question", header: "Readiness", question: "May implementation begin?", multiple: false, options: [{ id: "yes", label: "Yes", description: "" }], }], }, } as never); let graph = fixtureTools({ plan, server, room: "test", persist: () => Service.persist(plan), exclusive: action => Service.exclusive(plan, action), async publish() {}, anchors() {}, changes() {}, }).find(tool => tool.name === "edit_implementation_graph"); if (!graph?.handler) throw new Error("edit_implementation_graph is missing"); let args = { plan_revision: plan.revision, graph_revision: 0, operations: [{ op: "add", task: { id: "blocked", title: "Blocked graph", context: "The plan still has an open decision.", goal: "Demonstrate that preparation is refused.", acceptance: ["The tool names the blocker.", "No graph is created."], dependsOn: [], }, }], }; let response = await graph.handler(args, { sessionId: "session", toolCallId: "call", toolName: "edit_implementation_graph", arguments: args, }); expect(JSON.parse(response as string)).toEqual({ ok: false, reason: "not-ready", blockers: ["unanswered questionnaires"], }); expect(plan.graph).toBeUndefined(); });