440 lines
17 KiB
TypeScript
440 lines
17 KiB
TypeScript
import { afterEach, expect, test } from "bun:test";
|
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import type { Plugin } from "@opencode/plugin";
|
|
import optchat from "../src/optchat.js";
|
|
|
|
const directories: string[] = [];
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
|
|
interface Part {
|
|
type: string;
|
|
text?: string;
|
|
cache?: { type: string; ttlSeconds?: number };
|
|
name?: string;
|
|
input?: object;
|
|
id?: string;
|
|
providerExecuted?: boolean;
|
|
result?: { type: string; value: string };
|
|
}
|
|
interface Message {
|
|
id?: string;
|
|
role: string;
|
|
content: Part[];
|
|
}
|
|
// Stands in for the host's Message class, which the plugin reuses through `constructor`.
|
|
class Entry implements Message {
|
|
id?: string;
|
|
role = "";
|
|
content: Part[] = [];
|
|
constructor(input: Message) {
|
|
Object.assign(this, input);
|
|
}
|
|
}
|
|
interface Event {
|
|
sessionID: string;
|
|
agent: string;
|
|
system: Part[];
|
|
messages: Entry[];
|
|
tools: Record<string, string>;
|
|
result?: { summary: string };
|
|
}
|
|
interface ZoomInput {
|
|
id: number;
|
|
n?: number;
|
|
}
|
|
interface Tool {
|
|
name: string;
|
|
options: { codemode: boolean };
|
|
execute: (
|
|
input: ZoomInput,
|
|
context: { sessionID: string; agent: string },
|
|
) => Promise<{ content: string }>;
|
|
}
|
|
|
|
interface Gate {
|
|
promise?: Promise<void>;
|
|
}
|
|
|
|
async function host() {
|
|
const directory = await mkdtemp(join(tmpdir(), "optchat-plugin-"));
|
|
directories.push(directory);
|
|
const hooks = new Map<string, (event: Event) => Promise<void> | void>();
|
|
const storage = new Map<string, string | boolean>();
|
|
const notices: string[] = [];
|
|
const tools = new Map<string, Tool>();
|
|
const generated: { system: Part[]; messages: Entry[] }[] = [];
|
|
const replies: string[] = [];
|
|
// While set, compactions wait here before the host runs the generate hook.
|
|
const gate: Gate = {};
|
|
let command:
|
|
| ((input: { sessionID: string; prompt: { text: string } }) => Promise<void>)
|
|
| undefined;
|
|
const context = {
|
|
options: { dataDir: directory, compactionModel: "acme/cheap#xhigh" },
|
|
storage: {
|
|
get: async (key: string) => storage.get(key),
|
|
set: async (key: string, value: string | boolean) => void storage.set(key, value),
|
|
remove: async (key: string) => void storage.delete(key),
|
|
},
|
|
tool: {
|
|
transform: async (callback: (editor: { add: (tool: Tool) => void }) => void) =>
|
|
callback({ add: (tool) => tools.set(tool.name, tool) }),
|
|
},
|
|
command: {
|
|
transform: async (
|
|
callback: (editor: {
|
|
add: (definition: { execute: typeof command }) => void;
|
|
}) => void,
|
|
) => callback({ add: (definition) => (command = definition.execute) }),
|
|
},
|
|
session: {
|
|
hook: async (name: string, callback: (event: Event) => Promise<void> | void) => {
|
|
hooks.set(name, callback);
|
|
return { dispose: async () => {} };
|
|
},
|
|
get: async ({ sessionID }: { sessionID: string }) =>
|
|
sessionID === "ses_child"
|
|
? { id: sessionID, parentID: "ses_root" }
|
|
: sessionID === "ses_worker"
|
|
? { id: sessionID, parentID: "ses_root" }
|
|
: { id: sessionID, agent: "build" },
|
|
create: async (input: { parentID: string; model: object }) => {
|
|
expect(input.parentID).toBe("ses_root");
|
|
expect(input.model).toEqual({ providerID: "acme", id: "cheap", variant: "xhigh" });
|
|
return { id: "ses_worker" };
|
|
},
|
|
generate: async ({ sessionID, prompt }: { sessionID: string; prompt: string }) => {
|
|
await gate.promise;
|
|
const event: Event = {
|
|
sessionID,
|
|
agent: "build",
|
|
system: [{ type: "text", text: "host system" }],
|
|
messages: [
|
|
new Entry({ role: "user", content: [{ type: "text", text: prompt }] }),
|
|
],
|
|
tools: {},
|
|
};
|
|
await hooks.get("generate")!(event);
|
|
generated.push(event);
|
|
return { text: replies.shift() ?? "short summary" };
|
|
},
|
|
synthetic: async ({ text }: { text: string }) => void notices.push(text),
|
|
},
|
|
};
|
|
// SAFETY: optchat uses only these host services; a missing one fails the test.
|
|
const setup = optchat.setup as (
|
|
context: Pick<Plugin.Context, "options">,
|
|
) => ReturnType<typeof optchat.setup>;
|
|
const cleanup = await setup(context);
|
|
const turn = async (sessionID: string, messages: Message[]) => {
|
|
const event: Event = {
|
|
sessionID,
|
|
agent: "build",
|
|
system: [
|
|
{ type: "text", text: "host one", cache: { type: "ephemeral" } },
|
|
{ type: "text", text: "host two" },
|
|
],
|
|
messages: messages.map((message) => new Entry(message)),
|
|
tools: { read: "", zoom: "", date: "" },
|
|
};
|
|
await hooks.get("context")!(event);
|
|
return event;
|
|
};
|
|
return {
|
|
directory,
|
|
hooks,
|
|
notices,
|
|
tools,
|
|
generated,
|
|
replies,
|
|
gate,
|
|
turn,
|
|
command: () => command!,
|
|
cleanup,
|
|
};
|
|
}
|
|
|
|
const hints = (event: Event) =>
|
|
[...event.system, ...event.messages.flatMap((message) => message.content)].filter(
|
|
(part) => part.cache,
|
|
).length;
|
|
const settle = () => Bun.sleep(20);
|
|
|
|
test("optchat is off by default, refuses child sessions, and hides its tools when off", async () => {
|
|
const h = await host();
|
|
expect(h.tools.get("zoom")!.options.codemode).toBe(false);
|
|
expect(h.tools.get("date")!.options.codemode).toBe(false);
|
|
const off = await h.turn("ses_root", [
|
|
{ id: "m1", role: "user", content: [{ type: "text", text: "hi" }] },
|
|
]);
|
|
expect(Object.keys(off.tools)).toEqual(["read"]);
|
|
expect(off.messages).toHaveLength(1);
|
|
await h.command()({ sessionID: "ses_child", prompt: { text: "on" } });
|
|
expect(h.notices.at(-1)).toContain("only to root sessions");
|
|
await h.command()({ sessionID: "ses_root", prompt: { text: "status" } });
|
|
expect(h.notices.at(-1)).toBe("[optchat] off.");
|
|
await expect(
|
|
h.tools.get("zoom")!.execute({ id: 0, n: 1 }, { sessionID: "ses_root", agent: "build" }),
|
|
).rejects.toThrow("optchat is off");
|
|
if (h.cleanup) await h.cleanup();
|
|
});
|
|
|
|
test("turns see the view and the new message; compactions retry Too long and build the tree", async () => {
|
|
const h = await host();
|
|
await h.command()({ sessionID: "ses_root", prompt: { text: "/optchat on" } });
|
|
expect(h.notices.at(-1)).toStartWith("[optchat] on. 0 messages");
|
|
const long = "please remember: " + "detail ".repeat(100);
|
|
h.replies.push("x".repeat(700), "y".repeat(600), "z".repeat(400));
|
|
const first = await h.turn("ses_root", [
|
|
{ id: "m1", role: "user", content: [{ type: "text", text: long }] },
|
|
]);
|
|
expect(first.system[0]!.text).toStartWith("You are build, an AI agent");
|
|
expect(first.system.map((part) => part.text)).toEqual([
|
|
first.system[0]!.text,
|
|
"host one",
|
|
"host two",
|
|
]);
|
|
expect(first.messages[0]!.content.map((part) => part.text)).toEqual(["<chat>\n</chat>"]);
|
|
expect(first.messages[1]!.content[0]!.text).toBe(long);
|
|
// System start and end, and the request's end; no whole view block yet.
|
|
expect(hints(first)).toBe(3);
|
|
expect(first.messages[1]!.content[0]!.cache).toEqual({ type: "ephemeral", ttlSeconds: 300 });
|
|
await settle();
|
|
|
|
// The compaction ran in the worker with the same system prompt, retried twice, and kept the shortest line.
|
|
expect(h.generated).toHaveLength(3);
|
|
const task = h.generated[0]!.messages[1]!.content[0]!.text!;
|
|
expect(task).toStartWith("Compaction: compress message 0 into one line of at most 512 bytes");
|
|
expect(task).toContain(`<input>\nuser: ${long}\n</input>`);
|
|
expect(h.generated[0]!.system[0]!.text).toBe(first.system[0]!.text);
|
|
const retry = h.generated[2]!.messages.map((message) => message.role);
|
|
expect(retry).toEqual(["user", "user", "assistant", "user", "assistant", "user"]);
|
|
expect(h.generated[2]!.messages.at(-1)!.content[0]!.text).toStartWith(
|
|
"Too long: your line is 600 bytes",
|
|
);
|
|
|
|
const second = await h.turn("ses_root", [
|
|
{ id: "m1", role: "user", content: [{ type: "text", text: long }] },
|
|
{
|
|
id: "m2",
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "reasoning", text: "never logged" },
|
|
{ type: "tool-call", id: "c1", name: "read", input: { path: "a" } },
|
|
],
|
|
},
|
|
{
|
|
id: "m3",
|
|
role: "tool",
|
|
content: [
|
|
{
|
|
type: "tool-result",
|
|
id: "c1",
|
|
name: "read",
|
|
result: { type: "text", value: "file a" },
|
|
},
|
|
],
|
|
},
|
|
{ id: "m4", role: "assistant", content: [{ type: "text", text: "Read a." }] },
|
|
{ id: "m5", role: "user", content: [{ type: "text", text: "and now?" }] },
|
|
]);
|
|
// Messages before the new one are summarized before the turn; short ones are their own lines.
|
|
expect(second.messages.map((message) => message.role)).toEqual(["user", "user"]);
|
|
expect(second.messages[0]!.content.map((part) => part.text)).toEqual([
|
|
`<chat>\n0+1|${"z".repeat(400)}\n1+1|tool: read {"path":"a"}\n2+1|echo: read: file a\n3+1|agent: Read a.\n`,
|
|
"</chat>",
|
|
]);
|
|
// Last whole block, system start and end, and the end.
|
|
expect(hints(second)).toBe(4);
|
|
await settle();
|
|
|
|
const zoom = h.tools.get("zoom")!;
|
|
const at = { sessionID: "ses_root", agent: "build" };
|
|
expect((await zoom.execute({ id: 0, n: 1 }, at)).content).toBe(`user: ${long}`);
|
|
expect((await zoom.execute({ id: 0, n: 4 }, at)).content).toBe(
|
|
`0+2|${"z".repeat(400)} tool: read {"path":"a"}\n2+2|echo: read: file a agent: Read a.`,
|
|
);
|
|
await expect(zoom.execute({ id: 1, n: 2 }, at)).rejects.toThrow("No line 1+2");
|
|
expect((await h.tools.get("date")!.execute({ id: 4 }, at)).content).toMatch(
|
|
/^\d{4}-\d{2}-\d{2}T/,
|
|
);
|
|
|
|
const compaction: Event = {
|
|
sessionID: "ses_root",
|
|
agent: "build",
|
|
system: [],
|
|
messages: [],
|
|
tools: {},
|
|
};
|
|
await h.hooks.get("compaction")!(compaction);
|
|
expect(compaction.result!.summary).toStartWith("[optchat]");
|
|
|
|
if (h.cleanup) await h.cleanup();
|
|
const view = JSON.parse(await readFile(join(h.directory, "ses_root", "view.json"), "utf8"));
|
|
expect(view.lines).toHaveLength(5);
|
|
await expect(readFile(join(h.directory, "ses_root", "lock"))).rejects.toThrow();
|
|
});
|
|
|
|
async function logged(directory: string) {
|
|
const files = await Array.fromAsync(new Bun.Glob("ses_root/main/*.jsonl").scan(directory));
|
|
const lines: string[] = [];
|
|
for (const file of files)
|
|
lines.push(...(await readFile(join(directory, file), "utf8")).trim().split("\n"));
|
|
return lines.map((line) => {
|
|
const message = JSON.parse(line);
|
|
return `${message.kind}: ${message.text}`;
|
|
});
|
|
}
|
|
|
|
test("/optchat off stops queued and running compactions without writing nodes", async () => {
|
|
// Regression: the generate hook found no chat after off, and the reply was saved as a node.
|
|
const h = await host();
|
|
await h.command()({ sessionID: "ses_root", prompt: { text: "on" } });
|
|
let release = () => {};
|
|
h.gate.promise = new Promise((resolve) => (release = resolve));
|
|
await h.turn("ses_root", [
|
|
{
|
|
id: "m1",
|
|
role: "user",
|
|
content: [{ type: "text", text: "remember " + "word ".repeat(200) }],
|
|
},
|
|
]);
|
|
await settle();
|
|
await h.command()({ sessionID: "ses_root", prompt: { text: "off" } });
|
|
release();
|
|
await settle();
|
|
// The in-flight request still got its own chat's view and task.
|
|
expect(h.generated).toHaveLength(1);
|
|
expect(h.generated[0]!.messages[1]!.content[0]!.text).toStartWith(
|
|
"Compaction: compress message 0",
|
|
);
|
|
const tree = await Array.fromAsync(new Bun.Glob("ses_root/tree/*.jsonl").scan(h.directory));
|
|
expect(tree).toEqual([]);
|
|
if (h.cleanup) await h.cleanup();
|
|
});
|
|
|
|
test("dropped reasoning parts and tool messages without ids do not re-log or skip parts", async () => {
|
|
// Regression: sources were positions, which shift when a model switch drops reasoning parts.
|
|
const h = await host();
|
|
await h.command()({ sessionID: "ses_root", prompt: { text: "on" } });
|
|
const before: Message[] = [
|
|
{ id: "m1", role: "user", content: [{ type: "text", text: "hi" }] },
|
|
{
|
|
id: "m2",
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "reasoning", text: "" },
|
|
{ type: "tool-call", id: "c1", name: "read", input: { path: "a" } },
|
|
],
|
|
},
|
|
{
|
|
role: "tool",
|
|
content: [
|
|
{
|
|
type: "tool-result",
|
|
id: "c1",
|
|
name: "read",
|
|
result: { type: "text", value: "A" },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "m3",
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "reasoning", text: "" },
|
|
{ type: "text", text: "done" },
|
|
],
|
|
},
|
|
{ id: "m4", role: "user", content: [{ type: "text", text: "next" }] },
|
|
];
|
|
await h.turn("ses_root", before);
|
|
const after = before.map((message) => ({
|
|
...message,
|
|
content: message.content.filter((part) => part.type !== "reasoning"),
|
|
}));
|
|
await h.turn("ses_root", [
|
|
{ id: "m0", role: "system", content: [{ type: "text", text: "Instructions updated" }] },
|
|
...after,
|
|
{ id: "m5", role: "assistant", content: [{ type: "text", text: "ok" }] },
|
|
{ id: "m6", role: "user", content: [{ type: "text", text: "again" }] },
|
|
]);
|
|
await settle();
|
|
if (h.cleanup) await h.cleanup();
|
|
expect(await logged(h.directory)).toEqual([
|
|
"user: hi",
|
|
'tool: read {"path":"a"}',
|
|
"echo: read: A",
|
|
"agent: done",
|
|
"user: next",
|
|
"agent: ok",
|
|
"user: again",
|
|
]);
|
|
});
|
|
|
|
test("instruction updates before the turn stay after the view", async () => {
|
|
// Regression: system-role messages before the turn start were dropped.
|
|
const h = await host();
|
|
await h.command()({ sessionID: "ses_root", prompt: { text: "on" } });
|
|
const event = await h.turn("ses_root", [
|
|
{ id: "m1", role: "user", content: [{ type: "text", text: "hi" }] },
|
|
{ id: "m2", role: "assistant", content: [{ type: "text", text: "hello" }] },
|
|
{ id: "m3", role: "system", content: [{ type: "text", text: "Instructions updated" }] },
|
|
{ id: "m4", role: "user", content: [{ type: "text", text: "next" }] },
|
|
]);
|
|
expect(event.messages.map((message) => message.role)).toEqual(["user", "system", "user"]);
|
|
expect(event.messages[1]!.content[0]!.text).toBe("Instructions updated");
|
|
if (h.cleanup) await h.cleanup();
|
|
});
|
|
|
|
test("checkpoints are not logged, provider-executed calls end a turn, and empty replies stop retries", async () => {
|
|
const h = await host();
|
|
await h.command()({ sessionID: "ses_root", prompt: { text: "on" } });
|
|
h.replies.push("");
|
|
const event = await h.turn("ses_root", [
|
|
{
|
|
id: "m1",
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "<conversation-checkpoint>old</conversation-checkpoint>" },
|
|
],
|
|
},
|
|
{
|
|
id: "m2",
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "tool-call",
|
|
id: "w1",
|
|
name: "web_search",
|
|
input: {},
|
|
providerExecuted: true,
|
|
},
|
|
{ type: "text", text: "x".repeat(600) },
|
|
],
|
|
},
|
|
{ id: "m3", role: "user", content: [{ type: "text", text: "next" }] },
|
|
]);
|
|
// The turn is the new message; the provider-executed call did not keep the old turn open.
|
|
expect(event.messages.map((message) => message.content[0]!.text)).toEqual([
|
|
"<chat>\n0+1|tool: web_search {}\n1+1|(not summarized yet: zoom it)\n</chat>",
|
|
"next",
|
|
]);
|
|
await settle();
|
|
// The empty reply ended its compaction without a "Too long" retry; the failure is retried at
|
|
// the next message, as a fresh task.
|
|
expect(h.generated.map((request) => request.messages.length)).toEqual([2, 2]);
|
|
if (h.cleanup) await h.cleanup();
|
|
expect(await logged(h.directory)).toEqual([
|
|
"tool: web_search {}",
|
|
`agent: ${"x".repeat(600)}`,
|
|
"user: next",
|
|
]);
|
|
});
|