feat: overhauled stream timeout and eval session management

- Replaced external watchdog timers with per-request SDK timeouts for first-event budget across OpenAI, Anthropic, and Azure providers.
- Keyed Python shared kernels by (sessionId, cwd) to prevent cross-directory state bleed.
- Deduplicated concurrent cold-start session acquisition for JS and Python executors.
- Moved `isOpenAIResponsesProgressEvent` to shared module and scoped display output routing per run for interleaved async cells.
This commit is contained in:
can1357
2026-05-26 16:21:34 +02:00
parent 8da054f4cc
commit 796c437dc1
30 changed files with 1600 additions and 539 deletions
+2 -1
View File
@@ -1,7 +1,6 @@
# Changelog
## [Unreleased]
### Added
- Added `isOpenAICompletionsProgressChunk` export to identify real progress chunks vs. keepalives in OpenAI completions streams
@@ -12,6 +11,7 @@
### Changed
- Enabled OpenAI Codex WebSocket streams to apply `streamIdleTimeoutMs` and `streamFirstEventTimeoutMs` from `StreamOptions` per request instead of fixed internal defaults
- Changed stream idle watchdog implementation from `iterateUntilAbort` to `iterateWithIdleTimeout`, which now enforces maximum idle gaps between streamed events and distinguishes between first-event and steady-state timeouts
- Changed Anthropic, OpenAI Responses, OpenAI Completions, Azure OpenAI Responses, and OpenAI Codex Responses providers to use the new idle-timeout iterator with per-provider progress predicates so empty keepalive frames cannot keep a stalled stream alive
- Changed Codex WebSocket transport to reset `lastProgressAt` only on progress events (not keepalives), giving the 300s WS-internal idle ceiling the same liveness semantics as the SSE path
@@ -29,6 +29,7 @@
### Fixed
- Fixed first-item timeout handling so `iterateWithIdleTimeout` no longer keeps first-event timers active after the source throws or the consumer stops before semantic progress
- Fixed silent multi-hour hangs on Codex WebSocket subagent runs when the broker dropped frames between deltas by restoring per-provider stream watchdogs with progress-event filtering
- Fixed z.ai/GLM-via-OpenRouter subagent stalls where no-op keepalive chunks reset the idle watchdog indefinitely by filtering non-progress items before resetting the deadline
+39 -11
View File
@@ -2,7 +2,10 @@ import * as nodeCrypto from "node:crypto";
import * as fs from "node:fs";
import { scheduler } from "node:timers/promises";
import * as tls from "node:tls";
import Anthropic, { type ClientOptions as AnthropicSdkClientOptions } from "@anthropic-ai/sdk";
import Anthropic, {
APIConnectionTimeoutError as AnthropicConnectionTimeoutError,
type ClientOptions as AnthropicSdkClientOptions,
} from "@anthropic-ai/sdk";
import type {
ContentBlockParam,
MessageCreateParamsStreaming,
@@ -1090,6 +1093,8 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
) & { index: number };
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs();
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
const requestTimeoutMs =
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
const blocks = output.content as Block[];
stream.push({ type: "start", partial: output });
// Retry loop for transient errors from the stream.
@@ -1101,19 +1106,42 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
while (true) {
activeAbortTracker = createAbortSourceTracker(options?.signal);
const { requestSignal } = activeAbortTracker;
const anthropicRequest = client.messages.create({ ...params, stream: true }, { signal: requestSignal });
const requestOptions =
requestTimeoutMs === undefined
? { signal: requestSignal }
: { signal: requestSignal, timeout: requestTimeoutMs };
const anthropicRequest = client.messages.create({ ...params, stream: true }, requestOptions);
let streamedReplayUnsafeContent = false;
try {
const {
events: anthropicStream,
response,
requestId,
} = await getAnthropicStreamResponse(
anthropicRequest,
requestSignal,
options?.client ? event => options?.onSseEvent?.(event, model) : undefined,
);
let requestTimeout: NodeJS.Timeout | undefined;
if (requestTimeoutMs !== undefined) {
requestTimeout = setTimeout(
() => activeAbortTracker.abortLocally(firstEventTimeoutAbortError),
requestTimeoutMs,
);
}
let anthropicStream: AsyncIterable<RawMessageStreamEvent>;
let response: Response;
let requestId: string | null;
try {
({
events: anthropicStream,
response,
requestId,
} = await getAnthropicStreamResponse(
anthropicRequest,
requestSignal,
options?.client ? event => options?.onSseEvent?.(event, model) : undefined,
));
} catch (error) {
if (error instanceof AnthropicConnectionTimeoutError && !activeAbortTracker.wasCallerAbort()) {
throw firstEventTimeoutAbortError;
}
throw error;
} finally {
if (requestTimeout !== undefined) clearTimeout(requestTimeout);
}
await notifyProviderResponse(options, response, model, requestId);
let sawEvent = false;
let sawMessageStart = false;
@@ -1,9 +1,10 @@
import { $env, extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
import { AzureOpenAI } from "openai";
import { AzureOpenAI, APIConnectionTimeoutError as OpenAIConnectionTimeoutError } from "openai";
import type {
Tool as OpenAITool,
ResponseCreateParamsStreaming,
ResponseInput,
ResponseStreamEvent,
} from "openai/resources/responses/responses";
import { getEnvApiKey } from "../stream";
import type {
@@ -21,7 +22,6 @@ import { createAbortSourceTracker } from "../utils/abort";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
import {
createWatchdog,
getOpenAIStreamIdleTimeoutMs,
getStreamFirstEventTimeoutMs,
iterateWithIdleTimeout,
@@ -29,11 +29,7 @@ import {
import { sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
import { wrapFetchForSseDebug } from "../utils/sse-debug";
import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice";
import {
isOpenAIResponsesProgressEvent,
normalizeOpenAIResponsesPromptCacheKey,
supportsDeveloperRole,
} from "./openai-responses";
import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses";
import {
appendResponsesToolResultMessages,
applyCommonResponsesSamplingParams,
@@ -41,6 +37,7 @@ import {
convertResponsesAssistantMessage,
convertResponsesInputContent,
createInitialResponsesAssistantMessage,
isOpenAIResponsesProgressEvent,
normalizeResponsesToolCallIdForTransform,
processResponsesStream,
} from "./openai-responses-shared";
@@ -126,6 +123,8 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
options?.onPayload?.(params);
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
const requestTimeoutMs =
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
rawRequestDump = {
provider: model.provider,
api: output.api,
@@ -134,15 +133,29 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
url: `${baseUrl}/responses`,
body: params,
};
const openaiStream = await client.responses.create(params, { signal: requestSignal });
const firstEventWatchdog = createWatchdog(firstEventTimeoutMs, () =>
abortTracker.abortLocally(firstEventTimeoutAbortError),
);
let requestTimeout: NodeJS.Timeout | undefined;
if (requestTimeoutMs !== undefined) {
requestTimeout = setTimeout(() => abortTracker.abortLocally(firstEventTimeoutAbortError), requestTimeoutMs);
}
let openaiStream: AsyncIterable<ResponseStreamEvent>;
try {
const requestOptions =
requestTimeoutMs === undefined
? { signal: requestSignal }
: { signal: requestSignal, timeout: requestTimeoutMs };
openaiStream = await client.responses.create(params, requestOptions);
} catch (error) {
if (error instanceof OpenAIConnectionTimeoutError && !abortTracker.wasCallerAbort()) {
throw firstEventTimeoutAbortError;
}
throw error;
} finally {
if (requestTimeout !== undefined) clearTimeout(requestTimeout);
}
stream.push({ type: "start", partial: output });
await processResponsesStream(
iterateWithIdleTimeout(openaiStream, {
watchdog: firstEventWatchdog,
idleTimeoutMs,
firstItemTimeoutMs: firstEventTimeoutMs,
firstItemErrorMessage: AZURE_OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
@@ -72,6 +72,7 @@ import {
convertResponsesInputContent,
encodeResponsesToolCallId,
encodeTextSignatureV1,
isOpenAIResponsesProgressEvent,
mapOpenAIResponsesStopReason,
populateResponsesUsageFromResponse,
} from "./openai-responses-shared";
@@ -122,31 +123,13 @@ const X_REASONING_INCLUDED_HEADER = "x-reasoning-included";
const CODEX_WEBSOCKET_FATAL_PATTERNS = ["websocket error:", "websocket closed before open", "connection timeout"];
/** Max total time to spend retrying 429s with server-provided delays (5 minutes). */
const CODEX_RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000;
const CODEX_PROGRESS_EVENT_TYPES = new Set([
"response.created",
"response.output_item.added",
"response.reasoning_summary_part.added",
"response.reasoning_summary_text.delta",
"response.reasoning_summary_part.done",
"response.content_part.added",
"response.output_text.delta",
"response.refusal.delta",
"response.function_call_arguments.delta",
"response.function_call_arguments.done",
"response.custom_tool_call_input.delta",
"response.custom_tool_call_input.done",
"response.output_item.done",
"response.completed",
"response.done",
"response.incomplete",
"response.failed",
"error",
]);
const CODEX_ADDITIONAL_PROGRESS_EVENT_TYPES = new Set(["response.done", "response.incomplete"]);
function isCodexStreamProgressEvent(event: unknown): boolean {
if (isOpenAIResponsesProgressEvent(event)) return true;
if (!event || typeof event !== "object") return false;
const type = (event as { type?: unknown }).type;
return typeof type === "string" && CODEX_PROGRESS_EVENT_TYPES.has(type);
return typeof type === "string" && CODEX_ADDITIONAL_PROGRESS_EVENT_TYPES.has(type);
}
type CodexTransport = "sse" | "websocket";
@@ -200,6 +183,8 @@ interface CodexRequestSetup {
requestSignal: AbortSignal;
wrapCodexSseStream: (source: AsyncGenerator<Record<string, unknown>>) => AsyncGenerator<Record<string, unknown>>;
requestAbortController: AbortController;
websocketIdleTimeoutMs: number | undefined;
websocketFirstEventTimeoutMs: number | undefined;
}
interface CodexStreamRuntime {
@@ -558,7 +543,9 @@ function createRequestSetup(options: OpenAICodexResponsesOptions | undefined): C
? AbortSignal.any([options.signal, requestAbortController.signal])
: requestAbortController.signal;
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
const websocketIdleTimeoutMs = options?.streamIdleTimeoutMs ?? getCodexWebSocketIdleTimeoutMs();
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
const websocketFirstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getCodexWebSocketFirstEventTimeoutMs();
const wrapCodexSseStream = (
source: AsyncGenerator<Record<string, unknown>>,
): AsyncGenerator<Record<string, unknown>> =>
@@ -572,7 +559,13 @@ function createRequestSetup(options: OpenAICodexResponsesOptions | undefined): C
abortSignal: options?.signal,
isProgressItem: isCodexStreamProgressEvent,
});
return { requestAbortController, requestSignal, wrapCodexSseStream };
return {
requestAbortController,
requestSignal,
wrapCodexSseStream,
websocketIdleTimeoutMs,
websocketFirstEventTimeoutMs,
};
}
async function buildCodexRequestContext(
@@ -784,6 +777,10 @@ async function openCodexWebSocketTransport(
websocketHeaders,
websocketRequest,
websocketState,
{
idleTimeoutMs: requestSetup.websocketIdleTimeoutMs,
firstEventTimeoutMs: requestSetup.websocketFirstEventTimeoutMs,
},
requestSetup.requestSignal,
onSseEvent,
);
@@ -1970,17 +1967,18 @@ function headersToRecord(headers: Headers): Record<string, string> {
return result;
}
interface CodexWebSocketRequestTimeouts {
idleTimeoutMs?: number;
firstEventTimeoutMs?: number;
}
interface CodexWebSocketConnectionOptions {
idleTimeoutMs: number;
firstEventTimeoutMs: number;
onHandshakeHeaders?: (headers: Headers) => void;
}
class CodexWebSocketConnection {
#url: string;
#headers: Record<string, string>;
#idleTimeoutMs: number;
#firstEventTimeoutMs: number;
#onHandshakeHeaders?: (headers: Headers) => void;
#socket: Bun.WebSocket | null = null;
#queue: Array<Record<string, unknown> | Error | null> = [];
@@ -1992,8 +1990,6 @@ class CodexWebSocketConnection {
constructor(url: string, headers: Record<string, string>, options: CodexWebSocketConnectionOptions) {
this.#url = url;
this.#headers = headers;
this.#idleTimeoutMs = options.idleTimeoutMs;
this.#firstEventTimeoutMs = options.firstEventTimeoutMs;
this.#onHandshakeHeaders = options.onHandshakeHeaders;
}
@@ -2124,6 +2120,7 @@ class CodexWebSocketConnection {
async *streamRequest(
request: Record<string, unknown>,
timeouts: CodexWebSocketRequestTimeouts,
signal?: AbortSignal,
onSseEvent?: (event: RawSseEvent) => void,
): AsyncGenerator<Record<string, unknown>> {
@@ -2152,19 +2149,24 @@ class CodexWebSocketConnection {
notifyCodexWebSocketOutbound(onSseEvent, request, requestPayload);
this.#socket.send(requestPayload);
let sawFirstEvent = false;
const { idleTimeoutMs, firstEventTimeoutMs } = timeouts;
let lastProgressAt = Date.now();
while (true) {
let timeoutMs: number;
let timeoutMs: number | undefined;
let timeoutReason: string;
if (sawFirstEvent) {
timeoutMs = this.#idleTimeoutMs - (Date.now() - lastProgressAt);
timeoutReason = "idle timeout waiting for websocket";
if (timeoutMs <= 0) {
throw createCodexWebSocketTransportError(timeoutReason);
if (idleTimeoutMs !== undefined && idleTimeoutMs > 0) {
timeoutMs = idleTimeoutMs - (Date.now() - lastProgressAt);
if (timeoutMs <= 0) {
throw createCodexWebSocketTransportError(timeoutReason);
}
}
} else {
timeoutMs = this.#firstEventTimeoutMs;
timeoutReason = "timeout waiting for first websocket event";
if (firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0) {
timeoutMs = firstEventTimeoutMs;
}
}
const next = await this.#nextMessage(timeoutMs, timeoutReason);
if (next instanceof Error) {
@@ -2211,13 +2213,16 @@ class CodexWebSocketConnection {
if (waiter) waiter();
}
async #nextMessage(timeoutMs: number, timeoutReason: string): Promise<Record<string, unknown> | Error | null> {
async #nextMessage(
timeoutMs: number | undefined,
timeoutReason: string,
): Promise<Record<string, unknown> | Error | null> {
while (this.#queue.length === 0) {
const { promise, resolve } = Promise.withResolvers<void>();
this.#waiters.push(resolve);
let timedOut = false;
let timeout: NodeJS.Timeout | undefined;
if (timeoutMs > 0) {
if (timeoutMs !== undefined && timeoutMs > 0) {
timeout = setTimeout(() => {
timedOut = true;
const waiterIndex = this.#waiters.indexOf(resolve);
@@ -2256,8 +2261,6 @@ async function getOrCreateCodexWebSocketConnection(
resetCodexWebSocketAppendState(state);
logger.time("codexWs:newSocket");
state.connection = new CodexWebSocketConnection(url, headerRecord, {
idleTimeoutMs: getCodexWebSocketIdleTimeoutMs(),
firstEventTimeoutMs: getCodexWebSocketFirstEventTimeoutMs(),
onHandshakeHeaders: handshakeHeaders => {
updateCodexSessionMetadataFromHeaders(state, handshakeHeaders);
},
@@ -2324,11 +2327,12 @@ async function openCodexWebSocketEventStream(
headers: Headers,
request: Record<string, unknown>,
state: CodexWebSocketSessionState,
timeouts: CodexWebSocketRequestTimeouts,
signal?: AbortSignal,
onSseEvent?: (event: RawSseEvent) => void,
): Promise<AsyncGenerator<Record<string, unknown>>> {
const connection = await getOrCreateCodexWebSocketConnection(state, url, headers, signal);
return connection.streamRequest(request, signal, onSseEvent);
return connection.streamRequest(request, timeouts, signal, onSseEvent);
}
function createCodexHeaders(
+30 -35
View File
@@ -1,5 +1,5 @@
import { $env, extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
import OpenAI from "openai";
import OpenAI, { APIConnectionTimeoutError as OpenAIConnectionTimeoutError } from "openai";
import type {
ChatCompletionAssistantMessageParam,
ChatCompletionChunk,
@@ -46,7 +46,6 @@ import {
rewriteCopilotError,
} from "../utils/http-inspector";
import {
createWatchdog,
getOpenAIStreamIdleTimeoutMs,
getStreamFirstEventTimeoutMs,
iterateWithIdleTimeout,
@@ -394,7 +393,10 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
try {
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
const idleTimeoutMs = getOpenAIStreamIdleTimeoutMs();
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
const requestTimeoutMs =
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
const {
client,
copilotPremiumRequests,
@@ -410,7 +412,6 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
options?.initiatorOverride,
options?.onSseEvent,
options?.fetch,
options?.streamFirstEventTimeoutMs,
);
const premiumRequestsTotal = copilotPremiumRequests;
getCapturedErrorResponse = captureErrorResponse;
@@ -443,11 +444,31 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
headers: requestHeaders,
body: params,
};
const { data, response, request_id } = await client.chat.completions
.create(params, { signal: requestSignal })
.withResponse();
await notifyProviderResponse(options, response, model, request_id);
return data;
const requestOptions =
requestTimeoutMs === undefined
? { signal: requestSignal }
: { signal: requestSignal, timeout: requestTimeoutMs };
let requestTimeout: NodeJS.Timeout | undefined;
if (requestTimeoutMs !== undefined) {
requestTimeout = setTimeout(
() => abortTracker.abortLocally(firstEventTimeoutAbortError),
requestTimeoutMs,
);
}
try {
const { data, response, request_id } = await client.chat.completions
.create(params, requestOptions)
.withResponse();
await notifyProviderResponse(options, response, model, request_id);
return data;
} catch (error) {
if (error instanceof OpenAIConnectionTimeoutError && !abortTracker.wasCallerAbort()) {
throw firstEventTimeoutAbortError;
}
throw error;
} finally {
if (requestTimeout !== undefined) clearTimeout(requestTimeout);
}
};
let openaiStream: AsyncIterable<ChatCompletionChunk>;
try {
@@ -476,10 +497,6 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
openaiStream = await createCompletionsStream("none");
}
}
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
const firstEventWatchdog = createWatchdog(firstEventTimeoutMs, () =>
abortTracker.abortLocally(firstEventTimeoutAbortError),
);
if (premiumRequestsTotal !== undefined) {
output.usage.premiumRequests = premiumRequestsTotal;
}
@@ -660,7 +677,6 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
};
for await (const chunk of iterateWithIdleTimeout(openaiStream, {
watchdog: firstEventWatchdog,
idleTimeoutMs,
firstItemTimeoutMs: firstEventTimeoutMs,
firstItemErrorMessage: OPENAI_COMPLETIONS_FIRST_EVENT_TIMEOUT_MESSAGE,
@@ -888,7 +904,6 @@ async function createClient(
initiatorOverride?: MessageAttribution,
onSseEvent?: OpenAICompletionsOptions["onSseEvent"],
fetchOverride?: FetchImpl,
streamFirstEventTimeoutOverride?: number,
): Promise<{
client: OpenAI;
copilotPremiumRequests: number | undefined;
@@ -987,25 +1002,6 @@ async function createClient(
baseFetch.preconnect ? { preconnect: baseFetch.preconnect } : {},
);
const debugFetch = onSseEvent ? wrapFetchForSseDebug(wrappedFetch, event => onSseEvent(event, model)) : wrappedFetch;
// Bound HTTP request timeout to roughly the first-event watchdog window.
// The OpenAI SDK's default is 10 minutes per attempt × `maxRetries`, which
// turns a stalled-before-headers fetch into a multi-minute hang invisible
// to the agent loop (the iterator watchdog only arms AFTER `create()` returns).
// Using the first-event timeout keeps both layers aligned: the SDK gives up
// before the agent watchdog would have, surfacing a real error to the catch
// in the IIFE.
// A caller may raise `StreamOptions.streamFirstEventTimeoutMs` for a slow-
// before-headers provider; respect it so the SDK doesn't give up before the
// wrapping watchdog arms. An explicit `0` disables the first-event watchdog,
// and the SDK treats `timeout: 0` as an immediate timeout, so do not pass a
// request timeout in that case.
const envSdkTimeoutMs = getStreamFirstEventTimeoutMs(getOpenAIStreamIdleTimeoutMs());
const sdkTimeoutMs =
streamFirstEventTimeoutOverride === 0
? undefined
: streamFirstEventTimeoutOverride !== undefined
? Math.max(envSdkTimeoutMs ?? 0, streamFirstEventTimeoutOverride)
: envSdkTimeoutMs;
return {
client: new OpenAI({
apiKey,
@@ -1015,7 +1011,6 @@ async function createClient(
defaultHeaders: headers,
defaultQuery: azureDefaultQuery,
fetch: debugFetch,
...(sdkTimeoutMs !== undefined ? { timeout: sdkTimeoutMs } : {}),
}),
copilotPremiumRequests,
baseUrl,
@@ -32,6 +32,31 @@ import { normalizeResponsesToolCallId } from "../utils";
import type { AssistantMessageEventStream } from "../utils/event-stream";
import { parseStreamingJson } from "../utils/json-parse";
import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER, partitionVisionContent } from "./vision-guard";
export const OPENAI_RESPONSES_PROGRESS_EVENT_TYPES: ReadonlySet<string> = new Set([
"response.created",
"response.output_item.added",
"response.reasoning_summary_part.added",
"response.reasoning_summary_text.delta",
"response.reasoning_summary_part.done",
"response.reasoning_text.delta",
"response.content_part.added",
"response.output_text.delta",
"response.refusal.delta",
"response.function_call_arguments.delta",
"response.function_call_arguments.done",
"response.custom_tool_call_input.delta",
"response.custom_tool_call_input.done",
"response.output_item.done",
"response.completed",
"response.failed",
"error",
]);
export function isOpenAIResponsesProgressEvent(event: unknown): boolean {
if (!event || typeof event !== "object") return false;
const type = (event as { type?: unknown }).type;
return typeof type === "string" && OPENAI_RESPONSES_PROGRESS_EVENT_TYPES.has(type);
}
export function encodeTextSignatureV1(id: string, phase?: TextSignatureV1["phase"]): string {
const payload: TextSignatureV1 = { v: 1, id };
+29 -37
View File
@@ -1,5 +1,5 @@
import { $env, extractHttpStatusFromError, structuredCloneJSON } from "@oh-my-pi/pi-utils";
import OpenAI from "openai";
import OpenAI, { APIConnectionTimeoutError as OpenAIConnectionTimeoutError } from "openai";
import type {
Tool as OpenAITool,
ResponseCreateParamsStreaming,
@@ -33,7 +33,6 @@ import { createAbortSourceTracker } from "../utils/abort";
import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump, rewriteCopilotError } from "../utils/http-inspector";
import {
createWatchdog,
getOpenAIStreamIdleTimeoutMs,
getStreamFirstEventTimeoutMs,
iterateWithIdleTimeout,
@@ -59,6 +58,7 @@ import {
convertResponsesAssistantMessage,
convertResponsesInputContent,
createInitialResponsesAssistantMessage,
isOpenAIResponsesProgressEvent,
normalizeResponsesToolCallIdForTransform,
processResponsesStream,
repairOrphanResponsesToolOutputs,
@@ -103,32 +103,6 @@ const OPENAI_RESPONSES_PROVIDER_SESSION_STATE_PREFIX = "openai-responses:";
const OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE =
"OpenAI responses stream timed out while waiting for the first event";
const OPENAI_RESPONSES_PROGRESS_EVENT_TYPES = new Set([
"response.created",
"response.output_item.added",
"response.reasoning_summary_part.added",
"response.reasoning_summary_text.delta",
"response.reasoning_summary_part.done",
"response.reasoning_text.delta",
"response.content_part.added",
"response.output_text.delta",
"response.refusal.delta",
"response.function_call_arguments.delta",
"response.function_call_arguments.done",
"response.custom_tool_call_input.delta",
"response.custom_tool_call_input.done",
"response.output_item.done",
"response.completed",
"response.failed",
"error",
]);
export function isOpenAIResponsesProgressEvent(event: unknown): boolean {
if (!event || typeof event !== "object") return false;
const type = (event as { type?: unknown }).type;
return typeof type === "string" && OPENAI_RESPONSES_PROGRESS_EVENT_TYPES.has(type);
}
interface OpenAIResponsesProviderSessionState extends ProviderSessionState {
nativeHistoryReplayWarmed: boolean;
}
@@ -221,6 +195,8 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
const { params } = buildParams(model, context, options, providerSessionState, baseUrl);
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
const requestTimeoutMs =
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
options?.onPayload?.(params);
rawRequestDump = {
provider: model.provider,
@@ -232,18 +208,35 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
};
const openaiStream = await callWithCopilotModelRetry(
async () => {
const { data, response, request_id } = await client.responses
.create(params, { signal: requestSignal })
.withResponse();
await notifyProviderResponse(options, response, model, request_id);
return data;
const requestOptions =
requestTimeoutMs === undefined
? { signal: requestSignal }
: { signal: requestSignal, timeout: requestTimeoutMs };
let requestTimeout: NodeJS.Timeout | undefined;
if (requestTimeoutMs !== undefined) {
requestTimeout = setTimeout(
() => abortTracker.abortLocally(firstEventTimeoutAbortError),
requestTimeoutMs,
);
}
try {
const { data, response, request_id } = await client.responses
.create(params, requestOptions)
.withResponse();
await notifyProviderResponse(options, response, model, request_id);
return data;
} catch (error) {
if (error instanceof OpenAIConnectionTimeoutError && !abortTracker.wasCallerAbort()) {
throw firstEventTimeoutAbortError;
}
throw error;
} finally {
if (requestTimeout !== undefined) clearTimeout(requestTimeout);
}
},
{ provider: model.provider, signal: requestSignal },
);
if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
const firstEventWatchdog = createWatchdog(firstEventTimeoutMs, () =>
abortTracker.abortLocally(firstEventTimeoutAbortError),
);
stream.push({ type: "start", partial: output });
const nativeOutputItems: Array<Record<string, unknown>> = [];
@@ -252,7 +245,6 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
idleTimeoutMs,
firstItemTimeoutMs: firstEventTimeoutMs,
firstItemErrorMessage: OPENAI_RESPONSES_FIRST_EVENT_TIMEOUT_MESSAGE,
watchdog: firstEventWatchdog,
errorMessage: "OpenAI responses stream stalled while waiting for the next event",
onFirstItemTimeout: () => abortTracker.abortLocally(firstEventTimeoutAbortError),
onIdle: () => requestAbortController.abort(),
+7 -6
View File
@@ -352,12 +352,13 @@ export interface StreamOptions {
*/
onSseEvent?: (event: RawSseEvent, model?: Model<Api>) => void;
/**
* Optional override for the first-event watchdog in milliseconds. When the
* underlying transport exposes a per-request timeout (OpenAI/Anthropic SDKs),
* this value is also applied to the HTTP layer so the SDK gives up before the
* wrapping iterator watchdog would have. Set to `0` to disable both watchdogs
* for this request. Falls back to `PI_STREAM_FIRST_EVENT_TIMEOUT_MS` and then
* to a 100s default.
* Optional override for the first-event watchdog in milliseconds. Built-in
* providers apply this budget twice when they can: once to the underlying
* SDK/request while waiting for the HTTP stream object to exist, then again
* in the iterator while waiting for the first semantic stream event. Set to
* `0` to disable both layers for this request. After the first semantic
* event arrives, `streamIdleTimeoutMs` governs inter-event stalls. Falls
* back to `PI_STREAM_FIRST_EVENT_TIMEOUT_MS` and then to a 100s default.
*
* Iterator-level honored by: every built-in provider (via the lazy-stream
* forwarder in `register-builtins`). SDK-request honored by:
+89 -116
View File
@@ -57,24 +57,7 @@ export function getStreamFirstEventTimeoutMs(
return normalizeIdleTimeoutMs($env.PI_STREAM_FIRST_EVENT_TIMEOUT_MS, fallback);
}
export type Watchdog = NodeJS.Timeout | undefined;
const dummyWatchdog = setTimeout(() => {}, 1);
clearTimeout(dummyWatchdog);
/**
* Starts a watchdog that aborts a request if no first stream event arrives in time.
* Call `markFirstEventReceived()` as soon as the first event is observed.
*/
export function createWatchdog(timeoutMs: number | undefined, onTimeout: () => void): Watchdog {
if (timeoutMs !== undefined && timeoutMs > 0) {
return setTimeout(onTimeout, timeoutMs);
}
return undefined;
}
export interface IdleTimeoutIteratorOptions {
watchdog?: Watchdog;
idleTimeoutMs?: number;
firstItemTimeoutMs?: number;
errorMessage: string;
@@ -108,8 +91,7 @@ export async function* iterateWithIdleTimeout<T>(
iterable: AsyncIterable<T>,
options: IdleTimeoutIteratorOptions,
): AsyncGenerator<T> {
let watchdog = options.watchdog;
const firstItemTimeoutMs = options.firstItemTimeoutMs ?? (watchdog ? undefined : options.idleTimeoutMs);
const firstItemTimeoutMs = options.firstItemTimeoutMs ?? options.idleTimeoutMs;
const firstItemDeadlineMs =
firstItemTimeoutMs !== undefined && firstItemTimeoutMs > 0 ? Date.now() + firstItemTimeoutMs : undefined;
const abortSignal = options.abortSignal;
@@ -135,8 +117,6 @@ export async function* iterateWithIdleTimeout<T>(
let awaitingFirstItem = true;
const markFirstItemReceived = () => {
watchdog && clearTimeout(watchdog);
watchdog = undefined;
awaitingFirstItem = false;
};
const isProgressItem = (item: T): boolean => {
@@ -153,107 +133,100 @@ export async function* iterateWithIdleTimeout<T>(
(firstItemTimeoutMs === undefined || firstItemTimeoutMs <= 0) &&
(options.idleTimeoutMs === undefined || options.idleTimeoutMs <= 0);
try {
while (true) {
let activeTimeoutMs: number | undefined;
if (awaitingFirstItem) {
if (firstItemDeadlineMs !== undefined) {
activeTimeoutMs = firstItemDeadlineMs - Date.now();
if (activeTimeoutMs <= 0) {
options.onFirstItemTimeout?.();
closeIterator();
throw new Error(options.firstItemErrorMessage ?? options.errorMessage);
}
}
} else if (options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0) {
activeTimeoutMs = options.idleTimeoutMs - (Date.now() - lastProgressAt);
while (true) {
let activeTimeoutMs: number | undefined;
if (awaitingFirstItem) {
if (firstItemDeadlineMs !== undefined) {
activeTimeoutMs = firstItemDeadlineMs - Date.now();
if (activeTimeoutMs <= 0) {
options.onIdle?.();
options.onFirstItemTimeout?.();
closeIterator();
throw new Error(options.errorMessage);
throw new Error(options.firstItemErrorMessage ?? options.errorMessage);
}
}
const nextResultPromise = withRacy(iterator.next());
const racers: Array<
Promise<
| { kind: "next"; result: IteratorResult<T> }
| { kind: "error"; error: unknown }
| { kind: "timeout" }
| { kind: "abort" }
>
> = [nextResultPromise];
let timer: NodeJS.Timeout | undefined;
let resolveTimeout: ((value: { kind: "timeout" }) => void) | undefined;
const enforceTimeout = !noTimeoutEnforced && activeTimeoutMs !== undefined && activeTimeoutMs > 0;
if (enforceTimeout) {
const { promise, resolve } = Promise.withResolvers<{ kind: "timeout" }>();
resolveTimeout = resolve;
timer = setTimeout(() => resolve({ kind: "timeout" }), activeTimeoutMs);
racers.push(promise);
}
let abortListener: (() => void) | undefined;
let resolveAbort: ((value: { kind: "abort" }) => void) | undefined;
if (abortSignal) {
const { promise, resolve } = Promise.withResolvers<{ kind: "abort" }>();
resolveAbort = resolve;
abortListener = () => resolve({ kind: "abort" });
abortSignal.addEventListener("abort", abortListener, { once: true });
racers.push(promise);
}
try {
const outcome = await Promise.race(racers);
if (outcome.kind === "abort") {
closeIterator();
throw abortReason(abortSignal!);
}
if (outcome.kind === "timeout") {
if (!awaitingFirstItem) {
options.onIdle?.();
} else {
options.onFirstItemTimeout?.();
}
closeIterator();
throw new Error(
!awaitingFirstItem ? options.errorMessage : (options.firstItemErrorMessage ?? options.errorMessage),
);
}
if (outcome.kind === "error") {
throw outcome.error;
}
if (outcome.result.done) {
markFirstItemReceived();
return;
}
const item = outcome.result.value;
// Non-progress items (e.g. provider keepalives, synthetic `start` events that
// arrive before the model has produced any tokens) MUST NOT flip us out of
// `awaitingFirstItem`. Otherwise the next iteration switches from the (longer)
// first-item watchdog to the (shorter) idle watchdog while we're still waiting
// on the model's first real output.
if (isProgressItem(item)) {
markFirstItemReceived();
lastProgressAt = Date.now();
}
yield item;
} finally {
if (timer !== undefined) clearTimeout(timer);
// Resolve dangling promises so the racers don't leak (Promise.race is one-shot).
resolveTimeout?.({ kind: "timeout" });
if (abortListener && abortSignal) {
abortSignal.removeEventListener("abort", abortListener);
}
resolveAbort?.({ kind: "abort" });
} else if (options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0) {
activeTimeoutMs = options.idleTimeoutMs - (Date.now() - lastProgressAt);
if (activeTimeoutMs <= 0) {
options.onIdle?.();
closeIterator();
throw new Error(options.errorMessage);
}
}
} finally {
if (watchdog) {
clearTimeout(watchdog);
watchdog = undefined;
const nextResultPromise = withRacy(iterator.next());
const racers: Array<
Promise<
| { kind: "next"; result: IteratorResult<T> }
| { kind: "error"; error: unknown }
| { kind: "timeout" }
| { kind: "abort" }
>
> = [nextResultPromise];
let timer: NodeJS.Timeout | undefined;
let resolveTimeout: ((value: { kind: "timeout" }) => void) | undefined;
const enforceTimeout = !noTimeoutEnforced && activeTimeoutMs !== undefined && activeTimeoutMs > 0;
if (enforceTimeout) {
const { promise, resolve } = Promise.withResolvers<{ kind: "timeout" }>();
resolveTimeout = resolve;
timer = setTimeout(() => resolve({ kind: "timeout" }), activeTimeoutMs);
racers.push(promise);
}
let abortListener: (() => void) | undefined;
let resolveAbort: ((value: { kind: "abort" }) => void) | undefined;
if (abortSignal) {
const { promise, resolve } = Promise.withResolvers<{ kind: "abort" }>();
resolveAbort = resolve;
abortListener = () => resolve({ kind: "abort" });
abortSignal.addEventListener("abort", abortListener, { once: true });
racers.push(promise);
}
try {
const outcome = await Promise.race(racers);
if (outcome.kind === "abort") {
closeIterator();
throw abortReason(abortSignal!);
}
if (outcome.kind === "timeout") {
if (!awaitingFirstItem) {
options.onIdle?.();
} else {
options.onFirstItemTimeout?.();
}
closeIterator();
throw new Error(
!awaitingFirstItem ? options.errorMessage : (options.firstItemErrorMessage ?? options.errorMessage),
);
}
if (outcome.kind === "error") {
throw outcome.error;
}
if (outcome.result.done) {
markFirstItemReceived();
return;
}
const item = outcome.result.value;
// Non-progress items (e.g. provider keepalives, synthetic `start` events that
// arrive before the model has produced any tokens) MUST NOT flip us out of
// `awaitingFirstItem`. Otherwise the next iteration switches from the (longer)
// first-item watchdog to the (shorter) idle watchdog while we're still waiting
// on the model's first real output.
if (isProgressItem(item)) {
markFirstItemReceived();
lastProgressAt = Date.now();
}
yield item;
} finally {
if (timer !== undefined) clearTimeout(timer);
// Resolve dangling promises so the racers don't leak (Promise.race is one-shot).
resolveTimeout?.({ kind: "timeout" });
if (abortListener && abortSignal) {
abortSignal.removeEventListener("abort", abortListener);
}
resolveAbort?.({ kind: "abort" });
}
}
}
@@ -4,6 +4,8 @@ import { streamAnthropic } from "../src/providers/anthropic";
import type { Context, Model } from "../src/types";
import { waitForDelayOrAbort } from "./helpers";
const originalFetch = global.fetch;
const model: Model<"anthropic-messages"> = {
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
@@ -133,14 +135,17 @@ function createAnthropicMockStream({
}
afterEach(() => {
// No shared globals to restore; keep hook so the suite stays explicit.
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe("anthropic first-event timeout retries", () => {
it("retries when the provider never sends the first stream event", async () => {
let attempt = 0;
const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal }) => {
const requestTimeouts: Array<number | undefined> = [];
const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal; timeout?: number }) => {
attempt += 1;
requestTimeouts.push(requestOptions?.timeout);
return createAnthropicMockStream({
signal: requestOptions?.signal,
events: attempt === 1 ? undefined : createSuccessfulAnthropicEvents("retry recovered"),
@@ -157,13 +162,16 @@ describe("anthropic first-event timeout retries", () => {
expect(attempt).toBe(2);
expect(providerRetryWait).toHaveBeenCalledWith(2000, undefined);
expect(requestTimeouts).toEqual([1, 1]);
expect(result.stopReason).toBe("stop");
expect(result.content).toEqual([{ type: "text", text: "retry recovered" }]);
expect(result.responseId).toBe("msg_retry_success");
});
it("does not arm the Anthropic first-event watchdog before the stream connects", async () => {
const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal }) => {
let seenRequestTimeout: number | undefined;
const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal; timeout?: number }) => {
seenRequestTimeout = requestOptions?.timeout;
return createAnthropicMockStream({
signal: requestOptions?.signal,
connectDelayMs: 2,
@@ -174,13 +182,41 @@ describe("anthropic first-event timeout retries", () => {
const result = await streamAnthropic(model, context, {
client,
streamFirstEventTimeoutMs: 1,
streamFirstEventTimeoutMs: 20,
}).result();
expect(result.stopReason).toBe("stop");
expect(seenRequestTimeout).toBe(20);
expect(result.content).toEqual([{ type: "text", text: "delayed connect" }]);
});
it("times out before the Anthropic stream connects and forwards the budget to the SDK request", async () => {
let attempt = 0;
const requestTimeouts: Array<number | undefined> = [];
const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal; timeout?: number }) => {
attempt += 1;
requestTimeouts.push(requestOptions?.timeout);
return createAnthropicMockStream({
signal: requestOptions?.signal,
connectDelayMs: 20,
events: createSuccessfulAnthropicEvents("too late"),
}) 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: 1,
providerRetryWait,
}).result();
expect(attempt).toBe(4);
expect(providerRetryWait).toHaveBeenCalledTimes(3);
expect(requestTimeouts).toEqual([1, 1, 1, 1]);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("Anthropic stream timed out while waiting for the first event");
});
it("keeps caller aborts as aborted instead of retrying them as first-event timeouts", async () => {
let attempt = 0;
const create = ((_body: unknown, requestOptions?: { signal?: AbortSignal }) => {
+23 -7
View File
@@ -1886,13 +1886,12 @@ describe("openai-codex streaming", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("waits for caller abort when websocket status events do not make semantic progress", async () => {
it("surfaces a websocket idle-timeout error when status events never make semantic progress", async () => {
const tempDir = TempDir.createSync("@pi-codex-stream-");
setAgentDir(tempDir.path());
const token = createCodexTestToken();
const sse = createCompletedCodexSse("Hello fallback");
const fetchMock = vi.fn(async () => {
return new Response(sse, { headers: { "content-type": "text/event-stream" } });
throw new Error("SSE fallback should not run once the websocket stream becomes replay-unsafe");
});
global.fetch = fetchMock as unknown as typeof fetch;
@@ -1916,6 +1915,16 @@ describe("openai-codex streaming", () => {
arguments: "",
},
});
this.sendJson({
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_ws_stalled",
call_id: "call_ws_stalled",
name: "todo_write",
arguments: "{}",
},
});
interval = setInterval(() => {
this.sendJson({
type: "response.in_progress",
@@ -1933,18 +1942,25 @@ describe("openai-codex streaming", () => {
const model = createCodexTestModel("https://chatgpt.com/backend-api");
const providerSessionState = new Map<string, ProviderSessionState>();
const controller = new AbortController();
setTimeout(() => controller.abort(), 30);
const result = await streamOpenAICodexResponses(model, createCodexTestContext(), {
apiKey: token,
sessionId: "ws-no-progress-session",
providerSessionState,
signal: controller.signal,
streamIdleTimeoutMs: 5,
}).result();
expect(sendCount).toBe(1);
expect(result.stopReason).toBe("aborted");
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("idle timeout waiting for websocket");
expect(result.content).toEqual([
expect.objectContaining({
type: "toolCall",
id: "call_ws_stalled|fc_ws_stalled",
name: "todo_write",
arguments: {},
partialJson: "",
}),
]);
expect(fetchMock).not.toHaveBeenCalled();
});
@@ -1,6 +1,84 @@
import { describe, expect, it } from "bun:test";
import { isOpenAICompletionsProgressChunk } from "../src/providers/openai-completions";
import { afterEach, describe, expect, it } from "bun:test";
import { getBundledModel } from "../src/models";
import { isOpenAICompletionsProgressChunk, streamOpenAICompletions } from "../src/providers/openai-completions";
import type { Context, Model } from "../src/types";
const originalFetch = global.fetch;
const openAICompletionsModel = {
...(getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">),
api: "openai-completions",
} satisfies Model<"openai-completions">;
function baseContext(): Context {
return {
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
};
}
function getRequestSignal(input: string | URL | Request, init: RequestInit | undefined): AbortSignal | undefined {
if (init?.signal) {
return init.signal;
}
if (input instanceof Request) {
return input.signal;
}
return undefined;
}
function createKeepaliveOnlyCompletionsResponse(modelId: string, signal: AbortSignal | undefined): Response {
const encoder = new TextEncoder();
let interval: NodeJS.Timeout | undefined;
let abortListener: (() => void) | undefined;
const encode = (event: unknown): Uint8Array => encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
encode({
id: "chatcmpl-stalled",
object: "chat.completion.chunk",
created: 0,
model: modelId,
choices: [{ index: 0, delta: { content: "Hello" } }],
}),
);
interval = setInterval(() => {
controller.enqueue(
encode({
id: "chatcmpl-stalled",
object: "chat.completion.chunk",
created: 0,
model: modelId,
choices: [{ index: 0, delta: { role: "assistant" } }],
}),
);
}, 2);
abortListener = () => {
if (interval) clearInterval(interval);
if (abortListener) signal?.removeEventListener("abort", abortListener);
const reason = signal?.reason;
controller.error(reason instanceof Error ? reason : new Error("request aborted"));
};
if (signal?.aborted) {
queueMicrotask(() => abortListener?.());
} else {
signal?.addEventListener("abort", abortListener, { once: true });
}
},
cancel() {
if (interval) clearInterval(interval);
if (abortListener) signal?.removeEventListener("abort", abortListener);
},
});
return new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
afterEach(() => {
global.fetch = originalFetch;
});
/**
* Contract: `isOpenAICompletionsProgressChunk` decides whether a streamed chunk
* resets the idle-watchdog deadline in `iterateWithIdleTimeout`. A false
@@ -164,3 +242,21 @@ describe("isOpenAICompletionsProgressChunk", () => {
});
});
});
describe("provider integration", () => {
it("times out a completions stream whose keepalives never make progress", async () => {
global.fetch = ((input: string | URL | Request, init?: RequestInit) =>
Promise.resolve(
createKeepaliveOnlyCompletionsResponse(openAICompletionsModel.id, getRequestSignal(input, init)),
)) as typeof fetch;
const result = await streamOpenAICompletions(openAICompletionsModel, baseContext(), {
apiKey: "test-key",
streamFirstEventTimeoutMs: 1_000,
streamIdleTimeoutMs: 20,
}).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe("OpenAI completions stream stalled while waiting for the next event");
expect(result.content).toEqual([{ type: "text", text: "Hello" }]);
});
});
@@ -43,6 +43,15 @@ function getRequestSignal(input: string | URL | Request, init: RequestInit | und
return undefined;
}
function getRequestHeader(input: string | URL | Request, init: RequestInit | undefined, name: string): string | null {
if (init?.headers) {
return new Headers(init.headers).get(name);
}
if (input instanceof Request) {
return input.headers.get(name);
}
return null;
}
function createHangingSseResponse(signal: AbortSignal | undefined): Response {
let abortListener: (() => void) | undefined;
const stream = new ReadableStream<Uint8Array>({
@@ -144,8 +153,13 @@ function createNoProgressOpenAIResponsesStream(signal: AbortSignal | undefined):
});
}
function createDelayedFetch(delayMs: number, responseFactory: () => Response): typeof fetch {
function createDelayedFetch(
delayMs: number,
responseFactory: () => Response,
onRequest?: (input: string | URL | Request, init: RequestInit | undefined) => void,
): typeof fetch {
async function mockFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
onRequest?.(input, init);
await waitForDelayOrAbort(delayMs, getRequestSignal(input, init));
return responseFactory();
}
@@ -226,6 +240,23 @@ async function expectFirstEventTimeout(
expect(result.errorMessage).toBe(expectedMessage);
}
async function expectRequestSetupTimeout(
run: (streamFirstEventTimeoutMs: number) => Promise<{ stopReason: string; errorMessage?: string }>,
expectedMessage: string,
responseFactory: () => Response,
): Promise<void> {
const timeoutHeaders: string[] = [];
global.fetch = createDelayedFetch(30, responseFactory, (input, init) => {
timeoutHeaders.push(getRequestHeader(input, init, "X-Stainless-Timeout") ?? "");
});
const result = await run(20);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toBe(expectedMessage);
expect(timeoutHeaders).toContain("0");
}
async function expectCallerAbort(
run: (
signal: AbortSignal,
@@ -256,7 +287,7 @@ async function expectDelayedRequestSetupSucceeds(
): Promise<void> {
global.fetch = createDelayedFetch(30, responseFactory);
const result = await run(20);
const result = await run(50);
expect(result.stopReason).toBe("stop");
expect(getFirstTextContent(result)).toMatchObject({ type: "text", text: "Hello delayed" });
@@ -277,6 +308,17 @@ describe("OpenAI-family first-event timeouts", () => {
"OpenAI responses stream timed out while waiting for the first event",
);
});
it("times out OpenAI responses before the stream opens and forwards the budget to the SDK request", async () => {
await expectRequestSetupTimeout(
streamFirstEventTimeoutMs =>
streamOpenAIResponses(openAIResponsesModel, baseContext(), {
apiKey: "test-key",
streamFirstEventTimeoutMs,
}).result(),
"OpenAI responses stream timed out while waiting for the first event",
createOpenAIResponsesSuccessResponse,
);
});
it("times out OpenAI responses streams that only emit no-progress status events", async () => {
global.fetch = ((input: string | URL | Request, init?: RequestInit) =>
@@ -332,6 +374,17 @@ describe("OpenAI-family first-event timeouts", () => {
"OpenAI completions stream timed out while waiting for the first event",
);
});
it("times out OpenAI completions before the stream opens and forwards the budget to the SDK request", async () => {
await expectRequestSetupTimeout(
streamFirstEventTimeoutMs =>
streamOpenAICompletions(openAICompletionsModel, baseContext(), {
apiKey: "test-key",
streamFirstEventTimeoutMs,
}).result(),
"OpenAI completions stream timed out while waiting for the first event",
() => createOpenAICompletionsSuccessResponse(openAICompletionsModel.id),
);
});
it("surfaces the Azure OpenAI responses first-event timeout message", async () => {
await expectFirstEventTimeout(
@@ -345,6 +398,19 @@ describe("OpenAI-family first-event timeouts", () => {
"Azure OpenAI responses stream timed out while waiting for the first event",
);
});
it("times out Azure OpenAI responses before the stream opens and forwards the budget to the SDK request", async () => {
await expectRequestSetupTimeout(
streamFirstEventTimeoutMs =>
streamAzureOpenAIResponses(azureOpenAIResponsesModel, baseContext(), {
apiKey: "test-key",
azureBaseUrl: azureOpenAIResponsesModel.baseUrl,
azureApiVersion: "v1",
streamFirstEventTimeoutMs,
}).result(),
"Azure OpenAI responses stream timed out while waiting for the first event",
createOpenAIResponsesSuccessResponse,
);
});
it("times out Azure responses streams that only emit no-progress status events", async () => {
global.fetch = ((input: string | URL | Request, init?: RequestInit) =>
@@ -128,11 +128,8 @@ describe("iterateWithIdleTimeout", () => {
}
});
it("clears external first-event watchdogs when iteration exits before progress", async () => {
let watchdogFired = false;
const watchdog = setTimeout(() => {
watchdogFired = true;
}, 10);
it("cleans first-item timers when the source throws before progress", async () => {
let firstItemTimedOut = false;
// biome-ignore lint/correctness/useYield: intentionally yields nothing — the test exercises the path where the source generator throws before its first yield.
async function* failingStream(): AsyncGenerator<string> {
@@ -141,14 +138,45 @@ describe("iterateWithIdleTimeout", () => {
await expectRejectsWithMessage(async () => {
for await (const _item of iterateWithIdleTimeout(failingStream(), {
watchdog,
firstItemTimeoutMs: 10,
errorMessage: "idle timeout",
firstItemErrorMessage: "first progress timeout",
onFirstItemTimeout: () => {
firstItemTimedOut = true;
},
})) {
// Unreachable.
}
}, "stream failed");
await Bun.sleep(20);
expect(watchdogFired).toBe(false);
expect(firstItemTimedOut).toBe(false);
});
it("cleans first-item timers when the consumer returns before progress", async () => {
let firstItemTimedOut = false;
async function* noProgressItems(): AsyncGenerator<{ type: "keepalive" }> {
while (true) {
await Bun.sleep(2);
yield { type: "keepalive" };
}
}
for await (const _item of iterateWithIdleTimeout(noProgressItems(), {
firstItemTimeoutMs: 10,
idleTimeoutMs: 1_000,
errorMessage: "idle timeout",
firstItemErrorMessage: "first progress timeout",
onFirstItemTimeout: () => {
firstItemTimedOut = true;
},
isProgressItem: () => false,
})) {
break;
}
await Bun.sleep(20);
expect(firstItemTimedOut).toBe(false);
});
});
+8
View File
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
### Breaking Changes
- The `vim` edit mode option is no longer available; configurations using `edit.mode: vim` will be automatically mapped to `hashline` mode
@@ -23,6 +24,9 @@
### Changed
- Changed Python shared eval sessions to be keyed by `sessionId` and `cwd` so code state no longer leaks across different directories when reusing a session
- Changed shared JavaScript and Python startup to deduplicate concurrent first-time session initialization so parallel first calls share one warm session
- Changed shared JavaScript and Python execution output handling so interleaved async runs keep their `display` output scoped to the originating run
- Changed Python tool bridge to use per-run identifiers alongside session IDs for correct routing of tool responses and output in concurrent evaluations
- Changed JavaScript and Python `eval` execution to allow overlapping asynchronous cells on the same session ID to run concurrently instead of being strictly queued
- Updated the edit mode option set to support `replace`, `patch`, `hashline`, and `apply_patch` variants
@@ -58,6 +62,10 @@
### Fixed
- Fixed JavaScript module reloading to refresh local re-exports when transitive dependency files are edited
- Fixed Python tool calls in warm kernels to initialize once bridge environment variables appear after startup and to return a clear `tool bridge is unavailable` error when missing
- Fixed IRC `send` handling to preserve recipient incoming messages when auto-reply timeouts instead of dropping them
- Fixed Python session disposal to cancel all concurrent active executions in a shared kernel
- Fixed JavaScript `eval` imports to preserve module-level singletons across re-imports of unchanged local files and reload them only after edits
- Fixed concurrent Python evaluator tool calls to use per-run identifiers so tool responses and output are routed to the correct execution
- Fixed the `search` tool argument validation to accept a single string `paths` value as a one-path search.
@@ -39,6 +39,21 @@ function createToolSession(cwd: string, sessionFile: string | null, evalSessionI
} as unknown as ToolSession;
}
function createBridgeToolSession(resultText: string, calls: unknown[]): ToolSession {
const readTool = {
name: "read",
label: "read",
description: "read",
parameters: { type: "object" },
async execute(_id: string, args: unknown) {
calls.push(args);
return { content: [{ type: "text" as const, text: resultText }] };
},
};
const tools = new Map<string, unknown>([["read", readTool]]);
return { getToolByName: (name: string) => tools.get(name) } as unknown as ToolSession;
}
function assistantStopMessage(text: string): AssistantMessage {
return {
role: "assistant",
@@ -151,7 +166,68 @@ describe("shared eval executors", () => {
expect(result.output.trim()).toBe("42");
});
it("updates Python cwd when one shared session id runs from multiple directories", async () => {
it("deduplicates concurrent first JavaScript session acquisition", async () => {
using tempDir = TempDir.createSync("@omp-eval-js-cold-start-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `js-cold-start:${crypto.randomUUID()}`;
const session = createToolSession(tempDir.path(), sessionFile);
const [first, second] = await Promise.all([
executeJs(
"globalThis.sharedMarker ??= crypto.randomUUID(); await Bun.sleep(50); return globalThis.sharedMarker;",
{
sessionId,
session,
sessionFile,
},
),
executeJs("globalThis.sharedMarker ??= crypto.randomUUID(); return globalThis.sharedMarker;", {
sessionId,
session,
sessionFile,
}),
]);
const third = await executeJs("return globalThis.sharedMarker;", { sessionId, session, sessionFile });
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
expect(third.exitCode).toBe(0);
expect(first.output.trim()).toBe(second.output.trim());
expect(third.output.trim()).toBe(first.output.trim());
});
it("deduplicates concurrent first Python session acquisition", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-cold-start-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-cold-start:${crypto.randomUUID()}`;
const [first, second] = await Promise.all([
executePython(
`import asyncio, uuid
shared_marker = globals().get("shared_marker") or str(uuid.uuid4())
globals()["shared_marker"] = shared_marker
await asyncio.sleep(0.05)
print(shared_marker)`,
{ cwd: tempDir.path(), sessionId, sessionFile },
),
executePython(
`import uuid
shared_marker = globals().get("shared_marker") or str(uuid.uuid4())
globals()["shared_marker"] = shared_marker
print(shared_marker)`,
{ cwd: tempDir.path(), sessionId, sessionFile },
),
]);
const third = await executePython("print(shared_marker)", { cwd: tempDir.path(), sessionId, sessionFile });
expect(first.exitCode).toBe(0);
expect(second.exitCode).toBe(0);
expect(third.exitCode).toBe(0);
expect(first.output.trim()).toBe(second.output.trim());
expect(third.output.trim()).toBe(first.output.trim());
});
it("splits retained Python kernels by cwd for one shared session id", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-cwd-");
const dirA = path.join(tempDir.path(), "a");
const dirB = path.join(tempDir.path(), "b");
@@ -162,13 +238,34 @@ describe("shared eval executors", () => {
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-cwd:${crypto.randomUUID()}`;
const first = await executePython("import os\nprint(os.getcwd())", { cwd: dirA, sessionId, sessionFile });
const second = await executePython("import os\nprint(os.getcwd())", { cwd: dirB, sessionId, sessionFile });
const first = await executePython(
`import os
token = "from-a"
print(os.getcwd())`,
{
cwd: dirA,
sessionId,
sessionFile,
},
);
const second = await executePython(
`import os
print(os.getcwd())
print("token" in globals())`,
{
cwd: dirB,
sessionId,
sessionFile,
},
);
const third = await executePython("print(token)", { cwd: dirA, sessionId, sessionFile });
expect(first.exitCode).toBe(0);
expect(first.output.trim()).toBe(realDirA);
expect(second.exitCode).toBe(0);
expect(second.output.trim()).toBe(realDirB);
expect(second.output.trim().split("\n")).toEqual([realDirB, "False"]);
expect(third.exitCode).toBe(0);
expect(third.output.trim()).toBe("from-a");
});
it("interrupts timed out synchronous Python cells before they mutate shared state", async () => {
@@ -265,67 +362,59 @@ describe("shared eval executors", () => {
expect(seenPy).toBe("hello-py");
});
it("interleaves async JavaScript runs on one session id", async () => {
it("routes interleaved JavaScript display output to the matching run", async () => {
using tempDir = TempDir.createSync("@omp-eval-js-interleave-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `js-interleave:${crypto.randomUUID()}`;
const session = createToolSession(tempDir.path(), sessionFile);
const events: string[] = [];
const first = executeJs('await Bun.sleep(80); display("A");', {
const first = executeJs('await Bun.sleep(80); display({ label: "A" });', {
sessionId,
session,
sessionFile,
onChunk: chunk => {
events.push(chunk.trim());
},
});
await Bun.sleep(10);
const second = executeJs('display("B");', {
const second = executeJs('display({ label: "B" });', {
sessionId,
session,
sessionFile,
onChunk: chunk => {
events.push(chunk.trim());
},
});
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult.exitCode).toBe(0);
expect(secondResult.exitCode).toBe(0);
expect(events.filter(Boolean)).toEqual(["B", "A"]);
expect(firstResult.displayOutputs).toEqual([{ type: "json", data: { label: "A" } }]);
expect(secondResult.displayOutputs).toEqual([{ type: "json", data: { label: "B" } }]);
});
it("interleaves async Python runs on one session id", async () => {
it("routes interleaved Python display output to the matching run", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-interleave-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-interleave:${crypto.randomUUID()}`;
const events: string[] = [];
const first = executePython('import asyncio\nawait asyncio.sleep(0.08)\nprint("A")', {
cwd: tempDir.path(),
sessionId,
sessionFile,
onChunk: chunk => {
events.push(chunk.trim());
const first = executePython(
`import asyncio
await asyncio.sleep(0.08)
display({"label": "A"})`,
{
cwd: tempDir.path(),
sessionId,
sessionFile,
},
});
);
await Bun.sleep(10);
const second = executePython('print("B")', {
const second = executePython('display({"label": "B"})', {
cwd: tempDir.path(),
sessionId,
sessionFile,
onChunk: chunk => {
events.push(chunk.trim());
},
});
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult.exitCode).toBe(0);
expect(secondResult.exitCode).toBe(0);
expect(events.filter(Boolean)).toEqual(["B", "A"]);
expect(firstResult.displayOutputs).toEqual([{ type: "json", data: { label: "A" } }]);
expect(secondResult.displayOutputs).toEqual([{ type: "json", data: { label: "B" } }]);
});
it("preserves module-level singleton state across re-imports of an unchanged file", async () => {
using tempDir = TempDir.createSync("@omp-eval-js-mtime-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
@@ -370,4 +459,62 @@ describe("shared eval executors", () => {
expect(reloadResult.exitCode).toBe(0);
expect(reloadResult.output.trim()).toBe("0");
});
it("reloads a local re-export when a transitive dependency changes", async () => {
using tempDir = TempDir.createSync("@omp-eval-js-transitive-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `js-transitive:${crypto.randomUUID()}`;
const session = createToolSession(tempDir.path(), sessionFile);
const leafPath = path.join(tempDir.path(), "leaf.ts");
const entryPath = path.join(tempDir.path(), "entry.ts");
const entrySpec = JSON.stringify(entryPath);
await Bun.write(leafPath, "export const value = 1;\n");
await Bun.write(entryPath, 'export { value } from "./leaf.ts";\n');
const initial = await executeJs(`const mod = await import(${entrySpec}); return mod.value;`, {
sessionId,
session,
sessionFile,
});
expect(initial.exitCode).toBe(0);
expect(initial.output.trim()).toBe("1");
await Bun.write(leafPath, "export const value = 2;\n");
const future = new Date(Date.now() + 5_000);
await fs.utimes(leafPath, future, future);
const reloaded = await executeJs(`const mod = await import(${entrySpec}); return mod.value;`, {
sessionId,
session,
sessionFile,
});
expect(reloaded.exitCode).toBe(0);
expect(reloaded.output.trim()).toBe("2");
});
it("refreshes the Python tool proxy when bridge env appears after kernel warm-up", async () => {
using tempDir = TempDir.createSync("@omp-eval-py-tool-proxy-");
const sessionFile = path.join(tempDir.path(), "session.jsonl");
const sessionId = `py-tool-proxy:${crypto.randomUUID()}`;
const bridgeCalls: unknown[] = [];
const bridgeSession = createBridgeToolSession("bridge-ok", bridgeCalls);
const withoutBridge = await executePython(
'try:\n print(tool.read({"path": "foo.txt"}))\nexcept Exception as exc:\n print(type(exc).__name__)\n print(str(exc))',
{ cwd: tempDir.path(), sessionId, sessionFile },
);
const withBridge = await executePython('print(tool.read({"path": "foo.txt"}))', {
cwd: tempDir.path(),
sessionId,
sessionFile,
toolSession: bridgeSession,
});
expect(withoutBridge.exitCode).toBe(0);
expect(withoutBridge.output).toContain("RuntimeError");
expect(withoutBridge.output).toContain("tool bridge is unavailable");
expect(withBridge.exitCode).toBe(0);
expect(withBridge.output.trim()).toBe("bridge-ok");
expect(bridgeCalls).toEqual([{ path: "foo.txt", _i: "py prelude" }]);
});
});
@@ -51,6 +51,7 @@ interface JsSession {
}
const sessions = new Map<string, JsSession>();
const startingSessions = new Map<string, Promise<JsSession>>();
const resettingSessions = new Set<string>();
const READY_TIMEOUT_MS_DEFAULT = 5_000;
@@ -87,14 +88,21 @@ export async function executeInVmContext(options: {
}
export async function resetVmContext(sessionKey: string): Promise<void> {
const session = sessions.get(sessionKey);
const session = sessions.get(sessionKey) ?? (await startingSessions.get(sessionKey)?.catch(() => undefined));
if (!session) return;
sessions.delete(sessionKey);
await killSession(session, new ToolError("JS context reset"));
}
export async function disposeAllVmContexts(): Promise<void> {
const pending = [...startingSessions.values()];
startingSessions.clear();
const started = await Promise.allSettled(pending);
const all = [...sessions.values()];
for (const result of started) {
if (result.status !== "fulfilled") continue;
if (!all.includes(result.value)) all.push(result.value);
}
sessions.clear();
await Promise.all(all.map(session => killSession(session, new ToolError("JS context disposed"))));
}
@@ -156,42 +164,52 @@ async function runOnce(
async function acquireSession(sessionKey: string, snapshot: SessionSnapshot, timeoutMs?: number): Promise<JsSession> {
const existing = sessions.get(sessionKey);
if (existing && existing.state === "alive") return existing;
const starting = startingSessions.get(sessionKey);
if (starting) return await starting;
const worker = await spawnJsWorker();
const session: JsSession = {
sessionKey,
worker,
state: "alive",
pending: new Map(),
};
const { promise: readyPromise, resolve: resolveReady, reject: rejectReady } = Promise.withResolvers<void>();
let resolved = false;
const unsubscribe = worker.onMessage(msg => {
if (!resolved && msg.type === "ready") {
resolved = true;
resolveReady();
return;
const startup = (async (): Promise<JsSession> => {
const worker = await spawnJsWorker();
const session: JsSession = {
sessionKey,
worker,
state: "alive",
pending: new Map(),
};
const { promise: readyPromise, resolve: resolveReady, reject: rejectReady } = Promise.withResolvers<void>();
let resolved = false;
const unsubscribe = worker.onMessage(msg => {
if (!resolved && msg.type === "ready") {
resolved = true;
resolveReady();
return;
}
if (!resolved && msg.type === "init-failed") {
resolved = true;
rejectReady(errorFromPayload(msg.error));
return;
}
handleSessionMessage(session, msg);
});
try {
// Cold-start can exceed 5s on slow hosts. Let the caller's per-cell timeout dominate so
// users can grant more headroom when they raise `timeout` on a cell.
const readyTimeoutMs = Math.max(READY_TIMEOUT_MS_DEFAULT, timeoutMs ?? 0);
await raceWithTimeout(readyPromise, readyTimeoutMs, "Timed out initializing JS eval worker");
worker.send({ type: "init", snapshot });
sessions.set(sessionKey, session);
return session;
} catch (error) {
unsubscribe();
await worker.terminate().catch(() => undefined);
throw error;
}
if (!resolved && msg.type === "init-failed") {
resolved = true;
rejectReady(errorFromPayload(msg.error));
return;
}
handleSessionMessage(session, msg);
});
})();
startingSessions.set(sessionKey, startup);
try {
// Cold-start can exceed 5s on slow hosts. Let the caller's per-cell timeout dominate so
// users can grant more headroom when they raise `timeout` on a cell.
const readyTimeoutMs = Math.max(READY_TIMEOUT_MS_DEFAULT, timeoutMs ?? 0);
await raceWithTimeout(readyPromise, readyTimeoutMs, "Timed out initializing JS eval worker");
} catch (error) {
unsubscribe();
await worker.terminate().catch(() => undefined);
throw error;
return await startup;
} finally {
if (startingSessions.get(sessionKey) === startup) startingSessions.delete(sessionKey);
}
worker.send({ type: "init", snapshot });
sessions.set(sessionKey, session);
return session;
}
function handleSessionMessage(session: JsSession, msg: WorkerOutbound): void {
@@ -0,0 +1,265 @@
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import * as vm from "node:vm";
import { collectModuleSourceSpecifiers, stripTypeScriptSyntax } from "./rewrite-imports";
interface LocalModuleEntry {
version: number;
identifier: string;
module: vm.SourceTextModule;
}
export type LocalImportResolution = { mode: "local"; value: unknown } | { mode: "external"; target: string };
const LOCAL_MODULE_EXTENSIONS = new Set([".js", ".jsx", ".mjs", ".ts", ".tsx", ".mts"]);
export class LocalModuleLoader {
#context: vm.Context;
#sessionTag: string;
#moduleMtimes = new Map<string, number>();
#moduleDeps = new Map<string, Set<string>>();
#moduleParents = new Map<string, Set<string>>();
#moduleVersions = new Map<string, number>();
#moduleEntries = new Map<string, LocalModuleEntry>();
#moduleBuilds = new Map<string, Promise<LocalModuleEntry>>();
#externalModules = new Map<string, Promise<vm.Module>>();
#requireCache = new Map<string, NodeJS.Require>();
constructor(sessionId: string) {
this.#context = vm.createContext(globalThis);
this.#sessionTag = Bun.hash(sessionId).toString(16);
}
async resolveForRun(cwd: string, source: string): Promise<LocalImportResolution> {
this.#refreshTrackedLocalModules();
return await this.#resolveFromBase(cwd, source);
}
async resolveForModule(moduleUrl: string, source: string, cwd: string): Promise<LocalImportResolution> {
this.#refreshTrackedLocalModules();
const modulePath = this.filenameForUrl(moduleUrl);
const baseDir = modulePath ? path.dirname(modulePath) : cwd;
return await this.#resolveFromBase(baseDir, source);
}
requireForFile(moduleUrlOrPath: string | undefined, cwd: string): NodeJS.Require {
const basePath = this.filenameForUrl(moduleUrlOrPath) ?? path.join(cwd, "[eval]");
let cached = this.#requireCache.get(basePath);
if (!cached) {
cached = buildRequire(basePath);
this.#requireCache.set(basePath, cached);
}
return cached;
}
filenameForUrl(moduleUrlOrPath: string | undefined): string | null {
if (!moduleUrlOrPath) return null;
if (moduleUrlOrPath.startsWith("file://")) return fileURLToPath(moduleUrlOrPath);
return path.isAbsolute(moduleUrlOrPath) ? moduleUrlOrPath : null;
}
dirnameForUrl(moduleUrlOrPath: string | undefined, cwd: string): string {
const filename = this.filenameForUrl(moduleUrlOrPath);
return filename ? path.dirname(filename) : cwd;
}
async #resolveFromBase(baseDir: string, source: string): Promise<LocalImportResolution> {
const resolved = resolveImportSpecifier(baseDir, source);
if (isLocalPathSpecifier(source) && isManagedLocalModulePath(resolved)) {
const entry = await this.#ensureLocalModule(resolved);
return { mode: "local", value: entry.module.namespace };
}
return { mode: "external", target: normalizeImportTarget(resolved) };
}
async #ensureLocalModule(modulePath: string): Promise<LocalModuleEntry> {
const existing = this.#moduleEntries.get(modulePath);
if (existing) return existing;
const building = this.#moduleBuilds.get(modulePath);
if (building) return await building;
const buildPromise = this.#buildLocalModule(modulePath).finally(() => {
if (this.#moduleBuilds.get(modulePath) === buildPromise) this.#moduleBuilds.delete(modulePath);
});
this.#moduleBuilds.set(modulePath, buildPromise);
return await buildPromise;
}
async #buildLocalModule(modulePath: string): Promise<LocalModuleEntry> {
const rawSource = fs.readFileSync(modulePath, "utf8");
const stripped = stripTypeScriptSyntax(rawSource);
const moduleDir = path.dirname(modulePath);
const localDeps = new Set<string>();
for (const specifier of collectModuleSourceSpecifiers(stripped)) {
const resolved = resolveImportSpecifier(moduleDir, specifier);
if (isLocalPathSpecifier(specifier) && isManagedLocalModulePath(resolved)) {
localDeps.add(resolved);
}
}
this.#setModuleDependencies(modulePath, localDeps);
this.#moduleMtimes.set(modulePath, fs.statSync(modulePath).mtimeMs);
const version = this.#moduleVersions.get(modulePath) ?? 1;
this.#moduleVersions.set(modulePath, version);
const fileUrl = pathToFileURL(modulePath).href;
const identifier = `${fileUrl}?omp-session=${this.#sessionTag}&v=${version}`;
const wrappedSource = buildModuleSource(stripped, modulePath);
const module = new vm.SourceTextModule(wrappedSource, {
context: this.#context,
identifier,
initializeImportMeta: meta => {
(meta as { url?: string; path?: string; dir?: string }).url = fileUrl;
(meta as { url?: string; path?: string; dir?: string }).path = modulePath;
(meta as { url?: string; path?: string; dir?: string }).dir = moduleDir;
},
importModuleDynamically: async specifier => {
return await this.#resolveLinkedModule(modulePath, String(specifier));
},
});
const entry: LocalModuleEntry = { version, identifier, module };
this.#moduleEntries.set(modulePath, entry);
try {
await module.link(async specifier => await this.#resolveLinkedModule(modulePath, specifier));
await module.evaluate();
return entry;
} catch (error) {
this.#moduleEntries.delete(modulePath);
throw error;
}
}
async #resolveLinkedModule(referrerPath: string, specifier: string): Promise<vm.Module> {
const baseDir = path.dirname(referrerPath);
const resolved = resolveImportSpecifier(baseDir, specifier);
if (isLocalPathSpecifier(specifier) && isManagedLocalModulePath(resolved)) {
return (await this.#ensureLocalModule(resolved)).module;
}
return await this.#ensureExternalModule(normalizeImportTarget(resolved));
}
async #ensureExternalModule(target: string): Promise<vm.Module> {
const existing = this.#externalModules.get(target);
if (existing) return await existing;
const loadPromise = (async () => {
const namespace = await import(target);
const exportNames = Object.keys(namespace);
const module = new vm.SyntheticModule(
exportNames,
function () {
for (const name of exportNames) {
this.setExport(name, namespace[name as keyof typeof namespace]);
}
},
{ context: this.#context, identifier: target },
);
await module.link(() => {
throw new Error("Synthetic external modules have no dependencies");
});
await module.evaluate();
return module;
})();
this.#externalModules.set(target, loadPromise);
try {
return await loadPromise;
} catch (error) {
if (this.#externalModules.get(target) === loadPromise) this.#externalModules.delete(target);
throw error;
}
}
#refreshTrackedLocalModules(): void {
const changed: string[] = [];
for (const [modulePath, previousMtime] of this.#moduleMtimes.entries()) {
let nextMtime: number | undefined;
try {
nextMtime = fs.statSync(modulePath).mtimeMs;
} catch {
nextMtime = undefined;
}
if (nextMtime === previousMtime) continue;
if (nextMtime === undefined) this.#moduleMtimes.delete(modulePath);
else this.#moduleMtimes.set(modulePath, nextMtime);
changed.push(modulePath);
}
for (const modulePath of changed) {
this.#invalidateModuleAndParents(modulePath, new Set());
}
}
#invalidateModuleAndParents(modulePath: string, seen: Set<string>): void {
if (seen.has(modulePath)) return;
seen.add(modulePath);
this.#moduleEntries.delete(modulePath);
this.#moduleBuilds.delete(modulePath);
this.#moduleVersions.set(modulePath, (this.#moduleVersions.get(modulePath) ?? 1) + 1);
const parents = [...(this.#moduleParents.get(modulePath) ?? [])];
for (const parent of parents) this.#invalidateModuleAndParents(parent, seen);
}
#setModuleDependencies(modulePath: string, deps: Set<string>): void {
const previousDeps = this.#moduleDeps.get(modulePath);
if (previousDeps) {
for (const dep of previousDeps) {
const parents = this.#moduleParents.get(dep);
if (!parents) continue;
parents.delete(modulePath);
if (parents.size === 0) this.#moduleParents.delete(dep);
}
}
this.#moduleDeps.set(modulePath, new Set(deps));
for (const dep of deps) {
const parents = this.#moduleParents.get(dep) ?? new Set<string>();
parents.add(modulePath);
this.#moduleParents.set(dep, parents);
}
}
}
function buildRequire(fromPath: string): NodeJS.Require {
const basePath = path.extname(fromPath) ? fromPath : path.join(fromPath, "[eval]");
return createRequire(pathToFileURL(basePath).href);
}
function buildModuleSource(source: string, modulePath: string): string {
const moduleDir = path.dirname(modulePath);
return [
`const require = globalThis.__omp_get_require__(${JSON.stringify(pathToFileURL(modulePath).href)});`,
`const __filename = ${JSON.stringify(modulePath)};`,
`const __dirname = ${JSON.stringify(moduleDir)};`,
source,
].join("\n");
}
function resolveImportSpecifier(cwd: string, source: string): string {
if (/^[a-z][a-z0-9+.-]*:/i.test(source)) return source;
try {
return Bun.resolveSync(source, cwd);
} catch {
return source;
}
}
function isLocalPathSpecifier(source: string): boolean {
return (
source.startsWith("./") ||
source.startsWith("../") ||
source === "." ||
source === ".." ||
source.startsWith("/") ||
source.startsWith("~/") ||
/^[a-zA-Z]:[\\/]/.test(source)
);
}
function isManagedLocalModulePath(target: string): boolean {
return (
path.isAbsolute(target) &&
LOCAL_MODULE_EXTENSIONS.has(path.extname(target)) &&
!target.includes(`${path.sep}node_modules${path.sep}`)
);
}
function normalizeImportTarget(target: string): string {
if (path.isAbsolute(target)) return pathToFileURL(target).href;
return target;
}
@@ -57,6 +57,10 @@ type BabelExpressionStatement = {
};
type BabelProgramNode = BabelImportDeclaration | BabelLexicalDecl | BabelExpressionStatement | { type: string };
type BabelModuleSourceDeclaration = {
type: "ImportDeclaration" | "ExportNamedDeclaration" | "ExportAllDeclaration";
source?: { value: string; start: number; end: number } | null;
};
type BabelNode = { type: string; start: number; end: number; [key: string]: unknown };
@@ -189,6 +193,84 @@ export function rewriteImports(code: string): string {
}
return result;
}
export function collectModuleSourceSpecifiers(code: string): string[] {
const ast = parseProgram(code);
if (!ast) return [];
const sources: string[] = [];
for (const node of ast.program.body) {
if (
(node.type === "ImportDeclaration" ||
node.type === "ExportNamedDeclaration" ||
node.type === "ExportAllDeclaration") &&
typeof (node as BabelModuleSourceDeclaration).source?.value === "string"
) {
sources.push((node as BabelModuleSourceDeclaration).source!.value);
}
}
return sources;
}
export function rewriteModuleSourceSpecifiers(code: string, replacer: (source: string) => string): string {
const ast = parseProgram(code);
if (!ast) return code;
type Edit = { start: number; end: number; text: string };
const edits: Edit[] = [];
for (const node of ast.program.body) {
if (
node.type !== "ImportDeclaration" &&
node.type !== "ExportNamedDeclaration" &&
node.type !== "ExportAllDeclaration"
) {
continue;
}
const source = (node as BabelModuleSourceDeclaration).source;
if (!source || typeof source.value !== "string") continue;
const next = replacer(source.value);
if (next === source.value) continue;
edits.push({ start: source.start, end: source.end, text: JSON.stringify(next) });
}
if (edits.length === 0) return code;
edits.sort((a, b) => b.start - a.start);
let result = code;
for (const edit of edits) {
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
}
return result;
}
export function rewriteDynamicImports(code: string, callee = "__omp_import__"): string {
if (!code.includes("import")) return code;
const ast = parseProgram(code);
if (!ast) return code;
type Edit = { start: number; end: number; text: string };
const edits: Edit[] = [];
walkNodes(ast, node => {
if (node.type !== "CallExpression") return;
const call = node as unknown as { callee?: { type?: string; start?: number; end?: number } };
const callCallee = call.callee;
if (
!callCallee ||
callCallee.type !== "Import" ||
typeof callCallee.start !== "number" ||
typeof callCallee.end !== "number"
) {
return;
}
edits.push({ start: callCallee.start, end: callCallee.end, text: callee });
});
if (edits.length === 0) return code;
edits.sort((a, b) => b.start - a.start);
let result = code;
for (const edit of edits) {
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
}
return result;
}
function collectBindingNames(pattern: unknown, names: string[]): void {
if (!pattern || typeof pattern !== "object") return;
@@ -390,6 +472,9 @@ function stripTypeScript(code: string): string {
return code;
}
}
export function stripTypeScriptSyntax(code: string): string {
return stripTypeScript(code);
}
// Heuristic: any of the obvious TS-only tokens. Plain JS using `as` only inside strings
// won't match because we require a leading word boundary plus a colon/keyword neighbor.
@@ -4,13 +4,13 @@ import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as path from "node:path";
import { Writable } from "node:stream";
import { pathToFileURL } from "node:url";
import * as util from "node:util";
import { logger } from "@oh-my-pi/pi-utils";
import { createHelpers, type HelperBundle } from "./helpers";
import { awaitMaybePromise, indirectEval } from "./indirect-eval";
import { LocalModuleLoader } from "./local-module-loader";
import { JAVASCRIPT_PRELUDE_SOURCE } from "./prelude";
import { wrapCode } from "./rewrite-imports";
import type { JsDisplayOutput, JsStatusEvent } from "./types";
@@ -125,18 +125,13 @@ export class JsRuntime {
readonly sessionId: string;
#env: Map<string, string>;
#als = new AsyncLocalStorage<RunContext>();
/**
* mtime (ms) of every user-owned absolute path we've routed through `__omp_import__`.
* Powers edit-aware cache eviction: an unchanged file keeps its existing module
* instance — and therefore its module-private singletons — across cells; a bumped
* mtime triggers a one-shot `require.cache` eviction so the next `import` reloads.
*/
#moduleMtimes = new Map<string, number>();
#moduleLoader: LocalModuleLoader;
constructor(opts: RuntimeOptions) {
this.#cwd = opts.initialCwd;
this.sessionId = opts.sessionId;
this.#env = new Map();
this.#moduleLoader = new LocalModuleLoader(this.sessionId);
this.helpers = createHelpers({
cwd: () => this.#activeCwd(),
env: this.#env,
@@ -242,6 +237,34 @@ export class JsRuntime {
return hooks;
}
#activeRequire(moduleUrlOrPath?: string): NodeJS.Require {
return this.#moduleLoader.requireForFile(moduleUrlOrPath, this.#activeCwd());
}
#moduleFilename(moduleUrlOrPath?: string): string {
return this.#moduleLoader.filenameForUrl(moduleUrlOrPath) ?? path.join(this.#activeCwd(), "[eval]");
}
#moduleDirname(moduleUrlOrPath?: string): string {
return this.#moduleLoader.dirnameForUrl(moduleUrlOrPath, this.#activeCwd());
}
#buildDynamicRequire(): NodeJS.Require {
const dynamicRequire = ((id: string) => this.#activeRequire()(id)) as NodeJS.Require;
const resolve = ((id: string, options?: { paths?: string[] }) =>
this.#activeRequire().resolve(id, options)) as NodeJS.Require["resolve"] & {
paths(request: string): string[] | null;
};
resolve.paths = request => this.#activeRequire().resolve.paths(request);
Object.defineProperties(dynamicRequire, {
resolve: { value: resolve, configurable: true },
cache: { get: () => this.#activeRequire().cache, configurable: true },
extensions: { get: () => this.#activeRequire().extensions, configurable: true },
main: { get: () => this.#activeRequire().main, configurable: true },
});
return dynamicRequire;
}
#install(extraGlobals: Record<string, unknown> | undefined): void {
const injected: Record<string, unknown> = {
__omp_session__: { cwd: this.#cwd, sessionId: this.sessionId },
@@ -252,35 +275,20 @@ export class JsRuntime {
return await hooks.callTool(name, args);
},
__omp_import__: async (source: string, options?: ImportCallOptions) => {
const target = resolveImportSpecifier(this.#activeCwd(), source);
// Edit-aware module cache eviction for user-owned source files (relative or
// absolute paths). Bun's module cache otherwise pins the first evaluation for
// the lifetime of the worker, which (a) hides edits made between cells and
// (b) would force any module-private singleton state to be re-initialized on
// every re-import — breaking patterns like `Settings.init()`'s module-scoped
// `globalInstance` when one cell inits and a later cell re-imports.
//
// Strategy: stat the resolved file, compare mtime to the last value we saw,
// and only evict when the file actually changed. First sight just records
// the mtime so the current module instance — and any singleton state it
// owns — survives subsequent imports until the user edits the file. Bare
// specifiers and URL schemes are left alone: `node:` built-ins cannot be
// reloaded and busting packages would defeat module identity across cells.
if (isLocalPathSpecifier(source) && path.isAbsolute(target)) {
try {
const mtime = fs.statSync(target).mtimeMs;
const prev = this.#moduleMtimes.get(target);
if (prev !== undefined && prev !== mtime) {
delete require.cache[target];
}
this.#moduleMtimes.set(target, mtime);
} catch {
// stat failure (missing file, permission error, …) — fall through and
// let the real `import` surface the underlying error.
}
}
const resolved = await this.#moduleLoader.resolveForRun(this.#activeCwd(), source);
if (resolved.mode === "local") return resolved.value;
const target = resolved.target;
return options !== undefined ? await import(target, options) : await import(target);
},
__omp_import_from__: async (moduleUrl: string, source: string, options?: ImportCallOptions) => {
const resolved = await this.#moduleLoader.resolveForModule(moduleUrl, source, this.#activeCwd());
if (resolved.mode === "local") return resolved.value;
const target = resolved.target;
return options !== undefined ? await import(target, options) : await import(target);
},
__omp_get_require__: (moduleUrl?: string) => this.#activeRequire(moduleUrl),
__omp_get_filename__: (moduleUrl?: string) => this.#moduleFilename(moduleUrl),
__omp_get_dirname__: (moduleUrl?: string) => this.#moduleDirname(moduleUrl),
__omp_emit_status__: (op: string, data: Record<string, unknown> = {}) => {
const event: JsStatusEvent = { op, ...data };
this.#activeHooks("emitStatus")?.onDisplay({ type: "status", event });
@@ -318,7 +326,7 @@ export class JsRuntime {
// `process` is intentionally not overridden — user code gets the host worker's real
// `process` object. Subsetting it caused segfaults in workers that share state with
// puppeteer/worker_threads internals.
require: buildRequire(this.#cwd),
require: this.#buildDynamicRequire(),
createRequire,
fs,
};
@@ -334,41 +342,3 @@ function formatConsoleArgs(args: unknown[]): string {
.map(arg => (typeof arg === "string" ? arg : util.inspect(arg, { depth: 6, colors: false, breakLength: 120 })))
.join(" ");
}
function buildRequire(cwd: string): NodeJS.Require {
return createRequire(pathToFileURL(path.join(cwd, "[eval]")).href);
}
/**
* Resolve an import specifier emitted by `rewriteImports` against the active session
* cwd. Relative paths (`./`, `../`, `/`) and bare specifiers (`pkg`, `@scope/pkg`) both go
* through `Bun.resolveSync` rooted at the cwd so user-pasted ESM behaves as if it lived in
* the project — not next to the worker module. URL-like specifiers (`file://`, `data:`,
* `node:`, `http:`) are passed through unchanged.
*/
function resolveImportSpecifier(cwd: string, source: string): string {
if (/^[a-z][a-z0-9+.-]*:/i.test(source)) return source;
try {
return Bun.resolveSync(source, cwd);
} catch {
return source;
}
}
/**
* Returns true when the original specifier is a relative or absolute filesystem path
* (i.e. user-owned source the agent is iterating on). Bare specifiers and URL schemes
* are excluded — `node:` built-ins cannot be reloaded, and busting bare packages would
* defeat module identity for every cell while bringing no editing benefit.
*/
function isLocalPathSpecifier(source: string): boolean {
return (
source.startsWith("./") ||
source.startsWith("../") ||
source === "." ||
source === ".." ||
source.startsWith("/") ||
source.startsWith("~/") ||
/^[a-zA-Z]:[\\/]/.test(source)
);
}
+97 -39
View File
@@ -1,3 +1,5 @@
import * as path from "node:path";
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
import { Settings } from "../../config/settings";
import { OutputSink } from "../../session/streaming-output";
@@ -93,21 +95,33 @@ export interface PythonResult {
// ---------------------------------------------------------------------------
// Session bookkeeping
//
// One PythonKernel subprocess per session id. Sessions are reused until they
// die or are explicitly disposed. Multiple agent owners can register against
// the same session id; the kernel stays alive until the last owner detaches.
// One PythonKernel subprocess per (session id, cwd) tuple. The runner mutates
// process-global cwd/sys.path during execution, so cross-directory work MUST
// never share a live kernel. Multiple agent owners can still register against
// the same tuple; the kernel stays alive until the last owner detaches.
// ---------------------------------------------------------------------------
interface PythonSession {
sessionKey: string;
sessionId: string;
cwd: string;
kernel: PythonKernel;
ownerIds: Set<string>;
hasFallbackOwner: boolean;
}
const sessions = new Map<string, PythonSession>();
const startingSessions = new Map<string, Promise<PythonSession>>();
const resettingSessions = new Set<string>();
function normalizeSessionCwd(cwd: string): string {
return path.resolve(cwd);
}
function buildSessionKey(sessionId: string, cwd: string): string {
return `${sessionId}\0${normalizeSessionCwd(cwd)}`;
}
// ---------------------------------------------------------------------------
// Cancellation plumbing
// ---------------------------------------------------------------------------
@@ -304,22 +318,44 @@ function attachOwner(session: PythonSession, sessionId: string, ownerId: string
}
}
async function acquireSession(sessionId: string, cwd: string, options: PythonExecutorOptions): Promise<PythonSession> {
const existing = sessions.get(sessionId);
async function acquireSession(
sessionKey: string,
sessionId: string,
cwd: string,
options: PythonExecutorOptions,
): Promise<PythonSession> {
const existing = sessions.get(sessionKey);
if (existing) {
attachOwner(existing, sessionId, options.kernelOwnerId);
return existing;
}
const kernel = await startKernel(cwd, options);
const session: PythonSession = {
sessionId,
kernel,
ownerIds: new Set(),
hasFallbackOwner: false,
};
attachOwner(session, sessionId, options.kernelOwnerId);
sessions.set(sessionId, session);
return session;
const starting = startingSessions.get(sessionKey);
if (starting) {
const session = await starting;
attachOwner(session, sessionId, options.kernelOwnerId);
return session;
}
const startup = (async () => {
const kernel = await startKernel(cwd, options);
const session: PythonSession = {
sessionKey,
sessionId,
cwd,
kernel,
ownerIds: new Set(),
hasFallbackOwner: false,
};
sessions.set(sessionKey, session);
return session;
})();
startingSessions.set(sessionKey, startup);
try {
const session = await startup;
attachOwner(session, sessionId, options.kernelOwnerId);
return session;
} finally {
if (startingSessions.get(sessionKey) === startup) startingSessions.delete(sessionKey);
}
}
async function replaceSessionKernel(
@@ -332,22 +368,22 @@ async function replaceSessionKernel(
await old
.shutdown(remaining !== undefined ? { timeoutMs: Math.max(0, remaining) } : undefined)
.catch(() => undefined);
if (sessions.get(session.sessionId) !== session) {
if (sessions.get(session.sessionKey) !== session) {
throw new PythonExecutionCancelledError(false);
}
requireRemainingTimeoutMs(options.deadlineMs);
const next = await startKernel(cwd, options);
if (sessions.get(session.sessionId) !== session) {
if (sessions.get(session.sessionKey) !== session) {
await next.shutdown().catch(() => undefined);
throw new PythonExecutionCancelledError(false);
}
session.kernel = next;
}
async function resetSession(sessionId: string): Promise<void> {
const existing = sessions.get(sessionId);
async function resetSession(sessionKey: string): Promise<void> {
const existing = sessions.get(sessionKey) ?? (await startingSessions.get(sessionKey)?.catch(() => undefined));
if (!existing) return;
sessions.delete(sessionId);
sessions.delete(sessionKey);
await existing.kernel.shutdown().catch(() => undefined);
}
@@ -356,7 +392,16 @@ async function resetSession(sessionId: string): Promise<void> {
// ---------------------------------------------------------------------------
export async function disposeAllKernelSessions(): Promise<void> {
const pending = [...startingSessions.values()];
startingSessions.clear();
const started = await Promise.allSettled(pending);
const all = [...sessions.entries()];
for (const result of started) {
if (result.status !== "fulfilled") continue;
if (!all.some(([, session]) => session === result.value)) {
all.push([result.value.sessionKey, result.value]);
}
}
for (const [id, session] of all) {
if (sessions.get(id) === session) sessions.delete(id);
}
@@ -366,7 +411,12 @@ export async function disposeAllKernelSessions(): Promise<void> {
const result = results[i];
if (result.status === "fulfilled" && result.value?.confirmed !== false) continue;
const reason = result.status === "rejected" ? result.reason : "not confirmed";
logger.warn("Python kernel shutdown not confirmed", { sessionId: id, reason });
logger.warn("Python kernel shutdown not confirmed", {
sessionId: session.sessionId,
sessionKey: id,
cwd: session.cwd,
reason,
});
if (!sessions.has(id)) sessions.set(id, session);
}
}
@@ -382,7 +432,7 @@ export async function disposeKernelSessionsByOwner(ownerId: string): Promise<voi
session.ownerIds.delete(ownerId);
}
for (const session of toShutdown) {
if (sessions.get(session.sessionId) === session) sessions.delete(session.sessionId);
if (sessions.get(session.sessionKey) === session) sessions.delete(session.sessionKey);
}
const results = await Promise.allSettled(toShutdown.map(session => session.kernel.shutdown()));
for (let i = 0; i < toShutdown.length; i += 1) {
@@ -393,8 +443,13 @@ export async function disposeKernelSessionsByOwner(ownerId: string): Promise<voi
continue;
}
const reason = result.status === "rejected" ? result.reason : "not confirmed";
logger.warn("Python kernel shutdown not confirmed", { sessionId: session.sessionId, reason });
if (!sessions.has(session.sessionId)) sessions.set(session.sessionId, session);
logger.warn("Python kernel shutdown not confirmed", {
sessionId: session.sessionId,
sessionKey: session.sessionKey,
cwd: session.cwd,
reason,
});
if (!sessions.has(session.sessionKey)) sessions.set(session.sessionKey, session);
}
}
@@ -520,7 +575,7 @@ async function executePerCall(code: string, cwd: string, options: PythonExecutor
}
const kernel = await startKernel(cwd, options);
try {
return await executeWithKernel(kernel, code, options);
return await executeWithKernel(kernel, code, { ...options, cwd: undefined });
} finally {
await kernel.shutdown().catch(() => undefined);
}
@@ -528,49 +583,52 @@ async function executePerCall(code: string, cwd: string, options: PythonExecutor
async function executeOnSession(code: string, cwd: string, options: PythonExecutorOptions): Promise<PythonResult> {
const sessionId = options.sessionId ?? `session:${cwd}`;
const sessionKey = buildSessionKey(sessionId, cwd);
if (options.bridge && !options.bridgeSessionId) {
options.bridgeSessionId = sessionId;
}
if (options.reset) {
if (resettingSessions.has(sessionId)) {
if (resettingSessions.has(sessionKey)) {
throw new Error("Python kernel reset already in progress");
}
resettingSessions.add(sessionId);
resettingSessions.add(sessionKey);
try {
await resetSession(sessionId);
await resetSession(sessionKey);
} finally {
resettingSessions.delete(sessionId);
resettingSessions.delete(sessionKey);
}
} else if (resettingSessions.has(sessionId)) {
} else if (resettingSessions.has(sessionKey)) {
throw new Error("Python kernel reset in progress");
}
const session = await acquireSession(sessionId, cwd, options);
const session = await acquireSession(sessionKey, sessionId, cwd, options);
if (options.signal?.aborted) {
throw new PythonExecutionCancelledError(isTimedOutCancellation(options.signal.reason, options.signal));
}
if (sessions.get(session.sessionId) !== session) {
if (sessions.get(session.sessionKey) !== session) {
throw new PythonExecutionCancelledError(false);
}
if (!session.kernel.isAlive()) {
await replaceSessionKernel(session, cwd, options);
if (sessions.get(session.sessionId) !== session) {
if (sessions.get(session.sessionKey) !== session) {
throw new PythonExecutionCancelledError(false);
}
}
const runOptions = { ...options, cwd: undefined };
try {
return await executeWithKernel(session.kernel, code, options);
return await executeWithKernel(session.kernel, code, runOptions);
} catch (err) {
if (isCancellationError(err) || options.signal?.aborted) throw err;
if (session.kernel.isAlive()) throw err;
if (sessions.get(session.sessionId) !== session) {
if (sessions.get(session.sessionKey) !== session) {
throw new PythonExecutionCancelledError(false);
}
// Kernel died during execute. Replace it and retry once on a fresh one.
// Shared kernels are keyed by cwd, so a dead kernel can be recreated in place
// without risking cross-directory state bleed.
await replaceSessionKernel(session, cwd, options);
if (sessions.get(session.sessionId) !== session) {
if (sessions.get(session.sessionKey) !== session) {
throw new PythonExecutionCancelledError(false);
}
return await executeWithKernel(session.kernel, code, options);
return await executeWithKernel(session.kernel, code, runOptions);
}
}
@@ -583,7 +641,7 @@ export async function executePythonWithKernel(
}
export async function executePython(code: string, options?: PythonExecutorOptions): Promise<PythonResult> {
const cwd = options?.cwd ?? getProjectDir();
const cwd = normalizeSessionCwd(options?.cwd ?? getProjectDir());
const deadlineMs = getExecutionDeadlineMs(options);
const executionOptions: PythonExecutorOptions = {
...(options ?? {}),
+20 -24
View File
@@ -377,13 +377,20 @@ if "__omp_prelude_loaded__" not in globals():
return current
def _tool_proxy_from_env() -> tuple[str, str, str]:
base = os.environ.get("PI_TOOL_BRIDGE_URL")
token = os.environ.get("PI_TOOL_BRIDGE_TOKEN")
session = os.environ.get("PI_TOOL_BRIDGE_SESSION")
if not base or not token or not session:
raise RuntimeError("tool bridge is unavailable in this kernel")
return (base.rstrip("/"), token, session)
class _ToolCallable:
"""Invokes one host-side tool via the loopback HTTP bridge."""
__slots__ = ("_proxy", "_name")
__slots__ = ("_name",)
def __init__(self, proxy: "_ToolProxy", name: str):
self._proxy = proxy
def __init__(self, name: str):
self._name = name
def __repr__(self) -> str:
@@ -402,18 +409,19 @@ if "__omp_prelude_loaded__" not in globals():
merged.update(kwargs)
if "_i" not in merged:
merged["_i"] = "py prelude"
base, token, session = _tool_proxy_from_env()
_run_id_getter = globals().get("__omp_current_run_id__")
_run_id = _run_id_getter() if callable(_run_id_getter) else globals().get("__omp_run_id__")
payload = json.dumps(
{"session": self._proxy._session, "run": _run_id, "name": self._name, "args": merged}
{"session": session, "run": _run_id, "name": self._name, "args": merged}
).encode("utf-8")
req = urllib.request.Request(
f"{self._proxy._base}/v1/tool",
f"{base}/v1/tool",
data=payload,
method="POST",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self._proxy._token}",
"Authorization": f"Bearer {token}",
},
)
try:
@@ -435,30 +443,18 @@ if "__omp_prelude_loaded__" not in globals():
class _ToolProxy:
"""`tool.<name>(args)` proxy mirroring the JS runtime bridge."""
__slots__ = ("_base", "_token", "_session")
def __init__(self, base: str, token: str, session: str):
self._base = base.rstrip("/")
self._token = token
self._session = session
__slots__ = ()
def __getattr__(self, name: str) -> _ToolCallable:
if name.startswith("_"):
raise AttributeError(name)
return _ToolCallable(self, name)
return _ToolCallable(name)
def __getitem__(self, name: str) -> _ToolCallable:
return _ToolCallable(self, name)
return _ToolCallable(name)
def __repr__(self) -> str:
return f"<tool proxy session={self._session}>"
session = os.environ.get("PI_TOOL_BRIDGE_SESSION")
return f"<tool proxy session={session}>" if session else "<tool proxy unavailable>"
if all(
_k in os.environ
for _k in ("PI_TOOL_BRIDGE_URL", "PI_TOOL_BRIDGE_TOKEN", "PI_TOOL_BRIDGE_SESSION")
):
tool = _ToolProxy(
os.environ["PI_TOOL_BRIDGE_URL"],
os.environ["PI_TOOL_BRIDGE_TOKEN"],
os.environ["PI_TOOL_BRIDGE_SESSION"],
)
tool = _ToolProxy()
@@ -22,6 +22,7 @@ import { isSilentAbort } from "../../session/messages";
import type { SessionMessageEntry } from "../../session/session-manager";
import { parseSessionEntries } from "../../session/session-manager";
import { PREVIEW_LIMITS, replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
import { toPathList } from "../../tools/search";
import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
import { getMarkdownTheme, theme } from "../theme/theme";
import { DynamicBorder } from "./dynamic-border";
@@ -533,13 +534,21 @@ export class SessionObserverOverlayComponent extends Container {
case "write":
case "edit":
return args.path ? `path: ${args.path}` : "";
case "search":
case "search": {
const searchPathsInput =
typeof args.paths === "string" || Array.isArray(args.paths)
? args.paths
: typeof args.path === "string"
? args.path
: undefined;
const searchPaths = toPathList(searchPathsInput);
return [
args.pattern ? `pattern: ${args.pattern}` : "",
Array.isArray(args.paths) ? `paths: ${args.paths.join(", ")}` : "",
searchPaths.length > 0 ? `paths: ${searchPaths.join(", ")}` : "",
]
.filter(Boolean)
.join(", ");
}
case "find":
return Array.isArray(args.paths) ? `paths: ${args.paths.join(", ")}` : "";
case "bash": {
@@ -15,6 +15,7 @@ import { theme } from "../../modes/theme/theme";
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
import type { SessionTreeNode } from "../../session/session-manager";
import { shortenPath } from "../../tools/render-utils";
import { toPathList } from "../../tools/search";
import { DynamicBorder } from "./dynamic-border";
/** Gutter info: position (displayIndent where connector was) and whether to show │ */
@@ -690,8 +691,15 @@ class TreeList implements Component {
}
case "search": {
const pattern = String(args.pattern || "");
const paths = Array.isArray(args.paths) ? args.paths.join(", ") : String(args.path || ".");
return `[search: /${pattern}/ in ${shortenPath(paths)}]`;
const searchPathsInput =
typeof args.paths === "string" || Array.isArray(args.paths)
? args.paths
: typeof args.path === "string"
? args.path
: undefined;
const paths = toPathList(searchPathsInput);
const scope = paths.length > 0 ? paths.join(", ") : ".";
return `[search: /${pattern}/ in ${shortenPath(scope)}]`;
}
case "find": {
const paths = Array.isArray(args.paths) ? args.paths.join(", ") : String(args.pattern || ".");
@@ -7548,11 +7548,11 @@ export class AgentSession {
* Generate an ephemeral reply to a background message (e.g. an IRC ping from
* another agent) using this session's current model + system prompt + history.
*
* The reply is computed via a side-channel `streamSimple` call (analogous to
* `/btw`) so it never blocks on the recipient's in-flight tool calls. After
* the reply is generated, both the incoming question and the auto-reply are
* queued for injection into the recipient's persisted history so the model
* sees the exchange on its next turn. Injection happens immediately when the
* The incoming message is queued for injection into the recipient's persisted
* history immediately so timeouts/abort still preserve delivery. The reply is
* computed via a side-channel `streamSimple` call (analogous to `/btw`) so it
* never blocks on the recipient's in-flight tool calls. When a reply is
* generated, it is queued separately. Injection happens immediately when the
* session is idle, otherwise it is deferred until streaming ends.
*/
async respondAsBackground(args: {
@@ -7581,8 +7581,8 @@ export class AgentSession {
timestamp: incomingTimestamp,
});
this.#queueBackgroundExchangeInjection([incomingRecord]);
if (!awaitReply) {
this.#queueBackgroundExchangeInjection([incomingRecord]);
return { replyText: null };
}
@@ -7612,7 +7612,7 @@ export class AgentSession {
kind: "reply",
timestamp: replyRecord.timestamp,
});
this.#queueBackgroundExchangeInjection([incomingRecord, replyRecord]);
this.#queueBackgroundExchangeInjection([replyRecord]);
return { replyText };
}
+5 -2
View File
@@ -56,6 +56,9 @@ const searchSchema = z
.strict();
export type SearchToolInput = z.infer<typeof searchSchema>;
export function toPathList(input: string | string[] | undefined): string[] {
return typeof input === "string" ? [input] : (input ?? []);
}
/** Maximum number of distinct files surfaced in a single response. The
* agent paginates further pages via `skip`. */
@@ -249,7 +252,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
if (normalizedSkip < 0 || !Number.isFinite(normalizedSkip)) {
throw new ToolError("Skip must be a non-negative number");
}
const paths = typeof rawPaths === "string" ? [rawPaths] : rawPaths;
const paths = toPathList(rawPaths);
for (const entry of paths) {
if (containsTopLevelComma(entry)) {
throw new ToolError('paths is an array — pass ["a", "b"] not ["a,b"]');
@@ -645,7 +648,7 @@ const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
export const searchToolRenderer = {
inline: true,
renderCall(args: SearchRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
const paths = typeof args.paths === "string" ? [args.paths] : (args.paths ?? []);
const paths = toPathList(args.paths);
const meta: string[] = [];
if (paths.length) meta.push(`in ${paths.join(", ")}`);
if (args.i) meta.push("case:insensitive");
@@ -681,24 +681,30 @@ describe("AgentSession python cleanup", () => {
expect(executeSpy).not.toHaveBeenCalled();
});
it("aborts every active Python execution owned by the session during dispose", async () => {
it("aborts every active concurrent Python execution owned by the session during dispose", async () => {
const { tempDir, cwd } = createTempProject();
tempDirs.push(tempDir);
const kernel = new FakeKernel();
const blockedExecution = Promise.withResolvers<typeof OK_EXECUTION>();
const blockedExecutionStarted = Promise.withResolvers<void>();
kernel.blockedCode = "print('first')";
const bothStarted = Promise.withResolvers<void>();
let starts = 0;
kernel.blockedCode = "print('blocked')";
kernel.blockedExecution = blockedExecution.promise;
kernel.blockedExecutionStarted = () => blockedExecutionStarted.resolve();
kernel.blockedExecutionStarted = () => {
starts += 1;
if (starts >= 2) bothStarted.resolve();
};
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
vi.spyOn(pythonKernel.PythonKernel, "start").mockResolvedValue(kernel as unknown as PythonKernelInstance);
const session = await createSession(tempDir, cwd);
const firstExecution = session.executePython("print('first')");
await blockedExecutionStarted.promise;
const secondExecution = session.executePython("print('second')");
// Two concurrent blocked executions on the shared kernel session: both must
// be tracked when dispose runs so abortEval cancels every signal.
const firstExecution = session.executePython("print('blocked')");
const secondExecution = session.executePython("print('blocked')");
await bothStarted.promise;
const sleepSpy = mockPositiveSleepsImmediate();
await session.dispose();
@@ -707,7 +713,7 @@ describe("AgentSession python cleanup", () => {
expect(firstResult.cancelled).toBe(true);
expect(secondResult.cancelled).toBe(true);
expect(kernel.executeCalls).toEqual(["print('first')"]);
expect(kernel.executeCalls).toEqual(["print('blocked')", "print('blocked')"]);
expect(kernel.shutdownCalls).toBe(1);
});
});
@@ -316,43 +316,6 @@ describe("python executor owner cleanup", () => {
expect(unownedRetainedKernel.shutdown).toHaveBeenCalledTimes(1);
});
it("rejects a queued execute when its session is disposed before the slot runs", async () => {
const kernel = new FakeKernel();
const executeHang = Promise.withResolvers<KernelExecuteResult>();
const executeStarted = Promise.withResolvers<void>();
kernel.execute = vi.fn(async () => {
executeStarted.resolve();
return await executeHang.promise;
});
vi.spyOn(pythonKernel, "checkPythonKernelAvailability").mockResolvedValue({ ok: true });
const startSpy = vi.spyOn(PythonKernel, "start").mockResolvedValue(kernel as unknown as PythonKernelInstance);
const first = executePython("first", {
cwd: "/tmp/dispose-queue-race",
sessionId: "dispose-queue-session",
kernelMode: "session",
});
await executeStarted.promise;
const queued = executePython("queued", {
cwd: "/tmp/dispose-queue-race",
sessionId: "dispose-queue-session",
kernelMode: "session",
});
await flushMicrotasks();
await disposeAllKernelSessions();
executeHang.resolve(OK_RESULT);
const firstResult = await first;
const queuedResult = await queued;
expect(firstResult.cancelled).toBe(false);
expect(queuedResult.cancelled).toBe(true);
expect(startSpy).toHaveBeenCalledTimes(1);
expect(kernel.execute).toHaveBeenCalledTimes(1);
expect(kernel.shutdown).toHaveBeenCalledTimes(1);
});
it("retains sessions whose kernel shutdown is not confirmed so a later dispose retries", async () => {
const kernel = new FakeKernel();
+87 -2
View File
@@ -1,7 +1,9 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { Agent } from "@oh-my-pi/pi-agent-core";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { type FileEntry, SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { IrcTool } from "@oh-my-pi/pi-coding-agent/tools/irc";
@@ -91,14 +93,37 @@ function makeToolSession(registry: AgentRegistry, agentId: string): ToolSession
getAgentId: () => agentId,
};
}
function createRealBackgroundSession(): { session: AgentSession; sessionManager: SessionManager } {
const sessionManager = SessionManager.inMemory("/tmp");
const session = new AgentSession({
agent: new Agent({
initialState: {
systemPrompt: ["system prompt"],
messages: [],
tools: [],
},
}),
sessionManager,
settings: Settings.isolated({ "compaction.enabled": false }),
modelRegistry: {} as never,
});
return { session, sessionManager };
}
describe("IrcTool", () => {
let registry: AgentRegistry;
const sessions: AgentSession[] = [];
beforeEach(() => {
AgentRegistry.resetGlobalForTests();
registry = AgentRegistry.global();
});
afterEach(async () => {
vi.restoreAllMocks();
for (const session of sessions.splice(0)) {
await session.dispose();
}
});
it("createIf returns null when irc is disabled", () => {
const session: ToolSession = {
@@ -240,6 +265,66 @@ describe("IrcTool", () => {
expect(result.details?.failed).toEqual([{ id: "0-Hung", error: "IRC timed out waiting for 0-Hung after 5 ms" }]);
expect(sub.calls).toEqual([{ from: "0-Main", message: "ping", awaitReply: true }]);
});
it("op=send preserves the recipient's incoming DM when the auto-reply times out", async () => {
const main = makeFakeSession();
const { session: recipient, sessionManager } = createRealBackgroundSession();
sessions.push(recipient);
vi.spyOn(recipient, "runEphemeralTurn").mockImplementation(async ({ signal }) => {
if (!signal) {
throw new Error("Missing abort signal");
}
const deferred = Promise.withResolvers<never>();
const rejectOnAbort = () => {
deferred.reject(signal.reason instanceof Error ? signal.reason : new Error("IRC aborted"));
};
if (signal.aborted) {
rejectOnAbort();
} else {
signal.addEventListener("abort", rejectOnAbort, { once: true });
}
return await deferred.promise;
});
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
registry.register({
id: "0-Hung",
displayName: "task",
kind: "sub",
parentId: "0-Main",
session: recipient,
});
const toolSession = makeToolSession(registry, "0-Main");
toolSession.settings.set("irc.timeoutMs", 5);
const tool = new IrcTool(toolSession);
const result = await tool.execute("call-timeout-persist", { op: "send", to: "0-Hung", message: "ping" });
expect(result.details?.delivered ?? []).toEqual([]);
expect(result.details?.failed).toEqual([{ id: "0-Hung", error: "IRC timed out waiting for 0-Hung after 5 ms" }]);
expect(recipient.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "custom",
customType: "irc:incoming",
content: "[IRC `0-Main` → you]\n\nping",
}),
]),
);
expect(recipient.messages).not.toEqual(
expect.arrayContaining([expect.objectContaining({ role: "custom", customType: "irc:autoreply" })]),
);
const persistedEntries = sessionManager
.captureState()
.fileEntries.filter(
(entry): entry is Extract<FileEntry, { type: "custom_message" }> =>
entry.type === "custom_message" && typeof entry.content === "string",
);
expect(persistedEntries).toEqual([
expect.objectContaining({
customType: "irc:incoming",
content: "[IRC `0-Main` → you]\n\nping",
}),
]);
});
it("op=send surfaces recipient errors as failed", async () => {
const main = makeFakeSession();
@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { RenderResultOptions } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/types";
@@ -10,6 +11,11 @@ import { ToolChoiceQueue } from "@oh-my-pi/pi-coding-agent/session/tool-choice-q
import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { searchToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/search";
import { Text } from "@oh-my-pi/pi-tui";
import { SessionObserverOverlayComponent } from "../../src/modes/components/session-observer-overlay";
import { TreeSelectorComponent } from "../../src/modes/components/tree-selector";
import type { ObservableSession, SessionObserverRegistry } from "../../src/modes/session-observer-registry";
import { initTheme } from "../../src/modes/theme/theme";
import type { SessionEntry, SessionTreeNode } from "../../src/session/session-manager";
function createTestSession(cwd: string, overrides: Partial<ToolSession> = {}): ToolSession {
return {
@@ -72,11 +78,52 @@ async function createSearchFixture(rootDir: string): Promise<void> {
"const providerOptions = {};\nlegacyWrap(otherValue, otherArg);\n",
);
}
async function makeJsonlSessionFile(dirPath: string, entries: object[]): Promise<string> {
const filePath = path.join(dirPath, "session.jsonl");
await Bun.write(filePath, `${entries.map(entry => JSON.stringify(entry)).join("\n")}\n`);
return filePath;
}
function makeSubagentRegistry(sessions: ObservableSession[]): SessionObserverRegistry {
return {
getSessions: () => sessions,
onChange: () => () => {},
setMainSession: () => {},
getActiveSubagentCount: () => sessions.filter(session => session.status === "active").length,
} as unknown as SessionObserverRegistry;
}
let treeEntryCounter = 0;
function makeMessageNode(message: AgentMessage, parentId: string | null = null): SessionTreeNode {
const entry: SessionEntry = {
type: "message",
id: `entry-${treeEntryCounter++}`,
parentId,
timestamp: new Date().toISOString(),
message,
};
return { entry, children: [] };
}
function renderTree(tree: SessionTreeNode[], currentLeafId: string): string {
const selector = new TreeSelectorComponent(
tree,
currentLeafId,
60,
() => {},
() => {},
);
return Bun.stripANSI(selector.render(120).join("\n"));
}
describe("tool path arrays", () => {
let tempDir: string;
beforeAll(async () => {
await initTheme(false, undefined, undefined, "dark", "light");
});
beforeEach(async () => {
treeEntryCounter = 0;
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "search-path-lists-"));
await createSearchFixture(tempDir);
});
@@ -141,6 +188,125 @@ describe("tool path arrays", () => {
expect(component).toBeInstanceOf(Text);
expect((component as Text).getText()).toContain("in folder with spaces/");
});
it("session observer overlay renders a single-string search path summary", async () => {
const sessionFile = await makeJsonlSessionFile(tempDir, [
{ type: "session", version: 3, id: "search-overlay-session", timestamp: new Date().toISOString() },
{
type: "message",
id: "msg-user-1",
parentId: null,
timestamp: new Date().toISOString(),
message: { role: "user", content: "search", timestamp: 1 },
},
{
type: "message",
id: "msg-assistant-1",
parentId: "msg-user-1",
timestamp: new Date().toISOString(),
message: {
role: "assistant",
content: [
{
type: "toolCall",
id: "search-call-1",
name: "search",
arguments: { pattern: "space-needle", paths: "folder with spaces/" },
},
],
api: "test",
provider: "test",
model: "test",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: 2,
},
},
{
type: "message",
id: "msg-tool-1",
parentId: "msg-assistant-1",
timestamp: new Date().toISOString(),
message: {
role: "toolResult",
toolName: "search",
toolCallId: "search-call-1",
content: [{ type: "text", text: "note.txt" }],
isError: false,
timestamp: 3,
},
},
]);
const registry = makeSubagentRegistry([
{
id: "search-overlay-session",
kind: "subagent",
label: "Search Overlay",
status: "active",
sessionFile,
lastUpdate: Date.now(),
},
]);
const overlay = new SessionObserverOverlayComponent(registry, () => {}, ["ctrl+s"]);
const rendered = Bun.stripANSI(overlay.render(120).join("\n"));
expect(rendered).toContain("paths: folder with spaces/");
});
it("tree selector renders a single-string search path summary", () => {
const root = makeMessageNode({ role: "user", content: "search", timestamp: 1 });
const assistant = makeMessageNode(
{
role: "assistant",
content: [
{
type: "toolCall",
id: "search-call-1",
name: "search",
arguments: { pattern: "space-needle", paths: "folder with spaces/" },
},
],
api: "test",
provider: "test",
model: "test",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: 2,
stopReason: "stop",
} as AgentMessage,
root.entry.id,
);
const toolResult = makeMessageNode(
{
role: "toolResult",
toolCallId: "search-call-1",
toolName: "search",
content: [{ type: "text", text: "note.txt" }],
isError: false,
timestamp: 3,
} as AgentMessage,
assistant.entry.id,
);
root.children.push(assistant);
assistant.children.push(toolResult);
const rendered = renderTree([root], toolResult.entry.id);
expect(rendered).toContain("[search: /space-needle/ in folder with spaces/]");
expect(rendered).not.toContain("[search: /space-needle/ in .]");
});
it("search keeps a single path that contains spaces", async () => {
const tools = await createTools(createTestSession(tempDir));