From 2fa7ec17cbe3aa418bda5d520baabc3433dd01dd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 13 Jan 2026 11:06:21 +0700 Subject: [PATCH] WIP: Refactor and polyfill updates - Add crypto polyfill for Expo - Update daemon client and websocket server - Simplify codex agent tests --- package-lock.json | 13 +++++ packages/app/index.ts | 4 ++ packages/app/package.json | 1 + packages/app/src/components/message.tsx | 3 ++ packages/app/src/polyfills/crypto.ts | 24 +++++++++ .../server/src/client/daemon-client-v2.ts | 5 +- .../server/agent/providers/claude-agent.ts | 1 + .../agent/providers/codex-mcp-agent.test.ts | 50 ++++--------------- .../server/agent/providers/codex-mcp-agent.ts | 47 ++++++++--------- .../server/src/server/websocket-server.ts | 12 ++++- packages/server/vitest.config.ts | 7 +-- 11 files changed, 94 insertions(+), 73 deletions(-) create mode 100644 packages/app/src/polyfills/crypto.ts diff --git a/package-lock.json b/package-lock.json index 21cf842eb..d30631ffe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12154,6 +12154,18 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.8.tgz", + "integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-dev-client": { "version": "6.0.16", "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-6.0.16.tgz", @@ -21554,6 +21566,7 @@ "expo-build-properties": "^1.0.9", "expo-clipboard": "~8.0.7", "expo-constants": "~18.0.9", + "expo-crypto": "^15.0.8", "expo-dev-client": "^6.0.15", "expo-file-system": "~19.0.17", "expo-font": "~14.0.9", diff --git a/packages/app/index.ts b/packages/app/index.ts index 6ed2cf260..3a3dc1101 100644 --- a/packages/app/index.ts +++ b/packages/app/index.ts @@ -1,3 +1,7 @@ +// Polyfill crypto.randomUUID for React Native before any other imports +import { polyfillCrypto } from "./src/polyfills/crypto"; +polyfillCrypto(); + // Configure Unistyles before Expo Router pulls in any components using StyleSheet. import "./src/styles/unistyles"; import "expo-router/entry"; diff --git a/packages/app/package.json b/packages/app/package.json index 1e0c9abf3..61a3c5324 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -41,6 +41,7 @@ "expo-build-properties": "^1.0.9", "expo-clipboard": "~8.0.7", "expo-constants": "~18.0.9", + "expo-crypto": "^15.0.8", "expo-dev-client": "^6.0.15", "expo-file-system": "~19.0.17", "expo-font": "~14.0.9", diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 6e20a2208..c3d60b009 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -103,11 +103,14 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({ borderTopRightRadius: theme.borderRadius.sm, paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[4], + minWidth: 0, + flexShrink: 1, }, text: { color: theme.colors.foreground, fontSize: theme.fontSize.base, lineHeight: 22, + overflowWrap: "anywhere", }, bubblePressed: { opacity: 0.85, diff --git a/packages/app/src/polyfills/crypto.ts b/packages/app/src/polyfills/crypto.ts new file mode 100644 index 000000000..81678babe --- /dev/null +++ b/packages/app/src/polyfills/crypto.ts @@ -0,0 +1,24 @@ +import * as ExpoCrypto from "expo-crypto"; + +declare global { + interface Crypto { + randomUUID(): `${string}-${string}-${string}-${string}-${string}`; + } +} + +export function polyfillCrypto(): void { + if (typeof globalThis.crypto === "undefined") { + (globalThis as any).crypto = {}; + } + + if (typeof globalThis.crypto.randomUUID !== "function") { + globalThis.crypto.randomUUID = () => + ExpoCrypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`; + } + + if (typeof globalThis.crypto.getRandomValues !== "function") { + globalThis.crypto.getRandomValues = (array: T): T => { + return ExpoCrypto.getRandomValues(array as any) as T; + }; + } +} diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index 981f5a619..82cad8957 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -1,4 +1,3 @@ -import { nanoid } from "nanoid"; import type { z } from "zod"; import { AgentCreateFailedStatusPayloadSchema, @@ -754,7 +753,7 @@ export class DaemonClientV2 { text: string, options?: SendMessageOptions ): Promise { - const messageId = options?.messageId ?? nanoid(); + const messageId = options?.messageId ?? crypto.randomUUID(); const message = SessionInboundMessageSchema.parse({ type: "send_agent_message", agentId, @@ -1354,7 +1353,7 @@ export class DaemonClientV2 { // ============================================================================ private createRequestId(requestId?: string): string { - return requestId ?? nanoid(); + return requestId ?? crypto.randomUUID(); } private cleanupTransport(): void { diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 8039527b8..055d43fd6 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -719,6 +719,7 @@ class ClaudeAgentSession implements AgentSession { async close(): Promise { this.rejectAllPendingPermissions(new Error("Claude session closed")); this.input?.end(); + await this.query?.interrupt?.(); await this.query?.return?.(); this.query = null; this.input = null; diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts index 033521890..226b179d0 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.test.ts @@ -740,7 +740,7 @@ describe("CodexMcpAgentClient (MCP integration)", () => { ); test( - "captures tool call inputs/outputs for commands, file changes, file reads, MCP tools, and web search", + "captures tool call inputs/outputs for commands, file changes, file reads, and MCP tools", async () => { const cwd = tmpCwd(); const restoreSessionDir = useTempCodexSessionDir(); @@ -751,13 +751,9 @@ describe("CodexMcpAgentClient (MCP integration)", () => { const config = { provider: "codex", cwd, - modeId: "auto", - approvalPolicy: "on-request", - networkAccess: true, + modeId: "full-access", extra: { codex: { - search: true, - features: { web_search_request: true }, mcp_servers: { test: { command: process.execPath, @@ -770,38 +766,22 @@ describe("CodexMcpAgentClient (MCP integration)", () => { } satisfies AgentSessionConfig; let session: AgentSession | null = null; - let permissionRequest: AgentPermissionRequest | null = null; - let permissionResolved = false; const toolCalls: ToolCallItem[] = []; try { session = await client.createSession(config); const prompt = [ - "1. Run the command `bash -lc \"printf 'stdout-marker'\"` using your shell tool.", - "2. Run the command `bash -lc \"printf 'stderr-marker' 1>&2\"` using your shell tool.", - "3. Use apply_patch (not the shell) to create a new file named tool-create.txt containing only the line 'alpha'.", - "4. Use apply_patch (not the shell) to edit tool-create.txt, replacing 'alpha' with 'beta'.", - "5. Read the file tool-create.txt and report its contents (you can use cat or any file reading method).", + "1. Run the command `printf 'stdout-marker'` using your shell tool.", + "2. Run the command `printf 'stderr-marker' 1>&2` using your shell tool.", + "3. Use apply_patch to create a new file named tool-create.txt containing only the line 'alpha'.", + "4. Use apply_patch to edit tool-create.txt, replacing 'alpha' with 'beta'.", + "5. Read the file tool-create.txt using read_file tool.", "6. Call the MCP tool test.echo with input {\"text\":\"mcp-ok\"}.", - "7. Use the web_search tool to search for \"OpenAI Codex MCP\".", - "8. Request approval to run the command `printf \"permit\" > tool-permission.txt`, then run it.", - "9. After all tools finish, reply DONE and stop.", + "7. Reply DONE and stop.", ].join("\n"); for await (const event of session.stream(prompt)) { - if (event.type === "permission_requested" && !permissionRequest) { - permissionRequest = event.request; - await session.respondToPermission(permissionRequest.id, { behavior: "allow" }); - } - if ( - event.type === "permission_resolved" && - permissionRequest && - event.requestId === permissionRequest.id && - event.resolution.behavior === "allow" - ) { - permissionResolved = true; - } if (event.type === "timeline" && providerFromEvent(event) === "codex") { if (event.item.type === "tool_call" && event.item.name !== "permission") { toolCalls.push(event.item); @@ -812,9 +792,6 @@ describe("CodexMcpAgentClient (MCP integration)", () => { } } - expect.soft(permissionRequest).not.toBeNull(); - expect.soft(permissionResolved).toBe(true); - const commandCalls = toolCalls.filter( (item) => item.name === "shell" && item.status === "completed" ); @@ -868,15 +845,6 @@ describe("CodexMcpAgentClient (MCP integration)", () => { expect.soft(stringifyUnknown(mcpCall?.input)).toContain("mcp-ok"); expect.soft(stringifyUnknown(mcpCall?.output)).toContain("mcp-ok"); - const webSearchCall = toolCalls.find( - (item) => item.name === "web_search" - ); - expect.soft(webSearchCall).toBeTruthy(); - expect.soft(stringifyUnknown(webSearchCall?.input)).toContain("OpenAI Codex MCP"); - // NOTE: Codex MCP web_search does not return search results in the event. - // The search happens internally but results are not exposed via MCP events. - // Only verify that the search was performed (input contains query). - const callIdStatuses = new Map>(); for (const toolCall of toolCalls) { if (!toolCall.callId) { @@ -914,7 +882,7 @@ describe("CodexMcpAgentClient (MCP integration)", () => { restoreSessionDir(); } }, - 240_000 + 120_000 ); test( diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.ts index 31b36c4a7..d7abf3279 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.ts @@ -3136,41 +3136,38 @@ class CodexMcpAgentSession implements AgentSession { ? (rawMetadata as Record).codex_elicitation : undefined; - let action: ElicitResult["action"]; - let content: Record | undefined; - let decision: CodexExecApprovalDecision | undefined; - let reason: string | undefined; - - if (codexElicitation === "exec-approval") { - decision = - response.behavior === "allow" - ? "approved" - : response.interrupt - ? "abort" - : "denied"; - reason = response.behavior === "deny" ? response.message : undefined; - const responsePayload: CodexExecApprovalResponse = { - decision, - ...(reason ? { reason } : {}), - }; - pending.resolve(responsePayload); - return; - } else { - action = + // Use MCP ElicitResult format only if explicitly tagged as non-exec-approval + // Default to Codex exec-approval format (what Codex SDK actually sends) + if (codexElicitation && codexElicitation !== "exec-approval") { + const action: ElicitResult["action"] = response.behavior === "allow" ? "accept" : response.interrupt ? "cancel" : "decline"; - content = + const content = response.behavior === "allow" && response.updatedInput ? response.updatedInput : undefined; + const responsePayload: ElicitResult = { + action, + ...(content ? { content } : {}), + }; + pending.resolve(responsePayload); + return; } - const responsePayload: ElicitResult = { - action, - ...(content ? { content } : {}), + // Default: Codex exec-approval format + const decision: CodexExecApprovalDecision = + response.behavior === "allow" + ? "approved" + : response.interrupt + ? "abort" + : "denied"; + const reason = response.behavior === "deny" ? response.message : undefined; + const responsePayload: CodexExecApprovalResponse = { + decision, + ...(reason ? { reason } : {}), }; pending.resolve(responsePayload); } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 712210eb4..983154088 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -294,7 +294,17 @@ export class VoiceAssistantWebSocketServer { const cleanupPromises: Promise[] = []; this.sessions.forEach((session, ws) => { cleanupPromises.push(session.cleanup()); - ws.close(); + // Wait for WebSocket to actually close before resolving + cleanupPromises.push( + new Promise((resolve) => { + if (ws.readyState === WebSocket.CLOSED) { + resolve(); + return; + } + ws.once("close", () => resolve()); + ws.close(); + }) + ); }); await Promise.all(cleanupPromises); this.wss.close(); diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts index 6f3b21999..ce699e864 100644 --- a/packages/server/vitest.config.ts +++ b/packages/server/vitest.config.ts @@ -13,10 +13,11 @@ export default defineConfig({ globals: true, environment: "node", setupFiles: [path.resolve(__dirname, "./src/test-utils/vitest-setup.ts")], - pool: "threads", + pool: "forks", poolOptions: { - threads: { - singleThread: true, + forks: { + singleFork: false, + maxForks: 4, }, }, },