feat(ai): added websocket frame forwarding for raw SSE debug events

- Added helpers to synthesize RawSseEvent records for inbound, outbound, and malformed Codex WebSocket traffic.
- Passed onSseEvent through websocket transport and stream setup so frames are forwarded to the raw-SSE debug pipeline during streaming.
- Added a stream test that verifies outbound and inbound websocket frames are emitted with SSE-style raw lines for the debug viewer.
This commit is contained in:
can1357
2026-05-26 10:46:00 +02:00
parent f9866798c0
commit 44f85beb4a
7 changed files with 270 additions and 7 deletions
+1
View File
@@ -9,6 +9,7 @@
- Added `PI_CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS` and `PI_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS` options to tune Codex WebSocket timeout behavior before fallback
- Added `AuthStorage.getOAuthAccess` to return a refreshed OAuth access token with identity metadata (`accountId`, `email`, `projectId`, `enterpriseUrl`) for callers that need bearer-token headers together
- Added Codex WebSocket forwarding to the `onSseEvent` observer so the raw provider-stream debug viewer captures the inbound JSON frames and the outbound request frame from the WS transport using the same synthesized SSE-wire shape (`event:` + `data:` lines, prefixed with a `: ws ← <type>` (inbound) or `: ws → <type>` (outbound) comment).
### Changed
@@ -29,6 +29,7 @@ import {
type FetchImpl,
type Model,
type ProviderSessionState,
type RawSseEvent,
resolveServiceTier,
type ServiceTier,
type StreamFunction,
@@ -50,6 +51,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
import { parseStreamingJson } from "../utils/json-parse";
import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
import { notifyRawSseEvent } from "../utils/sse-debug";
import { compactGrammarDefinition } from "./grammar";
import { CODEX_BASE_URL, getCodexAccountId, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "./openai-codex/constants";
import {
@@ -356,6 +358,52 @@ function extractCodexWebSocketHandshakeHeaders(socket: Bun.WebSocket, openEvent?
);
}
// Synthesizes a `RawSseEvent` for a Codex WebSocket frame so the same debug
// pipeline used for HTTP SSE (`onSseEvent` → `RawSseDebugBuffer.recordEvent`)
// also captures WebSocket traffic. The `raw` array mirrors SSE wire format
// (one line per field) so the existing TUI viewer renders it identically:
// : ws ← <type>
// event: <type>
// data: <json>
// Outbound (client → server) uses `: ws → <type>`. The viewer pretty-prints
// `data:` JSON lines, so we keep the wire JSON single-line here and let the
// renderer expand it.
function notifyCodexWebSocketInbound(
observer: ((event: RawSseEvent) => void) | undefined,
parsed: Record<string, unknown>,
text: string,
): void {
const type = typeof parsed.type === "string" ? parsed.type : null;
const raw: string[] = [`: ws ← ${type ?? "(untyped)"}`];
if (type) raw.push(`event: ${type}`);
raw.push(`data: ${text}`);
notifyRawSseEvent(observer, { event: type, data: text, raw });
}
function notifyCodexWebSocketOutbound(
observer: ((event: RawSseEvent) => void) | undefined,
request: Record<string, unknown>,
payload: string,
): void {
const type = typeof request.type === "string" ? request.type : null;
const raw: string[] = [`: ws → ${type ?? "(untyped)"}`];
if (type) raw.push(`event: ${type}`);
raw.push(`data: ${payload}`);
notifyRawSseEvent(observer, { event: type, data: payload, raw });
}
function notifyCodexWebSocketMalformed(
observer: ((event: RawSseEvent) => void) | undefined,
data: unknown,
error: unknown,
): void {
const text = typeof data === "string" ? data : "";
const reason = error instanceof Error ? error.message : String(error);
const raw: string[] = [`: ws ← (parse-error: ${reason})`];
if (text) raw.push(`data: ${text}`);
notifyRawSseEvent(observer, { event: "parse_error", data: text, raw });
}
/** @internal Exported for tests. */
export function normalizeCodexToolChoice(
choice: ToolChoice | undefined,
@@ -613,7 +661,13 @@ async function openInitialCodexEventStream(
let websocketRetries = 0;
while (true) {
try {
return await openCodexWebSocketTransport(requestContext, requestSetup, websocketState, websocketRetries);
return await openCodexWebSocketTransport(
requestContext,
requestSetup,
websocketState,
websocketRetries,
options ? event => options.onSseEvent?.(event, model) : undefined,
);
} catch (error) {
const websocketError = error instanceof Error ? error : new Error(String(error));
const isFatal = isCodexWebSocketFatalError(websocketError);
@@ -644,6 +698,7 @@ async function openCodexWebSocketTransport(
requestSetup: CodexRequestSetup,
websocketState: CodexWebSocketSessionState,
retry: number,
onSseEvent?: (event: RawSseEvent) => void,
): Promise<{
eventStream: AsyncGenerator<Record<string, unknown>>;
requestBodyForState: RequestBody;
@@ -676,6 +731,7 @@ async function openCodexWebSocketTransport(
websocketRequest,
websocketState,
requestSetup.requestSignal,
onSseEvent,
);
return { eventStream, requestBodyForState, transport: "websocket" };
}
@@ -721,6 +777,7 @@ async function reopenCodexWebSocketRuntimeStream(
context.requestSetup,
state,
runtime.websocketStreamRetries,
context.options ? event => context.options?.onSseEvent?.(event, context.model) : undefined,
);
runtime.eventStream = next.eventStream;
runtime.requestBodyForState = next.requestBodyForState;
@@ -1865,6 +1922,7 @@ class CodexWebSocketConnection {
#waiters: Array<() => void> = [];
#connectPromise?: Promise<void>;
#activeRequest = false;
#streamObserver?: (event: RawSseEvent) => void;
constructor(url: string, headers: Record<string, string>, options: CodexWebSocketConnectionOptions) {
this.#url = url;
@@ -1983,8 +2041,10 @@ class CodexWebSocketConnection {
parsed.message = inner.message;
}
}
notifyCodexWebSocketInbound(this.#streamObserver, parsed, text);
this.#push(parsed);
} catch (error) {
notifyCodexWebSocketMalformed(this.#streamObserver, event.data, error);
this.#push(createCodexWebSocketTransportError(String(error)));
}
};
@@ -2000,6 +2060,7 @@ class CodexWebSocketConnection {
async *streamRequest(
request: Record<string, unknown>,
signal?: AbortSignal,
onSseEvent?: (event: RawSseEvent) => void,
): AsyncGenerator<Record<string, unknown>> {
if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
throw createCodexWebSocketTransportError("websocket connection is unavailable");
@@ -2008,6 +2069,7 @@ class CodexWebSocketConnection {
throw createCodexWebSocketTransportError("websocket request already in progress");
}
this.#activeRequest = true;
this.#streamObserver = onSseEvent;
const onAbort = () => {
this.close("aborted");
this.#push(createCodexWebSocketTransportError("request was aborted"));
@@ -2021,7 +2083,9 @@ class CodexWebSocketConnection {
}
try {
this.#socket.send(JSON.stringify(request));
const requestPayload = JSON.stringify(request);
notifyCodexWebSocketOutbound(onSseEvent, request, requestPayload);
this.#socket.send(requestPayload);
let sawFirstEvent = false;
let lastProgressAt = Date.now();
while (true) {
@@ -2060,6 +2124,7 @@ class CodexWebSocketConnection {
}
} finally {
this.#activeRequest = false;
this.#streamObserver = undefined;
if (signal) {
signal.removeEventListener("abort", onAbort);
}
@@ -2193,9 +2258,10 @@ async function openCodexWebSocketEventStream(
request: Record<string, unknown>,
state: CodexWebSocketSessionState,
signal?: AbortSignal,
onSseEvent?: (event: RawSseEvent) => void,
): Promise<AsyncGenerator<Record<string, unknown>>> {
const connection = await getOrCreateCodexWebSocketConnection(state, url, headers, signal);
return connection.streamRequest(request, signal);
return connection.streamRequest(request, signal, onSseEvent);
}
function createCodexHeaders(
+4 -1
View File
@@ -336,7 +336,10 @@ export interface StreamOptions {
*/
onResponse?: (response: ProviderResponseMetadata, model?: Model<Api>) => void | Promise<void>;
/**
* Optional callback for raw Server-Sent Events as they arrive from HTTP streaming providers.
* Optional callback for raw Server-Sent Events as they arrive from HTTP streaming providers,
* plus synthesized SSE-shaped frames for the Codex WebSocket transport (one synthetic frame
* per JSON request/response message). WebSocket frames are tagged with a leading
* `: ws → <type>` (outbound) or `: ws ← <type>` (inbound) comment line in `RawSseEvent.raw`.
*
* Diagnostic only: provider implementations must ignore callback failures and must not
* let observers alter stream contents.
@@ -359,6 +359,63 @@ describe("openai-codex streaming", () => {
expect(result.stopReason).toBe("stop");
});
it("forwards websocket frames through onSseEvent for the raw-SSE debug viewer", async () => {
const tempDir = TempDir.createSync("@pi-codex-stream-");
setAgentDir(tempDir.path());
const token = createCodexTestToken();
class ObservedWebSocket extends MockWebSocket {
constructor(url: string, options?: { headers?: WsHeaders }) {
super(url, options);
this.scheduleOpen();
}
send(): void {
this.emitCodexResponse({ messageId: "msg_obs", responseId: "resp_obs", text: "Observed" });
}
}
global.WebSocket = ObservedWebSocket as unknown as typeof WebSocket;
const observed: Array<{ event: string | null; data: string; raw: string[] }> = [];
const result = await streamOpenAICodexResponses(
createCodexTestModel("https://chatgpt.com/backend-api"),
createCodexTestContext(),
{
apiKey: token,
sessionId: "ws-observer-session",
providerSessionState: new Map<string, ProviderSessionState>(),
onSseEvent: event => {
observed.push({ event: event.event, data: event.data, raw: [...event.raw] });
},
},
).result();
expect(result.stopReason).toBe("stop");
// First record is the outbound request frame (the JSON we sent).
const [outbound, ...inbound] = observed;
expect(outbound).toBeDefined();
expect(outbound.raw[0]).toMatch(/^: ws → /);
expect(outbound.data.length).toBeGreaterThan(0);
expect(() => JSON.parse(outbound.data)).not.toThrow();
// Inbound frames mirror the Codex response sequence emitted by `emitCodexResponse`.
expect(inbound.map(e => e.event)).toEqual([
"response.output_item.added",
"response.content_part.added",
"response.output_text.delta",
"response.output_item.done",
"response.done",
]);
for (const event of inbound) {
expect(event.raw[0]).toBe(`: ws ← ${event.event}`);
// Synthesized SSE wire shape: `event:` line then `data:` line.
expect(event.raw[1]).toBe(`event: ${event.event}`);
expect(event.raw[2]).toBe(`data: ${event.data}`);
expect(JSON.parse(event.data)).toMatchObject({ type: event.event });
}
});
it("omits request-body headers and replaces stale beta headers for websocket handshakes", async () => {
const tempDir = TempDir.createSync("@pi-codex-stream-");
setAgentDir(tempDir.path());
+1
View File
@@ -10,6 +10,7 @@
- Added `codex` and `gemini` to the web search provider settings so users can configure OpenAI and Gemini web search directly from provider selection
- Added OpenAI (`codex`) and Gemini web search options with updated setup descriptions for `omp /login openai-codex` and Gemini OAuth login
- Added pretty-printing for wide JSON `data:` payloads in the raw provider-stream debug viewer so streamed event bodies expand across multiple `data:` lines instead of getting clipped by the per-line truncator, and updated the viewer header to read `raw provider stream (SSE + WS)` now that Codex WebSocket frames also flow through the buffer
### Changed
+68 -3
View File
@@ -2,15 +2,56 @@ import { type Component, matchesKey, padding, replaceTabs, truncateToWidth, visi
import { sanitizeText } from "@oh-my-pi/pi-utils";
import { theme } from "../modes/theme/theme";
import { copyToClipboard } from "../utils/clipboard";
import { formatRawSseIsoTime, type RawSseDebugBuffer, rawSseRecordLines } from "./raw-sse-buffer";
import {
formatRawSseIsoTime,
type RawSseDebugBuffer,
type RawSseDebugRecord,
rawSseRecordLines,
} from "./raw-sse-buffer";
const MIN_VIEWER_WIDTH = 20;
const VIEWER_FRAME_LINES = 5;
// `data:` lines below this width render fine on a single row; anything wider gets pretty-printed
// across multiple `data:` lines so streamed JSON blobs stop getting clipped by `truncateToWidth`.
const PRETTY_PRINT_DATA_THRESHOLD = 100;
function sanitizeFrameLine(line: string, width: number): string {
return truncateToWidth(replaceTabs(sanitizeText(line)), width);
}
// Walks the SSE wire lines and replaces single-line `data: <json>` payloads with
// multi-line `data: <indented-json>` entries when the JSON is wide enough to clip.
// Multi-line `data:` is still valid SSE (the spec joins lines with `\n`), so the
// transformed view round-trips back to the same event when copied.
/** @internal Exported for tests. */
export function expandPrettyDataLines(raw: readonly string[]): string[] {
const out: string[] = [];
for (const line of raw) {
if (!line.startsWith("data: ") || line.length <= PRETTY_PRINT_DATA_THRESHOLD) {
out.push(line);
continue;
}
const body = line.slice("data: ".length);
const trimmed = body.trim();
if (trimmed.length === 0 || (trimmed[0] !== "{" && trimmed[0] !== "[")) {
out.push(line);
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
out.push(line);
continue;
}
const pretty = JSON.stringify(parsed, null, 2);
for (const prettyLine of pretty.split("\n")) {
out.push(`data: ${prettyLine}`);
}
}
return out;
}
export interface RawSseViewerOptions {
buffer: RawSseDebugBuffer;
terminalRows: number;
@@ -30,6 +71,12 @@ export class RawSseViewerComponent implements Component {
#followTail = true;
#lastRenderWidth = MIN_VIEWER_WIDTH;
#statusMessage: string | undefined;
// Pretty-printed wire lines keyed by `record.sequence`. Pretty-printing is
// the JSON.parse + JSON.stringify per `data:` line, so we cache the result —
// the render path runs on every keypress and from `#maxScrollOffset()`.
// Sequences are monotonic; we prune entries below the oldest live record
// after each render so the cache tracks the buffer's eviction window.
readonly #prettyLinesCache = new Map<number, string[]>();
constructor(options: RawSseViewerOptions) {
this.#buffer = options.buffer;
@@ -131,8 +178,9 @@ export class RawSseViewerComponent implements Component {
);
lines.push("");
}
const firstSequence = snapshot.records[0]?.sequence;
for (const record of snapshot.records) {
for (const line of rawSseRecordLines(record)) {
for (const line of this.#prettyLinesFor(record)) {
lines.push(sanitizeFrameLine(line, innerWidth));
}
if (record.kind === "event" && record.truncated) {
@@ -140,14 +188,31 @@ export class RawSseViewerComponent implements Component {
}
lines.push("");
}
if (firstSequence !== undefined) this.#pruneCache(firstSequence);
return lines;
}
#prettyLinesFor(record: RawSseDebugRecord): string[] {
const cached = this.#prettyLinesCache.get(record.sequence);
if (cached) return cached;
const expanded = expandPrettyDataLines(rawSseRecordLines(record));
this.#prettyLinesCache.set(record.sequence, expanded);
return expanded;
}
#pruneCache(firstSequence: number): void {
// Bounded by the buffer eviction rate; with `MAX_RAW_SSE_EVENTS = 1000`
// this rarely runs and only walks freshly-evicted entries.
for (const key of this.#prettyLinesCache.keys()) {
if (key < firstSequence) this.#prettyLinesCache.delete(key);
}
}
#summaryText(): string {
const snapshot = this.#buffer.snapshot();
const last = snapshot.lastUpdatedAt ? ` last=${formatRawSseIsoTime(snapshot.lastUpdatedAt)}` : "";
const follow = this.#followTail ? "follow:on" : "follow:off";
return ` # raw SSE | events=${snapshot.totalEvents} records=${snapshot.records.length}${last} | ${follow} | Esc back Ctrl+C copy End follow`;
return ` # raw provider stream (SSE + WS) | events=${snapshot.totalEvents} records=${snapshot.records.length}${last} | ${follow} | Esc back Ctrl+C copy End follow`;
}
#statusText(): string {
@@ -0,0 +1,70 @@
import { describe, expect, it } from "bun:test";
import { expandPrettyDataLines } from "../../src/debug/raw-sse";
// Wide enough that `truncateToWidth` would clip the payload in the viewer; matches
// what real Codex `response.output_item.done` frames look like on the wire.
function wideObjectLine(): string {
const payload = {
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1234567890",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Hello there, this is a long enough message" }],
},
};
return `data: ${JSON.stringify(payload)}`;
}
describe("expandPrettyDataLines", () => {
it("expands wide JSON `data:` payloads into multi-line indented `data:` entries", () => {
const input = [`: ws ← response.output_item.done`, `event: response.output_item.done`, wideObjectLine()];
const out = expandPrettyDataLines(input);
// Comment + event: lines pass through.
expect(out[0]).toBe(input[0]);
expect(out[1]).toBe(input[1]);
// Original payload spans many lines now, each prefixed with `data: `.
const dataLines = out.slice(2);
expect(dataLines.length).toBeGreaterThan(1);
for (const line of dataLines) {
expect(line.startsWith("data: ")).toBe(true);
}
// The expanded JSON, with `data: ` stripped, round-trips to the original payload.
const rejoined = dataLines.map(line => line.slice("data: ".length)).join("\n");
expect(JSON.parse(rejoined)).toEqual(JSON.parse(wideObjectLine().slice("data: ".length)));
});
it("leaves short single-line payloads alone so small deltas stay compact", () => {
const input = [
`: ws ← response.output_text.delta`,
`event: response.output_text.delta`,
`data: {"type":"response.output_text.delta","delta":"hi"}`,
];
expect(expandPrettyDataLines(input)).toEqual([...input]);
});
it("falls back to the raw line when the payload is wide but not JSON", () => {
// Wide enough to cross the threshold but doesn't start with `{` or `[`.
const wideNonJson = `data: ${"x".repeat(200)}`;
const out = expandPrettyDataLines([wideNonJson]);
expect(out).toEqual([wideNonJson]);
});
it("falls back when the payload is wide JSON-looking text but parses as invalid", () => {
const wideBrokenJson = `data: {"unterminated":"${"x".repeat(200)}`;
const out = expandPrettyDataLines([wideBrokenJson]);
expect(out).toEqual([wideBrokenJson]);
});
it("preserves non-`data:` lines (event/comment) verbatim regardless of length", () => {
const wideComment = `: ${"x".repeat(300)}`;
const wideEvent = `event: ${"x".repeat(300)}`;
const out = expandPrettyDataLines([wideComment, wideEvent]);
expect(out).toEqual([wideComment, wideEvent]);
});
});