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