test(coding-agent): add session lifecycle characterization suite
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { fauxAssistantMessage, fauxToolCall } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { BashOperations } from "../../src/core/tools/bash.js";
|
||||
import { createHarness, type Harness } from "./harness.js";
|
||||
|
||||
function getEntryTypes(harness: Harness): string[] {
|
||||
return harness.sessionManager.getEntries().map((entry) => entry.type);
|
||||
}
|
||||
|
||||
describe("AgentSession bash and persistence characterization", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("records bash results immediately while idle", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.session.recordBashResult("echo hi", {
|
||||
output: "hi",
|
||||
exitCode: 0,
|
||||
cancelled: false,
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
expect(harness.session.hasPendingBashMessages).toBe(false);
|
||||
expect(harness.session.messages[harness.session.messages.length - 1]?.role).toBe("bashExecution");
|
||||
expect(getEntryTypes(harness)).toContain("message");
|
||||
});
|
||||
|
||||
it("defers bash results while streaming and flushes them before the next prompt", async () => {
|
||||
let releaseToolExecution: (() => void) | undefined;
|
||||
const toolRelease = new Promise<void>((resolve) => {
|
||||
releaseToolExecution = resolve;
|
||||
});
|
||||
const waitTool: AgentTool = {
|
||||
name: "wait",
|
||||
label: "Wait",
|
||||
description: "Wait for release",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
await toolRelease;
|
||||
return {
|
||||
content: [{ type: "text", text: "released" }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({ tools: [waitTool] });
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("wait", {})], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
fauxAssistantMessage("after flush"),
|
||||
]);
|
||||
|
||||
const sawToolStart = new Promise<void>((resolve) => {
|
||||
const unsubscribe = harness.session.subscribe((event) => {
|
||||
if (event.type === "tool_execution_start") {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const firstPrompt = harness.session.prompt("start");
|
||||
await sawToolStart;
|
||||
harness.session.recordBashResult("echo hi", {
|
||||
output: "hi",
|
||||
exitCode: 0,
|
||||
cancelled: false,
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
expect(harness.session.hasPendingBashMessages).toBe(true);
|
||||
expect(harness.session.messages.some((message) => message.role === "bashExecution")).toBe(false);
|
||||
|
||||
releaseToolExecution?.();
|
||||
await firstPrompt;
|
||||
|
||||
expect(harness.session.hasPendingBashMessages).toBe(true);
|
||||
expect(harness.session.messages.some((message) => message.role === "bashExecution")).toBe(false);
|
||||
|
||||
await harness.session.prompt("next turn");
|
||||
|
||||
expect(harness.session.hasPendingBashMessages).toBe(false);
|
||||
expect(harness.session.messages.some((message) => message.role === "bashExecution")).toBe(true);
|
||||
expect(getEntryTypes(harness).filter((type) => type === "message").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("executes bash commands and records the result", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
|
||||
const result = await harness.session.executeBash("printf 'hello'");
|
||||
|
||||
expect(result.output).toContain("hello");
|
||||
expect(harness.session.messages[harness.session.messages.length - 1]?.role).toBe("bashExecution");
|
||||
});
|
||||
|
||||
it("cancels running bash commands with abortBash", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const operations: BashOperations = {
|
||||
exec: async (_command, _cwd, options) => {
|
||||
return await new Promise<{ exitCode: number | null }>((_resolve, reject) => {
|
||||
options.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
reject(new Error("aborted"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const bashPromise = harness.session.executeBash("sleep", undefined, { operations });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(harness.session.isBashRunning).toBe(true);
|
||||
harness.session.abortBash();
|
||||
|
||||
const result = await bashPromise;
|
||||
expect(result.cancelled).toBe(true);
|
||||
expect(harness.session.isBashRunning).toBe(false);
|
||||
});
|
||||
|
||||
it("persists user, assistant, toolResult, and custom messages in order", async () => {
|
||||
const echoTool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo text back",
|
||||
parameters: Type.Object({ text: Type.String() }),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : "";
|
||||
return { content: [{ type: "text", text: `echo:${text}` }], details: { text } };
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({ tools: [echoTool] });
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
|
||||
await harness.session.sendCustomMessage({
|
||||
customType: "note",
|
||||
content: "hello",
|
||||
display: true,
|
||||
details: { a: 1 },
|
||||
});
|
||||
await harness.session.prompt("start");
|
||||
|
||||
const entries = harness.sessionManager.getEntries();
|
||||
expect(entries.map((entry) => entry.type)).toEqual([
|
||||
"custom_message",
|
||||
"message",
|
||||
"message",
|
||||
"message",
|
||||
"message",
|
||||
]);
|
||||
expect(harness.session.messages.map((message) => message.role)).toEqual([
|
||||
"custom",
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
"assistant",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not emit message_end for bash execution messages", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const messageEndRoles: string[] = [];
|
||||
harness.session.subscribe((event) => {
|
||||
if (event.type === "message_end") {
|
||||
messageEndRoles.push(event.message.role);
|
||||
}
|
||||
});
|
||||
|
||||
harness.session.recordBashResult("echo hi", {
|
||||
output: "hi",
|
||||
exitCode: 0,
|
||||
cancelled: false,
|
||||
truncated: false,
|
||||
});
|
||||
|
||||
expect(messageEndRoles).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists aborted assistant messages", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("x".repeat(20_000))]);
|
||||
|
||||
const sawMessageUpdate = new Promise<void>((resolve) => {
|
||||
const unsubscribe = harness.session.subscribe((event) => {
|
||||
if (event.type === "message_update") {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const promptPromise = harness.session.prompt("hi");
|
||||
await sawMessageUpdate;
|
||||
await harness.session.abort();
|
||||
await promptPromise;
|
||||
|
||||
const lastEntry = harness.sessionManager.getEntries()[harness.sessionManager.getEntries().length - 1];
|
||||
expect(lastEntry?.type).toBe("message");
|
||||
if (lastEntry?.type === "message") {
|
||||
expect(lastEntry.message.role).toBe("assistant");
|
||||
if (lastEntry.message.role === "assistant") {
|
||||
expect(lastEntry.message.stopReason).toBe("aborted");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("records bash output through custom operations", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const operations: BashOperations = {
|
||||
exec: async (_command, _cwd, options) => {
|
||||
options.onData(Buffer.from("hello from custom ops"));
|
||||
return { exitCode: 0 };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await harness.session.executeBash("custom", undefined, { operations });
|
||||
|
||||
expect(result.output).toContain("hello from custom ops");
|
||||
expect(harness.session.messages[harness.session.messages.length - 1]?.role).toBe("bashExecution");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
import { type AssistantMessage, fauxAssistantMessage, type Model } from "@mariozechner/pi-ai";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createHarness, type Harness } from "./harness.js";
|
||||
|
||||
type SessionWithCompactionInternals = {
|
||||
_checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise<void>;
|
||||
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<void>;
|
||||
};
|
||||
|
||||
function createUsage(totalTokens: number) {
|
||||
return {
|
||||
input: totalTokens,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function createAssistant(
|
||||
harness: Harness,
|
||||
options: {
|
||||
stopReason?: AssistantMessage["stopReason"];
|
||||
errorMessage?: string;
|
||||
totalTokens?: number;
|
||||
timestamp?: number;
|
||||
},
|
||||
): AssistantMessage {
|
||||
const model = harness.getModel();
|
||||
return {
|
||||
...fauxAssistantMessage("", {
|
||||
stopReason: options.stopReason,
|
||||
errorMessage: options.errorMessage,
|
||||
timestamp: options.timestamp,
|
||||
}),
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: createUsage(options.totalTokens ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentSession compaction characterization", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("manually compacts using an extension-provided summary", async () => {
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_before_compact", async (event) => ({
|
||||
compaction: {
|
||||
summary: "summary from extension",
|
||||
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||
tokensBefore: event.preparation.tokensBefore,
|
||||
details: { source: "extension" },
|
||||
},
|
||||
}));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
await harness.session.prompt("one");
|
||||
await harness.session.prompt("two");
|
||||
|
||||
const result = await harness.session.compact();
|
||||
const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction");
|
||||
|
||||
expect(result.summary).toBe("summary from extension");
|
||||
expect(compactionEntries).toHaveLength(1);
|
||||
expect(harness.session.messages[0]?.role).toBe("compactionSummary");
|
||||
});
|
||||
|
||||
it("throws when compacting without a model", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
harness.session.agent.state.model = undefined as unknown as Model<any>;
|
||||
|
||||
await expect(harness.session.compact()).rejects.toThrow("No model selected");
|
||||
});
|
||||
|
||||
it("throws when compacting without configured auth", async () => {
|
||||
const harness = await createHarness({ withConfiguredAuth: false });
|
||||
harnesses.push(harness);
|
||||
|
||||
await expect(harness.session.compact()).rejects.toThrow(`No API key found for ${harness.getModel().provider}.`);
|
||||
});
|
||||
|
||||
it("cancels in-progress manual compaction when abortCompaction is called", async () => {
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_before_compact", async (event) => {
|
||||
return await new Promise<{ cancel: true }>((resolve) => {
|
||||
event.signal.addEventListener("abort", () => resolve({ cancel: true }), { once: true });
|
||||
});
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
await harness.session.prompt("one");
|
||||
await harness.session.prompt("two");
|
||||
|
||||
const compactPromise = harness.session.compact();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
harness.session.abortCompaction();
|
||||
|
||||
await expect(compactPromise).rejects.toThrow("Compaction cancelled");
|
||||
});
|
||||
|
||||
it("resumes after threshold compaction when only agent-level queued messages exist", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = await createHarness({
|
||||
settings: { compaction: { keepRecentTokens: 1 } },
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_before_compact", async (event) => ({
|
||||
compaction: {
|
||||
summary: "auto compacted",
|
||||
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
||||
tokensBefore: event.preparation.tokensBefore,
|
||||
details: {},
|
||||
},
|
||||
}));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two")]);
|
||||
await harness.session.prompt("first");
|
||||
await harness.session.prompt("second");
|
||||
|
||||
harness.session.agent.followUp({
|
||||
role: "custom",
|
||||
customType: "test",
|
||||
content: [{ type: "text", text: "queued custom" }],
|
||||
display: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const continueSpy = vi.spyOn(harness.session.agent, "continue").mockResolvedValue();
|
||||
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
|
||||
|
||||
await sessionInternals._runAutoCompaction("threshold", false);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(continueSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry overflow recovery more than once", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
|
||||
const overflowMessage = createAssistant(harness, {
|
||||
stopReason: "error",
|
||||
errorMessage: "prompt is too long",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue();
|
||||
const compactionErrors: string[] = [];
|
||||
harness.session.subscribe((event) => {
|
||||
if (event.type === "compaction_end" && event.errorMessage) {
|
||||
compactionErrors.push(event.errorMessage);
|
||||
}
|
||||
});
|
||||
|
||||
await sessionInternals._checkCompaction(overflowMessage);
|
||||
await sessionInternals._checkCompaction({ ...overflowMessage, timestamp: Date.now() + 1 });
|
||||
|
||||
expect(runAutoCompactionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(compactionErrors).toContain(
|
||||
"Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores stale pre-compaction assistant usage on pre-prompt checks", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
|
||||
const staleTimestamp = Date.now() - 10_000;
|
||||
const staleAssistant = createAssistant(harness, {
|
||||
stopReason: "stop",
|
||||
totalTokens: 610_000,
|
||||
timestamp: staleTimestamp,
|
||||
});
|
||||
|
||||
harness.sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "before compaction" }],
|
||||
timestamp: staleTimestamp - 1000,
|
||||
});
|
||||
harness.sessionManager.appendMessage(staleAssistant);
|
||||
const firstKeptEntryId = harness.sessionManager.getEntries()[0]!.id;
|
||||
harness.sessionManager.appendCompaction(
|
||||
"summary",
|
||||
firstKeptEntryId,
|
||||
staleAssistant.usage.totalTokens,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
harness.sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "after compaction" }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue();
|
||||
|
||||
await sessionInternals._checkCompaction(staleAssistant, false);
|
||||
|
||||
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers threshold compaction for error messages using the last successful usage", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
|
||||
const successfulAssistant = createAssistant(harness, {
|
||||
stopReason: "stop",
|
||||
totalTokens: 190_000,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const errorAssistant = createAssistant(harness, {
|
||||
stopReason: "error",
|
||||
errorMessage: "529 overloaded",
|
||||
timestamp: Date.now() + 1000,
|
||||
});
|
||||
harness.session.agent.state.messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
|
||||
successfulAssistant,
|
||||
{ role: "user", content: [{ type: "text", text: "retry" }], timestamp: Date.now() + 500 },
|
||||
errorAssistant,
|
||||
];
|
||||
|
||||
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue();
|
||||
|
||||
await sessionInternals._checkCompaction(errorAssistant);
|
||||
|
||||
expect(runAutoCompactionSpy).toHaveBeenCalledWith("threshold", false);
|
||||
});
|
||||
|
||||
it("does not trigger threshold compaction for error messages when no prior usage exists", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
|
||||
const errorAssistant = createAssistant(harness, {
|
||||
stopReason: "error",
|
||||
errorMessage: "529 overloaded",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
harness.session.agent.state.messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
|
||||
errorAssistant,
|
||||
];
|
||||
|
||||
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue();
|
||||
|
||||
await sessionInternals._checkCompaction(errorAssistant);
|
||||
|
||||
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not trigger threshold compaction when only kept pre-compaction usage exists", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
|
||||
const preCompactionTimestamp = Date.now() - 10_000;
|
||||
const keptAssistant = createAssistant(harness, {
|
||||
stopReason: "stop",
|
||||
totalTokens: 190_000,
|
||||
timestamp: preCompactionTimestamp,
|
||||
});
|
||||
|
||||
harness.sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "before compaction" }],
|
||||
timestamp: preCompactionTimestamp - 1000,
|
||||
});
|
||||
harness.sessionManager.appendMessage(keptAssistant);
|
||||
const firstKeptEntryId = harness.sessionManager.getEntries()[0]!.id;
|
||||
harness.sessionManager.appendCompaction(
|
||||
"summary",
|
||||
firstKeptEntryId,
|
||||
keptAssistant.usage.totalTokens,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
const errorAssistant = createAssistant(harness, {
|
||||
stopReason: "error",
|
||||
errorMessage: "529 overloaded",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
harness.session.agent.state.messages = [
|
||||
{ role: "user", content: [{ type: "text", text: "kept user" }], timestamp: preCompactionTimestamp - 1000 },
|
||||
keptAssistant,
|
||||
{ role: "user", content: [{ type: "text", text: "new prompt" }], timestamp: Date.now() - 500 },
|
||||
errorAssistant,
|
||||
];
|
||||
|
||||
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue();
|
||||
|
||||
await sessionInternals._checkCompaction(errorAssistant);
|
||||
|
||||
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not trigger threshold compaction below the threshold or when disabled", async () => {
|
||||
const belowThresholdHarness = await createHarness({
|
||||
settings: { compaction: { enabled: true, reserveTokens: 1000 } },
|
||||
models: [{ id: "faux-1", contextWindow: 200_000 }],
|
||||
});
|
||||
harnesses.push(belowThresholdHarness);
|
||||
const disabledHarness = await createHarness({ settings: { compaction: { enabled: false } } });
|
||||
harnesses.push(disabledHarness);
|
||||
|
||||
const belowThresholdInternals = belowThresholdHarness.session as unknown as SessionWithCompactionInternals;
|
||||
const disabledInternals = disabledHarness.session as unknown as SessionWithCompactionInternals;
|
||||
const belowThresholdSpy = vi.spyOn(belowThresholdInternals, "_runAutoCompaction").mockResolvedValue();
|
||||
const disabledSpy = vi.spyOn(disabledInternals, "_runAutoCompaction").mockResolvedValue();
|
||||
|
||||
await belowThresholdInternals._checkCompaction(
|
||||
createAssistant(belowThresholdHarness, { stopReason: "stop", totalTokens: 1_000, timestamp: Date.now() }),
|
||||
);
|
||||
await disabledInternals._checkCompaction(
|
||||
createAssistant(disabledHarness, { stopReason: "stop", totalTokens: 1_000_000, timestamp: Date.now() }),
|
||||
);
|
||||
|
||||
expect(belowThresholdSpy).not.toHaveBeenCalled();
|
||||
expect(disabledSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import type { AgentTool, ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import { fauxAssistantMessage, fauxToolCall, type Model } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { ExtensionAPI } from "../../src/index.js";
|
||||
import { createHarness, getAssistantTexts, type Harness } from "./harness.js";
|
||||
|
||||
describe("AgentSession model and extension characterization", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("setModel saves the model and emits model_select", async () => {
|
||||
const modelEvents: string[] = [];
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", name: "One", reasoning: true },
|
||||
{ id: "faux-2", name: "Two", reasoning: true },
|
||||
],
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("model_select", async (event) => {
|
||||
modelEvents.push(`${event.previousModel?.id ?? "none"}->${event.model.id}:${event.source}`);
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const nextModel = harness.getModel("faux-2")!;
|
||||
|
||||
await harness.session.setModel(nextModel);
|
||||
|
||||
expect(harness.session.model?.id).toBe("faux-2");
|
||||
expect(modelEvents).toEqual(["faux-1->faux-2:set"]);
|
||||
expect(
|
||||
harness.sessionManager
|
||||
.getEntries()
|
||||
.filter((entry) => entry.type === "model_change")
|
||||
.map((entry) => `${entry.provider}/${entry.modelId}`),
|
||||
).toEqual([`${nextModel.provider}/${nextModel.id}`]);
|
||||
});
|
||||
|
||||
it("cycles through scoped models and preserves the scoped thinking preference", async () => {
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", name: "One", reasoning: true },
|
||||
{ id: "faux-2", name: "Two", reasoning: false },
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const modelOne = harness.getModel("faux-1")!;
|
||||
const modelTwo = harness.getModel("faux-2")!;
|
||||
harness.session.setScopedModels([{ model: modelOne, thinkingLevel: "high" }, { model: modelTwo }] as Array<{
|
||||
model: Model<string>;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}>);
|
||||
harness.session.setThinkingLevel("high");
|
||||
|
||||
await harness.session.cycleModel();
|
||||
expect(harness.session.model?.id).toBe("faux-2");
|
||||
expect(harness.session.thinkingLevel).toBe("off");
|
||||
|
||||
await harness.session.cycleModel();
|
||||
expect(harness.session.model?.id).toBe("faux-1");
|
||||
expect(harness.session.thinkingLevel).toBe("high");
|
||||
});
|
||||
|
||||
it("clamps thinking levels to model capabilities and cycles available levels", async () => {
|
||||
const harness = await createHarness({ models: [{ id: "faux-1", reasoning: false }] });
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.session.setThinkingLevel("high");
|
||||
expect(harness.session.thinkingLevel).toBe("off");
|
||||
expect(harness.session.cycleThinkingLevel()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws when setModel is called without configured auth", async () => {
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "faux-1", name: "One", reasoning: true },
|
||||
{ id: "faux-2", name: "Two", reasoning: true },
|
||||
],
|
||||
withConfiguredAuth: false,
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
await expect(harness.session.setModel(harness.getModel("faux-2")!)).rejects.toThrow(
|
||||
`No API key for ${harness.getModel().provider}/faux-2`,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows extension tool_call handlers to block tool execution", async () => {
|
||||
const echoTool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo text back",
|
||||
parameters: Type.Object({ text: Type.String() }),
|
||||
execute: async () => {
|
||||
throw new Error("tool should have been blocked");
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({
|
||||
tools: [echoTool],
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("tool_call", async () => ({ block: true, reason: "Blocked by test" }));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
|
||||
(context) => {
|
||||
const toolResult = context.messages.find((message) => message.role === "toolResult");
|
||||
const errorText =
|
||||
toolResult?.role === "toolResult"
|
||||
? toolResult.content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
: "";
|
||||
return fauxAssistantMessage(errorText);
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(getAssistantTexts(harness)).toContain("Blocked by test");
|
||||
expect(
|
||||
harness.session.messages.find((message) => message.role === "toolResult" && message.isError),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("allows extension tool_result handlers to modify tool results", async () => {
|
||||
const echoTool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo text back",
|
||||
parameters: Type.Object({ text: Type.String() }),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : "";
|
||||
return { content: [{ type: "text", text }], details: { text } };
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({
|
||||
tools: [echoTool],
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("tool_result", async () => ({
|
||||
content: [{ type: "text", text: "patched result" }],
|
||||
details: { patched: true },
|
||||
}));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
|
||||
(context) => {
|
||||
const toolResult = context.messages.find((message) => message.role === "toolResult");
|
||||
const text =
|
||||
toolResult?.role === "toolResult"
|
||||
? toolResult.content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
: "";
|
||||
return fauxAssistantMessage(text);
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(getAssistantTexts(harness)).toContain("patched result");
|
||||
expect(
|
||||
harness.session.messages.find((message) => message.role === "toolResult" && message.details?.patched === true),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("allows extension context handlers to modify messages before the LLM call", async () => {
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("context", async (event) => ({
|
||||
messages: event.messages.map((message) =>
|
||||
message.role === "user"
|
||||
? { ...message, content: [{ type: "text", text: "rewritten" }], timestamp: message.timestamp }
|
||||
: message,
|
||||
),
|
||||
}));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
let providerUserText = "";
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
const user = context.messages.find((message) => message.role === "user");
|
||||
providerUserText =
|
||||
user && typeof user.content !== "string"
|
||||
? user.content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
: "";
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("original");
|
||||
|
||||
expect(providerUserText).toBe("rewritten");
|
||||
const storedUserMessage = harness.session.messages.find((message) => message.role === "user");
|
||||
expect(storedUserMessage?.role).toBe("user");
|
||||
if (storedUserMessage?.role === "user") {
|
||||
expect(storedUserMessage.content).toEqual([{ type: "text", text: "original" }]);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows extension input handlers to transform or handle input", async () => {
|
||||
let extensionApi: ExtensionAPI | undefined;
|
||||
const transformedHarness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
extensionApi = pi;
|
||||
pi.on("input", async (event) => {
|
||||
if (event.text === "ping") {
|
||||
return { action: "handled" };
|
||||
}
|
||||
return { action: "transform", text: `transformed:${event.text}` };
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(transformedHarness);
|
||||
let providerUserText = "";
|
||||
transformedHarness.setResponses([
|
||||
(context) => {
|
||||
const user = context.messages.find((message) => message.role === "user");
|
||||
providerUserText =
|
||||
user && typeof user.content !== "string"
|
||||
? user.content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
: "";
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
|
||||
await transformedHarness.session.prompt("hello");
|
||||
await transformedHarness.session.prompt("ping");
|
||||
|
||||
expect(providerUserText).toBe("transformed:hello");
|
||||
expect(transformedHarness.session.messages.filter((message) => message.role === "user")).toHaveLength(1);
|
||||
expect(extensionApi).toBeDefined();
|
||||
});
|
||||
|
||||
it("allows before_agent_start handlers to inject custom messages and modify the system prompt", async () => {
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("before_agent_start", async (event) => ({
|
||||
message: {
|
||||
customType: "before-start",
|
||||
content: "injected",
|
||||
display: true,
|
||||
details: { injected: true },
|
||||
},
|
||||
systemPrompt: `${event.systemPrompt}\n\nextra instructions`,
|
||||
}));
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
let providerSystemPrompt = "";
|
||||
let sawInjectedUserMessage = false;
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
providerSystemPrompt = context.systemPrompt ?? "";
|
||||
sawInjectedUserMessage = context.messages.some(
|
||||
(message) =>
|
||||
message.role === "user" &&
|
||||
typeof message.content !== "string" &&
|
||||
message.content.some((part) => part.type === "text" && part.text === "injected"),
|
||||
);
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hello");
|
||||
|
||||
expect(providerSystemPrompt).toContain("extra instructions");
|
||||
expect(sawInjectedUserMessage).toBe(true);
|
||||
expect(
|
||||
harness.session.messages.some((message) => message.role === "custom" && message.customType === "before-start"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("bindExtensions emits session_start and reload emits session_shutdown then session_start", async () => {
|
||||
const lifecycleEvents: string[] = [];
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("session_start", async (event) => {
|
||||
lifecycleEvents.push(`start:${event.reason}`);
|
||||
});
|
||||
pi.on("session_shutdown", async () => {
|
||||
lifecycleEvents.push("shutdown");
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
await harness.session.bindExtensions({ shutdownHandler: () => {} });
|
||||
await harness.session.reload();
|
||||
|
||||
expect(lifecycleEvents).toEqual(["start:startup", "shutdown", "start:reload"]);
|
||||
});
|
||||
});
|
||||
323
packages/coding-agent/test/suite/agent-session-prompt.test.ts
Normal file
323
packages/coding-agent/test/suite/agent-session-prompt.test.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { fauxAssistantMessage, fauxToolCall, type Model } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PromptTemplate } from "../../src/core/prompt-templates.js";
|
||||
import { createSyntheticSourceInfo } from "../../src/core/source-info.js";
|
||||
import { createTestResourceLoader } from "../utilities.js";
|
||||
import { createHarness, getMessageText, type Harness } from "./harness.js";
|
||||
|
||||
describe("AgentSession prompt characterization", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
while (tempDirs.length > 0) {
|
||||
const tempDir = tempDirs.pop();
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("prompts while idle and records a single text response", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.setResponses([fauxAssistantMessage("hello")]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(harness.session.messages.map((message) => message.role)).toEqual(["user", "assistant"]);
|
||||
expect(getMessageText(harness.session.messages[0]!)).toBe("hi");
|
||||
expect(harness.getPendingResponseCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("handles a tool call turn and waits for the follow-up LLM response", async () => {
|
||||
const toolRuns: string[] = [];
|
||||
const echoTool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo text back",
|
||||
parameters: Type.Object({ text: Type.String() }),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : "";
|
||||
toolRuns.push(text);
|
||||
return {
|
||||
content: [{ type: "text", text: `echo:${text}` }],
|
||||
details: { text },
|
||||
};
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({ tools: [echoTool] });
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("echo", { text: "hello" }), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("start");
|
||||
|
||||
expect(toolRuns).toEqual(["hello"]);
|
||||
expect(harness.session.messages.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
"assistant",
|
||||
]);
|
||||
expect(harness.session.messages[2]?.role).toBe("toolResult");
|
||||
expect(harness.session.messages[3]?.role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("executes multiple tool calls from one response and continues with a single follow-up response", async () => {
|
||||
const toolRuns: string[] = [];
|
||||
const makeTool = (name: string, delayMs: number): AgentTool => ({
|
||||
name,
|
||||
label: name,
|
||||
description: `${name} tool`,
|
||||
parameters: Type.Object({ value: Type.String() }),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const value =
|
||||
typeof params === "object" && params !== null && "value" in params ? String(params.value) : "";
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
toolRuns.push(`${name}:${value}`);
|
||||
return {
|
||||
content: [{ type: "text", text: `${name}:${value}` }],
|
||||
details: { value },
|
||||
};
|
||||
},
|
||||
});
|
||||
const harness = await createHarness({ tools: [makeTool("slow", 25), makeTool("fast", 0)] });
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("slow", { value: "a" }), fauxToolCall("fast", { value: "b" })], {
|
||||
stopReason: "toolUse",
|
||||
}),
|
||||
(context) => {
|
||||
const toolResults = context.messages.filter((message) => message.role === "toolResult");
|
||||
return fauxAssistantMessage(`tool results: ${toolResults.length}`);
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("run tools");
|
||||
|
||||
expect(toolRuns.sort()).toEqual(["fast:b", "slow:a"]);
|
||||
expect(harness.session.messages.filter((message) => message.role === "toolResult")).toHaveLength(2);
|
||||
expect(harness.session.messages[harness.session.messages.length - 1]?.role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("preserves image attachments in the provider context", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
let sawImage = false;
|
||||
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
const user = context.messages.find((message) => message.role === "user");
|
||||
sawImage =
|
||||
user?.role === "user" &&
|
||||
typeof user.content !== "string" &&
|
||||
user.content.some((part) => part.type === "image");
|
||||
return fauxAssistantMessage("ok");
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("describe", {
|
||||
images: [
|
||||
{
|
||||
type: "image",
|
||||
mimeType: "image/png",
|
||||
data: "ZmFrZQ==",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(sawImage).toBe(true);
|
||||
});
|
||||
|
||||
it("expands skill commands before sending the prompt", async () => {
|
||||
const tempDir = join(tmpdir(), `pi-skill-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
tempDirs.push(tempDir);
|
||||
const skillPath = join(tempDir, "test-skill.md");
|
||||
writeFileSync(skillPath, "# Test Skill\n\nUse the skill body.");
|
||||
|
||||
const resourceLoader = {
|
||||
...createTestResourceLoader(),
|
||||
getSkills: () => ({
|
||||
skills: [
|
||||
{
|
||||
name: "test",
|
||||
description: "Test skill",
|
||||
filePath: skillPath,
|
||||
disableModelInvocation: false,
|
||||
baseDir: tempDir,
|
||||
sourceInfo: createSyntheticSourceInfo(skillPath, {
|
||||
source: "local",
|
||||
scope: "project",
|
||||
origin: "top-level",
|
||||
baseDir: tempDir,
|
||||
}),
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
}),
|
||||
};
|
||||
const harness = await createHarness({ resourceLoader });
|
||||
harnesses.push(harness);
|
||||
let expandedPrompt = "";
|
||||
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
const user = context.messages.find((message) => message.role === "user");
|
||||
expandedPrompt = user ? getMessageText(user) : "";
|
||||
return fauxAssistantMessage("ok");
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("/skill:test explain this");
|
||||
|
||||
expect(expandedPrompt).toContain('<skill name="test" location="');
|
||||
expect(expandedPrompt).toContain("Use the skill body.");
|
||||
expect(expandedPrompt).toContain("explain this");
|
||||
});
|
||||
|
||||
it("expands prompt templates before sending the prompt", async () => {
|
||||
const template: PromptTemplate = {
|
||||
name: "review",
|
||||
description: "Review template",
|
||||
content: "Review this code: $1",
|
||||
filePath: "/virtual/review.md",
|
||||
sourceInfo: createSyntheticSourceInfo("/virtual/review.md", {
|
||||
source: "local",
|
||||
scope: "temporary",
|
||||
origin: "top-level",
|
||||
}),
|
||||
};
|
||||
const resourceLoader = {
|
||||
...createTestResourceLoader(),
|
||||
getPrompts: () => ({ prompts: [template], diagnostics: [] }),
|
||||
};
|
||||
const harness = await createHarness({ resourceLoader });
|
||||
harnesses.push(harness);
|
||||
let expandedPrompt = "";
|
||||
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
const user = context.messages.find((message) => message.role === "user");
|
||||
expandedPrompt = user ? getMessageText(user) : "";
|
||||
return fauxAssistantMessage("ok");
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("/review src/index.ts");
|
||||
|
||||
expect(expandedPrompt).toBe("Review this code: src/index.ts");
|
||||
});
|
||||
|
||||
it("dispatches extension commands without consuming a provider response", async () => {
|
||||
const commandRuns: string[] = [];
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.registerCommand("testcmd", {
|
||||
description: "Test command",
|
||||
handler: async (args) => {
|
||||
commandRuns.push(args);
|
||||
},
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("should stay queued")]);
|
||||
|
||||
await harness.session.prompt("/testcmd hello world");
|
||||
|
||||
expect(commandRuns).toEqual(["hello world"]);
|
||||
expect(harness.session.messages).toEqual([]);
|
||||
expect(harness.getPendingResponseCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("sendUserMessage while idle triggers a turn", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.setResponses([fauxAssistantMessage("response")]);
|
||||
|
||||
await harness.session.sendUserMessage("from extension");
|
||||
|
||||
expect(harness.session.messages.map((message) => message.role)).toEqual(["user", "assistant"]);
|
||||
expect(getMessageText(harness.session.messages[0]!)).toBe("from extension");
|
||||
});
|
||||
|
||||
it("throws when prompted during streaming without a streamingBehavior", async () => {
|
||||
let releaseToolExecution: (() => void) | undefined;
|
||||
const toolRelease = new Promise<void>((resolve) => {
|
||||
releaseToolExecution = resolve;
|
||||
});
|
||||
const waitTool: AgentTool = {
|
||||
name: "wait",
|
||||
label: "Wait",
|
||||
description: "Wait for release",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
await toolRelease;
|
||||
return {
|
||||
content: [{ type: "text", text: "released" }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({ tools: [waitTool] });
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
|
||||
const sawToolStart = new Promise<void>((resolve) => {
|
||||
const unsubscribe = harness.session.subscribe((event) => {
|
||||
if (event.type === "tool_execution_start") {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const promptPromise = harness.session.prompt("start");
|
||||
await sawToolStart;
|
||||
|
||||
await expect(harness.session.prompt("second")).rejects.toThrow(
|
||||
"Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.",
|
||||
);
|
||||
|
||||
releaseToolExecution?.();
|
||||
await promptPromise;
|
||||
});
|
||||
|
||||
it("throws when prompting without a model", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
harness.session.agent.state.model = undefined as unknown as Model<any>;
|
||||
|
||||
await expect(harness.session.prompt("hi")).rejects.toThrow("No model selected.");
|
||||
});
|
||||
|
||||
it("throws when prompting without configured auth", async () => {
|
||||
const harness = await createHarness({ withConfiguredAuth: false });
|
||||
harnesses.push(harness);
|
||||
|
||||
await expect(harness.session.prompt("hi")).rejects.toThrow(
|
||||
`No API key found for ${harness.getModel().provider}.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,32 +3,58 @@ import { fauxAssistantMessage, fauxToolCall } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createHarness, type Harness } from "./harness.js";
|
||||
import { createHarness, getAssistantTexts, getMessageText, getUserTexts, type Harness } from "./harness.js";
|
||||
|
||||
function getUserTexts(harness: Harness): string[] {
|
||||
return harness.session.messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => {
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
async function createWaitingHarness(
|
||||
options: {
|
||||
tools?: AgentTool[];
|
||||
extensionFactories?: Harness["session"]["extensionRunner"] extends never
|
||||
? never
|
||||
: Array<(pi: ExtensionAPI) => void>;
|
||||
} = {},
|
||||
): Promise<{
|
||||
harness: Harness;
|
||||
releaseToolExecution: () => void;
|
||||
promptPromise: Promise<void>;
|
||||
waitForToolStart: Promise<void>;
|
||||
}> {
|
||||
let releaseToolExecution: (() => void) | undefined;
|
||||
const toolRelease = new Promise<void>((resolve) => {
|
||||
releaseToolExecution = resolve;
|
||||
});
|
||||
const waitTool: AgentTool = {
|
||||
name: "wait",
|
||||
label: "Wait",
|
||||
description: "Wait for release",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
await toolRelease;
|
||||
return {
|
||||
content: [{ type: "text", text: "released" }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({
|
||||
tools: [waitTool, ...(options.tools ?? [])],
|
||||
extensionFactories: options.extensionFactories,
|
||||
});
|
||||
|
||||
const waitForToolStart = new Promise<void>((resolve) => {
|
||||
const unsubscribe = harness.session.subscribe((event) => {
|
||||
if (event.type === "tool_execution_start" && event.toolName === "wait") {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
return content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function getAssistantTexts(harness: Harness): string[] {
|
||||
return harness.session.messages
|
||||
.filter((message) => message.role === "assistant")
|
||||
.map((message) =>
|
||||
message.content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n"),
|
||||
);
|
||||
return {
|
||||
harness,
|
||||
releaseToolExecution: () => releaseToolExecution?.(),
|
||||
promptPromise: harness.session.prompt("start"),
|
||||
waitForToolStart,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentSession queue characterization", () => {
|
||||
@@ -65,128 +91,332 @@ describe("AgentSession queue characterization", () => {
|
||||
|
||||
it("delivers extension-origin steering messages before the next LLM call", async () => {
|
||||
let extensionApi: ExtensionAPI | undefined;
|
||||
let releaseToolExecution: (() => void) | undefined;
|
||||
const toolStarted = new Promise<void>((resolve) => {
|
||||
releaseToolExecution = resolve;
|
||||
});
|
||||
const waitTool: AgentTool = {
|
||||
name: "wait",
|
||||
label: "Wait",
|
||||
description: "Wait for the test to release execution",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
await toolStarted;
|
||||
return {
|
||||
content: [{ type: "text", text: "released" }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({
|
||||
tools: [waitTool],
|
||||
const waiting = await createWaitingHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
extensionApi = pi;
|
||||
},
|
||||
],
|
||||
});
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
(context) => {
|
||||
const sawSteer = context.messages.some(
|
||||
(message) =>
|
||||
message.role === "user" &&
|
||||
typeof message.content !== "string" &&
|
||||
message.content.some((part) => part.type === "text" && part.text === "steer now"),
|
||||
(message) => message.role === "user" && getMessageText(message) === "steer now",
|
||||
);
|
||||
return fauxAssistantMessage(sawSteer ? "saw steer" : "missing steer");
|
||||
},
|
||||
]);
|
||||
|
||||
const sawToolStart = new Promise<void>((resolve) => {
|
||||
const unsubscribe = harness.session.subscribe((event) => {
|
||||
if (event.type === "tool_execution_start" && event.toolName === "wait") {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const promptPromise = harness.session.prompt("start");
|
||||
await sawToolStart;
|
||||
await waitForToolStart;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
extensionApi?.sendUserMessage("steer now", { deliverAs: "steer" });
|
||||
releaseToolExecution?.();
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(getUserTexts(harness)).toEqual(["start", "steer now"]);
|
||||
expect(getAssistantTexts(harness)).toContain("saw steer");
|
||||
});
|
||||
|
||||
it("treats queued slash-command follow-ups as raw user text instead of dispatching the command", async () => {
|
||||
let extensionApi: ExtensionAPI | undefined;
|
||||
const commandRuns: string[] = [];
|
||||
let releaseToolExecution: (() => void) | undefined;
|
||||
const toolRelease = new Promise<void>((resolve) => {
|
||||
releaseToolExecution = resolve;
|
||||
});
|
||||
const waitTool: AgentTool = {
|
||||
name: "wait",
|
||||
label: "Wait",
|
||||
description: "Wait for the test to release execution",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
await toolRelease;
|
||||
return {
|
||||
content: [{ type: "text", text: "released" }],
|
||||
details: {},
|
||||
};
|
||||
it("delivers follow-up messages only after the current run finishes", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
const assistantSeenBeforeFollowUp: string[] = [];
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
(context) => {
|
||||
assistantSeenBeforeFollowUp.push(
|
||||
...context.messages
|
||||
.filter((message) => message.role === "assistant")
|
||||
.map((message) =>
|
||||
message.content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n"),
|
||||
),
|
||||
);
|
||||
return fauxAssistantMessage("follow-up response");
|
||||
},
|
||||
};
|
||||
]);
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.followUp("after current run");
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(getUserTexts(harness)).toEqual(["start", "after current run"]);
|
||||
expect(assistantSeenBeforeFollowUp).toContain("");
|
||||
expect(getAssistantTexts(harness)).toContain("follow-up response");
|
||||
});
|
||||
|
||||
it("delivers multiple steering messages in order in one-at-a-time mode", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("handled steer 1"),
|
||||
fauxAssistantMessage("handled steer 2"),
|
||||
]);
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.steer("steer 1");
|
||||
await harness.session.steer("steer 2");
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(getUserTexts(harness)).toEqual(["start", "steer 1", "steer 2"]);
|
||||
expect(getAssistantTexts(harness)).toEqual(["", "handled steer 1", "handled steer 2"]);
|
||||
});
|
||||
|
||||
it("delivers multiple follow-up messages in order in one-at-a-time mode", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("original turn complete"),
|
||||
fauxAssistantMessage("handled follow-up 1"),
|
||||
fauxAssistantMessage("handled follow-up 2"),
|
||||
]);
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.followUp("follow-up 1");
|
||||
await harness.session.followUp("follow-up 2");
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(getUserTexts(harness)).toEqual(["start", "follow-up 1", "follow-up 2"]);
|
||||
expect(getAssistantTexts(harness)).toEqual([
|
||||
"",
|
||||
"original turn complete",
|
||||
"handled follow-up 1",
|
||||
"handled follow-up 2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("delivers all steering messages in one batch in all mode", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
harness.session.setSteeringMode("all");
|
||||
let batchedUserMessages: string[] = [];
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
(context) => {
|
||||
batchedUserMessages = context.messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => getMessageText(message));
|
||||
return fauxAssistantMessage("batched steer response");
|
||||
},
|
||||
]);
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.steer("steer 1");
|
||||
await harness.session.steer("steer 2");
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(batchedUserMessages).toEqual(["start", "steer 1", "steer 2"]);
|
||||
expect(getAssistantTexts(harness)).toEqual(["", "batched steer response"]);
|
||||
});
|
||||
|
||||
it("delivers all follow-up messages in one batch in all mode", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
harness.session.setFollowUpMode("all");
|
||||
let batchedUserMessages: string[] = [];
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("original turn complete"),
|
||||
(context) => {
|
||||
batchedUserMessages = context.messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => getMessageText(message));
|
||||
return fauxAssistantMessage("batched follow-up response");
|
||||
},
|
||||
]);
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.followUp("follow-up 1");
|
||||
await harness.session.followUp("follow-up 2");
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(batchedUserMessages).toEqual(["start", "follow-up 1", "follow-up 2"]);
|
||||
expect(getAssistantTexts(harness)).toEqual(["", "original turn complete", "batched follow-up response"]);
|
||||
});
|
||||
|
||||
it("queues custom messages with deliverAs steer while streaming", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
let sawCustomMessage = false;
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
(context) => {
|
||||
sawCustomMessage = context.messages.some(
|
||||
(message) =>
|
||||
message.role === "user" &&
|
||||
typeof message.content !== "string" &&
|
||||
message.content.some((part) => part.type === "text" && part.text === "steer custom"),
|
||||
);
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.sendCustomMessage(
|
||||
{ customType: "queue-test", content: "steer custom", display: true, details: { value: 1 } },
|
||||
{ deliverAs: "steer" },
|
||||
);
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(sawCustomMessage).toBe(true);
|
||||
expect(
|
||||
harness.session.messages.some((message) => message.role === "custom" && message.customType === "queue-test"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("queues custom messages with deliverAs followUp while streaming", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
let sawCustomMessage = false;
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("original turn complete"),
|
||||
(context) => {
|
||||
sawCustomMessage = context.messages.some(
|
||||
(message) =>
|
||||
message.role === "user" &&
|
||||
typeof message.content !== "string" &&
|
||||
message.content.some((part) => part.type === "text" && part.text === "follow-up custom"),
|
||||
);
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.sendCustomMessage(
|
||||
{ customType: "queue-test", content: "follow-up custom", display: true, details: { value: 1 } },
|
||||
{ deliverAs: "followUp" },
|
||||
);
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(sawCustomMessage).toBe(true);
|
||||
expect(
|
||||
harness.session.messages.some((message) => message.role === "custom" && message.customType === "queue-test"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("injects nextTurn custom messages into the next prompt", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
let sawCustomMessage = false;
|
||||
|
||||
await harness.session.sendCustomMessage(
|
||||
{ customType: "next-turn", content: "carry this", display: true, details: {} },
|
||||
{ deliverAs: "nextTurn" },
|
||||
);
|
||||
|
||||
harness.setResponses([
|
||||
(context) => {
|
||||
sawCustomMessage = context.messages.some(
|
||||
(message) =>
|
||||
message.role === "user" &&
|
||||
typeof message.content !== "string" &&
|
||||
message.content.some((part) => part.type === "text" && part.text === "carry this"),
|
||||
);
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("normal prompt");
|
||||
|
||||
expect(sawCustomMessage).toBe(true);
|
||||
expect(harness.session.messages.map((message) => message.role)).toEqual(["user", "custom", "assistant"]);
|
||||
});
|
||||
|
||||
it("updates pendingMessageCount and removes queued text before message_start is emitted", async () => {
|
||||
const waiting = await createWaitingHarness();
|
||||
const { harness, waitForToolStart, promptPromise, releaseToolExecution } = waiting;
|
||||
harnesses.push(harness);
|
||||
const countsAtQueuedMessageStart: number[] = [];
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
|
||||
harness.session.subscribe((event) => {
|
||||
if (
|
||||
event.type === "message_start" &&
|
||||
event.message.role === "user" &&
|
||||
getMessageText(event.message) === "queued"
|
||||
) {
|
||||
countsAtQueuedMessageStart.push(harness.session.pendingMessageCount);
|
||||
}
|
||||
});
|
||||
|
||||
await waitForToolStart;
|
||||
await harness.session.steer("queued");
|
||||
expect(harness.session.pendingMessageCount).toBe(1);
|
||||
releaseToolExecution();
|
||||
await promptPromise;
|
||||
|
||||
expect(countsAtQueuedMessageStart).toEqual([0]);
|
||||
expect(harness.session.pendingMessageCount).toBe(0);
|
||||
});
|
||||
|
||||
it("throws when queueing an extension command with steer", async () => {
|
||||
const harness = await createHarness({
|
||||
tools: [waitTool],
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
extensionApi = pi;
|
||||
pi.registerCommand("testcmd", {
|
||||
description: "Test command",
|
||||
handler: async (args) => {
|
||||
commandRuns.push(args);
|
||||
},
|
||||
handler: async () => {},
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("first turn complete"),
|
||||
fauxAssistantMessage("queued follow-up handled by model"),
|
||||
]);
|
||||
await expect(harness.session.steer("/testcmd queued")).rejects.toThrow(
|
||||
'Extension command "/testcmd" cannot be queued. Use prompt() or execute the command when not streaming.',
|
||||
);
|
||||
});
|
||||
|
||||
const sawToolStart = new Promise<void>((resolve) => {
|
||||
const unsubscribe = harness.session.subscribe((event) => {
|
||||
if (event.type === "tool_execution_start" && event.toolName === "wait") {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
it("throws when queueing an extension command with followUp", async () => {
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.registerCommand("testcmd", {
|
||||
description: "Test command",
|
||||
handler: async () => {},
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
const promptPromise = harness.session.prompt("start");
|
||||
await sawToolStart;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
extensionApi?.sendUserMessage("/testcmd queued", { deliverAs: "followUp" });
|
||||
releaseToolExecution?.();
|
||||
await promptPromise;
|
||||
|
||||
expect(commandRuns).toEqual([]);
|
||||
expect(getUserTexts(harness)).toEqual(["start", "/testcmd queued"]);
|
||||
expect(getAssistantTexts(harness)).toContain("queued follow-up handled by model");
|
||||
await expect(harness.session.followUp("/testcmd queued")).rejects.toThrow(
|
||||
'Extension command "/testcmd" cannot be queued. Use prompt() or execute the command when not streaming.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { fauxAssistantMessage, fauxThinking, fauxToolCall } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createHarness, type Harness } from "./harness.js";
|
||||
|
||||
function normalizeEventOrder(events: Harness["events"]): string[] {
|
||||
const normalized: string[] = [];
|
||||
for (const event of events) {
|
||||
const label =
|
||||
event.type === "message_start" || event.type === "message_end"
|
||||
? `${event.type}:${event.message.role}`
|
||||
: event.type === "tool_execution_start" || event.type === "tool_execution_end"
|
||||
? `${event.type}:${event.toolName}`
|
||||
: event.type;
|
||||
if (label === "message_update" && normalized[normalized.length - 1] === "message_update") {
|
||||
continue;
|
||||
}
|
||||
normalized.push(label);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
describe("AgentSession retry and event characterization", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("retries after a transient error and succeeds", async () => {
|
||||
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } } });
|
||||
harnesses.push(harness);
|
||||
const retryEvents: string[] = [];
|
||||
harness.session.subscribe((event) => {
|
||||
if (event.type === "auto_retry_start") retryEvents.push(`start:${event.attempt}`);
|
||||
if (event.type === "auto_retry_end") retryEvents.push(`end:${event.success}`);
|
||||
});
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
fauxAssistantMessage("recovered"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("test");
|
||||
|
||||
expect(retryEvents).toEqual(["start:1", "end:true"]);
|
||||
expect(harness.faux.state.callCount).toBe(2);
|
||||
expect(harness.session.isRetrying).toBe(false);
|
||||
});
|
||||
|
||||
it("retries multiple transient failures and succeeds on the final attempt", async () => {
|
||||
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } } });
|
||||
harnesses.push(harness);
|
||||
const retryEvents: string[] = [];
|
||||
harness.session.subscribe((event) => {
|
||||
if (event.type === "auto_retry_start") retryEvents.push(`start:${event.attempt}`);
|
||||
if (event.type === "auto_retry_end") retryEvents.push(`end:${event.success}`);
|
||||
});
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
fauxAssistantMessage("success"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("test");
|
||||
|
||||
expect(retryEvents).toEqual(["start:1", "start:2", "end:true"]);
|
||||
expect(harness.faux.state.callCount).toBe(3);
|
||||
});
|
||||
|
||||
it("exhausts max retries and emits a failure event", async () => {
|
||||
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 2, baseDelayMs: 1 } } });
|
||||
harnesses.push(harness);
|
||||
const retryEvents: string[] = [];
|
||||
harness.session.subscribe((event) => {
|
||||
if (event.type === "auto_retry_start") retryEvents.push(`start:${event.attempt}`);
|
||||
if (event.type === "auto_retry_end") retryEvents.push(`end:${event.success}`);
|
||||
});
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("test");
|
||||
|
||||
expect(retryEvents).toEqual(["start:1", "start:2", "end:false"]);
|
||||
expect(harness.faux.state.callCount).toBe(3);
|
||||
expect(harness.session.isRetrying).toBe(false);
|
||||
});
|
||||
|
||||
it("prompt waits for retry completion even when assistant message_end handling is delayed", async () => {
|
||||
const harness = await createHarness({
|
||||
settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } },
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("message_end", async (event) => {
|
||||
if (event.message.role === "assistant") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
fauxAssistantMessage("recovered"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("test");
|
||||
|
||||
expect(harness.faux.state.callCount).toBe(2);
|
||||
expect(harness.session.isRetrying).toBe(false);
|
||||
});
|
||||
|
||||
it("does not retry when retry is disabled", async () => {
|
||||
const harness = await createHarness({ settings: { retry: { enabled: false } } });
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" })]);
|
||||
|
||||
await harness.session.prompt("test");
|
||||
|
||||
expect(harness.faux.state.callCount).toBe(1);
|
||||
expect(harness.eventsOfType("auto_retry_start")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not retry non-retryable errors", async () => {
|
||||
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } } });
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "invalid_api_key" })]);
|
||||
|
||||
await harness.session.prompt("test");
|
||||
|
||||
expect(harness.faux.state.callCount).toBe(1);
|
||||
expect(harness.eventsOfType("auto_retry_start")).toEqual([]);
|
||||
});
|
||||
|
||||
it("cancels retry sleep when abortRetry is called", async () => {
|
||||
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 100 } } });
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" })]);
|
||||
|
||||
const sawRetryStart = new Promise<void>((resolve) => {
|
||||
const unsubscribe = harness.session.subscribe((event) => {
|
||||
if (event.type === "auto_retry_start") {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const promptPromise = harness.session.prompt("test");
|
||||
await sawRetryStart;
|
||||
harness.session.abortRetry();
|
||||
await promptPromise;
|
||||
|
||||
expect(harness.session.isRetrying).toBe(false);
|
||||
expect(harness.eventsOfType("auto_retry_end").map((event) => event.finalError)).toContain("Retry cancelled");
|
||||
expect(harness.faux.state.callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("waits for the full loop when retry recovery produces tool calls", async () => {
|
||||
const toolRuns: string[] = [];
|
||||
const echoTool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo text back",
|
||||
parameters: Type.Object({ text: Type.String() }),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : "";
|
||||
toolRuns.push(text);
|
||||
return { content: [{ type: "text", text: `echo:${text}` }], details: { text } };
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({
|
||||
tools: [echoTool],
|
||||
settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } },
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" }),
|
||||
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("final answer"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("test");
|
||||
|
||||
expect(harness.faux.state.callCount).toBe(3);
|
||||
expect(toolRuns).toEqual(["hello"]);
|
||||
expect(harness.session.isStreaming).toBe(false);
|
||||
await harness.session.prompt("follow-up");
|
||||
expect(harness.faux.state.callCount).toBe(4);
|
||||
});
|
||||
|
||||
it("emits extension events before public event subscribers", async () => {
|
||||
const order: string[] = [];
|
||||
const harness = await createHarness({
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.on("message_start", async (event) => {
|
||||
order.push(`extension:${event.type}:${event.message.role}`);
|
||||
});
|
||||
pi.on("message_end", async (event) => {
|
||||
order.push(`extension:${event.type}:${event.message.role}`);
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
harness.session.subscribe((event) => {
|
||||
if (event.type === "message_start" || event.type === "message_end") {
|
||||
order.push(`public:${event.type}:${event.message.role}`);
|
||||
}
|
||||
});
|
||||
harness.setResponses([fauxAssistantMessage("done")]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(order).toEqual([
|
||||
"extension:message_start:user",
|
||||
"public:message_start:user",
|
||||
"extension:message_end:user",
|
||||
"public:message_end:user",
|
||||
"extension:message_start:assistant",
|
||||
"public:message_start:assistant",
|
||||
"extension:message_end:assistant",
|
||||
"public:message_end:assistant",
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits the expected event order for a single prompt", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("hello")]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(normalizeEventOrder(harness.events)).toEqual([
|
||||
"agent_start",
|
||||
"turn_start",
|
||||
"message_start:user",
|
||||
"message_end:user",
|
||||
"message_start:assistant",
|
||||
"message_update",
|
||||
"message_end:assistant",
|
||||
"turn_end",
|
||||
"agent_end",
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits the expected event order for a tool call turn", async () => {
|
||||
const toolRuns: string[] = [];
|
||||
const echoTool: AgentTool = {
|
||||
name: "echo",
|
||||
label: "Echo",
|
||||
description: "Echo text back",
|
||||
parameters: Type.Object({ text: Type.String() }),
|
||||
execute: async (_toolCallId, params) => {
|
||||
const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : "";
|
||||
toolRuns.push(text);
|
||||
return { content: [{ type: "text", text: `echo:${text}` }], details: { text } };
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({ tools: [echoTool] });
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage([fauxToolCall("echo", { text: "hello" })], { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("done"),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(toolRuns).toEqual(["hello"]);
|
||||
expect(normalizeEventOrder(harness.events)).toEqual([
|
||||
"agent_start",
|
||||
"turn_start",
|
||||
"message_start:user",
|
||||
"message_end:user",
|
||||
"message_start:assistant",
|
||||
"message_update",
|
||||
"message_end:assistant",
|
||||
"tool_execution_start:echo",
|
||||
"tool_execution_end:echo",
|
||||
"message_start:toolResult",
|
||||
"message_end:toolResult",
|
||||
"turn_end",
|
||||
"turn_start",
|
||||
"message_start:assistant",
|
||||
"message_update",
|
||||
"message_end:assistant",
|
||||
"turn_end",
|
||||
"agent_end",
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits streaming deltas for text, thinking, and tool calls in message_update events", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(
|
||||
[fauxThinking("plan"), { type: "text", text: "answer" }, fauxToolCall("echo", { text: "hello" })],
|
||||
{
|
||||
stopReason: "toolUse",
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
await harness.session.prompt("hi").catch(() => {});
|
||||
|
||||
const updateTypes = harness.eventsOfType("message_update").map((event) => event.assistantMessageEvent.type);
|
||||
expect(updateTypes).toContain("thinking_delta");
|
||||
expect(updateTypes).toContain("text_delta");
|
||||
expect(updateTypes).toContain("toolcall_delta");
|
||||
});
|
||||
|
||||
it("emits agent_end for error responses", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("", { stopReason: "error", errorMessage: "broken" })]);
|
||||
|
||||
await harness.session.prompt("hi");
|
||||
|
||||
expect(harness.events[harness.events.length - 1]?.type).toBe("agent_end");
|
||||
});
|
||||
|
||||
it("emits agent_end for aborted runs and persists the aborted assistant message", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
harness.setResponses([fauxAssistantMessage("x".repeat(20_000))]);
|
||||
|
||||
const sawMessageUpdate = new Promise<void>((resolve) => {
|
||||
const unsubscribe = harness.session.subscribe((event) => {
|
||||
if (event.type === "message_update") {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const promptPromise = harness.session.prompt("hi");
|
||||
await sawMessageUpdate;
|
||||
await harness.session.abort();
|
||||
await promptPromise;
|
||||
|
||||
expect(harness.events[harness.events.length - 1]?.type).toBe("agent_end");
|
||||
const lastMessage = harness.session.messages[harness.session.messages.length - 1];
|
||||
expect(lastMessage?.role).toBe("assistant");
|
||||
if (lastMessage?.role === "assistant") {
|
||||
expect(lastMessage.stopReason).toBe("aborted");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import { existsSync, mkdirSync, realpathSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fauxAssistantMessage, registerFauxProvider } from "@mariozechner/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type AgentSessionRuntimeBootstrap,
|
||||
AgentSessionRuntimeHost,
|
||||
createAgentSessionRuntime,
|
||||
} from "../../src/core/agent-session-runtime.js";
|
||||
import { AuthStorage } from "../../src/core/auth-storage.js";
|
||||
import { SessionManager } from "../../src/core/session-manager.js";
|
||||
import type {
|
||||
ExtensionFactory,
|
||||
SessionBeforeForkEvent,
|
||||
SessionBeforeSwitchEvent,
|
||||
SessionStartEvent,
|
||||
} from "../../src/index.js";
|
||||
|
||||
type RecordedSessionEvent = SessionBeforeSwitchEvent | SessionBeforeForkEvent | SessionStartEvent;
|
||||
|
||||
describe("AgentSessionRuntimeHost characterization", () => {
|
||||
const cleanups: Array<() => Promise<void> | void> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) {
|
||||
await cleanups.pop()?.();
|
||||
}
|
||||
process.chdir(tmpdir());
|
||||
});
|
||||
|
||||
async function createRuntimeHost(
|
||||
extensionFactory: ExtensionFactory,
|
||||
options?: { cwd?: string; bootstrapModel?: boolean; bootstrapThinkingLevel?: boolean },
|
||||
) {
|
||||
const tempDir =
|
||||
options?.cwd ?? join(tmpdir(), `pi-runtime-suite-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const faux = registerFauxProvider({
|
||||
models: [
|
||||
{ id: "faux-1", reasoning: true },
|
||||
{ id: "faux-2", reasoning: false },
|
||||
],
|
||||
});
|
||||
faux.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two"), fauxAssistantMessage("three")]);
|
||||
|
||||
const authStorage = AuthStorage.inMemory();
|
||||
authStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
|
||||
|
||||
const bootstrap: AgentSessionRuntimeBootstrap = {
|
||||
agentDir: tempDir,
|
||||
authStorage,
|
||||
model: options?.bootstrapModel === false ? undefined : faux.getModel(),
|
||||
thinkingLevel: options?.bootstrapThinkingLevel === false ? undefined : undefined,
|
||||
resourceLoader: {
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.registerProvider(faux.getModel().provider, {
|
||||
baseUrl: faux.getModel().baseUrl,
|
||||
apiKey: "faux-key",
|
||||
api: faux.api,
|
||||
models: faux.models.map((registeredModel) => ({
|
||||
id: registeredModel.id,
|
||||
name: registeredModel.name,
|
||||
api: registeredModel.api,
|
||||
reasoning: registeredModel.reasoning,
|
||||
input: registeredModel.input,
|
||||
cost: registeredModel.cost,
|
||||
contextWindow: registeredModel.contextWindow,
|
||||
maxTokens: registeredModel.maxTokens,
|
||||
})),
|
||||
});
|
||||
extensionFactory(pi);
|
||||
},
|
||||
],
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
},
|
||||
};
|
||||
const runtime = await createAgentSessionRuntime(bootstrap, {
|
||||
cwd: tempDir,
|
||||
sessionManager: SessionManager.create(tempDir),
|
||||
});
|
||||
const runtimeHost = new AgentSessionRuntimeHost(bootstrap, runtime);
|
||||
await runtimeHost.session.bindExtensions({});
|
||||
|
||||
cleanups.push(async () => {
|
||||
await runtimeHost.dispose();
|
||||
faux.unregister();
|
||||
if (existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
return { runtimeHost, faux, tempDir };
|
||||
}
|
||||
|
||||
it("emits session_before_switch and session_start for new and resume flows", async () => {
|
||||
const events: RecordedSessionEvent[] = [];
|
||||
const { runtimeHost } = await createRuntimeHost((pi) => {
|
||||
pi.on("session_before_switch", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
pi.on("session_start", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
});
|
||||
|
||||
expect(events).toEqual([{ type: "session_start", reason: "startup" }]);
|
||||
events.length = 0;
|
||||
|
||||
await runtimeHost.session.prompt("hello");
|
||||
const originalSessionFile = runtimeHost.session.sessionFile;
|
||||
const originalSession = runtimeHost.session;
|
||||
|
||||
const newSessionResult = await runtimeHost.newSession();
|
||||
expect(newSessionResult.cancelled).toBe(false);
|
||||
await runtimeHost.session.bindExtensions({});
|
||||
expect(runtimeHost.session).not.toBe(originalSession);
|
||||
expect(runtimeHost.session.messages).toEqual([]);
|
||||
expect(events).toEqual([
|
||||
{ type: "session_before_switch", reason: "new", targetSessionFile: undefined },
|
||||
{ type: "session_start", reason: "new", previousSessionFile: originalSessionFile },
|
||||
]);
|
||||
|
||||
events.length = 0;
|
||||
const secondSessionFile = runtimeHost.session.sessionFile;
|
||||
|
||||
const switchResult = await runtimeHost.switchSession(originalSessionFile!);
|
||||
expect(switchResult.cancelled).toBe(false);
|
||||
await runtimeHost.session.bindExtensions({});
|
||||
expect(events).toEqual([
|
||||
{ type: "session_before_switch", reason: "resume", targetSessionFile: originalSessionFile },
|
||||
{ type: "session_start", reason: "resume", previousSessionFile: secondSessionFile },
|
||||
]);
|
||||
});
|
||||
|
||||
it("honors session_before_switch cancellation for new and resume", async () => {
|
||||
const events: RecordedSessionEvent[] = [];
|
||||
let cancelReason: "new" | "resume" | undefined;
|
||||
const { runtimeHost } = await createRuntimeHost((pi) => {
|
||||
pi.on("session_before_switch", (event) => {
|
||||
events.push(event);
|
||||
if (event.reason === cancelReason) {
|
||||
return { cancel: true };
|
||||
}
|
||||
});
|
||||
pi.on("session_start", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
});
|
||||
|
||||
await runtimeHost.session.prompt("hello");
|
||||
const originalSessionFile = runtimeHost.session.sessionFile;
|
||||
|
||||
cancelReason = "new";
|
||||
const newResult = await runtimeHost.newSession();
|
||||
expect(newResult.cancelled).toBe(true);
|
||||
expect(runtimeHost.session.sessionFile).toBe(originalSessionFile);
|
||||
|
||||
events.length = 0;
|
||||
const otherDir = join(tmpdir(), `pi-runtime-other-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(otherDir, { recursive: true });
|
||||
const otherSession = SessionManager.create(otherDir);
|
||||
otherSession.appendMessage({ role: "user", content: [{ type: "text", text: "other" }], timestamp: Date.now() });
|
||||
const otherSessionFile = otherSession.getSessionFile();
|
||||
cancelReason = "resume";
|
||||
const resumeResult = await runtimeHost.switchSession(otherSessionFile!);
|
||||
expect(resumeResult.cancelled).toBe(true);
|
||||
expect(runtimeHost.session.sessionFile).toBe(originalSessionFile);
|
||||
});
|
||||
|
||||
it("emits session_before_fork and session_start and honors cancellation", async () => {
|
||||
const events: RecordedSessionEvent[] = [];
|
||||
let cancelNextFork = false;
|
||||
const { runtimeHost } = await createRuntimeHost((pi) => {
|
||||
pi.on("session_before_fork", (event) => {
|
||||
events.push(event);
|
||||
if (cancelNextFork) {
|
||||
cancelNextFork = false;
|
||||
return { cancel: true };
|
||||
}
|
||||
});
|
||||
pi.on("session_start", (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
});
|
||||
|
||||
events.length = 0;
|
||||
await runtimeHost.session.prompt("hello");
|
||||
const userMessage = runtimeHost.session.getUserMessagesForForking()[0]!;
|
||||
const previousSessionFile = runtimeHost.session.sessionFile;
|
||||
|
||||
const successResult = await runtimeHost.fork(userMessage.entryId);
|
||||
expect(successResult.cancelled).toBe(false);
|
||||
expect(successResult.selectedText).toBe("hello");
|
||||
await runtimeHost.session.bindExtensions({});
|
||||
expect(events).toEqual([
|
||||
{ type: "session_before_fork", entryId: userMessage.entryId },
|
||||
{ type: "session_start", reason: "fork", previousSessionFile },
|
||||
]);
|
||||
|
||||
events.length = 0;
|
||||
cancelNextFork = true;
|
||||
const cancelResult = await runtimeHost.fork(userMessage.entryId);
|
||||
expect(cancelResult).toEqual({ cancelled: true });
|
||||
expect(events).toEqual([{ type: "session_before_fork", entryId: userMessage.entryId }]);
|
||||
});
|
||||
|
||||
it("throws when forking with an invalid entry id", async () => {
|
||||
const { runtimeHost } = await createRuntimeHost(() => {});
|
||||
await expect(runtimeHost.fork("missing-entry")).rejects.toThrow("Invalid entry ID for forking");
|
||||
});
|
||||
|
||||
it("updates process.cwd() on cross-cwd session replacement", async () => {
|
||||
const firstDir = join(tmpdir(), `pi-runtime-cwd-a-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
const secondDir = join(tmpdir(), `pi-runtime-cwd-b-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(firstDir, { recursive: true });
|
||||
mkdirSync(secondDir, { recursive: true });
|
||||
const { runtimeHost, faux, tempDir } = await createRuntimeHost(() => {}, { cwd: firstDir });
|
||||
const otherAuthStorage = AuthStorage.inMemory();
|
||||
otherAuthStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
|
||||
const otherRuntime = await createAgentSessionRuntime(
|
||||
{
|
||||
agentDir: tempDir,
|
||||
authStorage: otherAuthStorage,
|
||||
resourceLoader: {
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.registerProvider(faux.getModel().provider, {
|
||||
baseUrl: faux.getModel().baseUrl,
|
||||
apiKey: "faux-key",
|
||||
api: faux.api,
|
||||
models: faux.models.map((registeredModel) => ({
|
||||
id: registeredModel.id,
|
||||
name: registeredModel.name,
|
||||
api: registeredModel.api,
|
||||
reasoning: registeredModel.reasoning,
|
||||
input: registeredModel.input,
|
||||
cost: registeredModel.cost,
|
||||
contextWindow: registeredModel.contextWindow,
|
||||
maxTokens: registeredModel.maxTokens,
|
||||
})),
|
||||
});
|
||||
},
|
||||
],
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
},
|
||||
},
|
||||
{ cwd: secondDir, sessionManager: SessionManager.create(secondDir) },
|
||||
);
|
||||
cleanups.push(async () => {
|
||||
otherRuntime.session.dispose();
|
||||
});
|
||||
await otherRuntime.session.prompt("other");
|
||||
const otherSessionFile = otherRuntime.session.sessionFile!;
|
||||
|
||||
await runtimeHost.switchSession(otherSessionFile);
|
||||
|
||||
expect(realpathSync(process.cwd())).toBe(realpathSync(secondDir));
|
||||
expect(realpathSync(runtimeHost.session.sessionManager.getCwd())).toBe(realpathSync(secondDir));
|
||||
});
|
||||
|
||||
it("restores model and thinking state from the destination session", async () => {
|
||||
const { runtimeHost, faux, tempDir } = await createRuntimeHost(() => {}, {
|
||||
bootstrapModel: false,
|
||||
bootstrapThinkingLevel: false,
|
||||
});
|
||||
const otherDir = join(tempDir, "other");
|
||||
mkdirSync(otherDir, { recursive: true });
|
||||
const otherAuthStorage = AuthStorage.inMemory();
|
||||
otherAuthStorage.setRuntimeApiKey(faux.getModel().provider, "faux-key");
|
||||
const otherRuntime = await createAgentSessionRuntime(
|
||||
{
|
||||
agentDir: tempDir,
|
||||
authStorage: otherAuthStorage,
|
||||
resourceLoader: {
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
pi.registerProvider(faux.getModel().provider, {
|
||||
baseUrl: faux.getModel().baseUrl,
|
||||
apiKey: "faux-key",
|
||||
api: faux.api,
|
||||
models: faux.models.map((registeredModel) => ({
|
||||
id: registeredModel.id,
|
||||
name: registeredModel.name,
|
||||
api: registeredModel.api,
|
||||
reasoning: registeredModel.reasoning,
|
||||
input: registeredModel.input,
|
||||
cost: registeredModel.cost,
|
||||
contextWindow: registeredModel.contextWindow,
|
||||
maxTokens: registeredModel.maxTokens,
|
||||
})),
|
||||
});
|
||||
},
|
||||
],
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
},
|
||||
},
|
||||
{ cwd: otherDir, sessionManager: SessionManager.create(otherDir) },
|
||||
);
|
||||
cleanups.push(async () => {
|
||||
otherRuntime.session.dispose();
|
||||
});
|
||||
await otherRuntime.session.setModel(faux.getModel("faux-2")!);
|
||||
otherRuntime.session.setThinkingLevel("off");
|
||||
await otherRuntime.session.prompt("hello");
|
||||
const targetSessionFile = otherRuntime.session.sessionFile!;
|
||||
|
||||
await runtimeHost.switchSession(targetSessionFile);
|
||||
|
||||
expect(runtimeHost.session.model?.id).toBe("faux-2");
|
||||
expect(runtimeHost.session.thinkingLevel).toBe("off");
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,14 @@
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Agent } from "@mariozechner/pi-agent-core";
|
||||
import type { FauxModelDefinition, FauxProviderRegistration, FauxResponseStep, Model } from "@mariozechner/pi-ai";
|
||||
import { registerFauxProvider } from "@mariozechner/pi-ai";
|
||||
import { AgentSession, type AgentSessionEvent } from "../../src/core/agent-session.js";
|
||||
import { AuthStorage } from "../../src/core/auth-storage.js";
|
||||
import type { ExtensionRunner } from "../../src/core/extensions/index.js";
|
||||
import { convertToLlm } from "../../src/core/messages.js";
|
||||
import { ModelRegistry } from "../../src/core/model-registry.js";
|
||||
import { SessionManager } from "../../src/core/session-manager.js";
|
||||
import type { Settings } from "../../src/core/settings-manager.js";
|
||||
@@ -22,6 +24,37 @@ import {
|
||||
createTestResourceLoader,
|
||||
} from "../utilities.js";
|
||||
|
||||
type MessageTextPart = { type: "text"; text: string };
|
||||
|
||||
export function getMessageText(message: unknown): string {
|
||||
if (!message || typeof message !== "object" || !("content" in message)) {
|
||||
return "";
|
||||
}
|
||||
const content = (message as { content?: string | Array<{ type: string; text?: string }> }).content;
|
||||
if (content === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
return content
|
||||
.filter((part): part is MessageTextPart => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function getUserTexts(harness: Harness): string[] {
|
||||
return harness.session.messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => getMessageText(message));
|
||||
}
|
||||
|
||||
export function getAssistantTexts(harness: Harness): string[] {
|
||||
return harness.session.messages
|
||||
.filter((message) => message.role === "assistant")
|
||||
.map((message) => getMessageText(message));
|
||||
}
|
||||
|
||||
export interface HarnessOptions {
|
||||
models?: FauxModelDefinition[];
|
||||
settings?: Partial<Settings>;
|
||||
@@ -29,12 +62,14 @@ export interface HarnessOptions {
|
||||
tools?: AgentTool[];
|
||||
resourceLoader?: ResourceLoader;
|
||||
extensionFactories?: Array<ExtensionFactory | CreateTestExtensionsResultInput>;
|
||||
withConfiguredAuth?: boolean;
|
||||
}
|
||||
|
||||
export interface Harness {
|
||||
session: AgentSession;
|
||||
sessionManager: SessionManager;
|
||||
settingsManager: SettingsManager;
|
||||
authStorage: AuthStorage;
|
||||
faux: FauxProviderRegistration;
|
||||
models: [Model<string>, ...Model<string>[]];
|
||||
getModel(): Model<string>;
|
||||
@@ -62,37 +97,56 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
|
||||
fauxProvider.setResponses([]);
|
||||
const model = fauxProvider.getModel();
|
||||
const toolMap = options.tools ? Object.fromEntries(options.tools.map((tool) => [tool.name, tool])) : undefined;
|
||||
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "faux-key",
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: options.systemPrompt ?? "You are a test assistant.",
|
||||
tools: [],
|
||||
},
|
||||
});
|
||||
const withConfiguredAuth = options.withConfiguredAuth ?? true;
|
||||
const extensionRunnerRef: { current?: ExtensionRunner } = {};
|
||||
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.inMemory(options.settings);
|
||||
|
||||
const authStorage = AuthStorage.inMemory();
|
||||
authStorage.setRuntimeApiKey(model.provider, "faux-key");
|
||||
if (withConfiguredAuth) {
|
||||
authStorage.setRuntimeApiKey(model.provider, "faux-key");
|
||||
}
|
||||
const modelRegistry = ModelRegistry.inMemory(authStorage);
|
||||
modelRegistry.registerProvider(model.provider, {
|
||||
baseUrl: model.baseUrl,
|
||||
apiKey: "faux-key",
|
||||
api: fauxProvider.api,
|
||||
models: fauxProvider.models.map((registeredModel) => ({
|
||||
id: registeredModel.id,
|
||||
name: registeredModel.name,
|
||||
api: registeredModel.api,
|
||||
reasoning: registeredModel.reasoning,
|
||||
input: registeredModel.input,
|
||||
cost: registeredModel.cost,
|
||||
contextWindow: registeredModel.contextWindow,
|
||||
maxTokens: registeredModel.maxTokens,
|
||||
baseUrl: registeredModel.baseUrl,
|
||||
})),
|
||||
if (withConfiguredAuth) {
|
||||
modelRegistry.registerProvider(model.provider, {
|
||||
baseUrl: model.baseUrl,
|
||||
apiKey: "faux-key",
|
||||
api: fauxProvider.api,
|
||||
models: fauxProvider.models.map((registeredModel) => ({
|
||||
id: registeredModel.id,
|
||||
name: registeredModel.name,
|
||||
api: registeredModel.api,
|
||||
reasoning: registeredModel.reasoning,
|
||||
input: registeredModel.input,
|
||||
cost: registeredModel.cost,
|
||||
contextWindow: registeredModel.contextWindow,
|
||||
maxTokens: registeredModel.maxTokens,
|
||||
baseUrl: registeredModel.baseUrl,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const agent = new Agent({
|
||||
getApiKey: () => (withConfiguredAuth ? "faux-key" : undefined),
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: options.systemPrompt ?? "You are a test assistant.",
|
||||
tools: [],
|
||||
},
|
||||
convertToLlm,
|
||||
onPayload: async (payload) => {
|
||||
const runner = extensionRunnerRef.current;
|
||||
if (!runner?.hasHandlers("before_provider_request")) {
|
||||
return payload;
|
||||
}
|
||||
return runner.emitBeforeProviderRequest(payload);
|
||||
},
|
||||
transformContext: async (messages: AgentMessage[]) => {
|
||||
const runner = extensionRunnerRef.current;
|
||||
if (!runner) return messages;
|
||||
return runner.emitContext(messages);
|
||||
},
|
||||
});
|
||||
const extensionsResult = options.extensionFactories
|
||||
? await createTestExtensionsResult(options.extensionFactories, tempDir)
|
||||
@@ -108,6 +162,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
|
||||
modelRegistry,
|
||||
resourceLoader,
|
||||
baseToolsOverride: toolMap,
|
||||
extensionRunnerRef,
|
||||
});
|
||||
|
||||
const events: AgentSessionEvent[] = [];
|
||||
@@ -119,6 +174,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
|
||||
session,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
authStorage,
|
||||
faux: fauxProvider,
|
||||
models: fauxProvider.models,
|
||||
getModel: fauxProvider.getModel,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { fauxAssistantMessage, fauxToolCall } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createHarness, getAssistantTexts, getUserTexts, type Harness } from "../harness.js";
|
||||
|
||||
describe("issue #2023 queued slash-command follow-up", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats extension-origin queued slash-command follow-ups as raw user text instead of dispatching the command", async () => {
|
||||
let extensionApi: ExtensionAPI | undefined;
|
||||
const commandRuns: string[] = [];
|
||||
let releaseToolExecution: (() => void) | undefined;
|
||||
const toolRelease = new Promise<void>((resolve) => {
|
||||
releaseToolExecution = resolve;
|
||||
});
|
||||
const waitTool: AgentTool = {
|
||||
name: "wait",
|
||||
label: "Wait",
|
||||
description: "Wait for the test to release execution",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
await toolRelease;
|
||||
return {
|
||||
content: [{ type: "text", text: "released" }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
const harness = await createHarness({
|
||||
tools: [waitTool],
|
||||
extensionFactories: [
|
||||
(pi) => {
|
||||
extensionApi = pi;
|
||||
pi.registerCommand("testcmd", {
|
||||
description: "Test command",
|
||||
handler: async (args) => {
|
||||
commandRuns.push(args);
|
||||
},
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
|
||||
harness.setResponses([
|
||||
fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }),
|
||||
fauxAssistantMessage("first turn complete"),
|
||||
fauxAssistantMessage("queued follow-up handled by model"),
|
||||
]);
|
||||
|
||||
const sawToolStart = new Promise<void>((resolve) => {
|
||||
const unsubscribe = harness.session.subscribe((event) => {
|
||||
if (event.type === "tool_execution_start" && event.toolName === "wait") {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const promptPromise = harness.session.prompt("start");
|
||||
await sawToolStart;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
extensionApi?.sendUserMessage("/testcmd queued", { deliverAs: "followUp" });
|
||||
releaseToolExecution?.();
|
||||
await promptPromise;
|
||||
|
||||
expect(commandRuns).toEqual([]);
|
||||
expect(getUserTexts(harness)).toEqual(["start", "/testcmd queued"]);
|
||||
expect(getAssistantTexts(harness)).toContain("queued follow-up handled by model");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user