Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/server/src/agent/card-tool-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { jobToolContext } from "./job-tool-context";
import type { Context as JobContext } from "./job-tool-context";
import type { DocumentRoom } from "./tools";

export type Context = JobContext & { currentMemberRequest?: () => { text: string } | undefined };
export type CardToolRoom = DocumentRoom & Pick<Context, "currentMemberRequest">;

export function cardToolContext(room: CardToolRoom): Context {
return { ...jobToolContext(room), currentMemberRequest: room.currentMemberRequest };
}
53 changes: 53 additions & 0 deletions apps/server/src/agent/decision-prose-1.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { expect, test } from "bun:test";
import * as room from "../plan/room";
import * as Service from "../plan/service";
import * as Questions from "../questions/service";
import { createDecisionProseFixture } from "./decision-prose.test-fixtures";

let contexts: ReturnType<typeof createDecisionProseFixture>["contexts"];
let fixture = createDecisionProseFixture(() => contexts, value => {
contexts = value;
});
contexts = fixture.contexts;
let { CARD, WIDGET, opened, job, proseTool, input } = fixture;

test("write_decision_prose commits one staged insert and publishes anchors and metadata afterward", async () => {
let context = await opened();
job(context);
let tool = proseTool(context);
let before = room.project(context.plan.document);
let response = await tool.handler(input(context), {} as never);
expect(JSON.parse(String(response))).toEqual({
ok: true,
title: "Which authentication approach?",
mode: "insert",
revision: 1,
});
expect(room.project(context.plan.document)).toBe(
before.replace("Context.\n\n", "Context.\n\nWe chose GitHub Apps.\n\n"),
);
expect(context.plan.document.seq).toBe(1);
expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(1);
expect(context.broadcasts.filter(frame => frame.kind === "plan:anchors")).toHaveLength(1);
expect(context.broadcasts.filter(frame => frame.kind === "question:meta")).toHaveLength(1);
expect(tool.anchors()).toBe(1);
expect(tool.changes()).toBe(1);
expect(Questions.prose(context.plan)[0]).toMatchObject({ widget: CARD, orphaned: false });
expect(room.matchesAnchor(context.plan.document, context.plan.records.get(CARD)!.prose![0]!, 1))
.toBe(true);
let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!;
expect((await Service.readStored(loaded)).source).toBe(room.project(context.plan.document));
});

test("write_decision_prose uses one durable delta for a first-card two-step insertion", async () => {
let context = await opened(WIDGET);
job(context);
let before = room.project(context.plan.document);
let result = await proseTool(context).handler(input(context), {} as never);
expect(JSON.parse(String(result)).ok).toBe(true);
expect(room.project(context.plan.document)).toBe(`We chose GitHub Apps.\n\n${before}`);
expect(context.plan.document.seq).toBe(1);
expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(1);
expect(room.matchesAnchor(context.plan.document, context.plan.records.get(CARD)!.prose![0]!, 0))
.toBe(true);
});
98 changes: 98 additions & 0 deletions apps/server/src/agent/decision-prose-2.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { expect, test } from "bun:test";
import { $createParagraphNode, $getRoot, $isParagraphNode } from "lexical";
import * as Y from "yjs";
import * as room from "../plan/room";
import * as Service from "../plan/service";
import { createDecisionProseFixture } from "./decision-prose.test-fixtures";

let contexts: ReturnType<typeof createDecisionProseFixture>["contexts"];
let fixture = createDecisionProseFixture(() => contexts, value => {
contexts = value;
});
contexts = fixture.contexts;
let { CARD, WIDGET, OTHER_WIDGET, opened, job, proseTool, input } = fixture;

test("write_decision_prose addresses a first card past live leading and inter-card carets", async () => {
let context = await opened(`${WIDGET}\n${OTHER_WIDGET}`);
context.plan.document.editor.update(() => {
let children = $getRoot().getChildren();
children[0]!.insertBefore($createParagraphNode());
children[1]!.insertBefore($createParagraphNode());
}, { discrete: true });
await room.settle();
let caretKeys: string[] = [];
context.plan.document.editor.getEditorState().read(() => {
caretKeys = $getRoot().getChildren()
.filter(node => $isParagraphNode(node) && node.getChildrenSize() === 0)
.map(node => node.getKey());
});
expect(caretKeys).toHaveLength(2);
let relatives = caretKeys.map(key => {
let type = context.plan.document.binding.collabNodeMap.get(key)?.getSharedType();
if (!type) throw new Error("caret has no shared identity");
return Y.createRelativePositionFromTypeIndex(type, 0, -1);
});
let peer = await room.restore(
context.plan.document.epoch,
Y.encodeStateAsUpdate(context.plan.document.doc),
room.project(context.plan.document),
[],
);
expect(room.digests(context.plan.document)).toHaveLength(2);
expect(room.questionnaireIndex(context.plan.document, CARD)).toBe(0);
job(context);
let response = await proseTool(context).handler(input(context), {} as never);
expect(JSON.parse(String(response)).ok).toBe(true);
expect(room.project(context.plan.document)).toStartWith(
"We chose GitHub Apps.\n\n<Questionnaire",
);
expect(room.questionnaireIndex(context.plan.document, CARD)).toBe(1);
expect(room.matchesAnchor(context.plan.document, context.plan.records.get(CARD)!.prose![0]!, 0))
.toBe(true);
context.plan.document.editor.getEditorState().read(() => {
let current = new Set($getRoot().getChildren().map(node => node.getKey()));
for (let key of caretKeys) expect(current.has(key)).toBe(true);
});
let update = context.broadcasts.find(frame => frame.kind === "plan:update")?.update;
if (typeof update !== "string") throw new Error("missing persisted document delta");
Y.applyUpdate(peer.doc, Buffer.from(update, "base64"), "remote");
await room.settle();
let absolutes = relatives.map(relative =>
Y.createAbsolutePositionFromRelativePosition(relative, peer.doc, false)
);
for (let absolute of absolutes) expect(absolute).not.toBeNull();
peer.editor.getEditorState().read(() => {
let empty = $getRoot().getChildren().filter(node =>
$isParagraphNode(node) && node.getChildrenSize() === 0
);
expect(empty).toHaveLength(2);
for (let absolute of absolutes) {
expect(
empty.some(node =>
peer.binding.collabNodeMap.get(node.getKey())?.getSharedType() === absolute?.type
),
).toBe(true);
}
});
peer.doc.destroy();
let expected = room.project(context.plan.document);
await Service.close(context.plan);
contexts = contexts.filter(item => item !== context);
let reopened = await Service.open(context.channel.id, context.backend, context.server);
contexts.push({ ...context, plan: reopened });
expect(room.project(reopened.document)).toBe(expected);
expect(room.matchesAnchor(reopened.document, reopened.records.get(CARD)!.prose![0]!, 0))
.toBe(true);
});

test("write_decision_prose replaces the anchored duplicate, leaving its twin alone", async () => {
let context = await opened(`Same.\n\nSame.\n\n${WIDGET}`);
context.plan.records.get(CARD)!.prose = [
room.anchorAt(context.plan.document, 1, room.digests(context.plan.document)[1]!),
];
job(context);
let result = await proseTool(context).handler(input(context, "New prose."), {} as never);
expect(JSON.parse(String(result)).mode).toBe("replace");
expect(room.project(context.plan.document)).toStartWith("Same.\n\nNew prose.\n\n");
expect(context.plan.records.get(CARD)!.prose).toHaveLength(1);
});
48 changes: 48 additions & 0 deletions apps/server/src/agent/decision-prose-3.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { expect, spyOn, test } from "bun:test";
import { $getRoot } from "lexical";
import * as room from "../plan/room";
import { createDecisionProseFixture } from "./decision-prose.test-fixtures";

let contexts: ReturnType<typeof createDecisionProseFixture>["contexts"];
let fixture = createDecisionProseFixture(() => contexts, value => {
contexts = value;
});
contexts = fixture.contexts;
let { CARD, WIDGET, opened, job, proseTool, input } = fixture;

test("write_decision_prose replaces uniquely moved prose at its new position", async () => {
let context = await opened(`Other context.\n\nOld decision prose.\n\n${WIDGET}`);
context.plan.records.get(CARD)!.prose = [
room.anchorAt(context.plan.document, 1, room.digests(context.plan.document)[1]!),
];
context.plan.document.editor.update(() => {
let children = $getRoot().getChildren();
children[0]!.insertBefore(children[1]!);
}, { discrete: true });
await room.settle();
job(context);
let result = await proseTool(context).handler(input(context, "New prose."), {} as never);
expect(JSON.parse(String(result)).mode).toBe("replace");
expect(room.project(context.plan.document)).toStartWith("New prose.\n\nOther context.\n\n");
});

test("write_decision_prose rejects missing or stale job generation and invalid text", async () => {
let context = await opened();
let tool = proseTool(context);
let before = room.project(context.plan.document);
let errors = spyOn(console, "error").mockImplementation(() => {});
try {
expect(String(await tool.handler(input(context), {} as never)))
.toContain("only available to a background Planner job");
job(context, 2);
expect(String(await tool.handler(input(context), {} as never))).toContain("generation");
job(context);
for (let text of ["# Heading", "One.\n\nTwo.", "Text <Badge />"]) {
expect(String(await tool.handler(input(context, text), {} as never))).toStartWith("Error:");
}
expect(room.project(context.plan.document)).toBe(before);
expect(context.plan.chat.jobOutput).toBeUndefined();
} finally {
errors.mockRestore();
}
});
70 changes: 70 additions & 0 deletions apps/server/src/agent/decision-prose-4.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { expect, spyOn, test } from "bun:test";
import * as room from "../plan/room";
import type { ConversationPlan } from "@chopin/protocol";
import { createDecisionProseFixture } from "./decision-prose.test-fixtures";

let contexts: ReturnType<typeof createDecisionProseFixture>["contexts"];
let fixture = createDecisionProseFixture(() => contexts, value => {
contexts = value;
});
contexts = fixture.contexts;
let { CARD, OPTION, opened, job, proseTool, input } = fixture;

test("write_decision_prose fences a replaced job after an awaited staging read", async () => {
let context = await opened();
job(context);
let before = room.project(context.plan.document);
let original = room.restore;
let entered = Promise.withResolvers<void>();
let release = Promise.withResolvers<void>();
let held = spyOn(room, "restore").mockImplementation(async (...args) => {
entered.resolve();
await release.promise;
return original(...args);
});
let errors = spyOn(console, "error").mockImplementation(() => {});
try {
let action = proseTool(context).handler(input(context), {} as never);
await entered.promise;
context.plan.chat.job = { ...context.plan.chat.job!, id: "newer" } as ConversationPlan.Job;
release.resolve();
expect(String(await action)).toContain("background Planner job changed");
expect(room.project(context.plan.document)).toBe(before);
expect(context.plan.document.seq).toBe(0);
expect(context.plan.chat.jobOutput).toBeUndefined();
} finally {
errors.mockRestore();
held.mockRestore();
}
});

test("write_decision_prose fences a newer saved decision after an awaited staging read", async () => {
let context = await opened();
job(context);
let before = room.project(context.plan.document);
let original = room.restore;
let entered = Promise.withResolvers<void>();
let release = Promise.withResolvers<void>();
let held = spyOn(room, "restore").mockImplementation(async (...args) => {
entered.resolve();
await release.promise;
return original(...args);
});
let errors = spyOn(console, "error").mockImplementation(() => {});
try {
let action = proseTool(context).handler(input(context), {} as never);
await entered.promise;
let record = context.plan.records.get(CARD)!;
context.plan.records.set(CARD, {
...record,
history: [{ choices: [OPTION], owner: "mina", at: 1_758_645_000 }],
});
release.resolve();
expect(String(await action)).toContain("background Planner job changed");
expect(room.project(context.plan.document)).toBe(before);
expect(context.plan.chat.jobOutput).toBeUndefined();
} finally {
errors.mockRestore();
held.mockRestore();
}
});
56 changes: 56 additions & 0 deletions apps/server/src/agent/decision-prose-5.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { expect, spyOn, test } from "bun:test";
import * as room from "../plan/room";
import * as Service from "../plan/service";
import { createDecisionProseFixture } from "./decision-prose.test-fixtures";

let contexts: ReturnType<typeof createDecisionProseFixture>["contexts"];
let fixture = createDecisionProseFixture(() => contexts, value => {
contexts = value;
});
contexts = fixture.contexts;
let { CARD, opened, job, proseTool, input } = fixture;

test("failed prose persistence leaves live source, record, and publication untouched", async () => {
let context = await opened();
job(context);
let before = room.project(context.plan.document);
let record = context.plan.records.get(CARD);
let records = context.plan.records;
let original = context.storage.collaboration.commit;
context.storage.collaboration.commit = async () => {
throw new Error("storage unavailable");
};
context.backend.fatal = () => {};
let errors = spyOn(console, "error").mockImplementation(() => {});
try {
expect(String(await proseTool(context).handler(input(context), {} as never)))
.toBe("Error: storage unavailable");
} finally {
errors.mockRestore();
context.storage.collaboration.commit = original;
}
expect(room.project(context.plan.document)).toBe(before);
expect(context.plan.records).toBe(records);
expect(context.plan.records.get(CARD)).toBe(record);
expect(context.plan.document.seq).toBe(0);
expect(context.plan.chat.jobOutput).toBeUndefined();
expect(context.broadcasts.filter(frame => frame.kind === "plan:update")).toHaveLength(0);
expect(context.broadcasts.filter(frame => frame.kind === "plan:anchors")).toHaveLength(0);
});

test("a post-commit anchor relay error does not turn the durable write into a failed tool call", async () => {
let context = await opened();
job(context);
context.breakRelay("plan:anchors");
let errors = spyOn(console, "error").mockImplementation(() => {});
try {
let result = await proseTool(context).handler(input(context), {} as never);
expect(JSON.parse(String(result)).ok).toBe(true);
expect(context.plan.chat.jobOutput).toBeDefined();
expect(room.project(context.plan.document)).toContain("We chose GitHub Apps.");
let loaded = (await context.storage.collaboration.load(context.channel.id, context.now))!;
expect((await Service.readStored(loaded)).source).toBe(room.project(context.plan.document));
} finally {
errors.mockRestore();
}
});
16 changes: 16 additions & 0 deletions apps/server/src/agent/decision-prose-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { validateProse } from "../questions/write-prose";

export function input(raw: unknown): { revision: number; id: string; text: string } {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error("write_decision_prose arguments must be an object");
}
let value = raw as Record<string, unknown>;
if (
Object.keys(value).sort().join(",") !== "id,revision,text"
|| !Number.isSafeInteger(value.revision) || (value.revision as number) < 0
|| typeof value.id !== "string" || !value.id || value.id.length > 200
) throw new Error("write_decision_prose needs revision, id, and text");
let valid = validateProse(value.text);
if (!valid.ok) throw new Error(valid.message);
return { revision: value.revision as number, id: value.id, text: valid.text };
}
Loading
Loading