test: replaced real timers and sleeps with deterministic test hooks

- Added `providerRetryWait` and `retryWait` hooks to stream/usage options so tests bypass real scheduler delays.
- Parameterized GitHub Copilot poll intervals and Copilot model retry base delay for fast test execution.
- Replaced `Bun.sleep`/`setTimeout` polling loops with `AbortSignal` event listeners in agent session tests.
- Consolidated auth-gateway E2E helpers into a shared `test/helpers` module, eliminating duplicated `checkGatewayAvailable` implementations.
- Migrated credential-disabled tests from SQLite-backed stores to an in-memory store, removing temp-dir lifecycle overhead.
This commit is contained in:
can1357
2026-05-17 04:02:09 +02:00
parent 6d7c4008e5
commit 90b134ca4c
35 changed files with 749 additions and 507 deletions
+5 -1
View File
@@ -1298,7 +1298,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
}
providerRetryAttempt++;
const delayMs = PROVIDER_BASE_DELAY_MS * 2 ** (providerRetryAttempt - 1);
await scheduler.wait(delayMs, { signal: options?.signal });
if (options?.providerRetryWait) {
await options.providerRetryWait(delayMs, options.signal);
} else {
await scheduler.wait(delayMs, { signal: options?.signal });
}
output.content.length = 0;
output.responseId = undefined;
output.errorMessage = strictFallbackErrorMessage;
+4
View File
@@ -296,6 +296,10 @@ export interface StreamOptions {
* Set to 0 to disable the inter-event idle watchdog for this request.
*/
streamIdleTimeoutMs?: number;
/**
* Optional retry delay hook for tests and transports that need custom scheduling.
*/
providerRetryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
/**
* Optional `fetch` implementation override. Providers route every HTTP
* request — direct calls, SDK clients, and retry helpers — through this
+1
View File
@@ -156,6 +156,7 @@ export interface UsageFetchParams {
export interface UsageFetchContext {
fetch: typeof fetch;
logger?: UsageLogger;
retryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
}
/** Provider implementation for fetching usage information. */
+16 -5
View File
@@ -149,11 +149,21 @@ function retryDelayMs(attempt: number, retryAfter: string | null): number {
return Number.isFinite(dateDelay) ? Math.max(baseline, Math.max(0, dateDelay)) : baseline;
}
async function waitBeforeRetry(attempt: number, retryAfter: string | null, signal?: AbortSignal): Promise<boolean> {
async function waitBeforeRetry(
attempt: number,
retryAfter: string | null,
signal?: AbortSignal,
retryWait?: UsageFetchContext["retryWait"],
): Promise<boolean> {
if (signal?.aborted) return false;
if (attempt >= MAX_ATTEMPTS - 1) return false;
try {
await scheduler.wait(retryDelayMs(attempt, retryAfter), { signal });
const delayMs = retryDelayMs(attempt, retryAfter);
if (retryWait) {
await retryWait(delayMs, signal);
} else {
await scheduler.wait(delayMs, { signal });
}
return !signal?.aborted;
} catch (error) {
if (isAbortError(error, signal)) return false;
@@ -186,7 +196,8 @@ async function fetchUsagePayload(
willRetry: retryable && attempt < MAX_ATTEMPTS - 1,
});
if (!retryable) return null;
if (!(await waitBeforeRetry(attempt, response.headers.get("retry-after"), signal))) break;
const retryAfter = response.headers.get("retry-after");
if (!(await waitBeforeRetry(attempt, retryAfter, signal, ctx.retryWait))) break;
continue;
}
@@ -201,7 +212,7 @@ async function fetchUsagePayload(
attempt,
willRetry: attempt < MAX_ATTEMPTS - 1,
});
if (!(await waitBeforeRetry(attempt, null, signal))) break;
if (!(await waitBeforeRetry(attempt, null, signal, ctx.retryWait))) break;
} catch (error) {
if (isAbortError(error, signal)) return null;
ctx.logger?.warn("Claude usage fetch error", {
@@ -209,7 +220,7 @@ async function fetchUsagePayload(
attempt,
willRetry: attempt < MAX_ATTEMPTS - 1,
});
if (!(await waitBeforeRetry(attempt, null, signal))) break;
if (!(await waitBeforeRetry(attempt, null, signal, ctx.retryWait))) break;
}
}
+10 -2
View File
@@ -165,10 +165,12 @@ async function pollForGitHubAccessToken(
intervalSeconds: number,
expiresIn: number,
signal?: AbortSignal,
pollIntervalFloorMs = 1000,
pollIntervalScaleMs = 1000,
) {
const urls = getUrls(domain);
const deadline = Date.now() + expiresIn * 1000;
let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000));
let intervalMs = Math.max(pollIntervalFloorMs, Math.floor(intervalSeconds * pollIntervalScaleMs));
let intervalMultiplier = INITIAL_POLL_INTERVAL_MULTIPLIER;
let slowDownResponses = 0;
@@ -212,7 +214,9 @@ async function pollForGitHubAccessToken(
if (error === "slow_down") {
slowDownResponses += 1;
intervalMs =
typeof interval === "number" && interval > 0 ? interval * 1000 : Math.max(1000, intervalMs + 5000);
typeof interval === "number" && interval > 0
? Math.max(pollIntervalFloorMs, interval * pollIntervalScaleMs)
: Math.max(pollIntervalFloorMs, intervalMs + 5 * pollIntervalScaleMs);
intervalMultiplier = SLOW_DOWN_POLL_INTERVAL_MULTIPLIER;
continue;
}
@@ -308,6 +312,8 @@ export async function loginGitHubCopilot(options: {
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
onProgress?: (message: string) => void;
signal?: AbortSignal;
pollIntervalFloorMs?: number;
pollIntervalScaleMs?: number;
}): Promise<OAuthCredentials> {
const input = await options.onPrompt({
message: "GitHub Enterprise URL/domain (blank for github.com)",
@@ -337,6 +343,8 @@ export async function loginGitHubCopilot(options: {
device.interval,
device.expires_in,
options.signal,
options.pollIntervalFloorMs,
options.pollIntervalScaleMs,
);
// With opencode OAuth, the GitHub token is used directly for all API requests
+3 -2
View File
@@ -34,11 +34,12 @@ const COPILOT_MODEL_RETRY_BASE_DELAY_MS = 400;
*/
export async function callWithCopilotModelRetry<T>(
fn: () => Promise<T>,
options: { provider: string; signal?: AbortSignal },
options: { provider: string; signal?: AbortSignal; retryBaseDelayMs?: number },
): Promise<T> {
if (options.provider !== "github-copilot") return fn();
let lastError: unknown;
const retryBaseDelayMs = options.retryBaseDelayMs ?? COPILOT_MODEL_RETRY_BASE_DELAY_MS;
for (let attempt = 0; attempt < COPILOT_MODEL_RETRY_MAX_ATTEMPTS; attempt++) {
try {
return await fn();
@@ -46,7 +47,7 @@ export async function callWithCopilotModelRetry<T>(
lastError = error;
if (!isCopilotTransientModelError(error) && !isRetryableError(error)) throw error;
if (attempt === COPILOT_MODEL_RETRY_MAX_ATTEMPTS - 1) break;
await scheduler.wait(COPILOT_MODEL_RETRY_BASE_DELAY_MS * (attempt + 1), { signal: options.signal });
await scheduler.wait(retryBaseDelayMs * (attempt + 1), { signal: options.signal });
}
}
throw lastError;
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { scheduler } from "node:timers/promises";
import { Messages } from "@anthropic-ai/sdk/resources/messages/messages";
import * as z from "zod/v4";
import { streamAnthropic } from "../src/providers/anthropic";
import type { AssistantMessageEvent, Context, Model, ProviderSessionState } from "../src/types";
@@ -20,6 +20,17 @@ const model: Model<"anthropic-messages"> = {
const context: Context = {
messages: [{ role: "user", content: "Say hi", timestamp: Date.now() }],
};
const queryObjectSchema = {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
};
const cityObjectSchema = {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
};
type MockAnthropicEvent = Record<string, unknown>;
type MockAnthropicStream = AsyncIterable<MockAnthropicEvent>;
@@ -111,8 +122,11 @@ function getStrictFlags(params: unknown): boolean[] {
return tools.map(tool => tool.strict === true);
}
function createTextSuccessEvents(text: string): MockAnthropicEvent[] {
return [
function createTextSuccessEvents(
text: string,
options: { duplicateMessageStart?: boolean } = {},
): MockAnthropicEvent[] {
const events: MockAnthropicEvent[] = [
{
type: "message_start",
message: {
@@ -126,7 +140,6 @@ function createTextSuccessEvents(text: string): MockAnthropicEvent[] {
},
},
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "message_start", message: { id: "msg_duplicate", usage: { input_tokens: 99, output_tokens: 99 } } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text } },
{ type: "content_block_stop", index: 0 },
{
@@ -141,6 +154,13 @@ function createTextSuccessEvents(text: string): MockAnthropicEvent[] {
},
{ type: "message_stop" },
];
if (options.duplicateMessageStart) {
events.splice(2, 0, {
type: "message_start",
message: { id: "msg_duplicate", usage: { input_tokens: 99, output_tokens: 99 } },
});
}
return events;
}
function createTextSuccessEventsWithPreamble(text: string, preambleEvents: MockAnthropicEvent[]): MockAnthropicEvent[] {
@@ -190,7 +210,7 @@ afterEach(() => {
describe("anthropic stream envelope handling", () => {
it("ignores duplicate message_start envelopes without resetting streamed text", async () => {
vi.spyOn(Messages.prototype, "create").mockImplementation(
() => createMockRequest(createTextSuccessEvents("hello")) as never,
() => createMockRequest(createTextSuccessEvents("hello", { duplicateMessageStart: true })) as never,
);
const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" });
@@ -269,6 +289,7 @@ describe("anthropic stream envelope handling", () => {
attempt === 1 ? createMalformedPreMessageStartEvents() : createTextSuccessEvents("recovered"),
) as never;
});
vi.spyOn(scheduler, "wait").mockResolvedValue(undefined);
const stream = streamAnthropic(model, context, { apiKey: "sk-ant-test" });
const events: AssistantMessageEvent[] = [];
@@ -294,7 +315,7 @@ describe("anthropic stream envelope handling", () => {
name: "edit",
description: "Edit a value",
strict: true,
parameters: z.object({ query: z.string() }),
parameters: queryObjectSchema,
},
],
};
@@ -350,7 +371,7 @@ describe("anthropic stream envelope handling", () => {
name: "edit",
description: "Edit a value",
strict: true,
parameters: z.object({ query: z.string() }),
parameters: queryObjectSchema,
},
],
};
@@ -464,7 +485,7 @@ describe("anthropic stream envelope handling", () => {
sseFrame("content_block_start", successEvents[1]),
sseRawFrame("content_block_delta", malformedTextDelta),
sseFrame("content_block_stop", { type: "content_block_stop", index: 0 }),
sseFrame("message_delta", successEvents[5]),
sseFrame("message_delta", successEvents[4]),
sseFrame("message_stop", { type: "message_stop" }),
];
vi.spyOn(Messages.prototype, "create").mockImplementation(() => createRawSseRequest(frames) as never);
@@ -486,7 +507,7 @@ describe("anthropic stream envelope handling", () => {
{
name: "lookup_weather",
description: "Lookup weather",
parameters: z.object({ city: z.string() }),
parameters: cityObjectSchema,
},
],
};
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from "bun:test";
import { afterEach, describe, expect, it, vi } from "bun:test";
import type Anthropic from "@anthropic-ai/sdk";
import { streamAnthropic } from "../src/providers/anthropic";
import type { Context, Model } from "../src/types";
@@ -147,13 +147,16 @@ describe("anthropic first-event timeout retries", () => {
}) as never;
}) as unknown as Anthropic["messages"]["create"];
const client = { messages: { create } } as Anthropic;
const providerRetryWait = vi.fn(async () => {});
const result = await streamAnthropic(model, context, {
client,
streamFirstEventTimeoutMs: 20,
streamFirstEventTimeoutMs: 1,
providerRetryWait,
}).result();
expect(attempt).toBe(2);
expect(providerRetryWait).toHaveBeenCalledWith(2000, undefined);
expect(result.stopReason).toBe("stop");
expect(result.content).toEqual([{ type: "text", text: "retry recovered" }]);
expect(result.responseId).toBe("msg_retry_success");
@@ -163,7 +166,7 @@ describe("anthropic first-event timeout retries", () => {
const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal }) => {
return createAnthropicMockStream({
signal: requestOptions?.signal,
connectDelayMs: 30,
connectDelayMs: 2,
events: createSuccessfulAnthropicEvents("delayed connect"),
}) as never;
}) as unknown as Anthropic["messages"]["create"];
@@ -171,7 +174,7 @@ describe("anthropic first-event timeout retries", () => {
const result = await streamAnthropic(model, context, {
client,
streamFirstEventTimeoutMs: 20,
streamFirstEventTimeoutMs: 1,
}).result();
expect(result.stopReason).toBe("stop");
@@ -187,12 +190,12 @@ describe("anthropic first-event timeout retries", () => {
const client = { messages: { create } } as Anthropic;
const controller = new AbortController();
setTimeout(() => controller.abort(), 5);
setTimeout(() => controller.abort(), 1);
const result = await streamAnthropic(model, context, {
client,
signal: controller.signal,
streamFirstEventTimeoutMs: 50,
streamFirstEventTimeoutMs: 10,
}).result();
expect(attempt).toBe(1);
@@ -237,8 +240,8 @@ describe("anthropic first-event timeout retries", () => {
const result = await streamAnthropic(model, context, {
client,
streamFirstEventTimeoutMs: 1_000,
streamIdleTimeoutMs: 20,
streamFirstEventTimeoutMs: 10,
streamIdleTimeoutMs: 1,
}).result();
expect(attempt).toBe(1);
@@ -13,9 +13,7 @@
* with the gateway live (`omp auth-gateway serve` or pm2).
*/
import { describe, expect, it } from "bun:test";
import * as os from "node:os";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { AUTH_GATEWAY_E2E_URL, checkAuthGatewayE2EAvailable } from "./helpers";
interface AnthropicUsage {
input_tokens: number;
@@ -32,30 +30,9 @@ interface AnthropicResponse {
error?: { type: string; message: string };
}
const GATEWAY_URL = Bun.env.OMP_E2E_GATEWAY_URL ?? "http://127.0.0.1:4000";
const TOKEN_PATH = path.join(os.homedir(), ".omp", "auth-gateway.token");
const MODEL = Bun.env.OMP_E2E_ANTHROPIC_MODEL ?? "claude-sonnet-4-5";
async function checkGatewayAvailable(): Promise<{ ok: boolean; token?: string; reason?: string }> {
let token: string;
try {
token = (await Bun.file(TOKEN_PATH).text()).trim();
} catch (err) {
if (isEnoent(err)) return { ok: false, reason: `no token at ${TOKEN_PATH}` };
throw err;
}
if (!token) return { ok: false, reason: `empty token at ${TOKEN_PATH}` };
try {
const res = await fetch(`${GATEWAY_URL}/healthz`, { signal: AbortSignal.timeout(2_000) });
if (!res.ok) return { ok: false, reason: `healthz returned ${res.status}` };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, reason: `healthz unreachable: ${msg}` };
}
return { ok: true, token };
}
const gateway = await checkGatewayAvailable();
const gateway = await checkAuthGatewayE2EAvailable();
// Build a system prompt that comfortably exceeds Anthropic's 1024-token cache
// floor for Sonnet. Using a deterministic repeated paragraph so cache keys are
@@ -84,7 +61,7 @@ interface MessageBlock {
}
async function callGateway(body: unknown, token: string): Promise<AnthropicResponse> {
const res = await fetch(`${GATEWAY_URL}/v1/messages`, {
const res = await fetch(`${AUTH_GATEWAY_E2E_URL}/v1/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -134,7 +111,7 @@ describe.skipIf(!gateway.ok)("auth-gateway: anthropic prompt caching e2e", () =>
const turn1 = await callGateway(
{
model: MODEL,
max_tokens: 32,
max_tokens: 4,
system,
messages: turn1Messages,
},
@@ -162,7 +139,7 @@ describe.skipIf(!gateway.ok)("auth-gateway: anthropic prompt caching e2e", () =>
const turn2 = await callGateway(
{
model: MODEL,
max_tokens: 32,
max_tokens: 4,
system,
messages: turn2Messages,
},
@@ -25,9 +25,7 @@
* To run: `bun --cwd packages/ai test test/auth-gateway-anthropic-to-codex-caching.test.ts`
*/
import { describe, expect, it } from "bun:test";
import * as os from "node:os";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { AUTH_GATEWAY_E2E_URL, checkAuthGatewayE2EAvailable } from "./helpers";
interface AnthropicUsage {
input_tokens: number;
@@ -44,30 +42,9 @@ interface AnthropicResponse {
error?: { type: string; message: string };
}
const GATEWAY_URL = Bun.env.OMP_E2E_GATEWAY_URL ?? "http://127.0.0.1:4000";
const TOKEN_PATH = path.join(os.homedir(), ".omp", "auth-gateway.token");
const MODEL = Bun.env.OMP_E2E_CODEX_MODEL ?? "gpt-5.3-codex";
async function checkGatewayAvailable(): Promise<{ ok: boolean; token?: string; reason?: string }> {
let token: string;
try {
token = (await Bun.file(TOKEN_PATH).text()).trim();
} catch (err) {
if (isEnoent(err)) return { ok: false, reason: `no token at ${TOKEN_PATH}` };
throw err;
}
if (!token) return { ok: false, reason: `empty token at ${TOKEN_PATH}` };
try {
const res = await fetch(`${GATEWAY_URL}/healthz`, { signal: AbortSignal.timeout(2_000) });
if (!res.ok) return { ok: false, reason: `healthz returned ${res.status}` };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, reason: `healthz unreachable: ${msg}` };
}
return { ok: true, token };
}
const gateway = await checkGatewayAvailable();
const gateway = await checkAuthGatewayE2EAvailable();
// Long deterministic instructions, repeated to clear Codex's 1024-token
// cache floor with headroom.
@@ -94,7 +71,7 @@ interface MessageBlock {
}
async function callGateway(body: unknown, token: string): Promise<AnthropicResponse> {
const res = await fetch(`${GATEWAY_URL}/v1/messages`, {
const res = await fetch(`${AUTH_GATEWAY_E2E_URL}/v1/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -142,7 +119,7 @@ describe.skipIf(!gateway.ok)("auth-gateway: anthropic-messages → openai-codex
const turn1 = await callGateway(
{
model: MODEL,
max_tokens: 32,
max_tokens: 4,
system,
messages: turn1Messages,
},
@@ -167,7 +144,7 @@ describe.skipIf(!gateway.ok)("auth-gateway: anthropic-messages → openai-codex
const turn2 = await callGateway(
{
model: MODEL,
max_tokens: 32,
max_tokens: 4,
system,
messages: turn2Messages,
},
@@ -27,9 +27,7 @@
* with the gateway live (`omp auth-gateway serve` or pm2).
*/
import { describe, expect, it } from "bun:test";
import * as os from "node:os";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { AUTH_GATEWAY_E2E_URL, checkAuthGatewayE2EAvailable } from "./helpers";
interface OpenAIResponsesUsage {
input_tokens: number;
@@ -49,30 +47,9 @@ interface OpenAIResponse {
error?: { type?: string; message: string };
}
const GATEWAY_URL = Bun.env.OMP_E2E_GATEWAY_URL ?? "http://127.0.0.1:4000";
const TOKEN_PATH = path.join(os.homedir(), ".omp", "auth-gateway.token");
const MODEL = Bun.env.OMP_E2E_ANTHROPIC_MODEL ?? "claude-sonnet-4-5";
async function checkGatewayAvailable(): Promise<{ ok: boolean; token?: string; reason?: string }> {
let token: string;
try {
token = (await Bun.file(TOKEN_PATH).text()).trim();
} catch (err) {
if (isEnoent(err)) return { ok: false, reason: `no token at ${TOKEN_PATH}` };
throw err;
}
if (!token) return { ok: false, reason: `empty token at ${TOKEN_PATH}` };
try {
const res = await fetch(`${GATEWAY_URL}/healthz`, { signal: AbortSignal.timeout(2_000) });
if (!res.ok) return { ok: false, reason: `healthz returned ${res.status}` };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, reason: `healthz unreachable: ${msg}` };
}
return { ok: true, token };
}
const gateway = await checkGatewayAvailable();
const gateway = await checkAuthGatewayE2EAvailable();
// Long deterministic instructions, repeated to clear Anthropic's 1024-token
// cache floor for Sonnet.
@@ -99,7 +76,7 @@ interface ResponseInputMessage {
}
async function callGateway(body: unknown, token: string): Promise<OpenAIResponse> {
const res = await fetch(`${GATEWAY_URL}/v1/responses`, {
const res = await fetch(`${AUTH_GATEWAY_E2E_URL}/v1/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -147,7 +124,7 @@ describe.skipIf(!gateway.ok)("auth-gateway: openai-responses → anthropic cachi
const turn1 = await callGateway(
{
model: MODEL,
max_output_tokens: 64,
max_output_tokens: 4,
instructions: instructionsWithNonce,
input: turn1Input,
},
@@ -173,7 +150,7 @@ describe.skipIf(!gateway.ok)("auth-gateway: openai-responses → anthropic cachi
const turn2 = await callGateway(
{
model: MODEL,
max_output_tokens: 64,
max_output_tokens: 4,
instructions: instructionsWithNonce,
input: turn2Input,
},
@@ -1,12 +1,10 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import {
type AuthCredential,
type AuthCredentialStore,
AuthStorage,
type CredentialDisabledEvent,
SqliteAuthCredentialStore,
type StoredAuthCredential,
} from "../src/auth-storage";
import * as oauthUtils from "../src/utils/oauth";
@@ -29,33 +27,105 @@ const failOAuthRefresh = (message = 'HTTP 400 invalid_grant {"error":"invalid_gr
});
};
class MemoryAuthCredentialStore implements AuthCredentialStore {
#rows: StoredAuthCredential[] = [];
#nextId = 1;
close(): void {}
listAuthCredentials(provider?: string): StoredAuthCredential[] {
return this.#rows.filter(row => row.disabledCause === null && (!provider || row.provider === provider));
}
updateAuthCredential(id: number, credential: AuthCredential): void {
const row = this.#rows.find(entry => entry.id === id);
if (row) row.credential = credential;
}
deleteAuthCredential(id: number, disabledCause: string): void {
const row = this.#rows.find(entry => entry.id === id);
if (row) row.disabledCause = disabledCause;
}
tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean {
const row = this.#rows.find(entry => entry.id === id && entry.disabledCause === null);
if (!row || serializeTestCredential(row.credential) !== expectedData) return false;
row.disabledCause = disabledCause;
return true;
}
replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] {
for (const row of this.#rows) {
if (row.provider === provider && row.disabledCause === null) {
row.disabledCause = "replaced by newer credential";
}
}
const rows = credentials.map(
(credential): StoredAuthCredential => ({
id: this.#nextId++,
provider,
credential,
disabledCause: null,
}),
);
this.#rows.push(...rows);
return rows;
}
upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[] {
return this.replaceAuthCredentialsForProvider(provider, [credential]);
}
deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void {
for (const row of this.#rows) {
if (row.provider === provider && row.disabledCause === null) row.disabledCause = disabledCause;
}
}
getCache(): string | null {
return null;
}
setCache(): void {}
cleanExpiredCache(): void {}
}
function serializeTestCredential(credential: AuthCredential): string {
if (credential.type === "api_key") return JSON.stringify({ key: credential.key });
if (credential.type === "oauth") {
const { type: _type, ...rest } = credential;
return JSON.stringify(rest);
}
return "";
}
function disableCredential(authStorage: AuthStorage, id: number, provider = "anthropic"): void {
expect(authStorage.disableCredentialById(id, "oauth refresh failed: invalid_grant")).toBe(true);
expect(authStorage.list()).not.toContain(provider);
}
describe("AuthStorage credential_disabled subscriptions", () => {
let tempDir = "";
const stores: AuthCredentialStore[] = [];
const openStorage = async (options?: ConstructorParameters<typeof AuthStorage>[1]): Promise<AuthStorage> => {
const store = await SqliteAuthCredentialStore.open(path.join(tempDir, `agent-${stores.length}.db`));
const openStorage = (options?: ConstructorParameters<typeof AuthStorage>[1]): AuthStorage => {
const store = new MemoryAuthCredentialStore();
stores.push(store);
return new AuthStorage(store, options);
};
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-credential-disabled-subs-"));
beforeEach(() => {
for (const key of SUPPRESS_ANTHROPIC_ENV) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(async () => {
afterEach(() => {
vi.restoreAllMocks();
for (const store of stores.splice(0)) {
store.close();
}
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
tempDir = "";
}
for (const key of SUPPRESS_ANTHROPIC_ENV) {
if (savedEnv[key] === undefined) {
delete process.env[key];
@@ -69,7 +139,7 @@ describe("AuthStorage credential_disabled subscriptions", () => {
describe("constructor `onCredentialDisabled` option", () => {
test("fires when an OAuth credential is disabled by a definitive refresh failure", async () => {
const events: CredentialDisabledEvent[] = [];
const authStorage = await openStorage({
const authStorage = openStorage({
onCredentialDisabled: event => {
events.push(event);
},
@@ -87,7 +157,7 @@ describe("AuthStorage credential_disabled subscriptions", () => {
test("does not fire for transient (non-definitive) refresh failures", async () => {
const events: CredentialDisabledEvent[] = [];
const authStorage = await openStorage({
const authStorage = openStorage({
onCredentialDisabled: event => {
events.push(event);
},
@@ -100,21 +170,18 @@ describe("AuthStorage credential_disabled subscriptions", () => {
});
test("swallows synchronous handler exceptions so the disable still completes", async () => {
const authStorage = await openStorage({
const authStorage = openStorage({
onCredentialDisabled: () => {
throw new Error("subscriber exploded");
},
});
await authStorage.set("anthropic", [expiredOAuth()]);
failOAuthRefresh("invalid_grant");
await expect(authStorage.getApiKey("anthropic", "session-handler-throws")).resolves.toBeUndefined();
expect(authStorage.list()).not.toContain("anthropic");
disableCredential(authStorage, 1);
});
test("swallows async handler rejections so the disable path still completes", async () => {
const settled = Promise.withResolvers<void>();
const authStorage = await openStorage({
const authStorage = openStorage({
onCredentialDisabled: async () => {
// Yield so the rejection lands on the microtask queue, not synchronously.
await Promise.resolve();
@@ -123,7 +190,6 @@ describe("AuthStorage credential_disabled subscriptions", () => {
},
});
await authStorage.set("anthropic", [expiredOAuth()]);
failOAuthRefresh("invalid_grant");
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
@@ -131,10 +197,9 @@ describe("AuthStorage credential_disabled subscriptions", () => {
};
process.on("unhandledRejection", onUnhandled);
try {
await expect(authStorage.getApiKey("anthropic", "session-async-handler-throws")).resolves.toBeUndefined();
disableCredential(authStorage, 1);
await settled.promise;
await Bun.sleep(0);
expect(authStorage.list()).not.toContain("anthropic");
expect(unhandled).toHaveLength(0);
} finally {
process.off("unhandledRejection", onUnhandled);
@@ -146,7 +211,7 @@ describe("AuthStorage credential_disabled subscriptions", () => {
test("registers an additional subscriber alongside the constructor handler — both fire", async () => {
const constructorEvents: CredentialDisabledEvent[] = [];
const runtimeEvents: CredentialDisabledEvent[] = [];
const authStorage = await openStorage({
const authStorage = openStorage({
onCredentialDisabled: event => {
constructorEvents.push(event);
},
@@ -156,9 +221,7 @@ describe("AuthStorage credential_disabled subscriptions", () => {
});
await authStorage.set("anthropic", [expiredOAuth()]);
failOAuthRefresh("invalid_grant");
await authStorage.getApiKey("anthropic", "session-both-fire");
disableCredential(authStorage, 1);
expect(constructorEvents).toHaveLength(1);
expect(runtimeEvents).toHaveLength(1);
expect(constructorEvents[0]?.provider).toBe("anthropic");
@@ -168,7 +231,7 @@ describe("AuthStorage credential_disabled subscriptions", () => {
test("fans out every event to every subscriber", async () => {
const aEvents: CredentialDisabledEvent[] = [];
const bEvents: CredentialDisabledEvent[] = [];
const authStorage = await openStorage();
const authStorage = openStorage();
authStorage.onCredentialDisabled(event => {
aEvents.push(event);
});
@@ -177,17 +240,15 @@ describe("AuthStorage credential_disabled subscriptions", () => {
});
await authStorage.set("anthropic", [expiredOAuth()]);
await authStorage.set("openai", [expiredOAuth()]);
failOAuthRefresh("invalid_grant");
await authStorage.getApiKey("anthropic", "session-fanout-anthropic");
await authStorage.getApiKey("openai", "session-fanout-openai");
disableCredential(authStorage, 1);
disableCredential(authStorage, 2, "openai");
expect(aEvents.map(event => event.provider)).toEqual(["anthropic", "openai"]);
expect(bEvents.map(event => event.provider)).toEqual(["anthropic", "openai"]);
});
test("unsubscribe removes only that listener; others continue to fire", async () => {
const authStorage = await openStorage();
const authStorage = openStorage();
const aEvents: CredentialDisabledEvent[] = [];
const bEvents: CredentialDisabledEvent[] = [];
const unsubscribeA = authStorage.onCredentialDisabled(event => {
@@ -199,21 +260,20 @@ describe("AuthStorage credential_disabled subscriptions", () => {
await authStorage.set("anthropic", [expiredOAuth()]);
await authStorage.set("openai", [expiredOAuth()]);
failOAuthRefresh("invalid_grant");
await authStorage.getApiKey("anthropic", "session-pre-unsubscribe");
disableCredential(authStorage, 1);
expect(aEvents).toHaveLength(1);
expect(bEvents).toHaveLength(1);
unsubscribeA();
await authStorage.getApiKey("openai", "session-post-unsubscribe");
disableCredential(authStorage, 2, "openai");
expect(aEvents).toHaveLength(1);
expect(bEvents).toHaveLength(2);
});
test("unsubscribe is idempotent: a second call is a no-op and does not affect other listeners", async () => {
const authStorage = await openStorage();
const authStorage = openStorage();
const aEvents: CredentialDisabledEvent[] = [];
const bEvents: CredentialDisabledEvent[] = [];
const unsubscribeA = authStorage.onCredentialDisabled(event => {
@@ -227,15 +287,14 @@ describe("AuthStorage credential_disabled subscriptions", () => {
unsubscribeA();
await authStorage.set("anthropic", [expiredOAuth()]);
failOAuthRefresh("invalid_grant");
await authStorage.getApiKey("anthropic", "session-idempotent-unsub");
disableCredential(authStorage, 1);
expect(aEvents).toHaveLength(0);
expect(bEvents).toHaveLength(1);
});
test("a throwing subscriber does not block other subscribers from receiving the event", async () => {
const authStorage = await openStorage();
const authStorage = openStorage();
const tailEvents: CredentialDisabledEvent[] = [];
authStorage.onCredentialDisabled(() => {
throw new Error("first subscriber exploded");
@@ -245,14 +304,13 @@ describe("AuthStorage credential_disabled subscriptions", () => {
});
await authStorage.set("anthropic", [expiredOAuth()]);
failOAuthRefresh("invalid_grant");
await expect(authStorage.getApiKey("anthropic", "session-throw-isolation")).resolves.toBeUndefined();
disableCredential(authStorage, 1);
expect(tailEvents).toHaveLength(1);
});
test("an async-rejecting subscriber does not trip unhandledRejection and does not block others", async () => {
const authStorage = await openStorage();
const authStorage = openStorage();
const tailEvents: CredentialDisabledEvent[] = [];
const settled = Promise.withResolvers<void>();
authStorage.onCredentialDisabled(async () => {
@@ -265,7 +323,6 @@ describe("AuthStorage credential_disabled subscriptions", () => {
});
await authStorage.set("anthropic", [expiredOAuth()]);
failOAuthRefresh("invalid_grant");
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
@@ -273,7 +330,7 @@ describe("AuthStorage credential_disabled subscriptions", () => {
};
process.on("unhandledRejection", onUnhandled);
try {
await authStorage.getApiKey("anthropic", "session-async-throw-isolation");
disableCredential(authStorage, 1);
await settled.promise;
await Bun.sleep(0);
expect(tailEvents).toHaveLength(1);
@@ -286,11 +343,10 @@ describe("AuthStorage credential_disabled subscriptions", () => {
describe("buffer-and-replay for events fired with no subscribers", () => {
test("replays buffered events to the first subscriber that triggers the empty→non-empty transition", async () => {
const authStorage = await openStorage();
const authStorage = openStorage();
await authStorage.set("anthropic", [expiredOAuth()]);
failOAuthRefresh("invalid_grant");
await authStorage.getApiKey("anthropic", "session-pre-subscribe");
disableCredential(authStorage, 1);
const replayed: CredentialDisabledEvent[] = [];
authStorage.onCredentialDisabled(event => {
@@ -305,11 +361,10 @@ describe("AuthStorage credential_disabled subscriptions", () => {
});
test("drains once: a later subscriber attached after the first does not re-receive past events", async () => {
const authStorage = await openStorage();
const authStorage = openStorage();
await authStorage.set("anthropic", [expiredOAuth()]);
failOAuthRefresh("invalid_grant");
await authStorage.getApiKey("anthropic", "session-pre-first-listener");
disableCredential(authStorage, 1);
const firstEvents: CredentialDisabledEvent[] = [];
authStorage.onCredentialDisabled(event => {
@@ -328,21 +383,20 @@ describe("AuthStorage credential_disabled subscriptions", () => {
});
test("after every subscriber unsubscribes, subsequent events buffer until the next subscribe", async () => {
const authStorage = await openStorage();
const authStorage = openStorage();
const events: CredentialDisabledEvent[] = [];
const unsubscribe = authStorage.onCredentialDisabled(event => {
events.push(event);
});
await authStorage.set("anthropic", [expiredOAuth()]);
failOAuthRefresh("invalid_grant");
await authStorage.getApiKey("anthropic", "session-pre-unsubscribe");
disableCredential(authStorage, 1);
expect(events).toHaveLength(1);
unsubscribe();
// No subscribers; the next disable goes to the buffer.
await authStorage.set("openai", [expiredOAuth()]);
await authStorage.getApiKey("openai", "session-during-gap");
disableCredential(authStorage, 2, "openai");
expect(events).toHaveLength(1);
const replayed: CredentialDisabledEvent[] = [];
@@ -237,7 +237,9 @@ describe("AuthStorage usage cache: last-good failure fallback", () => {
describe("AuthStorage usage cache: jitter", () => {
it("writes per-credential cache TTLs with ±25% jitter so refreshes decorrelate", async () => {
const store = makeStore([oauthRow(1, "a@example.com"), oauthRow(2, "b@example.com")]);
const storage = new AuthStorage(store);
const storage = new AuthStorage(store, {
usageProviderResolver: provider => (provider === "anthropic" ? claudeUsage.claudeUsageProvider : undefined),
});
await storage.reload();
try {
const goldA = makeReport("a@example.com");
+28 -24
View File
@@ -1,5 +1,4 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { setTimeout as setTimeoutCb } from "node:timers";
import type { UsageFetchContext } from "../src/usage";
import { claudeUsageProvider } from "../src/usage/claude";
@@ -14,8 +13,8 @@ function jsonResponse(status: number, body: unknown, headers: Record<string, str
});
}
function makeContext(fetchImpl: typeof fetch): UsageFetchContext {
return { fetch: fetchImpl };
function makeContext(fetchImpl: typeof fetch, retryWait?: UsageFetchContext["retryWait"]): UsageFetchContext {
return { fetch: fetchImpl, retryWait };
}
function baseParams() {
@@ -36,6 +35,8 @@ describe("claudeUsageProvider retry contract", () => {
vi.restoreAllMocks();
});
const instantRetryWait: UsageFetchContext["retryWait"] = async () => {};
it("retries on 429 and succeeds on a later attempt", async () => {
let attempt = 0;
const fetchMock = (async () => {
@@ -44,7 +45,7 @@ describe("claudeUsageProvider retry contract", () => {
return jsonResponse(200, VALID_PAYLOAD);
}) as unknown as typeof fetch;
const report = await claudeUsageProvider.fetchUsage(baseParams(), makeContext(fetchMock));
const report = await claudeUsageProvider.fetchUsage(baseParams(), makeContext(fetchMock, instantRetryWait));
expect(report).not.toBeNull();
expect(attempt).toBe(3);
expect(report?.limits[0]?.amount.used).toBe(42);
@@ -58,7 +59,7 @@ describe("claudeUsageProvider retry contract", () => {
return jsonResponse(200, VALID_PAYLOAD);
}) as unknown as typeof fetch;
const report = await claudeUsageProvider.fetchUsage(baseParams(), makeContext(fetchMock));
const report = await claudeUsageProvider.fetchUsage(baseParams(), makeContext(fetchMock, instantRetryWait));
expect(report).not.toBeNull();
expect(attempt).toBe(2);
});
@@ -94,35 +95,28 @@ describe("claudeUsageProvider retry contract", () => {
return jsonResponse(429, { error: "rate_limited" });
}) as unknown as typeof fetch;
// Provider's MAX_RETRIES is 3; provider sleeps BASE_RETRY_DELAY_MS * 2^attempt
// between attempts — total worst-case ~1.5s, well within our test budget.
const report = await claudeUsageProvider.fetchUsage(baseParams(), makeContext(fetchMock));
const report = await claudeUsageProvider.fetchUsage(baseParams(), makeContext(fetchMock, instantRetryWait));
expect(report).toBeNull();
expect(attempt).toBe(3);
});
it("honours Retry-After when retrying a 429", async () => {
let attempt = 0;
const callTimes: number[] = [];
const retryWait = vi.fn(async () => {});
const fetchMock = (async () => {
attempt += 1;
callTimes.push(Date.now());
if (attempt === 1) {
// Retry-After: 1 second. Provider must wait ~1s before re-attempting.
// Retry-After: 1 second. Provider must compute a 1s backoff before re-attempting.
return jsonResponse(429, { error: "rate_limited" }, { "retry-after": "1" });
}
return jsonResponse(200, VALID_PAYLOAD);
}) as unknown as typeof fetch;
const t0 = Date.now();
const report = await claudeUsageProvider.fetchUsage(baseParams(), makeContext(fetchMock));
const elapsed = Date.now() - t0;
const report = await claudeUsageProvider.fetchUsage(baseParams(), makeContext(fetchMock, retryWait));
expect(report).not.toBeNull();
expect(attempt).toBe(2);
// Allow generous slop (Bun scheduling jitter) but ensure we actually waited
// closer to the Retry-After than to the default 500ms backoff.
expect(elapsed).toBeGreaterThanOrEqual(800);
expect(callTimes[1] - callTimes[0]).toBeGreaterThanOrEqual(800);
expect(retryWait).toHaveBeenCalledTimes(1);
expect(retryWait.mock.calls[0]?.[0]).toBe(1000);
});
it("aborts the retry sleep when the signal fires mid-backoff", async () => {
@@ -140,16 +134,26 @@ describe("claudeUsageProvider retry contract", () => {
}) as unknown as typeof fetch;
const controller = new AbortController();
setTimeoutCb(() => controller.abort(), 150);
const retryWait = vi.fn(async (delayMs: number, signal?: AbortSignal) => {
expect(delayMs).toBe(60_000);
if (signal?.aborted) throw new Error("AbortError");
const { promise, reject } = Promise.withResolvers<void>();
const onAbort = () => reject(new Error("AbortError"));
signal?.addEventListener("abort", onAbort, { once: true });
queueMicrotask(() => controller.abort());
try {
await promise;
} finally {
signal?.removeEventListener("abort", onAbort);
}
});
const t0 = Date.now();
const report = await claudeUsageProvider.fetchUsage(
{ ...baseParams(), signal: controller.signal },
makeContext(fetchMock),
makeContext(fetchMock, retryWait),
);
const elapsed = Date.now() - t0;
expect(report).toBeNull();
expect(elapsed).toBeLessThan(3_000);
expect(retryWait).toHaveBeenCalledTimes(1);
expect(attempt).toBe(1);
});
@@ -168,7 +172,7 @@ describe("claudeUsageProvider retry contract", () => {
return jsonResponse(429, { error: "rate_limited" });
}) as unknown as typeof fetch;
const report = await claudeUsageProvider.fetchUsage(baseParams(), makeContext(fetchMock));
const report = await claudeUsageProvider.fetchUsage(baseParams(), makeContext(fetchMock, instantRetryWait));
// The 200 set lastPayload but had no usage data; 429s mean no further
// successes. lastPayload survives but has no usage data → no limits.
// Specifically: report is null (since lastPayload has nothing to expose).
+3 -3
View File
@@ -83,7 +83,7 @@ describe("callWithCopilotModelRetry", () => {
calls += 1;
throw err;
},
{ provider: "github-copilot" },
{ provider: "github-copilot", retryBaseDelayMs: 0 },
),
).rejects.toBe(err);
expect(calls).toBe(3);
@@ -99,7 +99,7 @@ describe("callWithCopilotModelRetry", () => {
}
return "ok" as const;
},
{ provider: "github-copilot" },
{ provider: "github-copilot", retryBaseDelayMs: 0 },
);
expect(result).toBe("ok");
expect(calls).toBe(2);
@@ -130,7 +130,7 @@ describe("callWithCopilotModelRetry", () => {
calls += 1;
throw copilotError({ status: 400, code: "model_not_supported", message: "transient" });
},
{ provider: "github-copilot", signal: controller.signal },
{ provider: "github-copilot", signal: controller.signal, retryBaseDelayMs: 0 },
),
).rejects.toBeDefined();
// fn runs once; scheduler.wait rejects before a second attempt.
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test";
import { loginGitHubCopilot } from "../src/utils/oauth/github-copilot";
const originalFetch = global.fetch;
const FAST_POLL_OPTIONS = { pollIntervalFloorMs: 0, pollIntervalScaleMs: 1 } as const;
afterEach(() => {
global.fetch = originalFetch;
@@ -59,6 +60,7 @@ describe("loginGitHubCopilot", () => {
const onAuth = vi.fn();
const credentials = await loginGitHubCopilot({
...FAST_POLL_OPTIONS,
onAuth,
onPrompt: mockOnPrompt(""),
});
@@ -94,6 +96,7 @@ describe("loginGitHubCopilot", () => {
global.fetch = fetchMock as unknown as typeof fetch;
const credentials = await loginGitHubCopilot({
...FAST_POLL_OPTIONS,
onAuth: vi.fn(),
onPrompt: mockOnPrompt("ghe.example.com"),
});
@@ -125,6 +128,7 @@ describe("loginGitHubCopilot", () => {
global.fetch = fetchMock as unknown as typeof fetch;
const credentials = await loginGitHubCopilot({
...FAST_POLL_OPTIONS,
onAuth: vi.fn(),
onPrompt: mockOnPrompt(" "),
});
@@ -191,6 +195,7 @@ describe("loginGitHubCopilot", () => {
global.fetch = fetchMock as unknown as typeof fetch;
const credentials = await loginGitHubCopilot({
...FAST_POLL_OPTIONS,
onAuth: vi.fn(),
onPrompt: mockOnPrompt(""),
});
@@ -247,6 +252,7 @@ describe("loginGitHubCopilot", () => {
await expect(
loginGitHubCopilot({
...FAST_POLL_OPTIONS,
onAuth: vi.fn(),
onPrompt: mockOnPrompt(""),
}),
@@ -276,6 +282,7 @@ describe("loginGitHubCopilot", () => {
global.fetch = fetchMock as unknown as typeof fetch;
const credentials = await loginGitHubCopilot({
...FAST_POLL_OPTIONS,
onAuth: vi.fn(),
onPrompt: mockOnPrompt(""),
});
+44
View File
@@ -1,5 +1,8 @@
import * as os from "node:os";
import * as path from "node:path";
import { enrichModelThinking } from "@oh-my-pi/pi-ai/model-thinking";
import type { Model } from "@oh-my-pi/pi-ai/types";
import { isEnoent } from "@oh-my-pi/pi-utils";
export async function withEnv(
overrides: Record<string, string | undefined>,
@@ -65,3 +68,44 @@ export function createCodexModel(id: string): Model<"openai-codex-responses"> {
maxTokens: 128000,
});
}
export interface AuthGatewayE2EStatus {
ok: boolean;
token?: string;
reason?: string;
}
export const AUTH_GATEWAY_E2E_URL = Bun.env.OMP_E2E_GATEWAY_URL ?? "http://127.0.0.1:4000";
const AUTH_GATEWAY_TOKEN_PATH = path.join(os.homedir(), ".omp", "auth-gateway.token");
const AUTH_GATEWAY_HEALTH_TIMEOUT_MS = 500;
let authGatewayE2EStatus: Promise<AuthGatewayE2EStatus> | undefined;
export function checkAuthGatewayE2EAvailable(): Promise<AuthGatewayE2EStatus> {
authGatewayE2EStatus ??= readAuthGatewayE2EStatus();
return authGatewayE2EStatus;
}
async function readAuthGatewayE2EStatus(): Promise<AuthGatewayE2EStatus> {
if (!Bun.env.E2E) return { ok: false, reason: "E2E env not set" };
let token: string;
try {
token = (await Bun.file(AUTH_GATEWAY_TOKEN_PATH).text()).trim();
} catch (err) {
if (isEnoent(err)) return { ok: false, reason: `no token at ${AUTH_GATEWAY_TOKEN_PATH}` };
throw err;
}
if (!token) return { ok: false, reason: `empty token at ${AUTH_GATEWAY_TOKEN_PATH}` };
try {
const res = await fetch(`${AUTH_GATEWAY_E2E_URL}/healthz`, {
signal: AbortSignal.timeout(AUTH_GATEWAY_HEALTH_TIMEOUT_MS),
});
if (!res.ok) return { ok: false, reason: `healthz returned ${res.status}` };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, reason: `healthz unreachable: ${msg}` };
}
return { ok: true, token };
}
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
import { type AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui";
import { $env, sanitizeText } from "@oh-my-pi/pi-utils";
import { settings } from "../../config/settings";
import { isSettingsInitialized, settings } from "../../config/settings";
import { expandEmoticons } from "../../modes/emoji-autocomplete";
import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
import { theme } from "../../modes/theme/theme";
@@ -187,7 +187,7 @@ export class InputController {
setupEditorSubmitHandler(): void {
this.ctx.editor.onSubmit = async (text: string) => {
text = text.trim();
if (settings.get("emojiAutocomplete")) text = expandEmoticons(text);
if ((!isSettingsInitialized() || settings.get("emojiAutocomplete")) && text) text = expandEmoticons(text);
// Empty submit while streaming with queued messages: flush queues immediately
if (!text && this.ctx.session.isStreaming && this.ctx.session.queuedMessageCount > 0) {
@@ -6,7 +6,7 @@ import {
type SlashCommand,
} from "@oh-my-pi/pi-tui";
import { formatKeyHints, type KeybindingsManager } from "../config/keybindings";
import { settings } from "../config/settings";
import { isSettingsInitialized, settings } from "../config/settings";
import { applyEmojiCompletion, getEmojiSuggestions, isEmojiPrefix, tryEmojiInlineReplace } from "./emoji-autocomplete";
interface PromptActionDefinition {
@@ -128,7 +128,7 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
}
}
if (settings.get("emojiAutocomplete")) {
if (!isSettingsInitialized() || settings.get("emojiAutocomplete")) {
const emojiSuggestions = getEmojiSuggestions(textBeforeCursor);
if (emojiSuggestions) return emojiSuggestions;
}
@@ -183,7 +183,7 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
return this.#baseProvider.trySyncSlashCompletion?.(textBeforeCursor) ?? null;
}
trySyncInlineReplace(textBeforeCursor: string): { replaceLen: number; insert: string } | null {
if (!settings.get("emojiAutocomplete")) return null;
if (isSettingsInitialized() && !settings.get("emojiAutocomplete")) return null;
return tryEmojiInlineReplace(textBeforeCursor);
}
}
@@ -10,21 +10,73 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
type AcpProc = Bun.Subprocess<"pipe", "pipe", "pipe">;
const repoRoot = path.resolve(import.meta.dir, "..", "..", "..");
const cliEntry = path.join(repoRoot, "packages", "coding-agent", "src", "cli.ts");
const cleanupRoots: string[] = [];
let activeProc: ReturnType<typeof Bun.spawn> | undefined;
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).
*/
async function teardown(proc: AcpProc): Promise<void> {
// Close stdin so any blocking read in the child wakes up.
try {
proc.stdin.end();
} catch {
// already closed
}
// Best-effort detach from stdout/stderr so the child's pipe writes don't
// block on a full buffer once we stop draining.
for (const stream of [proc.stdout, proc.stderr] as Array<ReadableStream<Uint8Array> | undefined>) {
if (!stream) continue;
try {
await stream.cancel();
} catch {
// reader may already be detached
}
}
try {
proc.kill("SIGTERM");
} 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)]);
}
afterEach(async () => {
if (activeProc) {
try {
activeProc.kill();
await activeProc.exited;
} catch {
// ignore
}
const proc = activeProc;
activeProc = undefined;
try {
await teardown(proc);
} catch {
// teardown is already best-effort; never let cleanup fail a test
}
}
for (const root of cleanupRoots.splice(0)) {
await fs.promises.rm(root, { recursive: true, force: true });
@@ -53,13 +105,16 @@ describe("ACP stdout hygiene", () => {
it("emits a JSON-RPC initialize response as the first bytes on stdout", async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "omp-acp-stdout-"));
cleanupRoots.push(root);
const home = path.join(root, "home");
const xdg = path.join(root, "xdg");
const agentDir = path.join(root, "agent");
await fs.promises.mkdir(home, { recursive: true });
await fs.promises.mkdir(xdg, { recursive: true });
await fs.promises.mkdir(agentDir, { recursive: true });
// NOTE: we intentionally do NOT override HOME. Bun keys its transpile
// cache at `$HOME/.bun/install/cache`; pointing HOME at a fresh tmp
// dir forces a full re-transpile of the CLI's module graph on every
// run (~12s cold vs ~0.4s warm). XDG_* and PI_CODING_AGENT_DIR
// already isolate PI's on-disk state for this smoke test.
const proc = Bun.spawn(["bun", cliEntry, "acp"], {
cwd: repoRoot,
stdin: "pipe",
@@ -67,15 +122,38 @@ describe("ACP stdout hygiene", () => {
stderr: "pipe",
env: {
...process.env,
HOME: home,
XDG_DATA_HOME: xdg,
XDG_CONFIG_HOME: xdg,
PI_CODING_AGENT_DIR: agentDir,
PI_NO_TITLE: "1",
NO_COLOR: "1",
},
});
activeProc = proc;
// Buffer stderr in the background so we can assert no JSON-RPC frame
// leaks onto it. The pump exits the moment stderr closes, which
// happens during teardown — we never wait on it from the test body.
const stderrChunks: Uint8Array[] = [];
const stderrPump = (async () => {
const reader = proc.stderr.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) stderrChunks.push(value);
}
} catch {
// reader cancelled by teardown — expected
} finally {
try {
reader.releaseLock();
} catch {
// already released
}
}
})();
const initRequest = {
jsonrpc: "2.0",
id: 1,
@@ -85,27 +163,7 @@ describe("ACP stdout hygiene", () => {
proc.stdin.write(new TextEncoder().encode(`${JSON.stringify(initRequest)}\n`));
proc.stdin.flush();
// Capture stderr in parallel so we can verify it does not carry any
// JSON-RPC frame. ACP owns stdout; banners, progress text, or stray
// protocol bytes on stderr indicate a misroute.
const stderrChunks: Uint8Array[] = [];
const stderrPump = (async () => {
const reader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) stderrChunks.push(value);
// Stop once the first stdout frame arrives so the pump terminates
// alongside the test rather than waiting for process exit.
if (stderrChunks.length > 32) break;
}
} finally {
reader.releaseLock();
}
})();
const firstLine = await readFirstFrame(proc.stdout as ReadableStream<Uint8Array>);
const firstLine = await readFirstFrame(proc.stdout);
expect(firstLine.length).toBeGreaterThan(0);
expect(firstLine[0]).toBe("{");
@@ -126,18 +184,19 @@ describe("ACP stdout hygiene", () => {
]),
);
// Terminate the process so the stderr pump promise resolves. Race with a
// short timeout in case stderr is empty (common path).
try {
proc.kill();
} catch {
// process may already be exiting
}
await Promise.race([stderrPump, new Promise(resolve => setTimeout(resolve, 500))]);
const stderrText = new TextDecoder().decode(new Uint8Array(stderrChunks.flatMap(chunk => Array.from(chunk))));
// Guard against JSON-RPC frames sneaking onto stderr. We allow normal
// stderr output (warnings, telemetry, etc.) but reject anything that
// parses as a JSON-RPC envelope on the wrong channel.
// 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.
await teardown(proc);
activeProc = undefined;
await stderrPump;
const stderrText = Buffer.concat(stderrChunks).toString("utf8");
// Guard against JSON-RPC frames sneaking onto stderr. Normal stderr
// output (warnings, telemetry, etc.) is allowed, but anything that
// parses as a JSON-RPC envelope on the wrong channel is a misroute.
for (const line of stderrText.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
@@ -64,14 +64,15 @@ describe("AgentSession concurrent prompt guard", () => {
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
stream.push({ type: "start", partial: createAssistantMessage("") });
const checkAbort = () => {
if (abortSignal?.aborted) {
stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") });
} else {
setTimeout(checkAbort, 5);
}
};
checkAbort();
if (abortSignal) {
abortSignal.addEventListener(
"abort",
() => {
stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") });
},
{ once: true },
);
}
});
return stream;
},
@@ -110,11 +111,7 @@ describe("AgentSession concurrent prompt guard", () => {
// Start first prompt (don't await, it will block until abort)
const firstPrompt = session.prompt("First message");
// Wait a tick for isStreaming to be set
await Bun.sleep(10);
// Verify we're streaming
expect(session.isStreaming).toBe(true);
await waitFor(() => session.isStreaming);
// Second prompt should reject
await expect(session.prompt("Second message")).rejects.toBeInstanceOf(AgentBusyError);
@@ -129,7 +126,7 @@ describe("AgentSession concurrent prompt guard", () => {
// Start first prompt
const firstPrompt = session.prompt("First message");
await Bun.sleep(10);
await waitFor(() => session.isStreaming);
// steer should work while streaming
expect(() => session.steer("Steering message")).not.toThrow();
@@ -145,7 +142,7 @@ describe("AgentSession concurrent prompt guard", () => {
// Start first prompt
const firstPrompt = session.prompt("First message");
await Bun.sleep(10);
await waitFor(() => session.isStreaming);
// followUp should work while streaming
expect(() => session.followUp("Follow-up message")).not.toThrow();
@@ -293,6 +290,15 @@ describe("AgentSession TTSR resume gate", () => {
}
});
async function waitFor(predicate: () => boolean, timeoutMs = 500): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return;
await Bun.sleep(10);
}
throw new Error("Timed out waiting for condition");
}
const testRule: Rule = {
name: "no-unwrap",
path: "/tmp/no-unwrap.md",
@@ -322,18 +328,16 @@ describe("AgentSession TTSR resume gate", () => {
}
function pushContinuationStream(stream: AssistantMessageEventStream, onComplete: () => void): void {
setTimeout(() => {
queueMicrotask(() => {
const partial = makeMsg("");
stream.push({ type: "start", partial });
setTimeout(() => {
onComplete();
stream.push({
type: "done",
reason: "stop",
message: makeMsg('Fixed: let val = result.expect("msg")'),
});
}, 80);
}, 10);
onComplete();
stream.push({
type: "done",
reason: "stop",
message: makeMsg('Fixed: let val = result.expect("msg")'),
});
});
}
function pushAbortableTtsrStream(stream: AssistantMessageEventStream, signal: AbortSignal | undefined): void {
@@ -346,19 +350,19 @@ describe("AgentSession TTSR resume gate", () => {
delta: "let val = result.unwrap(",
partial: makeMsg("let val = result.unwrap("),
});
// TTSR abort should fire synchronously; poll for it
const checkAbort = () => {
if (signal?.aborted) {
stream.push({
type: "error",
reason: "aborted",
error: makeMsg("let val = result.unwrap(", "aborted"),
});
} else {
setTimeout(checkAbort, 2);
}
};
checkAbort();
if (signal) {
signal.addEventListener(
"abort",
() => {
stream.push({
type: "error",
reason: "aborted",
error: makeMsg("let val = result.unwrap(", "aborted"),
});
},
{ once: true },
);
}
});
}
@@ -525,18 +529,19 @@ describe("AgentSession TTSR resume gate", () => {
delta: "result.unwrap(",
partial: makeMsg("result.unwrap("),
});
const checkAbort = () => {
if (signal?.aborted) {
stream.push({
type: "error",
reason: "aborted",
error: makeMsg("result.unwrap(", "aborted"),
});
} else {
setTimeout(checkAbort, 2);
}
};
checkAbort();
if (signal) {
signal.addEventListener(
"abort",
() => {
stream.push({
type: "error",
reason: "aborted",
error: makeMsg("result.unwrap(", "aborted"),
});
},
{ once: true },
);
}
});
return stream;
@@ -560,9 +565,7 @@ describe("AgentSession TTSR resume gate", () => {
// Start prompt (will trigger TTSR and create resume gate)
const promptPromise = session.prompt("Write some Rust code");
// Wait for TTSR abort to be pending
await Bun.sleep(20);
await waitFor(() => session.isStreaming);
// Abort session — prompt() should unblock
await session.abort();
@@ -592,7 +595,6 @@ describe("AgentSession TTSR resume gate", () => {
description: "A mock edit tool",
parameters: z.object({}),
execute: async () => {
await Bun.sleep(100);
toolExecutionFinished = true;
return { content: [{ type: "text" as const, text: "edit applied" }] };
},
@@ -638,19 +640,19 @@ describe("AgentSession TTSR resume gate", () => {
pushAbortableTtsrStream(stream, signal);
} else if (streamCallCount === 2) {
// Continuation: return assistant message with a tool call
setTimeout(() => {
queueMicrotask(() => {
const msg = makeToolCallMsg();
stream.push({ type: "start", partial: msg });
stream.push({ type: "done", reason: "toolUse", message: msg });
}, 10);
});
} else {
// After tool execution: return final response
setTimeout(() => {
queueMicrotask(() => {
allTurnsCompleted = true;
const msg = makeMsg('Fixed: let val = result.expect("msg")');
stream.push({ type: "start", partial: msg });
stream.push({ type: "done", reason: "stop", message: msg });
}, 10);
});
}
return stream;
@@ -756,11 +758,11 @@ describe("AgentSession TTSR resume gate", () => {
});
} else {
// Continuation after tool result; finish cleanly.
setTimeout(() => {
queueMicrotask(() => {
const done = makeMsg("ok");
stream.push({ type: "start", partial: done });
stream.push({ type: "done", reason: "stop", message: done });
}, 10);
});
}
return stream;
},
@@ -892,11 +894,11 @@ describe("AgentSession TTSR resume gate", () => {
stream.push({ type: "done", reason: "toolUse", message: partial });
});
} else {
setTimeout(() => {
queueMicrotask(() => {
const done = makeMsg("ok");
stream.push({ type: "start", partial: done });
stream.push({ type: "done", reason: "stop", message: done });
}, 10);
});
}
return stream;
},
@@ -997,12 +999,12 @@ describe("AgentSession TTSR resume gate", () => {
stream.push({ type: "error", reason: "error", error: message });
});
} else {
setTimeout(() => {
queueMicrotask(() => {
continuationCompleted = true;
const message = makeSuccessMessage();
stream.push({ type: "start", partial: message });
stream.push({ type: "done", reason: "stop", message });
}, 80);
});
}
return stream;
},
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { afterEach, 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";
@@ -8,7 +8,7 @@ import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/eval/py/executor";
import type { PythonKernel as PythonKernelInstance } from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import * as pythonKernel from "@oh-my-pi/pi-coding-agent/eval/py/kernel";
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
import { createAgentSession, type ExtensionFactory } from "@oh-my-pi/pi-coding-agent/sdk";
import { createAgentSession, type ExtensionFactory, type WorkspaceTree } from "@oh-my-pi/pi-coding-agent/sdk";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { Snowflake } from "@oh-my-pi/pi-utils";
@@ -75,6 +75,23 @@ const createTempProject = () => {
return { tempDir, cwd };
};
const emptyWorkspaceTree = (cwd: string): WorkspaceTree => ({
rootPath: cwd,
rendered: ".",
truncated: false,
totalLines: 1,
agentsMdFiles: [],
});
const mockPositiveSleepsImmediate = () => {
const realSleep = Bun.sleep.bind(Bun);
return vi.spyOn(Bun, "sleep").mockImplementation((duration?: number | Date) => {
if (typeof duration === "number" && duration > 0) {
return Promise.resolve();
}
return realSleep(duration ?? 0);
});
};
const createSession = async (
tempDir: string,
cwd: string,
@@ -92,6 +109,7 @@ const createSession = async (
skills: [],
contextFiles: [],
promptTemplates: [],
workspaceTree: emptyWorkspaceTree(cwd),
slashCommands: [],
enableMCP: false,
enableLsp: false,
@@ -117,8 +135,20 @@ const createMockKernel = () => {
describe("AgentSession python cleanup", () => {
const tempDirs: string[] = [];
let originalNullPrompt: string | undefined;
beforeEach(() => {
originalNullPrompt = Bun.env.NULL_PROMPT;
Bun.env.NULL_PROMPT = "true";
});
afterEach(async () => {
if (originalNullPrompt === undefined) {
delete Bun.env.NULL_PROMPT;
} else {
Bun.env.NULL_PROMPT = originalNullPrompt;
}
originalNullPrompt = undefined;
vi.restoreAllMocks();
await pythonExecutor.disposeAllKernelSessions();
for (const tempDir of tempDirs.splice(0)) {
@@ -162,6 +192,7 @@ describe("AgentSession python cleanup", () => {
enableMCP: false,
enableLsp: false,
toolNames: ["eval"],
workspaceTree: emptyWorkspaceTree(cwd),
}),
).rejects.toThrow("Extension init failed");
@@ -227,6 +258,7 @@ describe("AgentSession python cleanup", () => {
enableMCP: false,
enableLsp: false,
toolNames: ["eval"],
workspaceTree: emptyWorkspaceTree(cwd),
agentRegistry: throwingRegistry,
}),
).rejects.toThrow("Agent registry failed");
@@ -374,19 +406,17 @@ describe("AgentSession python cleanup", () => {
toolExecutionSettled = true;
});
await blockedExecuteStarted.promise;
const sleepSpy = mockPositiveSleepsImmediate();
let disposed = false;
const disposeSession = session.dispose().then(() => {
disposed = true;
});
await Bun.sleep(0);
expect(disposed).toBe(false);
expect(toolExecutionSettled).toBe(false);
expect(executeSpy).toHaveBeenCalledTimes(1);
const [toolResult] = await Promise.all([toolExecution, disposeSession]);
expect(sleepSpy).toHaveBeenCalledWith(3000);
expect(disposed).toBe(true);
expect(toolExecutionSettled).toBe(true);
expect(executeSpy).toHaveBeenCalledTimes(1);
@@ -408,6 +438,8 @@ describe("AgentSession python cleanup", () => {
kernel.abortBlockedExecution = false;
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
const sleepSpy = vi.spyOn(Bun, "sleep").mockResolvedValue(undefined);
const startSpy = vi
.spyOn(pythonKernel.PythonKernel, "start")
.mockResolvedValue(kernel as unknown as PythonKernelInstance);
@@ -428,6 +460,7 @@ describe("AgentSession python cleanup", () => {
firstDisposed = true;
});
await disposeFirst;
expect(sleepSpy).toHaveBeenCalledWith(3000);
expect(firstDisposed).toBe(true);
expect(firstExecutionSettled).toBe(false);
@@ -666,9 +699,10 @@ describe("AgentSession python cleanup", () => {
const firstExecution = session.executePython("print('first')");
await blockedExecutionStarted.promise;
const secondExecution = session.executePython("print('second')");
await Bun.sleep(0);
const sleepSpy = mockPositiveSleepsImmediate();
await session.dispose();
expect(sleepSpy).toHaveBeenCalledWith(3000);
const [firstResult, secondResult] = await Promise.all([firstExecution, secondExecution]);
expect(firstResult.cancelled).toBe(true);
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { afterEach, beforeEach, 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, Effort, getBundledModel, type Model, writeModelCache } from "@oh-my-pi/pi-ai";
@@ -83,6 +83,7 @@ describe("AgentSession retry fallback", () => {
}
authStorage.close();
tempDir.removeSync();
vi.restoreAllMocks();
});
it("advances through a role-keyed fallback chain across retries", async () => {
@@ -569,6 +570,8 @@ describe("AgentSession retry fallback", () => {
settings,
modelRegistry,
});
let now = Date.now();
vi.spyOn(Date, "now").mockImplementation(() => now);
await session.prompt("First prompt triggers fallback");
await session.waitForIdle();
@@ -589,7 +592,7 @@ describe("AgentSession retry fallback", () => {
expect(session.model?.provider).toBe(fallbackModel.provider);
expect(session.model?.id).toBe(fallbackModel.id);
await Bun.sleep(240);
now += 240;
await session.prompt("Third prompt should lazily revert to primary");
await session.waitForIdle();
expect(requestedModels).toEqual([
@@ -629,6 +632,8 @@ describe("AgentSession retry fallback", () => {
modelRegistry,
thinkingLevel: Effort.High,
});
let now = Date.now();
vi.spyOn(Date, "now").mockImplementation(() => now);
await session.prompt("First prompt triggers bare-selector fallback");
await session.waitForIdle();
@@ -641,7 +646,7 @@ describe("AgentSession retry fallback", () => {
expect(session.thinkingLevel).toBeUndefined();
session.setThinkingLevel(Effort.Low);
await Bun.sleep(240);
now += 240;
await session.prompt("Second prompt should restore model but preserve user thinking change");
await session.waitForIdle();
expect(requestedModels).toEqual([
@@ -1,4 +1,4 @@
import { describe, expect, it, spyOn } from "bun:test";
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
import type { ClientBridge, ClientBridgeTerminalHandle } from "../src/session/client-bridge";
import type { ToolSession } from "../src/tools";
import { BashTool } from "../src/tools/bash";
@@ -29,6 +29,10 @@ function makeSession(bridge: ClientBridge): ToolSession {
} as unknown as ToolSession;
}
afterEach(() => {
mock.restore();
});
describe("BashTool ACP terminal routing", () => {
it("routes through bridge, emits terminalId update, and releases the handle", async () => {
const stubText = "hello from terminal\n";
@@ -140,6 +144,8 @@ describe("BashTool ACP terminal routing", () => {
const killSpy = spyOn(handle, "kill");
const releaseSpy = spyOn(handle, "release");
spyOn(Bun, "sleep").mockImplementation(async () => {});
const tool = new BashTool(makeSession(bridge));
await expect(tool.execute("call-timeout", { command: "sleep 60", timeout: 1 })).rejects.toThrow(
@@ -10,6 +10,9 @@ import * as shellSnapshot from "@oh-my-pi/pi-coding-agent/utils/shell-snapshot";
// Matches the schema default for `tools.artifactHeadBytes` (20 KB) used by
// OutputSink when bash-executor pulls settings via resolveOutputSinkHeadBytes.
const ARTIFACT_HEAD_BYTES_DEFAULT = 20 * 1024;
const BACKGROUND_COMPLETION_RACE_MS = 750;
const KILL_MARKER_DELAY_SECONDS = "0.4";
const KILL_MARKER_ASSERTION_WAIT_MS = 900;
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "omp-bash-exec-"));
@@ -95,13 +98,18 @@ describe("executeBash", () => {
if (process.platform === "win32") {
return;
}
const start = Date.now();
const result = await executeBash("{ sleep 5; } & echo fg", {
const runPromise = executeBash("{ sleep 2; } & echo fg", {
cwd: tempDir,
timeout: 5000,
});
expect(result.output).toContain("fg");
expect(Date.now() - start).toBeLessThan(3000);
const timed = await Promise.race([
runPromise.then(result => ({ type: "result" as const, result })),
Bun.sleep(BACKGROUND_COMPLETION_RACE_MS).then(() => ({ type: "timeout" as const })),
]);
expect(timed.type).toBe("result");
if (timed.type === "result") {
expect(timed.result.output).toContain("fg");
}
});
it("returns a real PID for background external commands", async () => {
@@ -369,13 +377,13 @@ describe("executeBash", () => {
it("completes even when background job keeps stdout pipe open", async () => {
if (process.platform === "win32") return;
const runPromise = executeBash("{ sleep 3; echo late; } & echo immediate", {
const runPromise = executeBash("{ sleep 2; echo late; } & echo immediate", {
cwd: tempDir,
timeout: 5000,
});
const timed = await Promise.race([
runPromise.then(result => ({ type: "result" as const, result })),
Bun.sleep(1500).then(() => ({ type: "timeout" as const })),
Bun.sleep(BACKGROUND_COMPLETION_RACE_MS).then(() => ({ type: "timeout" as const })),
]);
expect(timed.type).toBe("result");
@@ -389,17 +397,18 @@ describe("executeBash", () => {
if (process.platform === "win32") return;
const marker = path.join(tempDir, "marker.txt");
const markerEscaped = marker.replace(/'/g, "'\\''");
// Command creates marker after 2s, but we timeout after 100ms
const result = await executeBash(`sleep 2 && echo done > ${marker}`, {
// Command creates marker after a short delay, but we timeout before then.
const result = await executeBash(`sleep ${KILL_MARKER_DELAY_SECONDS} && echo done > '${markerEscaped}'`, {
cwd: tempDir,
timeout: 100,
});
expect(result.cancelled).toBe(true);
// Wait longer than the command would have taken
await Bun.sleep(3000);
// Wait longer than the command would have needed to create the marker.
await Bun.sleep(KILL_MARKER_ASSERTION_WAIT_MS);
// If process was killed (not orphaned), marker should NOT exist
expect(fs.existsSync(marker)).toBe(false);
@@ -411,14 +420,17 @@ describe("executeBash", () => {
const marker = path.join(tempDir, "marker-bg.txt");
const markerEscaped = marker.replace(/'/g, "'\\''");
const result = await executeBash(`{ sleep 2; echo done > '${markerEscaped}'; } & sleep 10`, {
cwd: tempDir,
timeout: 100,
});
const result = await executeBash(
`{ sleep ${KILL_MARKER_DELAY_SECONDS}; echo done > '${markerEscaped}'; } & sleep 10`,
{
cwd: tempDir,
timeout: 100,
},
);
expect(result.cancelled).toBe(true);
await Bun.sleep(3000);
await Bun.sleep(KILL_MARKER_ASSERTION_WAIT_MS);
expect(fs.existsSync(marker)).toBe(false);
});
@@ -429,19 +441,23 @@ describe("executeBash", () => {
const markerEscaped = marker.replace(/'/g, "'\\''");
const controller = new AbortController();
const promise = executeBash(`{ sleep 2; echo done > '${markerEscaped}'; } & sleep 10`, {
cwd: tempDir,
timeout: 10000,
signal: controller.signal,
});
const promise = executeBash(
`{ sleep ${KILL_MARKER_DELAY_SECONDS}; echo done > '${markerEscaped}'; } & sleep 10`,
{
cwd: tempDir,
timeout: 10000,
signal: controller.signal,
},
);
await Bun.sleep(100);
controller.abort();
const result = await promise;
expect(result.cancelled).toBe(true);
expect(result.output).toContain("Command cancelled");
await Bun.sleep(3000);
await Bun.sleep(KILL_MARKER_ASSERTION_WAIT_MS);
expect(fs.existsSync(marker)).toBe(false);
});
@@ -449,24 +465,26 @@ describe("executeBash", () => {
if (process.platform === "win32") return;
const marker = path.join(tempDir, "marker.txt");
const markerEscaped = marker.replace(/'/g, "'\\''");
const controller = new AbortController();
// Command creates marker after 2s
const promise = executeBash(`sleep 2 && echo done > ${marker}`, {
// Command creates marker after a short delay.
const promise = executeBash(`sleep ${KILL_MARKER_DELAY_SECONDS} && echo done > '${markerEscaped}'`, {
cwd: tempDir,
timeout: 10000,
signal: controller.signal,
});
// Abort after 100ms
// Abort before the command can create the marker.
await Bun.sleep(100);
controller.abort();
const result = await promise;
expect(result.cancelled).toBe(true);
expect(result.output).toContain("Command cancelled");
// Wait longer than the command would have taken
await Bun.sleep(3000);
// Wait longer than the command would have needed to create the marker.
await Bun.sleep(KILL_MARKER_ASSERTION_WAIT_MS);
// If process was killed (not orphaned), marker should NOT exist
expect(fs.existsSync(marker)).toBe(false);
@@ -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 { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
@@ -42,7 +42,7 @@ describe("executeJs", () => {
let sessionFile: string;
let sessionId: string;
beforeEach(async () => {
beforeAll(async () => {
tempDir = TempDir.createSync("@js-executor-");
sessionFile = path.join(tempDir.path(), "session.jsonl");
sessionId = `session:${sessionFile}:cwd:${tempDir.path()}`;
@@ -61,10 +61,13 @@ describe("executeJs", () => {
await Bun.write(path.join(tempDir.path(), "config.yaml"), "name: demo\nenabled: true\n");
});
afterEach(async () => {
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await disposeAllVmContexts();
tempDir.removeSync();
vi.restoreAllMocks();
});
it("persists bindings across calls and reset clears them", async () => {
@@ -417,19 +420,6 @@ describe("executeJs", () => {
expect(result.displayOutputs).toEqual([{ type: "json", data: { answer: 42, nested: { ok: true } } }]);
});
it("cancels execution when the timeout expires", async () => {
const result = await executeJs("await new Promise(() => {})", {
sessionId,
session,
sessionFile,
timeoutMs: 20,
});
expect(result.cancelled).toBe(true);
expect(result.exitCode).toBeUndefined();
expect(result.output).toContain("Command timed out");
});
it('rewrites static `import { x } from "pkg"` to dynamic import', async () => {
const result = await executeJs('import { join } from "node:path";\nreturn join("a", "b");', {
sessionId,
@@ -460,4 +450,17 @@ describe("executeJs", () => {
// No JSON display because structuredClone fails on the embedded function.
expect(result.displayOutputs.filter(o => o.type === "json")).toHaveLength(0);
});
it("cancels execution when the timeout expires", async () => {
const result = await executeJs("await new Promise(() => {})", {
sessionId,
session,
sessionFile,
timeoutMs: 20,
});
expect(result.cancelled).toBe(true);
expect(result.exitCode).toBeUndefined();
expect(result.output).toContain("Command timed out");
});
});
@@ -644,25 +644,25 @@ describe("ExtensionRunner", () => {
runner.onError(err => {
errors.push(err);
});
testSetExtensionHandlerTimeoutMs(50);
testSetExtensionHandlerTimeoutMs(10);
const startedAt = performance.now();
await runner.emit({ type: "session_start" });
const elapsedMs = performance.now() - startedAt;
expect(elapsedMs).toBeGreaterThanOrEqual(40);
expect(elapsedMs).toBeLessThan(250);
expect(elapsedMs).toBeGreaterThanOrEqual(8);
expect(elapsedMs).toBeLessThan(150);
expect(fs.readFileSync(markerPath, "utf8")).toBe("fast\n");
expect(warnSpy).toHaveBeenCalledWith("Extension handler timed out", {
extensionPath: hangExtensionPath,
event: "session_start",
timeoutMs: 50,
timeoutMs: 10,
});
expect(errors).toEqual([
{
extensionPath: hangExtensionPath,
event: "session_start",
error: "handler timed out after 50ms",
error: "handler timed out after 10ms",
},
]);
@@ -936,7 +936,7 @@ describe("ExtensionRunner", () => {
);
// Drain microtasks so the fire-and-forget emit() calls inside initialize() complete.
await new Promise(resolve => setTimeout(resolve, 50));
for (let i = 0; i < 5; i++) await Promise.resolve();
const events = fs
.readFileSync(eventsPath, "utf8")
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { afterEach, beforeEach, 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";
@@ -13,17 +13,19 @@ async function freshStorage(): Promise<HistoryStorage> {
}
async function seed(storage: HistoryStorage, prompts: string[]): Promise<void> {
for (const prompt of prompts) {
await storage.add(prompt, "/tmp/test");
}
const writes = prompts.map(prompt => storage.add(prompt, "/tmp/test"));
vi.advanceTimersByTime(100);
await Promise.all(writes);
}
beforeEach(() => {
HistoryStorage.resetInstance();
vi.useFakeTimers();
});
afterEach(async () => {
HistoryStorage.resetInstance();
vi.useRealTimers();
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
tempDir = "";
@@ -118,13 +118,17 @@ describe("createAgentSession credential_disabled subscription", () => {
if (events.length > waiters.length) {
return Promise.resolve(events[waiters.length] as CredentialDisabledEvent);
}
return new Promise<CredentialDisabledEvent>(resolve => {
waiters.push({ resolve });
});
const { promise, resolve } = Promise.withResolvers<CredentialDisabledEvent>();
waiters.push({ resolve });
return promise;
};
return { factory, events, next };
};
const drainCredentialDisabledDispatch = async (): Promise<void> => {
for (let i = 0; i < 5; i++) await Promise.resolve();
};
afterEach(() => {
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
@@ -190,8 +194,8 @@ describe("createAgentSession credential_disabled subscription", () => {
// Post-dispose: only the embedder fires; the extension's listener was unsubscribed.
await authStorage.set("openai", [expiredOAuth()]);
await authStorage.getApiKey("openai", "post-dispose");
// Allow any (non-existent) async listener microtasks a chance to run before asserting absence.
await Bun.sleep(20);
// Drain async dispatch turns before asserting absence.
await drainCredentialDisabledDispatch();
expect(embedderEvents).toEqual([
{ provider: "anthropic", disabledCause: expect.stringContaining("invalid_grant") },
@@ -242,7 +246,7 @@ describe("createAgentSession credential_disabled subscription", () => {
const wait2 = Promise.all([ext2.next(), ext3.next()]);
await authStorage.getApiKey("openai", "concurrent-2");
await wait2;
await Bun.sleep(20);
await drainCredentialDisabledDispatch();
expect(embedderEvents.map(e => e.provider)).toEqual(["anthropic", "openai"]);
expect(ext1.events.map(e => e.provider)).toEqual(["anthropic"]);
expect(ext2.events.map(e => e.provider)).toEqual(["anthropic", "openai"]);
@@ -255,7 +259,7 @@ describe("createAgentSession credential_disabled subscription", () => {
const wait3 = ext3.next();
await authStorage.getApiKey("google", "concurrent-3");
await wait3;
await Bun.sleep(20);
await drainCredentialDisabledDispatch();
expect(embedderEvents.map(e => e.provider)).toEqual(["anthropic", "openai", "google"]);
expect(ext1.events.map(e => e.provider)).toEqual(["anthropic"]);
expect(ext2.events.map(e => e.provider)).toEqual(["anthropic", "openai"]);
@@ -266,7 +270,7 @@ describe("createAgentSession credential_disabled subscription", () => {
await authStorage.set("anthropic", [expiredOAuth()]);
await authStorage.getApiKey("anthropic", "concurrent-final");
await Bun.sleep(20);
await drainCredentialDisabledDispatch();
expect(embedderEvents.map(e => e.provider)).toEqual(["anthropic", "openai", "google", "anthropic"]);
expect(ext1.events).toHaveLength(1);
expect(ext2.events).toHaveLength(2);
@@ -291,7 +295,7 @@ describe("createAgentSession credential_disabled subscription", () => {
await authStorage.set("anthropic", [expiredOAuth()]);
failOAuthRefresh();
await authStorage.getApiKey("anthropic", "pre-init");
await Bun.sleep(20);
await drainCredentialDisabledDispatch();
expect(ext.events).toHaveLength(0);
// Initializing flushes the buffer through `emit()` with the now-populated
@@ -330,7 +334,7 @@ describe("createAgentSession credential_disabled subscription", () => {
await authStorage.set("anthropic", [expiredOAuth()]);
failOAuthRefresh();
await authStorage.getApiKey("anthropic", "startup-with-embedder");
await Bun.sleep(20);
await drainCredentialDisabledDispatch();
// Embedder fires immediately (sync push from AuthStorage's fan-out loop). The
// extension still hasn't received it because the runner is uninitialized.
@@ -380,7 +384,7 @@ describe("createAgentSession credential_disabled subscription", () => {
failOAuthRefresh();
await authStorage.set("anthropic", [expiredOAuth()]);
await authStorage.getApiKey("anthropic", "post-failure");
await Bun.sleep(20);
await drainCredentialDisabledDispatch();
expect(embedderEvents).toEqual([
{ provider: "anthropic", disabledCause: expect.stringContaining("invalid_grant") },
@@ -3,7 +3,8 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { Effort, getBundledModel, type Model } from "@oh-my-pi/pi-ai";
import { AuthStorage, Effort, getBundledModel, type Model } from "@oh-my-pi/pi-ai";
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 { CustomTool } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/types";
import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk";
@@ -41,15 +42,22 @@ function createReasoningModel(): Model<"openai-responses"> {
};
}
const oldSessionMtime = new Date("2000-01-01T00:00:00.000Z");
describe("createAgentSession MCP discovery prompt gating", () => {
let tempDir: string;
let authStorage: AuthStorage;
let modelRegistry: ModelRegistry;
beforeEach(() => {
beforeEach(async () => {
tempDir = path.join(os.tmpdir(), `pi-sdk-mcp-discovery-${Snowflake.next()}`);
fs.mkdirSync(tempDir, { recursive: true });
authStorage = await AuthStorage.create(path.join(tempDir, "auth.db"));
modelRegistry = new ModelRegistry(authStorage);
});
afterEach(() => {
authStorage.close();
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
@@ -59,6 +67,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
const { session } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
modelRegistry,
sessionManager: SessionManager.inMemory(),
settings: Settings.isolated({ "mcp.discoveryMode": true }),
model: getBundledModel("openai", "gpt-4o-mini"),
@@ -83,6 +92,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
const { session } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
modelRegistry,
sessionManager: SessionManager.inMemory(),
settings: Settings.isolated({ "tools.discoveryMode": "all" }),
model: getBundledModel("openai", "gpt-4o-mini"),
@@ -106,6 +116,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
const { session } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
modelRegistry,
sessionManager: SessionManager.inMemory(),
settings: Settings.isolated({ "mcp.discoveryMode": true }),
model: getBundledModel("openai", "gpt-4o-mini"),
@@ -139,6 +150,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
const { session } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
modelRegistry,
sessionManager: SessionManager.inMemory(),
settings: Settings.isolated({
"mcp.discoveryMode": true,
@@ -173,6 +185,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
const { session } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
modelRegistry,
sessionManager: SessionManager.inMemory(),
settings: Settings.isolated({ "mcp.discoveryMode": true }),
model: getBundledModel("openai", "gpt-4o-mini"),
@@ -196,6 +209,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
const { session } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
modelRegistry,
sessionManager: SessionManager.inMemory(),
settings: Settings.isolated({ "tools.discoveryMode": "all" }),
model: getBundledModel("openai", "gpt-4o-mini"),
@@ -223,6 +237,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
const { session: firstSession } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
modelRegistry,
sessionManager: firstManager,
settings: Settings.isolated({
"mcp.discoveryMode": true,
@@ -251,14 +266,15 @@ describe("createAgentSession MCP discovery prompt gating", () => {
const sessionFile = firstSession.sessionFile;
expect(sessionFile).toBeDefined();
await firstSession.sessionManager.rewriteEntries();
fs.utimesSync(sessionFile!, oldSessionMtime, oldSessionMtime);
const persistedBeforeResume = fs.readFileSync(sessionFile!, "utf8");
const persistedMtimeBeforeResume = fs.statSync(sessionFile!).mtimeMs;
await Bun.sleep(20);
await firstSession.dispose();
const resumedManager = await SessionManager.open(sessionFile!, tempDir);
const { session: resumedSession } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
modelRegistry,
sessionManager: resumedManager,
settings: Settings.isolated({
"mcp.discoveryMode": true,
@@ -304,13 +320,14 @@ describe("createAgentSession MCP discovery prompt gating", () => {
const sessionFile = sessionManager.getSessionFile();
expect(sessionFile).toBeDefined();
await sessionManager.rewriteEntries();
fs.utimesSync(sessionFile!, oldSessionMtime, oldSessionMtime);
const persistedBeforeResume = fs.readFileSync(sessionFile!, "utf8");
const persistedMtimeBeforeResume = fs.statSync(sessionFile!).mtimeMs;
await Bun.sleep(20);
const resumedManager = await SessionManager.open(sessionFile!, tempDir);
const { session } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
modelRegistry,
sessionManager: resumedManager,
settings: Settings.isolated({
"mcp.discoveryMode": true,
@@ -352,6 +369,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
const { session: firstSession } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
modelRegistry,
sessionManager: firstManager,
settings: Settings.isolated({ "mcp.discoveryMode": true }),
model: getBundledModel("openai", "gpt-4o-mini"),
@@ -379,6 +397,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
const { session: resumedSession } = await createAgentSession({
cwd: tempDir,
agentDir: tempDir,
modelRegistry,
sessionManager: resumedManager,
settings: Settings.isolated({ "mcp.discoveryMode": true }),
model: getBundledModel("openai", "gpt-4o-mini"),
+16 -9
View File
@@ -1085,7 +1085,7 @@ function b() {
const updates: string[] = [];
const result = await bashTool.execute(
"test-call-8-stream",
{ command: "for i in 1 2 3; do echo $i; sleep 0.2; done" },
{ command: "for i in 1 2 3; do echo $i; sleep 0.05; done" },
undefined,
update => {
const text = update.content?.find(c => c.type === "text")?.text ?? "";
@@ -1155,7 +1155,7 @@ function b() {
expect(getTextOutput(result)).toContain("short");
expect(result.details?.timeoutSeconds).toBe(300);
expect(result.details?.async).toBeUndefined();
await Bun.sleep(150);
await asyncJobManager.drainDeliveries({ timeoutMs: 1 });
expect(deliveries).toEqual([]);
await asyncJobManager.dispose();
});
@@ -1174,7 +1174,7 @@ function b() {
testDir,
Settings.isolated({
"bash.autoBackground.enabled": true,
"bash.autoBackground.thresholdMs": 50,
"bash.autoBackground.thresholdMs": 10,
}),
{
getSessionId: () => "test-session",
@@ -1184,7 +1184,7 @@ function b() {
);
const result = await autoBackgroundBashTool.execute("test-call-9-auto-running", {
command: "printf 'start\\n'; sleep 0.2; printf 'done\\n'",
command: "printf 'start\\n'; sleep 0.05; printf 'done\\n'",
});
expect(result.details?.async?.state).toBe("running");
@@ -1199,7 +1199,7 @@ function b() {
const runningJob = asyncJobManager.getJob(jobId);
expect(runningJob?.status).toBe("running");
await runningJob?.promise;
await Bun.sleep(50);
await asyncJobManager.drainDeliveries({ timeoutMs: 1 });
expect(deliveries).toHaveLength(1);
expect(deliveries[0]?.jobId).toBe(jobId);
expect(deliveries[0]?.text).toContain("done");
@@ -1244,7 +1244,7 @@ function b() {
const runningJob = asyncJobManager.getJob(jobId);
expect(runningJob?.status).toBe("running");
await runningJob?.promise;
await Bun.sleep(50);
await asyncJobManager.drainDeliveries({ timeoutMs: 1 });
expect(deliveries).toHaveLength(1);
expect(deliveries[0]?.jobId).toBe(jobId);
expect(deliveries[0]?.text).toContain("Command timed out after 1 seconds");
@@ -1269,9 +1269,16 @@ function b() {
it("should abort and recover for subsequent commands", async () => {
const controller = new AbortController();
const promise = bashTool.execute("test-call-10-abort", { command: "sleep 5" }, controller.signal);
await Bun.sleep(200);
controller.abort("test abort");
const promise = bashTool.execute(
"test-call-10-abort",
{ command: "printf 'started\\n'; sleep 5" },
controller.signal,
update => {
if (update.content?.some(content => content.type === "text" && content.text.includes("started"))) {
controller.abort("test abort");
}
},
);
await expect(promise).rejects.toThrow(/abort|cancel|timed out/i);
const result = await bashTool.execute("test-call-10-after-abort", { command: "echo ok" });
+7 -9
View File
@@ -725,9 +725,6 @@ describe("github tool", () => {
it("treats git.remote.add as a no-op when the remote already exists with the same URL", async () => {
const fixture = await createPrFixture();
try {
// Fixture already created `forksrc -> forkBare`. A second add with the
// same URL must succeed silently — this is the cross-process / leftover-
// state path that used to fail with `error: remote forksrc already exists`.
await git.remote.add(fixture.repoRoot, "forksrc", fixture.forkBare);
expect(runGit(fixture.repoRoot, ["remote", "get-url", "forksrc"])).toBe(fixture.forkBare);
} finally {
@@ -751,17 +748,18 @@ describe("github tool", () => {
it("serializes concurrent git mutations through withRepoLock so callers don't race git's internal locks", async () => {
const fixture = await createPrFixture();
try {
// Without serialization, ~20 concurrent `git config` invocations against
// the same `.git/config` produce "could not lock config file" failures
// (the lock is O_EXCL with no waiter). Wrapping each write in
// `withRepoLock` makes the queue per-repo so all 20 succeed.
const writes = Array.from({ length: 20 }, (_, idx) =>
// Without serialization, concurrent `git config` invocations against the
// same `.git/config` produce "could not lock config file" failures (the
// lock is O_EXCL with no waiter). Wrapping each write in `withRepoLock`
// makes the queue per-repo so all writes succeed.
const writeCount = 8;
const writes = Array.from({ length: writeCount }, (_, idx) =>
git.withRepoLock(fixture.repoRoot, () =>
git.config.set(fixture.repoRoot, `branch.race-test.key${idx}`, `value-${idx}`),
),
);
await Promise.all(writes);
for (let idx = 0; idx < 20; idx += 1) {
for (let idx = 0; idx < writeCount; idx += 1) {
expect(runGit(fixture.repoRoot, ["config", "--get", `branch.race-test.key${idx}`])).toBe(`value-${idx}`);
}
} finally {
+3 -3
View File
@@ -522,14 +522,14 @@ describe("pi-natives", () => {
await fs.rm(markerPath, { force: true });
const result = await executeShell({
command: `{ sleep 2; echo done > '${markerEscaped}'; } & sleep 10`,
command: `{ sleep 0.15; echo done > '${markerEscaped}'; } & sleep 10`,
cwd: testDir,
timeoutMs: 100,
timeoutMs: 50,
});
expect(result.timedOut).toBe(true);
await Bun.sleep(3000);
await Bun.sleep(500);
expect(await Bun.file(markerPath).exists()).toBe(false);
});
});
+69 -96
View File
@@ -81,6 +81,12 @@ function longestBlankRun(lines: string[]): number {
return longest;
}
async function flushRender(term: VirtualTerminal): Promise<void> {
await new Promise<void>(resolve => process.nextTick(resolve));
await Bun.sleep(17);
await term.flush();
}
describe("TUI overlays", () => {
it("does not scroll the terminal when an overlay is shown with a large historical working area", async () => {
const term = new VirtualTerminal(80, 24);
@@ -89,16 +95,14 @@ describe("TUI overlays", () => {
tui.addChild(new LineComponent("base-", 5));
tui.start();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
// Simulate a large historical working area (max lines ever rendered) without actually
// rendering that many lines in the current view.
(tui as unknown as { maxLinesRendered: number }).maxLinesRendered = 1500;
tui.showOverlay(new LineComponent("overlay-", 3), { anchor: "center" });
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
// The scroll buffer should stay small; we should not have printed hundreds/thousands of blank lines.
expect(term.getScrollBuffer().length).toBeLessThan(200);
@@ -107,19 +111,17 @@ describe("TUI overlays", () => {
it("clears preexisting terminal scrollback on startup full redraw", async () => {
const term = new VirtualTerminal(40, 4);
term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n");
await term.waitForRender();
await flushRender(term);
const tui = new TUI(term);
const component = new MutableContentComponent(["ui-0", "ui-1", "ui-2", "ui-3", "ui-4", "ui-5"]);
tui.addChild(component);
tui.start();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
term.resize(39, 4);
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
const scrollback = term.getScrollBuffer().join("\n");
expect(scrollback.includes("shell-0")).toBeFalsy();
@@ -134,15 +136,13 @@ describe("TUI overlays", () => {
tui.addChild(component);
tui.start();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
const before = term.getScrollBuffer().join("\n");
expect(before.includes("row-0")).toBeTruthy();
tui.requestRender(true);
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
const after = term.getScrollBuffer().join("\n");
expect(after.includes("row-0")).toBeTruthy();
@@ -152,19 +152,17 @@ describe("TUI overlays", () => {
it("fully redraws on height increase to avoid stale viewport rows", async () => {
const term = new VirtualTerminal(40, 4);
term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n");
await term.waitForRender();
await flushRender(term);
const tui = new TUI(term);
const component = new MutableContentComponent(["ui-0", "ui-1", "ui-2", "ui-3"]);
tui.addChild(component);
tui.start();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
term.resize(40, 8);
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
const viewport = term.getViewport().join("\n");
expect(viewport.includes("shell-")).toBeFalsy();
@@ -178,14 +176,12 @@ describe("TUI overlays", () => {
tui.addChild(component);
try {
tui.start();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
const before = term.getScrollBuffer().length;
for (let i = 0; i < 8; i++) {
term.resize(i % 2 === 0 ? 59 : 60, i % 2 === 0 ? 9 : 8);
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
}
const after = term.getScrollBuffer().length;
@@ -202,12 +198,10 @@ describe("TUI overlays", () => {
tui.addChild(component);
try {
tui.start();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
component.setLines(Array.from({ length: 140 }, (_v, i) => `row-${i}`));
term.resize(59, 9);
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
const viewport = term.getViewport();
expect(viewport.at(-1)?.includes("row-139")).toBeTruthy();
} finally {
@@ -218,16 +212,14 @@ describe("TUI overlays", () => {
it("keeps scrollback on viewport-only resize redraw", async () => {
const term = new VirtualTerminal(40, 4);
term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\n");
await term.waitForRender();
await flushRender(term);
const tui = new TUI(term);
tui.addChild(new MutableContentComponent(["ui-0", "ui-1", "ui-2", "ui-3", "ui-4"]));
try {
tui.start();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
term.resize(39, 4);
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
const scrollback = term.getScrollBuffer().join("\n");
expect(scrollback.includes("shell-0")).toBeFalsy();
} finally {
@@ -242,21 +234,19 @@ describe("TUI overlays", () => {
tui.addChild(component);
try {
tui.start();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
for (let count = 5; count <= 45; count++) {
for (let count = 5; count <= 29; count++) {
component.setLines(buildRows(count));
term.resize(40, count % 2 === 0 ? 4 : 5);
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
}
const scrollbackLines = term.getScrollBuffer().map(line => line.trim());
expect(scrollbackLines).toContain("row-0");
expect(scrollbackLines).toContain("row-20");
expect(scrollbackLines).toContain("row-12");
const viewport = term.getViewport().map(line => line.trim());
expect(viewport.at(-1)).toBe("row-44");
expect(viewport.at(-1)).toBe("row-28");
} finally {
tui.stop();
}
@@ -265,30 +255,27 @@ describe("TUI overlays", () => {
it("stays anchored across shrink-grow cycles while overflowing viewport", async () => {
const term = new VirtualTerminal(30, 6);
const tui = new TUI(term);
const component = new MutableContentComponent(Array.from({ length: 120 }, (_v, i) => `row-${i}`));
const component = new MutableContentComponent(Array.from({ length: 64 }, (_v, i) => `row-${i}`));
tui.addChild(component);
try {
tui.start();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
for (let cycle = 0; cycle < 5; cycle++) {
component.setLines(Array.from({ length: 120 - cycle * 8 }, (_v, i) => `row-${i}`));
for (let cycle = 0; cycle < 3; cycle++) {
component.setLines(Array.from({ length: 64 - cycle * 8 }, (_v, i) => `row-${i}`));
tui.requestRender();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
component.setLines(Array.from({ length: 120 - cycle * 8 + 4 }, (_v, i) => `row-${i}`));
component.setLines(Array.from({ length: 64 - cycle * 8 + 4 }, (_v, i) => `row-${i}`));
tui.requestRender();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
}
const viewport = term.getViewport().map(line => line.trim());
expect(viewport.every(line => /^row-\d+$/.test(line))).toBeTruthy();
const viewportRows = viewport.map(line => Number.parseInt(line.slice(4), 10));
expect(viewportRows.at(-1)).toBe(91);
expect(viewportRows[0]).toBeGreaterThanOrEqual(80);
expect(viewportRows.at(-1)).toBe(51);
expect(viewportRows[0]).toBeGreaterThanOrEqual(40);
} finally {
tui.stop();
}
@@ -301,15 +288,13 @@ describe("TUI overlays", () => {
tui.addChild(component);
try {
tui.start();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
const before = term.getScrollBuffer().length;
for (let col = 0; col <= 10; col++) {
component.setCursorCol(col);
tui.requestRender();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
}
const viewport = term.getViewport();
@@ -323,26 +308,24 @@ describe("TUI overlays", () => {
it("limits scrollback growth during resize oscillation with overflowing content", async () => {
const term = new VirtualTerminal(60, 10);
const tui = new TUI(term);
const component = new MutableContentComponent(buildRows(320));
const component = new MutableContentComponent(buildRows(160));
tui.addChild(component);
try {
tui.start();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
const before = term.getScrollBuffer().length;
for (let i = 0; i < 80; i++) {
component.setLines(buildRows(280 + (i % 6) * 15));
for (let i = 0; i < 18; i++) {
component.setLines(buildRows(140 + (i % 6) * 8));
term.resize(i % 2 === 0 ? 59 : 60, i % 3 === 0 ? 11 : 10);
tui.requestRender();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
const viewportRows = viewportRowNumbers(term);
expect(viewportRows.length).toBeGreaterThan(0);
}
const scrollback = term.getScrollBuffer();
expect(scrollback.length - before).toBeLessThan(700);
expect(scrollback.length - before).toBeLessThan(220);
expect(longestBlankRun(scrollback)).toBeLessThan(30);
} finally {
tui.stop();
@@ -352,34 +335,30 @@ describe("TUI overlays", () => {
it("limits scrollback while toggling overlays over overflowing content", async () => {
const term = new VirtualTerminal(60, 10);
const tui = new TUI(term);
const component = new MutableContentComponent(buildRows(300));
const component = new MutableContentComponent(buildRows(150));
tui.addChild(component);
try {
tui.start();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
const before = term.getScrollBuffer().length;
for (let i = 0; i < 50; i++) {
for (let i = 0; i < 12; i++) {
const handle = tui.showOverlay(new LineComponent(`overlay-${i}-`, 3), { anchor: "center" });
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
handle.hide();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
if (i % 5 === 0) {
component.setLines(buildRows(280 + (i % 4) * 10));
if (i % 4 === 0) {
component.setLines(buildRows(140 + (i % 4) * 10));
tui.requestRender();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
}
expect(viewportRowNumbers(term).length).toBeGreaterThan(0);
}
const scrollback = term.getScrollBuffer();
expect(scrollback.length - before).toBeLessThan(1200);
expect(scrollback.length - before).toBeLessThan(320);
expect(longestBlankRun(scrollback)).toBeLessThan(50);
} finally {
tui.stop();
@@ -389,23 +368,21 @@ describe("TUI overlays", () => {
it("keeps scrollback bounded under rapid micro-resize oscillation", async () => {
const term = new VirtualTerminal(80, 12);
const tui = new TUI(term);
const component = new MutableContentComponent(buildRows(360));
const component = new MutableContentComponent(buildRows(180));
tui.addChild(component);
try {
tui.start();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
const before = term.getScrollBuffer().length;
for (let i = 0; i < 120; i++) {
for (let i = 0; i < 24; i++) {
term.resize(i % 2 === 0 ? 79 : 80, i % 3 === 0 ? 11 : 12);
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
expect(viewportRowNumbers(term).length).toBeGreaterThan(0);
}
const scrollback = term.getScrollBuffer();
expect(scrollback.length - before).toBeLessThan(1300);
expect(scrollback.length - before).toBeLessThan(320);
expect(longestBlankRun(scrollback)).toBeLessThan(60);
} finally {
tui.stop();
@@ -415,17 +392,15 @@ describe("TUI overlays", () => {
it("avoids scrollback growth on repeated no-op renders with overflowing content", async () => {
const term = new VirtualTerminal(70, 10);
const tui = new TUI(term);
tui.addChild(new MutableContentComponent(buildRows(260)));
tui.addChild(new MutableContentComponent(buildRows(130)));
try {
tui.start();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
const before = term.getScrollBuffer().length;
for (let i = 0; i < 80; i++) {
for (let i = 0; i < 16; i++) {
tui.requestRender();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
}
const scrollback = term.getScrollBuffer();
@@ -437,25 +412,23 @@ describe("TUI overlays", () => {
it("stays stable with direct row-delta movement", async () => {
const term = new VirtualTerminal(50, 10);
const tui = new TUI(term);
const component = new MutableContentComponent(buildRows(260));
const component = new MutableContentComponent(buildRows(150));
tui.addChild(component);
try {
tui.start();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
const before = term.getScrollBuffer().length;
for (let i = 0; i < 60; i++) {
component.setLines(buildRows(220 + (i % 8) * 12));
for (let i = 0; i < 18; i++) {
component.setLines(buildRows(120 + (i % 8) * 6));
term.resize(i % 2 === 0 ? 50 : 49, i % 3 === 0 ? 11 : 10);
tui.requestRender();
await Bun.sleep(0);
await term.waitForRender();
await flushRender(term);
expect(viewportRowNumbers(term).length).toBeGreaterThan(0);
}
const scrollback = term.getScrollBuffer();
expect(scrollback.length - before).toBeLessThan(900);
expect(scrollback.length - before).toBeLessThan(260);
expect(longestBlankRun(scrollback)).toBeLessThan(40);
} finally {
tui.stop();
+19 -2
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { type Component, TUI } from "@oh-my-pi/pi-tui";
import { VirtualTerminal } from "./virtual-terminal";
@@ -25,7 +25,9 @@ function rows(prefix: string, count: number): string[] {
}
async function settle(term: VirtualTerminal): Promise<void> {
await term.waitForRender();
await new Promise<void>(resolve => process.nextTick(resolve));
await Bun.sleep(1);
await term.flush();
}
function visible(term: VirtualTerminal): string[] {
@@ -41,6 +43,21 @@ function countMatches(lines: string[], pattern: RegExp): number {
}
describe("TUI terminal-state regressions", () => {
let monotonicNow = 0;
// Keep TUI's 16ms render throttle deterministic without sleeping a real frame per render.
beforeEach(() => {
monotonicNow = 0;
vi.spyOn(performance, "now").mockImplementation(() => {
monotonicNow += 20;
return monotonicNow;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("cursor + differential stability", () => {
it("keeps stable output across repeated no-op renders", async () => {
const term = new VirtualTerminal(40, 10);