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
56 changes: 56 additions & 0 deletions packages/gambit-simulator/simulator-ui/src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TraceEvent> = [
{
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<TraceEvent> = [
{
Expand Down
72 changes: 62 additions & 10 deletions packages/gambit-simulator/simulator-ui/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>): string => {
Expand Down Expand Up @@ -1282,36 +1291,53 @@ 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;
}
entries.push({
kind: "message",
role: "assistant",
content: text,
content: input.text,
});
assistantIndexById.set(messageId, entries.length - 1);
};
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 63 additions & 4 deletions packages/gambit-simulator/src/server_session_store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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" ||
Expand Down Expand Up @@ -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:");
Expand All @@ -1225,6 +1271,7 @@ export const createSessionStore = (deps: SessionStoreDeps) => {
itemKind: "message",
role,
content: text,
contentMode: "replace",
messageRefId,
reasoningType: null,
summary: null,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1381,7 +1440,7 @@ export const createSessionStore = (deps: SessionStoreDeps) => {
row.itemId,
row.itemKind,
row.role,
row.content,
content,
row.messageRefId,
row.reasoningType,
summary,
Expand Down
9 changes: 9 additions & 0 deletions src/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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('"', '\\"')}"`;
}
Expand Down Expand Up @@ -372,6 +378,9 @@ function codexConfigArgs(input: {
}): Array<string> {
const args: Array<string> = [];
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) {
Expand Down
21 changes: 21 additions & 0 deletions src/providers/codex_app_server.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { assertEquals } from "@std/assert";
import {
appServerRequestResultForTest,
codexConfigArgsForTest,
setCodexHostAuthBridgeForTests,
} from "./codex.ts";

Expand Down Expand Up @@ -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);
}
}
});
Loading
Loading