WIP: Refactor and polyfill updates

- Add crypto polyfill for Expo
- Update daemon client and websocket server
- Simplify codex agent tests
This commit is contained in:
Mohamed Boudra
2026-01-13 11:06:21 +07:00
parent 868f9fb571
commit 2fa7ec17cb
11 changed files with 94 additions and 73 deletions

13
package-lock.json generated
View File

@@ -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",

View File

@@ -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";

View File

@@ -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",

View File

@@ -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,

View File

@@ -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 = <T extends ArrayBufferView>(array: T): T => {
return ExpoCrypto.getRandomValues(array as any) as T;
};
}
}

View File

@@ -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<void> {
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 {

View File

@@ -719,6 +719,7 @@ class ClaudeAgentSession implements AgentSession {
async close(): Promise<void> {
this.rejectAllPendingPermissions(new Error("Claude session closed"));
this.input?.end();
await this.query?.interrupt?.();
await this.query?.return?.();
this.query = null;
this.input = null;

View File

@@ -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<string, Set<string>>();
for (const toolCall of toolCalls) {
if (!toolCall.callId) {
@@ -914,7 +882,7 @@ describe("CodexMcpAgentClient (MCP integration)", () => {
restoreSessionDir();
}
},
240_000
120_000
);
test(

View File

@@ -3136,41 +3136,38 @@ class CodexMcpAgentSession implements AgentSession {
? (rawMetadata as Record<string, unknown>).codex_elicitation
: undefined;
let action: ElicitResult["action"];
let content: Record<string, unknown> | 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);
}

View File

@@ -294,7 +294,17 @@ export class VoiceAssistantWebSocketServer {
const cleanupPromises: Promise<void>[] = [];
this.sessions.forEach((session, ws) => {
cleanupPromises.push(session.cleanup());
ws.close();
// Wait for WebSocket to actually close before resolving
cleanupPromises.push(
new Promise<void>((resolve) => {
if (ws.readyState === WebSocket.CLOSED) {
resolve();
return;
}
ws.once("close", () => resolve());
ws.close();
})
);
});
await Promise.all(cleanupPromises);
this.wss.close();

View File

@@ -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,
},
},
},