Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
929 lines
41 KiB
TypeScript
929 lines
41 KiB
TypeScript
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import type { Op } from "@earendil-works/chord/delta";
|
|
import {
|
|
type AssistantMessage,
|
|
type FauxResponseStep,
|
|
fauxAssistantMessage,
|
|
fauxText,
|
|
fauxToolCall,
|
|
type ToolResultMessage,
|
|
Type,
|
|
type Usage,
|
|
} from "@earendil-works/pi-ai";
|
|
import {
|
|
type Conversation,
|
|
defineDoc,
|
|
defineTool,
|
|
type EntryRecord,
|
|
GenerationTask,
|
|
type Harness,
|
|
InboxDoc,
|
|
type InboxState,
|
|
LiveDoc,
|
|
MemoryStorage,
|
|
ResetEntry,
|
|
type Submission,
|
|
type SubmissionRecord,
|
|
type ToolExecutionResult,
|
|
ToolTask,
|
|
UsageDoc,
|
|
} from "@earendil-works/pi-durable";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { recordUsage } from "../src/harness/usage.ts";
|
|
import { openNodeSqliteStorage } from "../src/storage/sqlite/node.ts";
|
|
import { allEntries, type ChatSetup, chatSetup, openChat, textOf, waitFor } from "./chat-support.ts";
|
|
import { addHooks, addTool } from "./harness-support.ts";
|
|
import { ControlledStorage, context, documentChanges, flush } from "./session-support.ts";
|
|
import { aborted, type Deferred, deferred } from "./task-support.ts";
|
|
|
|
const directories = new Set<string>();
|
|
|
|
afterEach(async () => {
|
|
for (const directory of directories) await rm(directory, { recursive: true, force: true });
|
|
directories.clear();
|
|
});
|
|
|
|
function answer(text: string): AssistantMessage {
|
|
return fauxAssistantMessage([fauxText(text)]);
|
|
}
|
|
|
|
/** A faux response held until `release` or cancellation; `reached` resolves when the request is sent. */
|
|
function gated(message: AssistantMessage): { step: FauxResponseStep; reached: Promise<void>; release: () => void } {
|
|
const reached = deferred();
|
|
const gate = deferred();
|
|
const step: FauxResponseStep = async (_request, options) => {
|
|
reached.resolve();
|
|
await Promise.race([gate.promise, aborted(options!.signal!)]);
|
|
return message;
|
|
};
|
|
return { step, reached: reached.promise, release: () => gate.resolve() };
|
|
}
|
|
|
|
/** Register a `hold` tool whose calls wait for `gate` and then return `result`. */
|
|
function holdTool(setup: ChatSetup, gate: Deferred, result: ToolExecutionResult = { output: [] }): void {
|
|
addTool(
|
|
setup.registry,
|
|
defineTool({
|
|
name: "hold",
|
|
description: "Waits for the test",
|
|
parameters: Type.Object({}),
|
|
execute: async () => {
|
|
await gate.promise;
|
|
return result;
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** A write that affects model context, so it queues while busy; a data-only write would be appended at once. */
|
|
function queuedNote(text: string) {
|
|
return { kind: "note", model: [{ role: "user", content: text, timestamp: 0 }] } as const;
|
|
}
|
|
|
|
const HOLD = fauxAssistantMessage([fauxToolCall("hold", {}, { id: "c1" })], { stopReason: "toolUse" });
|
|
|
|
async function status(submission: Submission): Promise<SubmissionRecord> {
|
|
return submission.status(context);
|
|
}
|
|
|
|
/** Kind and text of each entry, skipping system entries. */
|
|
function transcript(entries: readonly EntryRecord[]): string[] {
|
|
return entries
|
|
.filter((entry) => entry.kind !== "pi.system")
|
|
.map((entry) => {
|
|
const message = entry.model?.[0];
|
|
const text = message?.role === "toolResult" ? undefined : textOf(message);
|
|
return text === undefined ? entry.kind : `${entry.kind}:${text}`;
|
|
});
|
|
}
|
|
|
|
async function inbox(harness: Harness, root: Conversation) {
|
|
return (await harness.snapshot(InboxDoc, root.id, context))!.items.map((item) => [item.id, item.mode]);
|
|
}
|
|
|
|
async function toolRunning(harness: Harness, root: Conversation): Promise<void> {
|
|
await waitFor(async () => (await harness.snapshot(LiveDoc, root.id, context))?.tools?.[0]?.status === "running");
|
|
}
|
|
|
|
describe("inbox", () => {
|
|
it("queues busy submissions and places writes before user items at the final boundary, one follow-up per run", async () => {
|
|
const setup = chatSetup();
|
|
const first = gated(answer("first"));
|
|
setup.faux.setResponses([first.step, answer("second"), answer("third")]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await first.reached;
|
|
const f1 = await root.submit({ type: "input", content: "f1" }, context);
|
|
const write = await root.submit({ type: "write", entry: queuedNote("w") }, context);
|
|
const f2 = await root.submit({ type: "input", content: "f2", whenBusy: "followUp" }, context);
|
|
for (const submission of [f1, write, f2]) expect((await status(submission)).status).toBe("queued");
|
|
expect(await inbox(harness, root)).toEqual([
|
|
[f1.id, "followUp"],
|
|
[write.id, "write"],
|
|
[f2.id, "followUp"],
|
|
]);
|
|
expect(transcript(await allEntries(root))).toEqual(["pi.user:a"]);
|
|
|
|
first.release();
|
|
await f2.wait(context);
|
|
expect(transcript(await allEntries(root))).toEqual([
|
|
"pi.user:a",
|
|
"pi.assistant:first",
|
|
"note:w",
|
|
"pi.user:f1",
|
|
"pi.assistant:second",
|
|
"pi.user:f2",
|
|
"pi.assistant:third",
|
|
]);
|
|
const answers = (await allEntries(root)).filter((entry) => entry.kind === "pi.assistant");
|
|
expect(await status(input)).toMatchObject({ status: "done", answer: answers[0]!.id });
|
|
expect(await status(write)).toMatchObject({ status: "done" });
|
|
expect(await status(f1)).toMatchObject({ status: "done", answer: answers[1]!.id });
|
|
expect(await status(f2)).toMatchObject({ status: "done", answer: answers[2]!.id });
|
|
expect(await inbox(harness, root)).toEqual([]);
|
|
expect(await harness.snapshot(LiveDoc, root.id, context)).toEqual({});
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("places every follow-up in one successor run with followUpMode all", async () => {
|
|
const setup = chatSetup();
|
|
const first = gated(answer("first"));
|
|
setup.faux.setResponses([first.step, answer("both")]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
setup.settings.followUpMode = "all";
|
|
await root.submit({ type: "input", content: "a" }, context);
|
|
await first.reached;
|
|
const f1 = await root.submit({ type: "input", content: "f1" }, context);
|
|
const f2 = await root.submit({ type: "input", content: "f2" }, context);
|
|
first.release();
|
|
const settled = await f2.wait(context);
|
|
expect(await status(f1)).toEqual({ ...settled, id: f1.id, entry: expect.any(Number) });
|
|
expect(transcript(await allEntries(root))).toEqual([
|
|
"pi.user:a",
|
|
"pi.assistant:first",
|
|
"pi.user:f1",
|
|
"pi.user:f2",
|
|
"pi.assistant:both",
|
|
]);
|
|
expect(setup.faux.state.callCount).toBe(2);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("reads queue modes when the final boundary's commit runs on the Session line", async () => {
|
|
const setup = chatSetup();
|
|
const first = gated(answer("first"));
|
|
setup.faux.setResponses([first.step, answer("both")]);
|
|
const yielded = deferred();
|
|
addHooks(setup.registry, GenerationTask, { onYield: () => void yielded.resolve() });
|
|
const storage = new ControlledStorage();
|
|
const { harness, root } = await openChat(storage, setup);
|
|
await root.submit({ type: "input", content: "a" }, context);
|
|
await first.reached;
|
|
const f1 = await root.submit({ type: "input", content: "f1" }, context);
|
|
const f2 = await root.submit({ type: "input", content: "f2" }, context);
|
|
// Occupy the line, let the answer queue its boundary commit behind it, then change the mode.
|
|
const held = storage.holdCommits();
|
|
const Marker = defineDoc<{ n: number }>({
|
|
kind: "test.marker",
|
|
version: 1,
|
|
scope: "session",
|
|
initial: () => ({ n: 0 }),
|
|
});
|
|
const occupying = root.commit(async (tx) => void (await tx.doc(Marker)).n++, context);
|
|
await held.entered;
|
|
first.release();
|
|
await yielded.promise;
|
|
await flush();
|
|
setup.settings.followUpMode = "all";
|
|
held.release();
|
|
await occupying;
|
|
const settled = await f2.wait(context);
|
|
expect(await status(f1)).toEqual({ ...settled, id: f1.id, entry: expect.any(Number) });
|
|
expect(setup.faux.state.callCount).toBe(2);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("adds steers to the run at the postTools boundary and holds follow-ups for the final boundary", async () => {
|
|
const setup = chatSetup();
|
|
const gate = deferred();
|
|
holdTool(setup, gate);
|
|
setup.faux.setResponses([HOLD, answer("after tools"), answer("follow-up")]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await toolRunning(harness, root);
|
|
const steer = await root.submit({ type: "input", content: "s", whenBusy: "steer" }, context);
|
|
const followUp = await root.submit({ type: "input", content: "f" }, context);
|
|
gate.resolve();
|
|
await followUp.wait(context);
|
|
expect(transcript(await allEntries(root))).toEqual([
|
|
"pi.user:a",
|
|
"pi.assistant",
|
|
"pi.tool-result",
|
|
"pi.user:s",
|
|
"pi.assistant:after tools",
|
|
"pi.user:f",
|
|
"pi.assistant:follow-up",
|
|
]);
|
|
const answers = (await allEntries(root)).filter((entry) => entry.kind === "pi.assistant");
|
|
expect(await status(input)).toMatchObject({ status: "done", answer: answers[1]!.id });
|
|
expect(await status(steer)).toMatchObject({ status: "done", answer: answers[1]!.id });
|
|
expect(await status(followUp)).toMatchObject({ status: "done", answer: answers[2]!.id });
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("ends the run at a queued reset after tools and runs earlier follow-ups in the new context", async () => {
|
|
const setup = chatSetup();
|
|
const gate = deferred();
|
|
holdTool(setup, gate);
|
|
const requests: string[][] = [];
|
|
const record: FauxResponseStep = (request) => {
|
|
requests.push(request.messages.map((message) => `${message.role}:${textOf(message) ?? ""}`));
|
|
return answer("fresh");
|
|
};
|
|
setup.faux.setResponses([HOLD, record]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await toolRunning(harness, root);
|
|
const followUp = await root.submit({ type: "input", content: "f" }, context);
|
|
await root.reset(undefined, context);
|
|
gate.resolve();
|
|
await followUp.wait(context);
|
|
expect(await status(input)).toMatchObject({ status: "unanswered", reason: "reset" });
|
|
expect(transcript(await allEntries(root))).toEqual([
|
|
"pi.user:a",
|
|
"pi.assistant",
|
|
"pi.tool-result",
|
|
"pi.reset",
|
|
"pi.user:f",
|
|
"pi.assistant:fresh",
|
|
]);
|
|
const reset = (await allEntries(root)).find((entry) => ResetEntry.is(entry))!;
|
|
expect(reset.head).toBe(reset.id);
|
|
// The follow-up's request starts at the reset: the complete system baseline after the cut leads the follow-up.
|
|
expect(requests).toEqual([["system:", "user:f"]]);
|
|
expect(setup.faux.state.callCount).toBe(2);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("places a queued reset after the answer at the final boundary", async () => {
|
|
const setup = chatSetup();
|
|
const first = gated(answer("first"));
|
|
setup.faux.setResponses([first.step]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await first.reached;
|
|
await root.reset("handoff", context);
|
|
first.release();
|
|
await input.wait(context);
|
|
await harness.waitForIdle(context);
|
|
expect(await status(input)).toMatchObject({ status: "done" });
|
|
expect(transcript(await allEntries(root))).toEqual(["pi.user:a", "pi.assistant:first", "pi.reset:handoff"]);
|
|
expect((await root.context(context)).messages).toEqual([
|
|
{ role: "user", content: "handoff", timestamp: expect.any(Number) },
|
|
]);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("resets an idle conversation at once, with or without handoff text", async () => {
|
|
const setup = chatSetup();
|
|
setup.now = () => 7;
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
await root.commit((tx) => tx.appendEntry(root.id, { kind: "note" }), context);
|
|
await root.reset(undefined, context);
|
|
let view = await root.context(context);
|
|
expect(view.head?.kind).toBe("pi.reset");
|
|
expect(view.head?.model).toBeUndefined();
|
|
expect(view.messages).toEqual([]);
|
|
await root.reset("carry on", context);
|
|
view = await root.context(context);
|
|
expect(view.head?.head).toBe(view.head?.id);
|
|
expect(view.messages).toEqual([{ role: "user", content: "carry on", timestamp: 7 }]);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("makes a queued head write stale when it targets an entry before the active range", async () => {
|
|
const setup = chatSetup();
|
|
const first = gated(answer("first"));
|
|
setup.faux.setResponses([first.step, answer("second")]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const old = await root.commit((tx) => tx.appendEntry(root.id, { kind: "note" }), context);
|
|
await root.reset(undefined, context);
|
|
const reset = (await root.context(context)).head!;
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await first.reached;
|
|
const stale = await root.submit({ type: "write", entry: { kind: "summary", head: old.id } }, context);
|
|
const fresh = await root.submit({ type: "write", entry: { kind: "summary", head: reset.id } }, context);
|
|
first.release();
|
|
await input.wait(context);
|
|
expect(await status(stale)).toMatchObject({ status: "unanswered", reason: "stale" });
|
|
expect(await status(fresh)).toMatchObject({ status: "done" });
|
|
expect(await inbox(harness, root)).toEqual([]);
|
|
|
|
// The fresh summary's marker starts the range at the reset: a target inside the range is not stale, even when
|
|
// it is older than the marker itself. A reset placed earlier in the same boundary makes it stale.
|
|
const inside = (await allEntries(root)).find((entry) => entry.kind === "pi.user")!;
|
|
const second = await root.submit({ type: "input", content: "b" }, context);
|
|
const kept = await root.submit({ type: "write", entry: { kind: "summary", head: inside.id } }, context);
|
|
await second.wait(context);
|
|
expect(await status(kept)).toMatchObject({ status: "done" });
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("makes a head write stale behind a reset placed earlier in the same boundary", async () => {
|
|
const setup = chatSetup();
|
|
const first = gated(answer("first"));
|
|
setup.faux.setResponses([first.step]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await first.reached;
|
|
const target = (await allEntries(root))[0]!;
|
|
await root.reset(undefined, context);
|
|
const summary = await root.submit({ type: "write", entry: { kind: "summary", head: target.id } }, context);
|
|
first.release();
|
|
await input.wait(context);
|
|
expect(await status(summary)).toMatchObject({ status: "unanswered", reason: "stale" });
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("ends the run with a pi.reset entry when a tool requests a handoff", async () => {
|
|
const setup = chatSetup();
|
|
const gate = deferred();
|
|
gate.resolve();
|
|
holdTool(setup, gate, { output: [], control: { handoff: "continue here" } });
|
|
setup.faux.setResponses([HOLD]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const settled = await (await root.submit({ type: "input", content: "a" }, context)).wait(context);
|
|
const entries = await allEntries(root);
|
|
const calling = entries.find((entry) => entry.kind === "pi.assistant")!;
|
|
expect(settled).toMatchObject({ status: "done", answer: calling.id });
|
|
expect(transcript(entries)).toEqual(["pi.user:a", "pi.assistant", "pi.tool-result", "pi.reset:continue here"]);
|
|
expect(entries.at(-1)!.head).toBe(entries.at(-1)!.id);
|
|
expect(setup.faux.state.callCount).toBe(1);
|
|
expect(await harness.snapshot(LiveDoc, root.id, context)).toEqual({});
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("drops an onYield continuation when the final boundary selects a follow-up", async () => {
|
|
const setup = chatSetup();
|
|
const first = gated(answer("first"));
|
|
setup.faux.setResponses([first.step, answer("second")]);
|
|
let yields = 0;
|
|
addHooks(setup.registry, GenerationTask, { onYield: () => (yields++ === 0 ? { continue: "more" } : undefined) });
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await first.reached;
|
|
const followUp = await root.submit({ type: "input", content: "f" }, context);
|
|
first.release();
|
|
await followUp.wait(context);
|
|
expect(await status(input)).toMatchObject({ status: "done" });
|
|
expect(transcript(await allEntries(root))).toEqual([
|
|
"pi.user:a",
|
|
"pi.assistant:first",
|
|
"pi.user:f",
|
|
"pi.assistant:second",
|
|
]);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("withdraws a queued submission and removes its item", async () => {
|
|
const setup = chatSetup();
|
|
const first = gated(answer("first"));
|
|
setup.faux.setResponses([first.step]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await first.reached;
|
|
const kept = await root.submit({ type: "write", entry: queuedNote("kept") }, context);
|
|
const withdrawn = await root.submit({ type: "input", content: "f" }, context);
|
|
expect(await withdrawn.abort(context)).toBe("aborted");
|
|
expect(await withdrawn.wait(context)).toMatchObject({ status: "unanswered", reason: "aborted" });
|
|
expect(await inbox(harness, root)).toEqual([[kept.id, "write"]]);
|
|
first.release();
|
|
await input.wait(context);
|
|
expect(await status(kept)).toMatchObject({ status: "done" });
|
|
expect(setup.faux.state.callCount).toBe(1);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("leaves queued items after a failed run until the next submission places them in order", async () => {
|
|
const setup = chatSetup();
|
|
const failing = gated(fauxAssistantMessage([], { stopReason: "error", errorMessage: "invalid request" }));
|
|
setup.faux.setResponses([failing.step, answer("for f"), answer("for g")]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await failing.reached;
|
|
const f = await root.submit({ type: "input", content: "f" }, context);
|
|
failing.release();
|
|
expect(await input.wait(context)).toMatchObject({ status: "unanswered", reason: "model_error" });
|
|
await harness.waitForIdle(context);
|
|
expect((await status(f)).status).toBe("queued");
|
|
expect(await inbox(harness, root)).toEqual([[f.id, "followUp"]]);
|
|
|
|
// Idle with a queued item: the new input queues behind it, and a final boundary places the older one first.
|
|
const g = await root.submit({ type: "input", content: "g", whenBusy: "reject" }, context);
|
|
await g.wait(context);
|
|
expect(await status(f)).toMatchObject({ status: "done" });
|
|
expect(transcript(await allEntries(root)).slice(-4)).toEqual([
|
|
"pi.user:f",
|
|
"pi.assistant:for f",
|
|
"pi.user:g",
|
|
"pi.assistant:for g",
|
|
]);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("keeps queued submissions across reopen and settles them afterwards", async () => {
|
|
const directory = await mkdtemp(join(tmpdir(), "pi-durable-inbox-"));
|
|
directories.add(directory);
|
|
const path = join(directory, "session.sqlite");
|
|
const setup = chatSetup();
|
|
const first = gated(answer("first"));
|
|
setup.faux.setResponses([first.step, answer("first again"), answer("f")]);
|
|
let opened = await openChat(await openNodeSqliteStorage(path), setup);
|
|
await opened.root.submit({ type: "input", content: "a" }, context);
|
|
await first.reached;
|
|
const f = (await opened.root.submit({ type: "input", content: "f" }, context)).id;
|
|
await opened.harness.close(context);
|
|
|
|
opened = await openChat(await openNodeSqliteStorage(path), setup);
|
|
const settled = await (await opened.harness.submission(f, context))!.wait(context);
|
|
expect(settled).toMatchObject({ status: "done" });
|
|
expect(transcript(await allEntries(opened.root)).slice(-2)).toEqual(["pi.user:f", "pi.assistant:f"]);
|
|
await opened.harness.close(context);
|
|
});
|
|
|
|
it("commits inbox changes as positional Chord operations and a base when empty", async () => {
|
|
const setup = chatSetup();
|
|
const first = gated(answer("first"));
|
|
setup.faux.setResponses([first.step, answer("second")]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const ops: Op[][] = [];
|
|
harness.subscribeCommits((publication) => {
|
|
for (const change of documentChanges(publication)) {
|
|
if (change.record.kind === "pi.inbox" && change.ops.length > 0) ops.push([...change.ops]);
|
|
}
|
|
});
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await first.reached;
|
|
const w1 = await root.submit({ type: "write", entry: queuedNote("w") }, context);
|
|
const f1 = await root.submit({ type: "input", content: "f1" }, context);
|
|
const w2 = await root.submit({ type: "write", entry: queuedNote("w") }, context);
|
|
const f2 = await root.submit({ type: "input", content: "f2" }, context);
|
|
const w3 = await root.submit({ type: "write", entry: queuedNote("w") }, context);
|
|
expect(ops).toEqual([
|
|
[["p", ["items"], 0, 0, [{ id: w1.id, mode: "write", entry: queuedNote("w") }]]],
|
|
[["p", ["items"], 1, 0, [{ id: f1.id, mode: "followUp", content: "f1" }]]],
|
|
[["p", ["items"], 2, 0, [{ id: w2.id, mode: "write", entry: queuedNote("w") }]]],
|
|
[["p", ["items"], 3, 0, [{ id: f2.id, mode: "followUp", content: "f2" }]]],
|
|
[["p", ["items"], 4, 0, [{ id: w3.id, mode: "write", entry: queuedNote("w") }]]],
|
|
]);
|
|
first.release();
|
|
await input.wait(context);
|
|
// Every write and the first follow-up leave; only f2 at index 3 remains. No retained value is carried.
|
|
expect(ops[5]!.every((op) => op[0] === "p" && (op[4] as unknown[]).length === 0)).toBe(true);
|
|
expect(ops[5]).toContainEqual(["p", ["items"], 4, 1, []]);
|
|
expect(JSON.stringify(ops[5])).not.toContain("f2");
|
|
await f2.wait(context);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("settles a stale write at once while idle, and a queued one behind waiting items", async () => {
|
|
const setup = chatSetup();
|
|
const failing = gated(fauxAssistantMessage([], { stopReason: "error", errorMessage: "invalid request" }));
|
|
setup.faux.setResponses([failing.step, answer("for f")]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const old = await root.commit((tx) => tx.appendEntry(root.id, { kind: "note" }), context);
|
|
await root.reset(undefined, context);
|
|
const before = (await allEntries(root)).length;
|
|
const idle = await root.submit({ type: "write", entry: { kind: "summary", head: old.id } }, context);
|
|
expect(await idle.wait(context)).toMatchObject({ status: "unanswered", reason: "stale" });
|
|
expect(await allEntries(root)).toHaveLength(before);
|
|
|
|
// After a failed run, a follow-up waits; a stale write queues behind it and the boundary rejects it.
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await failing.reached;
|
|
const f = await root.submit({ type: "input", content: "f" }, context);
|
|
failing.release();
|
|
await input.wait(context);
|
|
await harness.waitForIdle(context);
|
|
const queued = await root.submit({ type: "write", entry: { kind: "summary", head: old.id } }, context);
|
|
expect(await queued.wait(context)).toMatchObject({ status: "unanswered", reason: "stale" });
|
|
expect(await f.wait(context)).toMatchObject({ status: "done" });
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("keeps an onYield continuation across a queued plain write, with the run's original input", async () => {
|
|
const setup = chatSetup();
|
|
const first = gated(answer("first"));
|
|
setup.faux.setResponses([first.step, answer("second")]);
|
|
let yields = 0;
|
|
addHooks(setup.registry, GenerationTask, { onYield: () => (yields++ === 0 ? { continue: "more" } : undefined) });
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await first.reached;
|
|
const write = await root.submit({ type: "write", entry: queuedNote("w") }, context);
|
|
first.release();
|
|
await input.wait(context);
|
|
const entries = await allEntries(root);
|
|
expect(transcript(entries)).toEqual([
|
|
"pi.user:a",
|
|
"pi.assistant:first",
|
|
"note:w",
|
|
"pi.user:more",
|
|
"pi.assistant:second",
|
|
]);
|
|
expect(await status(input)).toMatchObject({ status: "done", answer: entries.at(-1)!.id });
|
|
expect(await status(write)).toMatchObject({ status: "done" });
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("drops an onYield continuation for a queued reset", async () => {
|
|
const setup = chatSetup();
|
|
const first = gated(answer("first"));
|
|
setup.faux.setResponses([first.step]);
|
|
addHooks(setup.registry, GenerationTask, { onYield: () => ({ continue: "more" }) });
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await first.reached;
|
|
await root.reset(undefined, context);
|
|
first.release();
|
|
await input.wait(context);
|
|
await harness.waitForIdle(context);
|
|
expect(await status(input)).toMatchObject({ status: "done" });
|
|
expect(transcript(await allEntries(root))).toEqual(["pi.user:a", "pi.assistant:first", "pi.reset"]);
|
|
expect(setup.faux.state.callCount).toBe(1);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("adds every steer to the run at the postTools boundary with steeringMode all", async () => {
|
|
const setup = chatSetup();
|
|
const gate = deferred();
|
|
holdTool(setup, gate);
|
|
setup.faux.setResponses([HOLD, answer("after tools"), answer("follow-up")]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
setup.settings.steeringMode = "all";
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await toolRunning(harness, root);
|
|
const s1 = await root.submit({ type: "input", content: "s1", whenBusy: "steer" }, context);
|
|
const f = await root.submit({ type: "input", content: "f" }, context);
|
|
const s2 = await root.submit({ type: "input", content: "s2", whenBusy: "steer" }, context);
|
|
gate.resolve();
|
|
await f.wait(context);
|
|
const entries = await allEntries(root);
|
|
expect(transcript(entries)).toEqual([
|
|
"pi.user:a",
|
|
"pi.assistant",
|
|
"pi.tool-result",
|
|
"pi.user:s1",
|
|
"pi.user:s2",
|
|
"pi.assistant:after tools",
|
|
"pi.user:f",
|
|
"pi.assistant:follow-up",
|
|
]);
|
|
const answers = entries.filter((entry) => entry.kind === "pi.assistant");
|
|
for (const submission of [input, s1, s2]) {
|
|
expect(await status(submission)).toMatchObject({ status: "done", answer: answers[1]!.id });
|
|
}
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("queues an idle steer behind waiting items and places it with the first follow-up in ID order", async () => {
|
|
const setup = chatSetup();
|
|
const failing = gated(fauxAssistantMessage([], { stopReason: "error", errorMessage: "invalid request" }));
|
|
setup.faux.setResponses([failing.step, answer("both")]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await failing.reached;
|
|
const f = await root.submit({ type: "input", content: "f" }, context);
|
|
failing.release();
|
|
await input.wait(context);
|
|
await harness.waitForIdle(context);
|
|
const steer = await root.submit({ type: "input", content: "s", whenBusy: "steer" }, context);
|
|
const settled = await steer.wait(context);
|
|
expect(await status(f)).toMatchObject({ status: "done", answer: settled.status === "done" && settled.answer });
|
|
expect(transcript(await allEntries(root)).slice(-3)).toEqual(["pi.user:f", "pi.user:s", "pi.assistant:both"]);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("starts a queued follow-up after a terminating round", async () => {
|
|
const setup = chatSetup();
|
|
const gate = deferred();
|
|
holdTool(setup, gate, { output: [], control: { terminate: true } });
|
|
setup.faux.setResponses([HOLD, answer("follow-up")]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await toolRunning(harness, root);
|
|
const f = await root.submit({ type: "input", content: "f" }, context);
|
|
gate.resolve();
|
|
await f.wait(context);
|
|
const calling = (await allEntries(root)).find((entry) => entry.kind === "pi.assistant")!;
|
|
expect(await status(input)).toMatchObject({ status: "done", answer: calling.id });
|
|
expect(transcript(await allEntries(root))).toEqual([
|
|
"pi.user:a",
|
|
"pi.assistant",
|
|
"pi.tool-result",
|
|
"pi.user:f",
|
|
"pi.assistant:follow-up",
|
|
]);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("writes the last handoff in call order and then runs queued follow-ups in the new context", async () => {
|
|
const setup = chatSetup();
|
|
const firstGate = deferred();
|
|
const secondGate = deferred();
|
|
holdTool(setup, firstGate, { output: [], control: { handoff: "one" } });
|
|
addTool(
|
|
setup.registry,
|
|
defineTool({
|
|
name: "later",
|
|
description: "Finishes first",
|
|
parameters: Type.Object({}),
|
|
execute: async () => {
|
|
await secondGate.promise;
|
|
return { output: [], control: { handoff: "two" } };
|
|
},
|
|
}),
|
|
);
|
|
const round = fauxAssistantMessage(
|
|
[fauxToolCall("hold", {}, { id: "c1" }), fauxToolCall("later", {}, { id: "c2" })],
|
|
{ stopReason: "toolUse" },
|
|
);
|
|
setup.faux.setResponses([round, answer("follow-up")]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await toolRunning(harness, root);
|
|
const f = await root.submit({ type: "input", content: "f" }, context);
|
|
secondGate.resolve();
|
|
await waitFor(async () => (await harness.snapshot(LiveDoc, root.id, context))?.tools?.[1]?.status === "done");
|
|
firstGate.resolve();
|
|
await f.wait(context);
|
|
expect(await status(input)).toMatchObject({ status: "done" });
|
|
expect(transcript(await allEntries(root)).slice(-4)).toEqual([
|
|
"pi.tool-result",
|
|
"pi.reset:two",
|
|
"pi.user:f",
|
|
"pi.assistant:follow-up",
|
|
]);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("leaves the inbox alone when the run's task is aborted", async () => {
|
|
const setup = chatSetup();
|
|
const first = gated(answer("never"));
|
|
setup.faux.setResponses([first.step]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await first.reached;
|
|
const f = await root.submit({ type: "input", content: "f" }, context);
|
|
const taskId = (await harness.snapshot(LiveDoc, root.id, context))!.run!.taskId;
|
|
await harness.abortTask(taskId, context);
|
|
expect(await input.wait(context)).toMatchObject({ status: "unanswered", reason: "aborted" });
|
|
await harness.waitForIdle(context);
|
|
expect((await status(f)).status).toBe("queued");
|
|
expect(await inbox(harness, root)).toEqual([[f.id, "followUp"]]);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("returns a queued submission for its repeated request ID without a second item", async () => {
|
|
const setup = chatSetup();
|
|
const first = gated(answer("first"));
|
|
setup.faux.setResponses([first.step]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
await root.submit({ type: "input", content: "a" }, context);
|
|
await first.reached;
|
|
const queued = await root.submit({ type: "input", content: "f", requestId: "r" }, context);
|
|
const again = await root.submit({ type: "input", content: "f", requestId: "r" }, context);
|
|
expect(again.id).toBe(queued.id);
|
|
expect(await inbox(harness, root)).toEqual([[queued.id, "followUp"]]);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("withdraws a middle item with one positional removal", async () => {
|
|
const setup = chatSetup();
|
|
const first = gated(answer("first"));
|
|
setup.faux.setResponses([first.step]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
await root.submit({ type: "input", content: "a" }, context);
|
|
await first.reached;
|
|
const items = [];
|
|
for (const text of ["x", "y", "z"]) items.push(await root.submit({ type: "input", content: text }, context));
|
|
const ops: Op[][] = [];
|
|
harness.subscribeCommits((publication) => {
|
|
for (const change of documentChanges(publication)) {
|
|
if (change.record.kind === "pi.inbox" && change.ops.length > 0) ops.push([...change.ops]);
|
|
}
|
|
});
|
|
await items[1]!.abort(context);
|
|
expect(ops).toEqual([[["p", ["items"], 1, 1, []]]]);
|
|
expect(await inbox(harness, root)).toEqual([
|
|
[items[0]!.id, "followUp"],
|
|
[items[2]!.id, "followUp"],
|
|
]);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("stores the inbox as a base exactly when it becomes empty", async () => {
|
|
const written: { kind: "base" | "delta"; empty: boolean }[] = [];
|
|
let inboxId: number | undefined;
|
|
class RecordingStorage extends MemoryStorage {
|
|
override async commit(
|
|
writes: Parameters<MemoryStorage["commit"]>[0],
|
|
callContext: Parameters<MemoryStorage["commit"]>[1],
|
|
) {
|
|
for (const write of writes) {
|
|
if (write.type !== "document.change" || write.id !== inboxId) continue;
|
|
const content = write.content;
|
|
const empty = content.kind === "base" && (content.value as InboxState).items.length === 0;
|
|
written.push({ kind: content.kind, empty });
|
|
}
|
|
return super.commit(writes, callContext);
|
|
}
|
|
}
|
|
const setup = chatSetup();
|
|
const first = gated(answer("first"));
|
|
setup.faux.setResponses([first.step, answer("second")]);
|
|
const { harness, root } = await openChat(new RecordingStorage(), setup);
|
|
harness.subscribeCommits((publication) => {
|
|
for (const change of documentChanges(publication))
|
|
if (change.record.kind === "pi.inbox") inboxId = change.record.id;
|
|
});
|
|
const input = await root.submit({ type: "input", content: "a" }, context);
|
|
await first.reached;
|
|
await root.submit({ type: "input", content: "f1" }, context);
|
|
const f2 = await root.submit({ type: "input", content: "f2" }, context);
|
|
first.release();
|
|
await input.wait(context);
|
|
await f2.wait(context);
|
|
// The inbox ID is learned from the f1 push; later writes: the f2 push, removing f1, and emptying the inbox.
|
|
expect(written).toEqual([
|
|
{ kind: "delta", empty: false },
|
|
{ kind: "delta", empty: false },
|
|
{ kind: "base", empty: true },
|
|
]);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("keeps a complete inbox base exactly while it is empty, and a usage base on every change", () => {
|
|
const info = { deltasSinceBase: 1000 } as never;
|
|
const item = { id: 1 as never, mode: "followUp", content: "x" } as const;
|
|
expect(InboxDoc.definition.checkpointWhen!({ items: [] }, [], info)).toBe(true);
|
|
expect(InboxDoc.definition.checkpointWhen!({ items: [item] }, [], info)).toBe(false);
|
|
expect(UsageDoc.definition.checkpointWhen!({ models: {}, tools: {} }, [], info)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("usage", () => {
|
|
it("totals assistant usage per model and tool usage per tool, and sums the Session", async () => {
|
|
const setup = chatSetup();
|
|
const spent: Usage = {
|
|
input: 1,
|
|
output: 2,
|
|
cacheRead: 3,
|
|
cacheWrite: 4,
|
|
totalTokens: 10,
|
|
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
|
|
};
|
|
const gate = deferred();
|
|
gate.resolve();
|
|
holdTool(setup, gate, { output: [], usage: spent });
|
|
setup.faux.setResponses([HOLD, answer("done"), answer("other")]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
await (await root.submit({ type: "input", content: "a" }, context)).wait(context);
|
|
const entries = await allEntries(root);
|
|
const assistants = entries.flatMap((entry) =>
|
|
entry.kind === "pi.assistant" ? [entry.model![0] as AssistantMessage] : [],
|
|
);
|
|
const expected = assistants.reduce(
|
|
(sum, message) => ({
|
|
input: sum.input + message.usage.input,
|
|
output: sum.output + message.usage.output,
|
|
totalTokens: sum.totalTokens + message.usage.totalTokens,
|
|
}),
|
|
{ input: 0, output: 0, totalTokens: 0 },
|
|
);
|
|
const usage = (await harness.snapshot(UsageDoc, root.id, context))!;
|
|
expect(usage.models["faux/faux-1"]).toMatchObject(expected);
|
|
expect(usage.tools).toEqual({ hold: spent });
|
|
const result = entries.find((entry) => entry.kind === "pi.tool-result")!.model![0] as ToolResultMessage;
|
|
expect(result.usage).toEqual(spent);
|
|
|
|
// A fork starts at zero; the Session total adds every conversation once.
|
|
const fork = await root.fork(entries.at(-1)!.id, { ownership: { kind: "ownerless" } }, context);
|
|
expect(await harness.snapshot(UsageDoc, fork.id, context)).toEqual({ models: {}, tools: {} });
|
|
await (await fork.submit({ type: "input", content: "b" }, context)).wait(context);
|
|
const forkUsage = (await harness.snapshot(UsageDoc, fork.id, context))!.models["faux/faux-1"]!;
|
|
const total = await harness.usage(context);
|
|
expect(total.models["faux/faux-1"]!.output).toBe(expected.output + forkUsage.output);
|
|
expect(total.tools).toEqual({ hold: spent });
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("counts failed attempts, converted partials, and tool usage replaced by afterTool", async () => {
|
|
const setup = chatSetup({ tokensPerSecond: 200, tokenSize: { min: 1, max: 1 } });
|
|
const spent: Usage = {
|
|
input: 5,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 5,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
};
|
|
const gate = deferred();
|
|
gate.resolve();
|
|
holdTool(setup, gate);
|
|
addHooks(setup.registry, ToolTask, { afterTool: (_call, result) => ({ ...result, usage: spent }) });
|
|
const retryable = fauxAssistantMessage([], { stopReason: "error", errorMessage: "503 Service Unavailable" });
|
|
setup.faux.setResponses([retryable, HOLD, answer("done"), answer("x".repeat(400))]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
setup.settings.retry = { enabled: true, maxRetries: 1, baseDelayMs: 1 };
|
|
await (await root.submit({ type: "input", content: "a" }, context)).wait(context);
|
|
expect((await harness.snapshot(UsageDoc, root.id, context))!.tools).toEqual({ hold: spent });
|
|
|
|
// A partial committed while streaming becomes an aborted entry on abort; its usage counts too.
|
|
const input = await root.submit({ type: "input", content: "b" }, context);
|
|
await waitFor(async () => (await harness.snapshot(LiveDoc, root.id, context))?.generation?.message !== undefined);
|
|
await harness.abortTask((await harness.snapshot(LiveDoc, root.id, context))!.run!.taskId, context);
|
|
await input.wait(context);
|
|
const assistants = (await allEntries(root)).flatMap((entry) =>
|
|
entry.kind === "pi.assistant" ? [entry.model![0] as AssistantMessage] : [],
|
|
);
|
|
expect(assistants.map((message) => message.stopReason)).toEqual(["error", "toolUse", "stop", "aborted"]);
|
|
const output = assistants.reduce((sum, message) => sum + message.usage.output, 0);
|
|
expect((await harness.snapshot(UsageDoc, root.id, context))!.models["faux/faux-1"]!.output).toBe(output);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("keeps tools named like object prototype keys in the ledger and the Session total", async () => {
|
|
const { harness, root } = await openChat(new MemoryStorage(), chatSetup());
|
|
const usage: Usage = {
|
|
input: 1,
|
|
output: 1,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 2,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
};
|
|
const names = ["constructor", "__proto__", "toString"];
|
|
for (const name of names) {
|
|
await root.commit((tx) => recordUsage(tx, root.id, "tools", name, usage), context);
|
|
await root.commit((tx) => recordUsage(tx, root.id, "tools", name, usage), context);
|
|
}
|
|
const total = await harness.usage(context);
|
|
expect(Object.keys(total.tools)).toEqual(names);
|
|
for (const name of names) expect(Object.getOwnPropertyDescriptor(total.tools, name)?.value.output).toBe(2);
|
|
await harness.close(context);
|
|
});
|
|
|
|
it("keeps usage totals exact across reopen, counting a partial converted after reopen once", async () => {
|
|
const directory = await mkdtemp(join(tmpdir(), "pi-durable-usage-"));
|
|
directories.add(directory);
|
|
const path = join(directory, "session.sqlite");
|
|
const setup = chatSetup({ tokensPerSecond: 200, tokenSize: { min: 1, max: 1 } });
|
|
setup.faux.setResponses([answer("first"), answer("x".repeat(400)), answer("again")]);
|
|
let opened = await openChat(await openNodeSqliteStorage(path), setup);
|
|
await (await opened.root.submit({ type: "input", content: "a" }, context)).wait(context);
|
|
await opened.root.submit({ type: "input", content: "b" }, context);
|
|
const live = opened.harness;
|
|
await waitFor(
|
|
async () => (await live.snapshot(LiveDoc, opened.root.id, context))?.generation?.message !== undefined,
|
|
);
|
|
// Closing mid-stream keeps the committed partial; the reopened request converts it into an aborted entry.
|
|
await opened.harness.close(context);
|
|
opened = await openChat(await openNodeSqliteStorage(path), setup);
|
|
opened.harness.resume();
|
|
await opened.harness.waitForIdle(context);
|
|
const assistants = (await allEntries(opened.root)).flatMap((entry) =>
|
|
entry.kind === "pi.assistant" ? [entry.model![0] as AssistantMessage] : [],
|
|
);
|
|
expect(assistants.map((message) => message.stopReason)).toEqual(["stop", "aborted", "stop"]);
|
|
const total = (await opened.harness.usage(context)).models["faux/faux-1"]!;
|
|
const sum = (field: "input" | "output" | "totalTokens") =>
|
|
assistants.reduce((value, message) => value + message.usage[field], 0);
|
|
expect([total.input, total.output, total.totalTokens]).toEqual([sum("input"), sum("output"), sum("totalTokens")]);
|
|
await opened.harness.close(context);
|
|
});
|
|
|
|
it("records usage as numeric sets on the ledger in the entry's commit", async () => {
|
|
const setup = chatSetup();
|
|
setup.faux.setResponses([answer("one"), answer("two")]);
|
|
const { harness, root } = await openChat(new MemoryStorage(), setup);
|
|
const commits: { entries: number; ops: readonly Op[] }[] = [];
|
|
harness.subscribeCommits((publication) => {
|
|
for (const change of documentChanges(publication)) {
|
|
if (change.record.kind !== "pi.usage") continue;
|
|
const entries = publication.changes.filter((other) => other.type === "entry").length;
|
|
commits.push({ entries, ops: change.ops });
|
|
}
|
|
});
|
|
await (await root.submit({ type: "input", content: "a" }, context)).wait(context);
|
|
await (await root.submit({ type: "input", content: "b" }, context)).wait(context);
|
|
expect(commits).toHaveLength(2);
|
|
expect(commits[0]!.ops).toEqual([["s", ["models", "faux/faux-1"], expect.any(Object)]]);
|
|
expect(commits[1]!.ops.every((op) => op[0] === "s" && op[1][1] === "faux/faux-1")).toBe(true);
|
|
expect(commits.every((commit) => commit.entries >= 1)).toBe(true);
|
|
await harness.close(context);
|
|
});
|
|
});
|