Squashed 'third_party/pi/' content from commit d981de122
git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
677
packages/agent/test/harness/runtime/accept.test.ts
Normal file
677
packages/agent/test/harness/runtime/accept.test.ts
Normal file
@@ -0,0 +1,677 @@
|
||||
import { createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { type HarnessEvent, HarnessFault, type OperationRequest } from "../../../src/harness/agent-harness.ts";
|
||||
import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/harness/compaction/compaction.ts";
|
||||
import { BACKGROUND_CONTEXT, createContextKey, withContextValue } from "../../../src/harness/context.ts";
|
||||
import type { Result } from "../../../src/harness/result.ts";
|
||||
import { createAgentHarness, Harness } from "../../../src/harness/runtime/harness.ts";
|
||||
import { Lane } from "../../../src/harness/runtime/lane.ts";
|
||||
import { MemoryStorage } from "../../../src/harness/session/memory.ts";
|
||||
import { StorageBackedSession } from "../../../src/harness/session/session.ts";
|
||||
import { InstrumentedStorage } from "../../../src/harness/session/testing/index.ts";
|
||||
import * as storedValues from "../../../src/harness/session/values.ts";
|
||||
import type { AgentMessage, Session } from "../../../src/index.ts";
|
||||
import { ControlledMemoryStorage, deferred, FailingMemoryStorage } from "./test-utils.ts";
|
||||
|
||||
const sessions: Session[] = [];
|
||||
const configuration = {
|
||||
model: { provider: "faux", modelId: "faux-1" },
|
||||
thinkingLevel: "off" as const,
|
||||
activeToolNames: [],
|
||||
};
|
||||
|
||||
async function createSession(storage = new InstrumentedStorage(new MemoryStorage())): Promise<{
|
||||
session: Session;
|
||||
storage: InstrumentedStorage;
|
||||
}> {
|
||||
const session = new StorageBackedSession(
|
||||
{ id: `accept-${sessions.length}`, createdAt: 1, storageVersion: 1 },
|
||||
storage,
|
||||
);
|
||||
sessions.push(session);
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.branchTip("main"), null),
|
||||
storedValues.setValue(storedValues.laneConfig("main"), configuration),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
return { session, storage };
|
||||
}
|
||||
|
||||
function options(session: Session) {
|
||||
const provider = fauxProvider();
|
||||
const models = createModels();
|
||||
models.setProvider(provider.provider);
|
||||
return { session, models, model: provider.getModel() };
|
||||
}
|
||||
|
||||
async function createHarness(
|
||||
beforeCreate?: (session: Session) => Promise<void>,
|
||||
resources: Parameters<typeof createAgentHarness>[0]["resources"] = {},
|
||||
queueModes: { steeringMode?: "all" | "one-at-a-time"; followUpMode?: "all" | "one-at-a-time" } = {},
|
||||
): Promise<{
|
||||
harness: Harness<object | undefined>;
|
||||
lane: Lane<object | undefined>;
|
||||
session: Session;
|
||||
storage: InstrumentedStorage;
|
||||
models: ReturnType<typeof createModels>;
|
||||
}> {
|
||||
const created = await createSession();
|
||||
await beforeCreate?.(created.session);
|
||||
const harnessOptions = { ...options(created.session), resources, ...queueModes };
|
||||
const { harness } = await createAgentHarness(harnessOptions, BACKGROUND_CONTEXT);
|
||||
if (!(harness instanceof Harness)) throw new Error("Expected runtime Harness");
|
||||
const lane = await harness.lane("main", BACKGROUND_CONTEXT);
|
||||
if (!(lane instanceof Lane)) throw new Error("Expected runtime Lane");
|
||||
created.storage.clearCommitAttempts();
|
||||
return { harness, lane, ...created, models: harnessOptions.models };
|
||||
}
|
||||
|
||||
function unwrap<T>(result: Result<T, unknown>): T {
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const session of sessions.splice(0)) await session.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
describe("runtime atomic run acceptance", () => {
|
||||
it.each([
|
||||
["text", { kind: "prompt", prompt: "hello" } satisfies OperationRequest, [{ type: "text", text: "hello" }]],
|
||||
[
|
||||
"images",
|
||||
{
|
||||
kind: "prompt",
|
||||
prompt: "",
|
||||
images: [{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" }],
|
||||
} satisfies OperationRequest,
|
||||
[{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" }],
|
||||
],
|
||||
[
|
||||
"text and images",
|
||||
{
|
||||
kind: "prompt",
|
||||
prompt: "hello",
|
||||
images: [{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" }],
|
||||
} satisfies OperationRequest,
|
||||
[
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "image", data: "aW1hZ2U=", mimeType: "image/png" },
|
||||
],
|
||||
],
|
||||
] as const)("accepts normalized %s prompts into starting", async (_name, request, expectedContent) => {
|
||||
const { lane, session } = await createHarness();
|
||||
|
||||
const admission = unwrap(await lane.accept(request, BACKGROUND_CONTEXT));
|
||||
const operation = lane.state.operation;
|
||||
if (operation?.state.at !== "starting") throw new Error("Expected accepted run");
|
||||
const entryId = operation.meta.intent.kind === "run" ? operation.meta.intent.promptEntryIds[0] : undefined;
|
||||
if (entryId === undefined) throw new Error("Expected prompt entry");
|
||||
const entry = await session.getEntry(entryId, BACKGROUND_CONTEXT);
|
||||
|
||||
expect(admission).toMatchObject({ operationId: operation.meta.operationId, kind: "run" });
|
||||
expect(entry).toMatchObject({ type: "message", message: { role: "user", content: expectedContent } });
|
||||
expect(operation.state.settings).toEqual({
|
||||
compaction: DEFAULT_COMPACTION_SETTINGS,
|
||||
steeringMode: "all",
|
||||
followUpMode: "all",
|
||||
toolExecution: "parallel",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves supplied message arrays and commits the exact acceptance write families once", async () => {
|
||||
const { harness, lane, storage, models } = await createHarness();
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: "user", content: "one", timestamp: 10 },
|
||||
{ role: "user", content: "two", timestamp: 11 },
|
||||
];
|
||||
const getModel = vi.spyOn(models, "getModel");
|
||||
const contextKey = createContextKey<string>("accept.context");
|
||||
const context = withContextValue(contextKey, "source", BACKGROUND_CONTEXT);
|
||||
const seen: Array<{ event: HarnessEvent; sameContext: boolean }> = [];
|
||||
for (const type of ["run_start", "message_start", "message_end", "entry_added"] as const) {
|
||||
harness.events.on(type, (event, eventContext) => {
|
||||
seen.push({ event, sameContext: eventContext === context });
|
||||
});
|
||||
}
|
||||
|
||||
const admission = unwrap(
|
||||
await lane.accept({ kind: "prompt", operationId: "operation", prompt: messages }, context),
|
||||
);
|
||||
|
||||
expect(admission).toEqual({ operationId: "operation", kind: "run", startedAt: expect.any(Number) });
|
||||
expect(storage.getCommitAttempts()).toHaveLength(1);
|
||||
expect(
|
||||
storage
|
||||
.getCommitAttempts()[0]
|
||||
?.map((write) => (write.kind === "value" ? `${write.kind}:${write.op}` : write.kind)),
|
||||
).toEqual(["entry", "entry", "value:set", "value:set", "value:set", "value:set"]);
|
||||
const operation = lane.state.operation;
|
||||
if (operation?.meta.intent.kind !== "run") throw new Error("Expected run metadata");
|
||||
expect(operation.meta).toMatchObject({
|
||||
operationId: "operation",
|
||||
lane: "main",
|
||||
sourceTipId: null,
|
||||
intent: { promptEntryIds: expect.any(Array) },
|
||||
});
|
||||
expect(operation.meta.intent.promptEntryIds).toHaveLength(2);
|
||||
expect(await lane.getTipId(BACKGROUND_CONTEXT)).toBe(operation.meta.intent.promptEntryIds[1]);
|
||||
expect(seen.map(({ event }) => event.type)).toEqual([
|
||||
"run_start",
|
||||
"message_start",
|
||||
"message_end",
|
||||
"entry_added",
|
||||
"message_start",
|
||||
"message_end",
|
||||
"entry_added",
|
||||
]);
|
||||
expect(seen.every(({ sameContext }) => sameContext)).toBe(true);
|
||||
expect(getModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("captures next-run messages before request messages and accepts an otherwise empty request", async () => {
|
||||
const first = "pending-first";
|
||||
const second = "pending-second";
|
||||
const firstMessage = { role: "user" as const, content: "first", timestamp: 1 };
|
||||
const secondMessage = { role: "user" as const, content: "second", timestamp: 2 };
|
||||
const { harness, lane, session, storage } = await createHarness(async (source) => {
|
||||
await source.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.pendingEntry(first), {
|
||||
type: "message",
|
||||
payload: firstMessage,
|
||||
}),
|
||||
storedValues.setValue(storedValues.pendingEntry(second), {
|
||||
type: "message",
|
||||
payload: secondMessage,
|
||||
}),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: [
|
||||
{ entryId: first, kind: "nextRun" },
|
||||
{ entryId: second, kind: "nextRun" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
});
|
||||
const events: string[] = [];
|
||||
harness.events.on("queue_update", (event) => {
|
||||
events.push(event.type);
|
||||
});
|
||||
|
||||
unwrap(await lane.accept({ kind: "prompt", prompt: "" }, BACKGROUND_CONTEXT));
|
||||
|
||||
const operation = lane.state.operation;
|
||||
if (operation?.meta.intent.kind !== "run" || operation.state.at !== "starting") {
|
||||
throw new Error("Expected accepted run");
|
||||
}
|
||||
expect(operation.meta.intent.promptEntryIds).toEqual([]);
|
||||
expect(operation.meta.sourceTipId).toBeNull();
|
||||
expect(lane.state.inbox).toEqual([]);
|
||||
expect(
|
||||
(await session.scanBranch({ start: second, order: "oldestFirst" }, BACKGROUND_CONTEXT)).map(({ id }) => id),
|
||||
).toEqual([first, second]);
|
||||
expect(await session.getValue(storedValues.pendingEntry(first), BACKGROUND_CONTEXT)).toBeUndefined();
|
||||
expect(await session.getValue(storedValues.pendingEntry(second), BACKGROUND_CONTEXT)).toBeUndefined();
|
||||
expect(
|
||||
storage
|
||||
.getCommitAttempts()[0]
|
||||
?.map((write) => (write.kind === "value" ? `${write.kind}:${write.op}` : write.kind)),
|
||||
).toEqual(["entry", "entry", "value:delete", "value:delete", "value:set", "value:set", "value:set", "value:set"]);
|
||||
expect(events).toEqual(["queue_update"]);
|
||||
});
|
||||
|
||||
it("captures every eligible tag by mode and preserves admission order before the request", async () => {
|
||||
const ids = {
|
||||
write: "queued-write",
|
||||
next: "queued-next",
|
||||
steer1: "queued-steer-1",
|
||||
steer2: "queued-steer-2",
|
||||
follow1: "queued-follow-1",
|
||||
follow2: "queued-follow-2",
|
||||
};
|
||||
const { lane, session } = await createHarness(
|
||||
async (source) => {
|
||||
await source.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.pendingEntry(ids.write), {
|
||||
type: "custom",
|
||||
customType: "queued-write",
|
||||
}),
|
||||
...[ids.next, ids.steer1, ids.steer2, ids.follow1, ids.follow2].map((entryId) =>
|
||||
storedValues.setValue(storedValues.pendingEntry(entryId), {
|
||||
type: "message",
|
||||
payload: { role: "user", content: entryId, timestamp: 1 },
|
||||
}),
|
||||
),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: [
|
||||
{ entryId: ids.steer1, kind: "steer" },
|
||||
{ entryId: ids.write, kind: "write" },
|
||||
{ entryId: ids.next, kind: "nextRun" },
|
||||
{ entryId: ids.follow1, kind: "followUp" },
|
||||
{ entryId: ids.steer2, kind: "steer" },
|
||||
{ entryId: ids.follow2, kind: "followUp" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
},
|
||||
{},
|
||||
{ steeringMode: "one-at-a-time", followUpMode: "one-at-a-time" },
|
||||
);
|
||||
|
||||
unwrap(await lane.accept({ kind: "prompt", prompt: "request" }, BACKGROUND_CONTEXT));
|
||||
|
||||
const tipId = lane.state.tipId;
|
||||
if (tipId === null) throw new Error("Expected accepted tip");
|
||||
const entries = await session.scanBranch({ start: tipId, order: "oldestFirst" }, BACKGROUND_CONTEXT);
|
||||
expect(entries.map((entry) => entry.id)).toEqual([ids.steer1, ids.write, ids.next, ids.follow1, tipId]);
|
||||
expect(lane.state.inbox).toEqual([
|
||||
{ entryId: ids.steer2, kind: "steer" },
|
||||
{ entryId: ids.follow2, kind: "followUp" },
|
||||
]);
|
||||
expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value.inbox).toEqual(
|
||||
lane.state.inbox,
|
||||
);
|
||||
for (const entryId of [ids.write, ids.next, ids.steer1, ids.follow1]) {
|
||||
expect(await session.getValue(storedValues.pendingEntry(entryId), BACKGROUND_CONTEXT)).toBeUndefined();
|
||||
}
|
||||
for (const entryId of [ids.steer2, ids.follow2]) {
|
||||
expect(await session.getValue(storedValues.pendingEntry(entryId), BACKGROUND_CONTEXT)).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let a lone queued write validate empty acceptance", async () => {
|
||||
const entryId = "queued-write";
|
||||
const { lane, session } = await createHarness(async (source) => {
|
||||
await source.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.pendingEntry(entryId), {
|
||||
type: "custom",
|
||||
customType: "queued-write",
|
||||
}),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: [{ entryId, kind: "write" }],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
});
|
||||
|
||||
const result = await lane.accept({ kind: "prompt", prompt: "" }, BACKGROUND_CONTEXT);
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { _tag: "InvalidMessage", reason: "empty" } });
|
||||
expect(lane.state.inbox).toEqual([{ entryId, kind: "write" }]);
|
||||
expect(await session.getValue(storedValues.pendingEntry(entryId), BACKGROUND_CONTEXT)).toBeDefined();
|
||||
});
|
||||
|
||||
it("formats skills and templates before acceptance", async () => {
|
||||
const resources = {
|
||||
skills: [
|
||||
{ name: "review", description: "Review", content: "Inspect it", filePath: "/skills/review/SKILL.md" },
|
||||
],
|
||||
promptTemplates: [{ name: "fix", content: "Fix $1 then $@" }],
|
||||
};
|
||||
const skill = await createHarness(undefined, resources);
|
||||
unwrap(
|
||||
await skill.lane.accept(
|
||||
{ kind: "skill", name: "review", additionalInstructions: "Be strict" },
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
const skillEntry = await skill.session.getEntry(skill.lane.state.tipId!, BACKGROUND_CONTEXT);
|
||||
expect(skillEntry).toMatchObject({
|
||||
type: "message",
|
||||
message: { content: [{ type: "text", text: expect.stringContaining('<skill name="review"') }] },
|
||||
});
|
||||
|
||||
const template = await createHarness(undefined, resources);
|
||||
unwrap(
|
||||
await template.lane.accept({ kind: "prompt_template", name: "fix", args: ["A", "B"] }, BACKGROUND_CONTEXT),
|
||||
);
|
||||
const templateEntry = await template.session.getEntry(template.lane.state.tipId!, BACKGROUND_CONTEXT);
|
||||
expect(templateEntry).toMatchObject({
|
||||
type: "message",
|
||||
message: { content: [{ type: "text", text: "Fix A then A B" }] },
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts standalone compaction with durable preparation and no execution", async () => {
|
||||
const { harness, lane, session, storage } = await createHarness();
|
||||
await lane.appendMessage({ role: "user", content: "history", timestamp: 1 }, BACKGROUND_CONTEXT);
|
||||
storage.clearCommitAttempts();
|
||||
const starts = vi.fn();
|
||||
harness.events.on("compaction_start", starts);
|
||||
|
||||
const admission = unwrap(
|
||||
await lane.accept(
|
||||
{ kind: "compaction", operationId: "compaction", customInstructions: "focus" },
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
|
||||
expect(admission).toEqual({ operationId: "compaction", kind: "compaction", startedAt: expect.any(Number) });
|
||||
const operation = lane.state.operation;
|
||||
if (operation?.state.at !== "summary.deciding") throw new Error("Expected accepted compaction");
|
||||
expect(operation.state.task).toMatchObject({
|
||||
reason: "manual",
|
||||
customInstructions: "focus",
|
||||
boundary: { kind: "finish" },
|
||||
});
|
||||
expect(
|
||||
await session.getValue(
|
||||
storedValues.operationPreparation("compaction", operation.state.task.taskId),
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toMatchObject({ value: { kind: "compaction" } });
|
||||
expect(storage.getCommitAttempts()).toHaveLength(1);
|
||||
expect(starts).toHaveBeenCalledTimes(1);
|
||||
expect(lane.activeDrive).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects empty standalone compaction without writing", async () => {
|
||||
const { lane, storage } = await createHarness();
|
||||
|
||||
expect(await lane.accept({ kind: "compaction" }, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "NothingToCompact" },
|
||||
});
|
||||
expect(storage.getCommitAttempts()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([false, true])("accepts %s summarized navigation atomically", async (summarize) => {
|
||||
const { harness, lane, session, storage } = await createHarness(async (source) => {
|
||||
await source.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
{
|
||||
kind: "entry",
|
||||
entry: {
|
||||
id: "root",
|
||||
parentId: null,
|
||||
type: "message",
|
||||
message: { role: "user", content: "root", timestamp: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "entry",
|
||||
entry: {
|
||||
id: "source",
|
||||
parentId: "root",
|
||||
type: "message",
|
||||
message: { role: "user", content: "source", timestamp: 2 },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "entry",
|
||||
entry: {
|
||||
id: "target",
|
||||
parentId: "root",
|
||||
type: "message",
|
||||
message: { role: "user", content: "target", timestamp: 3 },
|
||||
},
|
||||
},
|
||||
storedValues.setValue(storedValues.branchTip("main"), "source"),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
});
|
||||
storage.clearCommitAttempts();
|
||||
const starts = vi.fn();
|
||||
harness.events.on("navigation_start", starts);
|
||||
|
||||
unwrap(
|
||||
await lane.accept(
|
||||
{
|
||||
kind: "navigation",
|
||||
operationId: summarize ? "summarized" : "direct",
|
||||
targetId: "target",
|
||||
options: { summarize, label: "chosen", customInstructions: "focus" },
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
);
|
||||
|
||||
const operation = lane.state.operation;
|
||||
if (operation === null) throw new Error("Expected accepted navigation");
|
||||
expect(operation.state.at).toBe(summarize ? "summary.deciding" : "navigation.ready_to_commit");
|
||||
expect(lane.state.tipId).toBe("source");
|
||||
if (summarize) {
|
||||
if (operation.state.at !== "summary.deciding") throw new Error("Expected summary decision");
|
||||
expect(
|
||||
await session.getValue(
|
||||
storedValues.operationPreparation(operation.meta.operationId, operation.state.task.taskId),
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toMatchObject({ value: { kind: "branch_summary", messages: [{ content: "source" }] } });
|
||||
}
|
||||
expect(storage.getCommitAttempts()).toHaveLength(1);
|
||||
expect(starts).toHaveBeenCalledTimes(1);
|
||||
expect(lane.activeDrive).toBeUndefined();
|
||||
});
|
||||
|
||||
it("validates navigation before acceptance", async () => {
|
||||
const { lane, storage } = await createHarness(async (source) => {
|
||||
await source.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
{
|
||||
kind: "entry",
|
||||
entry: {
|
||||
id: "source",
|
||||
parentId: null,
|
||||
type: "message",
|
||||
message: { role: "user", content: "source", timestamp: 1 },
|
||||
},
|
||||
},
|
||||
storedValues.setValue(storedValues.branchTip("main"), "source"),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
});
|
||||
|
||||
expect(await lane.accept({ kind: "navigation", targetId: "source" }, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "InvalidNavigation", reason: "current_tip" },
|
||||
});
|
||||
expect(
|
||||
await lane.accept({ kind: "navigation", targetId: null, options: { label: "bad" } }, BACKGROUND_CONTEXT),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "InvalidNavigation", reason: "root_label" },
|
||||
});
|
||||
expect(await lane.accept({ kind: "navigation", targetId: "missing" }, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "UnknownTarget" },
|
||||
});
|
||||
expect(storage.getCommitAttempts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns expected pre-acceptance errors without writing", async () => {
|
||||
const { lane, storage } = await createHarness();
|
||||
const pending = fauxAssistantMessage([], { stopReason: "pending" });
|
||||
|
||||
expect(await lane.accept({ kind: "prompt", prompt: "" }, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "InvalidMessage", reason: "empty" },
|
||||
});
|
||||
expect(await lane.accept({ kind: "prompt", prompt: pending }, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "InvalidMessage", reason: "pending_assistant" },
|
||||
});
|
||||
expect(await lane.accept({ kind: "skill", name: "missing" }, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "UnknownSkill" },
|
||||
});
|
||||
expect(await lane.accept({ kind: "prompt_template", name: "missing" }, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "UnknownTemplate" },
|
||||
});
|
||||
expect(storage.getCommitAttempts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("serializes concurrent accepts so exactly one wins", async () => {
|
||||
const { lane, storage } = await createHarness();
|
||||
|
||||
const results = await Promise.all([
|
||||
lane.accept({ kind: "prompt", prompt: "first" }, BACKGROUND_CONTEXT),
|
||||
lane.accept({ kind: "prompt", prompt: "second" }, BACKGROUND_CONTEXT),
|
||||
]);
|
||||
|
||||
expect(results.filter((result) => result.ok)).toHaveLength(1);
|
||||
expect(results.filter((result) => !result.ok)).toMatchObject([{ error: { _tag: "LaneBusy" } }]);
|
||||
expect(storage.getCommitAttempts()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("faults on commit failure without publishing acceptance", async () => {
|
||||
const storage = new FailingMemoryStorage();
|
||||
const session = new StorageBackedSession({ id: "failing", createdAt: 1, storageVersion: 1 }, storage);
|
||||
sessions.push(session);
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.branchTip("main"), null),
|
||||
storedValues.setValue(storedValues.laneConfig("main"), configuration),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const { harness } = await createAgentHarness(options(session), BACKGROUND_CONTEXT);
|
||||
if (!(harness instanceof Harness)) throw new Error("Expected runtime Harness");
|
||||
const lane = await harness.lane("main", BACKGROUND_CONTEXT);
|
||||
if (!(lane instanceof Lane)) throw new Error("Expected runtime Lane");
|
||||
storage.failure = new Error("accept failed");
|
||||
|
||||
await expect(lane.accept({ kind: "prompt", prompt: "hello" }, BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(
|
||||
HarnessFault,
|
||||
);
|
||||
expect(lane.state.operation).toBeNull();
|
||||
});
|
||||
|
||||
it("delivers acceptance listeners after publishing state and permits serialized reads", async () => {
|
||||
const { harness, lane } = await createHarness();
|
||||
let inspected = false;
|
||||
harness.events.on("run_start", async () => {
|
||||
inspected = (await lane.inspectExecution(BACKGROUND_CONTEXT)).current?.status === "open";
|
||||
});
|
||||
|
||||
unwrap(await lane.accept({ kind: "prompt", prompt: "hello" }, BACKGROUND_CONTEXT));
|
||||
|
||||
expect(inspected).toBe(true);
|
||||
});
|
||||
|
||||
it("does not resolve acceptance before its direct listeners settle", async () => {
|
||||
const { harness, lane } = await createHarness();
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
harness.events.on("run_start", async () => {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
});
|
||||
|
||||
const acceptance = lane.accept({ kind: "prompt", prompt: "hello" }, BACKGROUND_CONTEXT);
|
||||
await started.promise;
|
||||
let resolved = false;
|
||||
void acceptance.then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(resolved).toBe(false);
|
||||
release.resolve();
|
||||
unwrap(await acceptance);
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it("returns Closed when acceptance starts after close", async () => {
|
||||
const { harness, lane, storage } = await createHarness();
|
||||
await harness.close(BACKGROUND_CONTEXT);
|
||||
|
||||
expect(await lane.accept({ kind: "prompt", prompt: "late" }, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "Closed" },
|
||||
});
|
||||
expect(storage.getCommitAttempts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("publishes an acceptance admitted before close", async () => {
|
||||
const storage = new ControlledMemoryStorage();
|
||||
const session = new StorageBackedSession({ id: "closing", createdAt: 1, storageVersion: 1 }, storage);
|
||||
sessions.push(session);
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.branchTip("main"), null),
|
||||
storedValues.setValue(storedValues.laneConfig("main"), configuration),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const { harness } = await createAgentHarness(options(session), BACKGROUND_CONTEXT);
|
||||
const lane = await harness.lane("main", BACKGROUND_CONTEXT);
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
storage.beforeNextCommit = async () => {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
};
|
||||
const acceptance = lane.accept({ kind: "prompt", prompt: "hello" }, BACKGROUND_CONTEXT);
|
||||
await started.promise;
|
||||
const closing = harness.close(BACKGROUND_CONTEXT);
|
||||
release.resolve();
|
||||
|
||||
expect((await acceptance).ok).toBe(true);
|
||||
await closing;
|
||||
});
|
||||
});
|
||||
790
packages/agent/test/harness/runtime/drive-generation.test.ts
Normal file
790
packages/agent/test/harness/runtime/drive-generation.test.ts
Normal file
@@ -0,0 +1,790 @@
|
||||
import {
|
||||
type AssistantMessage,
|
||||
createAssistantMessageEventStream,
|
||||
createModels,
|
||||
fauxAssistantMessage,
|
||||
fauxProvider,
|
||||
type MutableModels,
|
||||
type Provider,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HarnessEvent, WatchHandle } from "../../../src/harness/agent-harness.ts";
|
||||
import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/harness/compaction/compaction.ts";
|
||||
import { BACKGROUND_CONTEXT, type Context } from "../../../src/harness/context.ts";
|
||||
import { HookRegistry } from "../../../src/harness/hooks.ts";
|
||||
import { runCheckpoint, startRun } from "../../../src/harness/runtime/drive/checkpoint.ts";
|
||||
import { runGeneration } from "../../../src/harness/runtime/drive/generation.ts";
|
||||
import { recoverAssistantGeneration } from "../../../src/harness/runtime/drive/recovery.ts";
|
||||
import { Lane } from "../../../src/harness/runtime/lane.ts";
|
||||
import { restoreLane } from "../../../src/harness/runtime/restore.ts";
|
||||
import { type Config, Drive } from "../../../src/harness/runtime/types.ts";
|
||||
import { MemoryStorage } from "../../../src/harness/session/memory.ts";
|
||||
import { StorageBackedSession } from "../../../src/harness/session/session.ts";
|
||||
import { InstrumentedStorage } from "../../../src/harness/session/testing/instrumented-storage.ts";
|
||||
import {
|
||||
type AssistantEffectPendingOperation,
|
||||
type AssistantReadyOperation,
|
||||
type CheckpointOperation,
|
||||
type LaneConfiguration,
|
||||
type MessageEntry,
|
||||
type OperationState,
|
||||
operationScopeOf,
|
||||
type Session,
|
||||
type Write,
|
||||
} from "../../../src/harness/session/types.ts";
|
||||
import * as storedValues from "../../../src/harness/session/values.ts";
|
||||
import { deferred } from "./test-utils.ts";
|
||||
|
||||
const sessions: Session[] = [];
|
||||
const operationId = "01950000-0000-7000-8000-000000000001";
|
||||
|
||||
class FrameBlockingMemoryStorage extends MemoryStorage {
|
||||
blockNextFrame = false;
|
||||
readonly frameStarted = deferred();
|
||||
readonly releaseFrame = deferred();
|
||||
|
||||
override async commit(writes: Write[], context: Context) {
|
||||
if (
|
||||
this.blockNextFrame &&
|
||||
writes.some(
|
||||
(write) =>
|
||||
write.kind === "list" && write.op === "append" && write.namespace === "pi.pending.assistant_frame",
|
||||
)
|
||||
) {
|
||||
this.blockNextFrame = false;
|
||||
this.frameStarted.resolve();
|
||||
await this.releaseFrame.promise;
|
||||
}
|
||||
return super.commit(writes, context);
|
||||
}
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
lane: Lane<undefined>;
|
||||
drive: Drive;
|
||||
session: Session;
|
||||
storage: InstrumentedStorage;
|
||||
models: MutableModels;
|
||||
faux: ReturnType<typeof fauxProvider>;
|
||||
hooks: HookRegistry;
|
||||
events: HarnessEvent[];
|
||||
config: Config<undefined>;
|
||||
}
|
||||
|
||||
function unusedWatch<T>(): WatchHandle<T> {
|
||||
throw new Error("watch is not used by generation tests");
|
||||
}
|
||||
|
||||
async function createFixture(backend: MemoryStorage = new MemoryStorage({ now: () => 100 })): Promise<Fixture> {
|
||||
const storage = new InstrumentedStorage(backend);
|
||||
const session = new StorageBackedSession(
|
||||
{ id: `generation-${sessions.length}`, createdAt: 1, storageVersion: 1 },
|
||||
storage,
|
||||
);
|
||||
sessions.push(session);
|
||||
const faux = fauxProvider({ tokenSize: { min: 1, max: 1 } });
|
||||
const model = faux.getModel();
|
||||
const configuration: LaneConfiguration = {
|
||||
model: { provider: model.provider, modelId: model.id },
|
||||
thinkingLevel: "off",
|
||||
activeToolNames: [],
|
||||
};
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.branchTip("main"), null),
|
||||
storedValues.setValue(storedValues.laneConfig("main"), configuration),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const models = createModels();
|
||||
models.setProvider(faux.provider);
|
||||
const hooks = new HookRegistry(() => {});
|
||||
const events: HarnessEvent[] = [];
|
||||
const config: Config<undefined> = {
|
||||
tools: [],
|
||||
resources: {},
|
||||
streamOptions: {},
|
||||
retryPolicy: { enabled: true, maxRetries: 3, baseDelayMs: 1 },
|
||||
compaction: DEFAULT_COMPACTION_SETTINGS,
|
||||
steeringMode: "all",
|
||||
followUpMode: "all",
|
||||
toolExecution: "parallel",
|
||||
toolContext: undefined,
|
||||
systemPrompt: undefined,
|
||||
toProviderMessages: (messages) =>
|
||||
messages.filter(
|
||||
(message) => message.role === "user" || message.role === "assistant" || message.role === "toolResult",
|
||||
),
|
||||
entryProjectors: {},
|
||||
};
|
||||
const lane = new Lane<undefined>(
|
||||
"main",
|
||||
session,
|
||||
models,
|
||||
hooks,
|
||||
await restoreLane(session, "main", BACKGROUND_CONTEXT),
|
||||
(cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
(batch) => {
|
||||
events.push(...structuredClone(batch));
|
||||
return Promise.resolve();
|
||||
},
|
||||
unusedWatch,
|
||||
() => config,
|
||||
);
|
||||
const admission = await lane.accept({ kind: "prompt", operationId, prompt: "question" }, BACKGROUND_CONTEXT);
|
||||
if (!admission.ok) throw admission.error;
|
||||
const drive = new Drive({ operationId }, BACKGROUND_CONTEXT);
|
||||
lane.activeDrive = drive;
|
||||
storage.clearCommitAttempts();
|
||||
return { lane, drive, session, storage, models, faux, hooks, events, config };
|
||||
}
|
||||
|
||||
function currentRun(lane: Lane<undefined>): OperationState {
|
||||
const operation = lane.state.operation;
|
||||
if (operation === null) throw new Error("fixture has no operation");
|
||||
return operation.state;
|
||||
}
|
||||
|
||||
function readyGeneration(lane: Lane<undefined>): AssistantReadyOperation {
|
||||
const run = currentRun(lane);
|
||||
if (run.at !== "assistant.ready") throw new Error("fixture has no ready generation");
|
||||
return run;
|
||||
}
|
||||
|
||||
async function startFixtureRun(fixture: Fixture) {
|
||||
const run = currentRun(fixture.lane);
|
||||
if (run.at !== "starting") throw new Error("run did not start at its initial boundary");
|
||||
return startRun(fixture.lane, fixture.drive, run);
|
||||
}
|
||||
|
||||
async function advanceToReady(fixture: Fixture): Promise<AssistantReadyOperation> {
|
||||
await startFixtureRun(fixture);
|
||||
const run = currentRun(fixture.lane);
|
||||
if (run.at !== "checkpoint") throw new Error("run did not reach checkpoint");
|
||||
await runCheckpoint(fixture.lane, fixture.drive, run);
|
||||
return readyGeneration(fixture.lane);
|
||||
}
|
||||
|
||||
async function expectProjectionRestores(fixture: Fixture): Promise<void> {
|
||||
expect(fixture.lane.state).toEqual(await restoreLane(fixture.session, "main", BACKGROUND_CONTEXT));
|
||||
}
|
||||
|
||||
function writesOperationState(writes: readonly Write[], status: string): boolean {
|
||||
return writes.some(
|
||||
(write) =>
|
||||
write.kind === "value" &&
|
||||
write.op === "set" &&
|
||||
write.namespace === "pi.op.state" &&
|
||||
typeof write.value === "object" &&
|
||||
write.value !== null &&
|
||||
"at" in write.value &&
|
||||
write.value.at === `assistant.${status}`,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const session of sessions.splice(0)) await session.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
describe("runtime generation checkpoint", () => {
|
||||
it("consumes before_run and snapshots a ready generation", async () => {
|
||||
const fixture = await createFixture();
|
||||
fixture.hooks.on("before_run", () => ({
|
||||
messages: [{ role: "user", content: "injected", timestamp: 2 }],
|
||||
}));
|
||||
expect(await startFixtureRun(fixture)).toEqual({ kind: "continue" });
|
||||
let run = currentRun(fixture.lane);
|
||||
if (run.at !== "checkpoint") throw new Error("missing checkpoint");
|
||||
expect(run.triggerEntryId).toBe(fixture.lane.state.tipId);
|
||||
expect(fixture.events.map((event) => event.type)).toContain("entry_added");
|
||||
await expectProjectionRestores(fixture);
|
||||
|
||||
expect(await runCheckpoint(fixture.lane, fixture.drive, run)).toEqual({ kind: "continue" });
|
||||
run = currentRun(fixture.lane);
|
||||
if (run.at !== "assistant.ready") {
|
||||
throw new Error("missing ready generation");
|
||||
}
|
||||
expect(run.generationContext).toMatchObject({
|
||||
configuration: fixture.lane.state.configuration,
|
||||
streamOptions: {},
|
||||
retryPolicy: { maxAttempts: 4, baseDelayMs: 1 },
|
||||
overflowRecoveryUsed: false,
|
||||
});
|
||||
await expectProjectionRestores(fixture);
|
||||
});
|
||||
|
||||
it("drains mixed pending writes while separating leaf and generation trigger", async () => {
|
||||
const fixture = await createFixture();
|
||||
await startFixtureRun(fixture);
|
||||
const messageId = "01950000-0000-7000-8000-000000000010";
|
||||
const customId = "01950000-0000-7000-8000-000000000011";
|
||||
await fixture.lane.command((state) => {
|
||||
const operation = state.operation;
|
||||
if (operation === null) throw new Error("missing operation");
|
||||
const inbox = [
|
||||
...state.inbox,
|
||||
{ entryId: messageId, kind: "write" as const },
|
||||
{ entryId: customId, kind: "write" as const },
|
||||
];
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [
|
||||
storedValues.setValue(storedValues.pendingEntry(messageId), {
|
||||
type: "message",
|
||||
payload: { role: "user", content: "projecting", timestamp: 3 },
|
||||
}),
|
||||
storedValues.setValue(storedValues.pendingEntry(customId), {
|
||||
type: "custom",
|
||||
customType: "display-only",
|
||||
payload: { value: true },
|
||||
}),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: operationId,
|
||||
lastOperationId: null,
|
||||
inbox,
|
||||
}),
|
||||
],
|
||||
next: { ...state, inbox },
|
||||
materialize: () => undefined,
|
||||
};
|
||||
}, BACKGROUND_CONTEXT);
|
||||
const run = currentRun(fixture.lane);
|
||||
if (run.at !== "checkpoint") throw new Error("missing checkpoint");
|
||||
fixture.storage.clearCommitAttempts();
|
||||
|
||||
await runCheckpoint(fixture.lane, fixture.drive, run);
|
||||
|
||||
expect(fixture.storage.getCommitAttempts()).toHaveLength(1);
|
||||
const routed = currentRun(fixture.lane);
|
||||
if (routed.at !== "assistant.ready") throw new Error("pending writes did not route to generation");
|
||||
expect(fixture.lane.state.tipId).toBe(customId);
|
||||
expect(routed.generationContext).toMatchObject({
|
||||
triggerEntryId: messageId,
|
||||
overflowRecoveryUsed: false,
|
||||
});
|
||||
expect(fixture.lane.state.inbox).toEqual([]);
|
||||
await expectProjectionRestores(fixture);
|
||||
});
|
||||
|
||||
it("preserves checkpoint routing when a custom-only pending write does not project", async () => {
|
||||
const fixture = await createFixture();
|
||||
await startFixtureRun(fixture);
|
||||
const current = currentRun(fixture.lane);
|
||||
if (current.at !== "checkpoint") throw new Error("missing checkpoint");
|
||||
const customId = "01950000-0000-7000-8000-000000000012";
|
||||
const checkpoint: CheckpointOperation = {
|
||||
...current,
|
||||
continuation: { kind: "may_finish", includeFinalAssistant: false },
|
||||
};
|
||||
await fixture.lane.command((state) => {
|
||||
const operation = state.operation;
|
||||
if (operation === null) throw new Error("missing operation");
|
||||
const inbox = [...state.inbox, { entryId: customId, kind: "write" as const }];
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [
|
||||
storedValues.setValue(storedValues.pendingEntry(customId), {
|
||||
type: "custom",
|
||||
customType: "display-only",
|
||||
payload: { value: true },
|
||||
}),
|
||||
storedValues.setValue(storedValues.operationState(operationId), checkpoint),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: operationId,
|
||||
lastOperationId: null,
|
||||
inbox,
|
||||
}),
|
||||
],
|
||||
next: { ...state, inbox, operation: { meta: operation.meta, state: checkpoint } },
|
||||
materialize: () => undefined,
|
||||
};
|
||||
}, BACKGROUND_CONTEXT);
|
||||
fixture.hooks.on("before_run_end", () => ({ followUp: "continue after custom write" }));
|
||||
|
||||
expect(await runCheckpoint(fixture.lane, fixture.drive, checkpoint)).toEqual({ kind: "continue" });
|
||||
|
||||
const ready = currentRun(fixture.lane);
|
||||
if (ready.at !== "assistant.ready") throw new Error("hook follow-up did not route to generation");
|
||||
const followUpId = ready.generationContext.triggerEntryId;
|
||||
expect(followUpId).toBe(fixture.lane.state.tipId);
|
||||
expect(await fixture.session.getEntry(customId, BACKGROUND_CONTEXT)).toMatchObject({ type: "custom" });
|
||||
expect(await fixture.session.getEntry(followUpId, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
parentId: customId,
|
||||
type: "message",
|
||||
message: { role: "user", content: "continue after custom write" },
|
||||
});
|
||||
expect(fixture.lane.state.inbox).toEqual([]);
|
||||
expect(await fixture.session.getValue(storedValues.pendingEntry(customId), BACKGROUND_CONTEXT)).toBeUndefined();
|
||||
await expectProjectionRestores(fixture);
|
||||
});
|
||||
|
||||
it("drops a stale finish-hook follow-up when input arrives during the hook", async () => {
|
||||
const fixture = await createFixture();
|
||||
await startFixtureRun(fixture);
|
||||
const current = currentRun(fixture.lane);
|
||||
if (current.at !== "checkpoint") throw new Error("missing checkpoint");
|
||||
const checkpoint: CheckpointOperation = {
|
||||
...current,
|
||||
continuation: { kind: "may_finish", includeFinalAssistant: false },
|
||||
};
|
||||
await fixture.lane.command((state) => {
|
||||
const operation = state.operation;
|
||||
if (operation === null) throw new Error("missing operation");
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [storedValues.setValue(storedValues.operationState(operationId), checkpoint)],
|
||||
next: { ...state, operation: { meta: operation.meta, state: checkpoint } },
|
||||
materialize: () => undefined,
|
||||
};
|
||||
}, BACKGROUND_CONTEXT);
|
||||
const hookStarted = deferred();
|
||||
const releaseHook = deferred();
|
||||
fixture.hooks.on("before_run_end", async () => {
|
||||
hookStarted.resolve();
|
||||
await releaseHook.promise;
|
||||
return { followUp: "stale hook follow-up" };
|
||||
});
|
||||
|
||||
const running = runCheckpoint(fixture.lane, fixture.drive, checkpoint);
|
||||
await hookStarted.promise;
|
||||
await fixture.lane.command((state) => {
|
||||
const inbox = [...state.inbox, { entryId: "steer-during-finish", kind: "steer" as const }];
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [
|
||||
storedValues.setValue(storedValues.pendingEntry("steer-during-finish"), {
|
||||
type: "message",
|
||||
payload: { role: "user", content: "new input", timestamp: 4 },
|
||||
}),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: operationId,
|
||||
lastOperationId: null,
|
||||
inbox,
|
||||
}),
|
||||
],
|
||||
next: { ...state, inbox },
|
||||
materialize: () => undefined,
|
||||
};
|
||||
}, BACKGROUND_CONTEXT);
|
||||
releaseHook.resolve();
|
||||
|
||||
expect(await running).toEqual({ kind: "continue" });
|
||||
const ready = currentRun(fixture.lane);
|
||||
if (ready.at !== "assistant.ready") throw new Error("new input did not replace stale finish decision");
|
||||
expect(ready.generationContext.triggerEntryId).toBe("steer-during-finish");
|
||||
expect(fixture.lane.state.tipId).toBe("steer-during-finish");
|
||||
expect((await fixture.session.getEntry("steer-during-finish", BACKGROUND_CONTEXT))?.type).toBe("message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime assistant generation", () => {
|
||||
it("commits intent before provider admission, preserves queued inbox state, and settles reserved ids", async () => {
|
||||
const fixture = await createFixture();
|
||||
const ready = await advanceToReady(fixture);
|
||||
const hookStarted = deferred();
|
||||
const releaseHook = deferred();
|
||||
fixture.hooks.on("before_request", async () => {
|
||||
hookStarted.resolve();
|
||||
await releaseHook.promise;
|
||||
return undefined;
|
||||
});
|
||||
let effectPendingAtProvider = false;
|
||||
fixture.faux.setResponses([
|
||||
async () => {
|
||||
const state = await fixture.session.getValue(storedValues.operationState(operationId), BACKGROUND_CONTEXT);
|
||||
effectPendingAtProvider = state?.value.at === "assistant.effect_pending";
|
||||
return fauxAssistantMessage("answer", { timestamp: 5 });
|
||||
},
|
||||
]);
|
||||
fixture.storage.clearCommitAttempts();
|
||||
const generating = runGeneration(fixture.lane, fixture.drive, ready);
|
||||
await hookStarted.promise;
|
||||
const steerId = "01950000-0000-7000-8000-000000000020";
|
||||
await fixture.lane.command((state) => {
|
||||
const operation = state.operation;
|
||||
if (operation === null) throw new Error("missing operation");
|
||||
const inbox = [...state.inbox, { entryId: steerId, kind: "steer" as const }];
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [
|
||||
storedValues.setValue(storedValues.pendingEntry(steerId), {
|
||||
type: "message",
|
||||
payload: { role: "user", content: "late steer", timestamp: 4 },
|
||||
}),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: operationId,
|
||||
lastOperationId: null,
|
||||
inbox,
|
||||
}),
|
||||
],
|
||||
next: { ...state, inbox },
|
||||
materialize: () => undefined,
|
||||
};
|
||||
}, BACKGROUND_CONTEXT);
|
||||
releaseHook.resolve();
|
||||
|
||||
expect(await generating).toEqual({ kind: "continue" });
|
||||
expect(effectPendingAtProvider).toBe(true);
|
||||
const settled = currentRun(fixture.lane);
|
||||
expect(fixture.lane.state.inbox).toEqual([{ entryId: steerId, kind: "steer" }]);
|
||||
if (settled.at !== "checkpoint") throw new Error("response did not reach checkpoint");
|
||||
const responseId = settled.latestAssistantEntryId;
|
||||
if (responseId === null) throw new Error("missing response id");
|
||||
const entry = await fixture.session.getEntry(responseId, BACKGROUND_CONTEXT);
|
||||
expect(entry).toMatchObject({ id: responseId, type: "message", message: { content: [{ text: "answer" }] } });
|
||||
expect(
|
||||
await fixture.session.readList(
|
||||
storedValues.pendingAssistantFrames(operationId, responseId),
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toEqual([]);
|
||||
const attempts = fixture.storage.getCommitAttempts();
|
||||
expect(attempts.some((writes) => writesOperationState(writes, "effect_pending"))).toBe(true);
|
||||
const settlement = attempts.find((writes) => writes.some((write) => write.kind === "entry"));
|
||||
expect(
|
||||
settlement?.map((write) =>
|
||||
write.kind === "value" || write.kind === "list"
|
||||
? `${write.kind}:${write.op}:${write.namespace}`
|
||||
: write.kind,
|
||||
),
|
||||
).toEqual([
|
||||
"entry",
|
||||
"usage",
|
||||
"value:set:pi.branch.tip",
|
||||
"list:delete:pi.pending.assistant_frame",
|
||||
"value:set:pi.op.state",
|
||||
]);
|
||||
expect(fixture.events.map((event) => event.type)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"turn_start",
|
||||
"message_start",
|
||||
"message_update",
|
||||
"message_end",
|
||||
"entry_added",
|
||||
"usage",
|
||||
"turn_end",
|
||||
]),
|
||||
);
|
||||
await expectProjectionRestores(fixture);
|
||||
});
|
||||
|
||||
it("does not await frame storage inside the provider event loop and persists frames in order", async () => {
|
||||
const backend = new FrameBlockingMemoryStorage({ now: () => 100 });
|
||||
const fixture = await createFixture(backend);
|
||||
const ready = await advanceToReady(fixture);
|
||||
const base = fixture.faux.provider;
|
||||
const provider: Provider = {
|
||||
id: base.id,
|
||||
name: base.name,
|
||||
auth: base.auth,
|
||||
getModels: () => base.getModels(),
|
||||
stream: (model, context, options) => base.stream(model, context, options),
|
||||
streamSimple: () => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
const partial: AssistantMessage = {
|
||||
...fauxAssistantMessage([], { timestamp: 5 }),
|
||||
stopReason: "pending",
|
||||
};
|
||||
const text = { type: "text" as const, text: "" };
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "start", partial });
|
||||
partial.content.push(text);
|
||||
stream.push({ type: "text_start", contentIndex: 0, partial });
|
||||
text.text = "ab";
|
||||
stream.push({ type: "text_delta", contentIndex: 0, delta: "ab", partial });
|
||||
stream.push({ type: "text_end", contentIndex: 0, content: "ab", partial });
|
||||
stream.push({ type: "done", reason: "stop", message: fauxAssistantMessage("ab", { timestamp: 5 }) });
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
};
|
||||
fixture.models.setProvider(provider);
|
||||
backend.blockNextFrame = true;
|
||||
fixture.storage.clearCommitAttempts();
|
||||
|
||||
const generating = runGeneration(fixture.lane, fixture.drive, ready);
|
||||
await backend.frameStarted.promise;
|
||||
await expect
|
||||
.poll(() => fixture.events.filter((event) => event.type === "message_update").length)
|
||||
.toBeGreaterThan(1);
|
||||
backend.releaseFrame.resolve();
|
||||
await generating;
|
||||
|
||||
const frames = fixture.storage
|
||||
.getCommitAttempts()
|
||||
.flatMap((writes) =>
|
||||
writes.flatMap((write) =>
|
||||
write.kind === "list" && write.op === "append" && write.namespace === "pi.pending.assistant_frame"
|
||||
? [write.value]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
expect(
|
||||
frames.map((frame) =>
|
||||
typeof frame === "object" && frame !== null && "type" in frame ? frame.type : undefined,
|
||||
),
|
||||
).toEqual(["start", "text_start", "text_end"]);
|
||||
expect(frames[0]).toMatchObject({ partial: { content: [] } });
|
||||
expect(frames[1]).toMatchObject({ content: { text: "ab" } });
|
||||
});
|
||||
|
||||
it("enters configuration failure without reserving response ids or calling the provider", async () => {
|
||||
const fixture = await createFixture();
|
||||
await fixture.lane.setActiveTools(["missing"], BACKGROUND_CONTEXT);
|
||||
const ready = await advanceToReady(fixture);
|
||||
const queuedId = await fixture.lane.appendCustomEntry("after-failure", { retained: true }, BACKGROUND_CONTEXT);
|
||||
fixture.storage.clearCommitAttempts();
|
||||
|
||||
expect(await runGeneration(fixture.lane, fixture.drive, ready)).toMatchObject({
|
||||
kind: "settled",
|
||||
outcome: {
|
||||
operationId,
|
||||
kind: "run",
|
||||
status: "failed",
|
||||
error: { code: "configured_tools_unavailable", details: { tools: ["missing"] } },
|
||||
},
|
||||
});
|
||||
expect(fixture.lane.state.operation).toBeNull();
|
||||
expect(fixture.lane.state.inbox).toEqual([{ entryId: queuedId, kind: "write" }]);
|
||||
expect(await fixture.session.getValue(storedValues.pendingEntry(queuedId), BACKGROUND_CONTEXT)).toBeDefined();
|
||||
expect(fixture.faux.state.callCount).toBe(0);
|
||||
expect(
|
||||
fixture.storage
|
||||
.getCommitAttempts()
|
||||
.flat()
|
||||
.some((write) => write.kind === "entry" || write.kind === "usage"),
|
||||
).toBe(false);
|
||||
await expectProjectionRestores(fixture);
|
||||
});
|
||||
|
||||
it("fails a missing captured model before reserving ids", async () => {
|
||||
const fixture = await createFixture();
|
||||
const ready = await advanceToReady(fixture);
|
||||
fixture.models.deleteProvider(fixture.faux.provider.id);
|
||||
fixture.storage.clearCommitAttempts();
|
||||
|
||||
expect(await runGeneration(fixture.lane, fixture.drive, ready)).toMatchObject({
|
||||
kind: "settled",
|
||||
outcome: { operationId, kind: "run", status: "failed", error: { code: "model_unavailable" } },
|
||||
});
|
||||
expect(fixture.lane.state.operation).toBeNull();
|
||||
expect(fixture.faux.state.callCount).toBe(0);
|
||||
expect(
|
||||
fixture.storage
|
||||
.getCommitAttempts()
|
||||
.flat()
|
||||
.some((write) => write.kind === "entry" || write.kind === "usage"),
|
||||
).toBe(false);
|
||||
await expectProjectionRestores(fixture);
|
||||
});
|
||||
|
||||
it("declines intent when cancellation wins preparation", async () => {
|
||||
const cancelled = await createFixture();
|
||||
const cancelledReady = await advanceToReady(cancelled);
|
||||
const cancelHookStarted = deferred();
|
||||
const releaseCancelHook = deferred();
|
||||
cancelled.hooks.on("before_request", async () => {
|
||||
cancelHookStarted.resolve();
|
||||
await releaseCancelHook.promise;
|
||||
return undefined;
|
||||
});
|
||||
cancelled.storage.clearCommitAttempts();
|
||||
const cancelling = runGeneration(cancelled.lane, cancelled.drive, cancelledReady);
|
||||
await cancelHookStarted.promise;
|
||||
await cancelled.lane.command((state) => {
|
||||
const operation = state.operation;
|
||||
if (operation === null) throw new Error("missing operation");
|
||||
const nextState: OperationState = {
|
||||
...operation.state,
|
||||
control: { status: "cancel_requested", requestedAt: 10 },
|
||||
};
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [storedValues.setValue(storedValues.operationState(operationId), nextState)],
|
||||
next: { ...state, operation: { meta: operation.meta, state: nextState } },
|
||||
materialize: () => undefined,
|
||||
};
|
||||
}, BACKGROUND_CONTEXT);
|
||||
releaseCancelHook.resolve();
|
||||
expect(await cancelling).toEqual({ kind: "continue" });
|
||||
expect(cancelled.faux.state.callCount).toBe(0);
|
||||
expect(
|
||||
cancelled.storage.getCommitAttempts().some((writes) => writesOperationState(writes, "effect_pending")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("recovers an orphan with no frames into a zero-usage retry wait", async () => {
|
||||
const fixture = await createFixture();
|
||||
const ready = await advanceToReady(fixture);
|
||||
const responseEntryId = "01950000-0000-7000-8000-000000000028";
|
||||
const usageId = "01950000-0000-7000-8000-000000000029";
|
||||
const pending: AssistantEffectPendingOperation = {
|
||||
...operationScopeOf(ready),
|
||||
at: "assistant.effect_pending",
|
||||
generationContext: { ...ready.generationContext, retryPolicy: { maxAttempts: 2, baseDelayMs: 1 } },
|
||||
attempt: 1,
|
||||
responseEntryId,
|
||||
usageId,
|
||||
intendedOutputLimit: 100,
|
||||
contextWindow: 1_000,
|
||||
};
|
||||
await fixture.lane.command((state) => {
|
||||
const operation = state.operation;
|
||||
if (operation === null) throw new Error("missing operation");
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [storedValues.setValue(storedValues.operationState(operationId), pending)],
|
||||
next: { ...state, operation: { meta: operation.meta, state: pending } },
|
||||
materialize: () => undefined,
|
||||
};
|
||||
}, BACKGROUND_CONTEXT);
|
||||
|
||||
await recoverAssistantGeneration(fixture.lane, fixture.drive, pending);
|
||||
|
||||
const entry = (await fixture.session.getEntry(responseEntryId, BACKGROUND_CONTEXT)) as MessageEntry;
|
||||
expect(entry.message).toMatchObject({
|
||||
api: "unknown",
|
||||
provider: ready.generationContext.configuration.model.provider,
|
||||
model: ready.generationContext.configuration.model.modelId,
|
||||
content: [],
|
||||
stopReason: "error",
|
||||
usage: { totalTokens: 0 },
|
||||
});
|
||||
expect(currentRun(fixture.lane)).toMatchObject({
|
||||
at: "assistant.retry_wait",
|
||||
nextAttempt: 2,
|
||||
});
|
||||
expect(fixture.faux.state.callCount).toBe(0);
|
||||
await expectProjectionRestores(fixture);
|
||||
});
|
||||
|
||||
it("recovers an orphan from committed frames without a provider or response hook", async () => {
|
||||
const fixture = await createFixture();
|
||||
const ready = await advanceToReady(fixture);
|
||||
const responseEntryId = "01950000-0000-7000-8000-000000000030";
|
||||
const usageId = "01950000-0000-7000-8000-000000000031";
|
||||
const partial: AssistantMessage = {
|
||||
...fauxAssistantMessage([], { timestamp: 6 }),
|
||||
stopReason: "pending",
|
||||
};
|
||||
const pending: AssistantEffectPendingOperation = {
|
||||
...operationScopeOf(ready),
|
||||
at: "assistant.effect_pending",
|
||||
generationContext: { ...ready.generationContext, retryPolicy: { maxAttempts: 1, baseDelayMs: 1 } },
|
||||
attempt: 1,
|
||||
responseEntryId,
|
||||
usageId,
|
||||
intendedOutputLimit: 100,
|
||||
contextWindow: 1_000,
|
||||
};
|
||||
await fixture.lane.command((state) => {
|
||||
const operation = state.operation;
|
||||
if (operation === null) throw new Error("missing operation");
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [
|
||||
storedValues.setValue(storedValues.operationState(operationId), pending),
|
||||
storedValues.appendList(storedValues.pendingAssistantFrames(operationId, responseEntryId), {
|
||||
type: "start",
|
||||
partial,
|
||||
}),
|
||||
storedValues.appendList(storedValues.pendingAssistantFrames(operationId, responseEntryId), {
|
||||
type: "text_start",
|
||||
contentIndex: 0,
|
||||
content: { type: "text", text: "" },
|
||||
}),
|
||||
storedValues.appendList(storedValues.pendingAssistantFrames(operationId, responseEntryId), {
|
||||
type: "text_delta",
|
||||
contentIndex: 0,
|
||||
delta: "draft",
|
||||
}),
|
||||
storedValues.appendList(storedValues.pendingAssistantFrames(operationId, responseEntryId), {
|
||||
type: "text_end",
|
||||
contentIndex: 0,
|
||||
content: "corrected",
|
||||
}),
|
||||
],
|
||||
next: { ...state, operation: { meta: operation.meta, state: pending } },
|
||||
materialize: () => undefined,
|
||||
};
|
||||
}, BACKGROUND_CONTEXT);
|
||||
let afterResponseCalls = 0;
|
||||
fixture.hooks.on("after_response", () => {
|
||||
afterResponseCalls++;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
expect(await recoverAssistantGeneration(fixture.lane, fixture.drive, pending)).toMatchObject({
|
||||
kind: "settled",
|
||||
outcome: { operationId, kind: "run", status: "failed", tipId: responseEntryId },
|
||||
});
|
||||
|
||||
expect(fixture.faux.state.callCount).toBe(0);
|
||||
expect(afterResponseCalls).toBe(0);
|
||||
const entry = (await fixture.session.getEntry(responseEntryId, BACKGROUND_CONTEXT)) as MessageEntry;
|
||||
expect(entry.message).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "corrected" }],
|
||||
stopReason: "error",
|
||||
usage: { input: 0, output: 0 },
|
||||
});
|
||||
expect(fixture.lane.state.operation).toBeNull();
|
||||
expect(
|
||||
await fixture.session.readList(
|
||||
storedValues.pendingAssistantFrames(operationId, responseEntryId),
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
fixture.events.filter((event) => "recovery" in event && event.recovery).map((event) => event.type),
|
||||
).toEqual(["message_start", "message_end", "entry_added"]);
|
||||
await expectProjectionRestores(fixture);
|
||||
});
|
||||
|
||||
it("finishes the no-tool run with terminal cleanup and an immutable result record", async () => {
|
||||
const fixture = await createFixture();
|
||||
const ready = await advanceToReady(fixture);
|
||||
fixture.faux.setResponses([fauxAssistantMessage("done", { timestamp: 7 })]);
|
||||
await runGeneration(fixture.lane, fixture.drive, ready);
|
||||
const run = currentRun(fixture.lane);
|
||||
if (run.at !== "checkpoint") throw new Error("missing terminal checkpoint");
|
||||
|
||||
const result = await runCheckpoint(fixture.lane, fixture.drive, run);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: "settled",
|
||||
outcome: { operationId, kind: "run", status: "completed", tipId: fixture.lane.state.tipId },
|
||||
});
|
||||
expect(fixture.lane.state.operation).toBeNull();
|
||||
expect(fixture.lane.state.lastOperationId).toBe(operationId);
|
||||
const getEntries = vi.spyOn(fixture.storage, "getEntries");
|
||||
expect(await fixture.lane.getResult(operationId, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
operationId,
|
||||
kind: "run",
|
||||
status: "completed",
|
||||
tipId: fixture.lane.state.tipId,
|
||||
});
|
||||
expect(await fixture.lane.getResult("unknown", BACKGROUND_CONTEXT)).toBeUndefined();
|
||||
expect(getEntries).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await fixture.session.getValue(storedValues.operationMeta(operationId), BACKGROUND_CONTEXT),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
await fixture.session.getValue(storedValues.operationState(operationId), BACKGROUND_CONTEXT),
|
||||
).toBeUndefined();
|
||||
expect(fixture.events.filter((event) => event.type === "run_end")).toHaveLength(1);
|
||||
await expectProjectionRestores(fixture);
|
||||
});
|
||||
});
|
||||
612
packages/agent/test/harness/runtime/drive-public.test.ts
Normal file
612
packages/agent/test/harness/runtime/drive-public.test.ts
Normal file
@@ -0,0 +1,612 @@
|
||||
import { createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
AgentHarness,
|
||||
type AgentLane,
|
||||
HarnessClosed,
|
||||
HarnessFault,
|
||||
type OperationAdmission,
|
||||
type Resources,
|
||||
} from "../../../src/harness/agent-harness.ts";
|
||||
import { BACKGROUND_CONTEXT, withCancel } from "../../../src/harness/context.ts";
|
||||
import { Lane } from "../../../src/harness/runtime/lane.ts";
|
||||
import { MemoryStorage } from "../../../src/harness/session/memory.ts";
|
||||
import { StorageBackedSession } from "../../../src/harness/session/session.ts";
|
||||
import type { Session } from "../../../src/harness/session/types.ts";
|
||||
import { deferred } from "./test-utils.ts";
|
||||
|
||||
const sessions: Session[] = [];
|
||||
|
||||
async function createFixture(options: { deferred?: boolean; resources?: Resources } = {}): Promise<{
|
||||
lane: AgentLane;
|
||||
runtimeLane: Lane<object | undefined>;
|
||||
harness: Awaited<ReturnType<typeof AgentHarness.create>>["harness"];
|
||||
faux: ReturnType<typeof fauxProvider>;
|
||||
session: Session;
|
||||
}> {
|
||||
const session = new StorageBackedSession(
|
||||
{ id: `public-drive-${sessions.length}`, createdAt: 1, storageVersion: 1 },
|
||||
new MemoryStorage(),
|
||||
);
|
||||
sessions.push(session);
|
||||
const faux = fauxProvider();
|
||||
const models = createModels();
|
||||
models.setProvider(faux.provider);
|
||||
const { harness } = await AgentHarness.create(
|
||||
{
|
||||
session,
|
||||
models,
|
||||
model: faux.getModel(),
|
||||
...(options.deferred === true ? { streamOptions: { deferred: true } } : {}),
|
||||
...(options.resources === undefined ? {} : { resources: options.resources }),
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const lane = await harness.lane("main", BACKGROUND_CONTEXT);
|
||||
if (!(lane instanceof Lane)) throw new Error("Expected runtime Lane");
|
||||
return { lane, runtimeLane: lane, harness, faux, session };
|
||||
}
|
||||
|
||||
async function acceptRun(lane: AgentLane, operationId: string): Promise<OperationAdmission> {
|
||||
const result = await lane.accept({ kind: "prompt", operationId, prompt: operationId }, BACKGROUND_CONTEXT);
|
||||
if (!result.ok) throw result.error;
|
||||
return result.value;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const session of sessions.splice(0)) await session.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
describe("runtime public drive", () => {
|
||||
it("persists model identity without requiring a local registration", async () => {
|
||||
const { lane, faux } = await createFixture();
|
||||
await lane.setModel({ provider: "missing", modelId: "missing-model" }, BACKGROUND_CONTEXT);
|
||||
|
||||
expect(await lane.getModel(BACKGROUND_CONTEXT)).toBeUndefined();
|
||||
expect(await lane.prompt("prompt", undefined, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "run",
|
||||
status: "failed",
|
||||
error: { code: "model_unavailable" },
|
||||
},
|
||||
});
|
||||
expect(faux.state.callCount).toBe(0);
|
||||
});
|
||||
|
||||
it("composes prompt, skill, and template acceptance with drive", async () => {
|
||||
const resources: Resources = {
|
||||
skills: [
|
||||
{ name: "review", description: "Review", content: "Inspect it", filePath: "/skills/review/SKILL.md" },
|
||||
],
|
||||
promptTemplates: [{ name: "fix", content: "Fix $1" }],
|
||||
};
|
||||
const { lane, faux } = await createFixture({ resources });
|
||||
faux.setResponses([
|
||||
fauxAssistantMessage("prompt answer"),
|
||||
fauxAssistantMessage("skill answer"),
|
||||
fauxAssistantMessage("template answer"),
|
||||
]);
|
||||
|
||||
expect(await lane.prompt("prompt", undefined, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "run", status: "completed" },
|
||||
});
|
||||
expect(await lane.skill("review", "strict", BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "run", status: "completed" },
|
||||
});
|
||||
expect(await lane.promptFromTemplate("fix", ["it"], BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "run", status: "completed" },
|
||||
});
|
||||
expect(faux.state.callCount).toBe(3);
|
||||
});
|
||||
|
||||
it("returns a convenience-only suspension observation", async () => {
|
||||
const { lane, runtimeLane, faux } = await createFixture({ deferred: true });
|
||||
faux.setResponses([fauxAssistantMessage("eventual answer")]);
|
||||
|
||||
expect(await lane.prompt("defer", undefined, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
operationId: expect.any(String),
|
||||
status: "suspended",
|
||||
deferred: { provider: "faux", modelId: "faux-1" },
|
||||
},
|
||||
});
|
||||
expect(runtimeLane.state.operation?.state.at).toBe("deferred.suspended");
|
||||
});
|
||||
|
||||
it("records caller usage as an adjustment and publishes committed totals", async () => {
|
||||
const { lane, harness, session } = await createFixture();
|
||||
const usage = fauxAssistantMessage("usage").usage;
|
||||
const events: unknown[] = [];
|
||||
harness.events.on("usage", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
const recorded = await lane.recordUsage(
|
||||
usage,
|
||||
{ entryId: "external", details: { source: "test" } },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
if (!recorded.ok) throw recorded.error;
|
||||
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "usage",
|
||||
lane: "main",
|
||||
row: expect.objectContaining({ id: recorded.value.usageId, adjustment: true }),
|
||||
totals: (await session.getStats(BACKGROUND_CONTEXT)).usage,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["steer", "followUp", "nextRun"] as const)(
|
||||
"starts an ordinary continuation run from queued %s input",
|
||||
async (kind) => {
|
||||
const { lane, faux } = await createFixture();
|
||||
await lane.appendMessage({ role: "user", content: "history", timestamp: 1 }, BACKGROUND_CONTEXT);
|
||||
const summaryStarted = deferred();
|
||||
const releaseSummary = deferred();
|
||||
faux.setResponses([
|
||||
async () => {
|
||||
summaryStarted.resolve();
|
||||
await releaseSummary.promise;
|
||||
return fauxAssistantMessage("summary");
|
||||
},
|
||||
fauxAssistantMessage("continuation answer"),
|
||||
]);
|
||||
const compacting = lane.compact(undefined, BACKGROUND_CONTEXT);
|
||||
await summaryStarted.promise;
|
||||
const queued = await lane[kind]("continue", undefined, BACKGROUND_CONTEXT);
|
||||
expect(queued.ok).toBe(true);
|
||||
releaseSummary.resolve();
|
||||
|
||||
expect(await compacting).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
compaction: { kind: "compaction", status: "completed" },
|
||||
run: { kind: "run", status: "completed" },
|
||||
},
|
||||
});
|
||||
expect(faux.state.callCount).toBe(2);
|
||||
},
|
||||
);
|
||||
|
||||
it("lets a competing acceptance win the structural continuation window", async () => {
|
||||
const { lane, harness, faux } = await createFixture();
|
||||
await lane.appendMessage({ role: "user", content: "history", timestamp: 1 }, BACKGROUND_CONTEXT);
|
||||
const summaryStarted = deferred();
|
||||
const releaseSummary = deferred();
|
||||
faux.setResponses([
|
||||
async () => {
|
||||
summaryStarted.resolve();
|
||||
await releaseSummary.promise;
|
||||
return fauxAssistantMessage("summary");
|
||||
},
|
||||
fauxAssistantMessage("competitor answer"),
|
||||
]);
|
||||
let competing: ReturnType<typeof lane.accept> | undefined;
|
||||
harness.events.on("compaction_end", (event) => {
|
||||
if (event.status === "completed") {
|
||||
competing = lane.accept({ kind: "prompt", prompt: "competitor" }, BACKGROUND_CONTEXT);
|
||||
}
|
||||
});
|
||||
const compacting = lane.compact(undefined, BACKGROUND_CONTEXT);
|
||||
await summaryStarted.promise;
|
||||
await lane.nextRun("queued", undefined, BACKGROUND_CONTEXT);
|
||||
releaseSummary.resolve();
|
||||
|
||||
const compacted = await compacting;
|
||||
expect(compacted).toMatchObject({ ok: true, value: { compaction: { status: "completed" } } });
|
||||
if (!compacted.ok) throw compacted.error;
|
||||
expect(compacted.value).not.toHaveProperty("run");
|
||||
if (competing === undefined) throw new Error("Competing acceptance did not start");
|
||||
const admission = await competing;
|
||||
if (!admission.ok) throw admission.error;
|
||||
expect(await lane.drive({ operationId: admission.value.operationId }, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "settled", outcome: { status: "completed" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels queued input and reports consumed or missing ids", async () => {
|
||||
const { lane, faux } = await createFixture();
|
||||
const cancelled = await lane.nextRun("cancel", undefined, BACKGROUND_CONTEXT);
|
||||
if (!cancelled.ok) throw cancelled.error;
|
||||
expect(await lane.cancelQueued(cancelled.value.entryId, BACKGROUND_CONTEXT)).toEqual({
|
||||
ok: true,
|
||||
value: { kind: "cancelled" },
|
||||
});
|
||||
expect(await lane.cancelQueued(cancelled.value.entryId, BACKGROUND_CONTEXT)).toEqual({
|
||||
ok: true,
|
||||
value: { kind: "not_found" },
|
||||
});
|
||||
|
||||
const consumed = await lane.nextRun("consume", undefined, BACKGROUND_CONTEXT);
|
||||
if (!consumed.ok) throw consumed.error;
|
||||
const admission = await lane.accept({ kind: "prompt", prompt: "" }, BACKGROUND_CONTEXT);
|
||||
if (!admission.ok) throw admission.error;
|
||||
expect(await lane.cancelQueued(consumed.value.entryId, BACKGROUND_CONTEXT)).toEqual({
|
||||
ok: true,
|
||||
value: { kind: "already_consumed" },
|
||||
});
|
||||
faux.setResponses([fauxAssistantMessage("answer")]);
|
||||
await lane.drive({ operationId: admission.value.operationId }, BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
it("admits input after cancellation and preserves it through reconciliation", async () => {
|
||||
const { lane, runtimeLane, faux } = await createFixture();
|
||||
await acceptRun(lane, "cancelled");
|
||||
await lane.requestAbort("cancelled", BACKGROUND_CONTEXT);
|
||||
const late = await lane.steer("late", undefined, BACKGROUND_CONTEXT);
|
||||
expect(late.ok).toBe(true);
|
||||
expect(await lane.drive({ operationId: "cancelled" }, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "settled", outcome: { status: "aborted" } },
|
||||
});
|
||||
expect(runtimeLane.state.inbox).toHaveLength(1);
|
||||
|
||||
faux.setResponses([fauxAssistantMessage("late answer")]);
|
||||
expect(await lane.prompt("", undefined, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "run", status: "completed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("composes standalone compaction acceptance with drive", async () => {
|
||||
const { lane, faux } = await createFixture();
|
||||
await lane.appendMessage({ role: "user", content: "history", timestamp: 1 }, BACKGROUND_CONTEXT);
|
||||
faux.setResponses([fauxAssistantMessage("summary")]);
|
||||
|
||||
expect(await lane.compact(undefined, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { compaction: { kind: "compaction", status: "completed" } },
|
||||
});
|
||||
expect(faux.state.callCount).toBe(1);
|
||||
});
|
||||
|
||||
it.each([false, true])("composes %s summarized navigation acceptance with drive", async (summarize) => {
|
||||
const { lane, session, faux } = await createFixture();
|
||||
const rootId = await lane.appendMessage({ role: "user", content: "root", timestamp: 1 }, BACKGROUND_CONTEXT);
|
||||
await lane.appendMessage({ role: "user", content: "source", timestamp: 2 }, BACKGROUND_CONTEXT);
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
{
|
||||
kind: "entry",
|
||||
entry: {
|
||||
id: "target",
|
||||
parentId: rootId,
|
||||
type: "message",
|
||||
message: { role: "user", content: "target", timestamp: 3 },
|
||||
},
|
||||
},
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
if (summarize) faux.setResponses([fauxAssistantMessage("branch summary")]);
|
||||
|
||||
expect(await lane.navigateTree("target", { summarize, label: "chosen" }, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { navigation: { kind: "navigation", status: "completed" } },
|
||||
});
|
||||
expect(faux.state.callCount).toBe(summarize ? 1 : 0);
|
||||
expect(await lane.getTipId(BACKGROUND_CONTEXT)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("resumes any current operation after acceptance or reopen", async () => {
|
||||
const { lane, faux } = await createFixture();
|
||||
await acceptRun(lane, "run");
|
||||
faux.setResponses([fauxAssistantMessage("answer")]);
|
||||
|
||||
expect(await lane.resume(BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { operationId: "run", kind: "run", status: "completed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("polls one deferred permit through resume", async () => {
|
||||
const { lane, runtimeLane, faux } = await createFixture({ deferred: true });
|
||||
faux.setResponses([fauxAssistantMessage("eventual answer")]);
|
||||
const suspended = await lane.prompt("defer", undefined, BACKGROUND_CONTEXT);
|
||||
if (!suspended.ok || suspended.value.status !== "suspended") throw new Error("Expected suspended run");
|
||||
|
||||
expect(await lane.resume(BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { operationId: suspended.value.operationId, kind: "run", status: "completed" },
|
||||
});
|
||||
expect(runtimeLane.state.operation).toBeNull();
|
||||
expect(faux.state.deferredFetchCount).toBe(1);
|
||||
expect(await lane.resume(BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "NothingToResume" },
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts and reconciles the current operation", async () => {
|
||||
const { lane, runtimeLane, faux } = await createFixture();
|
||||
await acceptRun(lane, "run");
|
||||
|
||||
expect(await lane.abort(BACKGROUND_CONTEXT)).toEqual({
|
||||
ok: true,
|
||||
value: { operationId: "run", steer: [], followUp: [] },
|
||||
});
|
||||
expect(runtimeLane.state.operation).toBeNull();
|
||||
expect(faux.state.callCount).toBe(0);
|
||||
expect(await lane.abort(BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "NoActiveOperation" },
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for an operation that has no installed drive", async () => {
|
||||
const { lane, faux } = await createFixture();
|
||||
await acceptRun(lane, "run");
|
||||
let idle = false;
|
||||
const waiting = lane.waitForIdle(BACKGROUND_CONTEXT).then(() => {
|
||||
idle = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(idle).toBe(false);
|
||||
|
||||
faux.setResponses([fauxAssistantMessage("answer")]);
|
||||
await lane.drive({ operationId: "run" }, BACKGROUND_CONTEXT);
|
||||
await waiting;
|
||||
expect(idle).toBe(true);
|
||||
});
|
||||
|
||||
it("serializes concurrent runWhenIdle callbacks", async () => {
|
||||
const { lane } = await createFixture();
|
||||
const firstStarted = deferred();
|
||||
const releaseFirst = deferred();
|
||||
const order: string[] = [];
|
||||
const first = lane.runWhenIdle(async () => {
|
||||
order.push("first:start");
|
||||
firstStarted.resolve();
|
||||
await releaseFirst.promise;
|
||||
order.push("first:end");
|
||||
}, BACKGROUND_CONTEXT);
|
||||
await firstStarted.promise;
|
||||
const second = lane.runWhenIdle(() => {
|
||||
order.push("second");
|
||||
}, BACKGROUND_CONTEXT);
|
||||
await Promise.resolve();
|
||||
expect(order).toEqual(["first:start"]);
|
||||
|
||||
releaseFirst.resolve();
|
||||
await Promise.all([first, second]);
|
||||
expect(order).toEqual(["first:start", "first:end", "second"]);
|
||||
});
|
||||
|
||||
it("owns the idle window while runWhenIdle executes", async () => {
|
||||
const { lane, faux } = await createFixture();
|
||||
const callbackStarted = deferred();
|
||||
const releaseCallback = deferred();
|
||||
const callback = lane.runWhenIdle(async () => {
|
||||
callbackStarted.resolve();
|
||||
await releaseCallback.promise;
|
||||
}, BACKGROUND_CONTEXT);
|
||||
await callbackStarted.promise;
|
||||
let accepted = false;
|
||||
const acceptance = lane.accept({ kind: "prompt", prompt: "after" }, BACKGROUND_CONTEXT).then((result) => {
|
||||
accepted = true;
|
||||
return result;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(accepted).toBe(false);
|
||||
|
||||
releaseCallback.resolve();
|
||||
await callback;
|
||||
const admission = await acceptance;
|
||||
if (!admission.ok) throw admission.error;
|
||||
faux.setResponses([fauxAssistantMessage("answer")]);
|
||||
await lane.drive({ operationId: admission.value.operationId }, BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
it("allows coherent lane reads from an idle callback", async () => {
|
||||
const { lane } = await createFixture();
|
||||
|
||||
await lane.runWhenIdle(async (context) => {
|
||||
const execution = await lane.inspectExecution(context);
|
||||
const watch = await lane.watch(context);
|
||||
expect(execution.current).toBeNull();
|
||||
expect(watch.snapshot.operation).toBeNull();
|
||||
watch.unsubscribe();
|
||||
}, BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
it("releases idle ownership when the callback fails", async () => {
|
||||
const { lane } = await createFixture();
|
||||
const failure = new Error("callback failed");
|
||||
|
||||
await expect(
|
||||
lane.runWhenIdle(() => {
|
||||
throw failure;
|
||||
}, BACKGROUND_CONTEXT),
|
||||
).rejects.toBe(failure);
|
||||
expect(await lane.accept({ kind: "prompt", prompt: "after" }, BACKGROUND_CONTEXT)).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("close waits for an already-running idle callback", async () => {
|
||||
const { lane, harness } = await createFixture();
|
||||
const callbackStarted = deferred();
|
||||
const releaseCallback = deferred();
|
||||
const callback = lane.runWhenIdle(async () => {
|
||||
callbackStarted.resolve();
|
||||
await releaseCallback.promise;
|
||||
}, BACKGROUND_CONTEXT);
|
||||
await callbackStarted.promise;
|
||||
let closed = false;
|
||||
const closing = harness.close(BACKGROUND_CONTEXT).then(() => {
|
||||
closed = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(closed).toBe(false);
|
||||
|
||||
releaseCallback.resolve();
|
||||
await Promise.all([callback, closing]);
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
|
||||
it("installs one pass and joins same-operation callers", async () => {
|
||||
const { lane, runtimeLane, faux } = await createFixture();
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
faux.setResponses([
|
||||
async () => {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
return fauxAssistantMessage("answer");
|
||||
},
|
||||
]);
|
||||
const admission = await acceptRun(lane, "run");
|
||||
|
||||
const first = lane.drive({ operationId: admission.operationId }, BACKGROUND_CONTEXT);
|
||||
await started.promise;
|
||||
const second = lane.drive({ operationId: admission.operationId }, BACKGROUND_CONTEXT);
|
||||
expect(runtimeLane.activeDrive?.operationId).toBe(admission.operationId);
|
||||
release.resolve();
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
expect(firstResult).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "settled", outcome: { operationId: "run", status: "completed" } },
|
||||
});
|
||||
expect(secondResult).toEqual(firstResult);
|
||||
expect(faux.state.callCount).toBe(1);
|
||||
expect(runtimeLane.activeDrive).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns old result records without disturbing the current operation", async () => {
|
||||
const { lane, runtimeLane, faux } = await createFixture();
|
||||
faux.setResponses([fauxAssistantMessage("first")]);
|
||||
await acceptRun(lane, "first");
|
||||
expect(await lane.drive({ operationId: "first" }, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "settled", outcome: { operationId: "first" } },
|
||||
});
|
||||
await acceptRun(lane, "second");
|
||||
|
||||
expect(await lane.drive({ operationId: "first" }, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "settled", outcome: { operationId: "first" } },
|
||||
});
|
||||
expect(runtimeLane.state.operation?.meta.operationId).toBe("second");
|
||||
expect(runtimeLane.activeDrive).toBeUndefined();
|
||||
|
||||
faux.setResponses([fauxAssistantMessage("second")]);
|
||||
expect(await lane.drive({ operationId: "second" }, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "settled", outcome: { operationId: "second" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("isolates stale operation ids", async () => {
|
||||
const { lane, runtimeLane, faux } = await createFixture();
|
||||
await acceptRun(lane, "current");
|
||||
|
||||
expect(await lane.drive({ operationId: "stale" }, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "OperationMismatch", expectedOperationId: "stale", currentOperationId: "current" },
|
||||
});
|
||||
expect(runtimeLane.activeDrive).toBeUndefined();
|
||||
expect(faux.state.callCount).toBe(0);
|
||||
});
|
||||
|
||||
it("does not install for a caller already cancelled", async () => {
|
||||
const { lane, runtimeLane, faux } = await createFixture();
|
||||
await acceptRun(lane, "run");
|
||||
const caller = withCancel(BACKGROUND_CONTEXT);
|
||||
const cancelled = new Error("caller cancelled");
|
||||
caller.cancel(cancelled);
|
||||
|
||||
await expect(lane.drive({ operationId: "run" }, caller.context)).rejects.toBe(cancelled);
|
||||
expect(runtimeLane.activeDrive).toBeUndefined();
|
||||
expect(faux.state.callCount).toBe(0);
|
||||
});
|
||||
|
||||
it("caller cancellation stops only that caller's observation", async () => {
|
||||
const { lane, runtimeLane, faux } = await createFixture();
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
faux.setResponses([
|
||||
async () => {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
return fauxAssistantMessage("answer");
|
||||
},
|
||||
]);
|
||||
await acceptRun(lane, "run");
|
||||
const owner = lane.drive({ operationId: "run" }, BACKGROUND_CONTEXT);
|
||||
await started.promise;
|
||||
const caller = withCancel(BACKGROUND_CONTEXT);
|
||||
const observer = lane.drive({ operationId: "run" }, caller.context);
|
||||
const cancelled = new Error("observer cancelled");
|
||||
caller.cancel(cancelled);
|
||||
|
||||
await expect(observer).rejects.toBe(cancelled);
|
||||
expect(runtimeLane.activeDrive?.operationId).toBe("run");
|
||||
release.resolve();
|
||||
expect(await owner).toMatchObject({ ok: true, value: { kind: "settled" } });
|
||||
expect(faux.state.callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("close rejects observation without waiting for a non-cooperative effect", async () => {
|
||||
const { lane, runtimeLane, harness, faux } = await createFixture();
|
||||
const started = deferred();
|
||||
const release = deferred();
|
||||
faux.setResponses([
|
||||
async () => {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
return fauxAssistantMessage("late");
|
||||
},
|
||||
]);
|
||||
await acceptRun(lane, "run");
|
||||
const observation = lane.drive({ operationId: "run" }, BACKGROUND_CONTEXT);
|
||||
await started.promise;
|
||||
|
||||
await harness.close(BACKGROUND_CONTEXT);
|
||||
await expect(observation).rejects.toBeInstanceOf(HarnessClosed);
|
||||
release.resolve();
|
||||
await expect.poll(() => runtimeLane.activeDrive).toBeUndefined();
|
||||
});
|
||||
|
||||
it("exposes durable abort and reconciles through public drive", async () => {
|
||||
const { lane, runtimeLane, faux } = await createFixture();
|
||||
await acceptRun(lane, "run");
|
||||
|
||||
expect(await lane.requestAbort("run", BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { operationId: "run", newlyRequested: true, steer: [], followUp: [] },
|
||||
});
|
||||
expect(runtimeLane.activeDrive).toBeUndefined();
|
||||
expect(await lane.drive({ operationId: "run" }, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "settled", outcome: { operationId: "run", status: "aborted" } },
|
||||
});
|
||||
expect(faux.state.callCount).toBe(0);
|
||||
expect(await lane.requestAbort("run", BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "OperationMismatch", expectedOperationId: "run", lastOperationId: "run" },
|
||||
});
|
||||
});
|
||||
|
||||
it("faults the harness when a detached pass fails", async () => {
|
||||
const { lane, runtimeLane, harness } = await createFixture();
|
||||
harness.hooks.on("before_drive", () => {
|
||||
throw new Error("drive failed");
|
||||
});
|
||||
await acceptRun(lane, "run");
|
||||
|
||||
await expect(lane.drive({ operationId: "run" }, BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(HarnessFault);
|
||||
expect(runtimeLane.activeDrive).toBeUndefined();
|
||||
await expect(lane.getTipId(BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(HarnessFault);
|
||||
});
|
||||
});
|
||||
747
packages/agent/test/harness/runtime/drive-reconcile.test.ts
Normal file
747
packages/agent/test/harness/runtime/drive-reconcile.test.ts
Normal file
@@ -0,0 +1,747 @@
|
||||
import {
|
||||
createModels,
|
||||
type DeferredHandle,
|
||||
fauxAssistantMessage,
|
||||
fauxProvider,
|
||||
fauxToolCall,
|
||||
type MutableModels,
|
||||
type Provider,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HarnessEvent, WatchHandle } from "../../../src/harness/agent-harness.ts";
|
||||
import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/harness/compaction/compaction.ts";
|
||||
import { BACKGROUND_CONTEXT } from "../../../src/harness/context.ts";
|
||||
import { HookRegistry } from "../../../src/harness/hooks.ts";
|
||||
import { reconcileOperation } from "../../../src/harness/runtime/drive/reconcile.ts";
|
||||
import { runStructuralDecision } from "../../../src/harness/runtime/drive/structural.ts";
|
||||
import { driveOperation } from "../../../src/harness/runtime/drive.ts";
|
||||
import { Lane } from "../../../src/harness/runtime/lane.ts";
|
||||
import { restoreLane } from "../../../src/harness/runtime/restore.ts";
|
||||
import { type Config, Drive } from "../../../src/harness/runtime/types.ts";
|
||||
import { insertEntry } from "../../../src/harness/session/commit.ts";
|
||||
import { MemoryStorage } from "../../../src/harness/session/memory.ts";
|
||||
import { StorageBackedSession } from "../../../src/harness/session/session.ts";
|
||||
import { InstrumentedStorage } from "../../../src/harness/session/testing/instrumented-storage.ts";
|
||||
import type {
|
||||
Control,
|
||||
DeferredEffectPendingOperation,
|
||||
DeferredSuspendedOperation,
|
||||
LaneConfiguration,
|
||||
NewEntry,
|
||||
OperationMeta,
|
||||
OperationScope,
|
||||
OperationState,
|
||||
Session,
|
||||
SummaryContext,
|
||||
SummaryTask,
|
||||
Write,
|
||||
} from "../../../src/harness/session/types.ts";
|
||||
import * as storedValues from "../../../src/harness/session/values.ts";
|
||||
import type { AgentMessage } from "../../../src/types.ts";
|
||||
|
||||
const sessions: Session[] = [];
|
||||
const operationId = "01950000-0000-7000-8000-000000000001";
|
||||
|
||||
interface Fixture {
|
||||
lane: Lane<undefined>;
|
||||
drive: Drive;
|
||||
session: Session;
|
||||
storage: InstrumentedStorage;
|
||||
models: MutableModels;
|
||||
faux: ReturnType<typeof fauxProvider>;
|
||||
configuration: LaneConfiguration;
|
||||
events: HarnessEvent[];
|
||||
hooks: HookRegistry;
|
||||
}
|
||||
|
||||
interface InstalledOperation {
|
||||
state: OperationState;
|
||||
intent: OperationMeta["intent"];
|
||||
entries?: NewEntry[];
|
||||
writes?: Write[];
|
||||
terminalEvents: HarnessEvent["type"][];
|
||||
}
|
||||
|
||||
function unusedWatch<T>(): WatchHandle<T> {
|
||||
throw new Error("watch is not used by reconciliation tests");
|
||||
}
|
||||
|
||||
function cancelledControl(): Control {
|
||||
return { status: "cancel_requested", requestedAt: 10 };
|
||||
}
|
||||
|
||||
function scope(control: Control = cancelledControl()): OperationScope {
|
||||
return {
|
||||
control,
|
||||
settings: {
|
||||
compaction: DEFAULT_COMPACTION_SETTINGS,
|
||||
steeringMode: "all",
|
||||
followUpMode: "all",
|
||||
toolExecution: "parallel",
|
||||
},
|
||||
latestAssistantEntryId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function user(content: string): AgentMessage {
|
||||
return { role: "user", content, timestamp: 1 };
|
||||
}
|
||||
|
||||
function summaryContext(configuration: LaneConfiguration): SummaryContext {
|
||||
return {
|
||||
resultEntryId: "summary-entry",
|
||||
configuration,
|
||||
streamOptions: {},
|
||||
retryPolicy: { maxAttempts: 2, baseDelayMs: 10 },
|
||||
};
|
||||
}
|
||||
|
||||
async function createFixture(): Promise<Fixture> {
|
||||
const storage = new InstrumentedStorage(new MemoryStorage({ now: () => 100 }));
|
||||
const session = new StorageBackedSession(
|
||||
{ id: `reconcile-${sessions.length}`, createdAt: 1, storageVersion: 1 },
|
||||
storage,
|
||||
);
|
||||
sessions.push(session);
|
||||
const faux = fauxProvider({ api: "faux", deferred: { pendingFetches: 0 } });
|
||||
const model = faux.getModel();
|
||||
const models = createModels();
|
||||
models.setProvider(faux.provider);
|
||||
const configuration: LaneConfiguration = {
|
||||
model: { provider: model.provider, modelId: model.id },
|
||||
thinkingLevel: "off",
|
||||
activeToolNames: [],
|
||||
};
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.branchTip("main"), null),
|
||||
storedValues.setValue(storedValues.laneConfig("main"), configuration),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const hooks = new HookRegistry(() => {});
|
||||
const events: HarnessEvent[] = [];
|
||||
const config: Config<undefined> = {
|
||||
tools: [],
|
||||
resources: {},
|
||||
streamOptions: {},
|
||||
retryPolicy: { enabled: true, maxRetries: 1, baseDelayMs: 10 },
|
||||
compaction: DEFAULT_COMPACTION_SETTINGS,
|
||||
steeringMode: "all",
|
||||
followUpMode: "all",
|
||||
toolExecution: "parallel",
|
||||
toolContext: undefined,
|
||||
systemPrompt: undefined,
|
||||
toProviderMessages: (messages) =>
|
||||
messages.filter(
|
||||
(message) => message.role === "user" || message.role === "assistant" || message.role === "toolResult",
|
||||
),
|
||||
entryProjectors: {},
|
||||
};
|
||||
const lane = new Lane<undefined>(
|
||||
"main",
|
||||
session,
|
||||
models,
|
||||
hooks,
|
||||
await restoreLane(session, "main", BACKGROUND_CONTEXT),
|
||||
(cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
(batch) => {
|
||||
events.push(...structuredClone(batch));
|
||||
return Promise.resolve();
|
||||
},
|
||||
unusedWatch,
|
||||
() => config,
|
||||
);
|
||||
const drive = new Drive({ operationId }, BACKGROUND_CONTEXT);
|
||||
lane.activeDrive = drive;
|
||||
storage.clearCommitAttempts();
|
||||
return { lane, drive, session, storage, models, faux, configuration, events, hooks };
|
||||
}
|
||||
|
||||
async function installOperation(fixture: Fixture, installed: InstalledOperation): Promise<void> {
|
||||
const entries = installed.entries ?? [
|
||||
{ id: "tip", parentId: null, type: "message" as const, message: user("history") },
|
||||
];
|
||||
const tipId = entries.at(-1)?.id ?? null;
|
||||
const meta: OperationMeta = {
|
||||
operationId,
|
||||
lane: "main",
|
||||
sourceTipId: tipId,
|
||||
startedAt: 1,
|
||||
intent: installed.intent,
|
||||
};
|
||||
await fixture.lane.command(
|
||||
(projection) => ({
|
||||
kind: "commit",
|
||||
writes: [
|
||||
...entries.map((entry) => insertEntry(entry)),
|
||||
storedValues.setValue(storedValues.branchTip("main"), tipId),
|
||||
...(installed.writes ?? []),
|
||||
storedValues.setValue(storedValues.operationMeta(operationId), meta),
|
||||
storedValues.setValue(storedValues.operationState(operationId), installed.state),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: operationId,
|
||||
lastOperationId: projection.lastOperationId,
|
||||
inbox: projection.inbox,
|
||||
}),
|
||||
],
|
||||
next: { ...projection, tipId, operation: { meta, state: installed.state } },
|
||||
materialize: () => undefined,
|
||||
}),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
fixture.storage.clearCommitAttempts();
|
||||
}
|
||||
|
||||
function deferredOperation(
|
||||
fixture: Fixture,
|
||||
effectPending: boolean,
|
||||
): { state: DeferredSuspendedOperation | DeferredEffectPendingOperation; entry: NewEntry; handle: DeferredHandle } {
|
||||
const model = fixture.faux.getModel();
|
||||
const handle = { provider: model.provider, modelId: model.id, api: model.api, id: "deferred-job" };
|
||||
const entry: NewEntry = {
|
||||
id: "deferred-source",
|
||||
parentId: null,
|
||||
type: "message",
|
||||
message: fauxAssistantMessage([], { stopReason: "deferred", deferred: handle }),
|
||||
};
|
||||
const base = {
|
||||
...scope(),
|
||||
stepId: "step",
|
||||
sourceEntryId: entry.id,
|
||||
poll: effectPending ? 1 : 0,
|
||||
configuration: fixture.configuration,
|
||||
streamOptions: {},
|
||||
};
|
||||
return {
|
||||
state: effectPending
|
||||
? {
|
||||
...base,
|
||||
at: "deferred.effect_pending",
|
||||
responseEntryId: "deferred-response",
|
||||
usageId: "deferred-usage",
|
||||
}
|
||||
: { ...base, at: "deferred.suspended" },
|
||||
entry,
|
||||
handle,
|
||||
};
|
||||
}
|
||||
|
||||
function cases(fixture: Fixture): InstalledOperation[] {
|
||||
const generation = {
|
||||
stepId: "step",
|
||||
triggerEntryId: "tip",
|
||||
configuration: fixture.configuration,
|
||||
streamOptions: {},
|
||||
retryPolicy: { maxAttempts: 2, baseDelayMs: 10 },
|
||||
overflowRecoveryUsed: false,
|
||||
};
|
||||
const runTask: SummaryTask = {
|
||||
taskId: "run-summary",
|
||||
reason: "threshold",
|
||||
boundary: {
|
||||
kind: "resume_checkpoint",
|
||||
resumeAfter: {
|
||||
continuation: { kind: "need_assistant", overflowRecoveryUsed: false },
|
||||
triggerEntryId: "tip",
|
||||
},
|
||||
},
|
||||
};
|
||||
const compactionTask: SummaryTask = { taskId: "compaction", reason: "manual", boundary: { kind: "finish" } };
|
||||
const navigationTask: SummaryTask = {
|
||||
taskId: "navigation",
|
||||
boundary: { kind: "commit_navigation", targetId: "target" },
|
||||
};
|
||||
const suspended = deferredOperation(fixture, false);
|
||||
const deferredEffect = deferredOperation(fixture, true);
|
||||
return [
|
||||
{
|
||||
state: { ...scope(), at: "starting" },
|
||||
intent: { kind: "run", promptEntryIds: ["tip"] },
|
||||
terminalEvents: ["run_end"],
|
||||
},
|
||||
{
|
||||
state: {
|
||||
...scope(),
|
||||
at: "checkpoint",
|
||||
continuation: { kind: "need_assistant", overflowRecoveryUsed: false },
|
||||
triggerEntryId: "tip",
|
||||
},
|
||||
intent: { kind: "run", promptEntryIds: ["tip"] },
|
||||
terminalEvents: ["run_end"],
|
||||
},
|
||||
{
|
||||
state: { ...scope(), at: "assistant.ready", generationContext: generation, nextAttempt: 1 },
|
||||
intent: { kind: "run", promptEntryIds: ["tip"] },
|
||||
terminalEvents: ["run_end"],
|
||||
},
|
||||
{
|
||||
state: {
|
||||
...scope(),
|
||||
at: "assistant.effect_pending",
|
||||
generationContext: generation,
|
||||
attempt: 1,
|
||||
responseEntryId: "assistant-response",
|
||||
usageId: "assistant-usage",
|
||||
intendedOutputLimit: 100,
|
||||
contextWindow: 1_000,
|
||||
},
|
||||
intent: { kind: "run", promptEntryIds: ["tip"] },
|
||||
writes: [
|
||||
storedValues.appendList(storedValues.pendingAssistantFrames(operationId, "assistant-response"), {
|
||||
type: "text_delta",
|
||||
contentIndex: 0,
|
||||
delta: "partial",
|
||||
}),
|
||||
],
|
||||
terminalEvents: ["run_end"],
|
||||
},
|
||||
{
|
||||
state: {
|
||||
...scope(),
|
||||
at: "assistant.retry_wait",
|
||||
generationContext: generation,
|
||||
nextAttempt: 2,
|
||||
notBefore: Date.now() + 100_000,
|
||||
errorMessage: "retry",
|
||||
},
|
||||
intent: { kind: "run", promptEntryIds: ["tip"] },
|
||||
terminalEvents: ["run_end"],
|
||||
},
|
||||
{
|
||||
state: {
|
||||
...scope(),
|
||||
at: "tools",
|
||||
batch: {
|
||||
assistantEntryId: "assistant",
|
||||
configuration: fixture.configuration,
|
||||
turnId: "turn",
|
||||
calls: [{ status: "planned", sourceIndex: 0, resultEntryId: "tool-result" }],
|
||||
},
|
||||
},
|
||||
intent: { kind: "run", promptEntryIds: [] },
|
||||
entries: [
|
||||
{
|
||||
id: "assistant",
|
||||
parentId: null,
|
||||
type: "message",
|
||||
message: fauxAssistantMessage(fauxToolCall("tool", {}), { stopReason: "toolUse" }),
|
||||
},
|
||||
],
|
||||
terminalEvents: ["run_end"],
|
||||
},
|
||||
{
|
||||
state: suspended.state,
|
||||
intent: { kind: "run", promptEntryIds: [] },
|
||||
entries: [suspended.entry],
|
||||
terminalEvents: ["run_end"],
|
||||
},
|
||||
{
|
||||
state: deferredEffect.state,
|
||||
intent: { kind: "run", promptEntryIds: [] },
|
||||
entries: [deferredEffect.entry],
|
||||
terminalEvents: ["run_end"],
|
||||
},
|
||||
{
|
||||
state: { ...scope(), at: "summary.deciding", task: runTask },
|
||||
intent: { kind: "run", promptEntryIds: ["tip"] },
|
||||
terminalEvents: ["compaction_end", "run_end"],
|
||||
},
|
||||
{
|
||||
state: {
|
||||
...scope(),
|
||||
at: "summary.ready",
|
||||
task: compactionTask,
|
||||
summaryContext: summaryContext(fixture.configuration),
|
||||
nextAttempt: 1,
|
||||
},
|
||||
intent: { kind: "compaction" },
|
||||
terminalEvents: ["compaction_end"],
|
||||
},
|
||||
{
|
||||
state: {
|
||||
...scope(),
|
||||
at: "summary.effect_pending",
|
||||
task: navigationTask,
|
||||
summaryContext: summaryContext(fixture.configuration),
|
||||
attempt: 1,
|
||||
request: { index: 0, usageId: "usage" },
|
||||
usageIds: [],
|
||||
},
|
||||
intent: { kind: "navigation", targetId: "target", summarize: true },
|
||||
terminalEvents: ["navigation_end"],
|
||||
},
|
||||
{
|
||||
state: {
|
||||
...scope(),
|
||||
at: "summary.retry_wait",
|
||||
task: runTask,
|
||||
summaryContext: summaryContext(fixture.configuration),
|
||||
nextAttempt: 2,
|
||||
notBefore: Date.now() + 100_000,
|
||||
errorMessage: "retry",
|
||||
},
|
||||
intent: { kind: "run", promptEntryIds: ["tip"] },
|
||||
terminalEvents: ["compaction_end", "run_end"],
|
||||
},
|
||||
{
|
||||
state: { ...scope(), at: "navigation.ready_to_commit", targetId: "target" },
|
||||
intent: { kind: "navigation", targetId: "target", summarize: false },
|
||||
terminalEvents: ["navigation_end"],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
for (const session of sessions.splice(0)) await session.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
describe("runtime total drive", () => {
|
||||
it("drives an ordinary run through all direct procedures with one before_drive hook", async () => {
|
||||
const fixture = await createFixture();
|
||||
await installOperation(fixture, {
|
||||
state: { ...scope({ status: "running" }), at: "starting" },
|
||||
intent: { kind: "run", promptEntryIds: ["tip"] },
|
||||
terminalEvents: ["run_end"],
|
||||
});
|
||||
const beforeDrive = vi.fn();
|
||||
fixture.hooks.on("before_drive", beforeDrive);
|
||||
fixture.faux.setResponses([fauxAssistantMessage("answer")]);
|
||||
|
||||
expect(await driveOperation(fixture.lane, fixture.drive)).toMatchObject({
|
||||
kind: "settled",
|
||||
outcome: { operationId, kind: "run", status: "completed" },
|
||||
});
|
||||
expect(beforeDrive).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.faux.state.callCount).toBe(1);
|
||||
expect(fixture.lane.state.operation).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves durable state unchanged when before_drive fails closed", async () => {
|
||||
const fixture = await createFixture();
|
||||
const starting = { ...scope({ status: "running" }), at: "starting" } as const;
|
||||
await installOperation(fixture, {
|
||||
state: starting,
|
||||
intent: { kind: "run", promptEntryIds: ["tip"] },
|
||||
terminalEvents: ["run_end"],
|
||||
});
|
||||
fixture.hooks.on("before_drive", () => {
|
||||
throw new Error("blocked drive");
|
||||
});
|
||||
|
||||
await expect(driveOperation(fixture.lane, fixture.drive)).rejects.toThrow("blocked drive");
|
||||
expect(fixture.lane.state.operation?.state).toEqual(starting);
|
||||
expect(fixture.storage.getCommitAttempts()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime cancellation reconciliation", () => {
|
||||
it("reconciles every durable leaf without ordinary hook admission", async () => {
|
||||
for (let index = 0; index < 13; index++) {
|
||||
const fixture = await createFixture();
|
||||
const installed = cases(fixture)[index]!;
|
||||
await installOperation(fixture, installed);
|
||||
const beforeDrive = vi.fn();
|
||||
fixture.hooks.on("before_drive", beforeDrive);
|
||||
|
||||
const outcome = await driveOperation(fixture.lane, fixture.drive);
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
kind: "settled",
|
||||
outcome: { operationId, status: "aborted" },
|
||||
});
|
||||
expect(beforeDrive).not.toHaveBeenCalled();
|
||||
expect(fixture.lane.state.operation).toBeNull();
|
||||
expect(await fixture.lane.getResult(operationId, BACKGROUND_CONTEXT)).toMatchObject({ status: "aborted" });
|
||||
expect(
|
||||
await fixture.session.getValue(storedValues.operationMeta(operationId), BACKGROUND_CONTEXT),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
await fixture.session.getValue(storedValues.operationState(operationId), BACKGROUND_CONTEXT),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
await fixture.session.scanValues(storedValues.operationToolArgsPrefix(operationId), BACKGROUND_CONTEXT),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await fixture.session.scanValues(storedValues.operationToolMemoPrefix(operationId), BACKGROUND_CONTEXT),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await fixture.session.scanValues(storedValues.operationPreparationPrefix(operationId), BACKGROUND_CONTEXT),
|
||||
).toEqual([]);
|
||||
if (installed.state.at === "deferred.suspended" || installed.state.at === "deferred.effect_pending") {
|
||||
expect(fixture.faux.state.cancelledDeferred).toHaveLength(1);
|
||||
}
|
||||
expect(
|
||||
fixture.events
|
||||
.filter((event) => installed.terminalEvents.includes(event.type))
|
||||
.slice(-installed.terminalEvents.length)
|
||||
.map((event) => event.type),
|
||||
).toEqual(installed.terminalEvents);
|
||||
}
|
||||
});
|
||||
|
||||
it("durably drains abortable input once and preserves lane-owned input through terminal cleanup", async () => {
|
||||
const fixture = await createFixture();
|
||||
await installOperation(fixture, {
|
||||
state: {
|
||||
...scope({ status: "running" }),
|
||||
at: "checkpoint",
|
||||
continuation: { kind: "need_assistant", overflowRecoveryUsed: false },
|
||||
triggerEntryId: "tip",
|
||||
},
|
||||
intent: { kind: "run", promptEntryIds: ["tip"] },
|
||||
terminalEvents: ["run_end"],
|
||||
});
|
||||
const queued = [
|
||||
{ entryId: "steer", kind: "steer" as const, message: user("steer") },
|
||||
{ entryId: "write", kind: "write" as const, message: user("write") },
|
||||
{ entryId: "follow", kind: "followUp" as const, message: user("follow") },
|
||||
{ entryId: "next", kind: "nextRun" as const, message: user("next") },
|
||||
];
|
||||
await fixture.lane.command(
|
||||
(state) => ({
|
||||
kind: "commit",
|
||||
writes: [
|
||||
...queued.map((item) =>
|
||||
storedValues.setValue(storedValues.pendingEntry(item.entryId), {
|
||||
type: "message" as const,
|
||||
payload: item.message,
|
||||
}),
|
||||
),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: operationId,
|
||||
lastOperationId: null,
|
||||
inbox: queued.map(({ entryId, kind }) => ({ entryId, kind })),
|
||||
}),
|
||||
],
|
||||
next: { ...state, inbox: queued.map(({ entryId, kind }) => ({ entryId, kind })) },
|
||||
materialize: () => undefined,
|
||||
}),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
fixture.storage.clearCommitAttempts();
|
||||
|
||||
const requested = await fixture.lane.requestOperationAbort(operationId, BACKGROUND_CONTEXT);
|
||||
expect(requested).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
operationId,
|
||||
newlyRequested: true,
|
||||
steer: [{ content: "steer" }],
|
||||
followUp: [{ content: "follow" }],
|
||||
},
|
||||
});
|
||||
expect(fixture.drive.gate.signal.aborted).toBe(true);
|
||||
expect(fixture.drive.closeSignal.aborted).toBe(false);
|
||||
expect(fixture.lane.state.inbox).toEqual([
|
||||
{ entryId: "write", kind: "write" },
|
||||
{ entryId: "next", kind: "nextRun" },
|
||||
]);
|
||||
expect(await fixture.session.getValue(storedValues.pendingEntry("steer"), BACKGROUND_CONTEXT)).toBeUndefined();
|
||||
expect(await fixture.session.getValue(storedValues.pendingEntry("follow"), BACKGROUND_CONTEXT)).toBeUndefined();
|
||||
expect(fixture.events.filter((event) => event.type === "operation_abort")).toMatchObject([
|
||||
{ operationId, steer: [{ content: "steer" }], followUp: [{ content: "follow" }] },
|
||||
]);
|
||||
const commitCount = fixture.storage.getCommitAttempts().length;
|
||||
const eventCount = fixture.events.length;
|
||||
expect(await fixture.lane.requestOperationAbort(operationId, BACKGROUND_CONTEXT)).toEqual({
|
||||
ok: true,
|
||||
value: { operationId, newlyRequested: false, steer: [], followUp: [] },
|
||||
});
|
||||
expect(fixture.storage.getCommitAttempts()).toHaveLength(commitCount);
|
||||
expect(fixture.events).toHaveLength(eventCount);
|
||||
|
||||
expect(await driveOperation(fixture.lane, fixture.drive)).toMatchObject({
|
||||
kind: "settled",
|
||||
outcome: { status: "aborted" },
|
||||
});
|
||||
expect(fixture.lane.state.inbox).toEqual([
|
||||
{ entryId: "write", kind: "write" },
|
||||
{ entryId: "next", kind: "nextRun" },
|
||||
]);
|
||||
expect(await fixture.session.getValue(storedValues.pendingEntry("write"), BACKGROUND_CONTEXT)).toBeDefined();
|
||||
expect(await fixture.session.getValue(storedValues.pendingEntry("next"), BACKGROUND_CONTEXT)).toBeDefined();
|
||||
fixture.storage.clearCommitAttempts();
|
||||
const stale = await fixture.lane.requestOperationAbort(operationId, BACKGROUND_CONTEXT);
|
||||
expect(stale.ok).toBe(false);
|
||||
if (stale.ok) throw new Error("settled abort unexpectedly succeeded");
|
||||
expect(stale.error._tag).toBe("OperationMismatch");
|
||||
expect(fixture.storage.getCommitAttempts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("marks cancellation without installing a Drive", async () => {
|
||||
const fixture = await createFixture();
|
||||
await installOperation(fixture, {
|
||||
state: { ...scope({ status: "running" }), at: "starting" },
|
||||
intent: { kind: "run", promptEntryIds: ["tip"] },
|
||||
terminalEvents: ["run_end"],
|
||||
});
|
||||
fixture.lane.activeDrive = undefined;
|
||||
|
||||
expect(await fixture.lane.requestOperationAbort(operationId, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { newlyRequested: true },
|
||||
});
|
||||
expect(fixture.lane.activeDrive).toBeUndefined();
|
||||
expect(fixture.lane.state.operation?.state.control.status).toBe("cancel_requested");
|
||||
});
|
||||
|
||||
it("cancels an admitted retry timer only after the abort marker commits", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
const fixture = await createFixture();
|
||||
const generation = {
|
||||
stepId: "step",
|
||||
triggerEntryId: "tip",
|
||||
configuration: fixture.configuration,
|
||||
streamOptions: {},
|
||||
retryPolicy: { maxAttempts: 2, baseDelayMs: 10 },
|
||||
overflowRecoveryUsed: false,
|
||||
};
|
||||
await installOperation(fixture, {
|
||||
state: {
|
||||
...scope({ status: "running" }),
|
||||
at: "assistant.retry_wait",
|
||||
generationContext: generation,
|
||||
nextAttempt: 2,
|
||||
notBefore: 2_000,
|
||||
errorMessage: "retry",
|
||||
},
|
||||
intent: { kind: "run", promptEntryIds: ["tip"] },
|
||||
terminalEvents: ["run_end"],
|
||||
});
|
||||
const drive = new Drive({ operationId, waitForRetry: true }, BACKGROUND_CONTEXT);
|
||||
fixture.lane.activeDrive = drive;
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const running = driveOperation(fixture.lane, drive);
|
||||
await vi.waitFor(() => expect(setTimeoutSpy).toHaveBeenCalled());
|
||||
|
||||
expect(await fixture.lane.requestOperationAbort(operationId, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { newlyRequested: true },
|
||||
});
|
||||
expect(await running).toMatchObject({ kind: "settled", outcome: { status: "aborted" } });
|
||||
expect(fixture.events.some((event) => event.type === "retry_start")).toBe(false);
|
||||
});
|
||||
|
||||
it("drops a stale structural hook result when cancellation commits first", async () => {
|
||||
const fixture = await createFixture();
|
||||
const task: SummaryTask = { taskId: "task", reason: "manual", boundary: { kind: "finish" } };
|
||||
const deciding = { ...scope({ status: "running" }), at: "summary.deciding", task } as const;
|
||||
await installOperation(fixture, {
|
||||
state: deciding,
|
||||
intent: { kind: "compaction" },
|
||||
writes: [
|
||||
storedValues.setValue(storedValues.operationPreparation(operationId, task.taskId), {
|
||||
kind: "compaction",
|
||||
messagesToSummarize: [user("history")],
|
||||
turnPrefixMessages: [],
|
||||
retainedTail: [],
|
||||
isSplitTurn: false,
|
||||
tokensBefore: 100,
|
||||
fileOps: { read: [], written: [], edited: [] },
|
||||
settings: DEFAULT_COMPACTION_SETTINGS,
|
||||
}),
|
||||
],
|
||||
terminalEvents: ["compaction_end"],
|
||||
});
|
||||
let releaseHook!: () => void;
|
||||
const hookStarted = new Promise<void>((resolve) => {
|
||||
fixture.hooks.on("before_compaction", async () => {
|
||||
resolve();
|
||||
await new Promise<void>((release) => {
|
||||
releaseHook = release;
|
||||
});
|
||||
return {
|
||||
compaction: { summary: "stale", tokensBefore: 100, retainedTail: [] },
|
||||
};
|
||||
});
|
||||
});
|
||||
const running = runStructuralDecision(fixture.lane, fixture.drive, deciding);
|
||||
await hookStarted;
|
||||
await fixture.lane.requestOperationAbort(operationId, BACKGROUND_CONTEXT);
|
||||
releaseHook();
|
||||
|
||||
expect(await running).toEqual({ kind: "continue" });
|
||||
expect(await fixture.session.findEntries({ type: "compaction" }, BACKGROUND_CONTEXT)).toEqual([]);
|
||||
expect(await driveOperation(fixture.lane, fixture.drive)).toMatchObject({
|
||||
kind: "settled",
|
||||
outcome: { status: "aborted" },
|
||||
});
|
||||
expect(fixture.events.at(-1)).toMatchObject({ type: "compaction_end", status: "aborted" });
|
||||
});
|
||||
|
||||
it("keeps the deferred cleanup signal separate from operation abort", () => {
|
||||
const drive = new Drive({ operationId }, BACKGROUND_CONTEXT);
|
||||
drive.beginAbort(Promise.resolve());
|
||||
drive.signalAbort();
|
||||
expect(drive.gate.signal.aborted).toBe(true);
|
||||
expect(drive.closeSignal.aborted).toBe(false);
|
||||
const closed = new Error("closed");
|
||||
drive.closeGate(closed);
|
||||
expect(drive.closeSignal.aborted).toBe(true);
|
||||
expect(drive.closeSignal.reason).toBe(closed);
|
||||
});
|
||||
|
||||
it("can crash between cancelled response settlement and terminal cleanup", async () => {
|
||||
const fixture = await createFixture();
|
||||
const installed = cases(fixture)[3]!;
|
||||
await installOperation(fixture, installed);
|
||||
|
||||
expect(await reconcileOperation(fixture.lane, fixture.drive)).toEqual({ kind: "continue" });
|
||||
expect(fixture.lane.state.operation?.state.at).toBe("checkpoint");
|
||||
expect(await fixture.session.getEntry("assistant-response", BACKGROUND_CONTEXT)).toMatchObject({
|
||||
type: "message",
|
||||
message: { stopReason: "aborted" },
|
||||
});
|
||||
expect(await fixture.lane.getResult(operationId, BACKGROUND_CONTEXT)).toBeUndefined();
|
||||
expect(
|
||||
await fixture.session.readList(
|
||||
storedValues.pendingAssistantFrames(operationId, "assistant-response"),
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
const resumed = new Drive({ operationId }, BACKGROUND_CONTEXT);
|
||||
fixture.lane.activeDrive = resumed;
|
||||
expect(await driveOperation(fixture.lane, resumed)).toMatchObject({
|
||||
kind: "settled",
|
||||
outcome: { status: "aborted" },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores deferred-provider cancellation failure", async () => {
|
||||
const fixture = await createFixture();
|
||||
const deferred = deferredOperation(fixture, false);
|
||||
const provider: Provider = {
|
||||
...fixture.faux.provider,
|
||||
cancelDeferred: async () => {
|
||||
throw new Error("remote cancellation failed");
|
||||
},
|
||||
};
|
||||
fixture.models.setProvider(provider);
|
||||
await installOperation(fixture, {
|
||||
state: deferred.state,
|
||||
intent: { kind: "run", promptEntryIds: [] },
|
||||
entries: [deferred.entry],
|
||||
terminalEvents: ["run_end"],
|
||||
});
|
||||
|
||||
expect(await driveOperation(fixture.lane, fixture.drive)).toMatchObject({
|
||||
kind: "settled",
|
||||
outcome: { status: "aborted" },
|
||||
});
|
||||
});
|
||||
});
|
||||
613
packages/agent/test/harness/runtime/drive-retry-deferred.test.ts
Normal file
613
packages/agent/test/harness/runtime/drive-retry-deferred.test.ts
Normal file
@@ -0,0 +1,613 @@
|
||||
import {
|
||||
type AssistantMessage,
|
||||
type DeferredFetchOptions,
|
||||
fauxAssistantMessage,
|
||||
fauxProvider,
|
||||
fauxToolCall,
|
||||
type MutableModels,
|
||||
type Provider,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createModels } from "../../../../ai/src/models.ts";
|
||||
import type { HarnessEvent, WatchHandle } from "../../../src/harness/agent-harness.ts";
|
||||
import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/harness/compaction/compaction.ts";
|
||||
import { BACKGROUND_CONTEXT } from "../../../src/harness/context.ts";
|
||||
import { HookRegistry } from "../../../src/harness/hooks.ts";
|
||||
import { runCheckpoint, startRun } from "../../../src/harness/runtime/drive/checkpoint.ts";
|
||||
import { recoverDeferredPoll, runDeferred, runDeferredSuspended } from "../../../src/harness/runtime/drive/deferred.ts";
|
||||
import { runGeneration } from "../../../src/harness/runtime/drive/generation.ts";
|
||||
import { Lane } from "../../../src/harness/runtime/lane.ts";
|
||||
import { restoreLane } from "../../../src/harness/runtime/restore.ts";
|
||||
import { type Config, Drive } from "../../../src/harness/runtime/types.ts";
|
||||
import { MemoryStorage } from "../../../src/harness/session/memory.ts";
|
||||
import { StorageBackedSession } from "../../../src/harness/session/session.ts";
|
||||
import { GatingStorage } from "../../../src/harness/session/testing/gating-storage.ts";
|
||||
import { InstrumentedStorage } from "../../../src/harness/session/testing/instrumented-storage.ts";
|
||||
import {
|
||||
type AssistantReadyOperation,
|
||||
type AssistantRetryWaitOperation,
|
||||
type DeferredEffectPendingOperation,
|
||||
type DeferredSuspendedOperation,
|
||||
type LaneConfiguration,
|
||||
type OperationState,
|
||||
operationScopeOf,
|
||||
type Session,
|
||||
type Write,
|
||||
} from "../../../src/harness/session/types.ts";
|
||||
import * as storedValues from "../../../src/harness/session/values.ts";
|
||||
import { deferred } from "./test-utils.ts";
|
||||
|
||||
const sessions: Session[] = [];
|
||||
const operationId = "01950000-0000-7000-8000-000000000001";
|
||||
|
||||
interface Fixture {
|
||||
backend: MemoryStorage;
|
||||
gating: GatingStorage | undefined;
|
||||
storage: InstrumentedStorage;
|
||||
session: Session;
|
||||
lane: Lane<undefined>;
|
||||
drive: Drive;
|
||||
models: MutableModels;
|
||||
faux: ReturnType<typeof fauxProvider>;
|
||||
hooks: HookRegistry;
|
||||
events: HarnessEvent[];
|
||||
config: Config<undefined>;
|
||||
}
|
||||
|
||||
function unusedWatch<T>(): WatchHandle<T> {
|
||||
throw new Error("watch is not used by retry/deferred tests");
|
||||
}
|
||||
|
||||
async function createFixture(
|
||||
options: { gated?: boolean; pendingFetches?: number; deferredSubmission?: boolean } = {},
|
||||
): Promise<Fixture> {
|
||||
const backend = new MemoryStorage({ now: () => 100 });
|
||||
const gating = options.gated ? new GatingStorage(backend) : undefined;
|
||||
const storage = new InstrumentedStorage(gating ?? backend);
|
||||
const session = new StorageBackedSession(
|
||||
{ id: `retry-deferred-${sessions.length}`, createdAt: 1, storageVersion: 1 },
|
||||
storage,
|
||||
);
|
||||
sessions.push(session);
|
||||
const faux = fauxProvider({
|
||||
tokenSize: { min: 1, max: 1 },
|
||||
deferred: { pendingFetches: options.pendingFetches ?? 0 },
|
||||
});
|
||||
const model = faux.getModel();
|
||||
const configuration: LaneConfiguration = {
|
||||
model: { provider: model.provider, modelId: model.id },
|
||||
thinkingLevel: "off",
|
||||
activeToolNames: [],
|
||||
};
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.branchTip("main"), null),
|
||||
storedValues.setValue(storedValues.laneConfig("main"), configuration),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const models = createModels();
|
||||
models.setProvider(faux.provider);
|
||||
const hooks = new HookRegistry(() => {});
|
||||
const events: HarnessEvent[] = [];
|
||||
const config: Config<undefined> = {
|
||||
tools: [],
|
||||
resources: {},
|
||||
streamOptions: { deferred: options.deferredSubmission ?? true },
|
||||
retryPolicy: { enabled: true, maxRetries: 3, baseDelayMs: 10 },
|
||||
compaction: DEFAULT_COMPACTION_SETTINGS,
|
||||
steeringMode: "all",
|
||||
followUpMode: "all",
|
||||
toolExecution: "parallel",
|
||||
toolContext: undefined,
|
||||
systemPrompt: undefined,
|
||||
toProviderMessages: (messages) =>
|
||||
messages.filter(
|
||||
(message) => message.role === "user" || message.role === "assistant" || message.role === "toolResult",
|
||||
),
|
||||
entryProjectors: {},
|
||||
};
|
||||
const lane = new Lane<undefined>(
|
||||
"main",
|
||||
session,
|
||||
models,
|
||||
hooks,
|
||||
await restoreLane(session, "main", BACKGROUND_CONTEXT),
|
||||
(cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
(batch) => {
|
||||
events.push(...structuredClone(batch));
|
||||
return Promise.resolve();
|
||||
},
|
||||
unusedWatch,
|
||||
() => config,
|
||||
);
|
||||
const admission = await lane.accept({ kind: "prompt", operationId, prompt: "question" }, BACKGROUND_CONTEXT);
|
||||
if (!admission.ok) throw admission.error;
|
||||
const drive = new Drive({ operationId }, BACKGROUND_CONTEXT);
|
||||
lane.activeDrive = drive;
|
||||
storage.clearCommitAttempts();
|
||||
return { backend, gating, storage, session, lane, drive, models, faux, hooks, events, config };
|
||||
}
|
||||
|
||||
function currentRun(fixture: Fixture): OperationState {
|
||||
const operation = fixture.lane.state.operation;
|
||||
if (operation === null) throw new Error("fixture has no operation");
|
||||
return operation.state;
|
||||
}
|
||||
|
||||
async function advanceToReady(fixture: Fixture): Promise<AssistantReadyOperation> {
|
||||
const starting = currentRun(fixture);
|
||||
if (starting.at !== "starting") throw new Error("run did not start at its initial boundary");
|
||||
await startRun(fixture.lane, fixture.drive, starting);
|
||||
let run = currentRun(fixture);
|
||||
if (run.at !== "checkpoint") throw new Error("run did not reach checkpoint");
|
||||
await runCheckpoint(fixture.lane, fixture.drive, run);
|
||||
run = currentRun(fixture);
|
||||
if (run.at !== "assistant.ready") {
|
||||
throw new Error("run did not reach ready generation");
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
async function replaceRunState(fixture: Fixture, nextState: OperationState, extraWrites: Write[] = []): Promise<void> {
|
||||
await fixture.lane.command((state) => {
|
||||
const operation = state.operation;
|
||||
if (operation === null) throw new Error("fixture has no operation");
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [...extraWrites, storedValues.setValue(storedValues.operationState(operationId), nextState)],
|
||||
next: { ...state, operation: { meta: operation.meta, state: nextState } },
|
||||
materialize: () => undefined,
|
||||
};
|
||||
}, BACKGROUND_CONTEXT);
|
||||
}
|
||||
|
||||
async function submitDeferred(
|
||||
fixture: Fixture,
|
||||
response: AssistantMessage = fauxAssistantMessage("done", { timestamp: 20 }),
|
||||
): Promise<DeferredSuspendedOperation> {
|
||||
fixture.faux.setResponses([response]);
|
||||
const ready = await advanceToReady(fixture);
|
||||
await runGeneration(fixture.lane, fixture.drive, ready);
|
||||
const run = currentRun(fixture);
|
||||
if (run.at !== "deferred.suspended") {
|
||||
throw new Error("initial request did not suspend");
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
async function installUnknownPoll(
|
||||
fixture: Fixture,
|
||||
suspended: DeferredSuspendedOperation,
|
||||
): Promise<DeferredEffectPendingOperation> {
|
||||
const effectPending: DeferredEffectPendingOperation = {
|
||||
...operationScopeOf(currentRun(fixture)),
|
||||
at: "deferred.effect_pending",
|
||||
stepId: suspended.stepId,
|
||||
sourceEntryId: suspended.sourceEntryId,
|
||||
poll: suspended.poll + 1,
|
||||
responseEntryId: fixture.session.idGenerator.next(),
|
||||
usageId: fixture.session.idGenerator.next(),
|
||||
configuration: suspended.configuration,
|
||||
streamOptions: suspended.streamOptions,
|
||||
};
|
||||
await replaceRunState(fixture, effectPending, [
|
||||
storedValues.appendList(storedValues.pendingAssistantFrames(operationId, effectPending.responseEntryId), {
|
||||
type: "text_delta",
|
||||
contentIndex: 0,
|
||||
delta: "old",
|
||||
}),
|
||||
]);
|
||||
return effectPending;
|
||||
}
|
||||
|
||||
function currentDeferred(fixture: Fixture): DeferredSuspendedOperation | DeferredEffectPendingOperation {
|
||||
const run = currentRun(fixture);
|
||||
if (run.at !== "deferred.suspended" && run.at !== "deferred.effect_pending") {
|
||||
throw new Error("fixture has no deferred phase");
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
function installDrive(fixture: Fixture, options: { waitForRetry?: boolean; pollDeferred?: boolean }): Drive {
|
||||
const drive = new Drive({ operationId, ...options }, BACKGROUND_CONTEXT);
|
||||
fixture.lane.activeDrive = drive;
|
||||
return drive;
|
||||
}
|
||||
|
||||
async function expectProjectionRestores(fixture: Fixture): Promise<void> {
|
||||
expect(fixture.lane.state).toEqual(await restoreLane(fixture.session, "main", BACKGROUND_CONTEXT));
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
for (const session of sessions.splice(0)) await session.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
describe("runtime assistant retry wait", () => {
|
||||
it("classifies a live retryable provider error into durable retry wait", async () => {
|
||||
const fixture = await createFixture({ deferredSubmission: false });
|
||||
const ready = await advanceToReady(fixture);
|
||||
fixture.faux.setResponses([
|
||||
fauxAssistantMessage([], { stopReason: "error", errorMessage: "503 service unavailable", timestamp: 10 }),
|
||||
]);
|
||||
|
||||
expect(await runGeneration(fixture.lane, fixture.drive, ready)).toEqual({
|
||||
kind: "continue",
|
||||
});
|
||||
expect(currentRun(fixture)).toMatchObject({
|
||||
at: "assistant.retry_wait",
|
||||
nextAttempt: 2,
|
||||
errorMessage: "503 service unavailable",
|
||||
});
|
||||
expect(fixture.events.at(-1)).toMatchObject({ type: "retry_scheduled", attempt: 2 });
|
||||
await expectProjectionRestores(fixture);
|
||||
});
|
||||
|
||||
it("returns a durable waiting outcome without a timer or write when local waiting is disabled", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
const fixture = await createFixture({ deferredSubmission: false });
|
||||
const ready = await advanceToReady(fixture);
|
||||
const retryWait: AssistantRetryWaitOperation = {
|
||||
...operationScopeOf(ready),
|
||||
at: "assistant.retry_wait",
|
||||
generationContext: ready.generationContext,
|
||||
nextAttempt: 2,
|
||||
notBefore: 1_100,
|
||||
errorMessage: "retry",
|
||||
};
|
||||
await replaceRunState(fixture, retryWait);
|
||||
fixture.storage.clearCommitAttempts();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
expect(await runGeneration(fixture.lane, fixture.drive, retryWait)).toEqual({
|
||||
kind: "waiting",
|
||||
outcome: { kind: "waiting", operationId, reason: "retry", notBefore: 1_100 },
|
||||
});
|
||||
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
||||
expect(fixture.storage.getCommitAttempts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("commits ready at the deadline and emits retry lifecycle around the next attempt", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(2_000);
|
||||
const fixture = await createFixture({ deferredSubmission: false });
|
||||
const ready = await advanceToReady(fixture);
|
||||
const retryWait: AssistantRetryWaitOperation = {
|
||||
...operationScopeOf(ready),
|
||||
at: "assistant.retry_wait",
|
||||
generationContext: ready.generationContext,
|
||||
nextAttempt: 2,
|
||||
notBefore: 2_000,
|
||||
errorMessage: "retry",
|
||||
};
|
||||
await replaceRunState(fixture, retryWait);
|
||||
fixture.storage.clearCommitAttempts();
|
||||
|
||||
expect(await runGeneration(fixture.lane, fixture.drive, retryWait)).toEqual({
|
||||
kind: "continue",
|
||||
});
|
||||
const next = currentRun(fixture);
|
||||
if (next.at !== "assistant.ready") {
|
||||
throw new Error("retry did not become ready");
|
||||
}
|
||||
expect(fixture.events.at(-1)).toMatchObject({ type: "retry_start", attempt: 2 });
|
||||
fixture.faux.setResponses([fauxAssistantMessage("retried", { timestamp: 30 })]);
|
||||
await runGeneration(fixture.lane, fixture.drive, next);
|
||||
expect(fixture.events).toContainEqual(expect.objectContaining({ type: "retry_end", attempt: 2, success: true }));
|
||||
await expectProjectionRestores(fixture);
|
||||
});
|
||||
|
||||
it("admits an abort-aware timer only for local waiting", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(3_000);
|
||||
const fixture = await createFixture({ deferredSubmission: false });
|
||||
const ready = await advanceToReady(fixture);
|
||||
const retryWait: AssistantRetryWaitOperation = {
|
||||
...operationScopeOf(ready),
|
||||
at: "assistant.retry_wait",
|
||||
generationContext: ready.generationContext,
|
||||
nextAttempt: 2,
|
||||
notBefore: 3_100,
|
||||
errorMessage: "retry",
|
||||
};
|
||||
await replaceRunState(fixture, retryWait);
|
||||
const drive = installDrive(fixture, { waitForRetry: true });
|
||||
fixture.storage.clearCommitAttempts();
|
||||
const waiting = runGeneration(fixture.lane, drive, retryWait);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
expect(fixture.storage.getCommitAttempts()).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(await waiting).toEqual({ kind: "continue" });
|
||||
expect(currentRun(fixture)).toMatchObject({
|
||||
at: "assistant.ready",
|
||||
nextAttempt: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime deferred polling", () => {
|
||||
it("waits without a permit, then performs at most one pending poll from captured options", async () => {
|
||||
const fixture = await createFixture({ pendingFetches: 1 });
|
||||
const suspended = await submitDeferred(fixture);
|
||||
fixture.storage.clearCommitAttempts();
|
||||
|
||||
expect(await runDeferredSuspended(fixture.lane, fixture.drive, suspended)).toEqual({
|
||||
kind: "waiting",
|
||||
outcome: {
|
||||
kind: "waiting",
|
||||
operationId,
|
||||
reason: "deferred",
|
||||
deferred: expect.objectContaining({ id: expect.any(String) }),
|
||||
},
|
||||
});
|
||||
expect(fixture.faux.state.deferredFetchCount).toBe(0);
|
||||
expect(fixture.storage.getCommitAttempts()).toEqual([]);
|
||||
|
||||
let hookOptions: unknown;
|
||||
fixture.hooks.on("before_request", (event) => {
|
||||
if (event.step === "deferred") hookOptions = event.streamOptions;
|
||||
return undefined;
|
||||
});
|
||||
let fetchOptions: DeferredFetchOptions | undefined;
|
||||
const provider = fixture.faux.provider;
|
||||
const fetchDeferred = provider.fetchDeferred;
|
||||
if (fetchDeferred === undefined) throw new Error("faux provider has no deferred fetch");
|
||||
const wrapped: Provider = {
|
||||
...provider,
|
||||
fetchDeferred: (model, handle, options) => {
|
||||
fetchOptions = options;
|
||||
return fetchDeferred(model, handle, options);
|
||||
},
|
||||
};
|
||||
fixture.models.setProvider(wrapped);
|
||||
const driveOptions = { operationId, pollDeferred: true } as const;
|
||||
const drive = new Drive(driveOptions, BACKGROUND_CONTEXT);
|
||||
fixture.lane.activeDrive = drive;
|
||||
|
||||
expect(await runDeferred(fixture.lane, drive, currentDeferred(fixture))).toEqual({
|
||||
kind: "continue",
|
||||
});
|
||||
expect(drive.deferredPermits).toBe(0);
|
||||
expect(driveOptions).toEqual({ operationId, pollDeferred: true });
|
||||
expect(hookOptions).toMatchObject({ deferred: false });
|
||||
expect(fetchOptions).toMatchObject({ wait: 0 });
|
||||
expect(fetchOptions?.signal).toBe(drive.gate.signal);
|
||||
expect(currentDeferred(fixture)).toMatchObject({ at: "deferred.suspended", poll: 1 });
|
||||
expect(fixture.faux.state.deferredFetchCount).toBe(1);
|
||||
expect(fixture.events.slice(-2).map((event) => event.type)).toEqual(["turn_end", "run_suspend"]);
|
||||
|
||||
expect(await runDeferred(fixture.lane, drive, currentDeferred(fixture))).toMatchObject({
|
||||
kind: "waiting",
|
||||
outcome: { reason: "deferred" },
|
||||
});
|
||||
expect(fixture.faux.state.deferredFetchCount).toBe(1);
|
||||
await expectProjectionRestores(fixture);
|
||||
});
|
||||
|
||||
it("declines poll intent when cancellation wins preparation", async () => {
|
||||
const fixture = await createFixture();
|
||||
const suspended = await submitDeferred(fixture);
|
||||
const drive = installDrive(fixture, { pollDeferred: true });
|
||||
const hookStarted = deferred();
|
||||
const releaseHook = deferred();
|
||||
fixture.hooks.on("before_request", async (event) => {
|
||||
if (event.step !== "deferred") return undefined;
|
||||
hookStarted.resolve();
|
||||
await releaseHook.promise;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const polling = runDeferredSuspended(fixture.lane, drive, suspended);
|
||||
await hookStarted.promise;
|
||||
await fixture.lane.command((state) => {
|
||||
const operation = state.operation;
|
||||
if (operation === null) throw new Error("missing operation");
|
||||
const nextState: OperationState = {
|
||||
...operation.state,
|
||||
control: { status: "cancel_requested", requestedAt: 10 },
|
||||
};
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [storedValues.setValue(storedValues.operationState(operationId), nextState)],
|
||||
next: { ...state, operation: { meta: operation.meta, state: nextState } },
|
||||
materialize: () => undefined,
|
||||
};
|
||||
}, BACKGROUND_CONTEXT);
|
||||
releaseHook.resolve();
|
||||
|
||||
expect(await polling).toEqual({ kind: "continue" });
|
||||
expect(fixture.faux.state.deferredFetchCount).toBe(0);
|
||||
expect(currentRun(fixture)).toMatchObject({
|
||||
at: "deferred.suspended",
|
||||
control: { status: "cancel_requested" },
|
||||
});
|
||||
});
|
||||
|
||||
it("plans ready deferred tool calls with the poll turn identity and follower result ids", async () => {
|
||||
const fixture = await createFixture();
|
||||
const suspended = await submitDeferred(
|
||||
fixture,
|
||||
fauxAssistantMessage(fauxToolCall("lookup", { query: "value" }), {
|
||||
stopReason: "toolUse",
|
||||
timestamp: 20,
|
||||
}),
|
||||
);
|
||||
const drive = installDrive(fixture, { pollDeferred: true });
|
||||
|
||||
expect(await runDeferredSuspended(fixture.lane, drive, suspended)).toEqual({
|
||||
kind: "continue",
|
||||
});
|
||||
const run = currentRun(fixture);
|
||||
if (run.at !== "tools") throw new Error("deferred tool response did not create a batch");
|
||||
expect(run.batch).toMatchObject({
|
||||
turnId: `${suspended.stepId}:poll:1`,
|
||||
calls: [{ status: "planned", sourceIndex: 0 }],
|
||||
});
|
||||
expect(run.batch.calls[0]?.resultEntryId.slice(0, 13)).toBe(run.latestAssistantEntryId?.slice(0, 13));
|
||||
expect(fixture.events.at(-1)).toMatchObject({ type: "usage" });
|
||||
await expectProjectionRestores(fixture);
|
||||
});
|
||||
|
||||
it("consumes its permit only after the fresh intent commit lands", async () => {
|
||||
const fixture = await createFixture({ gated: true });
|
||||
const suspended = await submitDeferred(
|
||||
fixture,
|
||||
fauxAssistantMessage([], { stopReason: "error", errorMessage: "failed", timestamp: 20 }),
|
||||
);
|
||||
const gating = fixture.gating;
|
||||
if (gating === undefined) throw new Error("fixture is not gated");
|
||||
const drive = installDrive(fixture, { pollDeferred: true });
|
||||
gating.arm();
|
||||
const polling = runDeferredSuspended(fixture.lane, drive, suspended);
|
||||
|
||||
await gating.waitPending();
|
||||
expect(drive.deferredPermits).toBe(1);
|
||||
expect(fixture.faux.state.deferredFetchCount).toBe(0);
|
||||
await gating.next();
|
||||
expect(drive.deferredPermits).toBe(0);
|
||||
await expect.poll(() => fixture.faux.state.deferredFetchCount).toBe(1);
|
||||
await gating.next(2); // start frame, then response settlement
|
||||
expect(await polling).toMatchObject({
|
||||
kind: "settled",
|
||||
outcome: { operationId, kind: "run", status: "failed" },
|
||||
});
|
||||
expect(fixture.lane.state.operation).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves suspended state unchanged when the fresh intent is discarded", async () => {
|
||||
const fixture = await createFixture({ gated: true });
|
||||
const suspended = await submitDeferred(fixture);
|
||||
const gating = fixture.gating;
|
||||
if (gating === undefined) throw new Error("fixture is not gated");
|
||||
const drive = installDrive(fixture, { pollDeferred: true });
|
||||
gating.arm();
|
||||
const polling = runDeferredSuspended(fixture.lane, drive, suspended);
|
||||
void polling.catch(() => {});
|
||||
|
||||
await gating.waitPending();
|
||||
gating.discard();
|
||||
await expect(polling).rejects.toThrow("commit discarded");
|
||||
expect(drive.deferredPermits).toBe(1);
|
||||
expect(fixture.faux.state.deferredFetchCount).toBe(0);
|
||||
const durable = await fixture.backend.getValue(storedValues.operationState(operationId), BACKGROUND_CONTEXT);
|
||||
expect(durable?.value).toMatchObject({ at: "deferred.suspended", poll: 0 });
|
||||
});
|
||||
|
||||
it("replaces an unknown poll under fresh ids at the same poll number and deletes old frames", async () => {
|
||||
const fixture = await createFixture();
|
||||
const suspended = await submitDeferred(fixture);
|
||||
const unknown = await installUnknownPoll(fixture, suspended);
|
||||
fixture.storage.clearCommitAttempts();
|
||||
|
||||
const noPermit = installDrive(fixture, {});
|
||||
expect(await recoverDeferredPoll(fixture.lane, noPermit, unknown)).toMatchObject({
|
||||
kind: "waiting",
|
||||
outcome: { reason: "deferred" },
|
||||
});
|
||||
expect(
|
||||
await fixture.session.readList(
|
||||
storedValues.pendingAssistantFrames(operationId, unknown.responseEntryId),
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(fixture.faux.state.deferredFetchCount).toBe(0);
|
||||
|
||||
const replacement = installDrive(fixture, { pollDeferred: true });
|
||||
expect(await recoverDeferredPoll(fixture.lane, replacement, unknown)).toEqual({
|
||||
kind: "continue",
|
||||
});
|
||||
const intent = fixture.storage
|
||||
.getCommitAttempts()
|
||||
.find((writes) =>
|
||||
writes.some(
|
||||
(write) =>
|
||||
write.kind === "list" &&
|
||||
write.op === "delete" &&
|
||||
write.namespace === "pi.pending.assistant_frame" &&
|
||||
write.key.endsWith(unknown.responseEntryId),
|
||||
),
|
||||
);
|
||||
expect(intent?.map((write) => `${write.kind}:${"op" in write ? write.op : "insert"}`)).toEqual([
|
||||
"list:delete",
|
||||
"value:set",
|
||||
]);
|
||||
const intentState = intent?.find(
|
||||
(write) => write.kind === "value" && write.op === "set" && write.namespace === "pi.op.state",
|
||||
);
|
||||
expect(intentState).toMatchObject({
|
||||
value: {
|
||||
at: "deferred.effect_pending",
|
||||
poll: unknown.poll,
|
||||
responseEntryId: expect.not.stringMatching(unknown.responseEntryId),
|
||||
usageId: expect.not.stringMatching(unknown.usageId),
|
||||
},
|
||||
});
|
||||
expect(await fixture.session.getEntry(unknown.responseEntryId, BACKGROUND_CONTEXT)).toBeUndefined();
|
||||
expect((await fixture.storage.scanUsage({}, BACKGROUND_CONTEXT)).some((row) => row.id === unknown.usageId)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
await fixture.session.readList(
|
||||
storedValues.pendingAssistantFrames(operationId, unknown.responseEntryId),
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(currentRun(fixture).at).toBe("checkpoint");
|
||||
expect(fixture.events.some((event) => "recovery" in event && event.recovery === true)).toBe(true);
|
||||
await expectProjectionRestores(fixture);
|
||||
});
|
||||
|
||||
it("abandons unknown ids and frames into configuration failure when the captured model is unavailable", async () => {
|
||||
const fixture = await createFixture();
|
||||
const suspended = await submitDeferred(fixture);
|
||||
const unknown = await installUnknownPoll(fixture, suspended);
|
||||
fixture.models.deleteProvider(fixture.faux.provider.id);
|
||||
fixture.storage.clearCommitAttempts();
|
||||
const drive = installDrive(fixture, { pollDeferred: true });
|
||||
|
||||
expect(await recoverDeferredPoll(fixture.lane, drive, unknown)).toMatchObject({
|
||||
kind: "settled",
|
||||
outcome: { operationId, kind: "run", status: "failed", error: { code: "model_unavailable" } },
|
||||
});
|
||||
expect(drive.deferredPermits).toBe(1);
|
||||
expect(fixture.faux.state.deferredFetchCount).toBe(0);
|
||||
expect(fixture.lane.state.operation).toBeNull();
|
||||
expect(
|
||||
fixture.storage
|
||||
.getCommitAttempts()
|
||||
.at(-1)
|
||||
?.map((write) =>
|
||||
write.kind === "value" || write.kind === "list"
|
||||
? `${write.kind}:${write.op}:${write.namespace}`
|
||||
: write.kind,
|
||||
),
|
||||
).toEqual([
|
||||
"value:delete:pi.op.meta",
|
||||
"value:delete:pi.op.state",
|
||||
"list:delete:pi.pending.assistant_frame",
|
||||
"value:set:pi.result",
|
||||
"value:set:pi.lane.state",
|
||||
]);
|
||||
expect(
|
||||
fixture.storage
|
||||
.getCommitAttempts()
|
||||
.flat()
|
||||
.some((write) => write.kind === "entry" || write.kind === "usage"),
|
||||
).toBe(false);
|
||||
await expectProjectionRestores(fixture);
|
||||
});
|
||||
});
|
||||
1451
packages/agent/test/harness/runtime/drive-structural.test.ts
Normal file
1451
packages/agent/test/harness/runtime/drive-structural.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
289
packages/agent/test/harness/runtime/drive-terminal.test.ts
Normal file
289
packages/agent/test/harness/runtime/drive-terminal.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../../../src/harness/context.ts";
|
||||
import { operationCleanupWrites, operationResultRecord } from "../../../src/harness/runtime/drive/terminal.ts";
|
||||
import { MemoryStorage } from "../../../src/harness/session/memory.ts";
|
||||
import { StorageBackedSession } from "../../../src/harness/session/session.ts";
|
||||
import type {
|
||||
AssistantEffectPendingOperation,
|
||||
LaneConfiguration,
|
||||
NavigationReadyToCommitOperation,
|
||||
OperationMeta,
|
||||
OperationScope,
|
||||
OperationState,
|
||||
Session,
|
||||
SummaryDecidingOperation,
|
||||
ToolsOperation,
|
||||
Write,
|
||||
} from "../../../src/harness/session/types.ts";
|
||||
import * as storedValues from "../../../src/harness/session/values.ts";
|
||||
|
||||
const sessions: Session[] = [];
|
||||
const configuration: LaneConfiguration = {
|
||||
model: { provider: "test", modelId: "model" },
|
||||
thinkingLevel: "off",
|
||||
activeToolNames: [],
|
||||
};
|
||||
|
||||
function runScope(): OperationScope {
|
||||
return {
|
||||
control: { status: "running" },
|
||||
settings: {
|
||||
compaction: { enabled: true, reserveTokens: 1_000, keepRecentTokens: 2_000 },
|
||||
steeringMode: "all",
|
||||
followUpMode: "all",
|
||||
toolExecution: "parallel",
|
||||
},
|
||||
latestAssistantEntryId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function meta(operationId: string, state: OperationState): OperationMeta {
|
||||
const intent: OperationMeta["intent"] =
|
||||
state.at === "summary.deciding"
|
||||
? { kind: "compaction" }
|
||||
: state.at === "navigation.ready_to_commit"
|
||||
? { kind: "navigation", targetId: state.targetId, summarize: false }
|
||||
: { kind: "run", promptEntryIds: [] };
|
||||
return { operationId, lane: "main", sourceTipId: null, startedAt: 1, intent };
|
||||
}
|
||||
|
||||
async function createSession(): Promise<{ session: Session; storage: MemoryStorage }> {
|
||||
const storage = new MemoryStorage();
|
||||
const session = new StorageBackedSession(
|
||||
{ id: `terminal-${sessions.length}`, createdAt: 1, storageVersion: 1 },
|
||||
storage,
|
||||
);
|
||||
sessions.push(session);
|
||||
return { session, storage };
|
||||
}
|
||||
|
||||
async function commit(session: Session, writes: Write[]): Promise<void> {
|
||||
await session.mutate(async (mutator) => {
|
||||
await mutator.commit(writes, BACKGROUND_CONTEXT);
|
||||
}, BACKGROUND_CONTEXT);
|
||||
}
|
||||
|
||||
function address(write: Write): string {
|
||||
if (write.kind === "entry") return `entry:${write.entry.id}`;
|
||||
if (write.kind === "usage") return `usage:${write.row.id}`;
|
||||
return `${write.kind}:${write.op}:${write.namespace}:${write.key}`;
|
||||
}
|
||||
|
||||
async function seedLeftovers(session: Session, operationId: string, state: OperationState): Promise<void> {
|
||||
await commit(session, [
|
||||
storedValues.setValue(storedValues.operationMeta(operationId), meta(operationId, state)),
|
||||
storedValues.setValue(storedValues.operationState(operationId), state),
|
||||
storedValues.setValue(storedValues.operationToolArgs(operationId, "step", 0), { value: true }),
|
||||
storedValues.setValue(storedValues.operationToolMemo(operationId, "invocation", "memo"), { value: true }),
|
||||
storedValues.setValue(storedValues.operationPreparation(operationId, "task"), {
|
||||
kind: "branch_summary",
|
||||
messages: [],
|
||||
fileOps: { read: [], written: [], edited: [] },
|
||||
totalTokens: 0,
|
||||
}),
|
||||
storedValues.setValue(storedValues.pendingToolOutput(operationId, "invocation"), {
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
details: {},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const session of sessions.splice(0)) await session.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
describe("runtime terminal cleanup mechanics", () => {
|
||||
it("deletes every operation-owned family and exact live frame list but preserves the lane inbox", async () => {
|
||||
const { session } = await createSession();
|
||||
const operationId = "run";
|
||||
const responseEntryId = "response";
|
||||
const state: AssistantEffectPendingOperation = {
|
||||
...runScope(),
|
||||
at: "assistant.effect_pending",
|
||||
generationContext: {
|
||||
stepId: "step",
|
||||
triggerEntryId: "trigger",
|
||||
configuration,
|
||||
streamOptions: {},
|
||||
retryPolicy: { maxAttempts: 2, baseDelayMs: 1 },
|
||||
overflowRecoveryUsed: false,
|
||||
},
|
||||
attempt: 1,
|
||||
responseEntryId,
|
||||
usageId: "usage",
|
||||
intendedOutputLimit: 100,
|
||||
contextWindow: 1_000,
|
||||
control: { status: "cancel_requested", requestedAt: 2 },
|
||||
};
|
||||
await seedLeftovers(session, operationId, state);
|
||||
await commit(session, [
|
||||
...["steer", "follow", "write", "next"].map((id) =>
|
||||
storedValues.setValue(storedValues.pendingEntry(id), {
|
||||
type: "custom",
|
||||
customType: "test",
|
||||
payload: { id },
|
||||
}),
|
||||
),
|
||||
storedValues.appendList(storedValues.pendingAssistantFrames(operationId, responseEntryId), {
|
||||
type: "text_delta",
|
||||
contentIndex: 0,
|
||||
delta: "partial",
|
||||
}),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: operationId,
|
||||
lastOperationId: null,
|
||||
inbox: [
|
||||
{ entryId: "steer", kind: "steer" },
|
||||
{ entryId: "follow", kind: "followUp" },
|
||||
{ entryId: "write", kind: "write" },
|
||||
{ entryId: "next", kind: "nextRun" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const writes = await operationCleanupWrites(session, operationId, state, BACKGROUND_CONTEXT);
|
||||
|
||||
expect(writes.map(address)).toEqual([
|
||||
"value:delete:pi.op.meta:run",
|
||||
"value:delete:pi.op.state:run",
|
||||
"value:delete:pi.op.tool_args:run:step:0",
|
||||
"value:delete:pi.op.tool_memo:run:invocation:memo",
|
||||
"value:delete:pi.op.preparation:run:task",
|
||||
"value:delete:pi.pending.tool_output:run:invocation",
|
||||
"list:delete:pi.pending.assistant_frame:run:response",
|
||||
]);
|
||||
await commit(session, writes);
|
||||
for (const id of ["steer", "follow", "write", "next"]) {
|
||||
expect(await session.getValue(storedValues.pendingEntry(id), BACKGROUND_CONTEXT)).toBeDefined();
|
||||
}
|
||||
expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value.inbox).toEqual([
|
||||
{ entryId: "steer", kind: "steer" },
|
||||
{ entryId: "follow", kind: "followUp" },
|
||||
{ entryId: "write", kind: "write" },
|
||||
{ entryId: "next", kind: "nextRun" },
|
||||
]);
|
||||
expect(
|
||||
await session.readList(
|
||||
storedValues.pendingAssistantFrames(operationId, responseEntryId),
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("deletes staged tool outcomes and leaves completed results alone", async () => {
|
||||
const { session } = await createSession();
|
||||
const operationId = "tools";
|
||||
const state: ToolsOperation = {
|
||||
...runScope(),
|
||||
at: "tools",
|
||||
batch: {
|
||||
assistantEntryId: "assistant",
|
||||
configuration,
|
||||
turnId: "step",
|
||||
calls: [
|
||||
{ status: "outcome_ready", sourceIndex: 0, resultEntryId: "staged", terminate: false },
|
||||
{ status: "completed", sourceIndex: 1, resultEntryId: "placed", terminate: false },
|
||||
],
|
||||
},
|
||||
};
|
||||
await seedLeftovers(session, operationId, state);
|
||||
await commit(session, [
|
||||
storedValues.setValue(storedValues.pendingEntry("staged"), {
|
||||
type: "message",
|
||||
payload: {
|
||||
role: "toolResult",
|
||||
toolCallId: "call",
|
||||
toolName: "tool",
|
||||
content: [],
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const writes = await operationCleanupWrites(session, operationId, state, BACKGROUND_CONTEXT);
|
||||
|
||||
expect(writes.map(address)).toContain("value:delete:pi.pending.entry:staged");
|
||||
expect(writes.map(address)).not.toContain("value:delete:pi.pending.entry:placed");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"compaction",
|
||||
{
|
||||
...runScope(),
|
||||
at: "summary.deciding",
|
||||
task: { taskId: "task", reason: "manual", boundary: { kind: "finish" } },
|
||||
} satisfies SummaryDecidingOperation,
|
||||
],
|
||||
[
|
||||
"navigation",
|
||||
{
|
||||
...runScope(),
|
||||
at: "navigation.ready_to_commit",
|
||||
targetId: null,
|
||||
} satisfies NavigationReadyToCommitOperation,
|
||||
],
|
||||
] as const)("defensively deletes leftover %s operation families", async (operationId, state) => {
|
||||
const { session } = await createSession();
|
||||
await seedLeftovers(session, operationId, state);
|
||||
|
||||
const writes = await operationCleanupWrites(session, operationId, state, BACKGROUND_CONTEXT);
|
||||
|
||||
expect(writes.map(address)).toEqual([
|
||||
`value:delete:pi.op.meta:${operationId}`,
|
||||
`value:delete:pi.op.state:${operationId}`,
|
||||
`value:delete:pi.op.tool_args:${operationId}:step:0`,
|
||||
`value:delete:pi.op.tool_memo:${operationId}:invocation:memo`,
|
||||
`value:delete:pi.op.preparation:${operationId}:task`,
|
||||
`value:delete:pi.pending.tool_output:${operationId}:invocation`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime operation result records", () => {
|
||||
it("constructs one flat immutable observation from terminal metadata", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(20);
|
||||
const record = operationResultRecord(
|
||||
{
|
||||
operationId: "run",
|
||||
lane: "main",
|
||||
sourceTipId: "source",
|
||||
startedAt: 10,
|
||||
intent: { kind: "run", promptEntryIds: ["prompt"] },
|
||||
},
|
||||
"failed",
|
||||
"tip",
|
||||
{ code: "provider", message: "failed" },
|
||||
);
|
||||
|
||||
expect(record).toEqual({
|
||||
operationId: "run",
|
||||
kind: "run",
|
||||
status: "failed",
|
||||
error: { code: "provider", message: "failed" },
|
||||
fromTipId: "source",
|
||||
tipId: "tip",
|
||||
startedAt: 10,
|
||||
endedAt: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects errors on non-failed records and missing errors on failed records", () => {
|
||||
const metadata: OperationMeta = {
|
||||
operationId: "run",
|
||||
lane: "main",
|
||||
sourceTipId: null,
|
||||
startedAt: 1,
|
||||
intent: { kind: "run", promptEntryIds: [] },
|
||||
};
|
||||
expect(() => operationResultRecord(metadata, "completed", "tip", { code: "x", message: "x" })).toThrow(
|
||||
"Only a failed operation result may carry an error",
|
||||
);
|
||||
expect(() => operationResultRecord(metadata, "failed", "tip")).toThrow(
|
||||
"Only a failed operation result may carry an error",
|
||||
);
|
||||
});
|
||||
});
|
||||
763
packages/agent/test/harness/runtime/drive-tools.test.ts
Normal file
763
packages/agent/test/harness/runtime/drive-tools.test.ts
Normal file
@@ -0,0 +1,763 @@
|
||||
import {
|
||||
createModels,
|
||||
fauxAssistantMessage,
|
||||
fauxProvider,
|
||||
fauxToolCall,
|
||||
type ToolResultMessage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { Type } from "typebox";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HarnessEvent, WatchHandle } from "../../../src/harness/agent-harness.ts";
|
||||
import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/harness/compaction/compaction.ts";
|
||||
import { BACKGROUND_CONTEXT, type Context } from "../../../src/harness/context.ts";
|
||||
import { HookRegistry } from "../../../src/harness/hooks.ts";
|
||||
import { runTools } from "../../../src/harness/runtime/drive/tools.ts";
|
||||
import { Lane } from "../../../src/harness/runtime/lane.ts";
|
||||
import { restoreLane } from "../../../src/harness/runtime/restore.ts";
|
||||
import { type Config, Drive } from "../../../src/harness/runtime/types.ts";
|
||||
import { insertEntry } from "../../../src/harness/session/commit.ts";
|
||||
import { MemoryStorage } from "../../../src/harness/session/memory.ts";
|
||||
import { StorageBackedSession } from "../../../src/harness/session/session.ts";
|
||||
import type {
|
||||
LaneConfiguration,
|
||||
OperationState,
|
||||
Session,
|
||||
ToolCall,
|
||||
ToolsOperation,
|
||||
Write,
|
||||
} from "../../../src/harness/session/types.ts";
|
||||
import * as storedValues from "../../../src/harness/session/values.ts";
|
||||
import type {
|
||||
AgentHarnessTool,
|
||||
AgentHarnessToolInvocation,
|
||||
AgentHarnessToolUpdateCallback,
|
||||
} from "../../../src/harness/types.ts";
|
||||
|
||||
const sessions: Session[] = [];
|
||||
const schema = Type.Object({ value: Type.String() });
|
||||
|
||||
interface Fixture {
|
||||
session: Session;
|
||||
lane: Lane<undefined>;
|
||||
drive: Drive;
|
||||
hooks: HookRegistry;
|
||||
events: HarnessEvent[];
|
||||
assistantEntryId: string;
|
||||
resultEntryIds: string[];
|
||||
operationId: string;
|
||||
observations: string[];
|
||||
}
|
||||
|
||||
interface FixtureOptions {
|
||||
calls: Array<{ name: string; value?: string }>;
|
||||
tools?: AgentHarnessTool<undefined>[];
|
||||
mode?: "sequential" | "parallel";
|
||||
stopReason?: "toolUse" | "length";
|
||||
callStates?: (resultEntryIds: string[]) => ToolCall[];
|
||||
extraWrites?: (fixture: {
|
||||
operationId: string;
|
||||
assistantEntryId: string;
|
||||
resultEntryIds: string[];
|
||||
run: ToolsOperation;
|
||||
}) => Write[];
|
||||
toolContext?: Config<undefined>["toolContext"];
|
||||
cancelled?: boolean;
|
||||
onEmit?: (events: readonly HarnessEvent[]) => Promise<void>;
|
||||
}
|
||||
|
||||
function unusedWatch<T>(): WatchHandle<T> {
|
||||
throw new Error("watch is not used by tool procedure tests");
|
||||
}
|
||||
|
||||
class ObservedMemoryStorage extends MemoryStorage {
|
||||
readonly observations: string[] = [];
|
||||
|
||||
override async commit(writes: Write[], context: Context) {
|
||||
const result = await super.commit(writes, context);
|
||||
if (
|
||||
writes.some((write) => write.kind === "value" && write.op === "set" && write.namespace === "pi.op.tool_args")
|
||||
) {
|
||||
this.observations.push("intent_commit");
|
||||
}
|
||||
const stagesOutcome = writes.some(
|
||||
(write) => write.kind === "value" && write.op === "set" && write.namespace === "pi.pending.entry",
|
||||
);
|
||||
if (stagesOutcome) this.observations.push("outcome_commit");
|
||||
if (
|
||||
!stagesOutcome &&
|
||||
writes.some(
|
||||
(write) => write.kind === "value" && write.op === "delete" && write.namespace === "pi.pending.tool_output",
|
||||
)
|
||||
) {
|
||||
this.observations.push("replay_commit");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T = void>(): { promise: Promise<T>; resolve(value: T): void } {
|
||||
let resolvePromise: ((value: T) => void) | undefined;
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
return { promise, resolve: (value) => resolvePromise?.(value) };
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean): Promise<void> {
|
||||
for (let index = 0; index < 200; index += 1) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
throw new Error("condition was not reached");
|
||||
}
|
||||
|
||||
function tool(
|
||||
name: string,
|
||||
execute: (
|
||||
value: string,
|
||||
onUpdate: AgentHarnessToolUpdateCallback<{ progress?: string }> | undefined,
|
||||
invocation: AgentHarnessToolInvocation,
|
||||
context: Parameters<AgentHarnessTool<undefined>["execute"]>[5],
|
||||
) => Promise<{
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
details: { value?: string };
|
||||
usage?: ToolResultMessage["usage"];
|
||||
addedToolNames?: string[];
|
||||
terminate?: boolean;
|
||||
}>,
|
||||
replay: "never" | "safe" = "never",
|
||||
): AgentHarnessTool<undefined, typeof schema, { progress?: string } | { value?: string }> {
|
||||
return {
|
||||
name,
|
||||
label: name,
|
||||
description: name,
|
||||
parameters: schema,
|
||||
replay,
|
||||
execute: async (_toolCallId, args, onUpdate, _toolContext, invocation, context) =>
|
||||
execute(args.value, onUpdate, invocation, context),
|
||||
};
|
||||
}
|
||||
|
||||
async function createFixture(options: FixtureOptions): Promise<Fixture> {
|
||||
const storage = new ObservedMemoryStorage({ now: () => 100 });
|
||||
const session = new StorageBackedSession(
|
||||
{ id: `drive-tools-${sessions.length}`, createdAt: 1, storageVersion: 1 },
|
||||
storage,
|
||||
);
|
||||
sessions.push(session);
|
||||
const operationId = session.idGenerator.next(10);
|
||||
const assistantEntryId = session.idGenerator.next(20);
|
||||
const resultEntryIds = options.calls.map(() => session.idGenerator.next(20));
|
||||
const faux = fauxProvider();
|
||||
const model = faux.getModel();
|
||||
const configuration: LaneConfiguration = {
|
||||
model: { provider: model.provider, modelId: model.id },
|
||||
thinkingLevel: "off",
|
||||
activeToolNames: options.calls.map(({ name }) => name),
|
||||
};
|
||||
const assistant = fauxAssistantMessage(
|
||||
options.calls.map(({ name, value = name }, index) => fauxToolCall(name, { value }, { id: `call-${index}` })),
|
||||
{ stopReason: options.stopReason ?? "toolUse", timestamp: 20 },
|
||||
);
|
||||
const calls =
|
||||
options.callStates?.(resultEntryIds) ??
|
||||
resultEntryIds.map((resultEntryId, index) => ({
|
||||
status: "planned" as const,
|
||||
sourceIndex: index,
|
||||
resultEntryId,
|
||||
}));
|
||||
const run: ToolsOperation = {
|
||||
at: "tools",
|
||||
control: options.cancelled ? { status: "cancel_requested", requestedAt: 30 } : { status: "running" },
|
||||
settings: {
|
||||
compaction: DEFAULT_COMPACTION_SETTINGS,
|
||||
steeringMode: "all",
|
||||
followUpMode: "all",
|
||||
toolExecution: options.mode ?? "parallel",
|
||||
},
|
||||
batch: { assistantEntryId, configuration, turnId: "turn-1", calls },
|
||||
latestAssistantEntryId: assistantEntryId,
|
||||
};
|
||||
const writes: Write[] = [
|
||||
insertEntry({ id: assistantEntryId, parentId: null, type: "message", message: assistant }),
|
||||
storedValues.setValue(storedValues.branchTip("main"), assistantEntryId),
|
||||
storedValues.setValue(storedValues.laneConfig("main"), configuration),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: operationId,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
storedValues.setValue(storedValues.operationMeta(operationId), {
|
||||
operationId,
|
||||
lane: "main",
|
||||
sourceTipId: null,
|
||||
startedAt: 10,
|
||||
intent: { kind: "run", promptEntryIds: [] },
|
||||
}),
|
||||
storedValues.setValue(storedValues.operationState(operationId), run),
|
||||
...(options.extraWrites?.({ operationId, assistantEntryId, resultEntryIds, run }) ?? []),
|
||||
];
|
||||
await session.mutate((mutator) => mutator.commit(writes, BACKGROUND_CONTEXT), BACKGROUND_CONTEXT);
|
||||
|
||||
const models = createModels();
|
||||
models.setProvider(faux.provider);
|
||||
const hooks = new HookRegistry(() => {});
|
||||
const events: HarnessEvent[] = [];
|
||||
const config: Config<undefined> = {
|
||||
tools: options.tools ?? [],
|
||||
resources: {},
|
||||
streamOptions: {},
|
||||
retryPolicy: { enabled: false, maxRetries: 0, baseDelayMs: 0 },
|
||||
compaction: DEFAULT_COMPACTION_SETTINGS,
|
||||
steeringMode: "all",
|
||||
followUpMode: "all",
|
||||
toolExecution: options.mode ?? "parallel",
|
||||
toolContext: options.toolContext,
|
||||
systemPrompt: undefined,
|
||||
toProviderMessages: (messages) =>
|
||||
messages.filter(
|
||||
(message) => message.role === "user" || message.role === "assistant" || message.role === "toolResult",
|
||||
),
|
||||
entryProjectors: {},
|
||||
};
|
||||
const lane = new Lane<undefined>(
|
||||
"main",
|
||||
session,
|
||||
models,
|
||||
hooks,
|
||||
await restoreLane(session, "main", BACKGROUND_CONTEXT),
|
||||
(cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
async (batch) => {
|
||||
events.push(...structuredClone(batch));
|
||||
storage.observations.push(...batch.map((event) => event.type));
|
||||
await options.onEmit?.(batch);
|
||||
},
|
||||
unusedWatch,
|
||||
() => config,
|
||||
);
|
||||
const drive = new Drive({ operationId }, BACKGROUND_CONTEXT);
|
||||
lane.activeDrive = drive;
|
||||
return {
|
||||
session,
|
||||
lane,
|
||||
drive,
|
||||
hooks,
|
||||
events,
|
||||
assistantEntryId,
|
||||
resultEntryIds,
|
||||
operationId,
|
||||
observations: storage.observations,
|
||||
};
|
||||
}
|
||||
|
||||
function currentRun(fixture: Fixture): OperationState {
|
||||
const operation = fixture.lane.state.operation;
|
||||
if (operation === null) throw new Error("fixture has no operation");
|
||||
return operation.state;
|
||||
}
|
||||
|
||||
function currentCalls(fixture: Fixture): ToolCall[] {
|
||||
const run = currentRun(fixture);
|
||||
if (run.at !== "tools") return [];
|
||||
return run.batch.calls;
|
||||
}
|
||||
|
||||
async function driveTools(fixture: Fixture) {
|
||||
const run = currentRun(fixture);
|
||||
if (run.at !== "tools") throw new Error("fixture has no tool batch");
|
||||
return runTools(fixture.lane, fixture.drive, run);
|
||||
}
|
||||
|
||||
async function expectProjectionRestores(fixture: Fixture): Promise<void> {
|
||||
expect(await restoreLane(fixture.session, "main", BACKGROUND_CONTEXT)).toEqual(fixture.lane.state);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(sessions.splice(0).map((session) => session.close(BACKGROUND_CONTEXT)));
|
||||
});
|
||||
|
||||
describe("durable tool batch", () => {
|
||||
it("executes a sequential batch with memos, checkpoints, hooks, usage, and source-order placement", async () => {
|
||||
let contextResolutions = 0;
|
||||
let lateInvocation: AgentHarnessToolInvocation | undefined;
|
||||
const usage = {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 3,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
const first = tool("first", async (value, onUpdate, invocation) => {
|
||||
lateInvocation = invocation;
|
||||
await invocation.setMemo("step/a", { value: "memo" });
|
||||
expect(await invocation.getMemo("step/a")).toEqual({ value: "memo" });
|
||||
onUpdate?.(
|
||||
{ content: [{ type: "text", text: "partial" }], details: { progress: "partial" } },
|
||||
{ checkpoint: true },
|
||||
);
|
||||
return { content: [{ type: "text", text: value }], details: { value }, usage };
|
||||
});
|
||||
const second = tool("second", async (value) => ({
|
||||
content: [{ type: "text", text: value }],
|
||||
details: { value },
|
||||
addedToolNames: ["introduced"],
|
||||
}));
|
||||
const fixture = await createFixture({
|
||||
calls: [{ name: "first" }, { name: "second" }],
|
||||
tools: [first, second],
|
||||
mode: "sequential",
|
||||
toolContext: () => {
|
||||
contextResolutions += 1;
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
let checkpointSeenByAfterHook = false;
|
||||
fixture.hooks.on("before_tool", ({ toolName, args }) =>
|
||||
toolName === "first" ? { args: { ...args, value: "prepared" } } : undefined,
|
||||
);
|
||||
fixture.hooks.on("after_tool", async ({ toolName }) => {
|
||||
if (toolName === "first") {
|
||||
checkpointSeenByAfterHook =
|
||||
(await fixture.session.getValue(
|
||||
storedValues.pendingToolOutput(fixture.operationId, fixture.resultEntryIds[0]!),
|
||||
BACKGROUND_CONTEXT,
|
||||
)) !== undefined;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
expect(await driveTools(fixture)).toEqual({ kind: "continue" });
|
||||
const transcript = await fixture.lane.findEntries({ order: "oldestFirst" }, BACKGROUND_CONTEXT);
|
||||
expect(transcript.map(({ id }) => id)).toEqual([fixture.assistantEntryId, ...fixture.resultEntryIds]);
|
||||
expect(transcript[1]?.type === "message" ? transcript[1].message : undefined).toMatchObject({
|
||||
role: "toolResult",
|
||||
content: [{ type: "text", text: "prepared" }],
|
||||
});
|
||||
expect(contextResolutions).toBe(1);
|
||||
expect(checkpointSeenByAfterHook).toBe(true);
|
||||
expect(currentRun(fixture)).toMatchObject({
|
||||
at: "checkpoint",
|
||||
continuation: { kind: "need_assistant" },
|
||||
});
|
||||
expect(fixture.lane.state.configuration.activeToolNames).toEqual(["first", "second", "introduced"]);
|
||||
expect(
|
||||
await fixture.session.scanValues(
|
||||
storedValues.operationToolArgsPrefix(fixture.operationId),
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await fixture.session.scanValues(
|
||||
storedValues.operationToolMemoPrefix(fixture.operationId),
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await fixture.session.getValue(
|
||||
storedValues.pendingToolOutput(fixture.operationId, fixture.resultEntryIds[0]!),
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toBeUndefined();
|
||||
await expect(lateInvocation?.setMemo("late", true)).rejects.toThrow("no longer owns");
|
||||
const eventTypes = fixture.events.map(({ type }) => type);
|
||||
expect(eventTypes.indexOf("tool_update")).toBeLessThan(eventTypes.indexOf("tool_end"));
|
||||
const firstIntent = fixture.observations.indexOf("intent_commit");
|
||||
const firstStart = fixture.observations.indexOf("tool_start");
|
||||
const firstUpdate = fixture.observations.indexOf("tool_update");
|
||||
const firstCommit = fixture.observations.indexOf("outcome_commit");
|
||||
const firstEnd = fixture.observations.indexOf("tool_end");
|
||||
const firstPlacement = fixture.observations.indexOf("entry_added");
|
||||
expect(firstIntent).toBeLessThan(firstStart);
|
||||
expect(firstStart).toBeLessThan(firstUpdate);
|
||||
expect(firstUpdate).toBeLessThan(firstCommit);
|
||||
expect(firstCommit).toBeLessThan(firstEnd);
|
||||
expect(firstEnd).toBeLessThan(firstPlacement);
|
||||
expect(fixture.events.filter(({ type }) => type === "entry_added")).toHaveLength(2);
|
||||
expect(fixture.events.filter(({ type }) => type === "usage")).toHaveLength(1);
|
||||
expect(fixture.events.at(-1)?.type).toBe("turn_end");
|
||||
await expectProjectionRestores(fixture);
|
||||
});
|
||||
|
||||
it("stages parallel completion order but materializes source order", async () => {
|
||||
const finishA = deferred<void>();
|
||||
const finishB = deferred<void>();
|
||||
const started: string[] = [];
|
||||
const make = (name: string, finish: ReturnType<typeof deferred<void>>) =>
|
||||
tool(name, async () => {
|
||||
started.push(name);
|
||||
await finish.promise;
|
||||
return { content: [{ type: "text", text: name }], details: { value: name } };
|
||||
});
|
||||
const fixture = await createFixture({
|
||||
calls: [{ name: "a" }, { name: "b" }],
|
||||
tools: [make("a", finishA), make("b", finishB)],
|
||||
mode: "parallel",
|
||||
});
|
||||
const running = driveTools(fixture);
|
||||
await waitFor(() => started.length === 2);
|
||||
finishB.resolve();
|
||||
await waitFor(() => currentCalls(fixture)[1]?.status === "outcome_ready");
|
||||
expect(currentCalls(fixture).map(({ status }) => status)).toEqual(["effect_pending", "outcome_ready"]);
|
||||
expect(await fixture.session.getEntry(fixture.resultEntryIds[1]!, BACKGROUND_CONTEXT)).toBeUndefined();
|
||||
expect(fixture.events.find((event) => event.type === "tool_start" && event.toolName === "b")).toMatchObject({
|
||||
args: { value: "b" },
|
||||
});
|
||||
expect(fixture.events.find((event) => event.type === "tool_end" && event.toolName === "b")).toMatchObject({
|
||||
result: { content: [{ type: "text", text: "b" }] },
|
||||
});
|
||||
finishA.resolve();
|
||||
await running;
|
||||
|
||||
const transcript = await fixture.lane.findEntries({ order: "oldestFirst" }, BACKGROUND_CONTEXT);
|
||||
expect(transcript.map(({ id }) => id)).toEqual([
|
||||
fixture.assistantEntryId,
|
||||
fixture.resultEntryIds[0],
|
||||
fixture.resultEntryIds[1],
|
||||
]);
|
||||
await expectProjectionRestores(fixture);
|
||||
});
|
||||
|
||||
it("safe-replays persisted arguments and memos while interrupting unsafe effects", async () => {
|
||||
const safeExecute = vi.fn(async (_value: string, _onUpdate: unknown, invocation: AgentHarnessToolInvocation) => {
|
||||
expect(invocation.invocationId).toBeDefined();
|
||||
expect(await invocation.getMemo("step/a")).toEqual({ complete: true });
|
||||
return { content: [{ type: "text" as const, text: "safe replay" }], details: { value: "safe" } };
|
||||
});
|
||||
const unsafeExecute = vi.fn(async () => ({
|
||||
content: [{ type: "text" as const, text: "must not run" }],
|
||||
details: {},
|
||||
}));
|
||||
const fixture = await createFixture({
|
||||
calls: [{ name: "safe" }, { name: "unsafe" }],
|
||||
tools: [tool("safe", safeExecute, "safe"), tool("unsafe", unsafeExecute, "never")],
|
||||
callStates: (ids) => [
|
||||
{ status: "effect_pending", sourceIndex: 0, resultEntryId: ids[0]!, replay: "safe" },
|
||||
{ status: "effect_pending", sourceIndex: 1, resultEntryId: ids[1]!, replay: "never" },
|
||||
],
|
||||
extraWrites: ({ operationId, resultEntryIds }) => [
|
||||
storedValues.setValue(storedValues.operationToolArgs(operationId, "turn-1", 0), { value: "persisted" }),
|
||||
storedValues.setValue(storedValues.operationToolArgs(operationId, "turn-1", 1), { value: "unsafe" }),
|
||||
storedValues.setValue(storedValues.operationToolMemo(operationId, resultEntryIds[0]!, "step/a"), {
|
||||
complete: true,
|
||||
}),
|
||||
storedValues.setValue(storedValues.pendingToolOutput(operationId, resultEntryIds[0]!), {
|
||||
content: [{ type: "text", text: "old progress" }],
|
||||
details: {},
|
||||
}),
|
||||
storedValues.setValue(storedValues.pendingToolOutput(operationId, resultEntryIds[1]!), {
|
||||
content: [{ type: "text", text: "durable partial" }],
|
||||
details: { progress: "kept" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await driveTools(fixture);
|
||||
expect(safeExecute).toHaveBeenCalledOnce();
|
||||
expect(safeExecute.mock.calls[0]?.[0]).toBe("persisted");
|
||||
expect(unsafeExecute).not.toHaveBeenCalled();
|
||||
expect(
|
||||
fixture.events.find((event) => event.type === "tool_start" && event.toolCallId === "call-0"),
|
||||
).toMatchObject({ recovery: true, args: { value: "persisted" } });
|
||||
expect(fixture.observations.indexOf("replay_commit")).toBeLessThan(fixture.observations.indexOf("tool_start"));
|
||||
expect(fixture.events.find((event) => event.type === "tool_end" && event.toolCallId === "call-0")).toMatchObject({
|
||||
recovery: true,
|
||||
isError: false,
|
||||
});
|
||||
const unsafeEntry = await fixture.session.getEntry(fixture.resultEntryIds[1]!, BACKGROUND_CONTEXT);
|
||||
expect(unsafeEntry?.type === "message" ? unsafeEntry.message : undefined).toMatchObject({
|
||||
role: "toolResult",
|
||||
isError: true,
|
||||
details: { progress: "kept" },
|
||||
});
|
||||
expect(
|
||||
unsafeEntry?.type === "message" && unsafeEntry.message.role === "toolResult"
|
||||
? unsafeEntry.message.content.at(-1)
|
||||
: undefined,
|
||||
).toMatchObject({ type: "text", text: expect.stringContaining("external outcome is unknown") });
|
||||
expect(fixture.events.some((event) => event.type === "tool_start" && event.toolCallId === "call-1")).toBe(false);
|
||||
expect(fixture.events.find((event) => event.type === "tool_end" && event.toolCallId === "call-1")).toMatchObject({
|
||||
recovery: true,
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reconciles a restored cancelled batch without hooks, context, or effects", async () => {
|
||||
const execute = vi.fn(async () => ({ content: [], details: {} }));
|
||||
const context = vi.fn(() => undefined);
|
||||
const fixture = await createFixture({
|
||||
calls: [{ name: "planned" }, { name: "pending" }],
|
||||
tools: [tool("planned", execute), tool("pending", execute, "safe")],
|
||||
toolContext: context,
|
||||
cancelled: true,
|
||||
callStates: (ids) => [
|
||||
{ status: "planned", sourceIndex: 0, resultEntryId: ids[0]! },
|
||||
{ status: "effect_pending", sourceIndex: 1, resultEntryId: ids[1]!, replay: "safe" },
|
||||
],
|
||||
extraWrites: ({ operationId, resultEntryIds }) => [
|
||||
storedValues.setValue(storedValues.operationToolArgs(operationId, "turn-1", 1), { value: "pending" }),
|
||||
storedValues.setValue(storedValues.pendingToolOutput(operationId, resultEntryIds[1]!), {
|
||||
content: [{ type: "text", text: "checkpoint" }],
|
||||
details: {},
|
||||
}),
|
||||
],
|
||||
});
|
||||
const beforeTool = vi.fn(() => undefined);
|
||||
const afterTool = vi.fn(() => undefined);
|
||||
fixture.hooks.on("before_tool", beforeTool);
|
||||
fixture.hooks.on("after_tool", afterTool);
|
||||
|
||||
await driveTools(fixture);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
expect(context).not.toHaveBeenCalled();
|
||||
expect(beforeTool).not.toHaveBeenCalled();
|
||||
expect(afterTool).not.toHaveBeenCalled();
|
||||
expect(currentRun(fixture)).toMatchObject({
|
||||
at: "checkpoint",
|
||||
control: { status: "cancel_requested" },
|
||||
continuation: { kind: "need_assistant" },
|
||||
});
|
||||
const pending = await fixture.session.getEntry(fixture.resultEntryIds[1]!, BACKGROUND_CONTEXT);
|
||||
expect(
|
||||
pending?.type === "message" && pending.message.role === "toolResult"
|
||||
? pending.message.content.at(-1)
|
||||
: undefined,
|
||||
).toMatchObject({ type: "text", text: expect.stringContaining("external outcome is unknown") });
|
||||
const starts = fixture.events.filter((event) => event.type === "tool_start");
|
||||
const ends = fixture.events.filter((event) => event.type === "tool_end");
|
||||
expect(starts).toHaveLength(1);
|
||||
expect(starts[0]).toMatchObject({ toolName: "planned", args: { value: "planned" } });
|
||||
expect(ends).toHaveLength(2);
|
||||
expect(ends.find((event) => event.toolCallId === starts[0]?.toolCallId)).toMatchObject({ isError: true });
|
||||
});
|
||||
|
||||
it("materializes outcome-ready state without resolving tools or tool context", async () => {
|
||||
const execute = vi.fn(async () => ({ content: [], details: {} }));
|
||||
const context = vi.fn(() => undefined);
|
||||
const staged: ToolResultMessage = {
|
||||
role: "toolResult",
|
||||
toolCallId: "call-0",
|
||||
toolName: "ready",
|
||||
content: [{ type: "text", text: "already done" }],
|
||||
addedToolNames: ["later"],
|
||||
isError: false,
|
||||
timestamp: 30,
|
||||
};
|
||||
const fixture = await createFixture({
|
||||
calls: [{ name: "ready" }],
|
||||
tools: [tool("ready", execute)],
|
||||
toolContext: context,
|
||||
callStates: (ids) => [{ status: "outcome_ready", sourceIndex: 0, resultEntryId: ids[0]!, terminate: true }],
|
||||
extraWrites: ({ resultEntryIds }) => [
|
||||
storedValues.setValue(storedValues.pendingEntry(resultEntryIds[0]!), {
|
||||
type: "message",
|
||||
payload: staged,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await driveTools(fixture);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
expect(context).not.toHaveBeenCalled();
|
||||
expect(currentRun(fixture)).toMatchObject({
|
||||
at: "checkpoint",
|
||||
continuation: { kind: "may_finish", includeFinalAssistant: false },
|
||||
});
|
||||
expect(fixture.lane.state.configuration.activeToolNames).toEqual(["ready", "later"]);
|
||||
expect(fixture.events[0]).toMatchObject({ type: "turn_start", turnId: "turn-1", recovery: true });
|
||||
expect(fixture.events.at(-1)).toMatchObject({ type: "turn_end", turnId: "turn-1", recovery: true });
|
||||
});
|
||||
|
||||
it("never executes genuine-length or missing tool calls", async () => {
|
||||
const execute = vi.fn(async () => ({ content: [], details: {} }));
|
||||
const truncated = await createFixture({
|
||||
calls: [{ name: "present" }],
|
||||
tools: [tool("present", execute)],
|
||||
stopReason: "length",
|
||||
});
|
||||
const truncatedAfterTool = vi.fn(() => undefined);
|
||||
truncated.hooks.on("after_tool", truncatedAfterTool);
|
||||
await driveTools(truncated);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
const truncatedEntry = await truncated.session.getEntry(truncated.resultEntryIds[0]!, BACKGROUND_CONTEXT);
|
||||
expect(
|
||||
truncatedEntry?.type === "message" && truncatedEntry.message.role === "toolResult"
|
||||
? truncatedEntry.message.content[0]
|
||||
: undefined,
|
||||
).toMatchObject({ type: "text", text: expect.stringContaining("arguments may be truncated") });
|
||||
expect(truncatedAfterTool).not.toHaveBeenCalled();
|
||||
expect(truncated.events.filter((event) => event.type === "tool_start")).toHaveLength(1);
|
||||
expect(truncated.events.filter((event) => event.type === "tool_end")).toHaveLength(1);
|
||||
expect(truncated.observations.indexOf("outcome_commit")).toBeLessThan(
|
||||
truncated.observations.indexOf("tool_start"),
|
||||
);
|
||||
expect(truncated.observations.indexOf("tool_start")).toBeLessThan(truncated.observations.indexOf("tool_end"));
|
||||
expect(truncated.observations.indexOf("tool_end")).toBeLessThan(truncated.observations.indexOf("entry_added"));
|
||||
|
||||
const missing = await createFixture({ calls: [{ name: "missing" }], tools: [] });
|
||||
const missingAfterTool = vi.fn(() => undefined);
|
||||
missing.hooks.on("after_tool", missingAfterTool);
|
||||
await driveTools(missing);
|
||||
const missingEntry = await missing.session.getEntry(missing.resultEntryIds[0]!, BACKGROUND_CONTEXT);
|
||||
expect(missingEntry?.type === "message" ? missingEntry.message : undefined).toMatchObject({
|
||||
role: "toolResult",
|
||||
isError: true,
|
||||
content: [{ type: "text", text: 'Tool "missing" is unavailable' }],
|
||||
});
|
||||
expect(
|
||||
missingEntry?.type === "message" && missingEntry.message.role === "toolResult"
|
||||
? Object.hasOwn(missingEntry.message, "details")
|
||||
: true,
|
||||
).toBe(false);
|
||||
expect(missingAfterTool).not.toHaveBeenCalled();
|
||||
expect(missing.events.filter((event) => event.type === "tool_start")).toHaveLength(1);
|
||||
const missingEnds = missing.events.filter((event) => event.type === "tool_end");
|
||||
expect(missingEnds).toHaveLength(1);
|
||||
expect(missing.events.find((event) => event.type === "tool_start")).toMatchObject({
|
||||
args: { value: "missing" },
|
||||
});
|
||||
expect(missingEnds[0]).toMatchObject({
|
||||
result: { content: [{ type: "text", text: 'Tool "missing" is unavailable' }], details: undefined },
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("awaits update delivery and checkpoint persistence before after_tool", async () => {
|
||||
const releaseUpdate = deferred<void>();
|
||||
const updateQueued = deferred<void>();
|
||||
let afterStarted = false;
|
||||
const fixture = await createFixture({
|
||||
calls: [{ name: "updating" }],
|
||||
tools: [
|
||||
tool("updating", async (_value, onUpdate) => {
|
||||
onUpdate?.(
|
||||
{ content: [{ type: "text", text: "partial" }], details: { progress: "partial" } },
|
||||
{ checkpoint: true },
|
||||
);
|
||||
return { content: [{ type: "text", text: "done" }], details: {} };
|
||||
}),
|
||||
],
|
||||
onEmit: async (events) => {
|
||||
if (events.some(({ type }) => type === "tool_update")) {
|
||||
updateQueued.resolve();
|
||||
await releaseUpdate.promise;
|
||||
}
|
||||
},
|
||||
});
|
||||
fixture.hooks.on("after_tool", async () => {
|
||||
afterStarted = true;
|
||||
expect(
|
||||
await fixture.session.getValue(
|
||||
storedValues.pendingToolOutput(fixture.operationId, fixture.resultEntryIds[0]!),
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toBeDefined();
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const running = driveTools(fixture);
|
||||
await updateQueued.promise;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(afterStarted).toBe(false);
|
||||
releaseUpdate.resolve();
|
||||
await running;
|
||||
expect(afterStarted).toBe(true);
|
||||
});
|
||||
|
||||
it("does not stage a cancelled outcome before cancellation is durable", async () => {
|
||||
const execute = vi.fn(async () => ({ content: [], details: {} }));
|
||||
const fixture = await createFixture({
|
||||
calls: [{ name: "cancel-before-admission" }],
|
||||
tools: [tool("cancel-before-admission", execute)],
|
||||
mode: "sequential",
|
||||
});
|
||||
const cancellation = deferred<void>();
|
||||
fixture.drive.beginAbort(cancellation.promise);
|
||||
const running = driveTools(fixture);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(currentCalls(fixture)[0]?.status).toBe("planned");
|
||||
expect(
|
||||
await fixture.session.getValue(storedValues.pendingEntry(fixture.resultEntryIds[0]!), BACKGROUND_CONTEXT),
|
||||
).toBeUndefined();
|
||||
await fixture.lane.command((state) => {
|
||||
const operation = state.operation;
|
||||
if (operation === null) throw new Error("missing operation");
|
||||
const nextRun: OperationState = {
|
||||
...operation.state,
|
||||
control: { status: "cancel_requested", requestedAt: 40 },
|
||||
};
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [storedValues.setValue(storedValues.operationState(fixture.operationId), nextRun)],
|
||||
next: { ...state, operation: { meta: operation.meta, state: nextRun } },
|
||||
materialize: () => undefined,
|
||||
};
|
||||
}, BACKGROUND_CONTEXT);
|
||||
cancellation.resolve();
|
||||
fixture.drive.signalAbort();
|
||||
await running;
|
||||
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
expect(await fixture.session.getEntry(fixture.resultEntryIds[0]!, BACKGROUND_CONTEXT)).toBeDefined();
|
||||
});
|
||||
|
||||
it("drains live updates before after_tool and stages a non-terminating result after cancellation", async () => {
|
||||
const updateDelivery = deferred<void>();
|
||||
const started = deferred<void>();
|
||||
let updateDelivered = false;
|
||||
const afterTool = vi.fn(() => {
|
||||
expect(updateDelivered).toBe(true);
|
||||
return undefined;
|
||||
});
|
||||
const executing = tool("slow", async (_value, onUpdate, _invocation, context) => {
|
||||
onUpdate?.({ content: [{ type: "text", text: "partial" }], details: { progress: "partial" } });
|
||||
started.resolve();
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
context.abortSignal?.addEventListener("abort", () => reject(new Error("cancelled effect")), { once: true });
|
||||
});
|
||||
return { content: [], details: {}, terminate: true };
|
||||
});
|
||||
const fixture = await createFixture({
|
||||
calls: [{ name: "slow" }],
|
||||
tools: [executing],
|
||||
mode: "sequential",
|
||||
onEmit: async (events) => {
|
||||
if (events.some(({ type }) => type === "tool_update")) {
|
||||
await updateDelivery.promise;
|
||||
updateDelivered = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
fixture.hooks.on("after_tool", afterTool);
|
||||
const running = driveTools(fixture);
|
||||
await started.promise;
|
||||
const cancellation = deferred<void>();
|
||||
fixture.drive.beginAbort(cancellation.promise);
|
||||
await fixture.lane.command((state) => {
|
||||
const operation = state.operation;
|
||||
if (operation === null) throw new Error("missing operation");
|
||||
const nextRun: OperationState = {
|
||||
...operation.state,
|
||||
control: { status: "cancel_requested", requestedAt: 40 },
|
||||
};
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [storedValues.setValue(storedValues.operationState(fixture.operationId), nextRun)],
|
||||
next: { ...state, operation: { meta: operation.meta, state: nextRun } },
|
||||
materialize: () => undefined,
|
||||
};
|
||||
}, BACKGROUND_CONTEXT);
|
||||
cancellation.resolve();
|
||||
fixture.drive.signalAbort();
|
||||
updateDelivery.resolve();
|
||||
await running;
|
||||
expect(afterTool).not.toHaveBeenCalled();
|
||||
const entry = await fixture.session.getEntry(fixture.resultEntryIds[0]!, BACKGROUND_CONTEXT);
|
||||
expect(entry?.type === "message" ? entry.terminate : undefined).toBeUndefined();
|
||||
expect(entry?.type === "message" ? entry.message : undefined).toMatchObject({
|
||||
role: "toolResult",
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
397
packages/agent/test/harness/runtime/harness.test.ts
Normal file
397
packages/agent/test/harness/runtime/harness.test.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import { createModels, fauxAssistantMessage, fauxProvider, type Provider } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentHarness, HarnessFault, InvalidLane, UnknownTarget } from "../../../src/harness/agent-harness.ts";
|
||||
import { BACKGROUND_CONTEXT } from "../../../src/harness/context.ts";
|
||||
import { Harness } from "../../../src/harness/runtime/harness.ts";
|
||||
import { Lane } from "../../../src/harness/runtime/lane.ts";
|
||||
import { MemorySessionRepo, MemoryStorage } from "../../../src/harness/session/memory.ts";
|
||||
import { StorageBackedSession } from "../../../src/harness/session/session.ts";
|
||||
import type { LaneConfiguration, Session } from "../../../src/harness/session/types.ts";
|
||||
import { branchTip, laneConfig, laneState, pendingEntry, setValue } from "../../../src/harness/session/values.ts";
|
||||
import { deferred } from "./test-utils.ts";
|
||||
|
||||
const sessions: Session[] = [];
|
||||
|
||||
function harnessOptions(session: Session) {
|
||||
const provider = fauxProvider();
|
||||
const models = createModels();
|
||||
models.setProvider(provider.provider);
|
||||
return {
|
||||
session,
|
||||
models,
|
||||
model: provider.getModel(),
|
||||
thinkingLevel: "medium" as const,
|
||||
activeToolNames: ["read", "bash"],
|
||||
};
|
||||
}
|
||||
|
||||
async function createSession(id = `session-${sessions.length}`): Promise<Session> {
|
||||
const session = new StorageBackedSession({ id, createdAt: 1, storageVersion: 1 }, new MemoryStorage());
|
||||
sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function createHarness(session?: Session): Promise<Harness<object | undefined>> {
|
||||
session ??= await createSession();
|
||||
const created = await AgentHarness.create(harnessOptions(session), BACKGROUND_CONTEXT);
|
||||
if (!(created.harness instanceof Harness)) throw new Error("Expected runtime Harness");
|
||||
return created.harness;
|
||||
}
|
||||
|
||||
const configured = {
|
||||
model: { provider: "faux", modelId: "faux-1" },
|
||||
thinkingLevel: "low",
|
||||
activeToolNames: ["read"],
|
||||
} satisfies LaneConfiguration;
|
||||
|
||||
afterEach(async () => {
|
||||
for (const session of sessions.splice(0)) await session.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
describe("runtime Harness lane management", () => {
|
||||
it("attaches to a fresh Session without creating an implicit main lane", async () => {
|
||||
const session = await createSession();
|
||||
const harness = await createHarness(session);
|
||||
|
||||
expect(await harness.lanes(BACKGROUND_CONTEXT)).toEqual([]);
|
||||
expect(await session.branch("main", BACKGROUND_CONTEXT)).toBeUndefined();
|
||||
expect("accept" in harness).toBe(false);
|
||||
expect("getTipId" in harness).toBe(false);
|
||||
expect("appendMessage" in harness).toBe(false);
|
||||
});
|
||||
|
||||
it("atomically gets or creates a complete AgentLane", async () => {
|
||||
const session = await createSession();
|
||||
const harness = await createHarness(session);
|
||||
const events: string[] = [];
|
||||
harness.events.on("lane_created", (event) => {
|
||||
events.push(event.lane);
|
||||
});
|
||||
|
||||
const lane = await harness.lane("main", BACKGROUND_CONTEXT);
|
||||
const same = await harness.lane("main", { createAt: "ignored" }, BACKGROUND_CONTEXT);
|
||||
|
||||
expect(lane).toBeInstanceOf(Lane);
|
||||
expect(same).toBe(lane);
|
||||
expect(await lane.getTipId(BACKGROUND_CONTEXT)).toBeNull();
|
||||
expect(await lane.getThinkingLevel(BACKGROUND_CONTEXT)).toBe("medium");
|
||||
expect(await lane.getActiveTools(BACKGROUND_CONTEXT)).toEqual(["read", "bash"]);
|
||||
expect((await session.getValue(laneState("main"), BACKGROUND_CONTEXT))?.value).toEqual({
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
});
|
||||
expect(events).toEqual(["main"]);
|
||||
expect(await harness.lanes(BACKGROUND_CONTEXT)).toMatchObject([{ name: "main", tipId: null }]);
|
||||
});
|
||||
|
||||
it("returns one published AgentLane under concurrent acquisition", async () => {
|
||||
const session = await createSession();
|
||||
const harness = await createHarness(session);
|
||||
const listener = vi.fn();
|
||||
harness.events.on("lane_created", listener);
|
||||
|
||||
const [first, second, third] = await Promise.all([
|
||||
harness.lane("main", BACKGROUND_CONTEXT),
|
||||
harness.lane("main", BACKGROUND_CONTEXT),
|
||||
harness.lane("main", BACKGROUND_CONTEXT),
|
||||
]);
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(third).toBe(first);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses one stable provider session id per lane", async () => {
|
||||
const session = await createSession("shared-session");
|
||||
const faux = fauxProvider();
|
||||
faux.setResponses([
|
||||
fauxAssistantMessage("main one"),
|
||||
fauxAssistantMessage("main two"),
|
||||
fauxAssistantMessage("review"),
|
||||
]);
|
||||
const sessionIds: Array<string | undefined> = [];
|
||||
const base = faux.provider;
|
||||
const provider: Provider = {
|
||||
id: base.id,
|
||||
name: base.name,
|
||||
auth: base.auth,
|
||||
getModels: () => base.getModels(),
|
||||
stream: (model, context, options) => base.stream(model, context, options),
|
||||
streamSimple: (model, context, options) => {
|
||||
sessionIds.push(options?.sessionId);
|
||||
return base.streamSimple(model, context, options);
|
||||
},
|
||||
};
|
||||
const models = createModels();
|
||||
models.setProvider(provider);
|
||||
const created = await AgentHarness.create(
|
||||
{ session, models, model: faux.getModel(), activeToolNames: [] },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const main = await created.harness.lane("main", BACKGROUND_CONTEXT);
|
||||
const review = await created.harness.lane("review", BACKGROUND_CONTEXT);
|
||||
|
||||
await main.prompt("one", undefined, BACKGROUND_CONTEXT);
|
||||
await main.prompt("two", undefined, BACKGROUND_CONTEXT);
|
||||
await review.prompt("review", undefined, BACKGROUND_CONTEXT);
|
||||
|
||||
expect(sessionIds).toEqual(["shared-session:main", "shared-session:main", "shared-session:review"]);
|
||||
});
|
||||
|
||||
it("serializes commands from different AgentLanes on the one Session line", async () => {
|
||||
const harness = await createHarness();
|
||||
const main = await harness.lane("main", BACKGROUND_CONTEXT);
|
||||
const review = await harness.lane("review", BACKGROUND_CONTEXT);
|
||||
if (!(main instanceof Lane) || !(review instanceof Lane)) throw new Error("Expected runtime Lanes");
|
||||
const started = deferred();
|
||||
const gate = deferred();
|
||||
const order: string[] = [];
|
||||
const first = main.command(async () => {
|
||||
order.push("main:start");
|
||||
started.resolve();
|
||||
await gate.promise;
|
||||
order.push("main:end");
|
||||
return { kind: "return", result: undefined };
|
||||
}, BACKGROUND_CONTEXT);
|
||||
await started.promise;
|
||||
const second = review.command(() => {
|
||||
order.push("review");
|
||||
return { kind: "return", result: undefined };
|
||||
}, BACKGROUND_CONTEXT);
|
||||
await Promise.resolve();
|
||||
expect(order).toEqual(["main:start"]);
|
||||
gate.resolve();
|
||||
await Promise.all([first, second]);
|
||||
expect(order).toEqual(["main:start", "main:end", "review"]);
|
||||
});
|
||||
|
||||
it("uses createAt only for a missing lane and validates the target", async () => {
|
||||
const session = await createSession();
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
{
|
||||
kind: "entry",
|
||||
entry: {
|
||||
id: "target",
|
||||
parentId: null,
|
||||
type: "custom",
|
||||
customType: "target",
|
||||
},
|
||||
},
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const harness = await createHarness(session);
|
||||
const lane = await harness.lane("review", { createAt: "target" }, BACKGROUND_CONTEXT);
|
||||
|
||||
expect(await lane.getTipId(BACKGROUND_CONTEXT)).toBe("target");
|
||||
expect(await harness.lane("review", { createAt: "missing" }, BACKGROUND_CONTEXT)).toBe(lane);
|
||||
await expect(harness.lane("missing", { createAt: "unknown" }, BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(
|
||||
UnknownTarget,
|
||||
);
|
||||
await expect(harness.lane("", BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(InvalidLane);
|
||||
await expect(harness.lane("bad\u0000name", BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(InvalidLane);
|
||||
});
|
||||
|
||||
it("attaches agent state to a data-only Branch without moving its tip", async () => {
|
||||
const session = await createSession();
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
{
|
||||
kind: "entry",
|
||||
entry: {
|
||||
id: "target",
|
||||
parentId: null,
|
||||
type: "custom",
|
||||
customType: "target",
|
||||
},
|
||||
},
|
||||
setValue(branchTip("main"), "target"),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const harness = await createHarness(session);
|
||||
|
||||
expect(await harness.lanes(BACKGROUND_CONTEXT)).toEqual([]);
|
||||
const lane = await harness.lane("main", BACKGROUND_CONTEXT);
|
||||
expect(await lane.getTipId(BACKGROUND_CONTEXT)).toBe("target");
|
||||
expect((await session.getValue(laneConfig("main"), BACKGROUND_CONTEXT))?.value).toEqual({
|
||||
model: { provider: "faux", modelId: "faux-1" },
|
||||
thinkingLevel: "medium",
|
||||
activeToolNames: ["read", "bash"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps AgentLane appends operation-aware while exposing the Branch surface directly", async () => {
|
||||
const session = await createSession();
|
||||
const harness = await createHarness(session);
|
||||
const lane = await harness.lane("main", BACKGROUND_CONTEXT);
|
||||
const idleId = await lane.appendCustomEntry("idle", undefined, BACKGROUND_CONTEXT);
|
||||
expect(await lane.getTipId(BACKGROUND_CONTEXT)).toBe(idleId);
|
||||
|
||||
const admission = await lane.accept({ kind: "prompt", prompt: "run" }, BACKGROUND_CONTEXT);
|
||||
if (!admission.ok) throw admission.error;
|
||||
const acceptedTip = await lane.getTipId(BACKGROUND_CONTEXT);
|
||||
const pendingId = await lane.appendCustomEntry("pending", { queued: true }, BACKGROUND_CONTEXT);
|
||||
|
||||
expect(await lane.getTipId(BACKGROUND_CONTEXT)).toBe(acceptedTip);
|
||||
expect((await session.getValue(branchTip("main"), BACKGROUND_CONTEXT))?.value).toBe(acceptedTip);
|
||||
expect((await session.getValue(pendingEntry(pendingId), BACKGROUND_CONTEXT))?.value).toEqual({
|
||||
type: "custom",
|
||||
customType: "pending",
|
||||
payload: { queued: true },
|
||||
});
|
||||
expect((await session.getValue(laneState("main"), BACKGROUND_CONTEXT))?.value.inbox).toEqual([
|
||||
{ entryId: pendingId, kind: "write" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("flushes queued writes before a new idle append in one commit", async () => {
|
||||
const session = await createSession();
|
||||
const harness = await createHarness(session);
|
||||
const lane = await harness.lane("main", BACKGROUND_CONTEXT);
|
||||
if (!(lane instanceof Lane)) throw new Error("Expected runtime Lane");
|
||||
const first = session.idGenerator.next();
|
||||
const second = session.idGenerator.next();
|
||||
await lane.command((state) => {
|
||||
const inbox = [
|
||||
{ entryId: first, kind: "write" as const },
|
||||
{ entryId: second, kind: "write" as const },
|
||||
];
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [
|
||||
setValue(pendingEntry(first), { type: "custom", customType: "queued-first" }),
|
||||
setValue(pendingEntry(second), { type: "custom", customType: "queued-second" }),
|
||||
setValue(laneState("main"), { currentOperationId: null, lastOperationId: null, inbox }),
|
||||
],
|
||||
next: { ...state, inbox },
|
||||
materialize: () => undefined,
|
||||
};
|
||||
}, BACKGROUND_CONTEXT);
|
||||
|
||||
const appended = await lane.appendCustomEntry("new", undefined, BACKGROUND_CONTEXT);
|
||||
|
||||
expect((await lane.findEntries({ order: "oldestFirst" }, BACKGROUND_CONTEXT)).map((entry) => entry.id)).toEqual([
|
||||
first,
|
||||
second,
|
||||
appended,
|
||||
]);
|
||||
expect(lane.state.inbox).toEqual([]);
|
||||
expect((await session.getValue(laneState("main"), BACKGROUND_CONTEXT))?.value.inbox).toEqual([]);
|
||||
expect(await session.getValue(pendingEntry(first), BACKGROUND_CONTEXT)).toBeUndefined();
|
||||
expect(await session.getValue(pendingEntry(second), BACKGROUND_CONTEXT)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("restores complete lanes without requiring main", async () => {
|
||||
const session = await createSession();
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
setValue(branchTip("review"), null),
|
||||
setValue(laneConfig("review"), configured),
|
||||
setValue(laneState("review"), {
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
const { harness, open } = await AgentHarness.create(harnessOptions(session), BACKGROUND_CONTEXT);
|
||||
expect(open).toEqual([]);
|
||||
expect((await harness.lanes(BACKGROUND_CONTEXT)).map(({ name }) => name)).toEqual(["review"]);
|
||||
expect(await harness.lane("review", BACKGROUND_CONTEXT)).toBeDefined();
|
||||
expect(await session.branch("main", BACKGROUND_CONTEXT)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects partial durable lane state as a Harness fault", async () => {
|
||||
const session = await createSession();
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[setValue(branchTip("main"), null), setValue(laneConfig("main"), configured)],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
await expect(AgentHarness.create(harnessOptions(session), BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(
|
||||
HarnessFault,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime Harness global metadata", () => {
|
||||
it("preserves value_update publication and delivery", async () => {
|
||||
const session = await createSession();
|
||||
const harness = await createHarness(session);
|
||||
const seen: string[] = [];
|
||||
harness.events.on("value_update", async (event) => {
|
||||
seen.push(event.value === "session_name" ? `name:${await harness.getName(BACKGROUND_CONTEXT)}` : event.value);
|
||||
});
|
||||
|
||||
await harness.setName("named", BACKGROUND_CONTEXT);
|
||||
await harness.setLabel("entry", "label", BACKGROUND_CONTEXT);
|
||||
|
||||
expect(await harness.getName(BACKGROUND_CONTEXT)).toBe("named");
|
||||
expect(await harness.getLabel("entry", BACKGROUND_CONTEXT)).toBe("label");
|
||||
expect(seen).toEqual(["name:named", "entry_label"]);
|
||||
});
|
||||
|
||||
it("publishes previous and current data-bearing global configuration", async () => {
|
||||
const harness = await createHarness();
|
||||
const events: unknown[] = [];
|
||||
harness.events.on("config_update", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
await harness.setStreamOptions({ timeoutMs: 123 }, BACKGROUND_CONTEXT);
|
||||
await harness.setSteeringMode("one-at-a-time", BACKGROUND_CONTEXT);
|
||||
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "config_update",
|
||||
property: "streamOptions",
|
||||
previous: {},
|
||||
value: { timeoutMs: 123 },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "config_update",
|
||||
property: "steeringMode",
|
||||
previous: "all",
|
||||
value: "one-at-a-time",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("closes every lane and rejects later acquisition", async () => {
|
||||
const harness = await createHarness();
|
||||
const lane = await harness.lane("main", BACKGROUND_CONTEXT);
|
||||
await harness.close(BACKGROUND_CONTEXT);
|
||||
|
||||
await expect(harness.lane("other", BACKGROUND_CONTEXT)).rejects.toThrow("closed");
|
||||
await expect(lane.getTipId(BACKGROUND_CONTEXT)).rejects.toThrow("closed");
|
||||
});
|
||||
|
||||
it("MemorySessionRepo creation also remains branchless", async () => {
|
||||
const repo = new MemorySessionRepo();
|
||||
const session = await repo.create({ id: "repo-session" }, BACKGROUND_CONTEXT);
|
||||
expect(await session.branch("main", BACKGROUND_CONTEXT)).toBeUndefined();
|
||||
await session.close(BACKGROUND_CONTEXT);
|
||||
await repo.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
});
|
||||
391
packages/agent/test/harness/runtime/lane.test.ts
Normal file
391
packages/agent/test/harness/runtime/lane.test.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { type Api, createModels, fauxProvider, type Model } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { HarnessClosed, type HarnessEvent } from "../../../src/harness/agent-harness.ts";
|
||||
import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/harness/compaction/compaction.ts";
|
||||
import { BACKGROUND_CONTEXT, type Context } from "../../../src/harness/context.ts";
|
||||
import { HarnessEventBus } from "../../../src/harness/events.ts";
|
||||
import { HookRegistry } from "../../../src/harness/hooks.ts";
|
||||
import { convertToLlm } from "../../../src/harness/messages.ts";
|
||||
import { Lane } from "../../../src/harness/runtime/lane.ts";
|
||||
import { restoreLane } from "../../../src/harness/runtime/restore.ts";
|
||||
import { StorageBackedSession } from "../../../src/harness/session/session.ts";
|
||||
import type { LaneConfiguration, Session, StartingOperation } from "../../../src/harness/session/types.ts";
|
||||
import * as storedValues from "../../../src/harness/session/values.ts";
|
||||
import { ControlledMemoryStorage, deferred } from "./test-utils.ts";
|
||||
|
||||
const sessions: Session[] = [];
|
||||
const configuration: LaneConfiguration = {
|
||||
model: { provider: "test", modelId: "model" },
|
||||
thinkingLevel: "off",
|
||||
activeToolNames: [],
|
||||
};
|
||||
|
||||
async function createLane(
|
||||
emitBatch: (events: readonly HarnessEvent[], context: Context) => Promise<void> = () => Promise.resolve(),
|
||||
): Promise<{
|
||||
lane: Lane<undefined>;
|
||||
model: Model<Api>;
|
||||
session: Session;
|
||||
storage: ControlledMemoryStorage;
|
||||
}> {
|
||||
const storage = new ControlledMemoryStorage();
|
||||
const session = new StorageBackedSession(
|
||||
{ id: `runtime-lane-${sessions.length}`, createdAt: 1, storageVersion: 1 },
|
||||
storage,
|
||||
);
|
||||
sessions.push(session);
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.branchTip("main"), null),
|
||||
storedValues.setValue(storedValues.laneConfig("main"), configuration),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const faux = fauxProvider();
|
||||
const models = createModels();
|
||||
models.setProvider(faux.provider);
|
||||
return {
|
||||
lane: new Lane<undefined>(
|
||||
"main",
|
||||
session,
|
||||
models,
|
||||
new HookRegistry(() => {}),
|
||||
await restoreLane(session, "main", BACKGROUND_CONTEXT),
|
||||
(cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
emitBatch,
|
||||
(snapshot) => ({
|
||||
snapshot,
|
||||
start: () => {},
|
||||
resnapshot: () => Promise.resolve(snapshot),
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
() => ({
|
||||
tools: [],
|
||||
resources: {},
|
||||
streamOptions: {},
|
||||
retryPolicy: { enabled: true, maxRetries: 3, baseDelayMs: 1_000 },
|
||||
compaction: DEFAULT_COMPACTION_SETTINGS,
|
||||
steeringMode: "all",
|
||||
followUpMode: "all",
|
||||
toolExecution: "parallel",
|
||||
toolContext: undefined,
|
||||
systemPrompt: undefined,
|
||||
toProviderMessages: (messages) => convertToLlm(messages),
|
||||
entryProjectors: {},
|
||||
}),
|
||||
),
|
||||
model: faux.getModel(),
|
||||
session,
|
||||
storage,
|
||||
};
|
||||
}
|
||||
|
||||
function setThinkingLevel(
|
||||
lane: Lane<undefined>,
|
||||
thinkingLevel: LaneConfiguration["thinkingLevel"],
|
||||
observed?: LaneConfiguration["thinkingLevel"][],
|
||||
): Promise<LaneConfiguration["thinkingLevel"]> {
|
||||
return lane.command((state) => {
|
||||
observed?.push(state.configuration.thinkingLevel);
|
||||
const next = { ...state, configuration: { ...state.configuration, thinkingLevel } };
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [storedValues.setValue(storedValues.laneConfig(lane.name), next.configuration)],
|
||||
next,
|
||||
materialize: () => thinkingLevel,
|
||||
};
|
||||
}, BACKGROUND_CONTEXT);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const session of sessions.splice(0)) await session.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
describe("runtime Lane commands", () => {
|
||||
it("reads and replaces configuration from owned state", async () => {
|
||||
const { lane, model, session } = await createLane();
|
||||
const activeToolNames = ["read"];
|
||||
|
||||
await lane.setModel({ provider: model.provider, modelId: model.id }, BACKGROUND_CONTEXT);
|
||||
await lane.setThinkingLevel("high", BACKGROUND_CONTEXT);
|
||||
await lane.setActiveTools(activeToolNames, BACKGROUND_CONTEXT);
|
||||
|
||||
expect(await lane.getModel(BACKGROUND_CONTEXT)).toBe(model);
|
||||
expect(await lane.getThinkingLevel(BACKGROUND_CONTEXT)).toBe("high");
|
||||
expect(await lane.getActiveTools(BACKGROUND_CONTEXT)).toBe(activeToolNames);
|
||||
expect((await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value).toEqual({
|
||||
model: { provider: model.provider, modelId: model.id },
|
||||
thinkingLevel: "high",
|
||||
activeToolNames,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives queued configuration updates from the latest committed state", async () => {
|
||||
const { lane, model, storage } = await createLane();
|
||||
const commitStarted = deferred();
|
||||
const releaseCommit = deferred();
|
||||
storage.beforeNextCommit = async () => {
|
||||
commitStarted.resolve();
|
||||
await releaseCommit.promise;
|
||||
};
|
||||
|
||||
const modelUpdate = lane.setModel({ provider: model.provider, modelId: model.id }, BACKGROUND_CONTEXT);
|
||||
await commitStarted.promise;
|
||||
const thinkingUpdate = lane.setThinkingLevel("high", BACKGROUND_CONTEXT);
|
||||
releaseCommit.resolve();
|
||||
await Promise.all([modelUpdate, thinkingUpdate]);
|
||||
|
||||
expect(lane.state.configuration).toEqual({
|
||||
model: { provider: model.provider, modelId: model.id },
|
||||
thinkingLevel: "high",
|
||||
activeToolNames: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a promise value without holding the lane line", async () => {
|
||||
const { lane } = await createLane();
|
||||
const completion = deferred();
|
||||
let completed = false;
|
||||
const joined = lane
|
||||
.command(() => ({ kind: "return", result: completion.promise }), BACKGROUND_CONTEXT)
|
||||
.then(() => {
|
||||
completed = true;
|
||||
});
|
||||
|
||||
await lane.setThinkingLevel("high", BACKGROUND_CONTEXT);
|
||||
expect(completed).toBe(false);
|
||||
completion.resolve();
|
||||
await joined;
|
||||
});
|
||||
|
||||
it("returns an expected rejection without faulting the lane", async () => {
|
||||
const { lane } = await createLane();
|
||||
const rejection = new Error("declined");
|
||||
|
||||
await expect(lane.command(() => ({ kind: "reject", error: rejection }), BACKGROUND_CONTEXT)).rejects.toBe(
|
||||
rejection,
|
||||
);
|
||||
expect(await lane.getTipId(BACKGROUND_CONTEXT)).toBeNull();
|
||||
await lane.setThinkingLevel("high", BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
it("passes bounded reads and commit metadata through the serialized command", async () => {
|
||||
const { lane } = await createLane();
|
||||
let storedConfiguration: LaneConfiguration | undefined;
|
||||
let memoryPublished = false;
|
||||
|
||||
const commit = await lane.command(async (state, reader) => {
|
||||
storedConfiguration = (await reader.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value;
|
||||
const configuration: LaneConfiguration = { ...state.configuration, thinkingLevel: "high" };
|
||||
const next = { ...state, configuration };
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [storedValues.setValue(storedValues.laneConfig("main"), configuration)],
|
||||
next,
|
||||
materialize: (commit) => {
|
||||
memoryPublished = lane.state === next;
|
||||
return commit;
|
||||
},
|
||||
};
|
||||
}, BACKGROUND_CONTEXT);
|
||||
|
||||
expect(storedConfiguration).toEqual(configuration);
|
||||
expect(memoryPublished).toBe(true);
|
||||
expect(commit.seqs).toHaveLength(1);
|
||||
expect(commit.timestamp).toEqual(expect.any(Number));
|
||||
});
|
||||
|
||||
it("rejects thenable materialization after publishing committed memory", async () => {
|
||||
const { lane, session } = await createLane();
|
||||
|
||||
await expect(
|
||||
// @ts-expect-error Exercise the runtime guard against untyped callers.
|
||||
lane.command((state) => {
|
||||
const configuration: LaneConfiguration = { ...state.configuration, thinkingLevel: "high" };
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [storedValues.setValue(storedValues.laneConfig("main"), configuration)],
|
||||
next: { ...state, configuration },
|
||||
materialize: async () => undefined,
|
||||
};
|
||||
}, BACKGROUND_CONTEXT),
|
||||
).rejects.toThrow("Lane command materialize() must be synchronous");
|
||||
|
||||
expect(lane.state.configuration.thinkingLevel).toBe("high");
|
||||
expect((await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value.thinkingLevel).toBe(
|
||||
"high",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves committed memory when synchronous event publication fails", async () => {
|
||||
const bus = new HarnessEventBus();
|
||||
const { lane, session } = await createLane((events, context) => bus.emitBatch(events, context));
|
||||
const uncloneable = {
|
||||
type: "run_start",
|
||||
runId: "run",
|
||||
lane: "main",
|
||||
invalid: () => undefined,
|
||||
} as unknown as HarnessEvent;
|
||||
|
||||
await expect(
|
||||
lane.command((state) => {
|
||||
const configuration: LaneConfiguration = { ...state.configuration, thinkingLevel: "high" };
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [storedValues.setValue(storedValues.laneConfig("main"), configuration)],
|
||||
next: { ...state, configuration },
|
||||
materialize: () => undefined,
|
||||
events: () => [uncloneable],
|
||||
};
|
||||
}, BACKGROUND_CONTEXT),
|
||||
).rejects.toMatchObject({ name: "DataCloneError" });
|
||||
|
||||
expect(lane.state.configuration.thinkingLevel).toBe("high");
|
||||
expect((await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value.thinkingLevel).toBe(
|
||||
"high",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects work after sealing while an admitted commit finishes", async () => {
|
||||
const { lane, storage } = await createLane();
|
||||
const commitStarted = deferred();
|
||||
const releaseCommit = deferred();
|
||||
storage.beforeNextCommit = async () => {
|
||||
commitStarted.resolve();
|
||||
await releaseCommit.promise;
|
||||
};
|
||||
|
||||
const admitted = setThinkingLevel(lane, "high");
|
||||
await commitStarted.promise;
|
||||
const closed = new HarnessClosed();
|
||||
lane.seal(closed);
|
||||
|
||||
await expect(lane.getTipId(BACKGROUND_CONTEXT)).rejects.toBe(closed);
|
||||
await expect(lane.setThinkingLevel("low", BACKGROUND_CONTEXT)).rejects.toBe(closed);
|
||||
releaseCommit.resolve();
|
||||
await admitted;
|
||||
expect(lane.state.configuration.thinkingLevel).toBe("high");
|
||||
});
|
||||
|
||||
it("publishes memory only after the durable commit succeeds", async () => {
|
||||
const { lane, session, storage } = await createLane();
|
||||
const commitStarted = deferred();
|
||||
const releaseCommit = deferred();
|
||||
storage.beforeNextCommit = async () => {
|
||||
commitStarted.resolve();
|
||||
await releaseCommit.promise;
|
||||
};
|
||||
|
||||
const command = setThinkingLevel(lane, "high");
|
||||
await commitStarted.promise;
|
||||
|
||||
expect(lane.state.configuration.thinkingLevel).toBe("off");
|
||||
releaseCommit.resolve();
|
||||
expect(await command).toBe("high");
|
||||
expect(lane.state.configuration.thinkingLevel).toBe("high");
|
||||
expect((await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value.thinkingLevel).toBe(
|
||||
"high",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves memory when the durable commit fails", async () => {
|
||||
const { lane, session, storage } = await createLane();
|
||||
const failure = new Error("commit failed");
|
||||
storage.beforeNextCommit = async () => {
|
||||
throw failure;
|
||||
};
|
||||
|
||||
await expect(setThinkingLevel(lane, "high")).rejects.toBe(failure);
|
||||
|
||||
expect(lane.state.configuration.thinkingLevel).toBe("off");
|
||||
expect((await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT))?.value.thinkingLevel).toBe(
|
||||
"off",
|
||||
);
|
||||
});
|
||||
|
||||
it("diverts ordinary work but settles against latest cancelled control", async () => {
|
||||
const { lane, session } = await createLane();
|
||||
const accepted = await lane.accept({ kind: "prompt", prompt: "hello" }, BACKGROUND_CONTEXT);
|
||||
expect(accepted.ok).toBe(true);
|
||||
const operation = lane.state.operation;
|
||||
if (operation?.state.at !== "starting") throw new Error("missing accepted operation");
|
||||
const cancelled: StartingOperation = {
|
||||
...operation.state,
|
||||
control: { status: "cancel_requested", requestedAt: 1 },
|
||||
};
|
||||
await lane.command(
|
||||
(state) => ({
|
||||
kind: "commit",
|
||||
writes: [storedValues.setValue(storedValues.operationState(operation.meta.operationId), cancelled)],
|
||||
next: { ...state, operation: { meta: operation.meta, state: cancelled } },
|
||||
materialize: () => undefined,
|
||||
}),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
let continued = false;
|
||||
expect(
|
||||
await lane.continueOperation(
|
||||
cancelled,
|
||||
() => {
|
||||
continued = true;
|
||||
return { kind: "return", result: "continued" };
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toEqual({ kind: "cancel_requested" });
|
||||
expect(continued).toBe(false);
|
||||
|
||||
const settled = await lane.settleOperation(
|
||||
cancelled,
|
||||
(state, current) => {
|
||||
expect(current.control.status).toBe("cancel_requested");
|
||||
const inbox = [...state.inbox, { entryId: "accepted-during-cancellation", kind: "write" as const }];
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [],
|
||||
operationState: current,
|
||||
lane: { inbox },
|
||||
materialize: () => inbox,
|
||||
};
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
expect(settled).toEqual([{ entryId: "accepted-during-cancellation", kind: "write" }]);
|
||||
expect(lane.state.inbox).toEqual(settled);
|
||||
expect((await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT))?.value.inbox).toEqual(
|
||||
settled,
|
||||
);
|
||||
});
|
||||
|
||||
it("plans queued commands from the latest committed memory", async () => {
|
||||
const { lane, storage } = await createLane();
|
||||
const commitStarted = deferred();
|
||||
const releaseCommit = deferred();
|
||||
const observed: LaneConfiguration["thinkingLevel"][] = [];
|
||||
storage.beforeNextCommit = async () => {
|
||||
commitStarted.resolve();
|
||||
await releaseCommit.promise;
|
||||
};
|
||||
|
||||
const first = setThinkingLevel(lane, "high", observed);
|
||||
await commitStarted.promise;
|
||||
const second = setThinkingLevel(lane, "medium", observed);
|
||||
await Promise.resolve();
|
||||
expect(observed).toEqual(["off"]);
|
||||
|
||||
releaseCommit.resolve();
|
||||
expect(await Promise.all([first, second])).toEqual(["high", "medium"]);
|
||||
expect(observed).toEqual(["off", "high"]);
|
||||
expect(lane.state.configuration.thinkingLevel).toBe("medium");
|
||||
});
|
||||
});
|
||||
341
packages/agent/test/harness/runtime/progress.test.ts
Normal file
341
packages/agent/test/harness/runtime/progress.test.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { type AssistantMessageFrame, createModels } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WatchHandle } from "../../../src/harness/agent-harness.ts";
|
||||
import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/harness/compaction/compaction.ts";
|
||||
import { BACKGROUND_CONTEXT, type Context, withAbortSignal } from "../../../src/harness/context.ts";
|
||||
import { HookRegistry } from "../../../src/harness/hooks.ts";
|
||||
import { Lane } from "../../../src/harness/runtime/lane.ts";
|
||||
import { openFrameProgress, openToolProgress } from "../../../src/harness/runtime/progress.ts";
|
||||
import { type Config, Drive, type LaneState as RuntimeLaneState } from "../../../src/harness/runtime/types.ts";
|
||||
import { MemoryStorage } from "../../../src/harness/session/memory.ts";
|
||||
import { StorageBackedSession } from "../../../src/harness/session/session.ts";
|
||||
import {
|
||||
type AssistantEffectPendingOperation,
|
||||
type CheckpointOperation,
|
||||
type OperationState,
|
||||
operationScopeOf,
|
||||
type Session,
|
||||
type Write,
|
||||
} from "../../../src/harness/session/types.ts";
|
||||
import * as storedValues from "../../../src/harness/session/values.ts";
|
||||
import { deferred } from "./test-utils.ts";
|
||||
|
||||
const sessions: Session[] = [];
|
||||
const operationId = "operation";
|
||||
const configuration = {
|
||||
model: { provider: "test", modelId: "model" },
|
||||
thinkingLevel: "off" as const,
|
||||
activeToolNames: [],
|
||||
};
|
||||
|
||||
class FailingStorage extends MemoryStorage {
|
||||
failure: Error | undefined;
|
||||
beforeNextCommit: (() => Promise<void>) | undefined;
|
||||
|
||||
override async commit(writes: Write[], context: Context) {
|
||||
const beforeCommit = this.beforeNextCommit;
|
||||
this.beforeNextCommit = undefined;
|
||||
await beforeCommit?.();
|
||||
if (this.failure === undefined) return super.commit(writes, context);
|
||||
const failure = this.failure;
|
||||
this.failure = undefined;
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeConfig: Config<undefined> = {
|
||||
tools: [],
|
||||
resources: {},
|
||||
streamOptions: {},
|
||||
retryPolicy: { enabled: true, maxRetries: 3, baseDelayMs: 1_000 },
|
||||
compaction: DEFAULT_COMPACTION_SETTINGS,
|
||||
steeringMode: "all",
|
||||
followUpMode: "all",
|
||||
toolExecution: "parallel",
|
||||
toolContext: undefined,
|
||||
systemPrompt: undefined,
|
||||
toProviderMessages: () => [],
|
||||
entryProjectors: {},
|
||||
};
|
||||
|
||||
function unusedWatch<T>(): WatchHandle<T> {
|
||||
throw new Error("watch is not used by progress tests");
|
||||
}
|
||||
|
||||
function runScope() {
|
||||
return {
|
||||
control: { status: "running" as const },
|
||||
settings: {
|
||||
compaction: DEFAULT_COMPACTION_SETTINGS,
|
||||
steeringMode: "all" as const,
|
||||
followUpMode: "all" as const,
|
||||
toolExecution: "parallel" as const,
|
||||
},
|
||||
inbox: { steer: [], followUp: [], writes: [] },
|
||||
latestAssistantEntryId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function assistantEffectPending(responseEntryId: string): AssistantEffectPendingOperation {
|
||||
return {
|
||||
...runScope(),
|
||||
at: "assistant.effect_pending",
|
||||
generationContext: {
|
||||
stepId: "step",
|
||||
triggerEntryId: "trigger",
|
||||
configuration,
|
||||
streamOptions: {},
|
||||
retryPolicy: { maxAttempts: 2, baseDelayMs: 1 },
|
||||
overflowRecoveryUsed: false,
|
||||
},
|
||||
attempt: 1,
|
||||
responseEntryId,
|
||||
usageId: "usage",
|
||||
intendedOutputLimit: 100,
|
||||
contextWindow: 1_000,
|
||||
};
|
||||
}
|
||||
|
||||
async function createFixture(
|
||||
state: OperationState,
|
||||
storage = new FailingStorage(),
|
||||
): Promise<{ lane: Lane<undefined>; drive: Drive; storage: FailingStorage }> {
|
||||
const session = new StorageBackedSession(
|
||||
{ id: `progress-${sessions.length}`, createdAt: 1, storageVersion: 1 },
|
||||
storage,
|
||||
);
|
||||
sessions.push(session);
|
||||
const meta = {
|
||||
operationId,
|
||||
lane: "main",
|
||||
sourceTipId: null,
|
||||
startedAt: 1,
|
||||
intent: { kind: "run" as const, promptEntryIds: [] },
|
||||
};
|
||||
const projection: RuntimeLaneState = {
|
||||
tipId: null,
|
||||
configuration,
|
||||
inbox: [],
|
||||
lastOperationId: null,
|
||||
operation: { meta, state },
|
||||
};
|
||||
const lane = new Lane<undefined>(
|
||||
"main",
|
||||
session,
|
||||
createModels(),
|
||||
new HookRegistry(() => {}),
|
||||
projection,
|
||||
(cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
() => Promise.resolve(),
|
||||
unusedWatch,
|
||||
() => runtimeConfig,
|
||||
);
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.branchTip("main"), null),
|
||||
storedValues.setValue(storedValues.laneConfig("main"), configuration),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: operationId,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
storedValues.setValue(storedValues.operationMeta(operationId), meta),
|
||||
storedValues.setValue(storedValues.operationState(operationId), state),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const drive = new Drive({ operationId }, BACKGROUND_CONTEXT);
|
||||
lane.activeDrive = drive;
|
||||
return { lane, drive, storage };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const session of sessions.splice(0)) await session.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
describe("runtime active drive ownership", () => {
|
||||
it("settles or rejects the shared completion without exposing owner controls through the gate", async () => {
|
||||
const settled = new Drive({ operationId }, BACKGROUND_CONTEXT);
|
||||
const outcome = { kind: "waiting", operationId, reason: "retry", notBefore: 10 } as const;
|
||||
settled.settle(outcome);
|
||||
settled.fail(new Error("late failure"));
|
||||
await expect(settled.completion).resolves.toBe(outcome);
|
||||
|
||||
const failed = new Drive({ operationId }, BACKGROUND_CONTEXT);
|
||||
const error = new Error("drive failed");
|
||||
failed.fail(error);
|
||||
await expect(failed.completion).rejects.toBe(error);
|
||||
expect("beginAbort" in failed.gate).toBe(false);
|
||||
});
|
||||
|
||||
it("strips invocation cancellation from pass context and owns policy and gate control", () => {
|
||||
const controller = new AbortController();
|
||||
const drive = new Drive(
|
||||
{ operationId, waitForRetry: true, pollDeferred: true },
|
||||
withAbortSignal(controller.signal, BACKGROUND_CONTEXT),
|
||||
);
|
||||
|
||||
expect(drive.operationId).toBe(operationId);
|
||||
expect(drive.context.abortSignal).toBeUndefined();
|
||||
expect(drive.waitForRetry).toBe(true);
|
||||
expect(drive.deferredPermits).toBe(1);
|
||||
|
||||
const closed = new Error("closed");
|
||||
drive.closeGate(closed);
|
||||
expect(drive.gate.signal.aborted).toBe(true);
|
||||
expect(() => drive.gate.admit(() => undefined)).toThrow(closed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime progress channels", () => {
|
||||
it("enqueues assistant frames in order, seals admission, and exposes the settlement delete", async () => {
|
||||
const responseEntryId = "response";
|
||||
const { lane, drive } = await createFixture(assistantEffectPending(responseEntryId));
|
||||
const progress = openFrameProgress(lane, drive, responseEntryId);
|
||||
const frames: AssistantMessageFrame[] = [
|
||||
{ type: "text_delta", contentIndex: 0, delta: "a" },
|
||||
{ type: "text_delta", contentIndex: 0, delta: "b" },
|
||||
];
|
||||
|
||||
progress.write(frames[0]!);
|
||||
progress.write(frames[1]!);
|
||||
progress.seal();
|
||||
progress.write({ type: "text_delta", contentIndex: 0, delta: "late" });
|
||||
await progress.drain();
|
||||
|
||||
expect(
|
||||
(
|
||||
await lane.session.readList(
|
||||
storedValues.pendingAssistantFrames(operationId, responseEntryId),
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
)
|
||||
).map(({ value }) => value),
|
||||
).toEqual(frames);
|
||||
});
|
||||
|
||||
it("declines a queued frame after the authoritative projection leaves its phase without control reads", async () => {
|
||||
const responseEntryId = "response";
|
||||
const { lane, drive, storage } = await createFixture(assistantEffectPending(responseEntryId));
|
||||
const progress = openFrameProgress(lane, drive, responseEntryId);
|
||||
const commitStarted = deferred();
|
||||
const releaseCommit = deferred();
|
||||
storage.beforeNextCommit = async () => {
|
||||
commitStarted.resolve();
|
||||
await releaseCommit.promise;
|
||||
};
|
||||
const getValue = vi.spyOn(storage, "getValue");
|
||||
const moving = lane.command((state) => {
|
||||
const operation = state.operation;
|
||||
if (operation === null) throw new Error("missing operation");
|
||||
const nextState: CheckpointOperation = {
|
||||
...operationScopeOf(operation.state),
|
||||
at: "checkpoint",
|
||||
continuation: { kind: "may_finish", includeFinalAssistant: true },
|
||||
triggerEntryId: responseEntryId,
|
||||
};
|
||||
return {
|
||||
kind: "commit",
|
||||
writes: [storedValues.setValue(storedValues.operationState(operationId), nextState)],
|
||||
next: { ...state, operation: { meta: operation.meta, state: nextState } },
|
||||
materialize: () => undefined,
|
||||
};
|
||||
}, BACKGROUND_CONTEXT);
|
||||
await commitStarted.promise;
|
||||
progress.write({ type: "text_delta", contentIndex: 0, delta: "late" });
|
||||
releaseCommit.resolve();
|
||||
await moving;
|
||||
await progress.drain();
|
||||
|
||||
expect(
|
||||
await lane.session.readList(
|
||||
storedValues.pendingAssistantFrames(operationId, responseEntryId),
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(getValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("declines a queued frame after terminal projection publication", async () => {
|
||||
const responseEntryId = "response";
|
||||
const { lane, drive, storage } = await createFixture(assistantEffectPending(responseEntryId));
|
||||
const progress = openFrameProgress(lane, drive, responseEntryId);
|
||||
const commitStarted = deferred();
|
||||
const releaseCommit = deferred();
|
||||
storage.beforeNextCommit = async () => {
|
||||
commitStarted.resolve();
|
||||
await releaseCommit.promise;
|
||||
};
|
||||
const getValue = vi.spyOn(storage, "getValue");
|
||||
const ending = lane.command(
|
||||
(state) => ({
|
||||
kind: "commit",
|
||||
writes: [
|
||||
storedValues.deleteValue(storedValues.operationMeta(operationId)),
|
||||
storedValues.deleteValue(storedValues.operationState(operationId)),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: state.inbox,
|
||||
}),
|
||||
],
|
||||
next: { ...state, operation: null },
|
||||
materialize: () => undefined,
|
||||
}),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await commitStarted.promise;
|
||||
progress.write({ type: "text_delta", contentIndex: 0, delta: "late" });
|
||||
releaseCommit.resolve();
|
||||
await ending;
|
||||
await progress.drain();
|
||||
|
||||
expect(
|
||||
await lane.session.readList(
|
||||
storedValues.pendingAssistantFrames(operationId, responseEntryId),
|
||||
undefined,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(getValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces tool checkpoints in invocation order", async () => {
|
||||
const invocationId = "result";
|
||||
const { lane, drive } = await createFixture({
|
||||
...runScope(),
|
||||
at: "tools",
|
||||
batch: {
|
||||
assistantEntryId: "assistant",
|
||||
configuration,
|
||||
turnId: "turn",
|
||||
calls: [{ status: "effect_pending", sourceIndex: 0, resultEntryId: invocationId, replay: "safe" }],
|
||||
},
|
||||
} satisfies OperationState);
|
||||
const progress = openToolProgress(lane, drive, "turn", 0, invocationId);
|
||||
progress.write({ content: [{ type: "text", text: "first" }], details: {} });
|
||||
progress.write({ content: [{ type: "text", text: "second" }], details: {} });
|
||||
await progress.drain();
|
||||
expect(
|
||||
(await lane.session.getValue(storedValues.pendingToolOutput(operationId, invocationId), BACKGROUND_CONTEXT))
|
||||
?.value.content,
|
||||
).toEqual([{ type: "text", text: "second" }]);
|
||||
});
|
||||
|
||||
it("retains the rejecting write promise so drain propagates commit failure", async () => {
|
||||
const responseEntryId = "response";
|
||||
const { lane, drive, storage } = await createFixture(assistantEffectPending(responseEntryId));
|
||||
const failure = new Error("frame commit failed");
|
||||
storage.failure = failure;
|
||||
const progress = openFrameProgress(lane, drive, responseEntryId);
|
||||
|
||||
progress.write({ type: "text_delta", contentIndex: 0, delta: "lost" });
|
||||
|
||||
await expect(progress.drain()).rejects.toBe(failure);
|
||||
});
|
||||
});
|
||||
295
packages/agent/test/harness/runtime/reducer.test.ts
Normal file
295
packages/agent/test/harness/runtime/reducer.test.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AgentHarness, type HarnessEvent, type LaneSnapshot } from "../../../src/harness/agent-harness.ts";
|
||||
import { BACKGROUND_CONTEXT } from "../../../src/harness/context.ts";
|
||||
import { reduceLaneSnapshot } from "../../../src/harness/runtime/reducer.ts";
|
||||
import { MemoryStorage } from "../../../src/harness/session/memory.ts";
|
||||
import { StorageBackedSession } from "../../../src/harness/session/session.ts";
|
||||
import type { Session } from "../../../src/harness/session/types.ts";
|
||||
|
||||
const sessions: Session[] = [];
|
||||
|
||||
async function createFixture(options: { deferred?: boolean } = {}) {
|
||||
const session = new StorageBackedSession(
|
||||
{ id: `reducer-${sessions.length}`, createdAt: 1, storageVersion: 1 },
|
||||
new MemoryStorage(),
|
||||
);
|
||||
sessions.push(session);
|
||||
const faux = fauxProvider();
|
||||
const models = createModels();
|
||||
models.setProvider(faux.provider);
|
||||
const { harness } = await AgentHarness.create(
|
||||
{
|
||||
session,
|
||||
models,
|
||||
model: faux.getModel(),
|
||||
...(options.deferred === true ? { streamOptions: { deferred: true } } : {}),
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const lane = await harness.lane("main", BACKGROUND_CONTEXT);
|
||||
return { harness, lane, faux };
|
||||
}
|
||||
|
||||
async function settleEvents(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function fold(snapshot: LaneSnapshot, events: HarnessEvent[]): LaneSnapshot {
|
||||
for (const event of events) {
|
||||
if (reduceLaneSnapshot(snapshot, event) === "rebase") throw new Error(`Unexpected rebase for ${event.type}`);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const session of sessions.splice(0)) await session.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
describe("lane snapshot reducer", () => {
|
||||
it("folds an ordinary run to the authoritative resnapshot", async () => {
|
||||
const { lane, faux } = await createFixture();
|
||||
const watch = await lane.watch(BACKGROUND_CONTEXT);
|
||||
const events: HarnessEvent[] = [];
|
||||
watch.start((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
faux.setResponses([fauxAssistantMessage("answer")]);
|
||||
|
||||
expect(await lane.prompt("question", undefined, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "run", status: "completed" },
|
||||
});
|
||||
await settleEvents();
|
||||
|
||||
expect(fold(watch.snapshot, events)).toEqual(await watch.resnapshot(BACKGROUND_CONTEXT));
|
||||
watch.unsubscribe();
|
||||
});
|
||||
|
||||
it("folds suspend and resume without closing the operation early", async () => {
|
||||
const { lane, faux } = await createFixture({ deferred: true });
|
||||
const watch = await lane.watch(BACKGROUND_CONTEXT);
|
||||
const events: HarnessEvent[] = [];
|
||||
watch.start((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
faux.setResponses([fauxAssistantMessage("answer")]);
|
||||
|
||||
expect(await lane.prompt("question", undefined, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { status: "suspended" },
|
||||
});
|
||||
await settleEvents();
|
||||
let replica = fold(watch.snapshot, events);
|
||||
expect(replica.operation).toMatchObject({ kind: "run", deferred: { poll: 0 } });
|
||||
expect(replica).toEqual(await watch.resnapshot(BACKGROUND_CONTEXT));
|
||||
|
||||
events.length = 0;
|
||||
expect(await lane.resume(BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "run", status: "completed" },
|
||||
});
|
||||
await settleEvents();
|
||||
replica = fold(watch.snapshot, events);
|
||||
expect(replica).toEqual(await watch.resnapshot(BACKGROUND_CONTEXT));
|
||||
watch.unsubscribe();
|
||||
});
|
||||
|
||||
it("folds standalone compaction and preserves segment semantics", async () => {
|
||||
const { lane, faux } = await createFixture();
|
||||
await lane.appendMessage({ role: "user", content: "history", timestamp: 1 }, BACKGROUND_CONTEXT);
|
||||
const watch = await lane.watch(BACKGROUND_CONTEXT);
|
||||
const events: HarnessEvent[] = [];
|
||||
watch.start((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
faux.setResponses([fauxAssistantMessage("summary")]);
|
||||
|
||||
expect(await lane.compact(undefined, BACKGROUND_CONTEXT)).toMatchObject({
|
||||
ok: true,
|
||||
value: { compaction: { status: "completed" } },
|
||||
});
|
||||
await settleEvents();
|
||||
const replica = fold(watch.snapshot, events);
|
||||
|
||||
expect(replica.operation).toBeNull();
|
||||
expect(replica.lastResult).toMatchObject({ kind: "compaction", status: "completed" });
|
||||
expect(replica).toEqual(await watch.resnapshot(BACKGROUND_CONTEXT));
|
||||
watch.unsubscribe();
|
||||
});
|
||||
|
||||
it("replicates globally ordered queue changes", async () => {
|
||||
const { lane } = await createFixture();
|
||||
const watch = await lane.watch(BACKGROUND_CONTEXT);
|
||||
const events: HarnessEvent[] = [];
|
||||
watch.start((event) => {
|
||||
events.push(event);
|
||||
});
|
||||
|
||||
await lane.nextRun("next", undefined, BACKGROUND_CONTEXT);
|
||||
const steer = await lane.steer("steer", undefined, BACKGROUND_CONTEXT);
|
||||
await lane.followUp("follow", undefined, BACKGROUND_CONTEXT);
|
||||
if (!steer.ok) throw steer.error;
|
||||
await lane.cancelQueued(steer.value.entryId, BACKGROUND_CONTEXT);
|
||||
await settleEvents();
|
||||
|
||||
const replica = fold(watch.snapshot, events);
|
||||
expect(replica.queues.map((item) => item.kind)).toEqual(["nextRun", "followUp"]);
|
||||
expect(replica).toEqual(await watch.resnapshot(BACKGROUND_CONTEXT));
|
||||
watch.unsubscribe();
|
||||
});
|
||||
|
||||
it("keeps in-run compaction segments inside the open run", async () => {
|
||||
const { lane } = await createFixture();
|
||||
const snapshot = (await lane.watch(BACKGROUND_CONTEXT)).snapshot;
|
||||
const running: LaneSnapshot = {
|
||||
...snapshot,
|
||||
operation: {
|
||||
id: "run",
|
||||
kind: "run",
|
||||
startedAt: 1,
|
||||
fromTipId: null,
|
||||
status: "open",
|
||||
runningTools: [],
|
||||
},
|
||||
};
|
||||
expect(
|
||||
reduceLaneSnapshot(running, {
|
||||
type: "compaction_start",
|
||||
lane: "main",
|
||||
runId: "run",
|
||||
reason: "threshold",
|
||||
startedAt: 2,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
reduceLaneSnapshot(running, {
|
||||
type: "compaction_end",
|
||||
lane: "main",
|
||||
runId: "run",
|
||||
reason: "threshold",
|
||||
status: "declined",
|
||||
endedAt: 3,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(running).toMatchObject({ operation: { id: "run", kind: "run" } });
|
||||
});
|
||||
|
||||
it("retains settled parallel tools until each source-ordered result is placed", async () => {
|
||||
const { lane } = await createFixture();
|
||||
const snapshot = (await lane.watch(BACKGROUND_CONTEXT)).snapshot;
|
||||
snapshot.operation = {
|
||||
id: "run",
|
||||
kind: "run",
|
||||
startedAt: 1,
|
||||
fromTipId: null,
|
||||
status: "open",
|
||||
runningTools: [],
|
||||
};
|
||||
const result = (text: string) => ({ content: [{ type: "text" as const, text }], details: { text } });
|
||||
const start = (index: number): HarnessEvent => ({
|
||||
type: "tool_start",
|
||||
lane: "main",
|
||||
runId: "run",
|
||||
turnId: "turn",
|
||||
toolCallId: `call-${index}`,
|
||||
toolName: `tool-${index}`,
|
||||
args: { index },
|
||||
});
|
||||
const end = (index: number): HarnessEvent => ({
|
||||
type: "tool_end",
|
||||
lane: "main",
|
||||
runId: "run",
|
||||
turnId: "turn",
|
||||
toolCallId: `call-${index}`,
|
||||
toolName: `tool-${index}`,
|
||||
result: result(`done-${index}`),
|
||||
isError: false,
|
||||
terminate: false,
|
||||
});
|
||||
const entry = (index: number): HarnessEvent => ({
|
||||
type: "entry_added",
|
||||
lane: "main",
|
||||
entry: {
|
||||
id: `result-${index}`,
|
||||
parentId: index === 0 ? null : `result-${index - 1}`,
|
||||
seq: index + 1,
|
||||
timestamp: index + 1,
|
||||
type: "message",
|
||||
message: {
|
||||
role: "toolResult",
|
||||
toolCallId: `call-${index}`,
|
||||
toolName: `tool-${index}`,
|
||||
content: [{ type: "text", text: `done-${index}` }],
|
||||
isError: false,
|
||||
timestamp: index + 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (let index = 0; index < 3; index += 1) reduceLaneSnapshot(snapshot, start(index));
|
||||
expect(snapshot.operation.runningTools).toHaveLength(3);
|
||||
reduceLaneSnapshot(snapshot, start(0));
|
||||
expect(snapshot.operation.runningTools).toHaveLength(3);
|
||||
|
||||
reduceLaneSnapshot(snapshot, {
|
||||
type: "tool_update",
|
||||
lane: "main",
|
||||
runId: "stale",
|
||||
turnId: "turn",
|
||||
toolCallId: "call-1",
|
||||
toolName: "tool-1",
|
||||
partialResult: result("stale"),
|
||||
});
|
||||
expect(snapshot.operation.runningTools[1]).not.toHaveProperty("result");
|
||||
|
||||
reduceLaneSnapshot(snapshot, end(2));
|
||||
expect(snapshot.operation.runningTools.find(({ toolCallId }) => toolCallId === "call-2")).toMatchObject({
|
||||
status: "settled",
|
||||
args: { index: 2 },
|
||||
result: result("done-2"),
|
||||
});
|
||||
|
||||
reduceLaneSnapshot(snapshot, end(0));
|
||||
expect(snapshot.operation.runningTools.map(({ status }) => status)).toEqual(["settled", "running", "settled"]);
|
||||
reduceLaneSnapshot(snapshot, entry(0));
|
||||
expect(snapshot.operation.runningTools.map(({ toolCallId }) => toolCallId)).toEqual(["call-1", "call-2"]);
|
||||
expect(snapshot.transcript.map(({ id }) => id)).toEqual(["result-0"]);
|
||||
|
||||
reduceLaneSnapshot(snapshot, end(1));
|
||||
expect(snapshot.operation.runningTools.every(({ status }) => status === "settled")).toBe(true);
|
||||
reduceLaneSnapshot(snapshot, entry(1));
|
||||
expect(snapshot.operation.runningTools.map(({ toolCallId }) => toolCallId)).toEqual(["call-2"]);
|
||||
reduceLaneSnapshot(snapshot, entry(2));
|
||||
expect(snapshot.operation.runningTools).toEqual([]);
|
||||
expect(snapshot.transcript.map(({ id }) => id)).toEqual(["result-0", "result-1", "result-2"]);
|
||||
});
|
||||
|
||||
it("marks navigation completion for rebase", async () => {
|
||||
const { lane } = await createFixture();
|
||||
const snapshot = (await lane.watch(BACKGROUND_CONTEXT)).snapshot;
|
||||
const reduced = reduceLaneSnapshot(
|
||||
{
|
||||
...snapshot,
|
||||
operation: {
|
||||
id: "navigation",
|
||||
kind: "navigation",
|
||||
startedAt: 1,
|
||||
fromTipId: null,
|
||||
status: "open",
|
||||
runningTools: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "navigation_end",
|
||||
lane: "main",
|
||||
runId: "navigation",
|
||||
status: "completed",
|
||||
fromTipId: null,
|
||||
tipId: "target",
|
||||
endedAt: 2,
|
||||
},
|
||||
);
|
||||
expect(reduced).toBe("rebase");
|
||||
});
|
||||
});
|
||||
441
packages/agent/test/harness/runtime/restore.test.ts
Normal file
441
packages/agent/test/harness/runtime/restore.test.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/harness/compaction/compaction.ts";
|
||||
import { BACKGROUND_CONTEXT } from "../../../src/harness/context.ts";
|
||||
import { restoreLane, restoreSession } from "../../../src/harness/runtime/restore.ts";
|
||||
import { MemorySessionRepo, MemoryStorage } from "../../../src/harness/session/memory.ts";
|
||||
import type {
|
||||
CheckpointOperation,
|
||||
LaneConfiguration,
|
||||
OperationMeta,
|
||||
OperationScope,
|
||||
OperationState,
|
||||
ResultBoundary,
|
||||
Session,
|
||||
} from "../../../src/harness/session/types.ts";
|
||||
import * as storedValues from "../../../src/harness/session/values.ts";
|
||||
|
||||
const repos: MemorySessionRepo[] = [];
|
||||
const configuration: LaneConfiguration = {
|
||||
model: { provider: "test", modelId: "model" },
|
||||
thinkingLevel: "off",
|
||||
activeToolNames: [],
|
||||
};
|
||||
|
||||
async function createSession(): Promise<Session> {
|
||||
const repo = new MemorySessionRepo();
|
||||
repos.push(repo);
|
||||
const session = await repo.create({}, BACKGROUND_CONTEXT);
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.branchTip("main"), null),
|
||||
storedValues.setValue(storedValues.laneConfig("main"), configuration),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
return session;
|
||||
}
|
||||
|
||||
function operationScope(): OperationScope {
|
||||
return {
|
||||
control: { status: "running" },
|
||||
settings: {
|
||||
compaction: DEFAULT_COMPACTION_SETTINGS,
|
||||
steeringMode: "all",
|
||||
followUpMode: "all",
|
||||
toolExecution: "parallel",
|
||||
},
|
||||
latestAssistantEntryId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function runState(triggerEntryId: string): CheckpointOperation {
|
||||
return {
|
||||
...operationScope(),
|
||||
at: "checkpoint",
|
||||
continuation: { kind: "need_assistant", overflowRecoveryUsed: false },
|
||||
triggerEntryId,
|
||||
};
|
||||
}
|
||||
|
||||
function summaryState(boundary: ResultBoundary): OperationState {
|
||||
return {
|
||||
...operationScope(),
|
||||
at: "summary.deciding",
|
||||
task: {
|
||||
taskId: "task",
|
||||
...(boundary.kind === "finish"
|
||||
? { reason: "manual" as const }
|
||||
: boundary.kind === "resume_checkpoint"
|
||||
? { reason: "threshold" as const }
|
||||
: {}),
|
||||
boundary,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const repo of repos.splice(0)) await repo.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
describe("runtime lane restore", () => {
|
||||
it("restores an idle lane's latest operation id without reading its result", async () => {
|
||||
const session = await createSession();
|
||||
const result = {
|
||||
operationId: session.idGenerator.next(),
|
||||
kind: "navigation" as const,
|
||||
status: "completed" as const,
|
||||
fromTipId: null,
|
||||
tipId: null,
|
||||
startedAt: 1,
|
||||
endedAt: 2,
|
||||
};
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.operationResult(result.operationId), result),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: null,
|
||||
lastOperationId: result.operationId,
|
||||
inbox: [],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
const state = await restoreLane(session, "main", BACKGROUND_CONTEXT);
|
||||
|
||||
expect(state).toEqual({
|
||||
tipId: null,
|
||||
configuration,
|
||||
inbox: [],
|
||||
lastOperationId: result.operationId,
|
||||
operation: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("restores an open operation without interpreting its referenced payloads", async () => {
|
||||
const session = await createSession();
|
||||
const operationId = session.idGenerator.next();
|
||||
const missingTriggerId = session.idGenerator.next();
|
||||
const meta: OperationMeta = {
|
||||
operationId,
|
||||
lane: "main",
|
||||
sourceTipId: null,
|
||||
startedAt: 1,
|
||||
intent: { kind: "run", promptEntryIds: [] },
|
||||
};
|
||||
const state = runState(missingTriggerId);
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.operationMeta(operationId), meta),
|
||||
storedValues.setValue(storedValues.operationState(operationId), state),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: operationId,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
const restored = await restoreLane(session, "main", BACKGROUND_CONTEXT);
|
||||
|
||||
expect(restored.operation).toEqual({ meta, state });
|
||||
});
|
||||
|
||||
it("validates current-operation identity, lane ownership, and intent/state compatibility", async () => {
|
||||
for (const corruption of ["identity", "lane", "kind"] as const) {
|
||||
const session = await createSession();
|
||||
const operationId = session.idGenerator.next();
|
||||
const base: OperationMeta = {
|
||||
operationId,
|
||||
lane: "main",
|
||||
sourceTipId: null,
|
||||
startedAt: 1,
|
||||
intent: { kind: "run", promptEntryIds: [] },
|
||||
};
|
||||
const meta: OperationMeta =
|
||||
corruption === "identity"
|
||||
? { ...base, operationId: "different-operation" }
|
||||
: corruption === "lane"
|
||||
? { ...base, lane: "worker" }
|
||||
: { ...base, intent: { kind: "navigation", targetId: null, summarize: false } };
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.operationMeta(operationId), meta),
|
||||
storedValues.setValue(storedValues.operationState(operationId), runState("trigger")),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: operationId,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
await expect(restoreLane(session, "main", BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts exactly the family-neutral state reachability matrix", async () => {
|
||||
const resume = { kind: "resume_checkpoint", resumeAfter: runState("trigger") } satisfies ResultBoundary;
|
||||
const finish = { kind: "finish" } satisfies ResultBoundary;
|
||||
const navigation = { kind: "commit_navigation", targetId: "target" } satisfies ResultBoundary;
|
||||
const cases: { intent: OperationMeta["intent"]; state: OperationState; accepted: boolean }[] = [
|
||||
{ intent: { kind: "run", promptEntryIds: [] }, state: runState("trigger"), accepted: true },
|
||||
{ intent: { kind: "run", promptEntryIds: [] }, state: summaryState(resume), accepted: true },
|
||||
{ intent: { kind: "run", promptEntryIds: [] }, state: summaryState(finish), accepted: false },
|
||||
{ intent: { kind: "run", promptEntryIds: [] }, state: summaryState(navigation), accepted: false },
|
||||
{ intent: { kind: "compaction" }, state: summaryState(finish), accepted: true },
|
||||
{ intent: { kind: "compaction" }, state: summaryState(resume), accepted: false },
|
||||
{ intent: { kind: "compaction" }, state: summaryState(navigation), accepted: false },
|
||||
{ intent: { kind: "compaction" }, state: runState("trigger"), accepted: false },
|
||||
{
|
||||
intent: { kind: "navigation", targetId: null, summarize: false },
|
||||
state: { ...operationScope(), at: "navigation.ready_to_commit", targetId: null },
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
intent: { kind: "navigation", targetId: "target", summarize: false },
|
||||
state: { ...operationScope(), at: "navigation.ready_to_commit", targetId: "different" },
|
||||
accepted: false,
|
||||
},
|
||||
{
|
||||
intent: { kind: "navigation", targetId: "target", summarize: false },
|
||||
state: summaryState(navigation),
|
||||
accepted: false,
|
||||
},
|
||||
{
|
||||
intent: { kind: "navigation", targetId: "target", summarize: true },
|
||||
state: summaryState(navigation),
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
intent: { kind: "navigation", targetId: "different", summarize: true },
|
||||
state: summaryState(navigation),
|
||||
accepted: false,
|
||||
},
|
||||
{
|
||||
intent: { kind: "navigation", targetId: "target", summarize: true },
|
||||
state: { ...operationScope(), at: "navigation.ready_to_commit", targetId: "target" },
|
||||
accepted: false,
|
||||
},
|
||||
{
|
||||
intent: { kind: "navigation", targetId: "target", summarize: true },
|
||||
state: summaryState(finish),
|
||||
accepted: false,
|
||||
},
|
||||
{
|
||||
intent: { kind: "navigation", targetId: "target", summarize: true },
|
||||
state: summaryState(resume),
|
||||
accepted: false,
|
||||
},
|
||||
{
|
||||
intent: { kind: "navigation", targetId: "target", summarize: false },
|
||||
state: runState("trigger"),
|
||||
accepted: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const session = await createSession();
|
||||
const operationId = session.idGenerator.next();
|
||||
const meta: OperationMeta = {
|
||||
operationId,
|
||||
lane: "main",
|
||||
sourceTipId: null,
|
||||
startedAt: 1,
|
||||
intent: testCase.intent,
|
||||
};
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.operationMeta(operationId), meta),
|
||||
storedValues.setValue(storedValues.operationState(operationId), testCase.state),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: operationId,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const restored = restoreLane(session, "main", BACKGROUND_CONTEXT);
|
||||
if (testCase.accepted)
|
||||
await expect(restored).resolves.toMatchObject({ operation: { meta, state: testCase.state } });
|
||||
else await expect(restored).rejects.toThrow("does not match state");
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[storedValues.branchTip("main").namespace, storedValues.deleteValue(storedValues.branchTip("main"))],
|
||||
[storedValues.laneConfig("main").namespace, storedValues.deleteValue(storedValues.laneConfig("main"))],
|
||||
[storedValues.laneState("main").namespace, storedValues.deleteValue(storedValues.laneState("main"))],
|
||||
] as const)("requires %s", async (namespace, write) => {
|
||||
const session = await createSession();
|
||||
await session.mutate((mutator) => mutator.commit([write], BACKGROUND_CONTEXT), BACKGROUND_CONTEXT);
|
||||
|
||||
await expect(restoreLane(session, "main", BACKGROUND_CONTEXT)).rejects.toThrow(`missing ${namespace.slice(3)}`);
|
||||
});
|
||||
|
||||
it.each([storedValues.operationMeta("").namespace, storedValues.operationState("").namespace] as const)(
|
||||
"requires %s for the current operation",
|
||||
async (namespace) => {
|
||||
const session = await createSession();
|
||||
const operationId = session.idGenerator.next();
|
||||
const meta: OperationMeta = {
|
||||
operationId,
|
||||
lane: "main",
|
||||
sourceTipId: null,
|
||||
startedAt: 1,
|
||||
intent: { kind: "run", promptEntryIds: [] },
|
||||
};
|
||||
const state = runState(session.idGenerator.next());
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
...(namespace === storedValues.operationMeta(operationId).namespace
|
||||
? []
|
||||
: [storedValues.setValue(storedValues.operationMeta(operationId), meta)]),
|
||||
...(namespace === storedValues.operationState(operationId).namespace
|
||||
? []
|
||||
: [storedValues.setValue(storedValues.operationState(operationId), state)]),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: operationId,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
await expect(restoreLane(session, "main", BACKGROUND_CONTEXT)).rejects.toThrow(
|
||||
`missing ${namespace.slice(3)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("restores every configured lane exactly once without writing", async () => {
|
||||
const session = await createSession();
|
||||
const workerConfiguration: LaneConfiguration = {
|
||||
model: { provider: "test", modelId: "worker" },
|
||||
thinkingLevel: "high",
|
||||
activeToolNames: ["read"],
|
||||
};
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.branchTip("worker"), null),
|
||||
storedValues.setValue(storedValues.laneConfig("worker"), workerConfiguration),
|
||||
storedValues.setValue(storedValues.laneState("worker"), {
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const operationId = session.idGenerator.next();
|
||||
const meta: OperationMeta = {
|
||||
operationId,
|
||||
lane: "worker",
|
||||
sourceTipId: null,
|
||||
startedAt: 1,
|
||||
intent: { kind: "run", promptEntryIds: [] },
|
||||
};
|
||||
const state = runState(session.idGenerator.next());
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.operationMeta(operationId), meta),
|
||||
storedValues.setValue(storedValues.operationState(operationId), state),
|
||||
storedValues.setValue(storedValues.laneState("worker"), {
|
||||
currentOperationId: operationId,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const before = {
|
||||
leaves: await session.scanValues(storedValues.branchTipInventoryPrefix(), BACKGROUND_CONTEXT),
|
||||
mainConfiguration: await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT),
|
||||
workerConfiguration: await session.getValue(storedValues.laneConfig("worker"), BACKGROUND_CONTEXT),
|
||||
mainState: await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT),
|
||||
workerState: await session.getValue(storedValues.laneState("worker"), BACKGROUND_CONTEXT),
|
||||
};
|
||||
const mutate = vi.spyOn(session, "mutate");
|
||||
const getValue = vi.spyOn(MemoryStorage.prototype, "getValue");
|
||||
const readList = vi.spyOn(MemoryStorage.prototype, "readList");
|
||||
|
||||
const lanes = await restoreSession(session, BACKGROUND_CONTEXT);
|
||||
|
||||
expect([...lanes.keys()].sort()).toEqual(["main", "worker"]);
|
||||
expect(lanes.get("main")?.configuration).toEqual(configuration);
|
||||
expect(lanes.get("worker")).toMatchObject({
|
||||
configuration: workerConfiguration,
|
||||
operation: { meta, state },
|
||||
});
|
||||
expect(mutate).toHaveBeenCalledTimes(1);
|
||||
expect(getValue.mock.calls.map(([address]) => ({ namespace: address.namespace, key: address.key }))).toEqual([
|
||||
{ namespace: storedValues.operationMeta("").namespace, key: operationId },
|
||||
{ namespace: storedValues.operationState("").namespace, key: operationId },
|
||||
]);
|
||||
expect(readList).not.toHaveBeenCalled();
|
||||
getValue.mockRestore();
|
||||
expect({
|
||||
leaves: await session.scanValues(storedValues.branchTipInventoryPrefix(), BACKGROUND_CONTEXT),
|
||||
mainConfiguration: await session.getValue(storedValues.laneConfig("main"), BACKGROUND_CONTEXT),
|
||||
workerConfiguration: await session.getValue(storedValues.laneConfig("worker"), BACKGROUND_CONTEXT),
|
||||
mainState: await session.getValue(storedValues.laneState("main"), BACKGROUND_CONTEXT),
|
||||
workerState: await session.getValue(storedValues.laneState("worker"), BACKGROUND_CONTEXT),
|
||||
}).toEqual(before);
|
||||
readList.mockRestore();
|
||||
});
|
||||
|
||||
it("allows an empty inventory but rejects lane values without a Branch", async () => {
|
||||
const repo = new MemorySessionRepo();
|
||||
repos.push(repo);
|
||||
const empty = await repo.create({}, BACKGROUND_CONTEXT);
|
||||
await expect(restoreSession(empty, BACKGROUND_CONTEXT)).resolves.toEqual(new Map());
|
||||
|
||||
const session = await createSession();
|
||||
await session.mutate(
|
||||
(mutator) => mutator.commit([storedValues.deleteValue(storedValues.branchTip("main"))], BACKGROUND_CONTEXT),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await expect(restoreSession(session, BACKGROUND_CONTEXT)).rejects.toThrow('Lane "main" is missing branch.tip');
|
||||
});
|
||||
});
|
||||
32
packages/agent/test/harness/runtime/test-utils.ts
Normal file
32
packages/agent/test/harness/runtime/test-utils.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { BACKGROUND_CONTEXT } from "../../../src/harness/context.ts";
|
||||
import { MemoryStorage } from "../../../src/harness/session/memory.ts";
|
||||
import type { CommitResult, Write } from "../../../src/harness/session/types.ts";
|
||||
|
||||
export class FailingMemoryStorage extends MemoryStorage {
|
||||
failure: Error | undefined;
|
||||
|
||||
override commit(writes: Write[]) {
|
||||
const failure = this.failure;
|
||||
this.failure = undefined;
|
||||
return failure === undefined ? super.commit(writes, BACKGROUND_CONTEXT) : Promise.reject(failure);
|
||||
}
|
||||
}
|
||||
|
||||
export class ControlledMemoryStorage extends MemoryStorage {
|
||||
beforeNextCommit: (() => Promise<void>) | undefined;
|
||||
|
||||
override async commit(writes: Write[]): Promise<CommitResult> {
|
||||
const beforeCommit = this.beforeNextCommit;
|
||||
this.beforeNextCommit = undefined;
|
||||
await beforeCommit?.();
|
||||
return super.commit(writes, BACKGROUND_CONTEXT);
|
||||
}
|
||||
}
|
||||
|
||||
export function deferred(): { promise: Promise<void>; resolve(): void } {
|
||||
let resolvePromise: (() => void) | undefined;
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
return { promise, resolve: () => resolvePromise?.() };
|
||||
}
|
||||
595
packages/agent/test/harness/runtime/watch.test.ts
Normal file
595
packages/agent/test/harness/runtime/watch.test.ts
Normal file
@@ -0,0 +1,595 @@
|
||||
import { type AssistantMessageFrame, createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { type HarnessEvent, HarnessFault, type WatchHandle } from "../../../src/harness/agent-harness.ts";
|
||||
import { DEFAULT_COMPACTION_SETTINGS } from "../../../src/harness/compaction/compaction.ts";
|
||||
import { BACKGROUND_CONTEXT, type Context, createContextKey, withContextValue } from "../../../src/harness/context.ts";
|
||||
import { createAgentHarness, Harness } from "../../../src/harness/runtime/harness.ts";
|
||||
import { Lane } from "../../../src/harness/runtime/lane.ts";
|
||||
import * as sessionWrites from "../../../src/harness/session/commit.ts";
|
||||
import { MemoryStorage } from "../../../src/harness/session/memory.ts";
|
||||
import { StorageBackedSession } from "../../../src/harness/session/session.ts";
|
||||
import type {
|
||||
OperationMeta,
|
||||
OperationScope,
|
||||
OperationState,
|
||||
Session,
|
||||
StorageBranchScan,
|
||||
Write,
|
||||
} from "../../../src/harness/session/types.ts";
|
||||
import * as storedValues from "../../../src/harness/session/values.ts";
|
||||
import { deferred } from "./test-utils.ts";
|
||||
|
||||
const sessions: Session[] = [];
|
||||
const configuration = {
|
||||
model: { provider: "configured", modelId: "model" },
|
||||
thinkingLevel: "off" as const,
|
||||
activeToolNames: [],
|
||||
};
|
||||
|
||||
async function createSession(storage: MemoryStorage = new MemoryStorage()): Promise<Session> {
|
||||
const session = new StorageBackedSession(
|
||||
{ id: `watch-${sessions.length}`, createdAt: 1, storageVersion: 1 },
|
||||
storage,
|
||||
);
|
||||
sessions.push(session);
|
||||
await session.mutate(
|
||||
(mutator) =>
|
||||
mutator.commit(
|
||||
[
|
||||
storedValues.setValue(storedValues.branchTip("main"), null),
|
||||
storedValues.setValue(storedValues.laneConfig("main"), configuration),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
],
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function commit(session: Session, writes: Write[]): Promise<void> {
|
||||
await session.mutate((mutator) => mutator.commit(writes, BACKGROUND_CONTEXT), BACKGROUND_CONTEXT);
|
||||
}
|
||||
|
||||
function runScope(control: OperationScope["control"] = { status: "running" }): OperationScope {
|
||||
return {
|
||||
control,
|
||||
settings: {
|
||||
compaction: DEFAULT_COMPACTION_SETTINGS,
|
||||
steeringMode: "all",
|
||||
followUpMode: "all",
|
||||
toolExecution: "parallel",
|
||||
},
|
||||
latestAssistantEntryId: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function attach(
|
||||
session: Session,
|
||||
): Promise<Lane<object | undefined> & Pick<Harness<object | undefined>, "events">> {
|
||||
const provider = fauxProvider();
|
||||
const models = createModels();
|
||||
models.setProvider(provider.provider);
|
||||
const { harness } = await createAgentHarness({ session, models, model: provider.getModel() }, BACKGROUND_CONTEXT);
|
||||
if (!(harness instanceof Harness)) throw new Error("Expected runtime Harness");
|
||||
const lane = await harness.lane("main", BACKGROUND_CONTEXT);
|
||||
if (!(lane instanceof Lane)) throw new Error("Expected runtime Lane");
|
||||
return Object.assign(lane, { events: harness.events });
|
||||
}
|
||||
|
||||
class BlockingScanStorage extends MemoryStorage {
|
||||
block = false;
|
||||
readonly started = deferred();
|
||||
readonly release = deferred();
|
||||
|
||||
override async scanBranch(query: StorageBranchScan, context: Context) {
|
||||
if (this.block) {
|
||||
this.started.resolve();
|
||||
await this.release.promise;
|
||||
}
|
||||
return super.scanBranch(query, context);
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const session of sessions.splice(0)) await session.close(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
describe("runtime lane watch", () => {
|
||||
it("captures a compaction-bounded transcript and isolates the returned snapshot", async () => {
|
||||
const session = await createSession();
|
||||
await commit(session, [
|
||||
sessionWrites.insertEntry({ id: "root", parentId: null, type: "custom", customType: "root" }),
|
||||
sessionWrites.insertEntry({
|
||||
id: "compact",
|
||||
parentId: "root",
|
||||
type: "compaction",
|
||||
summary: "summary",
|
||||
retainedTail: [],
|
||||
tokensBefore: 10,
|
||||
fromHook: false,
|
||||
}),
|
||||
sessionWrites.insertEntry({
|
||||
id: "after",
|
||||
parentId: "compact",
|
||||
type: "message",
|
||||
message: { role: "user", content: "after", timestamp: 2 },
|
||||
}),
|
||||
storedValues.setValue(storedValues.branchTip("main"), "after"),
|
||||
]);
|
||||
const harness = await attach(session);
|
||||
|
||||
const first = await harness.watch(BACKGROUND_CONTEXT);
|
||||
expect(first.snapshot.transcript.map(({ id }) => id)).toEqual(["compact", "after"]);
|
||||
expect(first.snapshot).toMatchObject({
|
||||
lane: "main",
|
||||
tipId: "after",
|
||||
configuration,
|
||||
stats: { messageCount: 1 },
|
||||
operation: null,
|
||||
queues: [],
|
||||
faulted: false,
|
||||
});
|
||||
first.snapshot.transcript.length = 0;
|
||||
first.snapshot.tipId = null;
|
||||
const second = await harness.watch(BACKGROUND_CONTEXT);
|
||||
expect(second.snapshot.transcript.map(({ id }) => id)).toEqual(["compact", "after"]);
|
||||
expect(second.snapshot.tipId).toBe("after");
|
||||
first.unsubscribe();
|
||||
second.unsubscribe();
|
||||
});
|
||||
|
||||
it("dereferences queues, pending writes, and deferred handles", async () => {
|
||||
const session = await createSession();
|
||||
const handle = { provider: "provider", modelId: "model", api: "test", id: "deferred" };
|
||||
const sourceMessage = fauxAssistantMessage([], { stopReason: "deferred", deferred: handle });
|
||||
const ids = {
|
||||
next: "next",
|
||||
steer: "steer",
|
||||
follow: "follow",
|
||||
write: "write",
|
||||
};
|
||||
const payload = (content: string) => ({
|
||||
type: "message" as const,
|
||||
payload: { role: "user" as const, content, timestamp: 1 },
|
||||
});
|
||||
const operationId = session.idGenerator.next();
|
||||
const state: OperationState = {
|
||||
...runScope({ status: "cancel_requested", requestedAt: 2 }),
|
||||
at: "deferred.suspended",
|
||||
stepId: "step",
|
||||
sourceEntryId: "source",
|
||||
poll: 3,
|
||||
configuration,
|
||||
streamOptions: {},
|
||||
};
|
||||
const meta: OperationMeta = {
|
||||
operationId,
|
||||
lane: "main",
|
||||
sourceTipId: null,
|
||||
startedAt: 1,
|
||||
intent: { kind: "run", promptEntryIds: [] },
|
||||
};
|
||||
await commit(session, [
|
||||
sessionWrites.insertEntry({ id: "source", parentId: null, type: "message", message: sourceMessage }),
|
||||
...Object.values(ids).map((id) =>
|
||||
storedValues.setValue(
|
||||
storedValues.pendingEntry(id),
|
||||
id === ids.write ? { type: "custom", customType: "note", payload: { id } } : payload(id),
|
||||
),
|
||||
),
|
||||
storedValues.setValue(storedValues.operationMeta(operationId), meta),
|
||||
storedValues.setValue(storedValues.operationState(operationId), state),
|
||||
storedValues.setValue(storedValues.branchTip("main"), "source"),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: operationId,
|
||||
lastOperationId: null,
|
||||
inbox: [
|
||||
{ entryId: ids.next, kind: "nextRun" },
|
||||
{ entryId: ids.steer, kind: "steer" },
|
||||
{ entryId: ids.follow, kind: "followUp" },
|
||||
{ entryId: ids.write, kind: "write" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const harness = await attach(session);
|
||||
|
||||
const watch = await harness.watch(BACKGROUND_CONTEXT);
|
||||
|
||||
expect(watch.snapshot.queues).toMatchObject([
|
||||
{ entryId: ids.next, kind: "nextRun", type: "message", message: { content: ids.next } },
|
||||
{ entryId: ids.steer, kind: "steer", type: "message", message: { content: ids.steer } },
|
||||
{ entryId: ids.follow, kind: "followUp", type: "message", message: { content: ids.follow } },
|
||||
{ entryId: ids.write, kind: "write", type: "custom", customType: "note", data: { id: ids.write } },
|
||||
]);
|
||||
expect(watch.snapshot.operation).toMatchObject({
|
||||
id: operationId,
|
||||
status: "aborting",
|
||||
deferred: { handle, poll: 3 },
|
||||
runningTools: [],
|
||||
});
|
||||
watch.unsubscribe();
|
||||
});
|
||||
|
||||
it("omits streaming presentation when an effect-pending response has no frames", async () => {
|
||||
const session = await createSession();
|
||||
const operationId = session.idGenerator.next();
|
||||
await commit(session, [
|
||||
storedValues.setValue(storedValues.operationMeta(operationId), {
|
||||
operationId,
|
||||
lane: "main",
|
||||
sourceTipId: null,
|
||||
startedAt: 1,
|
||||
intent: { kind: "run", promptEntryIds: [] },
|
||||
}),
|
||||
storedValues.setValue(storedValues.operationState(operationId), {
|
||||
...runScope(),
|
||||
at: "assistant.effect_pending",
|
||||
generationContext: {
|
||||
stepId: "step",
|
||||
triggerEntryId: "trigger",
|
||||
configuration,
|
||||
streamOptions: {},
|
||||
retryPolicy: { maxAttempts: 2, baseDelayMs: 0 },
|
||||
overflowRecoveryUsed: false,
|
||||
},
|
||||
attempt: 1,
|
||||
responseEntryId: "response-without-frames",
|
||||
usageId: "usage",
|
||||
intendedOutputLimit: 100,
|
||||
contextWindow: 1000,
|
||||
}),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: operationId,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
]);
|
||||
const harness = await attach(session);
|
||||
|
||||
const watch = await harness.watch(BACKGROUND_CONTEXT);
|
||||
|
||||
expect(watch.snapshot.operation).not.toHaveProperty("streamingMessage");
|
||||
watch.unsubscribe();
|
||||
});
|
||||
|
||||
it("reduces assistant frames and projects running and settled tools with full-content indexes", async () => {
|
||||
const frameSession = await createSession();
|
||||
const partial = fauxAssistantMessage([], { stopReason: "pending" });
|
||||
const frames: AssistantMessageFrame[] = [
|
||||
{ type: "start", partial },
|
||||
{ type: "text_start", contentIndex: 0, content: { type: "text", text: "" } },
|
||||
{ type: "text_delta", contentIndex: 0, delta: "partial" },
|
||||
];
|
||||
const frameOperationId = frameSession.idGenerator.next();
|
||||
await commit(frameSession, [
|
||||
storedValues.setValue(storedValues.operationMeta(frameOperationId), {
|
||||
operationId: frameOperationId,
|
||||
lane: "main",
|
||||
sourceTipId: null,
|
||||
startedAt: 1,
|
||||
intent: { kind: "run", promptEntryIds: [] },
|
||||
}),
|
||||
storedValues.setValue(storedValues.operationState(frameOperationId), {
|
||||
...runScope(),
|
||||
at: "assistant.effect_pending",
|
||||
generationContext: {
|
||||
stepId: "step",
|
||||
triggerEntryId: "trigger",
|
||||
configuration,
|
||||
streamOptions: {},
|
||||
retryPolicy: { maxAttempts: 2, baseDelayMs: 0 },
|
||||
overflowRecoveryUsed: false,
|
||||
},
|
||||
attempt: 1,
|
||||
responseEntryId: "response",
|
||||
usageId: "usage",
|
||||
intendedOutputLimit: 100,
|
||||
contextWindow: 1000,
|
||||
}),
|
||||
...frames.map((frame) =>
|
||||
storedValues.appendList(storedValues.pendingAssistantFrames(frameOperationId, "response"), frame),
|
||||
),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: frameOperationId,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
]);
|
||||
const frameHarness = await attach(frameSession);
|
||||
const frameWatch = await frameHarness.watch(BACKGROUND_CONTEXT);
|
||||
expect(frameWatch.snapshot.operation?.streamingMessage?.content).toEqual([{ type: "text", text: "partial" }]);
|
||||
frameWatch.unsubscribe();
|
||||
|
||||
const toolSession = await createSession();
|
||||
const toolUsage = {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 3,
|
||||
cacheWrite: 4,
|
||||
totalTokens: 10,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
const assistant = fauxAssistantMessage([
|
||||
{ type: "text", text: "before" },
|
||||
{ type: "toolCall", id: "call-completed", name: "completed", arguments: { source: "completed" } },
|
||||
{ type: "toolCall", id: "call-running", name: "read", arguments: { source: "running" } },
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call-without-checkpoint",
|
||||
name: "write",
|
||||
arguments: { source: "without-checkpoint" },
|
||||
},
|
||||
{ type: "toolCall", id: "call-ready", name: "real", arguments: { source: "real" } },
|
||||
{ type: "toolCall", id: "call-synthetic", name: "missing", arguments: { source: "synthetic" } },
|
||||
{ type: "toolCall", id: "call-planned", name: "planned", arguments: { source: "planned" } },
|
||||
]);
|
||||
const toolOperationId = toolSession.idGenerator.next();
|
||||
await commit(toolSession, [
|
||||
sessionWrites.insertEntry({ id: "assistant", parentId: null, type: "message", message: assistant }),
|
||||
storedValues.setValue(storedValues.operationMeta(toolOperationId), {
|
||||
operationId: toolOperationId,
|
||||
lane: "main",
|
||||
sourceTipId: null,
|
||||
startedAt: 1,
|
||||
intent: { kind: "run", promptEntryIds: [] },
|
||||
}),
|
||||
storedValues.setValue(storedValues.operationState(toolOperationId), {
|
||||
...runScope(),
|
||||
at: "tools",
|
||||
batch: {
|
||||
assistantEntryId: "assistant",
|
||||
configuration,
|
||||
turnId: "turn",
|
||||
calls: [
|
||||
{ status: "completed", sourceIndex: 1, resultEntryId: "completed", terminate: false },
|
||||
{ status: "effect_pending", sourceIndex: 2, resultEntryId: "result", replay: "safe" },
|
||||
{
|
||||
status: "effect_pending",
|
||||
sourceIndex: 3,
|
||||
resultEntryId: "without-checkpoint",
|
||||
replay: "never",
|
||||
},
|
||||
{ status: "outcome_ready", sourceIndex: 4, resultEntryId: "ready", terminate: true },
|
||||
{ status: "outcome_ready", sourceIndex: 5, resultEntryId: "synthetic", terminate: false },
|
||||
{ status: "planned", sourceIndex: 6, resultEntryId: "planned" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
sessionWrites.insertEntry({
|
||||
id: "completed",
|
||||
parentId: "assistant",
|
||||
type: "message",
|
||||
message: {
|
||||
role: "toolResult",
|
||||
toolCallId: "call-completed",
|
||||
toolName: "completed",
|
||||
content: [{ type: "text", text: "completed" }],
|
||||
isError: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
}),
|
||||
storedValues.setValue(storedValues.operationToolArgs(toolOperationId, "turn", 2), { path: "file" }),
|
||||
storedValues.setValue(storedValues.operationToolArgs(toolOperationId, "turn", 3), { path: "output" }),
|
||||
storedValues.setValue(storedValues.operationToolArgs(toolOperationId, "turn", 4), { path: "settled" }),
|
||||
storedValues.setValue(storedValues.pendingToolOutput(toolOperationId, "result"), {
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
details: { bytes: 1 },
|
||||
}),
|
||||
storedValues.setValue(storedValues.pendingEntry("ready"), {
|
||||
type: "message",
|
||||
payload: {
|
||||
role: "toolResult",
|
||||
toolCallId: "call-ready",
|
||||
toolName: "real",
|
||||
content: [{ type: "text", text: "settled" }],
|
||||
details: { kind: "real" },
|
||||
usage: toolUsage,
|
||||
addedToolNames: ["later"],
|
||||
isError: false,
|
||||
timestamp: 3,
|
||||
},
|
||||
}),
|
||||
storedValues.setValue(storedValues.pendingEntry("synthetic"), {
|
||||
type: "message",
|
||||
payload: {
|
||||
role: "toolResult",
|
||||
toolCallId: "call-synthetic",
|
||||
toolName: "missing",
|
||||
content: [{ type: "text", text: "unavailable" }],
|
||||
isError: true,
|
||||
timestamp: 4,
|
||||
},
|
||||
}),
|
||||
storedValues.setValue(storedValues.branchTip("main"), "completed"),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: toolOperationId,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
]);
|
||||
const toolHarness = await attach(toolSession);
|
||||
const toolWatch = await toolHarness.watch(BACKGROUND_CONTEXT);
|
||||
expect(toolWatch.snapshot.transcript.map(({ id }) => id)).toEqual(["assistant", "completed"]);
|
||||
expect(toolWatch.snapshot.operation?.runningTools).toEqual([
|
||||
{
|
||||
status: "running",
|
||||
toolCallId: "call-running",
|
||||
toolName: "read",
|
||||
args: { path: "file" },
|
||||
result: { content: [{ type: "text", text: "partial" }], details: { bytes: 1 } },
|
||||
},
|
||||
{
|
||||
status: "running",
|
||||
toolCallId: "call-without-checkpoint",
|
||||
toolName: "write",
|
||||
args: { path: "output" },
|
||||
},
|
||||
{
|
||||
status: "settled",
|
||||
toolCallId: "call-ready",
|
||||
toolName: "real",
|
||||
args: { path: "settled" },
|
||||
result: {
|
||||
content: [{ type: "text", text: "settled" }],
|
||||
details: { kind: "real" },
|
||||
usage: toolUsage,
|
||||
addedToolNames: ["later"],
|
||||
terminate: true,
|
||||
},
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
status: "settled",
|
||||
toolCallId: "call-synthetic",
|
||||
toolName: "missing",
|
||||
args: { source: "synthetic" },
|
||||
result: {
|
||||
content: [{ type: "text", text: "unavailable" }],
|
||||
details: undefined,
|
||||
},
|
||||
isError: true,
|
||||
},
|
||||
]);
|
||||
expect(toolWatch.snapshot.operation?.runningTools[1]).not.toHaveProperty("result");
|
||||
toolWatch.unsubscribe();
|
||||
});
|
||||
|
||||
it("faults missing or mismatched staged outcome-ready results", async () => {
|
||||
for (const corruption of ["missing", "mismatched"] as const) {
|
||||
const session = await createSession();
|
||||
const operationId = session.idGenerator.next();
|
||||
const writes: Write[] = [
|
||||
sessionWrites.insertEntry({
|
||||
id: "assistant",
|
||||
parentId: null,
|
||||
type: "message",
|
||||
message: fauxAssistantMessage([
|
||||
{ type: "toolCall", id: "call", name: "read", arguments: { path: "file" } },
|
||||
]),
|
||||
}),
|
||||
storedValues.setValue(storedValues.operationMeta(operationId), {
|
||||
operationId,
|
||||
lane: "main",
|
||||
sourceTipId: null,
|
||||
startedAt: 1,
|
||||
intent: { kind: "run", promptEntryIds: [] },
|
||||
}),
|
||||
storedValues.setValue(storedValues.operationState(operationId), {
|
||||
...runScope(),
|
||||
at: "tools",
|
||||
batch: {
|
||||
assistantEntryId: "assistant",
|
||||
configuration,
|
||||
turnId: "turn",
|
||||
calls: [{ status: "outcome_ready", sourceIndex: 0, resultEntryId: "result", terminate: false }],
|
||||
},
|
||||
}),
|
||||
storedValues.setValue(storedValues.branchTip("main"), "assistant"),
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: operationId,
|
||||
lastOperationId: null,
|
||||
inbox: [],
|
||||
}),
|
||||
];
|
||||
if (corruption === "mismatched") {
|
||||
writes.push(
|
||||
storedValues.setValue(storedValues.pendingEntry("result"), {
|
||||
type: "message",
|
||||
payload: {
|
||||
role: "toolResult",
|
||||
toolCallId: "other-call",
|
||||
toolName: "read",
|
||||
content: [],
|
||||
isError: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
await commit(session, writes);
|
||||
const harness = await attach(session);
|
||||
|
||||
await expect(harness.watch(BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(HarnessFault);
|
||||
}
|
||||
});
|
||||
|
||||
it("faults required payload corruption and unsubscribes the incomplete watcher", async () => {
|
||||
const session = await createSession();
|
||||
await commit(session, [
|
||||
storedValues.setValue(storedValues.laneState("main"), {
|
||||
currentOperationId: null,
|
||||
lastOperationId: null,
|
||||
inbox: [{ entryId: "missing", kind: "nextRun" }],
|
||||
}),
|
||||
]);
|
||||
const harness = await attach(session);
|
||||
let unsubscribed = false;
|
||||
const originalWatch = harness.events.watch.bind(harness.events);
|
||||
vi.spyOn(harness.events, "watch").mockImplementation(
|
||||
<T>(snapshot: T, filter: (event: HarnessEvent) => boolean, context: Context): WatchHandle<T> => {
|
||||
const handle = originalWatch(snapshot, filter, context);
|
||||
return {
|
||||
...handle,
|
||||
unsubscribe: () => {
|
||||
unsubscribed = true;
|
||||
handle.unsubscribe();
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
await expect(harness.watch(BACKGROUND_CONTEXT)).rejects.toBeInstanceOf(HarnessFault);
|
||||
expect(unsubscribed).toBe(true);
|
||||
});
|
||||
|
||||
it("returns snapshot-before plus buffered events when watch wins the lane line", async () => {
|
||||
const storage = new BlockingScanStorage();
|
||||
const session = await createSession(storage);
|
||||
await commit(session, [
|
||||
sessionWrites.insertEntry({ id: "root", parentId: null, type: "custom", customType: "root" }),
|
||||
storedValues.setValue(storedValues.branchTip("main"), "root"),
|
||||
]);
|
||||
const harness = await attach(session);
|
||||
storage.block = true;
|
||||
const watchPromise = harness.watch(BACKGROUND_CONTEXT);
|
||||
await storage.started.promise;
|
||||
const sourceKey = createContextKey<string>("watch.event.source");
|
||||
const sourceContext = withContextValue(sourceKey, "append", BACKGROUND_CONTEXT);
|
||||
const append = harness.appendMessage({ role: "user", content: "later", timestamp: 2 }, sourceContext);
|
||||
storage.release.resolve();
|
||||
const watch = await watchPromise;
|
||||
expect(watch.snapshot.transcript.map(({ id }) => id)).toEqual(["root"]);
|
||||
const seen: Array<{ type: string; sameContext: boolean }> = [];
|
||||
watch.start((event, eventContext) => {
|
||||
seen.push({ type: event.type, sameContext: eventContext === sourceContext });
|
||||
});
|
||||
await append;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(seen.map(({ type }) => type)).toEqual(["message_start", "message_end", "entry_added"]);
|
||||
expect(seen.every(({ sameContext }) => sameContext)).toBe(true);
|
||||
watch.unsubscribe();
|
||||
});
|
||||
|
||||
it("returns snapshot-after without replay when publication wins", async () => {
|
||||
const session = await createSession();
|
||||
const harness = await attach(session);
|
||||
const entryId = await harness.appendMessage(
|
||||
{ role: "user", content: "existing", timestamp: 1 },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
|
||||
const watch = await harness.watch(BACKGROUND_CONTEXT);
|
||||
const seen = vi.fn();
|
||||
watch.start(seen);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(watch.snapshot.transcript.map(({ id }) => id)).toEqual([entryId]);
|
||||
expect(seen).not.toHaveBeenCalled();
|
||||
watch.unsubscribe();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user