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:
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ?? {}),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user