@@ -29,7 +29,7 @@ const unrelated = {
|
||||
},
|
||||
},
|
||||
};
|
||||
const names = ["agents", "multi-work", "quota"];
|
||||
const names = ["agents", "multi-work", "quota", "optchat"];
|
||||
const helper = new URL("../scripts/install.ts", import.meta.url);
|
||||
async function run(
|
||||
command: string[],
|
||||
@@ -94,6 +94,8 @@ for (const selection of [
|
||||
"agents,quota",
|
||||
"agents,multi-work",
|
||||
"agents,multi-work,quota",
|
||||
"optchat",
|
||||
"agents,multi-work,quota,optchat",
|
||||
]) {
|
||||
test(`installs valid subset ${selection}, preserving unrelated config and MCP`, async () => {
|
||||
const f = await fixture();
|
||||
@@ -255,7 +257,10 @@ test("replaces the legacy monolith without duplicates and retains Agents options
|
||||
const result = await contents(f.config);
|
||||
expect(result.plugins.map((entry: { package: string }) => entry.package)).toEqual([
|
||||
"before",
|
||||
...names.map((name) => `./plugins/stanp-opencode/${name}`),
|
||||
// The monolith never had Optchat, so upgrading it does not add Optchat.
|
||||
...names
|
||||
.filter((name) => name !== "optchat")
|
||||
.map((name) => `./plugins/stanp-opencode/${name}`),
|
||||
"after",
|
||||
]);
|
||||
expect(result.plugins[1].options.lunaPriority).toBe(false);
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
NODE_BYTES,
|
||||
Queue,
|
||||
VIEW_HIGH,
|
||||
VIEW_LOW,
|
||||
before,
|
||||
blocks,
|
||||
bytes,
|
||||
clean,
|
||||
compressTask,
|
||||
contains,
|
||||
grow,
|
||||
key,
|
||||
merge,
|
||||
mergeTask,
|
||||
pieces,
|
||||
render,
|
||||
tooLong,
|
||||
viewBytes,
|
||||
type Line,
|
||||
type View,
|
||||
} from "../src/optchat-core.js";
|
||||
|
||||
interface State {
|
||||
keep: number;
|
||||
state: number;
|
||||
older: State | null;
|
||||
}
|
||||
|
||||
// Taelin's rollback push without `life`, which stays 0 under push alone.
|
||||
function push(next: number, states: State | null): State {
|
||||
if (states === null) return { keep: 0, state: next, older: null };
|
||||
if (states.keep === 0) return { ...states, keep: 1 };
|
||||
return { keep: 0, state: next, older: push(states.state, states.older) };
|
||||
}
|
||||
|
||||
// Reads the newest-first list as view lines, oldest first.
|
||||
function lines(states: State | null, t: number) {
|
||||
const starts: number[] = [];
|
||||
for (let s = states; s; s = s.older) starts.push(s.state);
|
||||
starts.reverse();
|
||||
return starts.map((first, k): Line => {
|
||||
const size = (starts[k + 1] ?? t + 1) - first;
|
||||
const l = Math.log2(size);
|
||||
expect(Number.isInteger(l) && first % size === 0).toBe(true);
|
||||
return [l, first / size];
|
||||
});
|
||||
}
|
||||
|
||||
test("due ordering reproduces Taelin's push at its list length for t=0..2000", () => {
|
||||
// Every parent counts as built. A target one byte under the current size merges exactly the
|
||||
// most due pair, so the budget is push's list length in lines.
|
||||
const text = () => "";
|
||||
let states: State | null = null;
|
||||
let view: Line[] = [];
|
||||
for (let t = 0; t <= 2000; t++) {
|
||||
states = push(t, states);
|
||||
const expected = lines(states, t);
|
||||
view.push([0, t]);
|
||||
while (view.length > expected.length) {
|
||||
const next = merge(view, t + 1, viewBytes(view, text) - 1, text);
|
||||
expect(next.length).toBe(view.length - 1);
|
||||
view = next;
|
||||
}
|
||||
expect(view).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("merging from the first message diverges from push (the recipe's T=10 example)", () => {
|
||||
const text = () => "";
|
||||
// View 0+4, 4+4, 8+1, 9+1 at T=10: push merges 8-9, not 0-7.
|
||||
const view: Line[] = [
|
||||
[2, 0],
|
||||
[2, 1],
|
||||
[0, 8],
|
||||
[0, 9],
|
||||
];
|
||||
expect(merge(view, 10, viewBytes(view, text) - 1, text)).toEqual([
|
||||
[2, 0],
|
||||
[2, 1],
|
||||
[1, 4],
|
||||
]);
|
||||
});
|
||||
|
||||
test("merge skips pairs whose parent is unbuilt", () => {
|
||||
const built = new Set(["1:0"]);
|
||||
const text = (line: Line) => (line[0] === 0 || built.has(key(line)) ? "" : undefined);
|
||||
const view: Line[] = [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
[0, 2],
|
||||
[0, 3],
|
||||
];
|
||||
expect(merge(view, 4, 0, text)).toEqual([
|
||||
[1, 0],
|
||||
[0, 2],
|
||||
[0, 3],
|
||||
]);
|
||||
});
|
||||
|
||||
test("view is a sawtooth: grows past 128 KB, then one batch drops it to 64 KB", () => {
|
||||
const line = "y".repeat(500);
|
||||
const text = () => line;
|
||||
const view: View = { lines: [], batching: false };
|
||||
let peak = 0;
|
||||
let batches = 0;
|
||||
let total = 0;
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const size = viewBytes(view.lines, text);
|
||||
expect(total).toBe(size);
|
||||
peak = Math.max(peak, size);
|
||||
const grown = grow(view, i, text, total, true);
|
||||
total = grown.total;
|
||||
if (grown.merged) {
|
||||
batches++;
|
||||
expect(size).toBeLessThanOrEqual(VIEW_HIGH);
|
||||
expect(viewBytes(view.lines, text)).toBeLessThanOrEqual(VIEW_LOW);
|
||||
} else expect(view.lines.at(-1)).toEqual([0, i]);
|
||||
}
|
||||
expect(peak).toBeGreaterThan(VIEW_LOW);
|
||||
expect(peak).toBeLessThanOrEqual(VIEW_HIGH + 600);
|
||||
expect(batches).toBeGreaterThan(1);
|
||||
// Lines stay contiguous and cover every message.
|
||||
let next = 0;
|
||||
for (const [l, i] of view.lines) {
|
||||
expect(i * 2 ** l).toBe(next);
|
||||
next += 2 ** l;
|
||||
}
|
||||
expect(next).toBe(2000);
|
||||
});
|
||||
|
||||
test("a batch that cannot reach 64 KB continues at the next message", () => {
|
||||
let parents = false;
|
||||
const text = (line: Line) => (line[0] === 0 || parents ? "z".repeat(500) : undefined);
|
||||
const view: View = { lines: [], batching: false };
|
||||
let total = 0;
|
||||
for (let i = 0; i < 260; i++) {
|
||||
const grown = grow(view, i, text, total, true);
|
||||
expect(grown.merged).toBe(false);
|
||||
total = grown.total;
|
||||
}
|
||||
expect(view.batching).toBe(true);
|
||||
parents = true;
|
||||
// Nothing was built since the last try: the batch waits.
|
||||
const waiting = grow(view, 260, text, total, false);
|
||||
expect(waiting).toEqual({ merged: false, tried: false, total: viewBytes(view.lines, text) });
|
||||
expect(grow(view, 261, text, waiting.total, true).merged).toBe(true);
|
||||
expect(viewBytes(view.lines, text)).toBeLessThanOrEqual(VIEW_LOW);
|
||||
expect(view.batching).toBe(false);
|
||||
});
|
||||
|
||||
test("compaction context stops before the target and at the first unbuilt line", () => {
|
||||
const text = (line: Line) => (key(line) === "0:5" ? undefined : "t");
|
||||
const view: Line[] = [
|
||||
[2, 0],
|
||||
[0, 4],
|
||||
[0, 5],
|
||||
[0, 6],
|
||||
];
|
||||
expect(before(view, 4, text)).toEqual([[2, 0]]);
|
||||
expect(before(view, 7, text)).toEqual([
|
||||
[2, 0],
|
||||
[0, 4],
|
||||
]);
|
||||
});
|
||||
|
||||
test("view blocks keep whole 4-line blocks stable as lines are appended", () => {
|
||||
const a = blocks(["1", "2", "3", "4", "5", "6"]);
|
||||
expect(a.whole).toEqual(["<chat>\n1\n2\n3\n4\n"]);
|
||||
expect(a.tail).toBe("5\n6\n</chat>");
|
||||
const b = blocks(["1", "2", "3", "4", "5", "6", "7", "8", "9"]);
|
||||
expect(b.whole[0]).toBe(a.whole[0]!);
|
||||
expect(b.tail).toBe("9\n</chat>");
|
||||
expect(blocks([])).toEqual({ whole: [], tail: "<chat>\n</chat>" });
|
||||
});
|
||||
|
||||
test("lines render with labels and without newlines", () => {
|
||||
expect(render([3, 5], "a\nb")).toBe("40+8|a b");
|
||||
expect(render([0, 7], undefined)).toBe("7+1|(not summarized yet: zoom it)");
|
||||
});
|
||||
|
||||
test("long tool outputs are clipped; other long texts are split", () => {
|
||||
const long = "a".repeat(20_000) + "b".repeat(20_000);
|
||||
const [echo] = pieces("echo", long);
|
||||
expect(echo!.startsWith("a".repeat(15_000))).toBe(true);
|
||||
expect(echo!.endsWith("b".repeat(15_000))).toBe(true);
|
||||
expect(echo!).toContain("10000 characters clipped");
|
||||
expect(pieces("agent", long).join("")).toBe(long);
|
||||
expect(pieces("agent", long)).toHaveLength(2);
|
||||
expect(pieces("user", "short")).toEqual(["short"]);
|
||||
});
|
||||
|
||||
test("tasks are verbatim and Too long cuts at 512 bytes", () => {
|
||||
const ruler = "-".repeat(512);
|
||||
expect(compressTask(7, "user", "hi")).toBe(
|
||||
`Compaction: compress message 7 into one line of at most 512 bytes\n(about 70 words), the length of this ruler:\n${ruler}\n<input>\nuser: hi\n</input>`,
|
||||
);
|
||||
expect(mergeTask([3, 5], "A", "B")).toBe(
|
||||
`Compaction: merge lines 40+4 and 44+4, adjacent, into one line of at most\n512 bytes (about 70 words), the length of this ruler:\n${ruler}\n<chat> may hold their messages, 40 to 47, in more detail: take details\nof them from there too.\n<input>\nA\nB\n</input>`,
|
||||
);
|
||||
const reply = "é".repeat(300);
|
||||
const message = tooLong(reply);
|
||||
expect(message).toStartWith("Too long: your line is 600 bytes, over the 512-byte limit.");
|
||||
expect(message).toEndWith(`\n${"é".repeat(256)}| ← LIMIT`);
|
||||
expect(bytes("é".repeat(256))).toBe(NODE_BYTES);
|
||||
expect(clean(" 40+8| the line \n")).toBe("the line");
|
||||
});
|
||||
|
||||
test("queue keeps at most 8 builds running, starts messages within 8 of the first unbuilt, and merges built pairs", async () => {
|
||||
const built = new Set<string>();
|
||||
const pending = new Map<string, (ok: boolean) => void>();
|
||||
let peak = 0;
|
||||
const queue = new Queue(
|
||||
(line) => built.has(key(line)),
|
||||
(line) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
pending.set(key(line), (ok) => {
|
||||
if (ok) built.add(key(line));
|
||||
resolve(ok);
|
||||
});
|
||||
peak = Math.max(peak, pending.size);
|
||||
}),
|
||||
);
|
||||
const finish = async (name: string, ok = true) => {
|
||||
const resolve = pending.get(name)!;
|
||||
pending.delete(name);
|
||||
resolve(ok);
|
||||
await Bun.sleep(0);
|
||||
};
|
||||
for (let i = 0; i < 12; i++) queue.add(i);
|
||||
expect([...pending.keys()].sort()).toEqual(
|
||||
["0:0", "0:1", "0:2", "0:3", "0:4", "0:5", "0:6", "0:7"].sort(),
|
||||
);
|
||||
await finish("0:1");
|
||||
// Message 8 starts: 7 earlier messages remain unbuilt.
|
||||
expect(pending.has("0:8")).toBe(true);
|
||||
await finish("0:0");
|
||||
// 0 and 1 are built: their merge competes with message 9 for the free slot; messages come first.
|
||||
expect(pending.has("0:9")).toBe(true);
|
||||
expect(pending.has("1:0")).toBe(false);
|
||||
await finish("0:2", false);
|
||||
// The failure keeps its slot as unbuilt; no leaf may start, so the merge runs.
|
||||
expect(pending.has("1:0")).toBe(true);
|
||||
expect(pending.has("0:10")).toBe(false);
|
||||
expect(peak).toBeLessThanOrEqual(8);
|
||||
const settled = queue.settle(3, 1_000);
|
||||
// settle requeues the failure at once; it starts in the next free slot.
|
||||
await finish("1:0");
|
||||
expect(pending.has("0:2")).toBe(true);
|
||||
await finish("0:2");
|
||||
expect(await settled).toBe(true);
|
||||
});
|
||||
|
||||
test("queue resumes unbuilt messages and ready merges from disk", async () => {
|
||||
const built = new Set(["0:0", "0:1", "0:2"]);
|
||||
const started: string[] = [];
|
||||
const queue = new Queue(
|
||||
(line) => built.has(key(line)),
|
||||
async (line) => {
|
||||
started.push(key(line));
|
||||
built.add(key(line));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
queue.resume(4, [
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
[0, 2],
|
||||
]);
|
||||
await Bun.sleep(0);
|
||||
expect(started).toEqual(["0:3", "1:0", "1:1", "2:0"]);
|
||||
});
|
||||
|
||||
test("a long unbuilt view grows in linear time and pending batches wait for builds", () => {
|
||||
// Regression: enabling optchat in a long session re-measured and re-merged the whole view per message.
|
||||
const text = () => undefined;
|
||||
const view: View = { lines: [], batching: false };
|
||||
let total = 0;
|
||||
let built = true;
|
||||
const started = performance.now();
|
||||
for (let i = 0; i < 20_000; i++) {
|
||||
const grown = grow(view, i, text, total, built);
|
||||
total = grown.total;
|
||||
if (grown.tried) built = false;
|
||||
}
|
||||
expect(total).toBe(viewBytes(view.lines, text));
|
||||
expect(view.batching).toBe(true);
|
||||
expect(performance.now() - started).toBeLessThan(2_000);
|
||||
});
|
||||
|
||||
test("contains finds exactly the view's lines", () => {
|
||||
const view: Line[] = [
|
||||
[2, 0],
|
||||
[1, 2],
|
||||
[0, 6],
|
||||
];
|
||||
expect(contains(view, [1, 2])).toBe(true);
|
||||
expect(contains(view, [0, 6])).toBe(true);
|
||||
expect(contains(view, [0, 4])).toBe(false);
|
||||
expect(contains(view, [0, 7])).toBe(false);
|
||||
});
|
||||
|
||||
test("a merge is queued once when both halves finish in the same tick", async () => {
|
||||
// Regression: short leaves build synchronously, so both finishes saw the parent unbuilt.
|
||||
const tree = new Set<string>();
|
||||
const starts: string[] = [];
|
||||
const queue = new Queue(
|
||||
(line) => tree.has(key(line)),
|
||||
async (line) => {
|
||||
starts.push(key(line));
|
||||
if (line[0] > 0) await Bun.sleep(5);
|
||||
tree.add(key(line));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
queue.add(0);
|
||||
queue.add(1);
|
||||
await Bun.sleep(30);
|
||||
expect(starts).toEqual(["0:0", "0:1", "1:0"]);
|
||||
});
|
||||
|
||||
test("settle stops waiting once failures leave nothing running", async () => {
|
||||
// Regression: it waited the full timeout though failures retry only at the next message.
|
||||
const queue = new Queue(
|
||||
() => false,
|
||||
async () => {
|
||||
await Bun.sleep(5);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
queue.add(0);
|
||||
const started = Date.now();
|
||||
expect(await queue.settle(1, 2_000)).toBe(false);
|
||||
expect(Date.now() - started).toBeLessThan(500);
|
||||
expect(queue.running).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { appendFile, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { grow } from "../src/optchat-core.js";
|
||||
import { Memory } from "../src/optchat-memory.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
async function fixture() {
|
||||
const directory = await mkdtemp(join(tmpdir(), "optchat-memory-"));
|
||||
directories.push(directory);
|
||||
return join(directory, "ses_root");
|
||||
}
|
||||
|
||||
test("logs append-only, dedupe by source, split long texts, and reload with tree and view", async () => {
|
||||
const directory = await fixture();
|
||||
const memory = await Memory.open(directory);
|
||||
expect(memory.log("user", "hello", "user:m1:0")).toEqual([0]);
|
||||
expect(memory.log("user", "hello", "user:m1:0")).toEqual([]);
|
||||
expect(memory.log("agent", "x".repeat(70_000), "assistant:m2:0")).toEqual([1, 2, 3]);
|
||||
memory.node([0, 0], "user: hello");
|
||||
let total = 0;
|
||||
for (const message of memory.messages)
|
||||
total = grow(memory.view, message.i, (line) => memory.text(line), total, true).total;
|
||||
memory.saveView();
|
||||
await memory.close();
|
||||
|
||||
const [day] = await readdir(join(directory, "main"));
|
||||
const lines = (await readFile(join(directory, "main", day!), "utf8")).trim().split("\n");
|
||||
expect(lines.map((line) => JSON.parse(line).i)).toEqual([0, 1, 2, 3]);
|
||||
expect(JSON.parse(lines[1]!).size).toBe(30_000);
|
||||
// A torn line from a crash is skipped.
|
||||
await appendFile(join(directory, "main", day!), '{"i":4,"kind":"us');
|
||||
|
||||
const again = await Memory.open(directory);
|
||||
expect(again.messages.map((message) => message.text.length)).toEqual([
|
||||
5, 30_000, 30_000, 10_000,
|
||||
]);
|
||||
expect(again.has("assistant:m2:0")).toBe(true);
|
||||
expect(again.has("assistant:m2:0#2")).toBe(true);
|
||||
expect(again.text([0, 0])).toBe("user: hello");
|
||||
expect(again.nodes()).toEqual([[0, 0]]);
|
||||
expect(again.view.lines).toEqual([
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
[0, 2],
|
||||
[0, 3],
|
||||
]);
|
||||
expect(again.log("user", "next", "user:m3:0")).toEqual([4]);
|
||||
await again.close();
|
||||
});
|
||||
|
||||
test("messages logged after the saved view are appended to it at load", async () => {
|
||||
const directory = await fixture();
|
||||
const memory = await Memory.open(directory);
|
||||
memory.log("user", "a", "s:0");
|
||||
memory.saveView();
|
||||
memory.log("user", "b", "s:1");
|
||||
await memory.close();
|
||||
const again = await Memory.open(directory);
|
||||
expect(again.view.lines).toEqual([
|
||||
[0, 0],
|
||||
[0, 1],
|
||||
]);
|
||||
await again.close();
|
||||
});
|
||||
|
||||
test("one writer: the lock rejects a live holder and replaces a dead one", async () => {
|
||||
const directory = await fixture();
|
||||
const memory = await Memory.open(directory);
|
||||
await expect(Memory.open(directory)).rejects.toThrow("already open");
|
||||
await memory.close();
|
||||
|
||||
const live = Bun.spawn(["sleep", "30"]);
|
||||
try {
|
||||
await writeFile(join(directory, "lock"), String(live.pid));
|
||||
await expect(Memory.open(directory)).rejects.toThrow(`in use by process ${live.pid}`);
|
||||
} finally {
|
||||
live.kill();
|
||||
await live.exited;
|
||||
}
|
||||
const again = await Memory.open(directory);
|
||||
expect(await readFile(join(directory, "lock"), "utf8")).toBe(String(process.pid));
|
||||
await again.close();
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
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",
|
||||
]);
|
||||
});
|
||||
@@ -27,15 +27,15 @@ test("packed package has independent native plugins and quota-only TUI discovery
|
||||
expect(manifest.exports["."]).toBeUndefined();
|
||||
expect(await Bun.file(join(source, "index.js")).exists()).toBe(false);
|
||||
expect(await Bun.file(join(source, "dist", "index.js")).exists()).toBe(false);
|
||||
for (const name of ["agents", "multi-work", "quota"]) {
|
||||
for (const name of ["agents", "multi-work", "quota", "optchat"]) {
|
||||
const entrypoint = join(source, manifest.exports[`./${name}`].import);
|
||||
const { default: plugin } = await import(entrypoint);
|
||||
expect(plugin.id).toBe(`stanp-opencode.${name}`);
|
||||
}
|
||||
const config = join(directory, "config");
|
||||
await mkdir(config);
|
||||
await install(source, config, "agents,multi-work,quota");
|
||||
for (const name of ["agents", "multi-work", "quota"]) {
|
||||
await install(source, config, "agents,multi-work,quota,optchat");
|
||||
for (const name of ["agents", "multi-work", "quota", "optchat"]) {
|
||||
const entries = Host.resolve({
|
||||
directory: join(config, "plugins", "stanp-opencode", name),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user