Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## [Unreleased]
- Cursor native tool calls (shell/read/write/… oneof variants) now convert their protobuf payloads into plain JSON-safe data before attaching them as toolCall `arguments`: `$typeName` markers are stripped, safe-range bigints become numbers (decimal strings beyond `Number.MAX_SAFE_INTEGER`), byte arrays become base64 strings, and cycles/functions collapse to null. Raw protobuf-es payloads carry `bigint` fields (`fileSize`, `durationMs`, `fileOutputThresholdBytes`, …) that defeat `JSON.stringify`, which broke managed snapshot staging, JSONL transcript persistence, and provider replay — the issue #4578 local-snapshot producer defect class fixed at its producer boundary.
- Generic OpenAI-compatible `/v1/models` discovery now reads served context-window and output-limit metadata instead of defaulting every dynamically listed model to the unknown-window sentinel. `max_model_len` (vLLM/SGLang/oMLX), `context_length`, `context_window`, `max_context_length` (LM Studio), and `max_position_embeddings` populate `contextWindow` in that precedence order, while `max_tokens`/`max_output_tokens` populate `maxTokens`; total-window fields never leak into the output-token ceiling. Malformed values (non-finite, zero, negative, non-numeric) are rejected per-field with fallback to the next candidate, so a `1e400`-style catalog entry can no longer poison compaction thresholds or compact-input budgets.
- Codex websocket requests now abort and close their transport when the downstream event-stream consumer returns early (including managed provisional-buffer rejection), so the next turn opens a clean connection instead of inheriting `websocket request already in progress` (#4534).
- Refreshed the bundled ZAI catalog with GLM-5.3 and made it the provider's default model.
- Added the typed `local_snapshot_failure` and `local_buffer_overflow` assistant error kinds so downstream retry policy can distinguish local event-snapshot and staging-buffer failures from provider failures.
- Anthropic first-event timeouts now report safe elapsed time, serialized request bytes, canonical-vs-custom endpoint class, and the `PI_STREAM_FIRST_EVENT_TIMEOUT_MS` override without exposing URL credentials, query tokens, or body content. Large requests through custom endpoints receive one bounded two-minute observation grace so a slightly later proxy 529 can surface without extending explicit-zero, small-request, or canonical deadlines; full-window multi-megabyte requests are never automatically re-uploaded and small requests get at most one session replay. Credit: @probepark (#4464).
Expand Down
21 changes: 13 additions & 8 deletions packages/ai/src/providers/openai-codex-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1855,24 +1855,29 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
context: Context,
options?: OpenAICodexResponsesOptions,
): AssistantMessageEventStream => {
const stream = new AssistantMessageEventStream();
const consumerAbortController = new AbortController();
const stream = new AssistantMessageEventStream(() => consumerAbortController.abort());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Propagate consumer closure through the lazy stream wrapper

When the coding-agent's managed provisional buffer rejects, it closes the iterator returned by streamSimple, but the main Codex route returns the outer lazy stream from register-builtins.ts; forwardStream owns and continues consuming this inner stream at lines 288-290. Closing the outer iterator therefore never invokes this callback, so the Codex request remains active and the next turn can still encounter websocket request already in progress. Propagate outer consumer closure to the inner iterator/request signal, and cover the public streamSimple path rather than calling the provider implementation directly.

Useful? React with 👍 / 👎.

const signal = options?.signal
? AbortSignal.any([options.signal, consumerAbortController.signal])
: consumerAbortController.signal;
const streamOptions = { ...options, signal };

(async () => {
const startTime = Date.now();
const output = createAssistantOutput(model);
const requestSetup = createRequestSetup(options);
const requestSetup = createRequestSetup(streamOptions);
let processingContext: CodexStreamProcessingContext | undefined;

try {
const requestContext = await buildCodexRequestContext(model, context, options, output);
const requestContext = await buildCodexRequestContext(model, context, streamOptions, output);
let initialTransport: CodexInitialTransport;
try {
initialTransport = await openInitialCodexEventStream(model, options, requestSetup, requestContext);
initialTransport = await openInitialCodexEventStream(model, streamOptions, requestSetup, requestContext);
} catch (error) {
if (options?.fallbackManaged) throw error;
if (streamOptions.fallbackManaged) throw error;
initialTransport = await retryCodexInitialTransportWithoutToolChoice(
model,
options,
streamOptions,
requestSetup,
requestContext,
stream,
Expand All @@ -1891,7 +1896,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
model,
output,
stream,
options,
options: streamOptions,
requestSetup,
requestContext,
startTime,
Expand All @@ -1909,7 +1914,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
model,
output,
stream,
options,
options: streamOptions,
requestSetup,
requestContext: {
apiKey: "",
Expand Down
7 changes: 5 additions & 2 deletions packages/ai/src/providers/register-builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,12 +339,15 @@ function createLazyStream<TApi extends Api>(
limits?: LazyStreamLimits,
): (model: Model<TApi>, context: Context, options: OptionsForApi<TApi>) => EventStreamImpl {
return (model, context, options) => {
const outer = new EventStreamImpl();
let abortTracker: AbortSourceTracker | undefined;
const outer = new EventStreamImpl(() =>
abortTracker?.abortLocally(new Error("Provider stream consumer stopped before completion")),
);
Comment on lines +343 to +345

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Propagate cleanup through the auth-retry wrapper

When streamSimple receives onAuthError on a normal non-fallback request—as the coding-agent SDK does in packages/coding-agent/src/sdk/session.ts—it returns a separate outer stream at packages/ai/src/stream.ts:496 while runAttempt continuously consumes this lazy stream at lines 507-508. Returning from the public iterator therefore never closes this EventStreamImpl, so this new callback is not invoked and the underlying Codex websocket request remains active after provisional-buffer rejection. The fresh evidence at the exact head is that the adversarial test calls streamBedrock directly and bypasses this production auth-retry wrapper; propagate consumer closure from that wrapper to its active inner request as well.

Useful? React with 👍 / 👎.

const streamOptions = (options ?? {}) as OptionsForApi<TApi>;

loadModule()
.then(module => {
const abortTracker = createAbortSourceTracker(streamOptions.signal);
abortTracker = createAbortSourceTracker(streamOptions.signal);
const providerOptions = { ...streamOptions, signal: abortTracker.requestSignal } as OptionsForApi<TApi>;
const inner = module.stream(model, context, providerOptions);
forwardStream(outer, inner, model, streamOptions, abortTracker, limits);
Expand Down
13 changes: 11 additions & 2 deletions packages/ai/src/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,11 @@ export function streamSimple<TApi extends Api>(
}
const retryApiKey = options?.onAuthError ? (options.apiKey ?? getEnvApiKey(model.provider)) : undefined;
if (retryApiKey) {
const outer = new AssistantMessageEventStream();
const consumerAbortController = new AbortController();
const outer = new AssistantMessageEventStream(() => consumerAbortController.abort());
const requestSignal = options?.signal
? AbortSignal.any([options.signal, consumerAbortController.signal])
: consumerAbortController.signal;
const onAuthError = options!.onAuthError!;
const runAttempt = async (apiKey: string, captureAuthFailure: boolean): Promise<AuthRetryFailure | undefined> => {
const bufferedEvents: AssistantMessageEvent[] = [];
Expand All @@ -504,7 +508,12 @@ export function streamSimple<TApi extends Api>(
};

try {
const inner = streamSimple(model, context, { ...options, apiKey, onAuthError: undefined });
const inner = streamSimple(model, context, {
...options,
apiKey,
onAuthError: undefined,
signal: requestSignal,
});
for await (const event of inner) {
if (!emittedReplayUnsafeEvent && event.type === "start") {
bufferedEvents.push(event);
Expand Down
8 changes: 6 additions & 2 deletions packages/ai/src/utils/event-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
rejectFinalResult!: (err: unknown) => void;
isComplete: (event: T) => boolean;
extractResult: (event: T) => R;
#onConsumerClose?: () => void;

constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R) {
constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R, onConsumerClose?: () => void) {
const { promise, resolve, reject } = Promise.withResolvers<R>();
// Prevent an unhandled rejection when fail() is called but nobody awaits result().
// Callers who do await result() still receive the rejection normally.
Expand All @@ -46,6 +47,7 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
this.rejectFinalResult = reject;
this.isComplete = isComplete;
this.extractResult = extractResult;
this.#onConsumerClose = onConsumerClose;
}

#enqueue(node: QueueNode<T>): void {
Expand Down Expand Up @@ -240,6 +242,7 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
} finally {
this.#activeConsumerCount -= 1;
this.#settleAllConsumerDrains("reject", new Error("Event stream consumer stopped before drain completed"));
if (!this.done) this.#onConsumerClose?.();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Abort before awaiting a pending next()

When a consumer calls return() while an earlier next() is still waiting for a provider event—for example, cancelling a stalled Codex request—async-generator operations are serialized, so execution cannot reach this finally block until that pending next() resolves. The new callback therefore does not abort the underlying request promptly, return() can remain blocked until another event or the provider timeout, and a successor request can still encounter the active-request guard; the iterator needs a return path that wakes the pending waiter and invokes cleanup immediately.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex P2 (event-stream.ts:245return() during a pending next() is serialized behind the pending wait) analysis for exact head 9582fc4028:

  • Reachability audit of every return?.() call site on AssistantMessageEventStream consumers:
    • agent-loop.ts:2688 (closeIterator after ManagedAttemptBufferOverflowError / abort / terminal): the loop only calls closeIterator() after the current next() has settled — stageAssistantMessageEvent throws synchronously inside event processing, and the abort path races next() against the signal, so no return() is ever issued while a next() is still pending. Not affected.
    • agent-session.ts:19160 (/btw ephemeral turn finally): consume() has already exited before the finally runs (await awaitEphemeralAbort(consume(), …) settled first), so its next() is not in flight when return() is called. Not affected.
    • idle-iterator.ts:173, coordinator-mcp/server.ts:5887: operate on different iterators (tool/agent event streams), not this class. Not affected.
  • No current consumer of AssistantMessageEventStream calls return() concurrently with a pending next(); the serialization window is real per spec but not reachable today, so it is not a defect in this change. The PR's three adversarial tests pin the reachable cleanup paths.
  • Filing as a non-blocking hardening follow-up (wake the pending waiter in return()), not a blocker for this fix.


[repo owner's gaebal-gajae (clawdbot) 🦞]

}
}

Expand All @@ -249,7 +252,7 @@ export class EventStream<T, R = T> implements AsyncIterable<T> {
}

export class AssistantMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
constructor() {
constructor(onConsumerClose?: () => void) {
super(
event => event.type === "done" || event.type === "error",
event => {
Expand All @@ -260,6 +263,7 @@ export class AssistantMessageEventStream extends EventStream<AssistantMessageEve
}
throw new Error("Unexpected event type for final result");
},
onConsumerClose,
);
}
}
64 changes: 64 additions & 0 deletions packages/ai/test/openai-codex-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2658,6 +2658,70 @@ describe("openai-codex streaming", () => {
expect(transportDetails.fallbackCount).toBe(1);
});

it("releases an in-flight websocket request when the stream consumer returns early", async () => {
const tempDir = TempDir.createSync("@pi-codex-stream-");
setAgentDir(tempDir.path());
const token = createCodexTestToken();
const providerSessionState = new Map<string, ProviderSessionState>();
const sentTypesByConnection: string[][] = [];
let constructorCount = 0;

class ConsumerReturnWebSocket extends MockWebSocket {
#connectionIndex: number;

constructor(url: string, options?: { headers?: WsHeaders }) {
super(url, options);
this.#connectionIndex = constructorCount++;
sentTypesByConnection[this.#connectionIndex] = [];
this.scheduleOpen();
}

send(data: string): void {
const request = JSON.parse(data) as { type?: string };
const requestType = typeof request.type === "string" ? request.type : "";
sentTypesByConnection[this.#connectionIndex]?.push(requestType);
if (this.#connectionIndex === 0) {
this.sendJson({
type: "response.output_item.added",
item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] },
});
this.sendJson({ type: "response.content_part.added", part: { type: "output_text", text: "" } });
this.sendJson({ type: "response.output_text.delta", delta: "oversized provisional payload" });
return;
}
this.emitCodexResponse({ messageId: "msg_2", responseId: "resp_2", text: "clean successor" });
}
}

global.WebSocket = ConsumerReturnWebSocket as unknown as typeof WebSocket;
global.fetch = vi.fn(async () => {
throw new Error("SSE fallback should not be called");
}) as unknown as typeof fetch;
const model = createCodexTestModel("https://chatgpt.com/backend-api");
const first = streamOpenAICodexResponses(model, createCodexTestContext(), {
apiKey: token,
sessionId: "ws-consumer-return-session",
providerSessionState,
});
const iterator = first[Symbol.asyncIterator]();
for (let i = 0; i < 3; i++) {
const event = await iterator.next();
expect(event.done).toBe(false);
}
await iterator.return?.();

const successor = await streamOpenAICodexResponses(model, createCodexTestContext(), {
apiKey: token,
sessionId: "ws-consumer-return-session",
providerSessionState,
}).result();

expect(successor.stopReason).toBe("stop");
expect(successor.content).toEqual([expect.objectContaining({ type: "text", text: "clean successor" })]);
expect(constructorCount).toBe(2);
expect(sentTypesByConnection).toEqual([["response.create"], ["response.create"]]);
});

it("resets websocket append state after an aborted request closes the connection", async () => {
const tempDir = TempDir.createSync("@pi-codex-stream-");
setAgentDir(tempDir.path());
Expand Down
37 changes: 37 additions & 0 deletions packages/ai/test/register-builtins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,43 @@ describe("register-builtins lazy streams", () => {
expect(result).toEqual(finalMessage);
});

it("aborts the lazy provider request when the public stream consumer returns early", async () => {
const partialMessage = createAssistantMessage("stop");
let providerSignal: AbortSignal | undefined;
let providerAborted = false;
const source = {
async *[Symbol.asyncIterator]() {
yield { type: "start", partial: partialMessage } as const;
const { promise, reject } = Promise.withResolvers<never>();
providerSignal?.addEventListener(
"abort",
() => {
providerAborted = true;
reject(new Error("Request was aborted"));
},
{ once: true },
);
await promise;
},
} as unknown as AssistantMessageEventStream;

setBedrockProviderModule({
streamBedrock: (_model, _context, options) => {
providerSignal = options.signal;
return source;
},
});

const stream = streamBedrock(createModel(), baseContext, {});
const iterator = stream[Symbol.asyncIterator]();
expect((await iterator.next()).done).toBe(false);
await iterator.return?.();
await Bun.sleep(0);

expect(providerSignal?.aborted).toBe(true);
expect(providerAborted).toBe(true);
});

it("turns iterator failures into terminal error results", async () => {
const partialMessage = createAssistantMessage("stop");
const source = {
Expand Down
39 changes: 39 additions & 0 deletions packages/ai/test/stream-auth-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,45 @@ describe("streamSimple auth retry", () => {
unregisterCustomApis(SOURCE_ID);
});

it("aborts the active auth-retry request when the public consumer returns early", async () => {
let providerSignal: AbortSignal | undefined;
let providerAborted = false;
registerCustomApi(
API,
(_model: Model<Api>, _context: Context, options?: SimpleStreamOptions) => {
providerSignal = options?.signal;
const stream = new AssistantMessageEventStream();
queueMicrotask(() => {
const message = assistant(["partial"]);
stream.push({ type: "start", partial: message });
stream.push({ type: "text_delta", contentIndex: 0, delta: "partial", partial: message });
});
providerSignal?.addEventListener(
"abort",
() => {
providerAborted = true;
stream.fail(new Error("Request was aborted"));
},
{ once: true },
);
return stream;
},
SOURCE_ID,
);

const stream = streamSimple(model(), context, {
apiKey: "old-key",
onAuthError: async () => "new-key",
});
const iterator = stream[Symbol.asyncIterator]();
expect((await iterator.next()).done).toBe(false);
await iterator.return?.();
await Bun.sleep(0);

expect(providerSignal?.aborted).toBe(true);
expect(providerAborted).toBe(true);
});

it("retries once with a fresh key when 401 happens before the first event", async () => {
const keys: Array<string | undefined> = [];
let authCalls = 0;
Expand Down
Loading