chore(vendor): import pi 1.3.0
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
This commit is contained in:
982
third_party/pi/packages/durable/test/harness-context.test.ts
vendored
Normal file
982
third_party/pi/packages/durable/test/harness-context.test.ts
vendored
Normal file
@@ -0,0 +1,982 @@
|
||||
import type { Context } from "@earendil-works/chord";
|
||||
import { createModels, type Message } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
type ContextView,
|
||||
createRegistry,
|
||||
defineTask,
|
||||
type EntryDraft,
|
||||
type EntryId,
|
||||
type EntryRecord,
|
||||
Harness,
|
||||
type HarnessSettings,
|
||||
MemoryStorage,
|
||||
type Storage,
|
||||
} from "@earendil-works/pi-durable";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { addTask, assistant, describeMessage, openHarness, system, toolResult, user } from "./harness-support.ts";
|
||||
import { context } from "./session-support.ts";
|
||||
|
||||
async function setup() {
|
||||
const { harness } = await openHarness(new MemoryStorage());
|
||||
const root = await harness.root(context);
|
||||
const append = (draft: EntryDraft): Promise<EntryRecord> =>
|
||||
root.commit((tx) => tx.appendEntry(root.id, draft), context);
|
||||
const message = (model: Message, kind = "message"): Promise<EntryRecord> => append({ kind, model: [model] });
|
||||
return { harness, root, append, message };
|
||||
}
|
||||
|
||||
/** A Harness over MemoryStorage that counts scanned entry rows, with a 21-entry root transcript. */
|
||||
async function countingSetup(options: { readonly settings?: HarnessSettings; readonly now?: () => number } = {}) {
|
||||
const scanned = { rows: 0 };
|
||||
/** When set, the next range scan waits for it; bounds probes (one row, on the Session line) do not. */
|
||||
const hold: { scan?: Promise<void> } = {};
|
||||
const memory = new MemoryStorage();
|
||||
const storage = new Proxy<Storage>(memory, {
|
||||
get(target, key) {
|
||||
const value: unknown = Reflect.get(target, key, target);
|
||||
if (key === "scanEntries") {
|
||||
return async (...args: Parameters<Storage["scanEntries"]>) => {
|
||||
const held = args[1] > 1 ? hold.scan : undefined;
|
||||
if (held !== undefined) hold.scan = undefined;
|
||||
await held;
|
||||
const page = await target.scanEntries(...args);
|
||||
scanned.rows += page.items.length;
|
||||
return page;
|
||||
};
|
||||
}
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
const registry = createRegistry();
|
||||
const harness = await Harness.open(storage, { models: createModels(), registry, ...options }, context);
|
||||
const root = await harness.root(context);
|
||||
const first = await root.commit(
|
||||
(tx) => tx.appendEntry(root.id, { kind: "message", model: [user("first")] }),
|
||||
context,
|
||||
);
|
||||
for (let index = 0; index < 20; index++) {
|
||||
await root.commit(
|
||||
(tx) => tx.appendEntry(root.id, { kind: "message", model: [assistant(`old ${index}`)] }),
|
||||
context,
|
||||
);
|
||||
}
|
||||
return { harness, registry, root, first, scanned, hold };
|
||||
}
|
||||
|
||||
function ids(entries: readonly EntryRecord[]): EntryId[] {
|
||||
return entries.map((entry) => entry.id);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("conversation context", () => {
|
||||
it("returns the whole transcript without a head and excludes model-less entries from messages", async () => {
|
||||
const { root, append, message } = await setup();
|
||||
const first = await message(user("hi"));
|
||||
const note = await append({ kind: "note", data: { text: "display only" } });
|
||||
const answer = await message(assistant("hello"));
|
||||
const view = await root.context(context);
|
||||
expect(view.head).toBeUndefined();
|
||||
expect(ids(view.entries)).toEqual([first.id, note.id, answer.id]);
|
||||
expect(view.messages.map(describeMessage)).toEqual(["user:hi", "assistant:hello"]);
|
||||
});
|
||||
|
||||
it("excludes aborted, error, and deferred assistant messages but keeps their raw entries", async () => {
|
||||
const { root, message } = await setup();
|
||||
await message(user("q"));
|
||||
const aborted = await message(assistant("partial", { stopReason: "aborted" }));
|
||||
await message(assistant("failed", { stopReason: "error" }));
|
||||
await message(assistant("later", { stopReason: "deferred" }));
|
||||
await message(assistant("done", { stopReason: "length" }));
|
||||
const view = await root.context(context);
|
||||
expect(view.entries).toHaveLength(5);
|
||||
expect(view.entries[1]!.id).toBe(aborted.id);
|
||||
expect(view.messages.map(describeMessage)).toEqual(["user:q", "assistant:done"]);
|
||||
});
|
||||
|
||||
it("resolves self heads and uses the newest head marker", async () => {
|
||||
const { root, append, message } = await setup();
|
||||
await message(user("old"));
|
||||
const reset = await append({ kind: "reset", head: "self", model: [user("fresh start")] });
|
||||
expect(reset.head).toBe(reset.id);
|
||||
const after = await message(assistant("after reset"));
|
||||
let view = await root.context(context);
|
||||
expect(view.head?.id).toBe(reset.id);
|
||||
expect(ids(view.entries)).toEqual([reset.id, after.id]);
|
||||
expect(view.messages.map(describeMessage)).toEqual(["user:fresh start", "assistant:after reset"]);
|
||||
|
||||
// A compaction summary heads an earlier kept entry; older head markers in range drop out.
|
||||
const summary = await append({ kind: "summary", head: after.id, model: [user("summary")] });
|
||||
const tail = await message(user("next"));
|
||||
view = await root.context(context);
|
||||
expect(view.head?.id).toBe(summary.id);
|
||||
expect(ids(view.entries)).toEqual([summary.id, after.id, tail.id]);
|
||||
expect(view.messages.map(describeMessage)).toEqual(["user:summary", "assistant:after reset", "user:next"]);
|
||||
});
|
||||
|
||||
it("applies the newest edit per target within the active range", async () => {
|
||||
const { root, append, message } = await setup();
|
||||
const first = await message(user("first"));
|
||||
const second = await message(user("second"));
|
||||
await append({ kind: "edit", edits: [{ target: first.id, action: "replace", messages: [user("first v2")] }] });
|
||||
await append({ kind: "edit", edits: [{ target: first.id, action: "replace", messages: [user("first v3")] }] });
|
||||
await append({ kind: "edit", edits: [{ target: second.id, action: "omit" }] });
|
||||
let view = await root.context(context);
|
||||
expect(view.entries).toHaveLength(5);
|
||||
expect(view.messages.map(describeMessage)).toEqual(["user:first v3"]);
|
||||
|
||||
// Edits before the active range no longer apply.
|
||||
const reset = await append({ kind: "reset", head: second.id });
|
||||
view = await root.context(context);
|
||||
expect(view.head?.id).toBe(reset.id);
|
||||
expect(view.messages.map(describeMessage)).toEqual([]);
|
||||
await append({ kind: "edit", edits: [{ target: second.id, action: "replace", messages: [user("second v2")] }] });
|
||||
view = await root.context(context);
|
||||
expect(view.messages.map(describeMessage)).toEqual(["user:second v2"]);
|
||||
});
|
||||
|
||||
it("keeps positional system messages and orders tool results by call order", async () => {
|
||||
const { root, message, append } = await setup();
|
||||
await message(system({ preamble: "You help." }), "pi.system");
|
||||
await message(user("run tools"));
|
||||
await message(assistant("calling", { calls: ["b", "a"] }));
|
||||
await message(toolResult("a"));
|
||||
await append({ kind: "pi.system", model: [system({ cwd: "/repo" })] });
|
||||
await message(toolResult("b"));
|
||||
await message(toolResult("zz"));
|
||||
await message(assistant("done"));
|
||||
const view = await root.context(context);
|
||||
expect(view.messages.map(describeMessage)).toEqual([
|
||||
"system:preamble",
|
||||
"user:run tools",
|
||||
"assistant:calling",
|
||||
"result:b:result b",
|
||||
"result:a:result a",
|
||||
"system:cwd",
|
||||
"assistant:done",
|
||||
]);
|
||||
});
|
||||
|
||||
// #10542
|
||||
it("leads with a system message that only user messages precede, and keeps later ones in place", async () => {
|
||||
const { root, message, append } = await setup();
|
||||
await message(user("first"));
|
||||
await message(user("steered"));
|
||||
await message(system({ preamble: "You help." }), "pi.system");
|
||||
await message(assistant("answer"));
|
||||
await message(user("next"));
|
||||
await message(system({ cwd: "/repo" }), "pi.system");
|
||||
let view = await root.context(context);
|
||||
expect(view.messages.map(describeMessage)).toEqual([
|
||||
"system:preamble",
|
||||
"user:first",
|
||||
"user:steered",
|
||||
"assistant:answer",
|
||||
"user:next",
|
||||
"system:cwd",
|
||||
]);
|
||||
// Stored order and contributions stay as committed.
|
||||
expect(view.contributions.flat().map(describeMessage).slice(0, 3)).toEqual([
|
||||
"user:first",
|
||||
"user:steered",
|
||||
"system:preamble",
|
||||
]);
|
||||
|
||||
// After a reset, the baseline written after the handoff leads too.
|
||||
await append({ kind: "reset", head: "self", model: [user("handoff")] });
|
||||
await message(system({ preamble: "Baseline." }), "pi.system");
|
||||
view = await root.context(context);
|
||||
expect(view.messages.map(describeMessage)).toEqual(["system:preamble", "user:handoff"]);
|
||||
});
|
||||
|
||||
// #10904
|
||||
it("leads with the baseline after a compaction that keeps assistant messages", async () => {
|
||||
const { root, message, append } = await setup();
|
||||
await message(user("old"));
|
||||
const kept = await message(user("kept"));
|
||||
await message(assistant("calling", { calls: ["a"] }));
|
||||
await message(toolResult("a"));
|
||||
await message(assistant("answer"));
|
||||
await append({ kind: "summary", head: kept.id, model: [user("summary")] });
|
||||
await message(user("next"));
|
||||
await message(system({ preamble: "Baseline." }), "pi.system");
|
||||
let view = await root.context(context);
|
||||
expect(view.messages.map(describeMessage)).toEqual([
|
||||
"system:preamble",
|
||||
"user:summary",
|
||||
"user:kept",
|
||||
"assistant:calling",
|
||||
"result:a:result a",
|
||||
"assistant:answer",
|
||||
"user:next",
|
||||
]);
|
||||
|
||||
// Later system messages stay in place.
|
||||
await message(assistant("done"));
|
||||
await message(system({ cwd: "/repo" }), "pi.system");
|
||||
view = await root.context(context);
|
||||
expect(view.messages.map(describeMessage).slice(0, 2)).toEqual(["system:preamble", "user:summary"]);
|
||||
expect(view.messages.map(describeMessage).slice(-2)).toEqual(["assistant:done", "system:cwd"]);
|
||||
});
|
||||
|
||||
// #10904
|
||||
it("leads with a baseline written after the first answers, when the agent had no section or tool before", async () => {
|
||||
const { root, message } = await setup();
|
||||
await message(user("first"), "pi.user");
|
||||
await message(assistant("answer"), "pi.assistant");
|
||||
await message(user("next"), "pi.user");
|
||||
await message(system({ plan: "Plan." }), "pi.system");
|
||||
const view = await root.context(context);
|
||||
expect(view.messages.map(describeMessage)).toEqual([
|
||||
"system:plan",
|
||||
"user:first",
|
||||
"assistant:answer",
|
||||
"user:next",
|
||||
]);
|
||||
});
|
||||
|
||||
// #10904
|
||||
it("keeps system messages other than the baseline in place once an assistant message precedes them", async () => {
|
||||
const { root, message, append } = await setup();
|
||||
await message(user("first"));
|
||||
await message(assistant("answer"));
|
||||
// Another kind's system message is positional; the baseline after it is not the first system message.
|
||||
await message(system({ note: "From here on." }), "host.note");
|
||||
await message(system({ preamble: "Baseline." }), "pi.system");
|
||||
let view = await root.context(context);
|
||||
expect(view.messages.map(describeMessage)).toEqual([
|
||||
"user:first",
|
||||
"assistant:answer",
|
||||
"system:note",
|
||||
"system:preamble",
|
||||
]);
|
||||
|
||||
// After a compaction that keeps the other kind's system message, the new baseline does not move ahead of it.
|
||||
const kept = await message(user("kept"));
|
||||
await message(assistant("kept answer"));
|
||||
await message(system({ mode: "plan" }), "host.note");
|
||||
await append({ kind: "summary", head: kept.id, model: [user("summary")] });
|
||||
await message(user("next"));
|
||||
await message(system({ preamble: "Baseline." }), "pi.system");
|
||||
view = await root.context(context);
|
||||
expect(view.messages.map(describeMessage)).toEqual([
|
||||
"user:summary",
|
||||
"user:kept",
|
||||
"assistant:kept answer",
|
||||
"system:mode",
|
||||
"user:next",
|
||||
"system:preamble",
|
||||
]);
|
||||
});
|
||||
|
||||
// #10904
|
||||
it("does not lead with a first system entry written after another system message, even once that is omitted", async () => {
|
||||
const { root, message, append } = await setup();
|
||||
const host = await message(system({ legacy: "Use the legacy format." }), "host.system");
|
||||
await message(user("first"));
|
||||
await message(assistant("answer"));
|
||||
// Generation wrote this entry against the host's message: it only removes a section, so it is no baseline.
|
||||
await message(system({ legacy: null }), "pi.system");
|
||||
await append({ kind: "edit", edits: [{ target: host.id, action: "omit" }] });
|
||||
let view = await root.context(context);
|
||||
expect(view.messages.map(describeMessage)).toEqual(["user:first", "assistant:answer", "system:legacy"]);
|
||||
|
||||
// The same holds for a system message an edit put before it.
|
||||
const other = await setup();
|
||||
const first = await other.message(user("first"));
|
||||
await other.append({
|
||||
kind: "edit",
|
||||
edits: [{ target: first.id, action: "replace", messages: [user("first"), system({ note: "Note." })] }],
|
||||
});
|
||||
await other.message(assistant("answer"));
|
||||
await other.message(system({ note: null }), "pi.system");
|
||||
await other.append({ kind: "edit", edits: [{ target: first.id, action: "replace", messages: [user("first")] }] });
|
||||
view = await other.root.context(context);
|
||||
expect(view.messages.map(describeMessage)).toEqual(["user:first", "assistant:answer", "system:note"]);
|
||||
});
|
||||
|
||||
// #10904
|
||||
it("does not lead with a retained, edited, or later system entry in place of the baseline", async () => {
|
||||
const { root, message, append } = await setup();
|
||||
const kept = await message(user("kept"));
|
||||
await message(assistant("answer"));
|
||||
const retained = await message(system({ cwd: "/repo" }), "pi.system");
|
||||
// Before generation writes the baseline after the head marker, a retained system entry stays in place.
|
||||
await append({ kind: "summary", head: kept.id, model: [user("summary")] });
|
||||
await message(user("next"));
|
||||
let view = await root.context(context);
|
||||
expect(view.messages.map(describeMessage)).toEqual([
|
||||
"user:summary",
|
||||
"user:kept",
|
||||
"assistant:answer",
|
||||
"system:cwd",
|
||||
"user:next",
|
||||
]);
|
||||
|
||||
// The baseline omits the retained entry, as generation writes it, and leads.
|
||||
const baseline = await append({
|
||||
kind: "pi.system",
|
||||
model: [system({ preamble: "Baseline." })],
|
||||
edits: [{ target: retained.id, action: "omit" }],
|
||||
});
|
||||
await message(assistant("done"));
|
||||
await message(system({ preamble: null }), "pi.system");
|
||||
view = await root.context(context);
|
||||
expect(view.messages.map(describeMessage)).toEqual([
|
||||
"system:preamble",
|
||||
"user:summary",
|
||||
"user:kept",
|
||||
"assistant:answer",
|
||||
"user:next",
|
||||
"assistant:done",
|
||||
"system:preamble",
|
||||
]);
|
||||
|
||||
// An omitted baseline leaves no message to lead; the delta after it is not a baseline and stays in place.
|
||||
await append({ kind: "edit", edits: [{ target: baseline.id, action: "omit" }] });
|
||||
view = await root.context(context);
|
||||
expect(view.messages.map(describeMessage)).toEqual([
|
||||
"user:summary",
|
||||
"user:kept",
|
||||
"assistant:answer",
|
||||
"user:next",
|
||||
"assistant:done",
|
||||
"system:preamble",
|
||||
]);
|
||||
|
||||
// A replacement of the baseline is not one either.
|
||||
await append({
|
||||
kind: "edit",
|
||||
edits: [{ target: baseline.id, action: "replace", messages: [system({ other: "Other." })] }],
|
||||
});
|
||||
view = await root.context(context);
|
||||
expect(view.messages.map(describeMessage)).toEqual([
|
||||
"user:summary",
|
||||
"user:kept",
|
||||
"assistant:answer",
|
||||
"user:next",
|
||||
"system:other",
|
||||
"assistant:done",
|
||||
"system:preamble",
|
||||
]);
|
||||
});
|
||||
|
||||
it("synthesizes missing tool results after a fork and drops results cut from their call", async () => {
|
||||
const { root, message } = await setup();
|
||||
await message(user("go"));
|
||||
const call = await message(assistant("calling", { calls: ["x", "y"] }));
|
||||
await message(toolResult("x"));
|
||||
const second = await message(toolResult("y"));
|
||||
const child = await root.fork(call.id, { ownership: { kind: "ownerless" } }, context);
|
||||
const childView = await child.context(context);
|
||||
expect(childView.messages.map(describeMessage)).toEqual([
|
||||
"user:go",
|
||||
"assistant:calling",
|
||||
"result:x:error",
|
||||
"result:y:error",
|
||||
]);
|
||||
const missing = childView.messages[2]!;
|
||||
expect(missing).toMatchObject({
|
||||
role: "toolResult",
|
||||
toolName: "tool-x",
|
||||
details: { reason: "missing_result" },
|
||||
});
|
||||
|
||||
// A head between a call and its results leaves stray results that are not sent.
|
||||
await root.commit((tx) => tx.appendEntry(root.id, { kind: "reset", head: second.id }), context);
|
||||
const parentView = await root.context(context);
|
||||
expect(parentView.messages.map(describeMessage)).toEqual([]);
|
||||
expect(parentView.entries.map((entry) => entry.kind)).toEqual(["reset", "message"]);
|
||||
});
|
||||
|
||||
// #10512
|
||||
it("reads the context as of an earlier entry, as a fork at that entry starts", async () => {
|
||||
const { root, append, message } = await setup();
|
||||
const first = await message(user("first"));
|
||||
const call = await message(assistant("calling", { calls: ["x", "y"] }));
|
||||
const result = await message(toolResult("x"));
|
||||
await message(toolResult("y"));
|
||||
const edit = await append({
|
||||
kind: "edit",
|
||||
edits: [{ target: first.id, action: "replace", messages: [user("first v2")] }],
|
||||
});
|
||||
const reset = await append({ kind: "reset", head: "self", model: [user("fresh start")] });
|
||||
const tail = await message(assistant("after reset"));
|
||||
|
||||
for (const at of [first, call, result, edit, reset, tail]) {
|
||||
const fork = await root.fork(at.id, { ownership: { kind: "ownerless" } }, context);
|
||||
expect(await root.context(context, { at: at.id })).toEqual(await fork.context(context));
|
||||
}
|
||||
// Stepped back before the edit and the reset: neither applies, and a cut call gets synthesized results.
|
||||
expect((await root.context(context, { at: call.id })).messages.map(describeMessage)).toEqual([
|
||||
"user:first",
|
||||
"assistant:calling",
|
||||
"result:x:error",
|
||||
"result:y:error",
|
||||
]);
|
||||
expect(await root.context(context, { at: tail.id })).toEqual(await root.context(context));
|
||||
expect(await root.context(context, {})).toEqual(await root.context(context));
|
||||
|
||||
const other = await root.fork(first.id, { ownership: { kind: "ownerless" } }, context);
|
||||
await expect(other.context(context, { at: tail.id })).rejects.toThrow("is not visible");
|
||||
});
|
||||
|
||||
it("extends a task's context read with only newer entries", async () => {
|
||||
const { harness, registry, root, first, scanned } = await countingSetup();
|
||||
const Reads = defineTask<Record<string, never>, { phase: "run" }, null>({
|
||||
name: "test.context-reads",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "run" }),
|
||||
phases: {
|
||||
run: async (_task, runtime, taskContext) => {
|
||||
const write = (draft: EntryDraft) =>
|
||||
runtime.commit(async (tx) => {
|
||||
await tx.appendEntry(root.id, draft);
|
||||
return undefined;
|
||||
}, taskContext);
|
||||
const read = async (at?: EntryId): Promise<{ view: ContextView; rows: number }> => {
|
||||
const before = scanned.rows;
|
||||
const view = await runtime.context(root.id, taskContext, { at });
|
||||
return { view, rows: scanned.rows - before };
|
||||
};
|
||||
const initial = await read();
|
||||
expect(initial.view).toEqual(await root.context(taskContext));
|
||||
|
||||
// Three new entries: the bounds probe reads one row, the extension the three new ones.
|
||||
await write({ kind: "message", model: [user("new")] });
|
||||
await write({ kind: "note", data: { text: "display only" } });
|
||||
await write({ kind: "message", model: [assistant("answer")] });
|
||||
const extended = await read();
|
||||
expect(extended.rows).toBe(4);
|
||||
expect(extended.view).toEqual(await root.context(taskContext));
|
||||
|
||||
// A newer edit of an older entry applies to the extended range.
|
||||
await write({ kind: "edit", edits: [{ target: first.id, action: "omit" }] });
|
||||
const edited = await read();
|
||||
expect(edited.rows).toBe(2);
|
||||
expect(edited.view).toEqual(await root.context(taskContext));
|
||||
expect(edited.view.messages.map(describeMessage)).not.toContain("user:first");
|
||||
|
||||
// An earlier cutoff reuses the range.
|
||||
const cutoff = await read(extended.view.entries.at(-1)!.id);
|
||||
expect(cutoff.rows).toBe(0);
|
||||
expect(cutoff.view).toEqual(extended.view);
|
||||
|
||||
// A new head marker changes the range: read it whole.
|
||||
await write({ kind: "reset", head: "self", model: [user("fresh")] });
|
||||
const reset = await read();
|
||||
expect(reset.view).toEqual(await root.context(taskContext));
|
||||
expect(reset.view.messages.map(describeMessage)).toEqual(["user:fresh"]);
|
||||
|
||||
await runtime.commit(
|
||||
() => ({ status: "terminal", outcome: { status: "completed", result: null } }),
|
||||
taskContext,
|
||||
);
|
||||
},
|
||||
},
|
||||
abort: async (_task, runtime, taskContext) => {
|
||||
await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);
|
||||
},
|
||||
});
|
||||
addTask(registry, Reads);
|
||||
const id = await harness.commit(
|
||||
(tx) => tx.createTask(Reads, {}, { ownership: { kind: "conversation" }, conversationId: root.id }),
|
||||
context,
|
||||
);
|
||||
harness.resume();
|
||||
const settled = await harness.waitForTask(id, context);
|
||||
expect(settled.state.outcome).toEqual({ status: "completed", result: null });
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("keeps a conversation's context read across its tasks and for the retention period once idle", async () => {
|
||||
let clock = 1_000;
|
||||
const { harness, registry, root, scanned } = await countingSetup({ now: () => clock });
|
||||
const rows: Record<string, number> = {};
|
||||
const read = async (name: string, load: () => Promise<ContextView>, taskContext: Context) => {
|
||||
const before = scanned.rows;
|
||||
const view = await load();
|
||||
rows[name] = scanned.rows - before;
|
||||
expect(view).toEqual(await root.context(taskContext));
|
||||
};
|
||||
const Child = defineTask<Record<string, never>, { phase: "run" }, null>({
|
||||
name: "test.context-child",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "run" }),
|
||||
phases: {
|
||||
run: async (_task, runtime, taskContext) => {
|
||||
await runtime.commit(async (tx) => {
|
||||
await tx.appendEntry(root.id, { kind: "message", model: [user("from child")] });
|
||||
return undefined;
|
||||
}, taskContext);
|
||||
await read("child", () => runtime.context(root.id, taskContext), taskContext);
|
||||
await runtime.commit(() => DONE, taskContext);
|
||||
},
|
||||
},
|
||||
abort: async (_task, runtime, taskContext) => {
|
||||
await runtime.commit(() => ABORTED, taskContext);
|
||||
},
|
||||
});
|
||||
const Parent = defineTask<Record<string, never>, { phase: "start" } | { phase: "after" }, null>({
|
||||
name: "test.context-parent",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "start" }),
|
||||
phases: {
|
||||
start: async (_task, runtime, taskContext) => {
|
||||
await read("parent", () => runtime.context(root.id, taskContext), taskContext);
|
||||
await runtime.commit(async (tx) => {
|
||||
const child = await tx.createTask(
|
||||
Child,
|
||||
{},
|
||||
{ ownership: { kind: "task", taskId: runtime.taskId }, conversationId: root.id },
|
||||
);
|
||||
return { status: "waiting", checkpoint: { phase: "after" }, on: [child], policy: "allSettled" };
|
||||
}, taskContext);
|
||||
},
|
||||
after: async (_task, runtime, taskContext) => {
|
||||
await read("after", () => runtime.context(root.id, taskContext), taskContext);
|
||||
await runtime.commit(() => DONE, taskContext);
|
||||
},
|
||||
},
|
||||
abort: async (_task, runtime, taskContext) => {
|
||||
await runtime.commit(() => ABORTED, taskContext);
|
||||
},
|
||||
});
|
||||
const Probe = probeTask(read);
|
||||
for (const task of [Child, Parent, Probe]) addTask(registry, task);
|
||||
harness.resume();
|
||||
const options = { ownership: { kind: "conversation" }, conversationId: root.id } as const;
|
||||
const parent = await harness.commit((tx) => tx.createTask(Parent, {}, options), context);
|
||||
expect((await harness.waitForTask(parent, context)).state.outcome).toEqual(DONE.outcome);
|
||||
await root.waitForIdle(context);
|
||||
const probe = async (name: string) => {
|
||||
const id = await harness.commit((tx) => tx.createTask(Probe, { name }, options), context);
|
||||
expect((await harness.waitForTask(id, context)).state.outcome).toEqual(DONE.outcome);
|
||||
await root.waitForIdle(context);
|
||||
};
|
||||
await probe("idle");
|
||||
clock += 600_000;
|
||||
await probe("expired");
|
||||
// Bounds probes read one row each. The child scans its own new entry; the waiting parent's later invocation and
|
||||
// the probe within the retention period scan nothing new. After it, the probe reads the whole transcript again.
|
||||
expect(rows).toEqual({ parent: 22, child: 2, after: 1, idle: 1, expired: 23 });
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("derives the same view from extended reads as from whole reads", async () => {
|
||||
const { harness, registry, root } = await countingSetup();
|
||||
// Deterministic pseudo-random transcript (32-bit LCG, high bits): calls and results in any order, missing and
|
||||
// stray results, excluded stop reasons, system entries, notes, edits of earlier entries, and head markers.
|
||||
let seed = 7;
|
||||
const random = (n: number) => {
|
||||
seed = (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0;
|
||||
return Math.floor((seed / 2 ** 32) * n);
|
||||
};
|
||||
const seen = new Map<string, number>();
|
||||
const count = (category: string) => seen.set(category, (seen.get(category) ?? 0) + 1);
|
||||
const Steps = defineTask<Record<string, never>, { phase: "run" }, null>({
|
||||
name: "test.context-steps",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "run" }),
|
||||
phases: {
|
||||
run: async (_task, runtime, taskContext) => {
|
||||
const written: EntryRecord[] = [];
|
||||
const calls: string[] = [];
|
||||
let next = 0;
|
||||
const draft = (): EntryDraft => {
|
||||
const pick = random(14);
|
||||
if (pick < 3) {
|
||||
count("user");
|
||||
return { kind: "message", model: [user(`u${next++}`)] };
|
||||
}
|
||||
if (pick < 5) {
|
||||
const ids = Array.from({ length: random(3) }, () => `c${next++}`);
|
||||
calls.push(...ids);
|
||||
const stopReason = (["aborted", "error", "deferred"] as const)[random(9)];
|
||||
count(stopReason ?? "assistant");
|
||||
return {
|
||||
kind: "message",
|
||||
model: [assistant(`a${next++}`, { calls: ids, ...(stopReason ? { stopReason } : {}) })],
|
||||
};
|
||||
}
|
||||
if (pick < 8) {
|
||||
const matched = calls.length > 0 && random(5) > 0;
|
||||
count(matched ? "result" : "stray result");
|
||||
const id = matched ? calls.splice(random(calls.length), 1)[0]! : `stray${next++}`;
|
||||
return { kind: "message", model: [toolResult(id)] };
|
||||
}
|
||||
if (pick < 9) {
|
||||
// `pi.system` entries can be the baseline that leads the context (#10904).
|
||||
const kind = random(2) === 0 ? "pi.system" : "message";
|
||||
count(kind === "pi.system" ? "baseline system" : "system");
|
||||
return { kind, model: [system({ s: `v${next++}` })] };
|
||||
}
|
||||
if (pick < 10) {
|
||||
count("note");
|
||||
return { kind: "note", data: { n: next++ } };
|
||||
}
|
||||
const target = written[random(written.length)];
|
||||
if (pick < 12 && target !== undefined) {
|
||||
const omit = random(2) === 0;
|
||||
count(omit ? "omit" : "replace");
|
||||
const action = omit
|
||||
? { action: "omit" as const }
|
||||
: { action: "replace" as const, messages: [user(`r${next++}`)] };
|
||||
return { kind: "edit", edits: [{ target: target.id, ...action }] };
|
||||
}
|
||||
if (pick < 13 && target !== undefined) {
|
||||
count("head");
|
||||
return { kind: "summary", head: target.id, model: [user(`h${next++}`)] };
|
||||
}
|
||||
count("user");
|
||||
return { kind: "message", model: [user(`u${next++}`)] };
|
||||
};
|
||||
const append = (conversationId: typeof root.id) =>
|
||||
runtime.commit(async (tx) => {
|
||||
const added = 1 + random(3);
|
||||
for (let index = 0; index < added; index++) {
|
||||
written.push(await tx.appendEntry(conversationId, draft()));
|
||||
}
|
||||
return undefined;
|
||||
}, taskContext);
|
||||
for (let step = 0; step < 150; step++) {
|
||||
await append(root.id);
|
||||
const whole = await root.context(taskContext);
|
||||
expect(await runtime.context(root.id, taskContext)).toEqual(whole);
|
||||
const contributed = whole.contributions.flat();
|
||||
const first = contributed.findIndex((message) => message.role === "system");
|
||||
if (
|
||||
first > 0 &&
|
||||
whole.messages[0] === contributed[first] &&
|
||||
contributed.slice(0, first).some((message) => message.role === "assistant")
|
||||
) {
|
||||
count("baseline past assistant");
|
||||
}
|
||||
if (step % 10 === 9) {
|
||||
// Overlapping reads, one with an earlier cutoff, share the kept range.
|
||||
const at = written[random(written.length)]!.id;
|
||||
const [whole, cut] = await Promise.all([
|
||||
runtime.context(root.id, taskContext),
|
||||
runtime.context(root.id, taskContext, { at }),
|
||||
]);
|
||||
expect(whole).toEqual(await root.context(taskContext));
|
||||
expect(cut).toEqual(await root.context(taskContext, { at }));
|
||||
}
|
||||
}
|
||||
// A fork sees its parent's entries through the fork point and extends with its own.
|
||||
const fork = await root.fork(
|
||||
written[random(written.length)]!.id,
|
||||
{ ownership: { kind: "ownerless" } },
|
||||
taskContext,
|
||||
);
|
||||
for (let step = 0; step < 20; step++) {
|
||||
expect(await runtime.context(fork.id, taskContext)).toEqual(await fork.context(taskContext));
|
||||
await append(fork.id);
|
||||
}
|
||||
await runtime.commit(() => DONE, taskContext);
|
||||
},
|
||||
},
|
||||
abort: async (_task, runtime, taskContext) => {
|
||||
await runtime.commit(() => ABORTED, taskContext);
|
||||
},
|
||||
});
|
||||
addTask(registry, Steps);
|
||||
harness.resume();
|
||||
const id = await harness.commit(
|
||||
(tx) => tx.createTask(Steps, {}, { ownership: { kind: "conversation" }, conversationId: root.id }),
|
||||
context,
|
||||
);
|
||||
expect((await harness.waitForTask(id, context)).state.outcome).toEqual(DONE.outcome);
|
||||
const categories = [
|
||||
"user",
|
||||
"assistant",
|
||||
"aborted",
|
||||
"error",
|
||||
"deferred",
|
||||
"result",
|
||||
"stray result",
|
||||
"system",
|
||||
"baseline system",
|
||||
"baseline past assistant",
|
||||
"note",
|
||||
];
|
||||
for (const category of [...categories, "omit", "replace", "head"]) expect(seen.get(category)).toBeGreaterThan(0);
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
// #10904
|
||||
it("finds the baseline among the entries an extended read adds", async () => {
|
||||
const { harness, registry, root, first, scanned } = await countingSetup();
|
||||
const Steps = defineTask<Record<string, never>, { phase: "run" }, null>({
|
||||
name: "test.context-baseline",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "run" }),
|
||||
phases: {
|
||||
run: async (_task, runtime, taskContext) => {
|
||||
const append = (conversationId: typeof root.id, draft: EntryDraft) =>
|
||||
runtime.commit(async (tx) => {
|
||||
await tx.appendEntry(conversationId, draft);
|
||||
return undefined;
|
||||
}, taskContext);
|
||||
const read = async (conversation: typeof root) => {
|
||||
const before = scanned.rows;
|
||||
const view = await runtime.context(conversation.id, taskContext);
|
||||
const rows = scanned.rows - before;
|
||||
expect(view).toEqual(await conversation.context(taskContext));
|
||||
return { messages: view.messages.map(describeMessage), rows };
|
||||
};
|
||||
|
||||
// The cached range has answers and no system message; the baseline arrives in a later read.
|
||||
expect((await read(root)).messages[0]).toBe("user:first");
|
||||
await append(root.id, { kind: "pi.system", model: [system({ plan: "Plan." })] });
|
||||
let read2 = await read(root);
|
||||
// One bounds probe and the added entry: the range was extended, not derived again.
|
||||
expect(read2.rows).toBe(2);
|
||||
expect(read2.messages.slice(0, 3)).toEqual(["system:plan", "user:first", "assistant:old 0"]);
|
||||
|
||||
// A system message the cached range already has keeps a later first `pi.system` entry in place.
|
||||
const fork = await root.fork(first.id, { ownership: { kind: "ownerless" } }, taskContext);
|
||||
await append(fork.id, { kind: "message", model: [assistant("answer")] });
|
||||
await append(fork.id, { kind: "host.system", model: [system({ note: "Note." })] });
|
||||
expect((await read(fork)).messages).toEqual(["user:first", "assistant:answer", "system:note"]);
|
||||
await append(fork.id, { kind: "pi.system", model: [system({ note: null })] });
|
||||
read2 = await read(fork);
|
||||
expect(read2.rows).toBe(2);
|
||||
expect(read2.messages).toEqual(["user:first", "assistant:answer", "system:note", "system:note"]);
|
||||
|
||||
// An omitted system message still rules out a later baseline; the extended read keeps that.
|
||||
const omitted = await root.fork(first.id, { ownership: { kind: "ownerless" } }, taskContext);
|
||||
await append(omitted.id, { kind: "message", model: [assistant("answer")] });
|
||||
let host: EntryRecord | undefined;
|
||||
await runtime.commit(async (tx) => {
|
||||
host = await tx.appendEntry(omitted.id, {
|
||||
kind: "host.system",
|
||||
model: [system({ legacy: "Legacy." })],
|
||||
});
|
||||
return undefined;
|
||||
}, taskContext);
|
||||
await append(omitted.id, { kind: "edit", edits: [{ target: host!.id, action: "omit" }] });
|
||||
expect((await read(omitted)).messages).toEqual(["user:first", "assistant:answer"]);
|
||||
await append(omitted.id, { kind: "pi.system", model: [system({ legacy: null })] });
|
||||
read2 = await read(omitted);
|
||||
expect(read2.rows).toBe(2);
|
||||
expect(read2.messages).toEqual(["user:first", "assistant:answer", "system:legacy"]);
|
||||
|
||||
// A first `pi.system` entry without a system message leaves no baseline for a later one to take.
|
||||
const empty = await root.fork(first.id, { ownership: { kind: "ownerless" } }, taskContext);
|
||||
await append(empty.id, { kind: "pi.system" });
|
||||
await append(empty.id, { kind: "message", model: [assistant("answer")] });
|
||||
expect((await read(empty)).messages).toEqual(["user:first", "assistant:answer"]);
|
||||
await append(empty.id, { kind: "pi.system", model: [system({ s: "Delta." })] });
|
||||
read2 = await read(empty);
|
||||
expect(read2.rows).toBe(2);
|
||||
expect(read2.messages).toEqual(["user:first", "assistant:answer", "system:s"]);
|
||||
await runtime.commit(() => DONE, taskContext);
|
||||
},
|
||||
},
|
||||
abort: async (_task, runtime, taskContext) => {
|
||||
await runtime.commit(() => ABORTED, taskContext);
|
||||
},
|
||||
});
|
||||
addTask(registry, Steps);
|
||||
harness.resume();
|
||||
const id = await harness.commit(
|
||||
(tx) => tx.createTask(Steps, {}, { ownership: { kind: "conversation" }, conversationId: root.id }),
|
||||
context,
|
||||
);
|
||||
expect((await harness.waitForTask(id, context)).state.outcome).toEqual(DONE.outcome);
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("keeps returned views and kept ranges apart", async () => {
|
||||
const { harness, registry, root } = await countingSetup();
|
||||
const Mutate = defineTask<Record<string, never>, { phase: "run" }, null>({
|
||||
name: "test.context-mutate",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "run" }),
|
||||
phases: {
|
||||
run: async (_task, runtime, taskContext) => {
|
||||
const view = await runtime.context(root.id, taskContext);
|
||||
(view.messages as Message[]).length = 0;
|
||||
(view.entries as EntryRecord[]).pop();
|
||||
// Kept entries are frozen like MemoryStorage records.
|
||||
expect(() => {
|
||||
(view.contributions[0]![0] as { content: unknown }).content = "changed";
|
||||
}).toThrow(TypeError);
|
||||
expect(await runtime.context(root.id, taskContext)).toEqual(await root.context(taskContext));
|
||||
await runtime.commit(() => DONE, taskContext);
|
||||
},
|
||||
},
|
||||
abort: async (_task, runtime, taskContext) => {
|
||||
await runtime.commit(() => ABORTED, taskContext);
|
||||
},
|
||||
});
|
||||
addTask(registry, Mutate);
|
||||
harness.resume();
|
||||
const id = await harness.commit(
|
||||
(tx) => tx.createTask(Mutate, {}, { ownership: { kind: "conversation" }, conversationId: root.id }),
|
||||
context,
|
||||
);
|
||||
expect((await harness.waitForTask(id, context)).state.outcome).toEqual(DONE.outcome);
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("freezes kept entries deeply even when storage freezes them shallowly", async () => {
|
||||
const memory = new MemoryStorage();
|
||||
// Fresh copies with only the record itself frozen; their messages stay mutable.
|
||||
const storage = new Proxy<Storage>(memory, {
|
||||
get(target, key) {
|
||||
const value: unknown = Reflect.get(target, key, target);
|
||||
if (key === "scanEntries") {
|
||||
return async (...args: Parameters<Storage["scanEntries"]>) => {
|
||||
const page = await target.scanEntries(...args);
|
||||
return { ...page, items: page.items.map((item) => Object.freeze(structuredClone(item))) };
|
||||
};
|
||||
}
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
const registry = createRegistry();
|
||||
const harness = await Harness.open(storage, { models: createModels(), registry }, context);
|
||||
const root = await harness.root(context);
|
||||
await root.commit((tx) => tx.appendEntry(root.id, { kind: "message", model: [user("hello")] }), context);
|
||||
const Mutate = defineTask<Record<string, never>, { phase: "run" }, null>({
|
||||
name: "test.context-shallow",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "run" }),
|
||||
phases: {
|
||||
run: async (_task, runtime, taskContext) => {
|
||||
const view = await runtime.context(root.id, taskContext);
|
||||
expect(() => {
|
||||
(view.messages[0] as { content: unknown }).content = "changed";
|
||||
}).toThrow(TypeError);
|
||||
await runtime.commit(() => DONE, taskContext);
|
||||
},
|
||||
},
|
||||
abort: async (_task, runtime, taskContext) => {
|
||||
await runtime.commit(() => ABORTED, taskContext);
|
||||
},
|
||||
});
|
||||
addTask(registry, Mutate);
|
||||
harness.resume();
|
||||
const id = await harness.commit(
|
||||
(tx) => tx.createTask(Mutate, {}, { ownership: { kind: "conversation" }, conversationId: root.id }),
|
||||
context,
|
||||
);
|
||||
expect((await harness.waitForTask(id, context)).state.outcome).toEqual(DONE.outcome);
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("does not keep a read that finishes after its task ended", async () => {
|
||||
const { harness, registry, root, scanned, hold } = await countingSetup({ settings: { contextRetentionMs: 0 } });
|
||||
const scans = Promise.withResolvers<void>();
|
||||
const probeStart = Promise.withResolvers<void>();
|
||||
let late: Promise<unknown> | undefined;
|
||||
const Late = defineTask<Record<string, never>, { phase: "run" }, null>({
|
||||
name: "test.context-late",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "run" }),
|
||||
phases: {
|
||||
run: async (_task, runtime, taskContext) => {
|
||||
hold.scan = scans.promise;
|
||||
late = runtime.context(root.id, taskContext).catch((error: unknown) => error);
|
||||
await runtime.commit(() => DONE, taskContext);
|
||||
},
|
||||
},
|
||||
abort: async (_task, runtime, taskContext) => {
|
||||
await runtime.commit(() => ABORTED, taskContext);
|
||||
},
|
||||
});
|
||||
const rows: Record<string, number> = {};
|
||||
const Probe = probeTask(async (name, load) => {
|
||||
await probeStart.promise;
|
||||
const before = scanned.rows;
|
||||
await load();
|
||||
rows[name] = scanned.rows - before;
|
||||
});
|
||||
for (const task of [Late, Probe]) addTask(registry, task);
|
||||
harness.resume();
|
||||
const options = { ownership: { kind: "conversation" }, conversationId: root.id } as const;
|
||||
const lateId = await harness.commit((tx) => tx.createTask(Late, {}, options), context);
|
||||
expect((await harness.waitForTask(lateId, context)).state.outcome).toEqual(DONE.outcome);
|
||||
// The conversation is busy again when the late read finishes; its range must not be kept.
|
||||
const probeId = await harness.commit((tx) => tx.createTask(Probe, { name: "probe" }, options), context);
|
||||
scans.resolve();
|
||||
await late;
|
||||
probeStart.resolve();
|
||||
expect((await harness.waitForTask(probeId, context)).state.outcome).toEqual(DONE.outcome);
|
||||
expect(rows).toEqual({ probe: 22 });
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("drops an idle conversation's context read on a timer when nothing else runs", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
|
||||
const { harness, registry, root, scanned } = await countingSetup({ settings: { contextRetentionMs: 1_000 } });
|
||||
const rows: Record<string, number> = {};
|
||||
const Probe = probeTask(async (name, load) => {
|
||||
const before = scanned.rows;
|
||||
await load();
|
||||
rows[name] = scanned.rows - before;
|
||||
});
|
||||
addTask(registry, Probe);
|
||||
harness.resume();
|
||||
const options = { ownership: { kind: "conversation" }, conversationId: root.id } as const;
|
||||
const probe = async (name: string) => {
|
||||
const id = await harness.commit((tx) => tx.createTask(Probe, { name }, options), context);
|
||||
expect((await harness.waitForTask(id, context)).state.outcome).toEqual(DONE.outcome);
|
||||
await root.waitForIdle(context);
|
||||
};
|
||||
const idle = vi.getTimerCount();
|
||||
await probe("first");
|
||||
expect(vi.getTimerCount()).toBe(idle + 1);
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(vi.getTimerCount()).toBe(idle);
|
||||
await probe("second");
|
||||
expect(rows).toEqual({ first: 22, second: 22 });
|
||||
await harness.close(context);
|
||||
});
|
||||
|
||||
it("drops a conversation's context read once idle with zero retention", async () => {
|
||||
const { harness, registry, root, scanned } = await countingSetup({ settings: { contextRetentionMs: 0 } });
|
||||
const rows: Record<string, number> = {};
|
||||
const Probe = probeTask(async (name, load) => {
|
||||
const before = scanned.rows;
|
||||
await load();
|
||||
rows[name] = scanned.rows - before;
|
||||
});
|
||||
addTask(registry, Probe);
|
||||
harness.resume();
|
||||
const options = { ownership: { kind: "conversation" }, conversationId: root.id } as const;
|
||||
for (const name of ["first", "second"]) {
|
||||
const id = await harness.commit((tx) => tx.createTask(Probe, { name }, options), context);
|
||||
expect((await harness.waitForTask(id, context)).state.outcome).toEqual(DONE.outcome);
|
||||
await root.waitForIdle(context);
|
||||
}
|
||||
expect(rows).toEqual({ first: 22, second: 22 });
|
||||
await harness.close(context);
|
||||
});
|
||||
});
|
||||
|
||||
const DONE = { status: "terminal", outcome: { status: "completed", result: null } } as const;
|
||||
|
||||
const ABORTED = { status: "terminal", outcome: { status: "aborted" } } as const;
|
||||
|
||||
/** A task that reads its conversation's context once, named by its input. */
|
||||
function probeTask(read: (name: string, load: () => Promise<ContextView>, taskContext: Context) => Promise<void>) {
|
||||
return defineTask<{ name: string }, { phase: "run" }, null>({
|
||||
name: "test.context-probe",
|
||||
version: 1,
|
||||
initial: () => ({ phase: "run" }),
|
||||
phases: {
|
||||
run: async (task, runtime, taskContext) => {
|
||||
await read(task.input.name, () => runtime.context(runtime.conversationId, taskContext), taskContext);
|
||||
await runtime.commit(() => DONE, taskContext);
|
||||
},
|
||||
},
|
||||
abort: async (_task, runtime, taskContext) => {
|
||||
await runtime.commit(() => ABORTED, taskContext);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user