test: refactored test suites to eliminate time-based sleeps and polling loops

- Replaced time-based sleeps and polling loops with event-driven promise resolvers and fake timers across agent and tool tests.
- Migrated test suites to share in-memory auth storage and fixtures using lifecycle hooks.
- Updated catalog model definitions, metadata, and configurations.
This commit is contained in:
can1357
2026-08-13 19:30:27 +02:00
parent 28997c0d46
commit b279db1790
263 changed files with 4603 additions and 6625 deletions
+14 -22
View File
@@ -2216,7 +2216,7 @@ describe("agentLoop with AgentMessage", () => {
let steerReady = false;
let drained = false;
let observedAbort = false;
let resolvedByTimeout = false;
const toolRelease = Promise.withResolvers<void>();
const tool: AgentTool<typeof toolSchema, Record<string, never>> = {
name: "wait",
@@ -2226,24 +2226,7 @@ describe("agentLoop with AgentMessage", () => {
interruptible: params => params.op === "wait",
async execute(_toolCallId, _params, signal) {
steerReady = true;
const { promise, resolve } = Promise.withResolvers<void>();
if (signal?.aborted) {
resolve();
} else {
const timer = setTimeout(() => {
resolvedByTimeout = true;
resolve();
}, 300);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
}
await promise;
if (!signal?.aborted) await toolRelease.promise;
observedAbort = signal?.aborted === true;
return { content: [{ type: "text", text: "waited" }], details: {} };
},
@@ -2260,7 +2243,11 @@ describe("agentLoop with AgentMessage", () => {
model: mock.model,
convertToLlm: identityConverter,
interruptMode: "immediate",
hasSteeringMessages: () => steerReady && !drained,
hasSteeringMessages: () => {
const queued = steerReady && !drained;
if (queued) toolRelease.resolve();
return queued;
},
getSteeringMessages: async () => {
if (steerReady && !drained) {
drained = true;
@@ -2276,7 +2263,7 @@ describe("agentLoop with AgentMessage", () => {
}
expect(observedAbort).toBe(false);
expect(resolvedByTimeout).toBe(true);
expect(steerReady).toBe(true);
expect(drained).toBe(true);
expect(
events.some(e => e.type === "message_start" && e.message.role === "user" && e.message.content === "interrupt"),
@@ -3203,7 +3190,12 @@ describe("agentLoop event-driven steering watch", () => {
// drain
}
})();
const completed = await Promise.race([drain.then(() => true), Bun.sleep(1000).then(() => false)]);
// This is the behavior under test, so retain a deadline; cancel its timer
// when teardown succeeds instead of leaving a losing sleep alive.
const timeout = Promise.withResolvers<boolean>();
const timeoutId = setTimeout(() => timeout.resolve(false), 1000);
const completed = await Promise.race([drain.then(() => true), timeout.promise]);
clearTimeout(timeoutId);
try {
expect(completed).toBe(true);
expect(executed).toEqual(["only"]);
+23 -3
View File
@@ -36,17 +36,27 @@ describe("agentPauseGate", () => {
const context: AgentContext = { systemPrompt: ["Test"], messages: [], tools: [] };
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const parked = Promise.withResolvers<void>();
const originalWait = agentPauseGate.waitUntilResumed;
agentPauseGate.waitUntilResumed = (signal?: AbortSignal) => {
parked.resolve();
return originalWait.call(agentPauseGate, signal);
};
expect(agentPauseGate.pause()).toBe(true);
expect(agentPauseGate.pause()).toBe(false); // already engaged
const result = agentLoop([createUserMessage("hi")], context, config, undefined, mock.stream).result();
await Bun.sleep(20);
await parked.promise;
expect(mock.calls.length).toBe(0); // parked before the first provider call
try {
expect(agentPauseGate.resume()).toBeGreaterThanOrEqual(0);
const messages = await result;
expect(mock.calls.length).toBe(1);
expect(messages[messages.length - 1].role).toBe("assistant");
} finally {
agentPauseGate.waitUntilResumed = originalWait;
}
});
it("holds tool execution at the tool boundary when paused mid-turn", async () => {
@@ -96,6 +106,12 @@ describe("agentPauseGate", () => {
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
const abortController = new AbortController();
const parked = Promise.withResolvers<void>();
const originalWait = agentPauseGate.waitUntilResumed;
agentPauseGate.waitUntilResumed = (signal?: AbortSignal) => {
parked.resolve();
return originalWait.call(agentPauseGate, signal);
};
agentPauseGate.pause();
const result = agentLoop(
[createUserMessage("hi")],
@@ -104,12 +120,13 @@ describe("agentPauseGate", () => {
abortController.signal,
mock.stream,
).result();
await Bun.sleep(20);
await parked.promise;
abortController.abort("user interrupt");
// The run must terminate as aborted promptly (not stay parked until
// resume). The provider request itself carries the aborted signal, so
// whether the transport is entered at all is an implementation detail.
try {
const messages = await result;
const last = messages[messages.length - 1];
expect(last.role).toBe("assistant");
@@ -117,6 +134,9 @@ describe("agentPauseGate", () => {
expect(last.stopReason).toBe("aborted");
}
expect(agentPauseGate.paused).toBe(true); // aborting one run never resumes the process
} finally {
agentPauseGate.waitUntilResumed = originalWait;
}
});
it("re-parks a waiter when the gate is re-engaged in the same tick as resume", async () => {
@@ -128,7 +148,7 @@ describe("agentPauseGate", () => {
agentPauseGate.resume();
agentPauseGate.pause(); // re-engage before the waiter's microtask runs
await Bun.sleep(10);
await Promise.resolve();
expect(released).toBe(false);
agentPauseGate.resume();
+5
View File
@@ -189,6 +189,7 @@ let providerInFlightHeartbeatWriterOverride:
| ((writeProviderInFlightInfo: () => Promise<void>) => Promise<void>)
| undefined;
let providerInFlightLeaseRemoverOverride: ((leasePath: string) => Promise<void>) | undefined;
let providerInFlightWaitObserverOverride: ((provider: string) => void) | undefined;
export function configureProviderMaxInFlightRequests(limits: Record<string, number> | undefined): void {
configuredProviderMaxInFlightRequests = limits ?? {};
@@ -489,6 +490,7 @@ function waitForProviderInFlightSignal(provider: string, signal?: AbortSignal):
if (signal?.aborted)
return Promise.reject(signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch"));
const signalPath = providerInFlightSignalPath(provider);
providerInFlightWaitObserverOverride?.(provider);
const waitStarted = Date.now();
const { promise, resolve, reject } = Promise.withResolvers<void>();
let settled = false;
@@ -615,6 +617,9 @@ export const __providerInFlightForTesting = {
setLeaseRemover(remover: ((leasePath: string) => Promise<void>) | undefined): void {
providerInFlightLeaseRemoverOverride = remover;
},
setWaitObserver(observer: ((provider: string) => void) | undefined): void {
providerInFlightWaitObserverOverride = observer;
},
providerDir(provider: string): string {
return providerInFlightDir(provider);
},
@@ -2354,7 +2354,8 @@ describe("AuthStorage codex oauth ranking", () => {
};
});
const refreshDelayMs = 75;
const allRefreshesStarted = Promise.withResolvers<void>();
const releaseRefreshes = Promise.withResolvers<void>();
let inFlight = 0;
let maxConcurrent = 0;
const refreshStarts: number[] = [];
@@ -2362,7 +2363,8 @@ describe("AuthStorage codex oauth ranking", () => {
refreshStarts.push(Date.now());
inFlight += 1;
maxConcurrent = Math.max(maxConcurrent, inFlight);
await Bun.sleep(refreshDelayMs);
if (inFlight === 3) allRefreshesStarted.resolve();
await releaseRefreshes.promise;
inFlight -= 1;
return {
...credential,
@@ -2378,7 +2380,10 @@ describe("AuthStorage codex oauth ranking", () => {
{ type: "oauth", ...createCredential("acct-third", "third@example.com"), expires: expiredAt },
]);
const apiKey = await authStorage.getApiKey("openai-codex");
const apiKeyPromise = authStorage.getApiKey("openai-codex");
await allRefreshesStarted.promise;
releaseRefreshes.resolve();
const apiKey = await apiKeyPromise;
expect(apiKey).toBe("refreshed-acct-third");
expect(refreshStarts).toHaveLength(3);
@@ -49,12 +49,12 @@ describe("OAuthCallbackFlow port fallback policy", () => {
it("falls back to a random port by default so historical AI-provider flows keep working", async () => {
const blocker = occupyLoopbackPort();
const progress: string[] = [];
const controller = new AbortController();
const flow = new TestCallbackFlow(
{
onAuth: () => {},
onAuth: () => controller.abort(new Error("redirect URI observed")),
onProgress: msg => progress.push(msg),
// Short abort — we only care that the flow advertised the fallback URI.
signal: AbortSignal.timeout(100),
signal: controller.signal,
},
{ preferredPort: blocker.port },
);
@@ -401,7 +401,7 @@ describe("Google Gemini CLI alignment", () => {
const encoder = new TextEncoder();
for (const chunk of sseChunks) {
controller.enqueue(encoder.encode(chunk));
await Bun.sleep(5);
await Promise.resolve();
}
controller.close();
},
@@ -464,7 +464,7 @@ describe("Google Gemini CLI alignment", () => {
const encoder = new TextEncoder();
for (const chunk of sseChunks) {
controller.enqueue(encoder.encode(chunk));
await Bun.sleep(5);
await Promise.resolve();
}
controller.close();
},
@@ -577,7 +577,7 @@ describe("Google Gemini CLI alignment", () => {
const encoder = new TextEncoder();
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
await Bun.sleep(5);
await Promise.resolve();
}
controller.close();
},
@@ -624,7 +624,7 @@ describe("Google Gemini CLI alignment", () => {
const encoder = new TextEncoder();
for (const chunk of sseChunks) {
controller.enqueue(encoder.encode(chunk));
await Bun.sleep(5);
await Promise.resolve();
}
controller.close();
},
@@ -668,7 +668,7 @@ describe("Google Gemini CLI alignment", () => {
const encoder = new TextEncoder();
for (const chunk of sseChunks) {
controller.enqueue(encoder.encode(chunk));
await Bun.sleep(5);
await Promise.resolve();
}
controller.close();
},
@@ -821,7 +821,7 @@ describe("Google Gemini CLI alignment", () => {
const encoder = new TextEncoder();
for (const chunk of sseChunks) {
controller.enqueue(encoder.encode(chunk));
await Bun.sleep(5);
await Promise.resolve();
}
controller.close();
},
+30 -24
View File
@@ -27,6 +27,7 @@ afterEach(async () => {
__providerInFlightForTesting.setHeartbeatTimings(undefined);
__providerInFlightForTesting.setHeartbeatWriter(undefined);
__providerInFlightForTesting.setLeaseRemover(undefined);
__providerInFlightForTesting.setWaitObserver(undefined);
if (limiterRoot !== undefined) {
await fs.rm(limiterRoot, { recursive: true, force: true });
limiterRoot = undefined;
@@ -41,6 +42,13 @@ async function useIsolatedLimiterRoot(): Promise<void> {
function limiterDir(provider: string): string {
return __providerInFlightForTesting.providerDir(provider);
}
function nextLimiterWait(provider = "tests"): Promise<void> {
const waiting = Promise.withResolvers<void>();
__providerInFlightForTesting.setWaitObserver(waitingProvider => {
if (waitingProvider === provider) waiting.resolve();
});
return waiting.promise;
}
describe("provider in-flight request limits", () => {
beforeEach(async () => {
@@ -75,9 +83,9 @@ describe("provider in-flight request limits", () => {
const firstResult = first.result();
await firstStarted.promise;
const secondWaiting = nextLimiterWait();
const second = streamSimple(mock.model, context(), { maxInFlightRequests: { tests: 1 } });
await Bun.sleep(20);
expect(mock.calls).toHaveLength(1);
await secondWaiting;
releaseFirst.resolve();
const [firstMessage, secondMessage] = await Promise.all([firstResult, second.result()]);
@@ -110,27 +118,21 @@ describe("provider in-flight request limits", () => {
return undefined;
})();
const resultPromise = stream.result();
const removalOutcome = await Promise.race([
removalStarted.promise.then(() => "started" as const),
Bun.sleep(2_000).then(() => "blocked" as const),
]);
if (removalOutcome === "blocked") {
await removalStarted.promise;
let terminalCompleted = false;
let resultCompleted = false;
void terminalObserved.promise.then(() => {
terminalCompleted = true;
});
void resultPromise.then(() => {
resultCompleted = true;
});
await Promise.resolve();
expect(terminalCompleted).toBe(false);
expect(resultCompleted).toBe(false);
allowRemoval.resolve();
throw new Error("Provider lease removal did not start");
}
let completionBeforeRelease: "terminal" | "result" | "pending";
try {
completionBeforeRelease = await Promise.race([
terminalObserved.promise.then(() => "terminal" as const),
resultPromise.then(() => "result" as const),
Bun.sleep(20).then(() => "pending" as const),
]);
} finally {
allowRemoval.resolve();
}
const [result, terminalType] = await Promise.all([resultPromise, terminalObservation]);
expect(completionBeforeRelease).toBe("pending");
expect(result.content).toEqual([{ type: "text", text: "reply" }]);
expect(terminalType).toBe("done");
const entries = await fs.readdir(limiterDir("tests"), { withFileTypes: true });
@@ -328,12 +330,13 @@ describe("provider in-flight request limits", () => {
const controller = new AbortController();
const mock = createMockModel({ provider: "tests", responses: [{ content: ["reply"] }] });
const waiting = nextLimiterWait();
const stream = streamSimple(mock.model, context(), {
maxInFlightRequests: { tests: 1 },
signal: controller.signal,
});
await Bun.sleep(150);
await waiting;
expect(mock.calls).toHaveLength(0);
await fs.rm(externalLease, { recursive: true, force: true });
@@ -355,12 +358,13 @@ describe("provider in-flight request limits", () => {
const controller = new AbortController();
const mock = createMockModel({ provider: "tests", responses: [{ content: ["reply"] }] });
const waiting = nextLimiterWait();
const stream = streamSimple(mock.model, context(), {
maxInFlightRequests: { tests: 1 },
signal: controller.signal,
});
await Bun.sleep(50);
await waiting;
expect(await Bun.file(path.join(providerDir, ".wakeup")).exists()).toBe(false);
expect(mock.calls).toHaveLength(0);
@@ -403,12 +407,13 @@ describe("provider in-flight request limits", () => {
const controller = new AbortController();
const mock = createMockModel({ provider: "tests", responses: [{ content: ["reply"] }] });
const waiting = nextLimiterWait();
const stream = streamSimple(mock.model, context(), {
maxInFlightRequests: { tests: 1 },
signal: controller.signal,
});
await Bun.sleep(150);
await waiting;
expect(mock.calls).toHaveLength(0);
controller.abort(new Error("cancel lock waiter"));
@@ -427,12 +432,13 @@ describe("provider in-flight request limits", () => {
const controller = new AbortController();
const mock = createMockModel({ provider: "tests", responses: [{ content: ["reply"] }] });
const waiting = nextLimiterWait();
const stream = streamSimple(mock.model, context(), {
maxInFlightRequests: { tests: 1 },
signal: controller.signal,
});
await Bun.sleep(150);
await waiting;
expect(mock.calls).toHaveLength(0);
controller.abort(new Error("cancel partial-info waiter"));
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Changed
- Updated model metadata, context windows, pricing, and configurations in the catalog
## [17.3.0] - 2026-08-13
### Breaking Changes
File diff suppressed because it is too large Load Diff
@@ -1,33 +1,25 @@
import { describe, expect, test } from "bun:test";
import { TempDir } from "@oh-my-pi/pi-utils";
import { createReferenceResolver } from "../src/provider-models/bundled-references";
import type { ModelSpec } from "../src/types";
const FIXTURE = `${import.meta.dir}/fixtures/bundled-reference-laziness.ts`;
async function runFixture(fixture: string): Promise<string> {
const tempDir = TempDir.createSync("@pi-catalog-bundled-reference-laziness-");
const resultPath = tempDir.join("result.json");
try {
function runFixture(fixture: string): string {
const result = Bun.spawnSync({
cmd: [process.execPath, fixture],
env: { ...process.env, OMP_CATALOG_LAZINESS_RESULT_PATH: resultPath },
env: process.env,
stdout: "pipe",
stderr: "pipe",
});
expect(result.exitCode, result.stderr.toString()).toBe(0);
return await Bun.file(resultPath).text();
} finally {
tempDir.removeSync();
}
return result.stdout.toString();
}
describe("bundled reference laziness", () => {
test("constructing bundled model-manager options retains less than 8 MiB of RSS", async () => {
const { retainedRssBytes } = JSON.parse(await runFixture(FIXTURE)) as { retainedRssBytes: number };
describe("bundled model laziness", () => {
test("provider options and the bundled registry stay lazy", () => {
const { retainedRssBytes } = JSON.parse(runFixture(FIXTURE)) as { retainedRssBytes: number };
expect(retainedRssBytes).toBeLessThan(8 * 1024 * 1024);
}, 60_000);
test("a lazy provider-reference factory initializes on first resolution and only once", () => {
const reference = {
id: "fixture-model",
@@ -1,3 +1,6 @@
import { expect, spyOn } from "bun:test";
import * as buildModule from "../../src/build";
import type { GeneratedProvider } from "../../src/models";
import { ollamaCloudModelManagerOptions } from "../../src/provider-models/ollama";
import { nanoGptModelManagerOptions } from "../../src/provider-models/openai-compat";
@@ -8,10 +11,48 @@ ollamaCloudModelManagerOptions();
Bun.gc(true);
const retainedRssBytes = process.memoryUsage().rss - rssBefore;
const result = JSON.stringify({ retainedRssBytes });
const resultPath = process.env.OMP_CATALOG_LAZINESS_RESULT_PATH;
if (resultPath) {
await Bun.write(resultPath, result);
} else {
process.stdout.write(result);
process.stdout.write(JSON.stringify({ retainedRssBytes }));
// Keep the model-registry import below the RSS assertion setup: importing it
// eagerly loads models.json and would invalidate the provider-option laziness
// measurement above. This same isolated process can then verify the registry's
// own lazy, per-provider enrichment without paying for a second Bun startup.
const { getBundledModel, getBundledModels, getBundledProviders } = await import("../../src/models");
const { default: MODELS } = await import("../../src/models.json", { with: { type: "json" } });
const buildSpy = spyOn(buildModule, "buildModel");
const rawProviders = Object.keys(MODELS);
const firstProviders = getBundledProviders();
const secondProviders = getBundledProviders();
expect(buildSpy).toHaveBeenCalledTimes(0);
expect(firstProviders as string[]).toEqual(rawProviders);
expect(secondProviders as string[]).toEqual(rawProviders);
expect(secondProviders).not.toBe(firstProviders);
const provider = "sakana" satisfies GeneratedProvider;
const rawModelIds = Object.keys(MODELS[provider]);
const firstModels = getBundledModels(provider);
expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length);
expect(firstModels.map(model => model.id)).toEqual(rawModelIds);
const secondModels = getBundledModels(provider);
expect(secondModels).not.toBe(firstModels);
expect(secondModels).toHaveLength(firstModels.length);
for (let index = 0; index < firstModels.length; index++) {
expect(secondModels[index]).toBe(firstModels[index]);
}
expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length);
const firstModelId = rawModelIds[0];
if (firstModelId === undefined) throw new Error(`${provider} must have a bundled model`);
expect(getBundledModel(provider, firstModelId)).toBe(firstModels[0]);
expect(getBundledModel(provider, firstModelId)).toBe(firstModels[0]);
expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length);
const unknownProvider = "not-a-bundled-provider" as GeneratedProvider;
expect(getBundledModels(unknownProvider)).toEqual([]);
expect(getBundledModel(unknownProvider, "missing-model")).toBeUndefined();
expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length);
buildSpy.mockRestore();
@@ -1,42 +0,0 @@
import { expect, spyOn } from "bun:test";
import * as buildModule from "../../src/build";
import { type GeneratedProvider, getBundledModel, getBundledModels, getBundledProviders } from "../../src/models";
import MODELS from "../../src/models.json" with { type: "json" };
const buildSpy = spyOn(buildModule, "buildModel");
const rawProviders = Object.keys(MODELS);
const firstProviders = getBundledProviders();
const secondProviders = getBundledProviders();
expect(buildSpy).toHaveBeenCalledTimes(0);
expect(firstProviders as string[]).toEqual(rawProviders);
expect(secondProviders as string[]).toEqual(rawProviders);
expect(secondProviders).not.toBe(firstProviders);
const provider = "sakana" satisfies GeneratedProvider;
const rawModelIds = Object.keys(MODELS[provider]);
const firstModels = getBundledModels(provider);
expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length);
expect(firstModels.map(model => model.id)).toEqual(rawModelIds);
const secondModels = getBundledModels(provider);
expect(secondModels).not.toBe(firstModels);
expect(secondModels).toHaveLength(firstModels.length);
for (let index = 0; index < firstModels.length; index++) {
expect(secondModels[index]).toBe(firstModels[index]);
}
expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length);
const firstModelId = rawModelIds[0];
if (firstModelId === undefined) throw new Error(`${provider} must have a bundled model`);
expect(getBundledModel(provider, firstModelId)).toBe(firstModels[0]);
expect(getBundledModel(provider, firstModelId)).toBe(firstModels[0]);
expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length);
const unknownProvider = "not-a-bundled-provider" as GeneratedProvider;
expect(getBundledModels(unknownProvider)).toEqual([]);
expect(getBundledModel(unknownProvider, "missing-model")).toBeUndefined();
expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length);
buildSpy.mockRestore();
@@ -1,11 +0,0 @@
import { expect, test } from "bun:test";
const FIXTURE = `${import.meta.dir}/fixtures/models-lazy-provider-cache.ts`;
test("bundled models are enriched one provider at a time", () => {
const result = Bun.spawnSync({
cmd: [process.execPath, FIXTURE],
env: process.env,
});
expect(result.exitCode, result.stderr.toString()).toBe(0);
}, 60_000);
+33 -4
View File
@@ -152,6 +152,7 @@ export class AsyncJobManager {
readonly #maxRunningJobs: number;
readonly #retentionMs: number;
#deliveryLoop: Promise<void> | undefined;
#deliveryQueueChanged = Promise.withResolvers<void>();
#disposed = false;
#filterJobs(jobs: Iterable<AsyncJob>, filter?: AsyncJobFilter): AsyncJob[] {
@@ -335,6 +336,7 @@ export class AsyncJobManager {
for (const jobId of uniqueJobIds) {
this.#watchedJobs.add(jobId);
}
this.#notifyDeliveryQueueChanged();
return uniqueJobIds.length;
}
@@ -389,6 +391,7 @@ export class AsyncJobManager {
this.#deliveries.length,
...this.#deliveries.filter(delivery => !this.isDeliverySuppressed(delivery.jobId)),
);
this.#notifyDeliveryQueueChanged();
return before - this.#deliveries.length;
}
@@ -603,6 +606,7 @@ export class AsyncJobManager {
this.#clearEvictionTimers();
this.#jobs.clear();
this.#deliveries.length = 0;
this.#notifyDeliveryQueueChanged();
this.#inFlightDeliveries.length = 0;
this.#suppressedDeliveries.clear();
this.#watchedJobs.clear();
@@ -704,13 +708,14 @@ export class AsyncJobManager {
const now = Date.now();
if (selected.nextAttemptAt > now) {
if (selected.nextAttemptAt > deadline) return false;
await Bun.sleep(selected.nextAttemptAt - now);
await this.#waitForDeliveryQueueChange(selected.nextAttemptAt - now);
continue;
}
const index = this.#deliveries.indexOf(selected);
if (index === -1) continue;
this.#deliveries.splice(index, 1);
this.#notifyDeliveryQueueChanged();
if (this.isDeliverySuppressed(selected.jobId)) continue;
return this.#waitForDeliveryPromise(this.#deliverDelivery(selected), deadline);
@@ -726,7 +731,7 @@ export class AsyncJobManager {
if (this.isDeliverySuppressed(jobId)) {
return;
}
this.#deliveries.push({
this.#queueDelivery({
jobId,
text,
attempt: 0,
@@ -762,7 +767,8 @@ export class AsyncJobManager {
}
const waitMs = delivery.nextAttemptAt - Date.now();
if (waitMs > 0) {
await Bun.sleep(waitMs);
await this.#waitForDeliveryQueueChange(waitMs);
continue;
}
if (this.#deliveries[0] !== delivery) {
continue;
@@ -813,7 +819,7 @@ export class AsyncJobManager {
delivery.lastError = error instanceof Error ? error.message : String(error);
delivery.nextAttemptAt = Date.now() + this.#getRetryDelay(delivery.attempt);
if (!this.isDeliverySuppressed(delivery.jobId)) {
this.#deliveries.push(delivery);
this.#queueDelivery(delivery);
}
logger.warn("Async job completion delivery failed", {
jobId: delivery.jobId,
@@ -831,6 +837,29 @@ export class AsyncJobManager {
return promise;
}
#queueDelivery(delivery: AsyncJobDelivery): void {
const index = this.#deliveries.findIndex(candidate => candidate.nextAttemptAt > delivery.nextAttemptAt);
if (index === -1) this.#deliveries.push(delivery);
else this.#deliveries.splice(index, 0, delivery);
this.#notifyDeliveryQueueChanged();
}
async #waitForDeliveryQueueChange(delayMs: number): Promise<void> {
const timerElapsed = Promise.withResolvers<void>();
const timer = setTimeout(timerElapsed.resolve, delayMs);
timer.unref();
try {
await Promise.race([timerElapsed.promise, this.#deliveryQueueChanged.promise]);
} finally {
clearTimeout(timer);
}
}
#notifyDeliveryQueueChanged(): void {
this.#deliveryQueueChanged.resolve();
this.#deliveryQueueChanged = Promise.withResolvers<void>();
}
async #waitForDeliveryPromise(promise: Promise<void> | undefined, deadline: number): Promise<boolean> {
if (!promise) return true;
if (deadline === Number.POSITIVE_INFINITY) {
+13 -3
View File
@@ -151,11 +151,21 @@ function numberSetting(value: number | undefined, fallback: unknown, defaultValu
async function resolveOptions(flags: GcCommandFlags): Promise<ResolvedGcOptions> {
const agentDir = path.resolve(flags.agentDir ?? getAgentDir());
const selected = flags.blobs === true || flags.archive === true || flags.wal === true;
const archiveSelected = selected && flags.archive === true;
const needsArchiveSettings =
archiveSelected &&
(flags.coldArchiveAfterDays === undefined ||
flags.retainNewestGlobal === undefined ||
flags.retainNewestPerCwd === undefined);
const settings =
flags.apply === true ? await Settings.loadIsolated({ agentDir }) : await Settings.loadReadOnly({ agentDir });
const getBoolean = (pathKey: "gc.blobs" | "gc.archive" | "gc.wal") => settings.get(pathKey);
!selected || needsArchiveSettings
? flags.apply === true
? await Settings.loadIsolated({ agentDir })
: await Settings.loadReadOnly({ agentDir })
: undefined;
const getBoolean = (pathKey: "gc.blobs" | "gc.archive" | "gc.wal") => settings?.get(pathKey) ?? getDefault(pathKey);
const getNumber = (pathKey: "gc.coldArchiveAfterDays" | "gc.retainNewestGlobal" | "gc.retainNewestPerCwd") =>
settings.get(pathKey);
settings?.get(pathKey) ?? getDefault(pathKey);
return {
apply: flags.apply === true,
json: flags.json === true,
+2 -2
View File
@@ -66,7 +66,7 @@ export function parseSetupArgs(args: string[]): SetupCommandArgs | undefined {
};
}
interface PythonCheckResult {
export interface PythonCheckResult {
available: boolean;
pythonPath?: string;
usingManagedEnv?: boolean;
@@ -82,7 +82,7 @@ function managedPythonPath(): string {
/**
* Check Python environment and kernel dependencies.
*/
async function checkPythonSetup(cwd: string, interpreter?: string): Promise<PythonCheckResult> {
export async function checkPythonSetup(cwd: string, interpreter?: string): Promise<PythonCheckResult> {
const availability = await checkPythonKernelAvailability(cwd, interpreter, { forceProbe: true });
return {
available: availability.ok,
@@ -15,6 +15,21 @@ import { commands } from "../cli-commands";
const ROOT_COMMAND = "launch";
const SHELLS = ["bash", "zsh", "fish"] as const;
/** Generate a completion script from the live command registry. */
export async function generateLiveCompletion(shell: Shell): Promise<string> {
const loaded = await Promise.all(commands.map(async entry => ({ entry, Cmd: await entry.load() })));
const map = new Map<string, CommandCtor>();
const aliasMap = new Map<string, readonly string[]>();
for (const { entry, Cmd } of loaded) {
map.set(entry.name, Cmd);
const merged = new Set<string>([...(Cmd.aliases ?? []), ...(entry.aliases ?? [])]);
aliasMap.set(entry.name, [...merged]);
}
const config: CliConfig = { bin: APP_NAME, version: VERSION, commands: map };
return generateCompletion(shell, buildSpec(config, ROOT_COMMAND, aliasMap));
}
export default class Completions extends Command {
static description = commandHelp.description;
static args = {
@@ -39,20 +54,7 @@ export default class Completions extends Command {
return;
}
// Load every command class so we can read its static flag/arg descriptors,
// and collect aliases from both the registration table and the class.
const loaded = await Promise.all(commands.map(async entry => ({ entry, Cmd: await entry.load() })));
const map = new Map<string, CommandCtor>();
const aliasMap = new Map<string, readonly string[]>();
for (const { entry, Cmd } of loaded) {
map.set(entry.name, Cmd);
const merged = new Set<string>([...(Cmd.aliases ?? []), ...(entry.aliases ?? [])]);
aliasMap.set(entry.name, [...merged]);
}
const config: CliConfig = { bin: APP_NAME, version: VERSION, commands: map };
const spec = buildSpec(config, ROOT_COMMAND, aliasMap);
await Bun.write(Bun.stdout, generateCompletion(shell, spec));
await Bun.write(Bun.stdout, await generateLiveCompletion(shell));
}
}
@@ -32,7 +32,7 @@ import {
resolveOllamaModelCacheProviderId,
} from "@oh-my-pi/pi-catalog/provider-models";
import { collapseBuiltModelVariants } from "@oh-my-pi/pi-catalog/variant-collapse";
import { isBunTestRuntime, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils";
import { getAgentDir, isBunTestRuntime, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils";
import { resolveProviderModelReference } from "../config/model-resolver";
import { generateCodexAttestation } from "../live/attestation";
import type { AuthStorage } from "../session/auth-storage";
@@ -246,7 +246,7 @@ export class ModelRegistry {
(isBunTestRuntime()
? () => Promise.reject(new Error("network disabled in model-registry runtime test"))
: wrapFetchForExtraCa(fetch));
this.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath);
this.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath ?? path.join(getAgentDir(), "models.yml"));
this.#cacheDbPath = modelsPath ? path.join(path.dirname(modelsPath), "models.db") : undefined;
// Set up fallback resolver for custom provider API keys
this.authStorage.setFallbackResolver(provider => {
@@ -67,18 +67,18 @@ const HOST_PROBE_TIMEOUT_MS = 500;
/**
* Run a best-effort discovery probe and return its trimmed stdout, or
* `undefined` when the command fails, produces no output, or exceeds
* {@link HOST_PROBE_TIMEOUT_MS}. On timeout the child is killed with SIGKILL so
* a wedged interop pipe cannot hang startup; the killed/non-zero exit is then
* reported as "unavailable" and discovery falls back to the Linux
* `$HOME`/`~/.omp` candidates.
* `undefined` when the command fails, produces no output, or exceeds the
* timeout. On timeout the child is killed with SIGKILL so a wedged interop pipe
* cannot hang startup; the killed/non-zero exit is then reported as
* "unavailable" and discovery falls back to the Linux `$HOME`/`~/.omp`
* candidates.
*/
export function runHostProbe(cmd: string[]): string | undefined {
export function runHostProbe(cmd: string[], timeoutMs = HOST_PROBE_TIMEOUT_MS): string | undefined {
try {
const result = Bun.spawnSync(cmd, {
stdout: "pipe",
stderr: "ignore",
timeout: HOST_PROBE_TIMEOUT_MS,
timeout: timeoutMs,
killSignal: "SIGKILL",
});
if (result.exitCode !== 0) return undefined;
+26 -4
View File
@@ -37,6 +37,7 @@ const MAX_LOG_BYTES = 25 * 1024 * 1024;
const LOG_READ_BYTES = 2 * 1024 * 1024;
const READINESS_BUFFER_CHARS = 64 * 1024;
const RESTART_MAX_DELAY_MS = 30_000;
const RESTART_BACKOFF_BASE_MS = 1_000;
/**
* Cap on terminal (exited/failed) daemons surfaced by `list`. Active daemons
* are always shown in full; older history is truncated so the response stays
@@ -351,6 +352,7 @@ class DaemonBroker {
readonly #endpoint: string;
readonly #token: string;
readonly #idleGraceMs: number;
readonly #restartBackoffBaseMs: number;
readonly #records = new Map<string, ManagedDaemon>();
/**
* Names reserved by an in-flight `start` before its record lands in
@@ -371,12 +373,19 @@ class DaemonBroker {
#idleTimer: NodeJS.Timeout | undefined;
#shuttingDown = false;
constructor(projectDir: string, runtimeDir: string, token: string, idleGraceMs: number) {
constructor(
projectDir: string,
runtimeDir: string,
token: string,
idleGraceMs: number,
restartBackoffBaseMs: number,
) {
this.#projectDir = projectDir;
this.#runtimeDir = runtimeDir;
this.#endpoint = daemonBrokerEndpoint(projectDir, runtimeDir);
this.#token = token;
this.#idleGraceMs = idleGraceMs;
this.#restartBackoffBaseMs = restartBackoffBaseMs;
}
async run(): Promise<void> {
@@ -955,7 +964,10 @@ class DaemonBroker {
record.snapshot.readyAt = undefined;
record.snapshot.readyMatch = undefined;
record.snapshot.state = "restarting";
const delay = Math.min(1_000 * 2 ** Math.min(record.consecutiveFailures, 5), RESTART_MAX_DELAY_MS);
const delay = Math.min(
this.#restartBackoffBaseMs * 2 ** Math.min(record.consecutiveFailures, 5),
RESTART_MAX_DELAY_MS,
);
record.log?.append(
`\n[daemon exited${exitCode === undefined ? "" : ` with code ${exitCode}`}; restarting in ${delay}ms]\n`,
);
@@ -1347,8 +1359,13 @@ class DaemonBroker {
}
}
export interface DaemonBrokerStartOptions {
/** Base of the exponential child-restart backoff. */
restartBackoffBaseMs?: number;
}
/** Start the detached project or global daemon broker selected by the CLI worker host. */
export async function startDaemonBrokerFromEnvironment(): Promise<void> {
export async function startDaemonBrokerFromEnvironment(options: DaemonBrokerStartOptions = {}): Promise<void> {
const projectDir = process.env[DAEMON_PROJECT_DIR_ENV];
const runtimeDir = process.env[DAEMON_RUNTIME_DIR_ENV];
if (!projectDir || !runtimeDir) throw new Error("Daemon broker environment is incomplete");
@@ -1358,13 +1375,18 @@ export async function startDaemonBrokerFromEnvironment(): Promise<void> {
delete process.env[DAEMON_IDLE_GRACE_ENV];
const parsedGrace = rawGrace === undefined ? DEFAULT_IDLE_GRACE_MS : Number.parseInt(rawGrace, 10);
const idleGraceMs = Number.isFinite(parsedGrace) && parsedGrace >= 0 ? parsedGrace : DEFAULT_IDLE_GRACE_MS;
const requestedRestartBackoffBaseMs = options.restartBackoffBaseMs ?? RESTART_BACKOFF_BASE_MS;
const restartBackoffBaseMs =
Number.isFinite(requestedRestartBackoffBaseMs) && requestedRestartBackoffBaseMs >= 0
? requestedRestartBackoffBaseMs
: RESTART_BACKOFF_BASE_MS;
await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 });
const lease = await acquireBrokerLease(runtimeDir);
if (!lease) return;
setProcessName("omp daemon broker");
const token = (await Bun.file(path.join(runtimeDir, TOKEN_FILE)).text()).trim();
if (!token) throw new Error("Daemon broker token is empty");
const broker = new DaemonBroker(projectDir, runtimeDir, token, idleGraceMs);
const broker = new DaemonBroker(projectDir, runtimeDir, token, idleGraceMs, restartBackoffBaseMs);
const cancelCleanup = postmortem.register("daemon-broker", () => broker.shutdown());
try {
await broker.run();
+7 -1
View File
@@ -687,7 +687,13 @@ export class LspMuxServer {
server.pending.set(id, { resolveInternal: resolve });
try {
await this.#writeServer(server, { jsonrpc: "2.0", id, method: "shutdown", params: null });
await Promise.race([promise, Bun.sleep(SHUTDOWN_BUDGET_MS)]);
const timeout = Promise.withResolvers<void>();
const timer = setTimeout(timeout.resolve, SHUTDOWN_BUDGET_MS);
try {
await Promise.race([promise, timeout.promise]);
} finally {
clearTimeout(timer);
}
await this.#writeServer(server, { jsonrpc: "2.0", method: "exit" });
} catch (error) {
logger.warn("LSP mux graceful server shutdown failed", { server: server.key, error: String(error) });
@@ -495,7 +495,7 @@ function signalStdioProcess(
/**
* Terminate an MCP stdio subprocess: SIGTERM (process-group when `detached`
* on POSIX, direct child otherwise), wait up to `TERM_GRACE_MS` for a
* on POSIX, direct child otherwise), wait up to `termGraceMs` for a
* cooperative exit, then escalate to SIGKILL — waiting up to `KILL_GRACE_MS`
* more only when the leader itself hadn't already exited. A detached
* leader's cooperative exit does not prove the whole process group is gone
@@ -508,15 +508,19 @@ function signalStdioProcess(
* `detached`/`platform` pair: `StdioTransport.connect()` derives `detached`
* from `resolveStdioSpawnCommand()`, which is tied to the host's real
* `process.platform`, so a POSIX detached session cannot be reproduced
* end-to-end through `connect()` on a non-Linux dev/CI host.
* end-to-end through `connect()` on a non-Linux dev/CI host. `termGraceMs`
* preserves the production grace by default while allowing those real
* subprocess tests to cover the same transition without sleeping for a
* production-length shutdown window.
*/
export async function terminateStdioProcess(
proc: KillableSubprocess,
detached: boolean,
platform: NodeJS.Platform = process.platform,
termGraceMs = TERM_GRACE_MS,
): Promise<void> {
signalStdioProcess(proc, detached, "SIGTERM", platform);
const exitedOnTerm = await waitForProcessExit(proc.exited, TERM_GRACE_MS);
const exitedOnTerm = await waitForProcessExit(proc.exited, termGraceMs);
// A non-detached transport has no process group beyond the leader itself:
// once it exits, there is nothing left to signal. A detached transport's
// leader exiting is NOT proof the group is empty — a grandchild it spawned
@@ -359,6 +359,20 @@ function readPersistedToolNames(value: unknown): string[] | undefined {
return value as string[];
}
export function shouldEnterPlanModeOnStartup(
sessionManager: Pick<SessionManager, "buildSessionContext" | "getEntries">,
sessionSettings: Pick<Settings, "get">,
): boolean {
const hasConversationContext = sessionManager.buildSessionContext().messages.length > 0;
const hasExplicitMode = sessionManager.getEntries().some(entry => entry.type === "mode_change");
return (
!hasConversationContext &&
!hasExplicitMode &&
sessionSettings.get("plan.defaultOnStartup") &&
sessionSettings.get("plan.enabled")
);
}
/** Options for creating an InteractiveMode instance (for future API use) */
export interface InteractiveModeOptions {
/** Providers that were migrated during startup */
@@ -1140,14 +1154,7 @@ export class InteractiveMode implements InteractiveModeContext {
// execution handoff clear never get dragged back into plan mode. #enterPlanMode
// is idempotent and self-guards against an already-active plan/goal mode; it
// does not check plan.enabled itself.
const hasConversationContext = this.sessionManager.buildSessionContext().messages.length > 0;
const hasExplicitMode = this.sessionManager.getEntries().some(entry => entry.type === "mode_change");
const isFreshSession = !hasConversationContext && !hasExplicitMode;
if (
isFreshSession &&
this.session.settings.get("plan.defaultOnStartup") &&
this.session.settings.get("plan.enabled")
) {
if (shouldEnterPlanModeOnStartup(this.sessionManager, this.session.settings)) {
await this.#enterPlanMode();
}
@@ -62,6 +62,8 @@ export interface RpcClientOptions {
sessionDir?: string;
/** Additional CLI arguments */
args?: string[];
/** Grace period before escalating process termination (default: process utility default, 1000ms) */
terminationGraceMs?: number;
/** Custom tools owned by the embedding host and exposed over the RPC transport */
customTools?: RpcClientCustomTool[];
}
@@ -324,7 +326,7 @@ export class RpcClient {
this.#pendingHostToolCalls.clear();
try {
child.kill();
child.kill(undefined, this.options.terminationGraceMs);
} catch {
// The process may already have exited.
}
@@ -440,7 +442,7 @@ export class RpcClient {
const error = new Error("Client stopped");
const child = this.#process;
child.kill();
child.kill(undefined, this.options.terminationGraceMs);
this.#abortController.abort(error);
this.#process = null;
for (const request of this.#pendingRequests.values()) request.reject(error);
@@ -1,3 +1,5 @@
import { readLines } from "@oh-my-pi/pi-utils";
/**
* Claims Bun's singleton stdin reader immediately and exposes a separately readable stream.
* RPC startup uses this before extension discovery so in-process modules cannot steal protocol input.
@@ -36,3 +38,28 @@ export function claimRpcInput(): ReadableStream<Uint8Array> {
},
});
}
/**
* Parses newline-delimited RPC input without letting one malformed line stop
* subsequent protocol frames.
*/
export async function readRpcInputFrames(
input: ReadableStream<Uint8Array>,
onFrame: (frame: unknown) => void,
onParseError: (message: string) => void,
): Promise<void> {
const decoder = new TextDecoder();
for await (const line of readLines(input)) {
const text = decoder.decode(line).trim();
if (!text) continue;
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
onParseError(`Failed to parse command: ${message}`);
continue;
}
onFrame(parsed);
}
}
+10 -19
View File
@@ -13,7 +13,7 @@
import { once } from "node:events";
import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
import { $env, isRecord, readLines, Snowflake } from "@oh-my-pi/pi-utils";
import { $env, isRecord, Snowflake } from "@oh-my-pi/pi-utils";
import { reset as resetCapabilities } from "../../capability";
import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
import {
@@ -37,7 +37,7 @@ import { initializeExtensions } from "../runtime-init";
import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
import { isRpcHostUriResult, RpcHostUriBridge } from "./host-uris";
import { MAX_RPC_FRAME_BYTES, MAX_RPC_REASSEMBLED_BYTES, RpcFrameEncoder } from "./rpc-frame";
import { claimRpcInput } from "./rpc-input";
import { claimRpcInput, readRpcInputFrames } from "./rpc-input";
import { pageRpcMessages, RPC_MESSAGES_PAGE_BUSY_ERROR, RpcMessagesPageError } from "./rpc-messages";
import { RpcSubagentRegistry, readRpcSubagentTranscript } from "./rpc-subagents";
import type {
@@ -1485,23 +1485,14 @@ export async function runRpcMode(
// Keep the stdin reader moving: side-channel frames dispatch immediately,
// ordinary commands serialize through inputDispatcher, and bash remains
// background-dispatched so abort_bash can overtake it. Frames are read
// line-by-line and parsed here (not via readJsonl) so a single malformed
// line is reported as an error frame and the loop keeps running instead of
// throwing out of the generator and killing the whole process (issue #5194).
const decoder = new TextDecoder();
for await (const line of readLines(input ?? Bun.stdin.stream())) {
const text = decoder.decode(line).trim();
if (!text) continue;
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
output(error(undefined, "parse", `Failed to parse command: ${message}`));
continue;
}
inputDispatcher.dispatch(parsed);
}
// line-by-line by readRpcInputFrames so a single malformed line is reported
// as an error frame and the loop keeps running instead of throwing out of
// the reader and killing the whole process (issue #5194).
await readRpcInputFrames(
input ?? Bun.stdin.stream(),
parsed => inputDispatcher.dispatch(parsed),
message => output(error(undefined, "parse", message)),
);
// stdin closed — RPC client is gone. Fail pending side-channel requests
// first so active/queued commands can settle, then drain accepted work.
+6 -3
View File
@@ -493,6 +493,8 @@ export interface ExecutorOptions {
keepAlive?: boolean;
/** Internal ownership handoff for cleanup that outlives the visible Task result. */
onCleanupDeferred?: (completion: Promise<void>) => void;
/** Internal cleanup grace override for deterministic lifecycle tests. */
cleanupGraceMs?: number;
}
function parseStringifiedJson(value: unknown): unknown {
@@ -2657,6 +2659,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
signal,
onProgress,
} = options;
const cleanupGraceMs = options.cleanupGraceMs ?? TASK_ABORT_CLEANUP_GRACE_MS;
const startTime = Date.now();
// Set by the session's onFirstChatDispatch hook the first time the agent
// loop dispatches a chat request to the provider — the launch-complete boundary.
@@ -3328,7 +3331,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
error = err instanceof Error ? err.stack || err.message : String(err);
}
} finally {
const cleanupDeadlineAt = Date.now() + TASK_ABORT_CLEANUP_GRACE_MS;
const cleanupDeadlineAt = Date.now() + cleanupGraceMs;
const cleanupChangeStatus =
worktree === undefined
? "This task was not isolated, so its changes may remain in the working directory."
@@ -3339,8 +3342,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
lateCleanups.push(completion);
exitCode = 1;
aborted = true;
abortReasonText = `cleanup exceeded ${TASK_ABORT_CLEANUP_GRACE_MS} ms`;
error ??= `Task aborted. Cleanup did not finish within ${TASK_ABORT_CLEANUP_GRACE_MS} ms. ${cleanupChangeStatus}`;
abortReasonText = `cleanup exceeded ${cleanupGraceMs} ms`;
error ??= `Task aborted. Cleanup did not finish within ${cleanupGraceMs} ms. ${cleanupChangeStatus}`;
};
if (abortSignal.aborted) {
aborted = monitor.isAbortedRun();
@@ -208,10 +208,14 @@ fi
/**
* Create a shell snapshot, caching the result.
* Returns the path to the snapshot file, or null if creation failed.
*
* `timeoutMs` is configurable so callers exercising failure handling do not
* have to wait out the production startup budget.
*/
export async function getOrCreateSnapshot(
shell: string,
env: Record<string, string | undefined>,
timeoutMs = SNAPSHOT_TIMEOUT_MS,
): Promise<string | null> {
const cacheKey = shell;
// Return cached snapshot if valid
@@ -284,7 +288,7 @@ export async function getOrCreateSnapshot(
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
timeout: SNAPSHOT_TIMEOUT_MS,
timeout: timeoutMs,
killSignal: "SIGKILL",
});
+25 -18
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, spyOn } from "bun:test";
import { afterEach, describe, expect, it, spyOn, vi } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
@@ -452,6 +452,7 @@ const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
const fallbackAgentDir = path.join(getConfigRootDir(), "agent");
afterEach(async () => {
vi.useRealTimers();
if (originalAgentDir) {
setAgentDir(originalAgentDir);
} else {
@@ -522,13 +523,10 @@ async function createHarness(
};
}
/**
* Wait until `#scheduleBootstrapUpdates`'s timer has fired and the
* session-lifetime subscription is installed. 30 ms of slack absorbs
* `setTimeout` drift without slowing tests meaningfully.
*/
async function waitForBootstrapGuard(): Promise<void> {
await Bun.sleep(ACP_BOOTSTRAP_RACE_GUARD_MS + 150);
/** Fire `#scheduleBootstrapUpdates`'s guard without paying wall-clock time. */
async function advanceBootstrapGuard(): Promise<void> {
vi.advanceTimersByTime(ACP_BOOTSTRAP_RACE_GUARD_MS);
await Promise.resolve();
}
describe("ACP agent", () => {
@@ -792,11 +790,12 @@ describe("ACP agent", () => {
// reached the client first), those changes must surface to clients as
// `config_option_update` so TORTAS-style fleet views stay in sync.
const harness = await createHarness();
vi.useFakeTimers();
const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] });
const session = harness.findSession(created.sessionId)!;
// Wait past the 50ms bootstrap timer so the lifetime subscription is
// Advance past the 50ms bootstrap timer so the lifetime subscription is
// installed before we drive an internal thinking-level change.
await waitForBootstrapGuard();
await advanceBootstrapGuard();
const updatesBefore = harness.updates.length;
session.setThinkingLevel("high");
@@ -823,6 +822,7 @@ describe("ACP agent", () => {
session.setThinkingLevel("high");
expect(harness.updates.length).toBe(updatesBeforeRedundant);
vi.useRealTimers();
harness.abortController.abort();
await Bun.sleep(0);
});
@@ -834,8 +834,9 @@ describe("ACP agent", () => {
// about yet (matches Zed's `Received session notification for unknown
// session` race that `#scheduleBootstrapUpdates` already guards).
// The fake harness lets us simulate that pre-bootstrap window by
// driving the change before sleeping past the 50ms guard.
// driving the change before advancing past the 50ms guard.
const harness = await createHarness();
vi.useFakeTimers();
const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] });
const session = harness.findSession(created.sessionId)!;
@@ -852,10 +853,9 @@ describe("ACP agent", () => {
notification.update.sessionUpdate === "config_option_update",
);
expect(beforeBootstrap.length).toBe(0);
// After the 50ms bootstrap timer fires the subscription is installed,
// and subsequent changes do surface.
await waitForBootstrapGuard();
// After advancing through the 50ms bootstrap timer, the subscription is
// installed and subsequent changes do surface.
await advanceBootstrapGuard();
const baseline = harness.updates.length;
session.setThinkingLevel("medium");
const afterBootstrap = harness.updates
@@ -867,6 +867,7 @@ describe("ACP agent", () => {
);
expect(afterBootstrap.length).toBeGreaterThanOrEqual(1);
vi.useRealTimers();
harness.abortController.abort();
await Bun.sleep(0);
});
@@ -877,11 +878,12 @@ describe("ACP agent", () => {
// push the notification. The ACP surface must not also push a duplicate
// `config_option_update` of its own.
const harness = await createHarness();
vi.useFakeTimers();
const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] });
// Wait past the bootstrap guard so the lifetime subscription is
// installed and the client-driven setSessionConfigOption produces
// exactly one notification through it.
await waitForBootstrapGuard();
await advanceBootstrapGuard();
const updatesBefore = harness.updates.length;
const response = await harness.agent.setSessionConfigOption({
@@ -907,6 +909,7 @@ describe("ACP agent", () => {
| undefined;
expect(thinkingOption?.currentValue).toBe("high");
vi.useRealTimers();
harness.abortController.abort();
await Bun.sleep(0);
});
@@ -920,9 +923,10 @@ describe("ACP agent", () => {
// Zed's status bar) goes stale the moment prewalk hands off to a
// cheaper model mid-session.
const harness = await createHarness();
vi.useFakeTimers();
const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] });
const session = harness.findSession(created.sessionId)!;
await waitForBootstrapGuard();
await advanceBootstrapGuard();
const updatesBefore = harness.updates.length;
await session.setModel(TEST_MODELS[1]!);
@@ -949,6 +953,7 @@ describe("ACP agent", () => {
await session.setModel(TEST_MODELS[1]!);
expect(harness.updates.length).toBe(updatesBeforeRedundant);
vi.useRealTimers();
harness.abortController.abort();
await Bun.sleep(0);
});
@@ -959,8 +964,9 @@ describe("ACP agent", () => {
// lifetime subscription push the notification. The ACP surface must not
// also push a duplicate `config_option_update` of its own.
const harness = await createHarness();
vi.useFakeTimers();
const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] });
await waitForBootstrapGuard();
await advanceBootstrapGuard();
const updatesBefore = harness.updates.length;
const response = await harness.agent.setSessionConfigOption({
@@ -984,6 +990,7 @@ describe("ACP agent", () => {
| undefined;
expect(modelOption?.currentValue).toBe(`${TEST_MODELS[1]!.provider}/${TEST_MODELS[1]!.id}`);
vi.useRealTimers();
harness.abortController.abort();
await Bun.sleep(0);
});
@@ -1,11 +1,11 @@
import { describe, expect, it } from "bun:test";
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import * as path from "node:path";
import type { Model } from "@oh-my-pi/pi-ai";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { createAcpConnection } from "@oh-my-pi/pi-coding-agent/modes/acp/acp-mode";
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
import {
@@ -18,6 +18,7 @@ import {
type RequestPermissionResponse,
type SessionNotification,
} from "@oh-my-pi/pi-utils/acp";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
const TEST_MODEL: Model = buildModel({
id: "claude-sonnet-4-20250514",
@@ -32,6 +33,19 @@ const TEST_MODEL: Model = buildModel({
maxTokens: 8_192,
});
let startupDir: TempDir;
let startupAuthStorage: AuthStorage;
beforeAll(() => {
startupDir = TempDir.createSync("@omp-acp-startup-shared-");
startupAuthStorage = createInMemoryAuthStorage();
});
afterAll(async () => {
startupAuthStorage.close();
await startupDir.remove();
});
function emptyWorkspaceTree(cwd: string) {
return { rootPath: cwd, rendered: ".\n", truncated: false, totalLines: 1, agentsMdFiles: [] };
}
@@ -149,13 +163,13 @@ class LazyFakeSession {
*/
async function closeTransport(writable: WritableStream<unknown>): Promise<void> {
for (let i = 0; i < 100 && writable.locked; i++) {
await Bun.sleep(0);
await new Promise<void>(resolve => setImmediate(resolve));
}
await Promise.allSettled([writable.close()]);
}
describe("ACP lazy startup", () => {
it("applies schema defaults for ACP background jobs and preserves explicit overrides", async () => {
it("applies schema defaults for ACP background jobs", async () => {
const { runRootCommand } = await import("@oh-my-pi/pi-coding-agent/main");
type ObservedBackgroundSettings = {
@@ -166,9 +180,7 @@ describe("ACP lazy startup", () => {
};
const runAcpStartup = async (settings: Settings): Promise<ObservedBackgroundSettings> => {
using tempDir = TempDir.createSync("@omp-acp-background-settings-");
const cwd = tempDir.path();
const authStorage = await AuthStorage.create(path.join(cwd, "auth.db"));
const cwd = startupDir.path();
let observed: ObservedBackgroundSettings | undefined;
const stopMessage = "stop test ACP mode";
try {
@@ -187,7 +199,7 @@ describe("ACP lazy startup", () => {
},
[],
{
discoverAuthStorage: async () => authStorage,
discoverAuthStorage: async () => startupAuthStorage,
settings,
runAcpMode: async () => {
observed = {
@@ -204,8 +216,6 @@ describe("ACP lazy startup", () => {
if (!(error instanceof Error) || error.message !== stopMessage) {
throw error;
}
} finally {
authStorage.close();
}
if (!observed) {
@@ -214,34 +224,16 @@ describe("ACP lazy startup", () => {
return observed;
};
// ACP startup must not clobber background-job settings: an unset config
// observes the schema defaults (async on since 844c8dbdfe)…
// An unset ACP config observes the background-job schema defaults.
await expect(runAcpStartup(Settings.isolated())).resolves.toEqual({
asyncEnabled: true,
asyncMaxJobs: 100,
bashAutoBackground: false,
bashAutoBackgroundThresholdMs: 60000,
});
// …and explicit overrides survive in both directions (here: async
// opted OUT against the default, auto-background opted IN).
await expect(
runAcpStartup(
Settings.isolated({
"async.enabled": false,
"async.maxJobs": 7,
"bash.autoBackground.enabled": true,
"bash.autoBackground.thresholdMs": 1234,
}),
),
).resolves.toEqual({
asyncEnabled: false,
asyncMaxJobs: 7,
bashAutoBackground: true,
bashAutoBackgroundThresholdMs: 1234,
});
});
it("honors explicit host-defaulted settings for protocol hosts", async () => {
it("honors explicit host-defaulted and todo settings for protocol hosts", async () => {
// Regression for #3207: in RPC/ACP startup, runtime overrides applied via
// `applyDefaultSettingOverrides` previously clobbered any explicitly
// configured value (caller, project, --config overlay, or global) with the
@@ -266,6 +258,9 @@ describe("ACP lazy startup", () => {
"advisor.enabled": true,
"advisor.syncBacklog": "5",
"advisor.immuneTurns": 7,
"todo.enabled": false,
"todo.reminders": false,
"todo.eager": "always",
} as const;
const rpcOnlyExplicit = {
"async.enabled": false,
@@ -280,9 +275,7 @@ describe("ACP lazy startup", () => {
type ObservedSettings = Record<string, unknown>;
const runProtocolStartup = async (mode: "rpc" | "rpc-ui" | "acp"): Promise<ObservedSettings> => {
using tempDir = TempDir.createSync("@omp-protocol-host-defaulted-");
const cwd = tempDir.path();
const authStorage = await AuthStorage.create(path.join(cwd, "auth.db"));
const cwd = startupDir.path();
const settings = Settings.isolated({ ...explicit, ...rpcOnlyExplicit });
let observed: ObservedSettings | undefined;
const stopMessage = "stop test host-defaulted settings";
@@ -311,7 +304,7 @@ describe("ACP lazy startup", () => {
},
[],
{
discoverAuthStorage: async () => authStorage,
discoverAuthStorage: async () => startupAuthStorage,
settings,
createAgentSession: async () => observe(),
runAcpMode: async () => observe(),
@@ -321,8 +314,6 @@ describe("ACP lazy startup", () => {
if (!(error instanceof Error) || error.message !== stopMessage) {
throw error;
}
} finally {
authStorage.close();
}
if (!observed) {
@@ -336,85 +327,12 @@ describe("ACP lazy startup", () => {
}
});
it("honors explicit todo settings for protocol hosts", async () => {
const { runRootCommand } = await import("@oh-my-pi/pi-coding-agent/main");
type ObservedTodoSettings = {
enabled: boolean;
reminders: boolean;
eager: "default" | "preferred" | "always";
};
const runProtocolStartup = async (mode: "rpc" | "rpc-ui" | "acp"): Promise<ObservedTodoSettings> => {
using tempDir = TempDir.createSync("@omp-protocol-todo-settings-");
const cwd = tempDir.path();
const authStorage = await AuthStorage.create(path.join(cwd, "auth.db"));
const settings = Settings.isolated({
"todo.enabled": false,
"todo.reminders": false,
"todo.eager": "always",
});
let observed: ObservedTodoSettings | undefined;
const stopMessage = "stop test protocol todo settings";
const observe = () => {
observed = {
enabled: settings.get("todo.enabled"),
reminders: settings.get("todo.reminders"),
eager: settings.get("todo.eager"),
};
throw new Error(stopMessage);
};
try {
await runRootCommand(
{
mode,
messages: [],
fileArgs: [],
unknownFlags: new Map(),
unrecognizedFlags: [],
noSkills: true,
noRules: true,
noTools: true,
noLsp: true,
noExtensions: true,
sessionDir: cwd,
},
[],
{
discoverAuthStorage: async () => authStorage,
settings,
createAgentSession: async () => observe(),
runAcpMode: async () => observe(),
},
);
} catch (error) {
if (!(error instanceof Error) || error.message !== stopMessage) {
throw error;
}
} finally {
authStorage.close();
}
if (!observed) {
throw new Error("Expected protocol mode to start");
}
return observed;
};
for (const mode of ["rpc", "rpc-ui", "acp"] as const) {
await expect(runProtocolStartup(mode)).resolves.toEqual({
enabled: false,
reminders: false,
eager: "always",
});
}
});
it("answers initialize before creating the first AgentSession", async () => {
const clientToAgent = new TransformStream();
const agentToClient = new TransformStream();
const client = new TestClient();
let createCalls = 0;
const creationStarted = Promise.withResolvers<void>();
const blockedCreation = Promise.withResolvers<AgentSession>();
const agentConnection = new ClientSideConnection(
@@ -424,6 +342,7 @@ describe("ACP lazy startup", () => {
const serverConnection = createAcpConnection(
ndJsonStream(agentToClient.writable, clientToAgent.readable),
async cwd => {
creationStarted.resolve();
createCalls++;
if (createCalls === 1) {
return await blockedCreation.promise;
@@ -433,12 +352,7 @@ describe("ACP lazy startup", () => {
);
try {
const initializeResponse = await Promise.race([
agentConnection.initialize({ protocolVersion: 1, clientCapabilities: {} }),
Bun.sleep(50).then(() => "timeout" as const),
]);
expect(initializeResponse).not.toBe("timeout");
const initializeResponse = await agentConnection.initialize({ protocolVersion: 1, clientCapabilities: {} });
expect(initializeResponse).toEqual(
expect.objectContaining({
protocolVersion: 1,
@@ -448,7 +362,7 @@ describe("ACP lazy startup", () => {
expect(createCalls).toBe(0);
const newSessionPromise = agentConnection.newSession({ cwd: "/tmp/acp-lazy-startup", mcpServers: [] });
await Bun.sleep(20);
await creationStarted.promise;
expect(createCalls).toBe(1);
blockedCreation.resolve(new LazyFakeSession("/tmp/acp-lazy-startup") as unknown as AgentSession);
@@ -486,7 +400,7 @@ describe("ACP lazy startup", () => {
`,
);
const authStorage = await AuthStorage.create(path.join(cwd, "auth.db"));
const authStorage = createInMemoryAuthStorage();
try {
const settings = Settings.isolated({ "marketplace.autoUpdate": "off" });
const { runRootCommand } = await import("@oh-my-pi/pi-coding-agent/main");
@@ -12,22 +12,26 @@
* `enableMCP: false`, regardless of what `baseOptions` carries.
*/
import { describe, expect, it } from "bun:test";
import { afterAll, describe, expect, it } from "bun:test";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { createAcpSessionFactory } from "@oh-my-pi/pi-coding-agent/main";
import type { CreateAgentSessionOptions, CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk";
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { TempDir } from "@oh-my-pi/pi-utils";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
const authStorage = createInMemoryAuthStorage();
const modelRegistry = new ModelRegistry(authStorage);
afterAll(() => {
authStorage.close();
});
describe("createAcpSessionFactory MCP isolation (issue #1234)", () => {
it("forces enableMCP=false even when baseOptions opts in", async () => {
const tempDir = TempDir.createSync("@pi-acp-mcp-isolation-");
let authStorage: AuthStorage | undefined;
try {
authStorage = await AuthStorage.create(tempDir.join("auth.db"));
const modelRegistry = new ModelRegistry(authStorage);
const settings = Settings.isolated({});
const fakeSession = {} as AgentSession;
const captured: CreateAgentSessionOptions[] = [];
@@ -66,21 +70,13 @@ describe("createAcpSessionFactory MCP isolation (issue #1234)", () => {
expect(captured).toHaveLength(1);
expect(captured[0].enableMCP).toBe(false);
} finally {
try {
authStorage?.close();
} finally {
await Bun.sleep(0);
await tempDir.remove();
}
}
});
it("rejects allowlisted tools absent from the completed ACP session registry", async () => {
const tempDir = TempDir.createSync("@pi-acp-tool-allowlist-");
let authStorage: AuthStorage | undefined;
try {
authStorage = await AuthStorage.create(tempDir.join("auth.db"));
const modelRegistry = new ModelRegistry(authStorage);
const settings = Settings.isolated({});
let disposed = false;
const fakeSession = {
@@ -103,21 +99,14 @@ describe("createAcpSessionFactory MCP isolation (issue #1234)", () => {
await expect(factory(tempDir.path())).rejects.toThrow(/Unknown tool in --tools: missing/);
expect(disposed).toBe(true);
} finally {
try {
authStorage?.close();
} finally {
await tempDir.remove();
}
}
});
it("shares the trusted extension EventBus with the ACP session", async () => {
const tempDir = TempDir.createSync("@pi-acp-trusted-extension-");
let authStorage: AuthStorage | undefined;
try {
authStorage = await AuthStorage.create(tempDir.join("auth.db"));
const modelRegistry = new ModelRegistry(authStorage);
const settings = Settings.isolated({});
const trustedPath = tempDir.join("trusted.ts");
const firedPath = tempDir.join("trusted-event-fired");
@@ -161,21 +150,14 @@ describe("createAcpSessionFactory MCP isolation (issue #1234)", () => {
expect(captured?.preloadedExtensions?.extensions).toHaveLength(1);
expect(await Bun.file(firedPath).text()).toBe("fired");
expect(await Bun.file(ambientFiredPath).exists()).toBe(false);
} finally {
try {
authStorage?.close();
} finally {
await tempDir.remove();
}
}
});
it("fails before ACP session creation when a trusted extension cannot load", async () => {
const tempDir = TempDir.createSync("@pi-acp-trusted-extension-failure-");
let authStorage: AuthStorage | undefined;
try {
authStorage = await AuthStorage.create(tempDir.join("auth.db"));
const modelRegistry = new ModelRegistry(authStorage);
const settings = Settings.isolated({});
const trustedPath = tempDir.join("throwing.ts");
await Bun.write(trustedPath, 'throw new Error("trusted extension fixture");');
@@ -199,23 +181,16 @@ describe("createAcpSessionFactory MCP isolation (issue #1234)", () => {
await expect(factory(tempDir.path())).rejects.toThrow(/Trusted extension failed to load.*fixture/);
expect(createCalls).toBe(0);
} finally {
try {
authStorage?.close();
} finally {
await tempDir.remove();
}
}
});
});
describe("createAcpSessionFactory TITLE_SYSTEM.md per-cwd resolution (PR #3736)", () => {
it("re-resolves the title prompt for the per-session cwd instead of inheriting the launch cwd's override", async () => {
const tempDir = TempDir.createSync("@pi-acp-title-prompt-");
let authStorage: AuthStorage | undefined;
try {
authStorage = await AuthStorage.create(tempDir.join("auth.db"));
const modelRegistry = new ModelRegistry(authStorage);
const settings = Settings.isolated({});
const projectDir = tempDir.join("project");
@@ -261,12 +236,7 @@ describe("createAcpSessionFactory TITLE_SYSTEM.md per-cwd resolution (PR #3736)"
expect(captured).toHaveLength(1);
expect(captured[0].titleSystemPrompt).toBe("Project-specific title policy.");
} finally {
try {
authStorage?.close();
} finally {
await Bun.sleep(0);
await tempDir.remove();
}
}
});
});
@@ -19,11 +19,9 @@ const cleanupRoots: string[] = [];
let activeProc: AcpProc | undefined;
/**
* Tear the child down hard. SIGTERM first so the process gets a chance to
* unwind, but force-kill quickly if it hasn't reaped — `omp acp` blocks on
* stdin reads and won't notice SIGTERM until we close the pipes. We bound
* the entire shutdown to ~2s so a stuck child never trips Bun's 5s hook
* timeout (which is what produced the "afterEach hook timed out" flakes).
* Tear the child down deterministically. Once the initialize frame has been
* asserted there is no graceful-shutdown behavior under test, so close stdin
* and kill the throwaway process rather than parking on a grace-period timer.
*/
async function teardown(proc: AcpProc): Promise<void> {
// Close stdin so any blocking read in the child wakes up.
@@ -44,28 +42,11 @@ async function teardown(proc: AcpProc): Promise<void> {
}
try {
proc.kill("SIGTERM");
proc.kill("SIGKILL");
} catch {
// already exited
}
// Race the natural exit against a short grace, then escalate to SIGKILL
// and race again against a hard cap. `await proc.exited` after SIGKILL
// always returns promptly on Darwin/Linux.
const graceMs = 200;
const hardCapMs = 1500;
const exited = proc.exited;
const raced = await Promise.race([
exited.then(() => "exited" as const),
Bun.sleep(graceMs).then(() => "grace" as const),
]);
if (raced === "exited") return;
try {
proc.kill("SIGKILL");
} catch {
// already exited between the SIGTERM and SIGKILL
}
await Promise.race([exited, Bun.sleep(hardCapMs)]);
await proc.exited;
}
afterEach(async () => {
@@ -185,9 +166,8 @@ describe("ACP stdout hygiene", () => {
// First frame is good. Tear the child down now so the test body's
// wall time is bounded by "boot + first frame", not by waiting for
// stderr or a delayed shutdown. teardown() closes stdin/stdout/stderr
// and escalates SIGTERM→SIGKILL, which both stops the child and
// resolves stderrPump.
// stderr or a delayed shutdown. teardown() closes the pipes and kills
// the throwaway child, which also resolves stderrPump.
await teardown(proc);
activeProc = undefined;
await stderrPump;
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import { Agent, type AgentMessage, type CompactionSummaryMessage, countTokens } from "@oh-my-pi/pi-agent-core";
import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction";
import { calculateContextTokens, estimateTokens, resolveThresholdTokens } from "@oh-my-pi/pi-agent-core/compaction";
@@ -9,9 +9,10 @@ import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { estimateToolSchemaTokens } from "@oh-my-pi/pi-coding-agent/modes/utils/context-usage";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
const CONTEXT_WINDOW = 372_000;
const CACHE_READ_TOKENS = 371_200;
@@ -34,15 +35,18 @@ describe("AgentSession advisor context maintenance", () => {
let authStorage: AuthStorage;
let session: AgentSession;
beforeEach(async () => {
beforeAll(() => {
tempDir = TempDir.createSync("@pi-advisor-context-maintenance-");
authStorage = await AuthStorage.create(tempDir.join("auth.db"));
authStorage = createInMemoryAuthStorage();
authStorage.setRuntimeApiKey("anthropic", "test-key");
});
afterEach(async () => {
vi.restoreAllMocks();
await session?.dispose();
});
afterAll(async () => {
authStorage.close();
await tempDir.remove();
});
@@ -237,8 +241,6 @@ describe("AgentSession advisor context maintenance", () => {
releaseCredential.resolve();
await credentialReturned.promise;
await prompt;
await Bun.sleep(0);
expect(credentialSignal?.aborted).toBe(true);
expect(session.getAdvisorAgent()?.state.model).toBe(advisorMock);
});
@@ -1,14 +1,13 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { Effort, type Model } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
// Regression for https://github.com/can1357/oh-my-pi/issues/4579.
//
@@ -24,15 +23,13 @@ import { TempDir } from "@oh-my-pi/pi-utils";
// `auto-thinking-classifier.test.ts:145` for `clampAutoThinkingEffort`, at the
// advisor descriptor boundary.
describe("AgentSession advisor descriptor thinking level", () => {
let sharedDir: TempDir;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
let anthropicModel: Model;
let devinModel: Model;
beforeAll(async () => {
sharedDir = TempDir.createSync("@pi-advisor-devin-thinking-shared-");
authStorage = await AuthStorage.create(path.join(sharedDir.path(), "testauth.db"));
beforeAll(() => {
authStorage = createInMemoryAuthStorage();
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
const anthropic = getBundledModel("anthropic", "claude-sonnet-4-5");
@@ -68,20 +65,15 @@ describe("AgentSession advisor descriptor thinking level", () => {
devinModel = devin;
});
afterAll(async () => {
afterAll(() => {
authStorage.close();
try {
await sharedDir.remove();
} catch {}
});
let tempDir: TempDir;
let session: AgentSession;
let sessionManager: SessionManager;
beforeEach(async () => {
tempDir = TempDir.createSync("@pi-advisor-devin-thinking-");
sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
beforeEach(() => {
sessionManager = SessionManager.inMemory("/tmp/advisor-devin-thinking");
const agent = new Agent({
initialState: {
model: anthropicModel,
@@ -102,9 +94,6 @@ describe("AgentSession advisor descriptor thinking level", () => {
afterEach(async () => {
await session.dispose();
try {
await tempDir.remove();
} catch {}
});
it("Devin advisor with no configured thinking suffix boots without an unsupported-effort throw", () => {
@@ -11,7 +11,6 @@
* and its explicit websocket preference.
*/
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
import * as path from "node:path";
import { Agent, type StreamFn } from "@oh-my-pi/pi-agent-core";
import type { FetchImpl, Model, SimpleStreamOptions } from "@oh-my-pi/pi-ai";
import { streamSimple } from "@oh-my-pi/pi-ai";
@@ -19,9 +18,10 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
/** Provider-facing advisor session ids must be UUIDv7 (issue #5040): Codex writes
* them verbatim onto `conversation_id`/`session_id` headers, so `-advisor`
@@ -41,14 +41,12 @@ function metadataSessionId(options: SimpleStreamOptions | undefined): string {
}
describe("AgentSession advisor provider-options parity", () => {
let sharedDir: TempDir;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
let model: Model;
beforeAll(async () => {
sharedDir = TempDir.createSync("@pi-advisor-parity-shared-");
authStorage = await AuthStorage.create(path.join(sharedDir.path(), "testauth.db"));
beforeAll(() => {
authStorage = createInMemoryAuthStorage();
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
const bundled = getBundledModel("anthropic", "claude-sonnet-4-5");
@@ -56,11 +54,8 @@ describe("AgentSession advisor provider-options parity", () => {
model = bundled;
});
afterAll(async () => {
afterAll(() => {
authStorage.close();
try {
await sharedDir.remove();
} catch {}
});
let tempDir: TempDir;
@@ -73,7 +68,7 @@ describe("AgentSession advisor provider-options parity", () => {
"model.loopGuard.enabled": true,
});
beforeEach(async () => {
beforeEach(() => {
tempDir = TempDir.createSync("@pi-advisor-parity-");
sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
});
@@ -14,20 +14,19 @@ import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/ex
import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AgentStorage } from "@oh-my-pi/pi-coding-agent/session/agent-storage";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { getProjectAgentDir, TempDir } from "@oh-my-pi/pi-utils";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
describe("AgentSession advisor toggle", () => {
let sharedDir: TempDir;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
let model: Model;
let replacementModel: Model;
beforeAll(async () => {
sharedDir = TempDir.createSync("@pi-advisor-toggle-shared-");
authStorage = await AuthStorage.create(path.join(sharedDir.path(), "testauth.db"));
beforeAll(() => {
authStorage = createInMemoryAuthStorage();
authStorage.setRuntimeApiKey("anthropic", "test-key");
authStorage.setRuntimeApiKey("openai", "test-key");
authStorage.setRuntimeApiKey("openrouter", "test-key");
@@ -40,11 +39,8 @@ describe("AgentSession advisor toggle", () => {
replacementModel = replacement;
});
afterAll(async () => {
afterAll(() => {
authStorage.close();
try {
await sharedDir.remove();
} catch {}
});
let tempDir: TempDir;
@@ -6,31 +6,38 @@ import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk";
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
import { discoverWatchdogFiles } from "../src/advisor/watchdog";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
describe("advisor watchdog prompt discovery", () => {
const tempDirs: TempDir[] = [];
afterEach(async () => {
await Bun.sleep(0);
for (const tempDir of tempDirs.splice(0)) {
await tempDir.remove();
}
});
async function withAdvisorHistory(
tempDir: TempDir,
cwd: string,
run: (dump: string) => void | Promise<void>,
): Promise<void> {
const authStorage = await AuthStorage.create(tempDir.join("testauth.db"));
it("appends WATCHDOG.md and active child repo context to the advisor prompt", async () => {
const tempDir = TempDir.createSync("@pi-advisor-watchdog-");
tempDirs.push(tempDir);
const cwd = tempDir.join("project-root");
fs.mkdirSync(cwd, { recursive: true });
fs.mkdirSync(path.join(cwd, "active-project", ".git"), { recursive: true });
// Write a WATCHDOG.md file
const watchdogContent = "Watchdog rule: Watch out for cheating on edits.";
fs.writeFileSync(path.join(cwd, "WATCHDOG.md"), watchdogContent, "utf8");
const activeRepoMarker = "`active-project`";
const authStorage = createInMemoryAuthStorage();
let session: AgentSession | undefined;
try {
authStorage.setRuntimeApiKey("openai", "test-key");
const modelRegistry = new ModelRegistry(authStorage);
const sessionManager = SessionManager.create(cwd, tempDir.join("sessions"));
const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml"));
const sessionManager = SessionManager.inMemory(cwd);
const result = await createAgentSession({
cwd,
agentDir: tempDir.path(),
@@ -63,66 +70,14 @@ describe("advisor watchdog prompt discovery", () => {
});
session = result.session;
expect(session.isAdvisorActive()).toBe(true);
const dump = session.formatAdvisorHistoryAsText();
if (dump === null) throw new Error("Advisor history was not available.");
await run(dump);
} finally {
try {
await session?.dispose();
} finally {
authStorage.close();
}
}
}
it("discovers and appends WATCHDOG.md to the advisor prompt", async () => {
const tempDir = TempDir.createSync("@pi-advisor-watchdog-");
tempDirs.push(tempDir);
const cwd = tempDir.join("project-root");
fs.mkdirSync(cwd, { recursive: true });
// Write a WATCHDOG.md file
const watchdogContent = "Watchdog rule: Watch out for cheating on edits.";
fs.writeFileSync(path.join(cwd, "WATCHDOG.md"), watchdogContent, "utf8");
const authStorage = await AuthStorage.create(tempDir.join("testauth.db"));
let session: AgentSession | undefined;
try {
authStorage.setRuntimeApiKey("openai", "test-key");
const modelRegistry = new ModelRegistry(authStorage);
const sessionManager = SessionManager.create(cwd, tempDir.join("sessions"));
const result = await createAgentSession({
cwd,
agentDir: tempDir.path(),
sessionManager,
authStorage,
modelRegistry,
settings: (() => {
const s = Settings.isolated({
"async.enabled": false,
"advisor.enabled": true,
});
s.setModelRole("advisor", "openai/gpt-4o-mini");
return s;
})(),
model: getBundledModel("openai", "gpt-4o-mini"),
disableExtensionDiscovery: true,
skills: [],
contextFiles: [],
promptTemplates: [],
slashCommands: [],
enableMCP: false,
enableLsp: false,
});
session = result.session;
expect(session.isAdvisorActive()).toBe(true);
const dump = session.formatAdvisorHistoryAsText();
expect(dump).not.toBeNull();
expect(dump).toContain("Especially pay attention to:");
expect(dump).toContain("<attention>");
expect(dump).toContain(watchdogContent);
expect(dump).toContain(activeRepoMarker);
expect(dump!.indexOf(watchdogContent)).toBeLessThan(dump!.indexOf(activeRepoMarker));
expect(dump).toContain("</attention>");
} finally {
try {
@@ -133,39 +88,12 @@ describe("advisor watchdog prompt discovery", () => {
}
});
it("adds built-in active child repo context to the advisor prompt", async () => {
const tempDir = TempDir.createSync("@pi-advisor-watchdog-");
tempDirs.push(tempDir);
const cwd = tempDir.join("parent-cwd");
fs.mkdirSync(path.join(cwd, "active-project", ".git"), { recursive: true });
const watchdogContent = "Parent watchdog remains before built-in active repo context.";
fs.writeFileSync(path.join(cwd, "WATCHDOG.md"), watchdogContent, "utf8");
await withAdvisorHistory(tempDir, cwd, dump => {
expect(dump).toContain("`active-project`");
expect(dump).toContain(watchdogContent);
expect(dump.indexOf(watchdogContent)).toBeLessThan(dump.indexOf("`active-project`"));
});
});
it("omits built-in active child repo context when multiple direct child repos exist", async () => {
const tempDir = TempDir.createSync("@pi-advisor-watchdog-");
tempDirs.push(tempDir);
const cwd = tempDir.join("parent-cwd");
fs.mkdirSync(path.join(cwd, "active-project", ".git"), { recursive: true });
fs.mkdirSync(path.join(cwd, "second-project", ".git"), { recursive: true });
await withAdvisorHistory(tempDir, cwd, dump => {
expect(dump).not.toContain("exactly one direct child git repository");
expect(dump).not.toContain("Do not claim work is missing, destroyed, or absent at the parent cwd");
});
});
it("resolves nested folders and sorts by depth", async () => {
const tempDir = TempDir.createSync("@pi-advisor-watchdog-");
tempDirs.push(tempDir);
const parentCwd = tempDir.join("project-root");
const childCwd = path.join(parentCwd, "subfolder");
fs.mkdirSync(path.join(parentCwd, ".git"), { recursive: true });
fs.mkdirSync(childCwd, { recursive: true });
// Write two WATCHDOG.md files
@@ -174,59 +102,18 @@ describe("advisor watchdog prompt discovery", () => {
fs.writeFileSync(path.join(parentCwd, "WATCHDOG.md"), parentWatchdogContent, "utf8");
fs.writeFileSync(path.join(childCwd, "WATCHDOG.md"), childWatchdogContent, "utf8");
const authStorage = await AuthStorage.create(tempDir.join("testauth.db"));
let session: AgentSession | undefined;
try {
authStorage.setRuntimeApiKey("openai", "test-key");
const modelRegistry = new ModelRegistry(authStorage);
const sessionManager = SessionManager.create(childCwd, tempDir.join("sessions"));
const result = await createAgentSession({
cwd: childCwd,
agentDir: tempDir.path(),
sessionManager,
authStorage,
modelRegistry,
settings: (() => {
const s = Settings.isolated({
"async.enabled": false,
"advisor.enabled": true,
});
s.setModelRole("advisor", "openai/gpt-4o-mini");
return s;
})(),
model: getBundledModel("openai", "gpt-4o-mini"),
disableExtensionDiscovery: true,
skills: [],
contextFiles: [],
promptTemplates: [],
slashCommands: [],
enableMCP: false,
enableLsp: false,
});
session = result.session;
expect(session.isAdvisorActive()).toBe(true);
const dump = session.formatAdvisorHistoryAsText();
expect(dump).not.toBeNull();
const dump = (await discoverWatchdogFiles(childCwd, tempDir.path())).join("\n\n");
expect(dump).toContain("Especially pay attention to:");
expect(dump).toContain("<attention>");
expect(dump).toContain("</attention>");
expect(dump).toContain(parentWatchdogContent);
expect(dump).toContain(childWatchdogContent);
// Check ordering: parent is farther (depth 1), child is closer (depth 0).
// So parent watchdog should appear first, followed by child watchdog.
const parentIndex = dump!.indexOf(parentWatchdogContent);
const childIndex = dump!.indexOf(childWatchdogContent);
// Parent is farther (depth 1), so it must precede the leaf watchdog.
const parentIndex = dump.indexOf(parentWatchdogContent);
const childIndex = dump.indexOf(childWatchdogContent);
expect(parentIndex).toBeGreaterThan(-1);
expect(childIndex).toBeGreaterThan(-1);
expect(parentIndex).toBeLessThan(childIndex);
} finally {
try {
await session?.dispose();
} finally {
authStorage.close();
}
}
});
it("discovers user-level and native project-level watchdog files", async () => {
@@ -236,6 +123,7 @@ describe("advisor watchdog prompt discovery", () => {
const ompDir = path.join(cwd, ".omp");
const userAgentDir = tempDir.join("user-agent");
fs.mkdirSync(cwd, { recursive: true });
fs.mkdirSync(path.join(cwd, ".git"), { recursive: true });
fs.mkdirSync(ompDir, { recursive: true });
fs.mkdirSync(userAgentDir, { recursive: true });
@@ -247,64 +135,19 @@ describe("advisor watchdog prompt discovery", () => {
fs.writeFileSync(path.join(ompDir, "WATCHDOG.md"), nativeWatchdogContent, "utf8");
fs.writeFileSync(path.join(cwd, "WATCHDOG.md"), standaloneWatchdogContent, "utf8");
const authStorage = await AuthStorage.create(tempDir.join("testauth.db"));
let session: AgentSession | undefined;
try {
authStorage.setRuntimeApiKey("openai", "test-key");
const modelRegistry = new ModelRegistry(authStorage);
const sessionManager = SessionManager.create(cwd, tempDir.join("sessions"));
const result = await createAgentSession({
cwd,
agentDir: userAgentDir,
sessionManager,
authStorage,
modelRegistry,
settings: (() => {
const s = Settings.isolated({
"async.enabled": false,
"advisor.enabled": true,
});
s.setModelRole("advisor", "openai/gpt-4o-mini");
return s;
})(),
model: getBundledModel("openai", "gpt-4o-mini"),
disableExtensionDiscovery: true,
skills: [],
contextFiles: [],
promptTemplates: [],
slashCommands: [],
enableMCP: false,
enableLsp: false,
});
session = result.session;
expect(session.isAdvisorActive()).toBe(true);
const dump = session.formatAdvisorHistoryAsText();
expect(dump).not.toBeNull();
const dump = (await discoverWatchdogFiles(cwd, userAgentDir)).join("\n\n");
expect(dump).toContain(userWatchdogContent);
expect(dump).toContain(nativeWatchdogContent);
expect(dump).toContain(standaloneWatchdogContent);
// Check ordering: user-level should appear first, then native project level (.omp/WATCHDOG.md has depth 0),
// then standalone project level (cwd/WATCHDOG.md has depth 0).
// Between native and standalone, they both have depth 0, so their relative order doesn't strictly matter
// as long as user-level comes before both of them.
const userIndex = dump!.indexOf(userWatchdogContent);
const nativeIndex = dump!.indexOf(nativeWatchdogContent);
const standaloneIndex = dump!.indexOf(standaloneWatchdogContent);
// User-level instructions precede both project-level variants.
const userIndex = dump.indexOf(userWatchdogContent);
const nativeIndex = dump.indexOf(nativeWatchdogContent);
const standaloneIndex = dump.indexOf(standaloneWatchdogContent);
expect(userIndex).toBeGreaterThan(-1);
expect(nativeIndex).toBeGreaterThan(-1);
expect(standaloneIndex).toBeGreaterThan(-1);
expect(userIndex).toBeLessThan(nativeIndex);
expect(userIndex).toBeLessThan(standaloneIndex);
} finally {
try {
await session?.dispose();
} finally {
authStorage.close();
}
}
});
});
@@ -38,7 +38,10 @@ import { YieldQueue } from "../../src/session/yield-queue";
* failing advisor), so failure-path tests cannot use it as a settle barrier. */
async function settleUntil(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!predicate() && Date.now() < deadline) await Bun.sleep(2);
while (!predicate()) {
if (Date.now() >= deadline) throw new Error(`Advisor did not settle within ${timeoutMs}ms`);
await new Promise<void>(resolve => setImmediate(resolve));
}
}
function promptText(input: string | AgentMessage[]): string {
@@ -974,7 +977,14 @@ describe("advisor", () => {
runtime.onTurnEnd();
await promptStarted.promise;
expect(await runtime.waitForCatchup(20, 1)).toBe(false);
vi.useFakeTimers();
try {
const catchup = runtime.waitForCatchup(20, 1);
vi.advanceTimersByTime(20);
expect(await catchup).toBe(false);
} finally {
vi.useRealTimers();
}
expect(runtime.backlog).toBe(1);
releasePrompt.resolve();
@@ -2634,6 +2644,11 @@ describe("advisor", () => {
state.error = overflowMessage;
} else {
state.error = undefined;
state.messages.push({
role: "assistant",
content: [{ type: "text", text: "ok" }],
timestamp: Date.now(),
} as AgentMessage);
}
},
abort: () => {},
@@ -2996,8 +3011,7 @@ describe("advisor", () => {
const runtime = new AdvisorRuntime(agent, host, 0);
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(() => promptInputs.length === 2 && runtime.backlog === 0);
expect(promptInputs).toHaveLength(2);
expect(runtime.backlog).toBe(0);
@@ -3022,9 +3036,7 @@ describe("advisor", () => {
const runtime = new AdvisorRuntime(agent, host, 0);
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(() => promptInputs.length === 3 && runtime.backlog === 0);
expect(promptInputs).toHaveLength(3);
expect(runtime.backlog).toBe(0);
@@ -3054,9 +3066,7 @@ describe("advisor", () => {
const runtime = new AdvisorRuntime(agent, host, 0);
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(() => promptInputs.length === 3 && failures.length === 1 && runtime.backlog === 0);
expect(promptInputs).toHaveLength(3);
expect(failures).toHaveLength(1);
@@ -3067,9 +3077,7 @@ describe("advisor", () => {
messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage);
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(() => promptInputs.length === 6 && runtime.backlog === 0);
expect(promptInputs).toHaveLength(6);
expect(failures).toHaveLength(1);
@@ -3077,15 +3085,13 @@ describe("advisor", () => {
shouldFail = false;
messages.push({ role: "user", content: "ccc", timestamp: 3 } as AgentMessage);
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await settleUntil(() => promptInputs.length === 7 && runtime.backlog === 0);
expect(failures).toHaveLength(1);
shouldFail = true;
messages.push({ role: "user", content: "ddd", timestamp: 4 } as AgentMessage);
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(() => promptInputs.length === 10 && failures.length === 2 && runtime.backlog === 0);
expect(failures).toHaveLength(2);
});
@@ -3117,9 +3123,7 @@ describe("advisor", () => {
const runtime = new AdvisorRuntime(agent, host, 0);
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(() => promptInputs.length === 3 && failures.length === 1 && runtime.halted);
expect(promptInputs).toHaveLength(3);
expect(failures).toHaveLength(1);
@@ -3128,8 +3132,6 @@ describe("advisor", () => {
// New deltas must be ignored while halted — no further prompts.
messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage);
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
expect(promptInputs).toHaveLength(3);
// The catch-up gate must not park the primary agent on a runtime that
@@ -3164,9 +3166,7 @@ describe("advisor", () => {
const runTurn = async (content: string) => {
messages.push({ role: "user", content, timestamp: messages.length + 1 } as AgentMessage);
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(() => runtime.backlog === 0);
};
// Two failing drop cycles, then a success: the cycle counter resets.
@@ -3292,14 +3292,11 @@ describe("advisor", () => {
) as AgentMessage;
};
const waitForPrompts = async (
const waitForPrompts = (
prompts: Array<string | AgentMessage[]>,
count: number,
timeoutMs = 10_000,
): Promise<void> => {
const deadline = Date.now() + timeoutMs;
while (prompts.length < count && Date.now() < deadline) await Bun.sleep(5);
};
): Promise<void> => settleUntil(() => prompts.length >= count, timeoutMs);
it("delivers a multi-MB transcript replay completely", async () => {
const promptInputs: Array<string | AgentMessage[]> = [];
@@ -3457,15 +3454,10 @@ describe("advisor", () => {
// Second turn arrives immediately behind the first.
messages.push({ role: "user", content: "late-arrival tail", timestamp: 300 } as AgentMessage);
runtime.onTurnEnd(messages);
const deadline = Date.now() + 10_000;
while (
Date.now() < deadline &&
!promptInputs
.map(i => promptText(i))
.join("\n")
.includes("late-arrival tail")
)
await Bun.sleep(5);
await settleUntil(
() => promptInputs.some(input => promptText(input).includes("late-arrival tail")),
10_000,
);
const combined = promptInputs.map(i => promptText(i)).join("\n");
// Every message exactly once, ordering preserved.
expect(combined).toContain("msg-0 ");
@@ -3510,9 +3502,7 @@ describe("advisor", () => {
const runtime = new AdvisorRuntime(agent, host, 0);
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(() => promptInputs.length === 3 && failures.length === 1 && runtime.backlog === 0);
expect(promptInputs).toHaveLength(3);
expect(failures).toHaveLength(1);
@@ -3524,15 +3514,13 @@ describe("advisor", () => {
shouldFail = false;
messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage);
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await settleUntil(() => promptInputs.length === 4 && runtime.backlog === 0);
expect(failures).toHaveLength(1);
shouldFail = true;
messages.push({ role: "user", content: "ccc", timestamp: 3 } as AgentMessage);
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(() => promptInputs.length === 7 && failures.length === 2 && runtime.backlog === 0);
expect(failures).toHaveLength(2);
});
@@ -4454,9 +4442,9 @@ describe("advisor", () => {
const runtime = new AdvisorRuntime(agent, host, 0);
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(
() => lengthsBeforePrompt.length === 3 && rollbackCalls.length === 3 && runtime.backlog === 0,
);
// Three failed prompts each rolled back to the empty baseline, so every retry
// saw a clean state.messages instead of stacked failed turns.
@@ -4472,7 +4460,7 @@ describe("advisor", () => {
shouldFail = false;
messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage);
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await settleUntil(() => lengthsBeforePrompt.length === 4 && runtime.backlog === 0);
expect(lengthsBeforePrompt[lengthsBeforePrompt.length - 1]).toBe(0);
expect(rollbackCalls).toHaveLength(3);
@@ -4699,8 +4687,6 @@ describe("advisor", () => {
messages.length = 0;
messages.push({ role: "user", content: "new-conversation", timestamp: 2 } as AgentMessage);
runtime.reset();
await Bun.sleep(0);
await Bun.sleep(0);
expect(promptInputs).toHaveLength(1);
expect(runtime.backlog).toBe(0);
@@ -4708,7 +4694,7 @@ describe("advisor", () => {
// The runtime still works afterward: the next turn replays the new
// transcript only, never the dropped pre-reset content.
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await settleUntil(() => promptInputs.length === 2 && runtime.backlog === 0);
expect(promptInputs).toHaveLength(2);
expect(promptText(promptInputs[1])).toContain("new-conversation");
expect(promptText(promptInputs[1])).not.toContain("old-conversation");
@@ -4834,19 +4820,14 @@ describe("advisor", () => {
runtime.onTurnEnd([{ role: "user", content: "old session", timestamp: 1 } as AgentMessage]);
await hookStarted.promise;
const pause = runtime.pauseForSessionTransition();
const pausedQuickly = await Promise.race([pause.then(() => true), Bun.sleep(50).then(() => false)]);
await pause;
runtime.reset();
runtime.onTurnEnd([{ role: "user", content: "replacement session", timestamp: 2 } as AgentMessage]);
const replacementRan = await Promise.race([
replacementPromptStarted.promise.then(() => true),
Bun.sleep(50).then(() => false),
]);
await replacementPromptStarted.promise;
releaseHook.resolve();
await pause;
runtime.dispose();
expect(pausedQuickly).toBe(true);
expect(replacementRan).toBe(true);
expect(promptCalls).toBe(2);
},
);
it("aborts retry backoff before pausing for a session transition", async () => {
@@ -4869,18 +4850,15 @@ describe("advisor", () => {
return false;
},
},
250,
60_000,
);
runtime.onTurnEnd([{ role: "user", content: "retry me", timestamp: 1 } as AgentMessage]);
await recoveryStarted.promise;
await Bun.sleep(0);
const pause = runtime.pauseForSessionTransition();
const pausedQuickly = await Promise.race([pause.then(() => true), Bun.sleep(50).then(() => false)]);
if (!pausedQuickly) await pause;
// Let the failed turn enter its retry backoff before pausing it.
await new Promise<void>(resolve => setImmediate(resolve));
await runtime.pauseForSessionTransition();
runtime.dispose();
expect(pausedQuickly).toBe(true);
});
});
@@ -4912,8 +4890,7 @@ describe("advisor", () => {
const messages: AgentMessage[] = [{ role: "user", content: "first", timestamp: 1 } as AgentMessage];
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(() => runtime.quotaExhausted && quotaNotified);
// Quota path: single prompt attempt, no retries, no generic failure.
expect(promptInputs).toHaveLength(1);
@@ -4924,7 +4901,6 @@ describe("advisor", () => {
// Subsequent turns are skipped while quota-exhausted.
messages.push({ role: "user", content: "second", timestamp: 2 } as AgentMessage);
runtime.onTurnEnd(messages);
await Bun.sleep(0);
expect(promptInputs).toHaveLength(1);
});
@@ -4949,9 +4925,7 @@ describe("advisor", () => {
const messages: AgentMessage[] = [{ role: "user", content: "first", timestamp: 1 } as AgentMessage];
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(() => promptInputs.length === 3 && failures.length === 1 && runtime.backlog === 0);
// Overloaded follows the 3-retry → notifyFailure path, not the quota path.
expect(promptInputs).toHaveLength(3);
@@ -4978,8 +4952,7 @@ describe("advisor", () => {
const runtime = new AdvisorRuntime(agent, host, 0);
const messages: AgentMessage[] = [{ role: "user", content: "quota-turn", timestamp: 1 } as AgentMessage];
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(() => runtime.quotaExhausted && promptInputs.length === 1);
// The batch must remain in the queue (backlog > 0) so it's replayed
// once the quota window resets, instead of being silently dropped.
@@ -4998,8 +4971,7 @@ describe("advisor", () => {
shouldFail = false;
runtime.reset();
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(() => promptInputs.length === 2 && runtime.backlog === 0);
expect(promptText(promptInputs.at(-1) as string | AgentMessage[])).toContain("quota-turn");
});
@@ -5020,17 +4992,14 @@ describe("advisor", () => {
const runtime = new AdvisorRuntime(agent, host, 0);
const messages: AgentMessage[] = [{ role: "user", content: "turn", timestamp: 1 } as AgentMessage];
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(() => runtime.quotaExhausted);
expect(runtime.quotaExhausted).toBe(true);
expect(runtime.backlog).toBeGreaterThan(0);
// waitForCatchup must resolve instantly — a quota-paused advisor can't
// make progress, so blocking the primary agent for 30s is wrong.
const start = Date.now();
await runtime.waitForCatchup(30_000, 1);
expect(Date.now() - start).toBeLessThan(1000);
});
it("retries once when onTurnError signals a switched sibling credential", async () => {
const promptInputs: Array<string | AgentMessage[]> = [];
@@ -5060,9 +5029,7 @@ describe("advisor", () => {
const messages: AgentMessage[] = [{ role: "user", content: "quota-turn", timestamp: 1 } as AgentMessage];
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(() => promptInputs.length === 2 && runtime.backlog === 0);
// Sibling credential switched: retry succeeds, no quota pause.
expect(promptInputs).toHaveLength(2);
@@ -5132,8 +5099,7 @@ describe("advisor", () => {
const messages: AgentMessage[] = [{ role: "user", content: "first", timestamp: 1 } as AgentMessage];
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(() => runtime.quotaExhausted && quotaNotified);
// No sibling: single prompt, then quota pause (no retry).
expect(promptInputs).toHaveLength(1);
@@ -5262,11 +5228,7 @@ describe("advisor", () => {
const messages: AgentMessage[] = [{ role: "user", content: "mixed-turn", timestamp: 1 } as AgentMessage];
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await Bun.sleep(0);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(() => promptInputs.length === 3 && hookErrors.length === 2 && runtime.backlog === 0);
// Sibling switched (call 1 quota), retry failed with non-quota
// (call 2), then succeeded (call 3). No quota pause, backlog cleared.
@@ -5312,9 +5274,9 @@ describe("advisor", () => {
const messages: AgentMessage[] = [{ role: "user", content: "double-quota", timestamp: 1 } as AgentMessage];
runtime.onTurnEnd(messages);
await Bun.sleep(0);
await Bun.sleep(0);
await Bun.sleep(0);
await settleUntil(
() => promptInputs.length === 2 && hookErrors.length === 2 && runtime.quotaExhausted && quotaNotified,
);
// Both credentials exhausted: retry prompted twice, then entered quota pause.
expect(promptInputs).toHaveLength(2);
@@ -20,6 +20,7 @@ describe("discoverAdvisorConfigs", () => {
beforeEach(async () => {
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-config-"));
await fsp.mkdir(path.join(tmp, ".git"));
// Empty agent dir so the user-level search path can't pick up a real ~/.omp/WATCHDOG.yml.
agentDir = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-agentdir-"));
});
@@ -195,6 +196,7 @@ describe("WATCHDOG.yml file round-trip", () => {
let tmp: string;
beforeEach(async () => {
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-file-"));
await fsp.mkdir(path.join(tmp, ".git"));
});
afterEach(async () => {
await fsp.rm(tmp, { recursive: true, force: true });
@@ -313,6 +315,7 @@ describe("resolveAdvisorConfigEditPath", () => {
describe("per-advisor enabled field", () => {
it("preserves explicit true, explicit false, and absence through save and discovery", async () => {
const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-enabled-"));
await fsp.mkdir(path.join(tmp, ".git"));
try {
const doc: WatchdogConfigDoc = {
advisors: [
@@ -19,10 +19,6 @@ function userMessage(text: string, timestamp: number): AgentMessage {
return { role: "user", content: text, timestamp } as AgentMessage;
}
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
function hasResetReason(details: unknown, reason: string): details is { reason: string } {
return typeof details === "object" && details !== null && "reason" in details && details.reason === reason;
}
@@ -48,13 +44,13 @@ describe("advisor context reset observability", () => {
const runtime = new AdvisorRuntime(agent, host);
runtime.onTurnEnd();
await settle();
expect(await runtime.waitForCatchup(1_000, 1)).toBe(true);
// Replace a delivered message with a changed clone, then grow the tail.
messages[0] = userMessage("turn one body EDITED", 1);
messages.push(userMessage("turn three body", 3));
runtime.onTurnEnd();
await settle();
expect(await runtime.waitForCatchup(1_000, 1)).toBe(true);
const events = debugSpy.mock.calls.map(call => ({ message: call[0], details: call[1] }));
const divergence = events.find(event => event.message === "advisor delivered prefix changed");
@@ -104,7 +100,13 @@ describe("advisor context reset observability", () => {
});
it("logs quarantine reset reasons while preserving the retry limit", async () => {
const debugSpy = vi.spyOn(logger, "debug").mockImplementation(() => {});
const recoveryLogged = Promise.withResolvers<void>();
const exhaustedLogged = Promise.withResolvers<void>();
const debugSpy = vi.spyOn(logger, "debug").mockImplementation((message, details) => {
if (message !== "advisor context reset") return;
if (hasResetReason(details, "quarantine-recovery")) recoveryLogged.resolve();
if (hasResetReason(details, "quarantine-retry-exhausted")) exhaustedLogged.resolve();
});
try {
const messages: AgentMessage[] = [userMessage("turn body", 1)];
let agentResetCalls = 0;
@@ -126,9 +128,9 @@ describe("advisor context reset observability", () => {
});
runtime.onTurnEnd();
await settle();
await recoveryLogged.promise;
runtime.onTurnEnd();
await settle();
await exhaustedLogged.promise;
const events = debugSpy.mock.calls.map(call => ({ message: call[0], details: call[1] }));
expect(
@@ -319,7 +319,7 @@ describe("Agent hub Enter activation", () => {
expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain("Read-only · 0 LoC");
hub.dispose();
});
it("yields to a macrotask while streaming a large session", async () => {
it("yields to a macrotask at the configured streaming threshold", async () => {
vi.useFakeTimers();
using tempDir = TempDir.createSync("@omp-agent-hub-responsive-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
@@ -330,7 +330,8 @@ describe("Agent hub Enter activation", () => {
timestamp: "2026-07-30T01:13:30.000Z",
message: { role: "user", content: [{ type: "text", text: "small" }] },
});
await Bun.write(sessionFile, `${entry}\n`.repeat(8_193));
await Bun.write(sessionFile, `${entry}\n`.repeat(3));
const thresholdVisited = Promise.withResolvers<void>();
let complete = false;
let yieldedBeforeComplete = false;
let visited = 0;
@@ -338,20 +339,21 @@ describe("Agent hub Enter activation", () => {
sessionFile,
() => {
visited++;
if (visited !== 8_192) return;
if (visited !== 2) return;
setTimeout(() => {
if (!complete) yieldedBeforeComplete = true;
}, 0);
thresholdVisited.resolve();
},
{ yieldEveryBytes: 0, yieldEveryEntries: 8_192 },
{ yieldEveryBytes: 0, yieldEveryEntries: 2 },
).finally(() => {
complete = true;
});
try {
for (let i = 0; i < 20_000 && visited < 8_192 && !complete; i++) await Promise.resolve();
expect(visited).toBeGreaterThanOrEqual(8_192);
await thresholdVisited.promise;
vi.runOnlyPendingTimers();
await visit;
expect(visited).toBe(3);
expect(yieldedBeforeComplete).toBe(true);
} finally {
vi.useRealTimers();
@@ -6,7 +6,7 @@
* (the reported "first char off / title shift"). Scrolling must also move the
* visible window.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
@@ -172,25 +172,40 @@ function withViewer(fn: (viewer: AgentTranscriptViewer) => void): void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "adv-view-"));
const file = path.join(dir, "__advisor.jsonl");
fs.writeFileSync(file, buildJsonl());
const viewer = makeViewer(file);
try {
fn(makeViewer(file));
fn(viewer);
} finally {
viewer.dispose();
removeSyncWithRetries(dir);
}
}
async function settleRemoteRefresh(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
beforeAll(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true });
await initTheme();
});
afterAll(() => {
resetSettingsForTest();
});
describe("AgentTranscriptViewer", () => {
let rowsDesc: PropertyDescriptor | undefined;
beforeEach(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true });
initTheme();
beforeEach(() => {
vi.useFakeTimers();
rowsDesc = Object.getOwnPropertyDescriptor(process.stdout, "rows");
Object.defineProperty(process.stdout, "rows", { configurable: true, get: () => 24, set: () => {} });
});
afterEach(() => {
vi.useRealTimers();
if (rowsDesc) {
Object.defineProperty(process.stdout, "rows", rowsDesc);
} else {
@@ -297,8 +312,8 @@ describe("AgentTranscriptViewer", () => {
});
});
it("renders tool-result images through the shared Kitty placeholder budget", async () => {
await Settings.init({ inMemory: true, overrides: { "terminal.showImages": true } });
it("renders tool-result images through the shared Kitty placeholder budget", () => {
Settings.instance.override("terminal.showImages", true);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "adv-view-image-"));
const file = path.join(dir, "__advisor.jsonl");
fs.writeFileSync(file, buildImageJsonl());
@@ -322,6 +337,7 @@ describe("AgentTranscriptViewer", () => {
expect(imageBudget.takeTransmits().join("")).toContain("a=t");
} finally {
viewer.dispose();
Settings.instance.clearOverride("terminal.showImages");
setKittyGraphics(previousGraphics);
setTerminalImageProtocol(previousProtocol);
removeSyncWithRetries(dir);
@@ -344,11 +360,8 @@ describe("AgentTranscriptViewer", () => {
expect(body()).toContain("PROMPTMARKER");
removeSyncWithRetries(file);
// Poll until the viewer's own poll timer re-stats and clears (deadline-bounded).
const deadline = Date.now() + 5000;
while (body().includes("PROMPTMARKER") && Date.now() < deadline) {
await Bun.sleep(50);
}
// Drive the viewer's own 250ms polling interval without paying wall-clock time.
vi.advanceTimersByTime(250);
expect(body()).not.toContain("PROMPTMARKER");
} finally {
viewer.dispose();
@@ -370,10 +383,7 @@ describe("AgentTranscriptViewer", () => {
.render(80)
.map(l => Bun.stripANSI(l))
.join("\n");
const deadline = Date.now() + 5000;
while (!body().includes("TAILMARKER") && Date.now() < deadline) {
await Bun.sleep(50);
}
vi.advanceTimersByTime(250);
expect(body()).toContain("TAILMARKER");
expect(readFileSpy).not.toHaveBeenCalled();
} finally {
@@ -423,10 +433,7 @@ describe("AgentTranscriptViewer", () => {
.render(80)
.map(l => Bun.stripANSI(l))
.join("\n");
const deadline = Date.now() + 5000;
while (!body().includes("TAILMARK") && Date.now() < deadline) {
await Bun.sleep(50);
}
vi.advanceTimersByTime(250);
expect(body()).toContain("BASEMARK");
expect(body()).toContain("TAILMARK");
// The race-window entry must be rendered exactly once, not duplicated
@@ -459,10 +466,7 @@ describe("AgentTranscriptViewer", () => {
.render(80)
.map(l => Bun.stripANSI(l))
.join("\n");
const deadline = Date.now() + 5000;
while (body().includes("Loading transcript from host") && Date.now() < deadline) {
await Bun.sleep(10);
}
await settleRemoteRefresh();
expect(body()).toContain("No messages yet.");
} finally {
viewer.dispose();
@@ -497,10 +501,7 @@ describe("AgentTranscriptViewer", () => {
// Completing the dangling line via a single newline must surface the
// buffered entry; it must NOT be dropped as a malformed fragment.
fs.appendFileSync(file, "\n");
const deadline = Date.now() + 5000;
while (!body().includes("PARTIALMARK") && Date.now() < deadline) {
await Bun.sleep(50);
}
vi.advanceTimersByTime(250);
expect(body()).toContain("PARTIALMARK");
} finally {
viewer.dispose();
@@ -508,22 +509,7 @@ describe("AgentTranscriptViewer", () => {
}
});
it("stops polling after an oversized remote JSONL entry cannot fit in one host read", async () => {
const transcriptReadCap = 4 * 1024 * 1024;
const oversizedLine = `${JSON.stringify({
type: "message",
id: "oversized",
parentId: null,
timestamp: TS,
message: {
role: "user",
synthetic: true,
attribution: "agent",
content: "x".repeat(transcriptReadCap + 1),
timestamp: 0,
},
})}\n`;
const transcript = Buffer.from(oversizedLine, "utf-8");
it("stops polling after the host reports an oversized remote JSONL entry", async () => {
const calls: number[] = [];
const remote: AgentHubRemote = {
chat: () => {},
@@ -531,22 +517,18 @@ describe("AgentTranscriptViewer", () => {
revive: () => {},
readTranscript: async (_id: string, fromByte: number) => {
calls.push(fromByte);
const slice = transcript.subarray(fromByte, fromByte + transcriptReadCap);
const lastNewline = slice.lastIndexOf(0x0a);
if (lastNewline < 0) {
return {
text: "",
newSize: fromByte,
error: `transcript entry exceeds transcript fetch cap (${transcriptReadCap} bytes)`,
error: "transcript entry exceeds transcript fetch cap (4194304 bytes)",
};
}
const complete = slice.subarray(0, lastNewline + 1);
return { text: complete.toString("utf-8"), newSize: fromByte + complete.byteLength };
},
};
const viewer = makeViewer("", remote);
try {
await Bun.sleep(650);
await settleRemoteRefresh();
vi.advanceTimersByTime(650);
await settleRemoteRefresh();
const body = viewer
.render(80)
.map(l => Bun.stripANSI(l))
@@ -582,7 +564,10 @@ describe("AgentTranscriptViewer", () => {
};
const viewer = makeViewer("", remote);
try {
await Bun.sleep(650);
await settleRemoteRefresh();
vi.advanceTimersByTime(250);
await settleRemoteRefresh();
vi.advanceTimersByTime(400);
const body = viewer
.render(80)
.map(l => Bun.stripANSI(l))
@@ -635,10 +620,9 @@ describe("AgentTranscriptViewer", () => {
.render(80)
.map(l => Bun.stripANSI(l))
.join("\n");
const deadline = Date.now() + 5000;
while (!body().includes("AFTER_ROTATE") && Date.now() < deadline) {
await Bun.sleep(20);
}
await settleRemoteRefresh();
vi.advanceTimersByTime(250);
await settleRemoteRefresh();
expect(body()).toContain("AFTER_ROTATE");
// Pre-rotation rows must not stack underneath the refetched transcript.
expect(body()).not.toContain("BEFORE_ROTATE");
@@ -5,7 +5,7 @@
* `ClientBridge.requestPermission`, while regular file-editing tools keep the same no-approval
* behavior they have in the TUI.
*/
import { afterEach, beforeEach, expect, it, spyOn } from "bun:test";
import { afterAll, afterEach, beforeAll, expect, it, spyOn } from "bun:test";
import { type } from "@oh-my-pi/omptype";
import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core";
import { createMockModel, type MockModelOptions } from "@oh-my-pi/pi-ai/providers/mock";
@@ -156,13 +156,16 @@ async function createSessionWithMockModel(
return sess;
}
beforeEach(() => {
beforeAll(() => {
tempDir = TempDir.createSync("@pi-acp-permission-test-");
});
afterEach(async () => {
await session?.dispose();
session = undefined;
});
afterAll(async () => {
await tempDir.remove();
});
@@ -17,7 +17,7 @@
* (which converts to `developer`) would send an invalid provider tail, so the
* follow-up stays queued for the next explicit resume rather than auto-running.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import { type } from "@oh-my-pi/omptype";
import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core";
import type { ToolCall } from "@oh-my-pi/pi-ai";
@@ -72,7 +72,7 @@ describe("AgentSession advisor auto-resume suppression", () => {
let session: AgentSession;
const authStorages: AuthStorage[] = [];
beforeEach(() => {
beforeAll(() => {
tempDir = TempDir.createSync("@pi-advisor-suppress-");
});
@@ -82,11 +82,13 @@ describe("AgentSession advisor auto-resume suppression", () => {
await session?.dispose();
} finally {
for (const authStorage of authStorages.splice(0)) authStorage.close();
await Bun.sleep(0);
await tempDir?.remove();
}
});
afterAll(async () => {
await tempDir?.remove();
});
/**
* First turn parks open (a 60s mock delay that abort cancels) so a steer/park
* + interrupt can be sequenced while the agent is genuinely streaming. The
@@ -112,7 +114,7 @@ describe("AgentSession advisor auto-resume suppression", () => {
});
const sessionManager = SessionManager.inMemory();
const settings = Settings.isolated({ "compaction.enabled": false });
const authStorage = await AuthStorage.create(tempDir.join(`auth-${Snowflake.next()}.db`));
const authStorage = await AuthStorage.create(":memory:");
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml"));
@@ -203,7 +205,7 @@ describe("AgentSession advisor auto-resume suppression", () => {
const sessionManager = SessionManager.inMemory();
const settings = Settings.isolated({ "compaction.enabled": false, "retry.enabled": false });
settings.setModelRole("advisor", "anthropic/claude-sonnet-4-5");
const authStorage = await AuthStorage.create(tempDir.join(`auth-${Snowflake.next()}.db`));
const authStorage = await AuthStorage.create(":memory:");
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml"));
@@ -620,7 +622,7 @@ describe("AgentSession advisor auto-resume suppression", () => {
});
const sessionManager = SessionManager.inMemory();
const settings = Settings.isolated({ "compaction.enabled": false });
const authStorage = await AuthStorage.create(tempDir.join(`auth-${Snowflake.next()}.db`));
const authStorage = await AuthStorage.create(":memory:");
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml"));
@@ -5,10 +5,7 @@
* THAT session, and `hasPendingAsyncWork()` / `settleAsyncWork()` define the
* run quiescence the task executor's barrier is built on.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, it } from "bun:test";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
@@ -21,18 +18,11 @@ import type { AsyncResultEntry } from "@oh-my-pi/pi-coding-agent/session/async-j
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils";
describe("AgentSession owner-routed async delivery", () => {
let session: AgentSession;
let tempDir: string;
const authStorages: AuthStorage[] = [];
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `pi-async-delivery-test-${Snowflake.next()}`);
fs.mkdirSync(tempDir, { recursive: true });
});
afterEach(async () => {
if (session) {
await session.dispose();
@@ -40,9 +30,6 @@ describe("AgentSession owner-routed async delivery", () => {
for (const authStorage of authStorages.splice(0)) {
authStorage.close();
}
if (tempDir && fs.existsSync(tempDir)) {
removeSyncWithRetries(tempDir);
}
AsyncJobManager.resetForTests();
});
@@ -55,7 +42,7 @@ describe("AgentSession owner-routed async delivery", () => {
convertToLlm,
streamFn: mock.stream,
});
const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db"));
const authStorage = await AuthStorage.create(":memory:");
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const manager = new AsyncJobManager({});
@@ -105,7 +92,7 @@ describe("AgentSession owner-routed async delivery", () => {
convertToLlm,
streamFn: mock.stream,
});
const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db"));
const authStorage = await AuthStorage.create(":memory:");
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const sessionManager = SessionManager.inMemory();
@@ -159,7 +146,7 @@ describe("AgentSession owner-routed async delivery", () => {
convertToLlm,
streamFn: mock.stream,
});
const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db"));
const authStorage = await AuthStorage.create(":memory:");
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const manager = new AsyncJobManager({ retentionMs: 60_000 });
@@ -213,7 +200,7 @@ describe("AgentSession owner-routed async delivery", () => {
convertToLlm,
streamFn: mock.stream,
});
const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db"));
const authStorage = await AuthStorage.create(":memory:");
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const manager = new AsyncJobManager({ retentionMs: 60_000 });
@@ -265,7 +252,7 @@ describe("AgentSession owner-routed async delivery", () => {
convertToLlm,
streamFn: mock.stream,
});
const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db"));
const authStorage = await AuthStorage.create(":memory:");
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const manager = new AsyncJobManager({ retentionMs: 60_000 });
@@ -322,7 +309,7 @@ describe("AgentSession owner-routed async delivery", () => {
convertToLlm,
streamFn: mock.stream,
});
const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db"));
const authStorage = await AuthStorage.create(":memory:");
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const manager = new AsyncJobManager({});
@@ -1,19 +1,31 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import { Agent } from "@oh-my-pi/pi-agent-core";
import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction";
import { resolveThresholdTokens, shouldCompact } from "@oh-my-pi/pi-agent-core/compaction";
import { type CompactionPreparation, resolveThresholdTokens, shouldCompact } from "@oh-my-pi/pi-agent-core/compaction";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader";
import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { CompactionEntry } from "@oh-my-pi/pi-coding-agent/session/session-entries";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { getProjectAgentDir, TempDir } from "@oh-my-pi/pi-utils";
it("clamps a reserve exceeding the window for small-window threshold recovery bands", () => {
const settings = {
enabled: true,
strategy: "context-full" as const,
thresholdTokens: -1,
thresholdPercent: -1,
reserveTokens: 16384,
keepRecentTokens: 10000,
autoContinue: true,
};
const threshold = resolveThresholdTokens(4096, settings);
expect(threshold).toBe(3482);
expect(Math.floor(threshold * 0.8)).toBe(2785);
expect(shouldCompact(3600, 4096, settings)).toBe(true);
});
/**
* Regression test for the auto-compaction thrash loop.
@@ -31,8 +43,8 @@ import { getProjectAgentDir, TempDir } from "@oh-my-pi/pi-utils";
* post-maintenance headroom check; with no headroom it pauses and emits a single
* warning notice instead of looping.
*/
describe("AgentSession auto-compaction progress guard", () => {
let tempDir: TempDir;
let session: AgentSession;
let sessionManager: SessionManager;
let authStorage: AuthStorage;
@@ -41,47 +53,35 @@ describe("AgentSession auto-compaction progress guard", () => {
const NOTICE_SOURCE = "compaction";
const NO_PROGRESS_FRAGMENT = "Compaction freed too little context to make progress";
beforeEach(async () => {
tempDir = TempDir.createSync("@pi-auto-compaction-progress-");
// Short-circuit the actual summarization so the test makes no LLM call: the
// hook supplies the compaction result, then the production tail (events,
// progress guard, continuation scheduling) runs exactly as in a real pass.
const extensionsDir = path.join(getProjectAgentDir(tempDir.path()), "extensions");
fs.mkdirSync(extensionsDir, { recursive: true });
const extensionPath = path.join(extensionsDir, "compaction-short-circuit.ts");
fs.writeFileSync(
extensionPath,
[
"export default function(pi) {",
'\tpi.on("session_before_compact", async (event) => {',
"\t\treturn {",
"\t\t\tcompaction: {",
'\t\t\t\tsummary: "compacted",',
"\t\t\t\tshortSummary: undefined,",
"\t\t\t\tfirstKeptEntryId: event.preparation.firstKeptEntryId,",
"\t\t\t\ttokensBefore: event.preparation.tokensBefore,",
"\t\t\t\tdetails: {},",
"\t\t\t},",
"\t\t};",
"\t});",
"}",
].join("\n"),
);
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
beforeAll(async () => {
authStorage = await AuthStorage.create(":memory:");
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
});
const extensionsResult = await loadExtensions([extensionPath], tempDir.path());
const extensionRunner = new ExtensionRunner(
extensionsResult.extensions,
extensionsResult.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
beforeEach(() => {
sessionManager = SessionManager.inMemory();
// The progress-guard tests exercise AgentSession's post-compaction state
// transitions, not extension discovery. Keep the production hook boundary
// while returning the same short-circuit result without compiling a
// temporary extension for every test.
const extensionRunner = {
hasHandlers: (type: string) => type === "session_before_compact",
emit: async (event: { type: string; preparation?: CompactionPreparation }) => {
if (event.type !== "session_before_compact" || !event.preparation) return undefined;
return {
compaction: {
summary: "compacted",
shortSummary: undefined,
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: {},
},
};
},
emitBeforeAgentStart: async () => undefined,
};
const bundled = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!bundled) {
@@ -116,18 +116,17 @@ describe("AgentSession auto-compaction progress guard", () => {
"compaction.autoContinue": true,
}),
modelRegistry,
extensionRunner,
extensionRunner: extensionRunner as never,
});
});
afterEach(async () => {
try {
await session?.dispose();
} finally {
authStorage?.close();
await tempDir?.remove();
vi.restoreAllMocks();
}
});
afterAll(() => {
authStorage?.close();
});
/** Build a threshold-tripping assistant turn (contextWindow 200k, ~80% threshold). */
@@ -253,23 +252,6 @@ describe("AgentSession auto-compaction progress guard", () => {
expect(noProgress[0].level).toBe("warning");
});
it("clamps a reserve exceeding the window for small-window threshold recovery bands", () => {
const settings = {
enabled: true,
strategy: "context-full" as const,
thresholdTokens: -1,
thresholdPercent: -1,
reserveTokens: 16384,
keepRecentTokens: 10000,
autoContinue: true,
};
const threshold = resolveThresholdTokens(4096, settings);
expect(threshold).toBe(3482);
expect(Math.floor(threshold * 0.8)).toBe(2785);
expect(shouldCompact(3600, 4096, settings)).toBe(true);
});
it("blocks todo continuations after no-headroom compaction when auto-continue is disabled", async () => {
session.settings.set("compaction.autoContinue", false);
session.setTodoPhases([{ name: "Work", tasks: [{ content: "Finish task", status: "in_progress" }] }]);
@@ -1,18 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import { scheduler } from "node:timers/promises";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader";
import { ExtensionRuntime, loadExtensionFromFactory } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader";
import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner";
import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import * as unexpectedStopClassifier from "@oh-my-pi/pi-coding-agent/session/unexpected-stop-classifier";
import { getProjectAgentDir, TempDir, withTimeout } from "@oh-my-pi/pi-utils";
import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus";
import { TempDir, withTimeout } from "@oh-my-pi/pi-utils";
import * as logger from "@oh-my-pi/pi-utils/logger";
const runtimeSignalStoreKey = "__ompRuntimeSignals";
@@ -37,65 +36,56 @@ describe("AgentSession auto-compaction queue resume", () => {
let sessionManager: SessionManager;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
beforeEach(async () => {
beforeAll(async () => {
tempDir = TempDir.createSync("@pi-auto-compaction-queue-");
vi.useFakeTimers();
// Provide an extension that short-circuits compaction so the test doesn't
// make any LLM calls.
const extensionsDir = path.join(getProjectAgentDir(tempDir.path()), "extensions");
fs.mkdirSync(extensionsDir, { recursive: true });
const extensionPath = path.join(extensionsDir, "compaction-short-circuit.ts");
fs.writeFileSync(
extensionPath,
[
"export default function(pi) {",
'\tpi.on("session_before_compact", async (event) => {',
`\t\tconst signals = globalThis.${runtimeSignalStoreKey} ?? (globalThis.${runtimeSignalStoreKey} = []);`,
'\t\tsignals.push("before_compact:enter");',
"\t\tconst gate = globalThis.__ompManualCompactGate;",
"\t\tif (gate) await gate;",
"\t\treturn {",
"\t\t\tcompaction: {",
'\t\t\t\tsummary: "compacted",',
"\t\t\t\tshortSummary: undefined,",
"\t\t\t\tfirstKeptEntryId: event.preparation.firstKeptEntryId,",
"\t\t\t\ttokensBefore: event.preparation.tokensBefore,",
"\t\t\t\tdetails: {},",
"\t\t\t},",
"\t\t};",
"\t});",
'\tpi.on("auto_compaction_start", async (event) => {',
`\t\tconst signals = globalThis.${runtimeSignalStoreKey} ?? (globalThis.${runtimeSignalStoreKey} = []);`,
'\t\tsignals.push("compaction:start:" + event.reason);',
"\t});",
'\tpi.on("auto_compaction_end", async (event) => {',
`\t\tconst signals = globalThis.${runtimeSignalStoreKey} ?? (globalThis.${runtimeSignalStoreKey} = []);`,
'\t\tsignals.push("compaction:end:" + (event.aborted ? "aborted" : "ok"));',
"\t});",
'\tpi.on("todo_reminder", async (event) => {',
`\t\tconst signals = globalThis.${runtimeSignalStoreKey} ?? (globalThis.${runtimeSignalStoreKey} = []);`,
'\t\tsignals.push("todo:" + event.attempt + "/" + event.maxAttempts);',
"\t});",
"}",
].join("\n"),
);
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage = await AuthStorage.create(":memory:");
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
});
beforeEach(async () => {
vi.useFakeTimers();
// Install the short-circuit extension directly. Loading a generated
// TypeScript file here used to compile the same fixture for every test.
const runtime = new ExtensionRuntime();
const extension = await loadExtensionFromFactory(
pi => {
pi.on("session_before_compact", async event => {
getRuntimeSignals().push("before_compact:enter");
const gate = (globalThis as typeof globalThis & { __ompManualCompactGate?: Promise<void> })
.__ompManualCompactGate;
if (gate) await gate;
return {
compaction: {
summary: "compacted",
shortSummary: undefined,
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: {},
},
};
});
pi.on("auto_compaction_start", event => {
getRuntimeSignals().push(`compaction:start:${event.reason}`);
});
pi.on("auto_compaction_end", event => {
getRuntimeSignals().push(`compaction:end:${event.aborted ? "aborted" : "ok"}`);
});
pi.on("todo_reminder", event => {
getRuntimeSignals().push(`todo:${event.attempt}/${event.maxAttempts}`);
});
},
tempDir.path(),
new EventBus(),
runtime,
"compaction-short-circuit",
);
sessionManager = SessionManager.inMemory(tempDir.path());
getRuntimeSignals().length = 0;
const extensionsResult = await loadExtensions([extensionPath], tempDir.path());
const extensionRunner = new ExtensionRunner(
extensionsResult.extensions,
extensionsResult.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const extensionRunner = new ExtensionRunner([extension], runtime, tempDir.path(), sessionManager, modelRegistry);
const bundled = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!bundled) {
@@ -140,10 +130,8 @@ describe("AgentSession auto-compaction queue resume", () => {
await session?.dispose();
} finally {
try {
authStorage?.close();
vi.useRealTimers();
await Bun.sleep(0);
await tempDir?.remove();
} finally {
getRuntimeSignals().length = 0;
(globalThis as typeof globalThis & { __ompManualCompactGate?: Promise<void> }).__ompManualCompactGate =
@@ -152,6 +140,10 @@ describe("AgentSession auto-compaction queue resume", () => {
}
}
});
afterAll(() => {
authStorage.close();
tempDir.removeSync();
});
it("resumes after threshold compaction when only agent-level queued messages exist", async () => {
session.agent.followUp({
@@ -19,10 +19,10 @@
* → brush-core::execute_external_command (the patched code)
* → spawned child reports getsid()/getpid()
*
* The assistant's first turn is a scripted `bash` tool call asking Python to
* print `getsid(0) getpid()`. The second scripted turn is a stop. After the
* loop settles, we extract the child's session ID from the persisted
* `toolResult` message and compare it against the test runner's session ID.
* The assistant's first turn is one scripted `bash` tool call that runs both
* the session-ID probe and a two-stage pipeline. The second scripted turn is a
* stop. We inspect the resulting `toolResult` for the child's session identity
* and the pipeline's output.
*
* Pre-fix (`new_pg=false` skipped `detach_session()`), the spawned child
* inherits the test runner's session, so `child_sid === host_sid`.
@@ -47,11 +47,12 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { BashTool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
/** Scripted assistant turn that issues a single `bash` tool call. */
function bashCall(command: string, callId: string): MockResponse {
@@ -136,7 +137,7 @@ describe("BashTool through AgentSession runs children in their own session (e2e)
// developer's real config (snapshots, shell prefix, etc).
await Settings.init({ inMemory: true, cwd: tempDir });
authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
authStorage = createInMemoryAuthStorage();
authStorage.setRuntimeApiKey("anthropic", "test-key");
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
@@ -206,19 +207,22 @@ describe("BashTool through AgentSession runs children in their own session (e2e)
resetSettingsForTest();
});
it.skipIf(skip)("spawned child runs as its own session leader, not in the host's session", async () => {
const callId = "call_bash_probe";
scriptedResponses = [bashCall(PYTHON_PROBE, callId), stopReply("ok")];
it.skipIf(skip)("preserves detached children and pipeline execution through BashTool", async () => {
const callId = "call_bash_lifecycle";
const command =
`${PYTHON_PROBE}; ` +
"python3 -c \"print('stage_a')\" | " +
"python3 -c \"import sys; data=sys.stdin.read().strip(); print('stage_b', data)\"";
scriptedResponses = [bashCall(command, callId), stopReply("ok")];
await session.prompt("probe child session id");
await session.waitForIdle();
await session.prompt("probe child session id and pipeline");
const resultText = getToolResultText(session.agent.state.messages, callId);
expect(resultText, "expected a toolResult for the bash call").toBeDefined();
expect(resultText, "expected a toolResult for the bash lifecycle probe").toBeDefined();
// `executeBash` wraps its own metadata around the raw output. We only
// care about the `<sid> <pid>` line the Python probe emitted. Pull the
// first whitespace-separated pair of positive integers.
// The standalone probe covers the embedded-host DetachSession path. The
// following pipeline in the same real BashTool invocation guards against
// setsid breaking multi-process commands.
const match = resultText!.match(/(\d+)\s+(\d+)/);
expect(match, `expected '<sid> <pid>' in tool result, saw: ${JSON.stringify(resultText)}`).not.toBeNull();
const childSid = Number.parseInt(match![1]!, 10);
@@ -226,44 +230,13 @@ describe("BashTool through AgentSession runs children in their own session (e2e)
expect(childSid).toBeGreaterThan(0);
expect(childPid).toBeGreaterThan(0);
// Pre-fix behavior: child inherits host's session.
expect(
childSid,
`child sid (${childSid}) equals host sid (${hostSid}) — embedded-host detach regressed`,
).not.toBe(hostSid);
// Post-fix: brush ran setsid() so the child is its own session leader.
expect(childSid, `child sid (${childSid}) !== child pid (${childPid}) — child is not session leader`).toBe(
childPid,
);
});
it.skipIf(skip)("pipelines through BashTool still produce both stages' output (no setsid breakage)", async () => {
// Sanity check that the embedded-host detach (which calls `setsid` on solo
// children) does not break multi-process commands. The brush-core fix carves
// out the `in_pipeline_group` case in `child_session_action`; this test asserts
// that pipelines run end-to-end through the agent and produce both stages'
// output with exit code 0.
//
// Note: the `in_pipeline_group=true` branch is unreachable from a non-
// interactive embedded brush (every stage spawns with `process_group_id=None`
// and falls into the embedded-host `DetachSession` rule). The fact that the
// pipeline still works is the load-bearing assertion: `setsid` is benign for
// stages that are already kernel-default pgroup leaders. The pgroup carve-out
// matters only for the interactive shell path, which is unit-tested in the
// rust truth-table.
const callId = "call_bash_pipeline";
const command =
"python3 -c \"print('stage_a')\" | " +
"python3 -c \"import sys; data=sys.stdin.read().strip(); print('stage_b', data)\"";
scriptedResponses = [bashCall(command, callId), stopReply("ok")];
await session.prompt("probe pipeline");
await session.waitForIdle();
const resultText = getToolResultText(session.agent.state.messages, callId);
expect(resultText, "expected a toolResult for the pipeline bash call").toBeDefined();
expect(resultText, `pipeline output missing 'stage_b stage_a': ${JSON.stringify(resultText)}`).toContain(
"stage_b stage_a",
);
@@ -9,10 +9,10 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import * as bashExecutor from "@oh-my-pi/pi-coding-agent/exec/bash-executor";
import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
import { createAssistantMessage } from "./helpers/agent-session-setup";
import { createAssistantMessage, createInMemoryAuthStorage } from "./helpers/agent-session-setup";
const bashResult = {
output: "old-output",
@@ -31,9 +31,9 @@ describe("AgentSession bash session ownership", () => {
let session: AgentSession;
let additionalManagers: SessionManager[];
beforeEach(async () => {
beforeEach(() => {
tempDir = TempDir.createSync("@pi-bash-session-owner-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage = createInMemoryAuthStorage();
authStorage.setRuntimeApiKey("anthropic", "test-key");
additionalManagers = [];
});
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { Message } from "@oh-my-pi/pi-ai";
import { inferCopilotInitiator } from "@oh-my-pi/pi-ai/providers/github-copilot-headers";
@@ -12,10 +11,8 @@ import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
describe("AgentSession before_agent_start attribution fallback", () => {
let tempDir: TempDir;
let session: AgentSession;
let modelRegistry: ModelRegistry;
let authStorage: AuthStorage | undefined;
@@ -23,8 +20,7 @@ describe("AgentSession before_agent_start attribution fallback", () => {
const injectedText = "before-agent-start injected message";
beforeEach(async () => {
tempDir = TempDir.createSync("@pi-before-agent-start-attribution-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage = await AuthStorage.create(":memory:");
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
});
@@ -36,7 +32,6 @@ describe("AgentSession before_agent_start attribution fallback", () => {
}
authStorage?.close();
authStorage = undefined;
tempDir.removeSync();
});
function createSession() {
@@ -68,7 +68,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("AgentSession branching", () =>
sessionManager = noSession ? SessionManager.inMemory() : SessionManager.create(tempDir, tempDir);
const settings = Settings.isolated();
authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
authStorage = await AuthStorage.create(":memory:");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
session = new AgentSession({
@@ -88,7 +88,7 @@ describe("AgentSession.branchFromBtw", () => {
const sessionManager =
options?.persisted === false ? SessionManager.inMemory() : SessionManager.create(tempDir, tempDir);
const settings = Settings.isolated({ "compaction.enabled": false });
authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
authStorage = await AuthStorage.create(":memory:");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
session = new AgentSession({
@@ -319,7 +319,7 @@ describe("AgentSession.branchFromBtw", () => {
const bashPromise = activeSession.executeBash('bun -e "await Bun.sleep(60_000)"', () => undefined, {
useUserShell: false,
});
while (!activeSession.isBashRunning) await Bun.sleep(1);
expect(activeSession.isBashRunning).toBe(true);
await expect(
activeSession.branchFromBtw(
@@ -110,7 +110,7 @@ async function createHarness(
options?: { onAgentEnd?: (willContinue: boolean | undefined) => void },
): Promise<Harness & { mock: MockModel }> {
const tempDir = TempDir.createSync("@pi-checkpoint-rewind-branch-");
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
const authStorage = await AuthStorage.create(":memory:");
authStorage.setRuntimeApiKey("mock", "test-key");
const mock = createMockModel({ responses });
@@ -71,7 +71,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("AgentSession compaction e2e",
sessionManager = inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir, tempDir);
const settings = Settings.isolated({ "compaction.keepRecentTokens": 1 });
authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
authStorage = await AuthStorage.create(":memory:");
const modelRegistry = new ModelRegistry(authStorage);
session = new AgentSession({
@@ -1,5 +1,6 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { scheduler } from "node:timers/promises";
import { Agent } from "@oh-my-pi/pi-agent-core";
import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction";
import type { AssistantMessage, Model, ProviderSessionState } from "@oh-my-pi/pi-ai";
@@ -10,6 +11,8 @@ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
const originalSchedulerWait = scheduler.wait.bind(scheduler);
describe("AgentSession context promotion", () => {
let tempDir: TempDir;
let session: AgentSession;
@@ -54,6 +57,12 @@ describe("AgentSession context promotion", () => {
tempDir.removeSync();
});
beforeEach(() => {
// Promotion retries deliberately settle for 100ms in production. These
// tests assert the continuation and state transition, not elapsed time.
vi.spyOn(scheduler, "wait").mockImplementation((_delayMs, options) => originalSchedulerWait(0, options));
});
afterEach(async () => {
if (session) {
await session.dispose();
@@ -9,9 +9,10 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state";
import { MnemopiSessionState, setMnemopiSessionState } from "@oh-my-pi/pi-coding-agent/mnemopi/state";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { logger, TempDir } from "@oh-my-pi/pi-utils";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
@@ -24,9 +25,9 @@ describe("AgentSession concurrent disposal", () => {
let authStorage: AuthStorage;
let session: AgentSession | undefined;
beforeEach(async () => {
beforeEach(() => {
tempDir = TempDir.createSync("@omp-dispose-concurrent-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
authStorage = createInMemoryAuthStorage();
authStorage.setRuntimeApiKey("anthropic", "test-key");
});
@@ -10,11 +10,12 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { ExtensionRuntime, loadExtensionFromFactory } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader";
import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { FileSessionStorage } from "@oh-my-pi/pi-coding-agent/session/session-storage";
import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus";
import { TempDir } from "@oh-my-pi/pi-utils";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
// Regression: a keep-alive subagent's AgentSession is disposed at park() but
// stays reachable through the lifecycle adoption record's reviver closure
@@ -29,9 +30,9 @@ describe("AgentSession dispose releases retained memory", () => {
let authStorage: AuthStorage;
let session: AgentSession | undefined;
beforeEach(async () => {
beforeEach(() => {
tempDir = TempDir.createSync("@omp-dispose-release-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
authStorage = createInMemoryAuthStorage();
authStorage.setRuntimeApiKey("anthropic", "test-key");
});
@@ -8,12 +8,12 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TodoTool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { TempDir } from "@oh-my-pi/pi-utils";
import { createAssistantMessage } from "./helpers/agent-session-setup";
import { createAssistantMessage, createInMemoryAuthStorage } from "./helpers/agent-session-setup";
type ObservedPromptCall = {
toolChoice: string | undefined;
@@ -80,17 +80,17 @@ describe("AgentSession eager task prelude", () => {
tempDir.removeSync();
});
async function createHarness(
function createHarness(
settingsOverride: Record<string, unknown> = {},
agentId?: string,
taskWireName?: string,
agentKind?: "main" | "sub",
): Promise<Harness> {
): Harness {
const observedCalls: ObservedPromptCall[] = [];
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist");
const authStorage = await AuthStorage.create(path.join(tempDir.path(), `testauth-${harnesses.length}.db`));
const authStorage = createInMemoryAuthStorage();
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), `models-${harnesses.length}.yml`));
const settings = Settings.isolated({
@@ -186,7 +186,7 @@ describe("AgentSession eager task prelude", () => {
}
it("prepends a hidden eager task reminder without forcing task or repeating the prompt text", async () => {
const { session, observedCalls } = await createHarness();
const { session, observedCalls } = createHarness();
await session.prompt("refactor the parser across modules");
@@ -201,7 +201,7 @@ describe("AgentSession eager task prelude", () => {
});
it("skips eager task prelude for prompts ending with a question mark", async () => {
const { session, observedCalls } = await createHarness();
const { session, observedCalls } = createHarness();
await session.prompt("should I refactor the parser?");
@@ -211,7 +211,7 @@ describe("AgentSession eager task prelude", () => {
});
it("skips eager task prelude for prompts ending with an exclamation mark", async () => {
const { session, observedCalls } = await createHarness();
const { session, observedCalls } = createHarness();
await session.prompt("refactor the parser now!");
@@ -221,7 +221,7 @@ describe("AgentSession eager task prelude", () => {
});
it("skips eager task prelude for subsequent user messages", async () => {
const { session, observedCalls } = await createHarness();
const { session, observedCalls } = createHarness();
await session.prompt("refactor the parser across modules");
expect(observedCalls).toHaveLength(1);
@@ -240,7 +240,7 @@ describe("AgentSession eager task prelude", () => {
});
it("skips eager task prelude when task.eager is disabled", async () => {
const { session, observedCalls } = await createHarness({ "task.eager": "default" });
const { session, observedCalls } = createHarness({ "task.eager": "default" });
await session.prompt("refactor the parser across modules");
@@ -250,7 +250,7 @@ describe("AgentSession eager task prelude", () => {
});
it("skips eager task prelude when task.eager is preferred (prompt section only, no reminder)", async () => {
const { session, observedCalls } = await createHarness({ "task.eager": "preferred" });
const { session, observedCalls } = createHarness({ "task.eager": "preferred" });
await session.prompt("refactor the parser across modules");
@@ -260,7 +260,7 @@ describe("AgentSession eager task prelude", () => {
});
it("skips eager task prelude for subagent sessions", async () => {
const { session, observedCalls } = await createHarness({}, "SubAgent", undefined, "sub");
const { session, observedCalls } = createHarness({}, "SubAgent", undefined, "sub");
await session.prompt("refactor the parser across modules");
@@ -270,7 +270,7 @@ describe("AgentSession eager task prelude", () => {
});
it("prepends eager task prelude for a main session with a custom agent id", async () => {
const { session, observedCalls } = await createHarness({}, "Alice", undefined, "main");
const { session, observedCalls } = createHarness({}, "Alice", undefined, "main");
await session.prompt("refactor the parser across modules");
@@ -279,7 +279,7 @@ describe("AgentSession eager task prelude", () => {
});
it("prepends both todo and task preludes when both are eager, keeping the forced todo choice", async () => {
const { session, observedCalls } = await createHarness({
const { session, observedCalls } = createHarness({
"todo.enabled": true,
"todo.eager": "always",
"todo.reminders": false,
@@ -299,7 +299,7 @@ describe("AgentSession eager task prelude", () => {
});
it("renders the task tool's wire name in the eager reminder", async () => {
const { session, observedCalls } = await createHarness({}, undefined, "delegate");
const { session, observedCalls } = createHarness({}, undefined, "delegate");
await session.prompt("refactor the parser across modules");
@@ -12,7 +12,7 @@ import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
import { TempDir, withTimeout } from "@oh-my-pi/pi-utils";
const recordToolSchema = type({ value: type("string") });
@@ -172,12 +172,7 @@ function reminderMessages(messages: AgentMessage[]): AgentMessage[] {
}
async function expectPromptCompletes(prompt: Promise<boolean>): Promise<void> {
await Promise.race([
prompt,
Bun.sleep(1_000).then(() => {
throw new Error("Expected session prompt to settle after empty-stop retry cap");
}),
]);
await withTimeout(prompt, 1_000, "Expected session prompt to settle after empty-stop retry cap");
}
afterEach(async () => {
@@ -7,10 +7,11 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
let tempDir: TempDir;
let authStorage: AuthStorage | undefined;
@@ -18,12 +19,12 @@ let session: AgentSession;
let sessionManager: SessionManager;
let mock: MockModel;
beforeEach(async () => {
beforeEach(() => {
tempDir = TempDir.createSync("@pi-agent-session-force-tool-");
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage = createInMemoryAuthStorage();
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
const settings = Settings.isolated({ "compaction.enabled": false });
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from "bun:test";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import * as path from "node:path";
import { Agent, AppendOnlyContextManager } from "@oh-my-pi/pi-agent-core";
import type { ProviderSessionState } from "@oh-my-pi/pi-ai";
@@ -16,6 +16,20 @@ interface FreshHarness {
}
const cleanup: Array<() => Promise<void>> = [];
let sharedDir: TempDir;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
beforeAll(async () => {
sharedDir = TempDir.createSync("@pi-agent-session-fresh-shared-");
authStorage = await AuthStorage.create(path.join(sharedDir.path(), "auth.db"));
modelRegistry = new ModelRegistry(authStorage, path.join(sharedDir.path(), "models.yml"));
});
afterAll(() => {
authStorage.close();
sharedDir.removeSync();
});
afterEach(async () => {
while (cleanup.length > 0) {
@@ -26,8 +40,6 @@ afterEach(async () => {
async function createFreshHarness(): Promise<FreshHarness> {
const tempDir = TempDir.createSync("@pi-agent-session-fresh-");
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
const sessionManager = SessionManager.create(tempDir.path(), path.join(tempDir.path(), "sessions"));
const agent = new Agent({
initialState: {
@@ -44,7 +56,6 @@ async function createFreshHarness(): Promise<FreshHarness> {
});
cleanup.push(async () => {
await session.dispose();
authStorage.close();
tempDir.removeSync();
});
return { agent, session, sessionManager };
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import type {
@@ -138,14 +138,21 @@ function successStream(model: Model<Api>, text: string): AssistantMessageEventSt
}
describe("AgentSession Gemini header-runaway interrupt", () => {
let tempDir: TempDir;
let sharedDir: TempDir;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
let session: AgentSession | undefined;
beforeEach(async () => {
tempDir = TempDir.createSync("@pi-gemini-header-interrupt-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
beforeAll(async () => {
sharedDir = TempDir.createSync("@pi-gemini-header-interrupt-shared-");
authStorage = await AuthStorage.create(path.join(sharedDir.path(), "auth.db"));
authStorage.setRuntimeApiKey("openrouter", "openrouter-test-key");
modelRegistry = new ModelRegistry(authStorage);
});
afterAll(() => {
authStorage.close();
sharedDir.removeSync();
});
afterEach(async () => {
@@ -153,8 +160,6 @@ describe("AgentSession Gemini header-runaway interrupt", () => {
await session.dispose();
session = undefined;
}
authStorage.close();
tempDir.removeSync();
vi.restoreAllMocks();
});
@@ -164,7 +169,6 @@ describe("AgentSession Gemini header-runaway interrupt", () => {
modelId = "google/gemini-3.5-flash",
): void {
const model = createMockModel({ provider: "openrouter", id: modelId }).model;
const modelRegistry = new ModelRegistry(authStorage);
const agent = new Agent({
getApiKey: requestedModel => `${requestedModel.provider}-test-key`,
initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] },
@@ -44,6 +44,18 @@ function highUsage(input: number) {
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
// These tests await real cross-pipeline concurrency signals; fake timers cannot
// drive those queues. Keep a failure-only watchdog, and cancel it as soon as
// the signal wins so successful cases never leave a wall-clock delay behind.
async function raceWithTimeout<T, F>(promise: Promise<T>, timeoutMs: number, timeoutValue: F): Promise<T | F> {
const timeout = Promise.withResolvers<F>();
const timer = setTimeout(() => timeout.resolve(timeoutValue), timeoutMs);
try {
return await Promise.race([promise, timeout.promise]);
} finally {
clearTimeout(timer);
}
}
describe("AgentSession mid-run threshold compaction", () => {
let tempDir: TempDir;
@@ -252,22 +264,25 @@ describe("AgentSession mid-run threshold compaction", () => {
const compactSpy = mockCompaction("SHOULD-NOT-RUN");
const prompt = session.prompt("work below the maintenance threshold");
const messageEndOutcome = await Promise.race([
const messageEndOutcome = await raceWithTimeout(
messageEndEntered.promise.then(() => "entered" as const),
Bun.sleep(2_000).then(() => "blocked" as const),
]);
2_000,
"blocked" as const,
);
const providerOutcome =
messageEndOutcome === "entered"
? await Promise.race([
? await raceWithTimeout(
nextProviderCall.promise.then(() => "dispatched" as const),
Bun.sleep(2_000).then(() => "blocked" as const),
])
2_000,
"blocked" as const,
)
: "blocked";
releaseMessageEnd.resolve();
const promptOutcome = await Promise.race([
const promptOutcome = await raceWithTimeout(
prompt.then(() => "settled" as const),
Bun.sleep(2_000).then(() => "blocked" as const),
]);
2_000,
"blocked" as const,
);
expect(messageEndOutcome).toBe("entered");
expect(providerOutcome).toBe("dispatched");
@@ -343,27 +358,31 @@ describe("AgentSession mid-run threshold compaction", () => {
);
const prompt = session.prompt("keep notification mutations out of live context");
const toolResultHookOutcome = await Promise.race([
const toolResultHookOutcome = await raceWithTimeout(
toolResultHookEntered.promise.then(() => "entered" as const),
Bun.sleep(2_000).then(() => "blocked" as const),
]);
2_000,
"blocked" as const,
);
const secondModelCallOutcome =
toolResultHookOutcome === "entered"
? await Promise.race([
? await raceWithTimeout(
secondModelCallEntered.promise.then(() => "dispatched" as const),
Bun.sleep(2_000).then(() => "blocked" as const),
])
2_000,
"blocked" as const,
)
: "blocked";
releaseMutation.resolve();
const mutationOutcome = await Promise.race([
const mutationOutcome = await raceWithTimeout(
mutationApplied.promise.then(() => "applied" as const),
Bun.sleep(2_000).then(() => "blocked" as const),
]);
2_000,
"blocked" as const,
);
releaseSecondModelCall.resolve();
const promptOutcome = await Promise.race([
const promptOutcome = await raceWithTimeout(
prompt.then(() => "settled" as const),
Bun.sleep(2_000).then(() => "blocked" as const),
]);
2_000,
"blocked" as const,
);
expect(toolResultHookOutcome).toBe("entered");
expect(secondModelCallOutcome).toBe("dispatched");
@@ -20,7 +20,7 @@ import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus";
import { TempDir } from "@oh-my-pi/pi-utils";
import { TempDir, withTimeout } from "@oh-my-pi/pi-utils";
import * as snapcompact from "@oh-my-pi/snapcompact";
const HANDOFF_SECRET = "HANDOFF_SECRET_TOKEN_12345";
@@ -1712,10 +1712,11 @@ describe("AgentSession handoff", () => {
expect(session.isGeneratingHandoff).toBe(true);
// dispose must NOT wait for the LLM call to resolve on its own — it must abort it.
const disposed = Promise.race([
const disposed = withTimeout(
session.dispose().then(() => "disposed" as const),
Bun.sleep(2_000).then(() => "timeout" as const),
]);
2_000,
"Timed out waiting for session disposal",
);
await expect(disposed).resolves.toBe("disposed");
// Releasing after the fact must not leak into other tests.
@@ -8,7 +8,7 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { ExtensionRuntime, loadExtensionFromFactory } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader";
import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import {
type CustomMessage,
convertToLlm,
@@ -19,6 +19,7 @@ import type { SessionEntry } from "@oh-my-pi/pi-coding-agent/session/session-ent
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus";
import { TempDir } from "@oh-my-pi/pi-utils";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
const REASONING_TEXT = "I have partly reasoned through the implementation and should preserve this.";
const VISIBLE_TEXT = "visible interrupted text";
@@ -97,9 +98,9 @@ describe("AgentSession interrupted thinking persistence", () => {
let authStorage: AuthStorage;
let session: AgentSession | undefined;
beforeEach(async () => {
beforeEach(() => {
tempDir = TempDir.createSync("@pi-interrupted-thinking-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
authStorage = createInMemoryAuthStorage();
authStorage.setRuntimeApiKey("anthropic", "anthropic-test-key");
});
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
@@ -32,12 +32,11 @@ const mockEvalTool: AgentTool = {
};
async function createMagicKeywordSession(
root: string,
modelRegistry: ModelRegistry,
tools: AgentTool[] = [mockTaskTool, mockEvalTool],
): Promise<{
session: AgentSession;
settings: Settings;
authStorage: AuthStorage;
}> {
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected bundled Claude Sonnet model");
@@ -50,9 +49,6 @@ async function createMagicKeywordSession(
thinkingLevel: Effort.High,
},
});
const authStorage = await AuthStorage.create(path.join(root, "auth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(root, "models.yml"));
const settings = Settings.isolated();
const session = new AgentSession({
agent,
@@ -60,31 +56,36 @@ async function createMagicKeywordSession(
settings,
modelRegistry,
});
return { session, settings, authStorage };
return { session, settings };
}
describe("AgentSession magic keyword settings", () => {
let root: string;
let session: AgentSession | undefined;
let authStorage: AuthStorage | undefined;
let authStorage: AuthStorage;
let authRoot: string;
let modelRegistry: ModelRegistry;
beforeEach(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-magic-keywords-"));
beforeAll(async () => {
authRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-magic-keywords-auth-"));
authStorage = await AuthStorage.create(path.join(authRoot, "auth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage, path.join(authRoot, "models.yml"));
});
afterAll(async () => {
authStorage.close();
await removeWithRetries(authRoot);
});
afterEach(async () => {
vi.restoreAllMocks();
if (session) await session.dispose();
authStorage?.close();
await removeWithRetries(root).catch(() => undefined);
session = undefined;
authStorage = undefined;
});
it("does not append magic keyword notices when disabled", async () => {
const created = await createMagicKeywordSession(root);
const created = await createMagicKeywordSession(modelRegistry);
session = created.session;
authStorage = created.authStorage;
created.settings.set("magicKeywords.enabled", false);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
@@ -95,9 +96,8 @@ describe("AgentSession magic keyword settings", () => {
});
it("honors non-ultrathink per-keyword notice toggles", async () => {
const created = await createMagicKeywordSession(root);
const created = await createMagicKeywordSession(modelRegistry);
session = created.session;
authStorage = created.authStorage;
created.settings.set("magicKeywords.orchestrate", false);
created.settings.set("magicKeywords.workflow", false);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
@@ -109,9 +109,8 @@ describe("AgentSession magic keyword settings", () => {
});
it("still appends enabled non-ultrathink notices", async () => {
const created = await createMagicKeywordSession(root);
const created = await createMagicKeywordSession(modelRegistry);
session = created.session;
authStorage = created.authStorage;
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
await session.prompt("please orchestrate and workflowz this");
@@ -124,9 +123,8 @@ describe("AgentSession magic keyword settings", () => {
});
it("renders the eval-specific workflowz notice", async () => {
const created = await createMagicKeywordSession(root);
const created = await createMagicKeywordSession(modelRegistry);
session = created.session;
authStorage = created.authStorage;
created.settings.set("task.batch", false);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
@@ -145,9 +143,8 @@ describe("AgentSession magic keyword settings", () => {
});
it("updates the workflowz notice when scout is disabled during the session", async () => {
const created = await createMagicKeywordSession(root);
const created = await createMagicKeywordSession(modelRegistry);
session = created.session;
authStorage = created.authStorage;
created.settings.set("task.disabledAgents", ["scout"]);
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
@@ -160,9 +157,8 @@ describe("AgentSession magic keyword settings", () => {
});
it("skips workflowz notice when the task tool is inactive", async () => {
const created = await createMagicKeywordSession(root, []);
const created = await createMagicKeywordSession(modelRegistry, []);
session = created.session;
authStorage = created.authStorage;
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
await session.prompt("please workflowz this");
@@ -172,9 +168,8 @@ describe("AgentSession magic keyword settings", () => {
});
it("skips orchestrate notice when the task tool is inactive", async () => {
const created = await createMagicKeywordSession(root, []);
const created = await createMagicKeywordSession(modelRegistry, []);
session = created.session;
authStorage = created.authStorage;
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
await session.prompt("please orchestrate this");
@@ -184,9 +179,8 @@ describe("AgentSession magic keyword settings", () => {
});
it("skips workflowz notice when the eval tool is inactive", async () => {
const created = await createMagicKeywordSession(root, [mockTaskTool]);
const created = await createMagicKeywordSession(modelRegistry, [mockTaskTool]);
session = created.session;
authStorage = created.authStorage;
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
await session.prompt("please workflowz this");
@@ -196,9 +190,8 @@ describe("AgentSession magic keyword settings", () => {
});
it("does not use a disabled ultrathink keyword to force auto thinking", async () => {
const created = await createMagicKeywordSession(root);
const created = await createMagicKeywordSession(modelRegistry);
session = created.session;
authStorage = created.authStorage;
created.settings.set("magicKeywords.ultrathink", false);
vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
const classifierSpy = vi.spyOn(autoThinkingClassifier, "classifyDifficulty").mockResolvedValue(Effort.Low);
@@ -212,9 +205,8 @@ describe("AgentSession magic keyword settings", () => {
});
it("queues the magic-keyword notice before the user message", async () => {
const created = await createMagicKeywordSession(root);
const created = await createMagicKeywordSession(modelRegistry);
session = created.session;
authStorage = created.authStorage;
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
await session.prompt("ultrathink do the thing");
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
@@ -23,11 +23,13 @@ describe("AgentSession manual retry", () => {
let tempDir: TempDir;
let authStorage: AuthStorage;
let session: AgentSession | undefined;
let modelRegistry: ModelRegistry;
beforeEach(async () => {
beforeAll(async () => {
tempDir = TempDir.createSync("@pi-manual-retry-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
});
afterEach(async () => {
@@ -35,6 +37,9 @@ describe("AgentSession manual retry", () => {
await session.dispose();
session = undefined;
}
});
afterAll(() => {
authStorage.close();
tempDir.removeSync();
});
@@ -65,7 +70,7 @@ describe("AgentSession manual retry", () => {
agent,
sessionManager: SessionManager.inMemory(),
settings: Settings.isolated({ "compaction.enabled": false, "retry.enabled": false }),
modelRegistry: new ModelRegistry(authStorage),
modelRegistry,
});
session.subscribe(() => {});
@@ -104,7 +109,7 @@ describe("AgentSession manual retry", () => {
agent,
sessionManager: SessionManager.inMemory(),
settings: Settings.isolated({ "compaction.enabled": false }),
modelRegistry: new ModelRegistry(authStorage),
modelRegistry,
});
session.subscribe(() => {});
@@ -150,7 +155,7 @@ describe("AgentSession manual retry", () => {
agent,
sessionManager: SessionManager.inMemory(),
settings: Settings.isolated({ "compaction.enabled": false, "retry.enabled": false }),
modelRegistry: new ModelRegistry(authStorage),
modelRegistry,
});
session.subscribe(() => {});
@@ -205,7 +210,7 @@ describe("AgentSession manual retry", () => {
agent,
sessionManager,
settings: Settings.isolated({ "compaction.enabled": false, "retry.enabled": false }),
modelRegistry: new ModelRegistry(authStorage),
modelRegistry,
});
session.subscribe(() => {});
@@ -247,7 +252,7 @@ describe("AgentSession manual retry", () => {
agent: reopenedAgent,
sessionManager: reopenedManager,
settings: Settings.isolated({ "compaction.enabled": false, "retry.enabled": false }),
modelRegistry: new ModelRegistry(authStorage),
modelRegistry,
});
session.subscribe(() => {});
@@ -8,10 +8,11 @@ import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { getMnemopiSessionState } from "@oh-my-pi/pi-coding-agent/mnemopi/state";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { resetMemoryForTests } from "@oh-my-pi/pi-mnemopi";
import { TempDir } from "@oh-my-pi/pi-utils";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
function createTool(name: string): AgentTool {
return {
@@ -31,9 +32,9 @@ describe("AgentSession memory backend lifecycle", () => {
let settings: Settings;
let tempDir: TempDir;
beforeEach(async () => {
beforeEach(() => {
tempDir = TempDir.createSync("@memory-backend-lifecycle-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
authStorage = createInMemoryAuthStorage();
authStorage.setRuntimeApiKey("anthropic", "test-key");
settings = Settings.isolated({
"compaction.enabled": false,
@@ -199,7 +199,7 @@ describe("AgentSession message pipeline", () => {
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC",
"base64",
);
const webpData = Buffer.from(await new Bun.Image(seed).resize(200, 200).webp({ quality: 90 }).bytes()).toBase64();
const webpData = Buffer.from(await new Bun.Image(seed).resize(2, 2).webp({ quality: 90 }).bytes()).toBase64();
const historicalImage: ImageContent = {
type: "image",
data: webpData,
@@ -67,7 +67,7 @@ describe("AgentSession mid-turn compaction dead-end", () => {
const extensionPath = path.join(extensionsDir, "compaction-short-circuit.ts");
const extensionLines = ["export default function(pi) {"];
if (options.delayMessageEndPersistence) {
extensionLines.push('\tpi.on("message_end", async () => {', "\t\tawait Bun.sleep(50);", "\t});");
extensionLines.push('\tpi.on("message_end", async () => {', "\t\tawait Promise.resolve();", "\t});");
}
if (options.shortCircuitCompaction) {
extensionLines.push(
@@ -50,7 +50,6 @@ describe("newSession() atomic boundary vs queued hidden steer", () => {
await session?.dispose();
} finally {
for (const authStorage of authStorages.splice(0)) authStorage.close();
await Bun.sleep(0);
await tempDir?.remove();
}
});
@@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, spyOn } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core";
import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
@@ -10,28 +10,33 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils";
import { removeSyncWithRetries, Snowflake, TempDir } from "@oh-my-pi/pi-utils";
import { createAssistantMessage } from "./helpers/agent-session-setup";
describe("AgentSession persistence-keys cache", () => {
let session: AgentSession;
let tempDir: string;
let sessionManager: SessionManager;
let authStorage: AuthStorage | undefined;
let authStorage: AuthStorage;
let authDir: TempDir;
let modelRegistry: ModelRegistry;
beforeEach(async () => {
beforeAll(async () => {
authDir = TempDir.createSync("@pi-cache-auth-");
authStorage = await AuthStorage.create(authDir.join("auth.db"));
modelRegistry = new ModelRegistry(authStorage, authDir.join("models.yml"));
});
afterAll(() => {
authStorage.close();
authDir.removeSync();
});
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `pi-cache-test-${Snowflake.next()}`);
fs.mkdirSync(tempDir, { recursive: true });
const toolSession: ToolSession = {
cwd: tempDir,
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: Settings.isolated(),
};
const tools = await createTools(toolSession);
const tools: AgentTool[] = [];
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) {
throw new Error("bundled model claude-sonnet-4-5 not found");
@@ -42,8 +47,6 @@ describe("AgentSession persistence-keys cache", () => {
});
sessionManager = SessionManager.create(tempDir, tempDir);
authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
session = new AgentSession({
agent,
@@ -59,7 +62,6 @@ describe("AgentSession persistence-keys cache", () => {
if (session) {
await session.dispose();
}
authStorage?.close();
if (fs.existsSync(tempDir)) {
removeSyncWithRetries(tempDir);
}
@@ -10,7 +10,7 @@
* terminal settle, bounded by PLAN_MODE_REMINDER_MAX (then yields to the
* user), and either decision tool resets the counter.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
import { type } from "@oh-my-pi/omptype";
import { Agent, type AgentMessage, type AgentTool, type StreamFn } from "@oh-my-pi/pi-agent-core";
import { createMockModel, type MockModel, type MockResponse } from "@oh-my-pi/pi-ai/providers/mock";
@@ -23,7 +23,7 @@ import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { Snowflake, TempDir } from "@oh-my-pi/pi-utils";
import { TempDir } from "@oh-my-pi/pi-utils";
import planModeReminderPrompt from "../src/prompts/system/plan-mode-tool-decision-reminder.md" with { type: "text" };
/** A stable, literal (non-templated) line of the reminder prompt, so the test
@@ -77,7 +77,21 @@ interface PlanHarness {
describe("AgentSession plan-mode convergence", () => {
let tempDir: TempDir;
let session: AgentSession | undefined;
const authStorages: AuthStorage[] = [];
let authDir: TempDir;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
beforeAll(async () => {
authDir = TempDir.createSync("@pi-plan-converge-auth-");
authStorage = await AuthStorage.create(authDir.join("auth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage, authDir.join("models.yml"));
});
afterAll(() => {
authStorage.close();
authDir.removeSync();
});
beforeEach(() => {
tempDir = TempDir.createSync("@pi-plan-converge-");
@@ -88,7 +102,6 @@ describe("AgentSession plan-mode convergence", () => {
await session?.dispose();
} finally {
session = undefined;
for (const authStorage of authStorages.splice(0)) authStorage.close();
await tempDir?.remove();
}
});
@@ -123,11 +136,6 @@ describe("AgentSession plan-mode convergence", () => {
streamFn: mock.stream,
});
const authStorage = await AuthStorage.create(tempDir.join(`auth-${Snowflake.next()}.db`));
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, tempDir.join(`models-${Snowflake.next()}.yml`));
let advisorMock: MockModel | undefined;
let advisorStreamFn: StreamFn | undefined;
if (options?.advisorResponses) {
@@ -13,7 +13,7 @@
* MUST carry the approved plan reference again (re-read from disk).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core";
@@ -111,8 +111,18 @@ function emitHighUsageTurn(session: AgentSession): void {
describe("AgentSession approved-plan reference re-injection after compaction (issue #1246)", () => {
let tempDir: TempDir;
let fixtureDir: TempDir;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
const cleanups: Array<() => Promise<void>> = [];
beforeAll(async () => {
fixtureDir = TempDir.createSync("@pi-agent-session-plan-ref-compaction-fixture-");
authStorage = await AuthStorage.create(path.join(fixtureDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage, path.join(fixtureDir.path(), "models.yml"));
});
beforeEach(() => {
tempDir = TempDir.createSync("@pi-agent-session-plan-ref-compaction-");
cleanups.length = 0;
@@ -125,6 +135,11 @@ describe("AgentSession approved-plan reference re-injection after compaction (is
vi.restoreAllMocks();
});
afterAll(() => {
authStorage.close();
fixtureDir.removeSync();
});
async function createHarness(strategy: "context-full" | "snapcompact" = "context-full"): Promise<Harness> {
const observedCalls: ObservedPromptCall[] = [];
const waiters: Array<{
@@ -140,9 +155,6 @@ describe("AgentSession approved-plan reference re-injection after compaction (is
// agent-session-eager-compaction / -auto-compaction-queue.
const model = { ...bundled, contextWindow: 200_000, maxTokens: 64_000 };
const authStorage = await AuthStorage.create(path.join(tempDir.path(), `testauth-${cleanups.length}.db`));
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), `models-${cleanups.length}.yml`));
const settings = Settings.isolated({
"compaction.enabled": true,
"compaction.autoContinue": true,
@@ -193,10 +205,7 @@ describe("AgentSession approved-plan reference re-injection after compaction (is
return promise;
};
cleanups.push(async () => {
await session.dispose();
authStorage.close();
});
cleanups.push(() => session.dispose());
return { session, sessionManager, observedCalls, waitForCall };
}
@@ -18,7 +18,7 @@
* plan is delivered exactly once.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core";
@@ -81,8 +81,18 @@ function createAssistantResponse(text: string) {
describe("AgentSession plan-reference delivery tracking (issue #4094)", () => {
let tempDir: TempDir;
let fixtureDir: TempDir;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
const cleanups: Array<() => Promise<void>> = [];
beforeAll(async () => {
fixtureDir = TempDir.createSync("@pi-agent-session-plan-ref-setup-bail-fixture-");
authStorage = await AuthStorage.create(path.join(fixtureDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage, path.join(fixtureDir.path(), "models.yml"));
});
beforeEach(() => {
tempDir = TempDir.createSync("@pi-agent-session-plan-ref-setup-bail-");
cleanups.length = 0;
@@ -95,6 +105,11 @@ describe("AgentSession plan-reference delivery tracking (issue #4094)", () => {
vi.restoreAllMocks();
});
afterAll(() => {
authStorage.close();
fixtureDir.removeSync();
});
async function createHarness(): Promise<Harness> {
const observedCalls: ObservedPromptCall[] = [];
@@ -102,9 +117,6 @@ describe("AgentSession plan-reference delivery tracking (issue #4094)", () => {
if (!bundled) throw new Error("Expected claude-sonnet-4-5 model to exist");
const model = { ...bundled, contextWindow: 200_000, maxTokens: 64_000 };
const authStorage = await AuthStorage.create(path.join(tempDir.path(), `testauth-${cleanups.length}.db`));
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), `models-${cleanups.length}.yml`));
const settings = Settings.isolated({
"compaction.enabled": false,
"task.eager": "off",
@@ -134,10 +146,7 @@ describe("AgentSession plan-reference delivery tracking (issue #4094)", () => {
session = new AgentSession({ agent, sessionManager, settings, modelRegistry });
cleanups.push(async () => {
await session.dispose();
authStorage.close();
});
cleanups.push(() => session.dispose());
return { session, sessionManager, observedCalls };
}
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { type } from "@oh-my-pi/omptype";
import { Agent, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
@@ -9,13 +9,14 @@ import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { executeBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry";
import type { TuiSlashCommandRuntime } from "@oh-my-pi/pi-coding-agent/slash-commands/types";
import { AUTO_THINKING } from "@oh-my-pi/pi-coding-agent/thinking";
import { TempDir } from "@oh-my-pi/pi-utils";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
/**
* Prewalk: one-way switch from the starting model to a fast/cheap target
@@ -29,16 +30,22 @@ import { TempDir } from "@oh-my-pi/pi-utils";
describe("AgentSession prewalk", () => {
let tempDir: TempDir;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
let session: AgentSession | undefined;
beforeEach(async () => {
beforeAll(() => {
tempDir = TempDir.createSync("@pi-prewalk-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
authStorage = createInMemoryAuthStorage();
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
});
afterEach(async () => {
if (session) await session.dispose();
session = undefined;
});
afterAll(() => {
authStorage.close();
tempDir.removeSync();
});
@@ -103,7 +110,6 @@ describe("AgentSession prewalk", () => {
it("prewalks at the first edit/write after the todo gate opens; bash and todo don't trigger", async () => {
const primary = modelOrThrow("claude-sonnet-4-5");
const target = modelOrThrow("claude-sonnet-4-6");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
// Turn 1: read-only. Turn 2: bash is excluded. Turn 3: todo opens the gate.
// Turn 4: write is the first post-todo edit/write, so it switches.
@@ -156,7 +162,6 @@ describe("AgentSession prewalk", () => {
it("an edit before any todo call does not switch while a todo tool exists; the next edit after todo does", async () => {
const primary = modelOrThrow("claude-sonnet-4-5");
const target = modelOrThrow("claude-sonnet-4-6");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
// Turn 1: exploration. Turn 2: write while the gate is closed.
// Turn 3: todo opens the gate. Turn 4: write switches.
@@ -209,7 +214,7 @@ describe("AgentSession prewalk", () => {
it("keeps the todo gate closed after a failed todo call", async () => {
const primary = modelOrThrow("claude-sonnet-4-5");
const target = modelOrThrow("claude-sonnet-4-6");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
const failingTodoTool: AgentTool<typeof todoToolSchema, undefined> = {
...todoTool,
async execute() {
@@ -262,7 +267,6 @@ describe("AgentSession prewalk", () => {
// was written. The safety net must force one more turn.
const primary = modelOrThrow("claude-sonnet-4-5");
const target = modelOrThrow("claude-sonnet-4-6");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
const mock = createMockModel({
responses: [
@@ -319,7 +323,6 @@ describe("AgentSession prewalk", () => {
// reply end the run. No mock fallback: a stray extra turn rejects.
const primary = modelOrThrow("claude-sonnet-4-5");
const target = modelOrThrow("claude-sonnet-4-6");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
// Turn 1: record (nudge injected after). Turn 2: bash — not an action
// tool. Turn 3: prose — the single continuation fires. Turn 4: prose
@@ -374,7 +377,6 @@ describe("AgentSession prewalk", () => {
it("does not switch on a read-only xd:// device dispatched through write (issue #7312)", async () => {
const primary = modelOrThrow("claude-sonnet-4-5");
const target = modelOrThrow("claude-sonnet-4-6");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
// A read-only lsp navigation is dispatched as `write xd://lsp`; the write
// result carries the wrapped tool's read tier. Like a bash step, it must
@@ -439,7 +441,6 @@ describe("AgentSession prewalk", () => {
it("switches on a write-tier xd:// device dispatched through write (issue #7312)", async () => {
const primary = modelOrThrow("claude-sonnet-4-5");
const target = modelOrThrow("claude-sonnet-4-6");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
// An lsp rename is a write-tier device call — it must arm the hand-off
// just like a direct edit/write: the write turn stays on the strong model,
@@ -507,7 +508,6 @@ describe("AgentSession prewalk", () => {
// cannot end the run before edit/write.
const primary = modelOrThrow("claude-sonnet-4-5");
const target = modelOrThrow("claude-sonnet-4-6");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
// Turn 1: read-only (nudge injected after). Turn 2: prose plan —
// bridged. Turn 3: todo — gate opens and re-arms the net. Turn 4:
@@ -567,7 +567,6 @@ describe("AgentSession prewalk", () => {
// cannot call an inactive tool — and prewalk never fired.
const primary = modelOrThrow("claude-sonnet-4-5");
const target = modelOrThrow("claude-sonnet-4-6");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
// Turn 1: read-only (nudge injected after). Turn 2: write — first
// edit/write must switch immediately; no todo call is possible.
@@ -613,7 +612,7 @@ describe("AgentSession prewalk", () => {
it("armPrewalk (the /prewalk slash command) pre-arms the switch for the very next edit/write", async () => {
const primary = modelOrThrow("claude-sonnet-4-5");
const target = modelOrThrow("claude-sonnet-4-6");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
const sessionManager = SessionManager.inMemory();
sessionManager.appendCustomMessageEntry(
"prewalk-plan",
@@ -676,7 +675,7 @@ describe("AgentSession prewalk", () => {
it("armPrewalk rejects a same-model same-effort no-op", async () => {
const model = modelOrThrow("claude-sonnet-4-5");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
const mock = createMockModel({ responses: [{ content: ["status only"] }] });
const agent = new Agent({
getApiKey: () => "test-key",
@@ -712,7 +711,7 @@ describe("AgentSession prewalk", () => {
it("/prewalk reports success only when the requested arm remains active", async () => {
const primary = modelOrThrow("claude-sonnet-4-5");
const target = modelOrThrow("claude-sonnet-4-6");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
const settings = Settings.isolated({ "compaction.enabled": false });
const sessionManager = SessionManager.inMemory();
const agent = new Agent({
@@ -766,7 +765,7 @@ describe("AgentSession prewalk", () => {
it("requires a fresh todo before a later explicit prewalk can hand off", async () => {
const primary = modelOrThrow("claude-sonnet-4-5");
const target = modelOrThrow("claude-sonnet-4-6");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
const mock = createMockModel({
responses: [
toolCall("first-todo", "todo"),
@@ -826,7 +825,6 @@ describe("AgentSession prewalk", () => {
// as a no-op. On a reasoning model the effort is the bulk of the cost, so
// this must still switch.
const model = modelOrThrow("claude-sonnet-4-5");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
// todo excluded from the active slate → the gate opens; record then write.
const mock = createMockModel({
@@ -866,7 +864,6 @@ describe("AgentSession prewalk", () => {
it("emits a notice when the prewalk target is a genuine no-op", async () => {
// Same model and same effective thinking level: no state change.
const model = modelOrThrow("claude-sonnet-4-5");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
const mock = createMockModel({
responses: [toolCall("t1", "record"), toolCall("t2", "write"), { content: ["done"] }],
@@ -909,7 +906,6 @@ describe("AgentSession prewalk", () => {
// A model capped at high resolves an xhigh target back to high.
// The equal effective settings must be recognized as a no-op.
const model = modelOrThrow("claude-sonnet-4-6"); // supported efforts cap at high
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
const mock = createMockModel({
responses: [toolCall("t1", "record"), toolCall("t2", "write"), { content: ["done"] }],
@@ -952,7 +948,6 @@ describe("AgentSession prewalk", () => {
// per-turn classification, so this is a real change and must switch — not
// collapse to a no-op.
const model = modelOrThrow("claude-sonnet-4-5");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
const mock = createMockModel({
responses: [toolCall("t1", "record"), toolCall("t2", "write"), { content: ["done"] }],
@@ -11,7 +11,7 @@
* post-prompt recovery, but the loop is already done) must be drained when
* the session settles.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
@@ -36,8 +36,18 @@ interface SteerHarness {
describe("AgentSession queued steer delivery", () => {
let tempDir: string;
let fixtureDir: string;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
let session: AgentSession;
const authStorages: AuthStorage[] = [];
beforeAll(async () => {
fixtureDir = path.join(os.tmpdir(), `pi-steer-strand-fixture-${Snowflake.next()}`);
fs.mkdirSync(fixtureDir, { recursive: true });
authStorage = await AuthStorage.create(path.join(fixtureDir, "auth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage, path.join(fixtureDir, "models.yml"));
});
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `pi-steer-strand-${Snowflake.next()}`);
@@ -46,12 +56,14 @@ describe("AgentSession queued steer delivery", () => {
afterEach(async () => {
await session?.dispose();
for (const authStorage of authStorages.splice(0)) {
authStorage.close();
}
removeSyncWithRetries(tempDir);
});
afterAll(() => {
authStorage.close();
removeSyncWithRetries(fixtureDir);
});
async function createSession(responses: MockResponse[]): Promise<SteerHarness> {
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
const mock = createMockModel({ responses });
@@ -62,10 +74,7 @@ describe("AgentSession queued steer delivery", () => {
});
const sessionManager = SessionManager.inMemory();
const settings = Settings.isolated({ "compaction.enabled": false });
const authStorage = await AuthStorage.create(path.join(tempDir, `auth-${Snowflake.next()}.db`));
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
session = new AgentSession({ agent, sessionManager, settings, modelRegistry });
return { session, sessionManager, mock };
}
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { scheduler } from "node:timers/promises";
import { Agent } from "@oh-my-pi/pi-agent-core";
@@ -64,14 +64,24 @@ describe("AgentSession retry delay cap", () => {
let modelRegistry: ModelRegistry;
let session: AgentSession | undefined;
beforeEach(async () => {
beforeAll(async () => {
tempDir = TempDir.createSync("@pi-retry-cap-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
});
beforeEach(async () => {
// A live env var now overrides a stored static api_key; these tests rotate stored Anthropic
// credentials, so neutralize env resolution (ignores every provider's ambient env key).
vi.spyOn(aiStream, "getEnvApiKey").mockReturnValue(undefined);
for (const provider of ["anthropic", "openai-codex"]) {
await authStorage.remove(provider);
}
for (const provider of ["anthropic", "openai", "openai-codex", "openrouter", "cursor"]) {
authStorage.removeRuntimeApiKey(provider);
}
authStorage.setRuntimeApiKey("anthropic", "anthropic-test-key");
modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
modelRegistry.clearSuppressedSelectors();
});
afterEach(async () => {
@@ -81,6 +91,9 @@ describe("AgentSession retry delay cap", () => {
}
unregisterCustomApis(RETRY_CAP_MOCK_API_SOURCE);
vi.restoreAllMocks();
});
afterAll(() => {
authStorage.close();
tempDir.removeSync();
});
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { scheduler } from "node:timers/promises";
import { Agent } from "@oh-my-pi/pi-agent-core";
@@ -119,16 +119,24 @@ function successfulAssistantEntry(sessionManager: SessionManager, text: string):
describe("AgentSession retry recovery", () => {
let tempDir: TempDir;
let fixtureDir: TempDir;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
let sessions: AgentSession[];
let managers: SessionManager[];
beforeAll(async () => {
fixtureDir = TempDir.createSync("@pi-retry-recovery-fixture-");
authStorage = await AuthStorage.create(path.join(fixtureDir.path(), "testauth.db"));
modelRegistry = new ModelRegistry(authStorage, path.join(fixtureDir.path(), "models.yml"));
});
beforeEach(async () => {
tempDir = TempDir.createSync("@pi-retry-recovery-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
vi.spyOn(aiStream, "getEnvApiKey").mockReturnValue(undefined);
modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
await authStorage.remove("anthropic");
authStorage.removeRuntimeApiKey("anthropic");
modelRegistry.clearSuppressedSelectors();
sessions = [];
managers = [];
});
@@ -140,11 +148,15 @@ describe("AgentSession retry recovery", () => {
for (const manager of managers.splice(0).reverse()) {
await manager.close();
}
authStorage.close();
tempDir.removeSync();
vi.restoreAllMocks();
});
afterAll(() => {
authStorage.close();
fixtureDir.removeSync();
});
async function runCredentialRecovery(): Promise<RecoveryRun> {
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) {
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { Effort } from "@oh-my-pi/pi-ai";
@@ -19,9 +19,19 @@ import { createAssistantMessage } from "./helpers/agent-session-setup";
describe("AgentSession role model thinking behavior", () => {
let tempDir: TempDir;
let fixtureDir: TempDir;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
let session: AgentSession;
let sessionSettings: Settings;
const authStorages: AuthStorage[] = [];
beforeAll(async () => {
fixtureDir = TempDir.createSync("@pi-role-thinking-fixture-");
authStorage = await AuthStorage.create(path.join(fixtureDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
authStorage.setRuntimeApiKey("openai", "test-key");
modelRegistry = new ModelRegistry(authStorage, path.join(fixtureDir.path(), "models.yml"));
});
beforeEach(() => {
tempDir = TempDir.createSync("@pi-role-thinking-");
@@ -32,12 +42,14 @@ describe("AgentSession role model thinking behavior", () => {
if (session) {
await session.dispose();
}
for (const authStorage of authStorages.splice(0)) {
authStorage.close();
}
tempDir.removeSync();
});
afterAll(() => {
authStorage.close();
fixtureDir.removeSync();
});
function getAnthropicModelOrThrow(id: string) {
const model = getBundledModel("anthropic", id);
if (!model) throw new Error(`Expected anthropic model ${id} to exist`);
@@ -60,14 +72,11 @@ describe("AgentSession role model thinking behavior", () => {
thinkingLevel: options.initialThinkingLevel,
},
});
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const runtimeApiKeys = options.runtimeApiKeys ?? {};
for (const provider in runtimeApiKeys) {
authStorage.setRuntimeApiKey(provider, runtimeApiKeys[provider]);
}
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
sessionSettings = Settings.isolated();
for (const [role, modelRoleValue] of Object.entries(options.modelRoles)) {
@@ -220,10 +229,7 @@ describe("AgentSession role model thinking behavior", () => {
thinkingLevel: undefined,
},
});
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-non-xhigh.db"));
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-non-xhigh.yml"));
sessionSettings = Settings.isolated();
session = new AgentSession({
@@ -250,10 +256,7 @@ describe("AgentSession role model thinking behavior", () => {
thinkingLevel: undefined,
},
});
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-max-clamp.db"));
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-max-clamp.yml"));
sessionSettings = Settings.isolated();
session = new AgentSession({
@@ -280,10 +283,7 @@ describe("AgentSession role model thinking behavior", () => {
thinkingLevel: Effort.High,
},
});
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-cycle-thinking.db"));
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-cycle-thinking.yml"));
sessionSettings = Settings.isolated();
session = new AgentSession({
@@ -330,10 +330,7 @@ describe("AgentSession role model thinking behavior", () => {
thinkingLevel: Effort.XHigh,
},
});
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-cycle-max.db"));
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-cycle-max.yml"));
sessionSettings = Settings.isolated();
session = new AgentSession({
@@ -388,10 +385,7 @@ describe("AgentSession role model thinking behavior", () => {
thinkingLevel: resolveProvisionalAutoLevel(model),
},
});
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-auto-resume.db"));
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-auto-resume.yml"));
const sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
sessionSettings = Settings.isolated();
sessionSettings.set("defaultThinkingLevel", AUTO_THINKING);
@@ -434,10 +428,7 @@ describe("AgentSession role model thinking behavior", () => {
thinkingLevel: resolveProvisionalAutoLevel(model),
},
});
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-manual-resume.db"));
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-manual-resume.yml"));
const sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
sessionSettings = Settings.isolated();
sessionSettings.set("defaultThinkingLevel", AUTO_THINKING);
@@ -480,10 +471,7 @@ describe("AgentSession role model thinking behavior", () => {
thinkingLevel: resolveProvisionalAutoLevel(model),
},
});
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-pin-eq.db"));
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-pin-eq.yml"));
const sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
sessionSettings = Settings.isolated();
sessionSettings.set("defaultThinkingLevel", AUTO_THINKING);
@@ -640,10 +628,7 @@ describe("AgentSession role model thinking behavior", () => {
thinkingLevel: undefined,
},
});
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-non-reasoning-auto.db"));
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("openai", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-non-reasoning-auto.yml"));
sessionSettings = Settings.isolated();
sessionSettings.set("defaultThinkingLevel", AUTO_THINKING);
session = new AgentSession({
@@ -12,7 +12,7 @@
* the persisted message (in-place mutation) and the emitted display event
* (deobfuscated spread copy) carry the marker (A4).
*/
import { afterEach, describe, expect, it, vi } from "bun:test";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, TextContent } from "@oh-my-pi/pi-ai";
@@ -55,16 +55,13 @@ function makeStoppedAssistantMessage(text = "done"): AssistantMessage {
}
interface SessionFixture {
tempDir: TempDir;
authStorage: AuthStorage;
session: AgentSession;
}
async function createSessionWithObfuscator(obfuscator?: SecretObfuscator): Promise<SessionFixture> {
const tempDir = TempDir.createSync("@pi-silent-abort-");
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage);
async function createSessionWithObfuscator(
modelRegistry: ModelRegistry,
obfuscator?: SecretObfuscator,
): Promise<SessionFixture> {
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected built-in anthropic model to exist");
@@ -85,24 +82,36 @@ async function createSessionWithObfuscator(obfuscator?: SecretObfuscator): Promi
obfuscator,
});
return { tempDir, authStorage, session };
return { session };
}
describe("AgentSession silent-abort marker stamping", () => {
let fixture: SessionFixture | undefined;
let fixtureDir: TempDir;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
beforeAll(async () => {
fixtureDir = TempDir.createSync("@pi-silent-abort-fixture-");
authStorage = await AuthStorage.create(path.join(fixtureDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
});
afterEach(async () => {
if (fixture) {
await fixture.session.dispose();
fixture.authStorage.close();
fixture.tempDir.removeSync();
fixture = undefined;
}
vi.restoreAllMocks();
});
afterAll(() => {
authStorage.close();
fixtureDir.removeSync();
});
it("A1: flag set + aborted assistant message_end stamps the marker and clears the flag", async () => {
fixture = await createSessionWithObfuscator();
fixture = await createSessionWithObfuscator(modelRegistry);
const { session } = fixture;
session.markPlanInternalAbortPending();
expect(session.isPlanInternalAbortPending).toBe(true);
@@ -121,7 +130,7 @@ describe("AgentSession silent-abort marker stamping", () => {
});
it("A2: flag unset + aborted assistant message_end leaves errorMessage and flag alone", async () => {
fixture = await createSessionWithObfuscator();
fixture = await createSessionWithObfuscator(modelRegistry);
const { session } = fixture;
expect(session.isPlanInternalAbortPending).toBe(false);
@@ -135,7 +144,7 @@ describe("AgentSession silent-abort marker stamping", () => {
});
it("A3: flag set + non-aborted message_end does NOT consume the flag", async () => {
fixture = await createSessionWithObfuscator();
fixture = await createSessionWithObfuscator(modelRegistry);
const { session } = fixture;
session.markPlanInternalAbortPending();
@@ -169,7 +178,7 @@ describe("AgentSession silent-abort marker stamping", () => {
// Sanity: obfuscation produced a placeholder embedded in the text.
expect(obfuscatedText).not.toBe("hello SECRET_VALUE world");
fixture = await createSessionWithObfuscator(obfuscator);
fixture = await createSessionWithObfuscator(modelRegistry, obfuscator);
const { session } = fixture;
// Capture session-emitted events.
@@ -50,9 +50,9 @@ describe("AgentSession skill prompt keyword steering", () => {
tempDir = TempDir.createSync("@pi-agent-session-skill-keywords-");
observedTurns.length = 0;
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage = await AuthStorage.create(":memory:");
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
const modelRegistry = new ModelRegistry(authStorage);
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist");
@@ -1,5 +1,4 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import { Agent } from "@oh-my-pi/pi-agent-core";
import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction";
import type { Message } from "@oh-my-pi/pi-ai";
@@ -9,7 +8,6 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
const UNRENDERABLE_SNAPCOMPACT_TEXT = "\uE000\uE001\uE002\uE003\uE004\uE005\uE006\uE007\uE008\uE009";
@@ -26,16 +24,13 @@ interface HarnessOptions {
seedMessages?: Message[];
}
async function createHarness(tempDir: TempDir, authStorage: AuthStorage, options: HarnessOptions): Promise<Harness> {
async function createHarness(modelRegistry: ModelRegistry, options: HarnessOptions): Promise<Harness> {
const activeModel = getBundledModel(options.activeModel.provider, options.activeModel.id);
if (!activeModel) throw new Error(`Missing bundled model ${options.activeModel.provider}/${options.activeModel.id}`);
authStorage.setRuntimeApiKey(options.activeModel.provider, "test-key");
const modelRegistry = new ModelRegistry(authStorage);
const agent = new Agent({
initialState: { model: activeModel, systemPrompt: ["Test"], tools: [], messages: [] },
});
const sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
const sessionManager = SessionManager.inMemory();
const seed = options.seedMessages ?? [{ role: "user", content: "hello", timestamp: Date.now() }];
for (const message of seed) sessionManager.appendMessage(message);
const firstKeptEntryId = sessionManager.getBranch()[0]?.id;
@@ -110,26 +105,27 @@ async function createHarness(tempDir: TempDir, authStorage: AuthStorage, options
describe("AgentSession auto-snapcompact local-blocker fallback", () => {
let session: AgentSession | undefined;
let authStorage: AuthStorage | undefined;
let tempDir: TempDir | undefined;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
beforeAll(async () => {
authStorage = await AuthStorage.create(":memory:");
authStorage.setRuntimeApiKey("aimlapi", "test-key");
modelRegistry = new ModelRegistry(authStorage);
});
afterEach(async () => {
try {
await session?.dispose();
} finally {
authStorage?.close();
await tempDir?.remove();
vi.restoreAllMocks();
session = undefined;
authStorage = undefined;
tempDir = undefined;
}
});
afterAll(() => {
authStorage.close();
});
it("downgrades to context-full when the active model cannot read snapcompact frames", async () => {
tempDir = TempDir.createSync("@pi-snapcompact-text-only-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
const harness = await createHarness(tempDir, authStorage, {
const harness = await createHarness(modelRegistry, {
activeModel: { provider: "aimlapi", id: "alibaba/qwen3-coder-480b-a35b-instruct" },
});
session = harness.session;
@@ -148,9 +144,7 @@ describe("AgentSession auto-snapcompact local-blocker fallback", () => {
});
it("downgrades to context-full when unsupported glyphs make snapcompact unsafe", async () => {
tempDir = TempDir.createSync("@pi-snapcompact-unsupported-glyphs-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
const harness = await createHarness(tempDir, authStorage, {
const harness = await createHarness(modelRegistry, {
activeModel: { provider: "aimlapi", id: "claude-sonnet-4-5-20250929" },
seedMessages: [
{
@@ -18,8 +18,7 @@
* result instead of falling back to the LLM summarizer.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { effectiveReserveTokens, estimateTokens, prepareCompaction } from "@oh-my-pi/pi-agent-core/compaction";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
@@ -30,23 +29,22 @@ import { computeNonMessageTokens } from "@oh-my-pi/pi-coding-agent/modes/utils/c
import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
import * as snapcompact from "@oh-my-pi/snapcompact";
describe("AgentSession snapcompact frame-budget sizing", () => {
let tempDir: TempDir;
let session: AgentSession;
let sessionManager: SessionManager;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
beforeEach(async () => {
tempDir = TempDir.createSync("@pi-snapcompact-budget-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
beforeAll(async () => {
authStorage = await AuthStorage.create(":memory:");
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
});
beforeEach(() => {
sessionManager = SessionManager.inMemory();
const bundled = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!bundled) throw new Error("Expected bundled claude-sonnet-4-5 model");
@@ -105,13 +103,12 @@ describe("AgentSession snapcompact frame-budget sizing", () => {
});
afterEach(async () => {
try {
await session?.dispose();
} finally {
authStorage?.close();
await tempDir?.remove();
vi.restoreAllMocks();
}
});
afterAll(() => {
authStorage.close();
});
it("passes a maxFrames whose full projection (frames + text edges + base) fits the budget", async () => {
@@ -246,25 +243,7 @@ describe("AgentSession snapcompact frame-budget sizing", () => {
it("applies the frame byte cap when the model context window is unknown", async () => {
const model = session.model;
if (!model) throw new Error("Expected model");
await session.dispose();
// dispose() released the manager's in-memory transcript; reopen the
// persisted file for the replacement session, as revival paths do.
const sessionFile = sessionManager.getSessionFile();
if (!sessionFile) throw new Error("Expected a persisted session file");
sessionManager = await SessionManager.open(sessionFile, tempDir.path());
const unknownWindowModel = { ...model, contextWindow: 0 };
session = new AgentSession({
agent: new Agent({
initialState: { model: unknownWindowModel, systemPrompt: ["Test"], tools: [], messages: [] },
}),
sessionManager,
settings: Settings.isolated({
"compaction.strategy": "snapcompact",
"compaction.autoContinue": false,
"compaction.keepRecentTokens": 4000,
}),
modelRegistry,
});
session.agent.setModel({ ...model, contextWindow: 0 });
const branchEntries = sessionManager.getBranch();
const lastEntry = branchEntries[branchEntries.length - 1];
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import { Agent, RESCUE_SHAKE_CONFIG } from "@oh-my-pi/pi-agent-core";
@@ -38,6 +38,12 @@ describe("AgentSession snapcompact frame dead-end rescue", () => {
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
beforeAll(async () => {
authStorage = await AuthStorage.create(":memory:");
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
});
const NOTICE_SOURCE = "compaction";
const NO_PROGRESS_FRAGMENT = "Compaction freed too little context to make progress";
const SEEDED_FRAME_COUNT = 16;
@@ -75,10 +81,7 @@ describe("AgentSession snapcompact frame dead-end rescue", () => {
preArchiveKeptText?: string;
}): Promise<void> {
tempDir = TempDir.createSync("@pi-snapcompact-frame-dead-end-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
sessionManager = SessionManager.inMemory(tempDir.path());
let extensionRunner: ExtensionRunner | undefined;
if (options.hookArchiveFrames !== undefined) {
@@ -192,12 +195,15 @@ describe("AgentSession snapcompact frame dead-end rescue", () => {
try {
await session?.dispose();
} finally {
authStorage?.close();
await tempDir?.remove();
vi.restoreAllMocks();
}
});
afterAll(() => {
authStorage.close();
});
function collectNotices() {
const notices: { level: string; message: string; source?: string }[] = [];
session.subscribe(event => {
@@ -1,5 +1,4 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, Message, UserMessage } from "@oh-my-pi/pi-ai";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
@@ -7,23 +6,19 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
describe("AgentSession session stats", () => {
let tempDir: TempDir;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
let session: AgentSession | undefined;
beforeAll(async () => {
tempDir = TempDir.createSync("@pi-session-stats-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
authStorage = await AuthStorage.create(":memory:");
modelRegistry = new ModelRegistry(authStorage);
});
afterAll(() => {
authStorage.close();
tempDir.removeSync();
});
afterEach(async () => {
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
import { Agent } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, ToolResultMessage } from "@oh-my-pi/pi-ai";
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
@@ -60,6 +59,14 @@ describe("AgentSession steer idle drain", () => {
let tempDir: TempDir;
let session: AgentSession;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
beforeAll(async () => {
tempDir = TempDir.createSync("@pi-steer-idle-drain-");
authStorage = await AuthStorage.create(":memory:");
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
});
async function createSession(messages: Parameters<typeof Agent.prototype.appendMessage>[0][]): Promise<void> {
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
@@ -68,29 +75,28 @@ describe("AgentSession steer idle drain", () => {
const agent = new Agent({
initialState: { model, systemPrompt: ["Test"], tools: [], messages },
});
const sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
const sessionManager = SessionManager.inMemory(tempDir.path());
session = new AgentSession({
agent,
sessionManager,
settings: Settings.isolated({}),
modelRegistry: new ModelRegistry(authStorage),
modelRegistry,
});
}
beforeEach(async () => {
tempDir = TempDir.createSync("@pi-steer-idle-drain-");
beforeEach(() => {
vi.useFakeTimers();
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
});
afterEach(async () => {
await session.dispose();
authStorage.close();
tempDir.removeSync();
vi.useRealTimers();
vi.restoreAllMocks();
});
afterAll(() => {
authStorage.close();
tempDir.removeSync();
});
it("delivers a steer queued on an idle resumable session via continue()", async () => {
await createSession([{ role: "user", content: "hello", timestamp: Date.now() }, createAssistantMessage()]);
@@ -147,12 +153,12 @@ describe("AgentSession steer idle drain", () => {
initialState: { model, systemPrompt: ["Test"], tools: [] },
streamFn: mock.stream,
});
const sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
const sessionManager = SessionManager.inMemory(tempDir.path());
session = new AgentSession({
agent,
sessionManager,
settings: Settings.isolated({ "compaction.enabled": false }),
modelRegistry: new ModelRegistry(authStorage),
modelRegistry,
});
const running = session.prompt("do the thing");
@@ -1,5 +1,4 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import type { Model } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
@@ -21,7 +20,6 @@ import { TempDir } from "@oh-my-pi/pi-utils";
* different-session switches MUST skip that work.
*/
describe("AgentSession.switchSession previous-context build", () => {
let sharedDir: TempDir;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
let model: Model;
@@ -29,8 +27,7 @@ describe("AgentSession.switchSession previous-context build", () => {
const sessions: AgentSession[] = [];
beforeAll(async () => {
sharedDir = TempDir.createSync("@pi-switch-prev-ctx-shared-");
authStorage = await AuthStorage.create(path.join(sharedDir.path(), "testauth.db"));
authStorage = await AuthStorage.create(":memory:");
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
const bundled = getBundledModel("anthropic", "claude-sonnet-4-5");
@@ -38,11 +35,8 @@ describe("AgentSession.switchSession previous-context build", () => {
model = bundled;
});
afterAll(async () => {
afterAll(() => {
authStorage.close();
try {
await sharedDir.remove();
} catch {}
});
afterEach(async () => {
@@ -1,5 +1,4 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import { type } from "@oh-my-pi/omptype";
import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
@@ -23,15 +22,24 @@ const failingTool: AgentTool<typeof failingToolSchema, Record<string, never>> =
},
};
type Harness = { session: AgentSession; authStorage: AuthStorage; tempDir: TempDir };
type Harness = { session: AgentSession; tempDir: TempDir };
const activeHarnesses: Harness[] = [];
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
beforeAll(async () => {
authStorage = await AuthStorage.create(":memory:");
authStorage.setRuntimeApiKey("mock", "test-key");
modelRegistry = new ModelRegistry(authStorage);
});
afterAll(() => {
authStorage.close();
});
async function createHarness(responses: MockResponse[]): Promise<Harness & { sessionManager: SessionManager }> {
const tempDir = TempDir.createSync("@pi-terminal-error-persistence-");
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
authStorage.setRuntimeApiKey("mock", "test-key");
const mock = createMockModel({ responses });
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.enabled": false,
@@ -54,7 +62,7 @@ async function createHarness(responses: MockResponse[]): Promise<Harness & { ses
modelRegistry,
toolRegistry: new Map(tools.map(tool => [tool.name, tool])),
});
const harness = { session, authStorage, tempDir };
const harness = { session, tempDir };
activeHarnesses.push(harness);
return { ...harness, sessionManager };
}
@@ -70,7 +78,6 @@ function persistedErrorTurns(sessionManager: SessionManager): AssistantMessage[]
afterEach(async () => {
for (const harness of activeHarnesses.splice(0)) {
await harness.session.dispose();
harness.authStorage.close();
harness.tempDir.removeSync();
}
vi.restoreAllMocks();
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import { scheduler } from "node:timers/promises";
import { Agent } from "@oh-my-pi/pi-agent-core";
import type {
@@ -21,7 +20,6 @@ import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { type CustomMessage, convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
const LOOP_PARAGRAPHS = [
"I am now verifying the test module to guarantee there are no compile errors and the code is completely safe.",
@@ -112,14 +110,14 @@ function errorIdOnlyThinkingLoopStream(model: Model<Api>): AssistantMessageEvent
}
describe("AgentSession thinking-loop retry", () => {
let tempDir: TempDir;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
let session: AgentSession | undefined;
beforeEach(async () => {
tempDir = TempDir.createSync("@pi-thinking-loop-retry-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
beforeAll(async () => {
authStorage = await AuthStorage.create(":memory:");
authStorage.setRuntimeApiKey("openrouter", "openrouter-test-key");
modelRegistry = new ModelRegistry(authStorage);
});
afterEach(async () => {
@@ -127,14 +125,15 @@ describe("AgentSession thinking-loop retry", () => {
await session.dispose();
session = undefined;
}
authStorage.close();
tempDir.removeSync();
vi.restoreAllMocks();
});
afterAll(() => {
authStorage.close();
});
it("drops a chunked thinking-loop error and retries the turn", async () => {
const model = createMockModel({ provider: "openrouter", id: "google/gemini-3.5-flash" }).model;
const modelRegistry = new ModelRegistry(authStorage);
const calls: string[] = [];
const agent = new Agent({
getApiKey: requestedModel => `${requestedModel.provider}-test-key`,
@@ -195,7 +194,6 @@ describe("AgentSession thinking-loop retry", () => {
it("starts retry for thinking-loop errorId even without transient wording", async () => {
const model = createMockModel({ provider: "openrouter", id: "google/gemini-3.5-flash" }).model;
const modelRegistry = new ModelRegistry(authStorage);
const calls: string[] = [];
const agent = new Agent({
getApiKey: requestedModel => `${requestedModel.provider}-test-key`,
@@ -247,7 +245,6 @@ describe("AgentSession thinking-loop retry", () => {
it("injects a redirect notice into the retried turn after a thinking loop", async () => {
const model = createMockModel({ provider: "openrouter", id: "google/gemini-3.5-flash" }).model;
const modelRegistry = new ModelRegistry(authStorage);
const calls: string[] = [];
const contexts: Context[] = [];
const agent = new Agent({
@@ -315,7 +312,6 @@ describe("AgentSession thinking-loop retry", () => {
it("injects a redirect notice on each consecutive thinking-loop retry", async () => {
const model = createMockModel({ provider: "openrouter", id: "google/gemini-3.5-flash" }).model;
const modelRegistry = new ModelRegistry(authStorage);
const calls: string[] = [];
const contexts: Context[] = [];
const agent = new Agent({
@@ -1,5 +1,4 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import * as ai from "@oh-my-pi/pi-ai";
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
@@ -9,27 +8,22 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
import { createAssistantMessage } from "./helpers/agent-session-setup";
let session: AgentSession | undefined;
let authStorage: AuthStorage | undefined;
let tempDir: TempDir | undefined;
afterEach(async () => {
vi.restoreAllMocks();
await session?.dispose();
authStorage?.close();
tempDir?.removeSync();
session = undefined;
authStorage = undefined;
tempDir = undefined;
});
describe("AgentSession title generation disposal", () => {
it("uses the active provider session and aborts an in-flight title request during disposal", async () => {
tempDir = TempDir.createSync("@pi-title-dispose-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
authStorage = await AuthStorage.create(":memory:");
authStorage.setRuntimeApiKey("anthropic", "test-key");
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist");
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
@@ -7,7 +6,6 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
/**
* Regression coverage: `AgentSession.#cloneTodoPhases` used to clone only
@@ -18,18 +16,16 @@ import { TempDir } from "@oh-my-pi/pi-utils";
* storage read/write goes through).
*/
describe("AgentSession todo blocker clone", () => {
let tempDir: TempDir;
let session: AgentSession;
let sessionManager: SessionManager;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
beforeEach(async () => {
tempDir = TempDir.createSync("@pi-todo-blocker-clone-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage = await AuthStorage.create(":memory:");
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
sessionManager = SessionManager.inMemory();
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected built-in anthropic model to exist");
@@ -49,9 +45,6 @@ describe("AgentSession todo blocker clone", () => {
afterEach(async () => {
await session.dispose();
authStorage.close();
try {
await tempDir.remove();
} catch {}
});
it("preserves a blocker reason across a setTodoPhases/getTodoPhases round-trip", () => {
@@ -1,16 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { Agent, type AgentTool, type AsideMessage } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, TextContent, ToolCall } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type { CustomMessage } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TodoTool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { TempDir } from "@oh-my-pi/pi-utils";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
/**
* Regression coverage for issue #3651 and its redesign: the mid-run todo
@@ -34,12 +33,18 @@ import { TempDir } from "@oh-my-pi/pi-utils";
* after a batch of synthesized `message_end` events mirrors that injection
* point without spinning a real model.
*/
const sharedAuthStorage = createInMemoryAuthStorage();
sharedAuthStorage.setRuntimeApiKey("anthropic", "test-key");
const sharedModelRegistry = new ModelRegistry(sharedAuthStorage);
afterAll(() => {
sharedAuthStorage.close();
});
describe("AgentSession mid-run todo reconciliation nudge", () => {
let tempDir: TempDir;
let session: AgentSession;
let sessionManager: SessionManager;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
let reminderEvents: Array<Extract<AgentSessionEvent, { type: "todo_reminder" }>>;
let asideProvider: (() => AsideMessage[] | Promise<AsideMessage[]>) | undefined;
@@ -88,11 +93,9 @@ describe("AgentSession mid-run todo reconciliation nudge", () => {
timestamp: Date.now(),
};
}
async function emitTextOnlyStop(): Promise<void> {
function emitTextOnlyStop(): void {
const msg = textOnlyAssistant();
session.agent.emitExternalEvent({ type: "message_end", message: msg });
await settle();
session.agent.emitExternalEvent({ type: "agent_end", messages: [msg] });
}
@@ -114,20 +117,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => {
});
}
/**
* #processAgentEvent fires off message_end handlers as async microtasks that
* chain on `#messageEndPersistenceTail`. After a batch of synchronous emits
* the counter only catches up once every queued persist task drains, so
* tests yield a full event-loop tick before draining asides.
*
* Real-timer exception (ts-no-test-timers): `Bun.sleep(0)` is a single
* event-loop tick, not a tuned duration — the private persistence tail
* exposes no drain promise to await, and fake timers cannot flush it.
*/
async function settle(): Promise<void> {
await Bun.sleep(0);
}
async function drainNudges(): Promise<CustomMessage[]> {
if (!asideProvider) throw new Error("aside provider was never captured");
const thunks = await asideProvider();
@@ -142,12 +131,9 @@ describe("AgentSession mid-run todo reconciliation nudge", () => {
return out;
}
beforeEach(async () => {
beforeEach(() => {
tempDir = TempDir.createSync("@pi-todo-mid-run-nudge-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
sessionManager = SessionManager.inMemory(tempDir.path());
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected built-in anthropic model to exist");
@@ -190,7 +176,7 @@ describe("AgentSession mid-run todo reconciliation nudge", () => {
agent,
sessionManager,
settings,
modelRegistry,
modelRegistry: sharedModelRegistry,
});
reminderEvents = [];
@@ -212,7 +198,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => {
afterEach(async () => {
await session.dispose();
authStorage.close();
try {
await tempDir.remove();
} catch {}
@@ -222,7 +207,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => {
it("read-only exploration never ticks the counter, no matter how long", async () => {
for (let i = 0; i < THRESHOLD * 3; i++) emitToolResult(i % 2 === 0 ? "grep" : "read");
await settle();
expect(await drainNudges()).toEqual([]);
expect(reminderEvents).toEqual([]);
});
@@ -230,7 +214,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => {
it("stays silent below the mutation threshold", async () => {
for (let i = 0; i < THRESHOLD - 1; i++) emitToolResult("edit");
await settle();
expect(await drainNudges()).toEqual([]);
expect(reminderEvents).toEqual([]);
});
@@ -238,7 +221,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => {
it("injects a hidden custom nudge at the threshold — no event, no render", async () => {
for (let i = 0; i < THRESHOLD; i++) emitToolResult("edit");
await settle();
const nudges = await drainNudges();
expect(nudges.length).toBe(1);
const nudge = nudges[0];
@@ -264,7 +246,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => {
it("errored mutating results do not tick the counter", async () => {
for (let i = 0; i < THRESHOLD; i++) emitToolResult("bash", { isError: true });
await settle();
expect(await drainNudges()).toEqual([]);
});
@@ -273,7 +254,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => {
emitToolResult("todo");
for (let i = 0; i < THRESHOLD - 1; i++) emitToolResult("write");
await settle();
expect(await drainNudges()).toEqual([]);
expect(reminderEvents).toEqual([]);
});
@@ -282,7 +262,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => {
let fired = 0;
for (let cycle = 0; cycle < MAX_PER_CYCLE + 2; cycle++) {
for (let i = 0; i < THRESHOLD; i++) emitToolResult("edit");
await settle();
fired += (await drainNudges()).length;
}
expect(fired).toBe(MAX_PER_CYCLE);
@@ -317,7 +296,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => {
expect(session.getActiveToolNames()).not.toContain("todo");
for (let i = 0; i < THRESHOLD; i++) emitToolResult("edit");
await settle();
expect(await drainNudges()).toEqual([]);
expect(reminderEvents).toEqual([]);
});
@@ -326,8 +304,7 @@ describe("AgentSession mid-run todo reconciliation nudge", () => {
vi.spyOn(session.agent, "continue").mockResolvedValue();
for (let i = 0; i < THRESHOLD - 1; i++) emitToolResult("edit");
await settle();
await emitTextOnlyStop();
emitTextOnlyStop();
await session.waitForIdle();
// The stop-time path is the user-visible ladder: it emits the event.
expect(reminderEvents.length).toBe(1);
@@ -336,7 +313,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => {
// The stop-time reminder reset the mutation counter, so one more landed
// mutation (crossing the stale pre-reminder threshold) must stay silent.
emitToolResult("edit");
await settle();
expect(await drainNudges()).toEqual([]);
expect(reminderEvents.length).toBe(1);
});
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { Agent } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
@@ -8,9 +7,9 @@ import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir, withTimeout } from "@oh-my-pi/pi-utils";
import { TempDir } from "@oh-my-pi/pi-utils";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
/**
* Regression coverage for the `#hasPendingAsyncWake()` gate shared by the
@@ -47,19 +46,23 @@ import { TempDir, withTimeout } from "@oh-my-pi/pi-utils";
* the same way — so once `waitForIdle()` resolves, the settle has definitively
* decided whether to fire the stop-time passes. No wall-clock sleeps needed.
*/
const sharedAuthStorage = createInMemoryAuthStorage();
sharedAuthStorage.setRuntimeApiKey("anthropic", "test-key");
const sharedModelRegistry = new ModelRegistry(sharedAuthStorage);
afterAll(() => {
sharedAuthStorage.close();
});
describe("AgentSession todo reminder async-job deferral", () => {
let tempDir: TempDir;
let session: AgentSession;
let sessionManager: SessionManager;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
let manager: AsyncJobManager;
let extensionRunner: ExtensionRunner;
let gates: Array<PromiseWithResolvers<string>>;
let reminderAttempts: number[];
let firstReminderPromise: Promise<void>;
let agentEndTerminalStates: Array<boolean | undefined>;
let resolveFirstReminder: () => void;
function textOnlyAssistantMessage(): AssistantMessage {
return {
@@ -108,12 +111,9 @@ describe("AgentSession todo reminder async-job deferral", () => {
]);
}
beforeEach(async () => {
beforeEach(() => {
tempDir = TempDir.createSync("@pi-todo-reminder-async-jobs-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
sessionManager = SessionManager.inMemory(tempDir.path());
manager = new AsyncJobManager({});
gates = [];
extensionRunner = {
@@ -144,7 +144,7 @@ describe("AgentSession todo reminder async-job deferral", () => {
"todo.reminders": true,
"todo.remindersMax": 3,
}),
modelRegistry,
modelRegistry: sharedModelRegistry,
agentId: "Main",
asyncJobManager: manager,
extensionRunner,
@@ -155,12 +155,8 @@ describe("AgentSession todo reminder async-job deferral", () => {
reminderAttempts = [];
agentEndTerminalStates = [];
({ promise: firstReminderPromise, resolve: resolveFirstReminder } = Promise.withResolvers<void>());
session.subscribe((event: AgentSessionEvent) => {
if (event.type === "todo_reminder") {
reminderAttempts.push(event.attempt);
if (reminderAttempts.length === 1) resolveFirstReminder();
}
if (event.type === "todo_reminder") reminderAttempts.push(event.attempt);
if (event.type === "agent_end") {
agentEndTerminalStates.push(
(event as Extract<AgentSessionEvent, { type: "agent_end" }> & { isTerminal?: boolean }).isTerminal,
@@ -175,7 +171,6 @@ describe("AgentSession todo reminder async-job deferral", () => {
await session.dispose();
manager.cancelAll();
await manager.dispose();
authStorage.close();
try {
await tempDir.remove();
} catch {}
@@ -201,7 +196,7 @@ describe("AgentSession todo reminder async-job deferral", () => {
registerGatedJob("OtherAgent");
emitTextOnlyStop();
await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired");
await session.waitForIdle();
expect(reminderAttempts).toEqual([1]);
});
@@ -223,7 +218,7 @@ describe("AgentSession todo reminder async-job deferral", () => {
await manager.drainDeliveries();
emitTextOnlyStop();
await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired after job drained");
await session.waitForIdle();
expect(reminderAttempts).toEqual([1]);
});
@@ -1,14 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { Agent } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, TextContent, ToolCall } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir, withTimeout } from "@oh-my-pi/pi-utils";
import { TempDir } from "@oh-my-pi/pi-utils";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
/**
* Regression coverage for issue #2590: `#checkTodoCompletion` used to schedule
@@ -21,15 +20,19 @@ import { TempDir, withTimeout } from "@oh-my-pi/pi-utils";
* self-continuation chain unless the agent has produced a tool-level result
* (e.g. called `todo` or `edit`) between the prior reminder and the next stop.
*/
const sharedAuthStorage = createInMemoryAuthStorage();
sharedAuthStorage.setRuntimeApiKey("anthropic", "test-key");
const sharedModelRegistry = new ModelRegistry(sharedAuthStorage);
afterAll(() => {
sharedAuthStorage.close();
});
describe("AgentSession todo reminder self-continuation suppression", () => {
let tempDir: TempDir;
let session: AgentSession;
let sessionManager: SessionManager;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
let reminderAttempts: number[];
let firstReminderPromise: Promise<void>;
let resolveFirstReminder: () => void;
function textOnlyAssistantMessage(text = "paused at your instruction"): AssistantMessage {
return {
@@ -105,12 +108,9 @@ describe("AgentSession todo reminder self-continuation suppression", () => {
});
}
beforeEach(async () => {
beforeEach(() => {
tempDir = TempDir.createSync("@pi-todo-reminder-loop-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
sessionManager = SessionManager.inMemory(tempDir.path());
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected built-in anthropic model to exist");
@@ -133,16 +133,12 @@ describe("AgentSession todo reminder self-continuation suppression", () => {
"todo.reminders": true,
"todo.remindersMax": 3,
}),
modelRegistry,
modelRegistry: sharedModelRegistry,
});
reminderAttempts = [];
({ promise: firstReminderPromise, resolve: resolveFirstReminder } = Promise.withResolvers<void>());
session.subscribe((event: AgentSessionEvent) => {
if (event.type === "todo_reminder") {
reminderAttempts.push(event.attempt);
if (reminderAttempts.length === 1) resolveFirstReminder();
}
if (event.type === "todo_reminder") reminderAttempts.push(event.attempt);
});
session.setTodoPhases([
@@ -158,7 +154,6 @@ describe("AgentSession todo reminder self-continuation suppression", () => {
afterEach(async () => {
await session.dispose();
authStorage.close();
try {
await tempDir.remove();
} catch {}
@@ -168,7 +163,7 @@ describe("AgentSession todo reminder self-continuation suppression", () => {
it("baseline: a single text-only stop fires reminder 1/3 and records it in the transcript", async () => {
vi.spyOn(session.agent, "continue").mockResolvedValue();
emitTextOnlyStop();
await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired");
await session.waitForIdle();
expect(reminderAttempts).toEqual([1]);
const reminderEntry = todoReminderTranscriptEntry();
@@ -203,7 +198,6 @@ describe("AgentSession todo reminder self-continuation suppression", () => {
emitTextOnlyStop(
"Which configuration should this use?\nUse the existing default; the remaining todo items still need work.",
);
await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired");
await session.waitForIdle();
expect(reminderAttempts).toEqual([1]);
@@ -215,7 +209,6 @@ describe("AgentSession todo reminder self-continuation suppression", () => {
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
emitTextOnlyStop("Final answer: I summarized the work completed so far, but the todo items remain open.");
await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired");
await session.waitForIdle();
expect(reminderAttempts).toEqual([1]);
@@ -227,7 +220,6 @@ describe("AgentSession todo reminder self-continuation suppression", () => {
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
emitTextOnlyStop("Tail note: the interface includes foo?: string, but the todo items remain open.");
await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired");
await session.waitForIdle();
expect(reminderAttempts).toEqual([1]);
@@ -243,7 +235,6 @@ describe("AgentSession todo reminder self-continuation suppression", () => {
});
emitTextOnlyStop();
await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired");
await session.waitForIdle();
// With the bug: reminderAttempts === [1, 2, 3] within a single user pause.
@@ -268,7 +259,6 @@ describe("AgentSession todo reminder self-continuation suppression", () => {
});
emitTextOnlyStop();
await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired");
await session.waitForIdle();
// 1/3 fires, agent does work, 2/3 fires, agent acks → suppressed, no 3/3.
@@ -57,7 +57,7 @@ describe("AgentSession tool-call loop guard", () => {
convertToLlm,
streamFn: (_model, context) => {
contexts.push(context);
const toolCallTurn = callCount < 5;
const toolCallTurn = callCount < 2;
const toolCallId = `tc-${callCount}`;
callCount++;
const message: AssistantMessage = toolCallTurn
@@ -93,7 +93,7 @@ describe("AgentSession tool-call loop guard", () => {
"compaction.enabled": false,
"todo.enabled": false,
"model.toolCallLoopGuard.enabled": true,
"model.toolCallLoopGuard.threshold": 5,
"model.toolCallLoopGuard.threshold": 2,
"model.toolCallLoopGuard.exemptTools": ["hub"],
});
settings.setModelRole("default", `${model.provider}/${model.id}`);
@@ -108,9 +108,9 @@ describe("AgentSession tool-call loop guard", () => {
await session.prompt("run checks");
await session.waitForIdle();
expect(contexts).toHaveLength(6);
expect(JSON.stringify(contexts[5]!.messages)).toContain("tool_call_loop_detected");
expect(JSON.stringify(contexts[5]!.messages)).toContain("1263 passed, 4 skipped");
expect(contexts).toHaveLength(3);
expect(JSON.stringify(contexts[2]!.messages)).toContain("tool_call_loop_detected");
expect(JSON.stringify(contexts[2]!.messages)).toContain("1263 passed, 4 skipped");
const redirects = session.agent.state.messages.filter(
(message): message is CustomMessage =>
message.role === "custom" && message.customType === "tool-call-loop-redirect",
@@ -14,11 +14,68 @@
* silently reporting a successful no-op navigation (review on #5895).
*/
import { describe, expect, it, vi } from "bun:test";
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { Agent, type AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { ExtensionRunner, ExtensionUIContext } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
import { SecretObfuscator } from "@oh-my-pi/pi-coding-agent/secrets/obfuscator";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import type { AskToolDetails } from "@oh-my-pi/pi-coding-agent/tools/ask";
import { assistantMsg, createTestSession, userMsg } from "./utilities";
const TEST_MODEL = getBundledModel("anthropic", "claude-sonnet-4-5")!;
async function createTestSession(
options: { inMemory?: boolean; extensionRunner?: ExtensionRunner; obfuscator?: SecretObfuscator } = {},
) {
const sessionManager = SessionManager.inMemory();
const settings = Settings.isolated();
const modelRegistry = {} as never;
const session = new AgentSession({
agent: new Agent({
getApiKey: () => "test-key",
initialState: {
model: TEST_MODEL,
systemPrompt: ["test"],
tools: [],
},
}),
sessionManager,
settings,
modelRegistry,
extensionRunner: options.extensionRunner,
obfuscator: options.obfuscator,
});
return {
session,
sessionManager,
cleanup: () => session.dispose(),
};
}
function userMsg(text: string) {
return { role: "user" as const, content: text, timestamp: Date.now() };
}
function assistantMsg(text: string) {
return {
role: "assistant" as const,
content: [{ type: "text" as const, text }],
api: "anthropic-messages" as const,
provider: "anthropic",
model: "test",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop" as const,
timestamp: Date.now(),
};
}
const ORIGINAL_QUESTIONS = [
{
@@ -8,16 +8,29 @@
* - Summary attachment at correct position in tree
* - Abort handling during summarization
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
import { createTestSession, e2eApiKey, type TestSessionContext } from "./utilities";
describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("AgentSession tree navigation e2e", () => {
let ctx: TestSessionContext;
let observeTreePreparation: boolean;
let treePreparationStarted: PromiseWithResolvers<void>;
beforeEach(async () => {
observeTreePreparation = false;
treePreparationStarted = Promise.withResolvers<void>();
const extensionRunner = {
hasHandlers: vi.fn((eventType: string) => observeTreePreparation && eventType === "session_before_tree"),
emit: vi.fn().mockImplementation(async () => {
treePreparationStarted.resolve();
return undefined;
}),
} as unknown as ExtensionRunner;
ctx = await createTestSession({
systemPrompt: ["You are a helpful assistant. Reply with just a few words."],
settingsOverrides: { compaction: { keepRecentTokens: 1 } },
extensionRunner,
});
});
@@ -187,11 +200,11 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("AgentSession tree navigation e
const tree = sessionManager.getTree();
const rootNode = tree[0];
// Start navigation with summarization but abort immediately
// Synchronize on the session_before_tree boundary: at this point the
// production abort controller exists, so aborting cannot race setup.
observeTreePreparation = true;
const navigationPromise = session.navigateTree(rootNode.entry.id, { summarize: true });
// Abort after a short delay (let the LLM call start)
await Bun.sleep(100);
await treePreparationStarted.promise;
session.abortBranchSummary();
const result = await navigationPromise;
@@ -1,27 +1,32 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { afterAll, afterEach, describe, expect, it, vi } from "bun:test";
import { type } from "@oh-my-pi/omptype";
import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core";
import { createMockModel, type MockModel, type MockResponse } from "@oh-my-pi/pi-ai/providers/mock";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { type SettingPath, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import * as unexpectedStopClassifier from "@oh-my-pi/pi-coding-agent/session/unexpected-stop-classifier";
import { logger, TempDir } from "@oh-my-pi/pi-utils";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
const recordToolSchema = type({ value: type("string") });
type Harness = {
session: AgentSession;
authStorage: AuthStorage;
tempDir: TempDir;
};
type SettingsOverrides = Partial<Record<SettingPath, unknown>>;
const activeHarnesses: Harness[] = [];
const sharedAuthStorage = createInMemoryAuthStorage();
sharedAuthStorage.setRuntimeApiKey("mock", "test-key");
const sharedModelRegistry = new ModelRegistry(sharedAuthStorage);
afterAll(() => {
sharedAuthStorage.close();
});
const recordTool: AgentTool<typeof recordToolSchema, { value: string }> = {
name: "record",
@@ -62,11 +67,9 @@ async function createHarness(
settingsOverrides: SettingsOverrides = {},
): Promise<Harness & { mock: MockModel }> {
const tempDir = TempDir.createSync("@pi-unexpected-stop-guard-");
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
authStorage.setRuntimeApiKey("mock", "test-key");
const mock = createMockModel({ responses });
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml"));
const modelRegistry = sharedModelRegistry;
const settings = Settings.isolated({
"compaction.enabled": false,
"retry.enabled": false,
@@ -98,7 +101,7 @@ async function createHarness(
modelRegistry,
toolRegistry: new Map(tools.map(tool => [tool.name, tool])),
});
const harness = { session, authStorage, tempDir };
const harness = { session, tempDir };
activeHarnesses.push(harness);
return { ...harness, mock };
}
@@ -130,8 +133,7 @@ afterEach(async () => {
vi.restoreAllMocks();
for (const harness of activeHarnesses) {
await harness.session.dispose();
harness.authStorage.close();
harness.tempDir.remove();
harness.tempDir.removeSync();
}
activeHarnesses.length = 0;
});
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as path from "node:path";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
@@ -8,20 +7,25 @@ import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import * as bashExecutor from "@oh-my-pi/pi-coding-agent/exec/bash-executor";
import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
import { createInMemoryAuthStorage } from "./helpers/agent-session-setup";
const sharedAuthStorage = createInMemoryAuthStorage();
const sharedModelRegistry = new ModelRegistry(sharedAuthStorage);
afterAll(() => {
sharedAuthStorage.close();
});
describe("AgentSession user shortcut hooks", () => {
let tempDir: TempDir;
let session: AgentSession;
let modelRegistry: ModelRegistry;
let authStorage: AuthStorage | undefined;
beforeEach(async () => {
beforeEach(() => {
tempDir = TempDir.createSync("@pi-user-shortcut-hooks-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
modelRegistry = new ModelRegistry(authStorage);
modelRegistry = sharedModelRegistry;
});
afterEach(async () => {
@@ -30,8 +34,6 @@ describe("AgentSession user shortcut hooks", () => {
await session.dispose();
}
await pythonExecutor.disposeAllKernelSessions();
authStorage?.close();
authStorage = undefined;
tempDir.removeSync();
});

Some files were not shown because too many files have changed in this diff Show More