diff --git a/packages/gambit-simulator/simulator-ui/src/utils.test.ts b/packages/gambit-simulator/simulator-ui/src/utils.test.ts index 372ed5d7..6b3b448f 100644 --- a/packages/gambit-simulator/simulator-ui/src/utils.test.ts +++ b/packages/gambit-simulator/simulator-ui/src/utils.test.ts @@ -225,6 +225,62 @@ Deno.test("deriveBuildDisplayMessages dedupes model.result after streamed output assertEquals(assistantRows[0]?.content, "hi there"); }); +Deno.test("deriveBuildDisplayMessages preserves whitespace in streamed output text deltas", () => { + const traces: Array = [ + { + type: "model.stream.event", + actionCallId: "action-1", + event: { + type: "codex.event", + payload: { + type: "response.output_text.delta", + output_index: 0, + item_id: "msg_1", + delta: "Hello", + }, + }, + }, + { + type: "model.stream.event", + actionCallId: "action-1", + event: { + type: "codex.event", + payload: { + type: "response.output_text.delta", + output_index: 0, + item_id: "msg_1", + delta: " ", + }, + }, + }, + { + type: "model.stream.event", + actionCallId: "action-1", + event: { + type: "codex.event", + payload: { + type: "response.output_text.delta", + output_index: 0, + item_id: "msg_1", + delta: "world", + }, + }, + }, + { + type: "model.result", + actionCallId: "action-1", + message: { role: "assistant", content: "Hello world " }, + }, + ]; + + const display = deriveBuildDisplayMessages([], traces); + const assistantRows = display.filter((row) => + row.kind === "message" && row.role === "assistant" + ); + assertEquals(assistantRows.length, 1); + assertEquals(assistantRows[0]?.content, "Hello world "); +}); + Deno.test("deriveBuildDisplayMessages projects namespaced extension items", () => { const traces: Array = [ { diff --git a/packages/gambit-simulator/simulator-ui/src/utils.ts b/packages/gambit-simulator/simulator-ui/src/utils.ts index 8a8ff198..498bb23b 100644 --- a/packages/gambit-simulator/simulator-ui/src/utils.ts +++ b/packages/gambit-simulator/simulator-ui/src/utils.ts @@ -1119,20 +1119,29 @@ export function deriveBuildDisplayMessages( return String(value); } }; + const hasVisibleText = (value: string): boolean => value.trim().length > 0; const pushAssistantMessage = (content: string) => { + if (!hasVisibleText(content)) return; const normalized = content.trim(); - if (!normalized) return; for (let i = entries.length - 1; i >= 0; i -= 1) { const entry = entries[i]; if (entry?.kind !== "message") continue; if (entry.role !== "assistant") continue; - if ((entry.content ?? "").trim() === normalized) return; + if ((entry.content ?? "").trim() === normalized) { + if (entry.content !== content) { + entries[i] = { + ...entry, + content, + }; + } + return; + } break; } entries.push({ kind: "message", role: "assistant", - content: normalized, + content, }); }; const extractReasoningText = (payload: Record): string => { @@ -1282,28 +1291,45 @@ export function deriveBuildDisplayMessages( }; }; const upsertAssistantMessage = ( - input: { messageId: string; text: string }, + input: { messageId: string; text: string; mode?: "append" | "replace" }, ) => { - const text = input.text.trim(); - if (!text) return; const messageId = input.messageId || `assistant-${entries.length}`; + const existingIndex = assistantIndexById.get(messageId); + if ( + !hasVisibleText(input.text) && + existingIndex === undefined && + input.mode !== "append" + ) return; for (let i = entries.length - 1; i >= 0; i -= 1) { const entry = entries[i]; if (entry?.kind !== "message") continue; if (entry.role !== "assistant") continue; - if ((entry.content ?? "").trim() === text) { + if ( + hasVisibleText(input.text) && + (entry.content ?? "").trim() === input.text.trim() + ) { + if (entry.content !== input.text) { + entries[i] = { + ...entry, + content: input.text, + }; + } assistantIndexById.set(messageId, i); return; } break; } - const existingIndex = assistantIndexById.get(messageId); if (existingIndex !== undefined) { const existing = entries[existingIndex]; if (existing?.kind === "message" && existing.role === "assistant") { + const previousText = existing.content ?? ""; + const nextText = input.mode === "append" + ? `${previousText}${input.text}` + : input.text; + if (!hasVisibleText(nextText)) return; entries[existingIndex] = { ...existing, - content: text, + content: nextText, }; } return; @@ -1311,7 +1337,7 @@ export function deriveBuildDisplayMessages( entries.push({ kind: "message", role: "assistant", - content: text, + content: input.text, }); assistantIndexById.set(messageId, entries.length - 1); }; @@ -1385,6 +1411,32 @@ export function deriveBuildDisplayMessages( }); continue; } + if ( + payloadType === "response.output_text.delta" || + payloadType === "response.output_text.done" + ) { + const outputIndex = typeof payload.output_index === "number" + ? String(payload.output_index) + : ""; + const actionScope = asString(record.actionCallId) || + asString(record.runId); + const baseMessageId = asString(payload.item_id) || + (outputIndex ? `output-${outputIndex}` : payloadType); + const text = payloadType === "response.output_text.delta" + ? asString(payload.delta) + : asString(payload.text); + if (payloadType === "response.output_text.delta" && text.length === 0) { + continue; + } + upsertAssistantMessage({ + messageId: scopedId(actionScope, baseMessageId), + text, + mode: payloadType === "response.output_text.delta" + ? "append" + : "replace", + }); + continue; + } const item = asRecord(payload.item); if (!item) continue; const itemType = asString(item.type); diff --git a/packages/gambit-simulator/src/server/workspace/response_events.ts b/packages/gambit-simulator/src/server/workspace/response_events.ts index c4127c6c..3481d486 100644 --- a/packages/gambit-simulator/src/server/workspace/response_events.ts +++ b/packages/gambit-simulator/src/server/workspace/response_events.ts @@ -162,8 +162,8 @@ export const createOpenResponsesEventPersistence = (deps: { ) { continue; } - const content = stringifyMessageContent(message.content).trim(); - if (!content) continue; + const content = stringifyMessageContent(message.content); + if (content.trim().length === 0) continue; const ref = refs[index]; const messageRefId = typeof ref?.id === "string" && ref.id.trim().length > 0 diff --git a/packages/gambit-simulator/src/server_session_store.ts b/packages/gambit-simulator/src/server_session_store.ts index dacf6b90..214bd5f9 100644 --- a/packages/gambit-simulator/src/server_session_store.ts +++ b/packages/gambit-simulator/src/server_session_store.ts @@ -1046,6 +1046,7 @@ export const createSessionStore = (deps: SessionStoreDeps) => { itemKind: "message" | "reasoning" | "tool_call"; role: string | null; content: string | null; + contentMode?: "replace" | "append"; messageRefId: string | null; reasoningType: string | null; summary: string | null; @@ -1080,6 +1081,7 @@ export const createSessionStore = (deps: SessionStoreDeps) => { itemKind: "message", role, content: text, + contentMode: "replace", messageRefId: asString(payload.message_id).trim() || asString(payload.messageRefId).trim() || null, @@ -1096,6 +1098,49 @@ export const createSessionStore = (deps: SessionStoreDeps) => { sequence: event.sequence, }]; } + if ( + payloadType === "response.output_text.delta" || + payloadType === "response.output_text.done" + ) { + const outputIndex = asFiniteNumber(payload.output_index); + const itemId = asString(payload.item_id).trim() || + `message-${outputIndex ?? event.sequence}`; + const text = payloadType === "response.output_text.delta" + ? asString(payload.delta) + : asString(payload.text); + if (payloadType === "response.output_text.delta" && text.length === 0) { + return []; + } + if ( + text.trim().length === 0 && payloadType !== "response.output_text.delta" + ) { + return []; + } + return [{ + workspaceId: event.workspace_id, + runId: event.run_id, + itemKey: `message:assistant:${itemId}`, + itemId, + itemKind: "message", + role: "assistant", + content: text, + contentMode: payloadType === "response.output_text.delta" + ? "append" + : "replace", + messageRefId: null, + reasoningType: null, + summary: null, + summaryMode: "replace", + toolCallId: null, + toolName: null, + toolStatus: null, + argumentsText: null, + resultText: null, + errorText: null, + outputIndex, + sequence: event.sequence, + }]; + } if ( payloadType === "response.reasoning.delta" || payloadType === "response.reasoning.done" || @@ -1200,9 +1245,10 @@ export const createSessionStore = (deps: SessionStoreDeps) => { itemType === "message" || itemType === "agent_message" || itemType === "assistant_message" ) { - const text = toEventMessageText(item.content).trim() || - toEventMessageText(item.text).trim(); - if (!text) return []; + const contentText = toEventMessageText(item.content); + const fallbackText = toEventMessageText(item.text); + const text = contentText.trim().length > 0 ? contentText : fallbackText; + if (text.trim().length === 0) return []; const role = asString(item.role).trim() || "assistant"; const rawItemId = asString(item.id).trim() || null; const canonicalBackfill = event.idempotency_key.includes(":canonical:"); @@ -1225,6 +1271,7 @@ export const createSessionStore = (deps: SessionStoreDeps) => { itemKind: "message", role, content: text, + contentMode: "replace", messageRefId, reasoningType: null, summary: null, @@ -1334,6 +1381,18 @@ export const createSessionStore = (deps: SessionStoreDeps) => { | undefined; summary = `${existing?.summary ?? ""}${row.summary ?? ""}`; } + let content = row.content; + if (row.itemKind === "message" && row.contentMode === "append") { + const existing = db.prepare(` + SELECT content + FROM ${OPENRESPONSES_OUTPUT_ITEMS_SQLITE_TABLE} + WHERE workspace_id = ? AND run_id = ? AND item_key = ? + LIMIT 1 + `).get(row.workspaceId, row.runId, itemKey) as + | { content?: string | null } + | undefined; + content = `${existing?.content ?? ""}${row.content ?? ""}`; + } db.prepare(` INSERT INTO ${OPENRESPONSES_OUTPUT_ITEMS_SQLITE_TABLE} ( workspace_id, @@ -1381,7 +1440,7 @@ export const createSessionStore = (deps: SessionStoreDeps) => { row.itemId, row.itemKind, row.role, - row.content, + content, row.messageRefId, row.reasoningType, summary, diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 1ed5e30c..882f03ee 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -28,6 +28,7 @@ const CODEX_REASONING_SUMMARY_ENV = "GAMBIT_CODEX_REASONING_SUMMARY"; const CODEX_VERBOSITY_ENV = "GAMBIT_CODEX_VERBOSITY"; const CODEX_BIN_ENV = "GAMBIT_CODEX_BIN"; const CODEX_SKIP_SANDBOX_CONFIG_ENV = "GAMBIT_CODEX_SKIP_SANDBOX_CONFIG"; +const CODEX_DISABLE_WEBSOCKETS_ENV = "GAMBIT_CODEX_DISABLE_WEBSOCKETS"; const CODEX_DANGEROUS_BYPASS_ENV = "GAMBIT_CODEX_DANGEROUSLY_BYPASS_APPROVALS_AND_SANDBOX"; const MCP_DENO_BIN_ENV = "GAMBIT_MCP_DENO_BIN"; @@ -265,6 +266,11 @@ function shouldSkipCodexSandboxConfig( return Boolean(envRaw && parseTruthy(envRaw)); } +function shouldDisableCodexWebsockets(): boolean { + const envRaw = Deno.env.get(CODEX_DISABLE_WEBSOCKETS_ENV); + return Boolean(envRaw && parseTruthy(envRaw)); +} + function tomlString(value: string): string { return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; } @@ -372,6 +378,9 @@ function codexConfigArgs(input: { }): Array { const args: Array = []; args.push(...codexAdditionalConfigArgs(input.params)); + if (shouldDisableCodexWebsockets()) { + args.push("-c", "model_providers.openai.supports_websockets=false"); + } args.push("-c", `approval_policy=${tomlString("never")}`); const pathEnv = Deno.env.get("PATH")?.trim(); if (pathEnv) { diff --git a/src/providers/codex_app_server.test.ts b/src/providers/codex_app_server.test.ts index ee3eadcf..46da3290 100644 --- a/src/providers/codex_app_server.test.ts +++ b/src/providers/codex_app_server.test.ts @@ -1,6 +1,7 @@ import { assertEquals } from "@std/assert"; import { appServerRequestResultForTest, + codexConfigArgsForTest, setCodexHostAuthBridgeForTests, } from "./codex.ts"; @@ -33,3 +34,23 @@ Deno.test("codex app-server refresh host failures are returned as RPC errors", a setCodexHostAuthBridgeForTests(null); } }); + +Deno.test("codex config can disable OpenAI websocket responses", () => { + const previous = Deno.env.get("GAMBIT_CODEX_DISABLE_WEBSOCKETS"); + Deno.env.set("GAMBIT_CODEX_DISABLE_WEBSOCKETS", "1"); + + try { + const args = codexConfigArgsForTest({ cwd: "/workspace" }); + + assertEquals( + args.includes("model_providers.openai.supports_websockets=false"), + true, + ); + } finally { + if (previous === undefined) { + Deno.env.delete("GAMBIT_CODEX_DISABLE_WEBSOCKETS"); + } else { + Deno.env.set("GAMBIT_CODEX_DISABLE_WEBSOCKETS", previous); + } + } +}); diff --git a/src/server_session_store_output_projection.test.ts b/src/server_session_store_output_projection.test.ts index f402f9f9..2a743f66 100644 --- a/src/server_session_store_output_projection.test.ts +++ b/src/server_session_store_output_projection.test.ts @@ -387,3 +387,89 @@ leakTolerantTest( } }, ); + +leakTolerantTest( + "openresponses output projection preserves whitespace across streamed deltas and final text", + async () => { + const dir = await Deno.makeTempDir(); + const sessionsRoot = path.join(dir, "sessions"); + const workspaceId = "workspace-output-delta-whitespace"; + const sessionDir = path.join(sessionsRoot, workspaceId); + const statePath = path.join(sessionDir, "state.json"); + const eventsPath = path.join(sessionDir, "events.jsonl"); + const sqlitePath = path.join(sessionDir, "workspace.sqlite"); + const store = createProjectionStore({ + sessionsRoot, + workspaceId, + sessionDir, + statePath, + eventsPath, + }); + + const initialState = store.persistSessionState({ + runId: workspaceId, + messages: [], + meta: { workspaceId, sessionId: workspaceId }, + }); + const runId = "run-1"; + const itemId = "msg-assistant-1"; + + for (const [index, delta] of ["Hello", " ", "world"].entries()) { + await store.appendOpenResponsesRunEvent(initialState, { + workspace_id: workspaceId, + run_id: runId, + event_type: "response.output_text.delta", + payload: { + type: "response.output_text.delta", + output_index: 0, + item_id: itemId, + delta, + }, + idempotency_key: `${runId}:delta:${index}`, + }); + } + await store.appendOpenResponsesRunEvent(initialState, { + workspace_id: workspaceId, + run_id: runId, + event_type: "response.output_text.done", + payload: { + type: "response.output_text.done", + output_index: 0, + item_id: itemId, + text: "Hello world ", + }, + idempotency_key: `${runId}:done`, + }); + + const outputItems = store.listOpenResponsesOutputItems({ + workspaceId, + runId, + }); + const assistantMessages = outputItems.filter((item): item is { + __typename: "OutputMessage"; + id: string; + role: string; + content: string; + messageRefId?: string; + feedbackEligible: boolean; + } => + item.__typename === "OutputMessage" && + item.role === "assistant" + ); + assertEquals(assistantMessages.length, 1); + assertEquals(assistantMessages[0]?.content, "Hello world "); + + const db = new DatabaseSync(sqlitePath); + try { + const rows = db.prepare(` + SELECT content + FROM openresponses_output_items_v0 + WHERE workspace_id = ? AND run_id = ? + `).all(workspaceId, runId) as Array<{ content: string | null }>; + assertEquals(rows.length, 1); + assertEquals(rows[0]?.content, "Hello world "); + } finally { + db.close(); + } + }, +);