diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index bdce415abe81..87539537fc2c 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -77,12 +77,12 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; +import { ServerConfig } from "../../config.ts"; +import { buildCodexDeveloperInstructions } from "../../provider/CodexDeveloperInstructions.ts"; import { describeMcpElicitation, toMcpElicitationResponse, -} from "../../provider/Layers/CodexSessionRuntime.ts"; -import { ServerConfig } from "../../config.ts"; -import { buildCodexDeveloperInstructions } from "../../provider/CodexDeveloperInstructions.ts"; +} from "../../provider/CodexMcpElicitation.ts"; import { materializeCodexShadowHome, resolveCodexHomeLayout, @@ -896,8 +896,7 @@ export const resolveCodexForkBoundary = Effect.fn("CodexAdapterV2.resolveForkBou /** * The generated `thread/read` response schema does not surface `historyMode`, - * so the probe goes through the raw request channel with a permissive decode - * (mirrors the V1 session runtime's paginated-history detection). + * so the probe goes through the raw request channel with a permissive decode. */ const CodexThreadHistoryMetadata = Schema.Struct({ thread: Schema.Struct({ diff --git a/apps/server/src/orchestration-v2/TODO.md b/apps/server/src/orchestration-v2/TODO.md index 315a644c0d4a..a4ac59fc8c5f 100644 --- a/apps/server/src/orchestration-v2/TODO.md +++ b/apps/server/src/orchestration-v2/TODO.md @@ -16,8 +16,8 @@ implementation checklist for `apps/server/src/orchestration-v2`. to `thread/fork` as `lastTurnId`; the fork-then-rollback fallback remains only for source turns without a native turn reference and only on legacy-history threads. - Codex provider conversation rollback supports only legacy-history threads. The adapter probes - `historyMode` and fails explicitly on paginated threads; the V1 session runtime's - `thread/turns/list` + `thread/revert` path is the reference for closing this gap. + `historyMode` and fails explicitly on paginated threads. Closing the gap means paging + `thread/turns/list` to find the first removed turn, then `thread/revert` with `beforeTurnId`. - Native Codex fork-from-earlier-run has a real replay-backed test fixture: `testkit/fixtures/thread_fork_native_prior_turn`. - Merge-back from a fork into its source thread records a `merge_back` context transfer, materializes diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts new file mode 100644 index 000000000000..4e2d2f2b0fc2 --- /dev/null +++ b/apps/server/src/provider/CodexDeveloperInstructions.test.ts @@ -0,0 +1,102 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import { buildCodexDeveloperInstructions } from "./CodexDeveloperInstructions.ts"; + +describe("buildCodexDeveloperInstructions", () => { + it("appends runtime info after the mode instructions", () => { + const instructions = buildCodexDeveloperInstructions("default", { + model: "gpt-5.3-codex", + reasoningEffort: "high", + }); + + NodeAssert.match(instructions, /^# Collaboration Mode: Default/); + NodeAssert.match(instructions, /T3 Code/); + NodeAssert.match(instructions, /Codex harness/); + NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/); + }); + + it("describes Markdown media support in the runtime context in both modes", () => { + for (const mode of ["default", "plan"] as const) { + const instructions = buildCodexDeveloperInstructions(mode, { + model: "gpt-5.3-codex", + reasoningEffort: "high", + }); + NodeAssert.match( + instructions, + /.*embed images and videos.*Markdown.*<\/runtime_info>/, + ); + } + }); + + it("includes runtime info alongside plan mode instructions", () => { + const instructions = buildCodexDeveloperInstructions("plan", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }); + + NodeAssert.match(instructions, /^# Plan Mode/); + NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/); + }); + + it("varies with the model and effort of each turn", () => { + const first = buildCodexDeveloperInstructions("default", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }); + const second = buildCodexDeveloperInstructions("default", { + model: "gpt-5.4", + reasoningEffort: "high", + }); + + NodeAssert.notEqual(first, second); + }); + + it("flattens multiline metadata into single-line runtime info", () => { + const instructions = buildCodexDeveloperInstructions("default", { + model: "gpt\n5.3\ncodex", + reasoningEffort: " high\neffort ", + }); + + NodeAssert.match(instructions, /as gpt 5\.3 codex with high effort reasoning effort/); + NodeAssert.doesNotMatch(instructions, /[^<]*\n/); + }); +}); + +describe("T3 browser developer instructions", () => { + const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; + + it("prefers the product-native preview tools in both collaboration modes", () => { + for (const mode of ["default", "plan"] as const) { + const instructions = buildCodexDeveloperInstructions(mode, runtime, true); + NodeAssert.match(instructions, /t3-code/); + NodeAssert.match(instructions, /preview_status/); + NodeAssert.match(instructions, /preview_open/); + NodeAssert.match(instructions, /Do not switch to global browser skills/); + } + }); + + it("omits the browser block entirely when the preview tools are not attached", () => { + for (const mode of ["default", "plan"] as const) { + const instructions = buildCodexDeveloperInstructions(mode, runtime, false); + NodeAssert.doesNotMatch(instructions, /preview_status/); + NodeAssert.doesNotMatch(instructions, /preview_open/); + NodeAssert.doesNotMatch(instructions, /T3 Code collaborative browser/); + // Steering away from other browser automation must go with the tools; + // keeping it would leave the model talked out of its only option. + NodeAssert.doesNotMatch(instructions, /Do not switch to global browser skills/); + // The rest of the collaboration mode is untouched. + NodeAssert.match(instructions, //); + NodeAssert.match(instructions, /<\/collaboration_mode>/); + } + }); + + it("tracks the turn's MCP configuration rather than defaulting to on", () => { + NodeAssert.match(buildCodexDeveloperInstructions("default", runtime, true), /preview_open/); + NodeAssert.doesNotMatch( + buildCodexDeveloperInstructions("default", runtime, false), + /preview_open/, + ); + }); +}); diff --git a/apps/server/src/provider/CodexMcpElicitation.test.ts b/apps/server/src/provider/CodexMcpElicitation.test.ts new file mode 100644 index 000000000000..c0655faf0d67 --- /dev/null +++ b/apps/server/src/provider/CodexMcpElicitation.test.ts @@ -0,0 +1,208 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; +import type * as EffectCodexSchema from "effect-codex-app-server/schema"; + +import { describeMcpElicitation, toMcpElicitationResponse } from "./CodexMcpElicitation.ts"; + +describe("Codex MCP elicitation approvals", () => { + const request = { + mode: "form", + message: "Allow ChatGPT to use Safari?", + serverName: "computer-use", + threadId: "provider-thread-1", + turnId: "turn-1", + _meta: { + app_name: "Safari", + persist: ["session", "always"], + }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + oneOf: [ + { const: "once", title: "Allow once" }, + { const: "session", title: "Allow for this session" }, + { const: "always", title: "Always allow Safari" }, + ], + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + it("preserves the app name and advertised persistence choices", () => { + NodeAssert.deepStrictEqual(describeMcpElicitation(request), { + appName: "Safari", + options: [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "acceptForSession", label: "Allow for this session" }, + { decision: "acceptAlways", label: "Always allow Safari" }, + { decision: "accept", label: "Approve" }, + ], + }); + }); + + it("extracts the app name from a Computer Use request without metadata", () => { + const { _meta, ...requestWithoutMetadata } = request; + + NodeAssert.equal(describeMcpElicitation(requestWithoutMetadata).appName, "Safari"); + }); + + it("returns the accepted form option to Codex", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "accept"), { + action: "accept", + content: { approval: "once" }, + }); + }); + + it("returns session-scoped approval in the MCP response", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptForSession"), { + action: "accept", + _meta: { persist: "session" }, + content: { approval: "session" }, + }); + }); + + it("returns persistent approval in the MCP response", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { approval: "always" }, + }); + }); + + it("returns rejection without form content", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "decline"), { + action: "decline", + }); + }); + + it("returns cancellation without form content", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "cancel"), { + action: "cancel", + }); + }); + + it("supports boolean permanent-approval fields", () => { + const booleanRequest = { + ...request, + _meta: { app_name: "Safari" }, + requestedSchema: { + type: "object", + properties: { + always: { type: "boolean", title: "Always allow Safari" }, + }, + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.ok( + describeMcpElicitation(booleanRequest).options.some( + (option) => option.decision === "acceptAlways", + ), + ); + NodeAssert.deepStrictEqual(toMcpElicitationResponse(booleanRequest, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { always: true }, + }); + }); + + it("preserves valid nullable MCP form fields and persistence choices", () => { + const nullableRequest = { + ...request, + _meta: { + app_name: null, + appName: "Safari", + connector_name: null, + persist: null, + target: null, + tool_params: null, + }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + title: null, + description: null, + default: null, + enum: ["once", "always"], + enumNames: null, + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.equal(describeMcpElicitation(nullableRequest).appName, "Safari"); + NodeAssert.ok( + describeMcpElicitation(nullableRequest).options.some( + (option) => option.decision === "acceptAlways", + ), + ); + NodeAssert.deepStrictEqual(toMcpElicitationResponse(nullableRequest, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { approval: "always" }, + }); + }); + + it("declines required form fields that an approval prompt cannot collect", () => { + const inputRequest = { + ...request, + requestedSchema: { + type: "object", + properties: { + email: { type: "string", format: "email" }, + }, + required: ["email"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(toMcpElicitationResponse(inputRequest, "accept"), { + action: "decline", + }); + }); + + it("does not approve URL elicitations without opening their requested URL", () => { + const urlRequest = { + mode: "url", + message: "Finish signing in to continue.", + serverName: "computer-use", + threadId: "provider-thread-1", + turnId: "turn-1", + elicitationId: "sign-in-1", + url: "https://example.com/authorize", + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(toMcpElicitationResponse(urlRequest, "accept"), { + action: "decline", + }); + }); + + it("omits persistence choices that cannot satisfy required form fields", () => { + const onceOnlyRequest = { + ...request, + _meta: { app_name: "Safari", persist: ["session", "always"] }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + enum: ["once"], + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(describeMcpElicitation(onceOnlyRequest).options, [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "accept", label: "Approve" }, + ]); + }); +}); diff --git a/apps/server/src/provider/CodexMcpElicitation.ts b/apps/server/src/provider/CodexMcpElicitation.ts new file mode 100644 index 000000000000..9a8826d78c80 --- /dev/null +++ b/apps/server/src/provider/CodexMcpElicitation.ts @@ -0,0 +1,222 @@ +import type { ProviderApprovalDecision, ProviderApprovalOption } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import type * as EffectCodexSchema from "effect-codex-app-server/schema"; + +const NullableMcpElicitationString = Schema.NullOr(Schema.String); +const McpElicitationMetadata = Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + app_name: Schema.optionalKey(NullableMcpElicitationString), + appName: Schema.optionalKey(NullableMcpElicitationString), + connector_name: Schema.optionalKey(NullableMcpElicitationString), + connectorName: Schema.optionalKey(NullableMcpElicitationString), + allowPersistentApproval: Schema.optionalKey(Schema.NullOr(Schema.Boolean)), + persist: Schema.optionalKey( + Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)])), + ), + target: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + name: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), + tool_params: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + app_name: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), +}); +const McpElicitationFormField = Schema.Struct({ + type: Schema.optionalKey(NullableMcpElicitationString), + title: Schema.optionalKey(NullableMcpElicitationString), + description: Schema.optionalKey(NullableMcpElicitationString), + default: Schema.optionalKey(Schema.Unknown), + enum: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), + enumNames: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), + oneOf: Schema.optionalKey( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + const: Schema.String, + title: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), + ), +}); +const McpElicitationForm = Schema.Struct({ + properties: Schema.optionalKey(Schema.Record(Schema.String, McpElicitationFormField)), + required: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), +}); +const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata); +const isMcpElicitationForm = Schema.is(McpElicitationForm); + +type McpElicitationPersistenceDecision = Extract< + ProviderApprovalDecision, + "acceptForSession" | "acceptAlways" +>; + +function mcpElicitationPersistenceDecision( + value: string, +): McpElicitationPersistenceDecision | null { + const normalized = value.toLowerCase(); + if (normalized.includes("session")) return "acceptForSession"; + if ( + normalized.includes("always") || + normalized.includes("permanent") || + normalized.includes("forever") || + normalized.includes("persistent") + ) { + return "acceptAlways"; + } + return null; +} + +function mcpElicitationFormFields(payload: EffectCodexSchema.McpServerElicitationRequestParams) { + if (payload.mode === "url" || !isMcpElicitationForm(payload.requestedSchema)) { + return undefined; + } + return payload.requestedSchema; +} + +function mcpElicitationFieldOptions(field: typeof McpElicitationFormField.Type) { + if (field.oneOf) { + return field.oneOf.map((option) => ({ value: option.const, label: option.title })); + } + return (field.enum ?? []).map((value, index) => ({ + value, + label: field.enumNames?.[index], + })); +} + +function isMcpElicitationPersistenceField( + key: string, + field: typeof McpElicitationFormField.Type, +): boolean { + return ( + mcpElicitationPersistenceDecision(key) !== null || + key.toLowerCase() === "persist" || + mcpElicitationPersistenceDecision(field.title ?? "") !== null || + mcpElicitationPersistenceDecision(field.description ?? "") !== null + ); +} + +/** Returns the app and approval choices advertised by an MCP elicitation. */ +export function describeMcpElicitation( + payload: EffectCodexSchema.McpServerElicitationRequestParams, +): { readonly appName: string; readonly options: ReadonlyArray } { + const metadata = isMcpElicitationMetadata(payload._meta) ? payload._meta : undefined; + const appName = + metadata?.app_name ?? + metadata?.appName ?? + metadata?.app ?? + metadata?.target?.app ?? + metadata?.target?.name ?? + metadata?.tool_params?.app_name ?? + metadata?.tool_params?.app ?? + payload.message.match(/^Allow ChatGPT to use (.+?)\?$/i)?.[1] ?? + metadata?.connector_name ?? + metadata?.connectorName ?? + payload.serverName; + const persistenceOptions = new Map(); + const persist = metadata?.persist; + for (const value of typeof persist === "string" ? [persist] : (persist ?? [])) { + const decision = mcpElicitationPersistenceDecision(value); + if (decision) persistenceOptions.set(decision, ""); + } + if (metadata?.allowPersistentApproval) { + persistenceOptions.set("acceptAlways", ""); + } + + const form = mcpElicitationFormFields(payload); + for (const [key, field] of Object.entries(form?.properties ?? {})) { + for (const option of mcpElicitationFieldOptions(field)) { + const decision = mcpElicitationPersistenceDecision(option.value); + if (decision) persistenceOptions.set(decision, option.label ?? ""); + } + if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) { + persistenceOptions.set("acceptAlways", field.title ?? ""); + } + } + + return { + appName, + options: [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + ...(persistenceOptions.has("acceptForSession") && + toMcpElicitationResponse(payload, "acceptForSession").action === "accept" + ? [ + { + decision: "acceptForSession" as const, + label: persistenceOptions.get("acceptForSession") || "Always allow this session", + }, + ] + : []), + ...(persistenceOptions.has("acceptAlways") && + toMcpElicitationResponse(payload, "acceptAlways").action === "accept" + ? [ + { + decision: "acceptAlways" as const, + label: persistenceOptions.get("acceptAlways") || "Always allow", + }, + ] + : []), + { decision: "accept", label: "Approve" }, + ], + }; +} + +/** Converts a T3 approval decision into the MCP elicitation wire response. */ +export function toMcpElicitationResponse( + payload: EffectCodexSchema.McpServerElicitationRequestParams, + decision: ProviderApprovalDecision, +): EffectCodexSchema.McpServerElicitationRequestResponse { + if (decision === "decline" || decision === "cancel") { + return { action: decision }; + } + + if (payload.mode === "url") { + return { action: "decline" }; + } + + const persist = + decision === "acceptForSession" + ? "session" + : decision === "acceptAlways" + ? "always" + : undefined; + const form = mcpElicitationFormFields(payload); + const content: Record = {}; + + for (const [key, field] of Object.entries(form?.properties ?? {})) { + const options = mcpElicitationFieldOptions(field); + const chosenOption = options.find((option) => + persist + ? mcpElicitationPersistenceDecision(option.value) === decision + : /once|accept|approve|allow/i.test(option.value) && + mcpElicitationPersistenceDecision(option.value) === null, + ); + if (chosenOption) { + content[key] = chosenOption.value; + } else if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) { + content[key] = decision === "acceptAlways"; + } else if (field.default !== undefined && field.default !== null) { + content[key] = field.default; + } + } + + if (form?.required?.some((key) => !Object.hasOwn(content, key))) { + return { action: "decline" }; + } + + return { + action: "accept", + ...(persist ? { _meta: { persist } } : {}), + ...(form ? { content } : {}), + }; +} diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts deleted file mode 100644 index 2a9fb56c186a..000000000000 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ /dev/null @@ -1,870 +0,0 @@ -/** - * Runtime-level collab regression: boots the REAL CodexSessionRuntime against - * a scripted mock app-server peer that replays the captured multi-agent wire - * sequence (codexMultiAgentWire.json) plus the shapes the capture alone can't - * script (receiver-turn bookkeeping via collabAgentToolCall, child terminal - * lifecycle, approval pass-through). This is the layer the pure routing-table - * test can't reach: ordering between the legacy receiver-turn suppressor and - * v2 interception, registration state, and synthetic event emission. - */ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; -import { type ProviderApprovalDecision, type ProviderEvent, ThreadId } from "@t3tools/contracts"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; -import { assert, describe } from "vite-plus/test"; - -import wireFixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" }; -import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; - -const ROOT = wireFixture.rootThreadId; -const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string]; -const MEMORY = "memory-consolidation-thread"; -const decodeMcpElicitationResponse = Schema.decodeUnknownEffect( - Schema.fromJsonString( - Schema.Struct({ - id: Schema.Number, - result: Schema.Unknown, - }), - ), -); - -/** - * The captured sequence, extended with the shapes the live capture didn't - * include: a collabAgentToolCall with receiverThreadIds (feeds the legacy - * receiver-turn map, so ordering vs. v2 interception is exercised), child - * terminal lifecycle, and a serverRequest/resolved addressed to a child - * (must pass through to the parent path, not vanish). - */ -function buildScript() { - const captured = wireFixture.notifications; - const extras = [ - { - method: "item/completed", - params: { - threadId: ROOT, - item: { - type: "collabAgentToolCall", - id: "call_fixture_wait", - tool: "wait", - status: "completed", - senderThreadId: ROOT, - receiverThreadIds: [CHILD_A, CHILD_B], - }, - }, - }, - // Child terminal lifecycle AFTER the receiver map knows the children — - // pre-fix, the legacy suppressor dropped these before interception saw - // them, so no synthetic agent events were emitted. - { - method: "turn/completed", - params: { - threadId: CHILD_A, - turn: { id: `${CHILD_A}-turn-1`, status: "completed", items: [] }, - }, - }, - { method: "thread/closed", params: { threadId: CHILD_B } }, - // Parent-owned traffic addressed to a child conversation: must reach the - // parent path (approval correlation cleanup), not be swallowed. - { method: "serverRequest/resolved", params: { threadId: CHILD_A, requestId: "req-1" } }, - ]; - return { - rootThreadId: ROOT, - notifications: [...captured.filter((entry) => entry.method !== "turn/completed"), ...extras], - }; -} - -function capturedStartedActivity(childId = CHILD_A) { - const captured = wireFixture.notifications.find((entry) => { - const item = (entry.params as { item?: { type?: string; kind?: string } }).item; - return item?.type === "subAgentActivity" && item.kind === "started"; - }); - assert.isDefined(captured); - return { - ...captured, - params: { - ...captured.params, - item: { - ...captured.params.item, - agentThreadId: childId, - agentPath: "/root/model-check", - }, - }, - }; -} - -function capturedSpawnedThread(childId = CHILD_A) { - const captured = wireFixture.notifications.find((entry) => entry.method === "thread/started"); - assert.isDefined(captured); - return { - ...captured, - params: { - thread: { - ...captured.params.thread, - id: childId, - sessionId: childId, - parentThreadId: ROOT, - agentNickname: "model-check", - agentRole: "verifier", - source: { - subAgent: { - thread_spawn: { - agent_nickname: "model-check", - agent_path: "/root/model-check", - agent_role: "verifier", - depth: 1, - parent_thread_id: ROOT, - }, - }, - }, - }, - }, - }; -} - -function childSettings(threadId: string, model: string, effort: string) { - return { - method: "thread/settings/updated", - params: { - threadId, - threadSettings: { - approvalPolicy: "on-request", - approvalsReviewer: "auto_review", - collaborationMode: { mode: "default", settings: { model } }, - cwd: "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/workspace/repo", - effort, - model, - modelProvider: "openai", - sandboxPolicy: { type: "dangerFullAccess" }, - }, - }, - }; -} - -function readRecordedRequests() { - return NodeFS.readFileSync(`${scriptPath}.requests`, "utf8") - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as { method: string; params: Record }); -} - -const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json"); -// Windows cannot run the shebang wrapper; the .cmd sibling does the same job. -const peerPath = NodePath.join( - import.meta.dirname, - `../testFixtures/codexCollabMockPeer.${HostProcessPlatform.defaultValue() === "win32" ? "cmd" : "sh"}`, -); - -describe("CodexSessionRuntime collab integration", () => { - it.effect("looks up child model metadata once after activity registration", () => - Effect.gen(function* () { - const script = { - rootThreadId: ROOT, - recordRequests: true, - notifications: [ - capturedStartedActivity(), - capturedStartedActivity(), - { - ...capturedStartedActivity(CHILD_B), - params: { - ...capturedStartedActivity(CHILD_B).params, - item: { ...capturedStartedActivity(CHILD_B).params.item, kind: "interacted" }, - }, - }, - { method: "thread/closed", params: { threadId: CHILD_B } }, - capturedSpawnedThread(ROOT), - ], - childResumeSnapshots: { - [CHILD_A]: { model: "gpt-5.6-luna", reasoningEffort: "low" }, - }, - }; - // @effect-diagnostics-next-line preferSchemaOverJson:off - NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); - NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - NodeFS.rmSync(scriptPath, { force: true }); - NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); - }), - ); - - const runtime = yield* makeCodexSessionRuntime({ - threadId: ThreadId.make("thread-collab-model-activity"), - binaryPath: peerPath, - cwd: NodeOS.tmpdir(), - runtimeMode: "full-access", - environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, - }); - const metadataFiber = yield* runtime.events.pipe( - Stream.filter( - (event) => - event.method === "collabAgent/metadataUpdated" && - (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A, - ), - Stream.take(1), - Stream.runCollect, - Effect.forkScoped, - ); - - const session = yield* runtime.start(); - assert.equal(session.model, "gpt-5.6-sol"); - yield* runtime.sendTurn({ input: "start one child" }); - const metadataEvents = Array.from(yield* Fiber.join(metadataFiber)); - assert.deepInclude(metadataEvents[0]?.payload, { - agentThreadId: CHILD_A, - model: "gpt-5.6-luna", - effort: "low", - }); - assert.deepEqual(readRecordedRequests(), [ - { - method: "thread/resume", - params: { threadId: CHILD_A, excludeTurns: true }, - }, - ]); - - yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("keeps child settings and reroutes newer than the resume snapshot", () => - Effect.gen(function* () { - const statusChanged = wireFixture.notifications.find( - (entry) => - entry.method === "thread/status/changed" && - (entry.params as { threadId?: string }).threadId === CHILD_A, - ); - assert.isDefined(statusChanged); - const script = { - rootThreadId: ROOT, - recordRequests: true, - notifications: [ - childSettings(CHILD_A, "child-before", "medium"), - capturedSpawnedThread(), - childSettings(CHILD_A, "child-after", "high"), - { - method: "model/rerouted", - params: { - threadId: CHILD_A, - turnId: `${CHILD_A}-turn`, - fromModel: "child-after", - toModel: "child-rerouted", - reason: "highRiskCyberActivity", - }, - }, - { - method: "model/rerouted", - params: { - threadId: ROOT, - turnId: `${ROOT}-turn`, - fromModel: "gpt-5.6-sol", - toModel: "root-rerouted", - reason: "highRiskCyberActivity", - }, - }, - ], - childResumeSnapshots: { - [CHILD_A]: { - model: "stale-snapshot", - reasoningEffort: "low", - notifications: [statusChanged], - }, - }, - }; - // @effect-diagnostics-next-line preferSchemaOverJson:off - NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); - NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - NodeFS.rmSync(scriptPath, { force: true }); - NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); - }), - ); - - const runtime = yield* makeCodexSessionRuntime({ - threadId: ThreadId.make("thread-collab-model-spawn"), - binaryPath: peerPath, - cwd: NodeOS.tmpdir(), - runtimeMode: "full-access", - environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, - }); - const eventsFiber = yield* runtime.events.pipe( - Stream.takeUntil( - (event) => - event.method === "collabAgent/statusChanged" && - (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A, - ), - Stream.runCollect, - Effect.forkScoped, - ); - - yield* runtime.start(); - yield* runtime.sendTurn({ input: "start one spawned child" }); - const events = Array.from(yield* Fiber.join(eventsFiber)); - const started = events.find((event) => event.method === "collabAgent/started"); - assert.deepInclude(started?.payload, { - agentThreadId: CHILD_A, - model: "child-before", - effort: "medium", - }); - const childStatus = events.find((event) => event.method === "collabAgent/statusChanged"); - assert.deepInclude(childStatus?.payload, { - agentThreadId: CHILD_A, - model: "child-rerouted", - effort: "high", - }); - assert.isTrue( - events.some( - (event) => - event.method === "model/rerouted" && - (event.payload as { threadId?: string }).threadId === ROOT, - ), - "the root reroute must stay on the parent path", - ); - assert.isFalse( - events.some( - (event) => - (event.method === "thread/settings/updated" || event.method === "model/rerouted") && - (event.payload as { threadId?: string }).threadId === CHILD_A, - ), - "child metadata notifications must not leak to the parent path", - ); - assert.equal(readRecordedRequests().length, 1); - - yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("does not delay the parent turn when the child lookup fails", () => - Effect.gen(function* () { - yield* Effect.addFinalizer(() => - Effect.sync(() => { - NodeFS.rmSync(scriptPath, { force: true }); - NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); - }), - ); - for (const [name, childSnapshot] of [ - ["hang", { hang: true }], - ["error", { error: "child unavailable" }], - ] as const) { - yield* Effect.gen(function* () { - const marker = `lookup-${name}`; - const script = { - rootThreadId: ROOT, - recordRequests: true, - resumeRequestMarker: marker, - notifications: [capturedStartedActivity()], - childResumeSnapshots: { [CHILD_A]: childSnapshot }, - }; - // @effect-diagnostics-next-line preferSchemaOverJson:off - NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); - NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); - - const runtime = yield* makeCodexSessionRuntime({ - threadId: ThreadId.make(`thread-collab-model-${name}`), - binaryPath: peerPath, - cwd: NodeOS.tmpdir(), - runtimeMode: "full-access", - environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, - }); - const eventsFiber = yield* runtime.events.pipe( - Stream.takeUntil( - (event) => - event.method === "serverRequest/resolved" && - (event.payload as { requestId?: string }).requestId === marker, - ), - Stream.runCollect, - Effect.forkScoped, - ); - - yield* runtime.start(); - yield* runtime.sendTurn({ input: "finish without child metadata" }); - const events = Array.from(yield* Fiber.join(eventsFiber)); - assert.isTrue(events.some((event) => event.method === "turn/completed")); - assert.equal(readRecordedRequests().length, 1); - - yield* runtime.close; - NodeFS.rmSync(scriptPath, { force: true }); - NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); - }).pipe(Effect.scoped); - } - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("replays the captured fan-out into synthetic agent events without child leaks", () => - Effect.gen(function* () { - // @effect-diagnostics-next-line preferSchemaOverJson:off - NodeFS.writeFileSync(scriptPath, JSON.stringify(buildScript()), "utf8"); - yield* Effect.addFinalizer(() => - Effect.sync(() => NodeFS.rmSync(scriptPath, { force: true })), - ); - - const runtime = yield* makeCodexSessionRuntime({ - threadId: ThreadId.make("thread-collab-integration"), - binaryPath: peerPath, - cwd: NodeOS.tmpdir(), - runtimeMode: "full-access", - environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, - }); - - const eventsFiber = yield* runtime.events.pipe( - Stream.takeUntil((event) => event.method === "turn/completed"), - Stream.runCollect, - Effect.forkScoped, - ); - - yield* runtime.start(); - yield* runtime.sendTurn({ input: "fan out" }); - - const events = Array.from(yield* Fiber.join(eventsFiber)); - const methods = events.map((event) => event.method); - - // Children registered from subAgentActivity become synthetic agent - // lifecycle — including terminal rows that arrive AFTER the receiver - // map knows them (the ordering this test exists to pin). - assert.include(methods, "collabAgent/activity"); - assert.include(methods, "collabAgent/turnCompleted"); - assert.include(methods, "collabAgent/closed"); - - const childTurnCompleted = events.find( - (event) => - event.method === "collabAgent/turnCompleted" && - (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A, - ); - assert.isDefined(childTurnCompleted, "child A's turn completion becomes an agent event"); - - const childClosed = events.find( - (event) => - event.method === "collabAgent/closed" && - (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_B, - ); - assert.isDefined(childClosed, "child B's close becomes an agent event"); - - // Parent-owned resolution passes through — not swallowed, not - // re-labelled as an agent event. - assert.include(methods, "serverRequest/resolved"); - - // The root's own subAgentActivity about "/root" must NOT register the - // root as a child: the parent turn completion still flows. - assert.include(methods, "turn/completed"); - - // No raw child conversation methods leak onto the parent stream. - const leaked = events.filter((event) => { - const payload = event.payload as { threadId?: string } | undefined; - const addressedToChild = payload?.threadId === CHILD_A || payload?.threadId === CHILD_B; - return addressedToChild && (event.method?.startsWith("thread/") ?? false); - }); - assert.deepEqual( - leaked.map((event) => event.method), - [], - "child thread/* lifecycle must not appear as parent events", - ); - - yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - // it.live: the runtime talks to a real child process; under it.effect's - // TestClock the internal timers freeze and the join never completes. - it.live("Stop interrupts every live child regardless of registration timing", () => - Effect.gen(function* () { - // Ordering + liveness torture for stop-everything: child A's - // turn/started arrives BEFORE anything registers it (foreign - // suppression path must record the live turn); child B's arrives after - // registration; child A's interrupt HANGS (RPC never settles — worse - // than rejecting) and the bounded deadline must still deliver B's and - // the parent's interrupts. The turn stays open so children are live - // when Stop fires. - // Build from REAL captured rows (hand-written shapes fail notification - // schema validation and are silently dropped): reorder so child A's - // turn/started precedes its registration, and drop terminal rows so - // children stay live when Stop fires. - const byIndex = wireFixture.notifications; - const isTurnStarted = (entry: (typeof byIndex)[number], child: string) => - entry.method === "turn/started" && - (entry.params as { threadId?: string }).threadId === child; - const isRegistration = (entry: (typeof byIndex)[number], child: string) => { - const item = (entry.params as { item?: { type?: string; agentThreadId?: string } }).item; - return item?.type === "subAgentActivity" && item.agentThreadId === child; - }; - const turnStartedA = byIndex.find((entry) => isTurnStarted(entry, CHILD_A)); - const turnStartedB = byIndex.find((entry) => isTurnStarted(entry, CHILD_B)); - const registrationA = byIndex.find((entry) => isRegistration(entry, CHILD_A)); - const registrationB = byIndex.find((entry) => isRegistration(entry, CHILD_B)); - const rootThreadStarted = byIndex.find((entry) => entry.method === "thread/started"); - assert.isDefined(turnStartedA); - assert.isDefined(turnStartedB); - assert.isDefined(registrationA); - assert.isDefined(registrationB); - assert.isDefined(rootThreadStarted); - const memoryThreadStarted = { - ...rootThreadStarted, - params: { - thread: { - ...rootThreadStarted.params.thread, - id: MEMORY, - sessionId: MEMORY, - source: "unknown", - threadSource: "memory_consolidation", - }, - }, - }; - const memoryTurnStarted = { - ...turnStartedA, - params: { - ...turnStartedA.params, - threadId: MEMORY, - turn: { ...turnStartedA.params.turn, id: "memory-consolidation-turn" }, - }, - }; - const script = { - rootThreadId: ROOT, - holdTurnOpen: true, - hangInterruptFor: CHILD_A, - notifications: [ - turnStartedA, - registrationA, - memoryThreadStarted, - memoryTurnStarted, - registrationB, - turnStartedB, - ], - }; - // @effect-diagnostics-next-line preferSchemaOverJson:off - NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); - const interruptsPath = `${scriptPath}.interrupts`; - NodeFS.rmSync(interruptsPath, { force: true }); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - NodeFS.rmSync(scriptPath, { force: true }); - NodeFS.rmSync(interruptsPath, { force: true }); - }), - ); - - const runtime = yield* makeCodexSessionRuntime({ - threadId: ThreadId.make("thread-collab-stop"), - binaryPath: peerPath, - cwd: NodeOS.tmpdir(), - runtimeMode: "full-access", - environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, - }); - - // Wait for both children's turnStarted signals to be processed before - // stopping (B via the registered-child path; A only produces live-turn - // bookkeeping, so key on B's synthetic event). - const childBStartedFiber = yield* runtime.events.pipe( - Stream.filter( - (event) => - event.method === "collabAgent/turnStarted" && - (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_B, - ), - Stream.take(1), - Stream.runCollect, - Effect.forkScoped, - ); - - yield* runtime.start(); - yield* runtime.sendTurn({ input: "fan out and hang" }); - const childBStarted = yield* Fiber.join(childBStartedFiber).pipe( - Effect.timeoutOption("15 seconds"), - ); - assert.isTrue(childBStarted._tag === "Some", "child B turnStarted never arrived"); - - // Stop everything. A's interrupt hangs forever — the bounded child - // deadline must expire and the parent interrupt must still be sent. - yield* runtime.interruptTurn(); - - const parseInterruptLine = (line: string) => JSON.parse(line) as { threadId?: string }; - const interrupted = NodeFS.readFileSync(interruptsPath, "utf8") - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map(parseInterruptLine); - const interruptedThreads = new Set(interrupted.map((entry) => entry.threadId)); - assert.isTrue( - interruptedThreads.has(CHILD_A), - "pre-registration child A must still receive the interrupt RPC", - ); - assert.isTrue(interruptedThreads.has(CHILD_B), "registered child B must be interrupted"); - assert.isTrue( - interruptedThreads.has(MEMORY), - "memory consolidation must be interrupted without appearing in chat", - ); - assert.isTrue(interruptedThreads.has(ROOT), "parent turn must be interrupted last"); - - yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - // it.live: the runtime talks to a real child process; under it.effect's - // TestClock the internal timers freeze and the join never completes. - it.live("Stop answers a parked app-permission approval with a withheld grant", () => - Effect.gen(function* () { - // Interrupting a turn whose app-permission prompt is still parked must - // settle that prompt: the handler resumes with "cancel", the peer gets - // an empty grant (permission withheld), and nothing hangs until close. - const script = { - rootThreadId: ROOT, - holdTurnOpen: true, - notifications: [], - serverRequests: [ - { - method: "item/permissions/requestApproval", - label: "perm-1", - params: { - cwd: "/tmp/project", - itemId: "app_1", - permissions: { network: { enabled: true } }, - reason: "Fetch data from api.example.com", - startedAtMs: 1_778_000_000_000, - threadId: "${threadId}", - turnId: "${turnId}", - }, - }, - ], - }; - // @effect-diagnostics-next-line preferSchemaOverJson:off - NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); - const responsesPath = `${scriptPath}.approvalResponses`; - NodeFS.rmSync(responsesPath, { force: true }); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - NodeFS.rmSync(scriptPath, { force: true }); - NodeFS.rmSync(responsesPath, { force: true }); - }), - ); - - const runtime = yield* makeCodexSessionRuntime({ - threadId: ThreadId.make("thread-codex-permission-stop"), - binaryPath: peerPath, - cwd: "/tmp", - runtimeMode: "full-access", - environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, - }); - - // One consumer for the whole stream: `events` is a plain queue stream, - // so two forks would compete for events and each could starve the - // other's filter. Signal the two milestones through Deferreds instead. - const requestedReady = yield* Deferred.make(); - const settledReady = yield* Deferred.make(); - yield* runtime.events.pipe( - Stream.runForEach((event) => { - if (event.method === "item/permissions/requestApproval") { - return Deferred.succeed(requestedReady, event); - } - if (event.method === "serverRequest/resolved" && event.requestKind === "permission") { - return Deferred.succeed(settledReady, event); - } - return Effect.void; - }), - Effect.forkScoped, - ); - - yield* runtime.start(); - yield* runtime.sendTurn({ input: "use the connected app" }); - const requested = yield* Deferred.await(requestedReady).pipe( - Effect.timeoutOption("15 seconds"), - ); - assert.isTrue(requested._tag === "Some", "permission approval request never arrived"); - - yield* runtime.interruptTurn(); - - // The peer emits serverRequest/resolved only AFTER recording the - // runtime's answer, so awaiting this receipt makes reading the sidecar - // race-free. The runtime correlates that receipt back to the canonical - // request (requestKind + requestId) — the same event chain the adapter - // folds into approval.resolved, so the card actually closes. - const settled = yield* Deferred.await(settledReady).pipe(Effect.timeoutOption("15 seconds")); - assert.isTrue(settled._tag === "Some", "interrupt did not settle the parked approval"); - const settledEvent = settled._tag === "Some" ? settled.value : undefined; - assert.isDefined(settledEvent); - assert.isDefined( - settledEvent?.requestId, - "receipt must correlate back to the canonical approval request", - ); - - const recorded = NodeFS.readFileSync(responsesPath, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { id: number; label: string; result: unknown }); - assert.equal(recorded.length, 1); - const answer = recorded[0]; - assert.isDefined(answer); - assert.equal(answer.label, "perm-1"); - // Cancelled approvals withhold the grant: an empty permission profile. - assert.deepEqual(answer.result, { permissions: {} }); - - yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.live("Stop targets the active turn when Codex has accepted a queued follow-up", () => - Effect.gen(function* () { - const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a"; - const queuedTurnId = "019fe3eb-8faf-7de3-a85b-ac64c7f9c8c3"; - const script = { - rootThreadId: ROOT, - holdTurnOpen: true, - onlyFirstTurnStarts: true, - turnIds: [activeTurnId, queuedTurnId], - expectedActiveTurnId: activeTurnId, - notifications: [], - }; - // @effect-diagnostics-next-line preferSchemaOverJson:off - NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); - const interruptsPath = `${scriptPath}.interrupts`; - NodeFS.rmSync(interruptsPath, { force: true }); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - NodeFS.rmSync(scriptPath, { force: true }); - NodeFS.rmSync(interruptsPath, { force: true }); - }), - ); - - const runtime = yield* makeCodexSessionRuntime({ - threadId: ThreadId.make("thread-codex-queued-stop"), - binaryPath: peerPath, - cwd: NodeOS.tmpdir(), - runtimeMode: "full-access", - environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, - }); - - yield* runtime.start(); - yield* runtime.sendTurn({ input: "keep working" }); - yield* runtime.sendTurn({ input: "queued follow-up" }); - yield* runtime.interruptTurn(); - - const interrupts = NodeFS.readFileSync(interruptsPath, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { threadId?: string; turnId?: string }); - assert.deepEqual(interrupts.at(-1), { - threadId: ROOT, - turnId: activeTurnId, - }); - - yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - const elicitationCases = [ - { - decision: "accept", - response: { action: "accept", content: { approval: "once" } }, - }, - { - decision: "acceptForSession", - response: { - action: "accept", - _meta: { persist: "session" }, - content: { approval: "session" }, - }, - }, - { - decision: "acceptAlways", - response: { - action: "accept", - _meta: { persist: "always" }, - content: { approval: "always" }, - }, - }, - { decision: "decline", response: { action: "decline" } }, - { decision: "cancel", response: { action: "cancel" } }, - ] satisfies ReadonlyArray<{ - readonly decision: ProviderApprovalDecision; - readonly response: Record; - }>; - - for (const { decision, response } of elicitationCases) { - it.live(`returns the MCP elicitation ${decision} response to Codex`, () => - Effect.gen(function* () { - const scriptedRequest = { - id: 7001, - method: "mcpServer/elicitation/request", - params: { - mode: "form", - message: "Allow ChatGPT to use Safari?", - serverName: "computer-use", - threadId: ROOT, - turnId: wireFixture.responses.turnStart.turn.id, - _meta: { app_name: "Safari", persist: ["session", "always"] }, - requestedSchema: { - type: "object", - properties: { - approval: { - type: "string", - enum: ["once", "session", "always"], - }, - }, - required: ["approval"], - }, - }, - }; - const script = { - rootThreadId: ROOT, - holdTurnOpen: true, - completeTurnOnServerResponse: true, - notifications: [], - serverRequests: [scriptedRequest], - }; - const responsesPath = `${scriptPath}.responses`; - // @effect-diagnostics-next-line preferSchemaOverJson:off - NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); - NodeFS.rmSync(responsesPath, { force: true }); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - NodeFS.rmSync(scriptPath, { force: true }); - NodeFS.rmSync(responsesPath, { force: true }); - }), - ); - - const runtime = yield* makeCodexSessionRuntime({ - threadId: ThreadId.make("thread-codex-mcp-elicitation"), - binaryPath: peerPath, - cwd: NodeOS.tmpdir(), - runtimeMode: "auto", - environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, - }); - const approvalRequested = yield* Deferred.make(); - const turnCompleted = yield* Deferred.make(); - yield* runtime.events.pipe( - Stream.runForEach((event) => - event.method === "mcpServer/elicitation/request" - ? Deferred.succeed(approvalRequested, event).pipe(Effect.asVoid) - : event.method === "turn/completed" - ? Deferred.succeed(turnCompleted, undefined).pipe(Effect.asVoid) - : Effect.void, - ), - Effect.forkScoped, - ); - - yield* runtime.start(); - yield* runtime.sendTurn({ input: "Open Safari" }); - const approval = yield* Deferred.await(approvalRequested); - assert.equal(approval.requestKind, "mcp-elicitation"); - assert.isDefined(approval.requestId); - if (approval.requestId === undefined) return; - - yield* runtime.respondToRequest(approval.requestId, decision); - yield* Deferred.await(turnCompleted); - - const recordedResponse = yield* decodeMcpElicitationResponse( - NodeFS.readFileSync(responsesPath, "utf8"), - ); - assert.equal(recordedResponse.id, scriptedRequest.id); - assert.deepEqual(recordedResponse.result, response); - - yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - } -}); diff --git a/apps/server/src/provider/Layers/CodexCollabWire.test.ts b/apps/server/src/provider/Layers/CodexCollabWire.test.ts deleted file mode 100644 index 363c1560ca54..000000000000 --- a/apps/server/src/provider/Layers/CodexCollabWire.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Codex multi-agent wire fixtures. - * - * The child-interception path is the highest-blast-radius code in the - * subagent stack: it decides, per notification, whether traffic reaches the - * parent timeline at all. Three shipped bugs came from that decision being - * implicit (root thread registered as its own child; `error` and - * `serverRequest/resolved` swallowed by a catch-all). These tests pin the - * decision against a REAL capture rather than hand-written shapes. - * - * Fixture provenance: codex-cli 0.145.0 driven directly over stdio with - * gpt-5.6-luna at low effort, prompting a two-child fan-out (alpha, beta). - * See codexMultiAgentWire.json. - */ -import { assert, describe, it } from "vite-plus/test"; - -import fixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" }; -import { routeCodexChildNotification } from "./CodexSessionRuntime.ts"; - -interface WireNotification { - readonly method: string; - readonly params: Record; -} - -const notifications = fixture.notifications as ReadonlyArray; -const rootThreadId = fixture.rootThreadId; -const childThreadIds = new Set(fixture.childThreadIds); - -/** Mirrors readNotificationThreadId's addressing for the captured methods. */ -function notificationThreadId(entry: WireNotification): string | undefined { - const params = entry.params; - const thread = params.thread; - if ( - typeof thread === "object" && - thread !== null && - typeof (thread as { id?: unknown }).id === "string" - ) { - return (thread as { id: string }).id; - } - return typeof params.threadId === "string" ? params.threadId : undefined; -} - -function subAgentActivityItems(): ReadonlyArray> { - return notifications.flatMap((entry) => { - const item = entry.params.item; - if (typeof item !== "object" || item === null) return []; - const record = item as Record; - return record.type === "subAgentActivity" ? [record] : []; - }); -} - -describe("codex multi-agent wire capture", () => { - it("captures a real two-child fan-out", () => { - assert.equal(fixture.capturedWith.model, "gpt-5.6-luna"); - assert.equal(childThreadIds.size, 2); - const paths = subAgentActivityItems().map((item) => item.agentPath); - assert.include(paths, "/root/alpha"); - assert.include(paths, "/root/beta"); - }); - - it("emits child traffic BEFORE the item that registers the child", () => { - // Ordering hazard: the child's own thread/status/changed arrives before - // the parent-side subAgentActivity naming it. Registration must tolerate - // child-first arrival, so unregistered child traffic passes through - // rather than being eaten (no regression vs. pre-feature behavior). - const firstChildTraffic = notifications.findIndex((entry) => { - const threadId = notificationThreadId(entry); - return threadId !== undefined && childThreadIds.has(threadId); - }); - const firstRegistration = notifications.findIndex((entry) => { - const item = entry.params.item; - if (typeof item !== "object" || item === null) return false; - const record = item as Record; - return record.type === "subAgentActivity" && record.kind === "started"; - }); - assert.isAtLeast(firstChildTraffic, 0); - assert.isAtLeast(firstRegistration, 0); - assert.isBelow( - firstChildTraffic, - firstRegistration, - "capture should exercise child-first ordering", - ); - }); - - it("contains a /root self-activity emitted from a CHILD thread", () => { - // The bug this guards: the wire reports subAgentActivity about the ROOT - // (agentPath "/root"). Registering it made the runtime intercept the - // parent's own final message and turn/completed, so the thread hung - // "working" forever. The root guard must key on agentPath/thread id, - // never on which thread the notification arrived from. - const rootSelfActivity = subAgentActivityItems().find((item) => item.agentPath === "/root"); - assert.isDefined(rootSelfActivity, "capture should contain a /root self-activity"); - assert.equal(rootSelfActivity?.agentThreadId, rootThreadId); - }); - - it("routes every captured child method to a defined disposition", () => { - const childMethods = new Set( - notifications - .filter((entry) => { - const threadId = notificationThreadId(entry); - return threadId !== undefined && childThreadIds.has(threadId); - }) - .map((entry) => entry.method), - ); - assert.isAbove(childMethods.size, 0); - for (const method of childMethods) { - const route = routeCodexChildNotification(method); - // Child lifecycle traffic must become agent events — never silently - // dropped, never leaked to the parent timeline. - assert.equal(route, "agent-event", `${method} should map to an agent event`); - } - }); -}); - -describe("routeCodexChildNotification", () => { - it("maps child lifecycle to agent events", () => { - for (const method of [ - "turn/started", - "turn/completed", - "thread/status/changed", - "thread/tokenUsage/updated", - "thread/settings/updated", - "model/rerouted", - "item/started", - "item/completed", - "thread/closed", - "error", - ]) { - assert.equal(routeCodexChildNotification(method), "agent-event", method); - } - }); - - it("drops only enumerated child chatter", () => { - for (const method of [ - "item/agentMessage/delta", - "item/reasoning/textDelta", - "item/commandExecution/outputDelta", - "turn/plan/updated", - "thread/name/updated", - ]) { - assert.equal(routeCodexChildNotification(method), "drop", method); - } - }); - - it("never routes child-owned thread lifecycle to the parent", () => { - // These mutate PARENT thread state in CodexAdapter (archived/compacted), - // so a child emitting them must never reach the parent path. This list - // mirrors shouldSuppressChildConversationNotification (the v1 collab - // suppressor) — the two must not drift (review finding: the router - // initially omitted them and they leaked). - for (const method of [ - "thread/started", - "thread/status/changed", - "thread/archived", - "thread/unarchived", - "thread/closed", - "thread/compacted", - "thread/name/updated", - "thread/tokenUsage/updated", - "turn/started", - "turn/completed", - "turn/plan/updated", - "item/plan/delta", - "thread/settings/updated", - "model/rerouted", - ]) { - assert.notEqual( - routeCodexChildNotification(method), - "parent", - `${method} is child-owned and must not reach the parent path`, - ); - } - }); - - it("sends parent-owned and UNKNOWN methods to the parent path", () => { - // serverRequest/resolved clears the parent's approval correlation: - // swallowing it left approvals stuck (shipped bug). Unknown methods take - // the same route by design — a codex update that adds a notification - // must degrade to "parent sees it", never to silent loss. - assert.equal(routeCodexChildNotification("serverRequest/resolved"), "parent"); - assert.equal(routeCodexChildNotification("thread/somethingBrandNew"), "parent"); - assert.equal(routeCodexChildNotification("account/rateLimits/updated"), "parent"); - }); -}); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 8fffc4e5f5e7..9db819daa769 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -334,7 +334,7 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( return models; }); -export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { +function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { return { clientInfo: { name: "t3code_desktop", @@ -363,7 +363,7 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu // `~` is not shell-expanded when env vars are set via `child_process.spawn`, // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip // "CODEX_HOME points to '~/.codex_work', but that path does not exist". - // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. + // Expand here for parity with `CodexTextGeneration`. const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const environment = { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts deleted file mode 100644 index ec113ab7c521..000000000000 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ /dev/null @@ -1,1059 +0,0 @@ -import * as NodeAssert from "node:assert/strict"; - -import { it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import { describe } from "vite-plus/test"; -import { DEFAULT_MODEL, ThreadId } from "@t3tools/contracts"; -import * as CodexErrors from "effect-codex-app-server/errors"; -import * as CodexRpc from "effect-codex-app-server/rpc"; -import * as EffectCodexSchema from "effect-codex-app-server/schema"; - -import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; -import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; -import { - buildTurnStartParams, - describeMcpElicitation, - hasConfiguredMcpServer, - isRecoverableThreadResumeError, - makeMemoryConsolidationNotificationFilter, - openCodexThread, - readCodexThread, - rollbackCodexThread, - toMcpElicitationResponse, -} from "./CodexSessionRuntime.ts"; -const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); - -describe("Codex thread history", () => { - for (const numTurns of [1, 2, 3, 5]) { - it.effect(`reverts ${numTurns} paginated turns at the durable boundary`, () => - Effect.gen(function* () { - let retained = ["turn-1", "turn-2", "turn-3"]; - const client: Parameters[0] = { - request: () => Effect.die("Legacy history API must not be used for paginated threads"), - raw: { - request: (method, params) => - Effect.sync(() => { - if (method === "thread/read") return { thread: { historyMode: "paginated" } }; - if (method === "thread/turns/list") { - const { cursor } = params as { cursor: string | null }; - const start = cursor === null ? 0 : Number(cursor); - const ids = retained.slice(start, start + 2); - return { - data: ids.map((id) => ({ id, items: [], status: "completed" })), - nextCursor: start + 2 < retained.length ? String(start + 2) : null, - }; - } - NodeAssert.equal(method, "thread/revert"); - const { beforeTurnId } = params as { beforeTurnId: string }; - retained = retained.slice(0, retained.indexOf(beforeTurnId)); - return { thread: { id: "thread-1", turns: [] } }; - }), - }, - }; - const result = yield* rollbackCodexThread(client, "thread-1", numTurns); - const expected = ["turn-1", "turn-2", "turn-3"].slice(0, Math.max(0, 3 - numTurns)); - NodeAssert.deepEqual( - result.turns.map((turn) => turn.id), - expected, - ); - NodeAssert.deepEqual( - (yield* readCodexThread(client, "thread-1")).turns.map((turn) => turn.id), - expected, - ); - }), - ); - } - - for (const cursors of [ - ["next", "next"], - ["first", "second", "first"], - ]) { - it.effect(`rejects a pagination cursor cycle: ${cursors.join(", ")}`, () => - Effect.gen(function* () { - let pageCount = 0; - const client: Parameters[0] = { - request: () => Effect.die("Unexpected legacy request"), - raw: { - request: (method) => - Effect.sync(() => { - if (method === "thread/read") return { thread: { historyMode: "paginated" } }; - NodeAssert.ok(pageCount < cursors.length, "Repeated cursor was requested"); - return { data: [], nextCursor: cursors[pageCount++] }; - }), - }, - }; - const error = yield* Effect.flip(readCodexThread(client, "thread-1")); - NodeAssert.ok(isCodexAppServerRequestError(error)); - NodeAssert.equal(pageCount, cursors.length); - }), - ); - } - - it.effect("keeps the count-based rollback API for older threads", () => - Effect.gen(function* () { - const client: Parameters[0] = { - raw: { request: () => Effect.succeed({ thread: {} }) }, - request: ( - method: M, - params: CodexRpc.ClientRequestParamsByMethod[M], - ) => { - NodeAssert.equal(method, "thread/rollback"); - NodeAssert.deepEqual(params, { threadId: "legacy-thread", numTurns: 2 }); - return Effect.succeed({ - thread: { id: "legacy-thread", turns: [] }, - } as unknown as CodexRpc.ClientRequestResponsesByMethod[M]); - }, - }; - NodeAssert.deepEqual(yield* rollbackCodexThread(client, "legacy-thread", 2), { - threadId: "legacy-thread", - turns: [], - }); - }), - ); -}); - -describe("CodexSessionRuntimeIdentifierGenerationError", () => { - it("retains identifier purpose and the random source failure", () => { - const cause = new Error("random source unavailable"); - const error = new CodexErrors.CodexAppServerIdentifierGenerationError({ - purpose: "provider-event", - cause, - }); - - NodeAssert.equal(error.purpose, "provider-event"); - NodeAssert.strictEqual(error.cause, cause); - NodeAssert.equal( - error.message, - "Failed to generate Codex App Server identifier for provider-event.", - ); - }); -}); - -function makeThreadOpenResponse( - threadId: string, -): CodexRpc.ClientRequestResponsesByMethod["thread/start"] { - return { - cwd: "/tmp/project", - model: "gpt-5.3-codex", - modelProvider: "openai", - approvalPolicy: "never", - approvalsReviewer: "user", - sandbox: { type: "danger-full-access" }, - thread: { - id: threadId, - createdAt: "2026-04-18T00:00:00.000Z", - source: { session: "cli" }, - turns: [], - status: { - state: "idle", - activeFlags: [], - }, - }, - } as unknown as CodexRpc.ClientRequestResponsesByMethod["thread/start"]; -} - -describe("buildTurnStartParams", () => { - it.effect("sends currency skill aliases in Codex's canonical dollar form", () => - Effect.gen(function* () { - for (const symbol of ["€", "£", "¥", "₹", "₩", "₿", "𑿝"]) { - const prose = `${symbol}20 ${symbol}20k ${symbol}100M ${symbol}1e6 5${symbol}review`; - const params = yield* buildTurnStartParams({ - threadId: "provider-thread-1", - runtimeMode: "full-access", - prompt: `${symbol}review ${symbol}2spec $existing ${prose} ${symbol}last`, - }); - - NodeAssert.deepEqual(params.input, [ - { type: "text", text: `$review $2spec $existing ${prose} $last` }, - ]); - } - }), - ); - - it("keeps invalid turn values only in the schema cause", () => { - const secret = "codex-turn-input-secret-sentinel"; - const error = Effect.runSync( - buildTurnStartParams({ - threadId: "provider-thread-1", - runtimeMode: "full-access", - attachments: [ - { - type: "localImage", - path: { secret } as unknown as string, - }, - ], - }).pipe(Effect.flip), - ); - const { cause, ...directDiagnostics } = error; - - NodeAssert.equal(error.operation, "decode-request-payload"); - NodeAssert.equal(error.method, "turn/start"); - NodeAssert.ok((error.issueCount ?? 0) > 0); - NodeAssert.ok(error.issueKinds?.includes("Pointer")); - NodeAssert.ok((error.maximumPathDepth ?? 0) > 0); - NodeAssert.ok(Schema.isSchemaError(cause)); - NodeAssert.doesNotMatch(error.message, new RegExp(secret)); - NodeAssert.doesNotMatch(JSON.stringify(directDiagnostics), new RegExp(secret)); - }); - - it("includes plan collaboration mode when requested", () => { - const params = Effect.runSync( - buildTurnStartParams({ - threadId: "provider-thread-1", - runtimeMode: "full-access", - prompt: "Make a plan", - model: "gpt-5.3-codex", - effort: "medium", - interactionMode: "plan", - }), - ); - - NodeAssert.deepStrictEqual(params, { - threadId: "provider-thread-1", - approvalPolicy: "never", - approvalsReviewer: "user", - sandboxPolicy: { - type: "dangerFullAccess", - }, - input: [ - { - type: "text", - text: "Make a plan", - }, - ], - model: "gpt-5.3-codex", - effort: "medium", - collaborationMode: { - mode: "plan", - settings: { - model: "gpt-5.3-codex", - reasoning_effort: "medium", - developer_instructions: buildCodexDeveloperInstructions("plan", { - model: "gpt-5.3-codex", - reasoningEffort: "medium", - }), - }, - }, - }); - }); - - it("includes default collaboration mode and image attachments", () => { - const params = Effect.runSync( - buildTurnStartParams({ - threadId: "provider-thread-1", - runtimeMode: "auto-accept-edits", - prompt: "Implement it", - model: "gpt-5.3-codex", - interactionMode: "default", - attachments: [ - { - type: "localImage", - path: "/tmp/generated.png", - }, - ], - }), - ); - - NodeAssert.deepStrictEqual(params, { - threadId: "provider-thread-1", - approvalPolicy: "on-request", - approvalsReviewer: "user", - sandboxPolicy: { - type: "workspaceWrite", - }, - input: [ - { - type: "text", - text: "Implement it", - }, - { - type: "localImage", - path: "/tmp/generated.png", - }, - ], - model: "gpt-5.3-codex", - collaborationMode: { - mode: "default", - settings: { - model: "gpt-5.3-codex", - reasoning_effort: "medium", - developer_instructions: buildCodexDeveloperInstructions("default", { - model: "gpt-5.3-codex", - reasoningEffort: "medium", - }), - }, - }, - }); - }); - - it("reports the same fallback model and effort in settings and instructions", () => { - const params = Effect.runSync( - buildTurnStartParams({ - threadId: "provider-thread-1", - runtimeMode: "full-access", - prompt: "Go", - interactionMode: "default", - }), - ); - - const settings = params.collaborationMode?.settings; - NodeAssert.equal(settings?.model, DEFAULT_MODEL); - NodeAssert.equal(settings?.reasoning_effort, "medium"); - NodeAssert.ok(settings?.developer_instructions?.includes(`as ${DEFAULT_MODEL} with medium`)); - }); - - it.effect("routes approvals to the auto reviewer in auto mode", () => - Effect.gen(function* () { - const params = yield* buildTurnStartParams({ - threadId: "provider-thread-1", - runtimeMode: "auto", - prompt: "Ship it", - }); - - NodeAssert.deepStrictEqual(params, { - threadId: "provider-thread-1", - approvalPolicy: "on-request", - approvalsReviewer: "auto_review", - sandboxPolicy: { - type: "workspaceWrite", - }, - input: [ - { - type: "text", - text: "Ship it", - }, - ], - }); - }), - ); - - it("omits collaboration mode when interaction mode is absent", () => { - const params = Effect.runSync( - buildTurnStartParams({ - threadId: "provider-thread-1", - runtimeMode: "approval-required", - prompt: "Review", - }), - ); - - NodeAssert.deepStrictEqual(params, { - threadId: "provider-thread-1", - approvalPolicy: "untrusted", - approvalsReviewer: "user", - sandboxPolicy: { - type: "readOnly", - }, - input: [ - { - type: "text", - text: "Review", - }, - ], - }); - }); -}); - -describe("Codex MCP elicitation approvals", () => { - const request = { - mode: "form", - message: "Allow ChatGPT to use Safari?", - serverName: "computer-use", - threadId: "provider-thread-1", - turnId: "turn-1", - _meta: { - app_name: "Safari", - persist: ["session", "always"], - }, - requestedSchema: { - type: "object", - properties: { - approval: { - type: "string", - oneOf: [ - { const: "once", title: "Allow once" }, - { const: "session", title: "Allow for this session" }, - { const: "always", title: "Always allow Safari" }, - ], - }, - }, - required: ["approval"], - }, - } satisfies EffectCodexSchema.McpServerElicitationRequestParams; - - it("preserves the app name and advertised persistence choices", () => { - NodeAssert.deepStrictEqual(describeMcpElicitation(request), { - appName: "Safari", - options: [ - { decision: "cancel", label: "Cancel" }, - { decision: "decline", label: "Decline" }, - { decision: "acceptForSession", label: "Allow for this session" }, - { decision: "acceptAlways", label: "Always allow Safari" }, - { decision: "accept", label: "Approve" }, - ], - }); - }); - - it("extracts the app name from a Computer Use request without metadata", () => { - const { _meta, ...requestWithoutMetadata } = request; - - NodeAssert.equal(describeMcpElicitation(requestWithoutMetadata).appName, "Safari"); - }); - - it("returns the accepted form option to Codex", () => { - NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "accept"), { - action: "accept", - content: { approval: "once" }, - }); - }); - - it("returns session-scoped approval in the MCP response", () => { - NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptForSession"), { - action: "accept", - _meta: { persist: "session" }, - content: { approval: "session" }, - }); - }); - - it("returns persistent approval in the MCP response", () => { - NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptAlways"), { - action: "accept", - _meta: { persist: "always" }, - content: { approval: "always" }, - }); - }); - - it("returns rejection without form content", () => { - NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "decline"), { - action: "decline", - }); - }); - - it("returns cancellation without form content", () => { - NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "cancel"), { - action: "cancel", - }); - }); - - it("supports boolean permanent-approval fields", () => { - const booleanRequest = { - ...request, - _meta: { app_name: "Safari" }, - requestedSchema: { - type: "object", - properties: { - always: { type: "boolean", title: "Always allow Safari" }, - }, - }, - } satisfies EffectCodexSchema.McpServerElicitationRequestParams; - - NodeAssert.ok( - describeMcpElicitation(booleanRequest).options.some( - (option) => option.decision === "acceptAlways", - ), - ); - NodeAssert.deepStrictEqual(toMcpElicitationResponse(booleanRequest, "acceptAlways"), { - action: "accept", - _meta: { persist: "always" }, - content: { always: true }, - }); - }); - - it("preserves valid nullable MCP form fields and persistence choices", () => { - const nullableRequest = { - ...request, - _meta: { - app_name: null, - appName: "Safari", - connector_name: null, - persist: null, - target: null, - tool_params: null, - }, - requestedSchema: { - type: "object", - properties: { - approval: { - type: "string", - title: null, - description: null, - default: null, - enum: ["once", "always"], - enumNames: null, - }, - }, - required: ["approval"], - }, - } satisfies EffectCodexSchema.McpServerElicitationRequestParams; - - NodeAssert.equal(describeMcpElicitation(nullableRequest).appName, "Safari"); - NodeAssert.ok( - describeMcpElicitation(nullableRequest).options.some( - (option) => option.decision === "acceptAlways", - ), - ); - NodeAssert.deepStrictEqual(toMcpElicitationResponse(nullableRequest, "acceptAlways"), { - action: "accept", - _meta: { persist: "always" }, - content: { approval: "always" }, - }); - }); - - it("declines required form fields that an approval prompt cannot collect", () => { - const inputRequest = { - ...request, - requestedSchema: { - type: "object", - properties: { - email: { type: "string", format: "email" }, - }, - required: ["email"], - }, - } satisfies EffectCodexSchema.McpServerElicitationRequestParams; - - NodeAssert.deepStrictEqual(toMcpElicitationResponse(inputRequest, "accept"), { - action: "decline", - }); - }); - - it("does not approve URL elicitations without opening their requested URL", () => { - const urlRequest = { - mode: "url", - message: "Finish signing in to continue.", - serverName: "computer-use", - threadId: "provider-thread-1", - turnId: "turn-1", - elicitationId: "sign-in-1", - url: "https://example.com/authorize", - } satisfies EffectCodexSchema.McpServerElicitationRequestParams; - - NodeAssert.deepStrictEqual(toMcpElicitationResponse(urlRequest, "accept"), { - action: "decline", - }); - }); - - it("omits persistence choices that cannot satisfy required form fields", () => { - const onceOnlyRequest = { - ...request, - _meta: { app_name: "Safari", persist: ["session", "always"] }, - requestedSchema: { - type: "object", - properties: { - approval: { - type: "string", - enum: ["once"], - }, - }, - required: ["approval"], - }, - } satisfies EffectCodexSchema.McpServerElicitationRequestParams; - - NodeAssert.deepStrictEqual(describeMcpElicitation(onceOnlyRequest).options, [ - { decision: "cancel", label: "Cancel" }, - { decision: "decline", label: "Decline" }, - { decision: "accept", label: "Approve" }, - ]); - }); -}); - -describe("buildCodexDeveloperInstructions", () => { - it("appends runtime info after the mode instructions", () => { - const instructions = buildCodexDeveloperInstructions("default", { - model: "gpt-5.3-codex", - reasoningEffort: "high", - }); - - NodeAssert.match(instructions, /^# Collaboration Mode: Default/); - NodeAssert.match(instructions, /T3 Code/); - NodeAssert.match(instructions, /Codex harness/); - NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/); - }); - - it("describes Markdown media support in the runtime context in both modes", () => { - for (const mode of ["default", "plan"] as const) { - const instructions = buildCodexDeveloperInstructions(mode, { - model: "gpt-5.3-codex", - reasoningEffort: "high", - }); - NodeAssert.match( - instructions, - /.*embed images and videos.*Markdown.*<\/runtime_info>/, - ); - } - }); - - it("includes runtime info alongside plan mode instructions", () => { - const instructions = buildCodexDeveloperInstructions("plan", { - model: "gpt-5.3-codex", - reasoningEffort: "medium", - }); - - NodeAssert.match(instructions, /^# Plan Mode/); - NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/); - }); - - it("varies with the model and effort of each turn", () => { - const first = buildCodexDeveloperInstructions("default", { - model: "gpt-5.3-codex", - reasoningEffort: "medium", - }); - const second = buildCodexDeveloperInstructions("default", { - model: "gpt-5.4", - reasoningEffort: "high", - }); - - NodeAssert.notEqual(first, second); - }); - - it("flattens multiline metadata into single-line runtime info", () => { - const instructions = buildCodexDeveloperInstructions("default", { - model: "gpt\n5.3\ncodex", - reasoningEffort: " high\neffort ", - }); - - NodeAssert.match(instructions, /as gpt 5\.3 codex with high effort reasoning effort/); - NodeAssert.doesNotMatch(instructions, /[^<]*\n/); - }); -}); - -describe("T3 browser developer instructions", () => { - const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; - - it("prefers the product-native preview tools in both collaboration modes", () => { - for (const mode of ["default", "plan"] as const) { - const instructions = buildCodexDeveloperInstructions(mode, runtime, true); - NodeAssert.match(instructions, /t3-code/); - NodeAssert.match(instructions, /preview_status/); - NodeAssert.match(instructions, /preview_open/); - NodeAssert.match(instructions, /Do not switch to global browser skills/); - } - }); - - it("omits the browser block entirely when the preview tools are not attached", () => { - for (const mode of ["default", "plan"] as const) { - const instructions = buildCodexDeveloperInstructions(mode, runtime, false); - NodeAssert.doesNotMatch(instructions, /preview_status/); - NodeAssert.doesNotMatch(instructions, /preview_open/); - NodeAssert.doesNotMatch(instructions, /T3 Code collaborative browser/); - // Steering away from other browser automation must go with the tools; - // keeping it would leave the model talked out of its only option. - NodeAssert.doesNotMatch(instructions, /Do not switch to global browser skills/); - // The rest of the collaboration mode is untouched. - NodeAssert.match(instructions, //); - NodeAssert.match(instructions, /<\/collaboration_mode>/); - } - }); - - it("tracks the turn's MCP configuration rather than defaulting to on", () => { - NodeAssert.match(buildCodexDeveloperInstructions("default", runtime, true), /preview_open/); - NodeAssert.doesNotMatch( - buildCodexDeveloperInstructions("default", runtime, false), - /preview_open/, - ); - }); -}); - -describe("hasConfiguredMcpServer", () => { - it("detects inline Codex MCP configuration arguments", () => { - NodeAssert.equal(hasConfiguredMcpServer(undefined), false); - NodeAssert.equal(hasConfiguredMcpServer(["--model", "gpt-5.4"]), false); - NodeAssert.equal( - hasConfiguredMcpServer(["-c", 'mcp_servers.t3-code.url="http://127.0.0.1/mcp"']), - true, - ); - }); -}); - -function makeThreadStartedNotification( - threadId: string, - source: EffectCodexSchema.V2ThreadStartedNotification["thread"]["source"], - threadSource?: string, -) { - return { - method: "thread/started" as const, - params: { - thread: { - cliVersion: "0.0.0", - createdAt: 0, - cwd: "/tmp/project", - ephemeral: true, - id: threadId, - modelProvider: "openai", - preview: "", - sessionId: threadId, - source, - status: { type: "idle" as const }, - ...(threadSource ? { threadSource } : {}), - turns: [], - updatedAt: 0, - }, - }, - }; -} - -describe("makeMemoryConsolidationNotificationFilter", () => { - it("suppresses memory consolidation without hiding other Codex subagents", () => { - const shouldSuppress = makeMemoryConsolidationNotificationFilter(); - - NodeAssert.equal( - shouldSuppress( - makeThreadStartedNotification("memory-thread", "unknown", "memory_consolidation"), - ), - true, - ); - NodeAssert.equal( - shouldSuppress({ - method: "item/agentMessage/delta", - params: { - delta: "internal memory update", - itemId: "memory-message", - threadId: "memory-thread", - turnId: "memory-turn", - }, - }), - true, - ); - NodeAssert.equal( - shouldSuppress({ - method: "serverRequest/resolved", - params: { - requestId: "memory-approval", - threadId: "memory-thread", - }, - }), - false, - ); - NodeAssert.equal( - shouldSuppress({ - method: "warning", - params: { - message: "internal warning", - threadId: "memory-thread", - }, - }), - true, - ); - NodeAssert.equal( - shouldSuppress({ - method: "item/agentMessage/delta", - params: { - delta: "normal reply", - itemId: "root-message", - threadId: "root-thread", - turnId: "root-turn", - }, - }), - false, - ); - - NodeAssert.equal( - shouldSuppress( - makeThreadStartedNotification("legacy-memory-thread", { - subAgent: "memory_consolidation", - }), - ), - true, - ); - - for (const source of [ - { subAgent: "review" as const }, - { subAgent: "compact" as const }, - { - subAgent: { - thread_spawn: { - depth: 1, - parent_thread_id: "root-thread", - }, - }, - }, - ]) { - NodeAssert.equal( - shouldSuppress(makeThreadStartedNotification("visible-subagent", source)), - false, - ); - } - }); - - it("forgets memory consolidation threads after they close", () => { - const shouldSuppress = makeMemoryConsolidationNotificationFilter(); - shouldSuppress( - makeThreadStartedNotification("memory-thread", "unknown", "memory_consolidation"), - ); - - NodeAssert.equal( - shouldSuppress({ - method: "thread/closed", - params: { threadId: "memory-thread" }, - }), - true, - ); - NodeAssert.equal( - shouldSuppress({ - method: "item/agentMessage/delta", - params: { - delta: "later message", - itemId: "later-message", - threadId: "memory-thread", - turnId: "later-turn", - }, - }), - false, - ); - }); -}); - -describe("codexSessionAppServerArgs", () => { - it("keeps the app-server subcommand when explicit args are provided", () => { - NodeAssert.deepStrictEqual(codexSessionAppServerArgs(["-c", "model=gpt-5"], undefined), [ - "app-server", - "-c", - "model=gpt-5", - ]); - }); - - it("keeps launch args when explicit app-server args are provided", () => { - NodeAssert.deepStrictEqual( - codexSessionAppServerArgs( - ["-c", "mcp_servers.t3-code.url=http://127.0.0.1/mcp"], - "--strict-config --enable foo", - ), - [ - "app-server", - "--strict-config", - "--enable", - "foo", - "-c", - "mcp_servers.t3-code.url=http://127.0.0.1/mcp", - ], - ); - }); -}); - -describe("isRecoverableThreadResumeError", () => { - it("matches missing thread errors", () => { - NodeAssert.equal( - isRecoverableThreadResumeError( - new CodexErrors.CodexAppServerRequestError({ - code: -32603, - errorMessage: "Thread does not exist", - }), - ), - true, - ); - }); - - it("matches a missing rollout for a known thread id", () => { - NodeAssert.equal( - isRecoverableThreadResumeError( - new CodexErrors.CodexAppServerRequestError({ - code: -32603, - errorMessage: "no rollout found for thread id 019fdf74-aaa9-7950-b252-7cc7a8650470", - }), - ), - true, - ); - }); - - it("ignores non-recoverable resume errors", () => { - NodeAssert.equal( - isRecoverableThreadResumeError( - new CodexErrors.CodexAppServerRequestError({ - code: -32603, - errorMessage: "Permission denied", - }), - ), - false, - ); - }); - - it("ignores unrelated missing-resource errors that do not mention threads", () => { - NodeAssert.equal( - isRecoverableThreadResumeError( - new CodexErrors.CodexAppServerRequestError({ - code: -32603, - errorMessage: "Config file not found", - }), - ), - false, - ); - NodeAssert.equal( - isRecoverableThreadResumeError( - new CodexErrors.CodexAppServerRequestError({ - code: -32603, - errorMessage: "Model does not exist", - }), - ), - false, - ); - }); -}); - -describe("openCodexThread", () => { - it.effect("resumes metadata when historical turns contain unknown error values", () => - Effect.gen(function* () { - const response = makeThreadOpenResponse("saved-thread"); - const calls: unknown[] = []; - const opened = yield* openCodexThread({ - client: { - request: () => Effect.die("A valid resumed thread must not start fresh"), - raw: { - request: (method, payload) => { - calls.push({ method, payload }); - return Effect.succeed({ - ...response, - thread: { - ...response.thread, - turns: [ - { - id: "old-turn", - status: "failed", - items: [], - error: { - message: "Historical provider error", - codexErrorInfo: "misalignment_policy_violation", - }, - }, - ], - }, - }); - }, - }, - }, - threadId: ThreadId.make("thread-1"), - runtimeMode: "auto", - cwd: "/tmp/project", - requestedModel: "gpt-5.3-codex", - serviceTier: "fast", - resumeThreadId: "saved-thread", - }); - - NodeAssert.deepStrictEqual(opened, { - cwd: response.cwd, - model: response.model, - thread: { id: "saved-thread" }, - }); - NodeAssert.deepStrictEqual(calls, [ - { - method: "thread/resume", - payload: { - threadId: "saved-thread", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - serviceTier: "fast", - approvalPolicy: "on-request", - sandbox: "workspace-write", - approvalsReviewer: "auto_review", - excludeTurns: true, - }, - }, - ]); - }), - ); - - it.effect("rejects malformed required resume metadata without starting a fresh thread", () => - Effect.gen(function* () { - for (const invalidMetadata of [ - { cwd: null }, - { model: 42 }, - { thread: { id: null } }, - { thread: {} }, - ]) { - const error = yield* openCodexThread({ - client: { - request: () => Effect.die("Invalid resume metadata must not start a fresh thread"), - raw: { - request: () => - Effect.succeed({ ...makeThreadOpenResponse("saved-thread"), ...invalidMetadata }), - }, - }, - threadId: ThreadId.make("thread-1"), - runtimeMode: "full-access", - cwd: "/tmp/project", - requestedModel: "gpt-5.3-codex", - serviceTier: undefined, - resumeThreadId: "saved-thread", - }).pipe(Effect.flip); - - NodeAssert.ok(isCodexAppServerRequestError(error)); - NodeAssert.equal(error.operation, "decode-payload"); - NodeAssert.equal(error.method, "thread/resume"); - } - }), - ); - - it.effect("falls back to thread/start when resume fails recoverably", () => - Effect.gen(function* () { - const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = []; - const started = makeThreadOpenResponse("fresh-thread"); - const client = { - raw: { - request: ( - method: "thread/resume", - payload: CodexRpc.ClientRequestParamsByMethod["thread/resume"], - ) => { - calls.push({ method, payload }); - return Effect.fail( - new CodexErrors.CodexAppServerRequestError({ - code: -32603, - errorMessage: "thread not found", - }), - ); - }, - }, - request: ( - method: "thread/start", - payload: CodexRpc.ClientRequestParamsByMethod["thread/start"], - ) => { - calls.push({ method, payload }); - return Effect.succeed(started); - }, - }; - - const opened = yield* openCodexThread({ - client, - threadId: ThreadId.make("thread-1"), - runtimeMode: "full-access", - cwd: "/tmp/project", - requestedModel: "gpt-5.3-codex", - serviceTier: undefined, - resumeThreadId: "stale-thread", - }); - - NodeAssert.equal(opened.thread.id, "fresh-thread"); - NodeAssert.deepStrictEqual( - calls.map((call) => call.method), - ["thread/resume", "thread/start"], - ); - }), - ); - - it.effect("propagates non-recoverable resume failures", () => - Effect.gen(function* () { - const client = { - request: () => Effect.die("Non-recoverable resume failures must not start a fresh thread"), - raw: { - request: () => - Effect.fail( - new CodexErrors.CodexAppServerRequestError({ - code: -32603, - errorMessage: "timed out waiting for server", - }), - ), - }, - }; - - const error = yield* openCodexThread({ - client, - threadId: ThreadId.make("thread-1"), - runtimeMode: "full-access", - cwd: "/tmp/project", - requestedModel: "gpt-5.3-codex", - serviceTier: undefined, - resumeThreadId: "stale-thread", - }).pipe(Effect.flip); - - NodeAssert.ok(isCodexAppServerRequestError(error)); - NodeAssert.equal(error.errorMessage, "timed out waiting for server"); - }), - ); -}); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts deleted file mode 100644 index 674d23327b65..000000000000 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ /dev/null @@ -1,2687 +0,0 @@ -import { - ApprovalRequestId, - DEFAULT_MODEL, - EventId, - ProviderDriverKind, - ProviderItemId, - type ProviderInstanceId, - type ProviderApprovalDecision, - type ProviderApprovalOption, - type ProviderEvent, - type ProviderInteractionMode, - type ProviderRequestKind, - type ProviderSession, - type ProviderTurnStartResult, - type ProviderUserInputAnswers, - RuntimeMode, - ThreadId, - TurnId, -} from "@t3tools/contracts"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { normalizeModelSlug } from "@t3tools/shared/model"; -import * as Crypto from "effect/Crypto"; -import * as DateTime from "effect/DateTime"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Layer from "effect/Layer"; -import * as Queue from "effect/Queue"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import * as CodexClient from "effect-codex-app-server/client"; -import * as CodexErrors from "effect-codex-app-server/errors"; -import * as CodexRpc from "effect-codex-app-server/rpc"; -import * as EffectCodexSchema from "effect-codex-app-server/schema"; - -import { buildCodexInitializeParams } from "./CodexProvider.ts"; -import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; -import { expandHomePath } from "../../pathExpansion.ts"; -import { - buildCodexDeveloperInstructions, - type T3CodeToolAvailability, -} from "../CodexDeveloperInstructions.ts"; -const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); - -const PROVIDER = ProviderDriverKind.make("codex"); - -const ANSI_ESCAPE_CHAR = String.fromCharCode(27); -const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g"); -const CODEX_STDERR_LOG_REGEX = - /^\d{4}-\d{2}-\d{2}T\S+\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+\S+:\s+(.*)$/; -const BENIGN_ERROR_LOG_SNIPPETS = [ - "state db missing rollout path for thread", - "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back", -]; -const CODEX_APP_SERVER_FORCE_KILL_AFTER = "2 seconds" as const; -const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ - "not found", - "missing thread", - "no such thread", - "unknown thread", - "does not exist", - "no rollout found", -]; - -export function hasConfiguredMcpServer(appServerArgs: ReadonlyArray | undefined): boolean { - return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true; -} - -function configuredMcpToolAvailability( - appServerArgs: ReadonlyArray | undefined, - mcpCapabilities: ReadonlySet | undefined, -): T3CodeToolAvailability { - if (!hasConfiguredMcpServer(appServerArgs)) return { browser: false, device: false }; - // Callers predating the capability set attached the browser toolkit only. - if (mcpCapabilities === undefined) return { browser: true, device: false }; - return { browser: mcpCapabilities.has("preview"), device: mcpCapabilities.has("device") }; -} - -export const CodexResumeCursorSchema = Schema.Struct({ - threadId: Schema.String, -}); -const CodexUserInputAnswerObject = Schema.Struct({ - answers: Schema.Array(Schema.String), -}); -const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema); -const isCodexUserInputAnswerObject = Schema.is(CodexUserInputAnswerObject); -const NullableMcpElicitationString = Schema.NullOr(Schema.String); -const McpElicitationMetadata = Schema.Struct({ - app: Schema.optionalKey(NullableMcpElicitationString), - app_name: Schema.optionalKey(NullableMcpElicitationString), - appName: Schema.optionalKey(NullableMcpElicitationString), - connector_name: Schema.optionalKey(NullableMcpElicitationString), - connectorName: Schema.optionalKey(NullableMcpElicitationString), - allowPersistentApproval: Schema.optionalKey(Schema.NullOr(Schema.Boolean)), - persist: Schema.optionalKey( - Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)])), - ), - target: Schema.optionalKey( - Schema.NullOr( - Schema.Struct({ - app: Schema.optionalKey(NullableMcpElicitationString), - name: Schema.optionalKey(NullableMcpElicitationString), - }), - ), - ), - tool_params: Schema.optionalKey( - Schema.NullOr( - Schema.Struct({ - app: Schema.optionalKey(NullableMcpElicitationString), - app_name: Schema.optionalKey(NullableMcpElicitationString), - }), - ), - ), -}); -const McpElicitationFormField = Schema.Struct({ - type: Schema.optionalKey(NullableMcpElicitationString), - title: Schema.optionalKey(NullableMcpElicitationString), - description: Schema.optionalKey(NullableMcpElicitationString), - default: Schema.optionalKey(Schema.Unknown), - enum: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), - enumNames: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), - oneOf: Schema.optionalKey( - Schema.NullOr( - Schema.Array( - Schema.Struct({ - const: Schema.String, - title: Schema.optionalKey(NullableMcpElicitationString), - }), - ), - ), - ), -}); -const McpElicitationForm = Schema.Struct({ - properties: Schema.optionalKey(Schema.Record(Schema.String, McpElicitationFormField)), - required: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), -}); -const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata); -const isMcpElicitationForm = Schema.is(McpElicitationForm); - -// TODO: Verify `packages/effect-codex-app-server/scripts/generate.ts` so the generated -// `V2TurnStartParams` schema includes `collaborationMode` directly. -const CodexTurnStartParamsWithCollaborationMode = EffectCodexSchema.V2TurnStartParams.pipe( - Schema.fieldsAssign({ - collaborationMode: Schema.optionalKey(EffectCodexSchema.V2TurnStartParams__CollaborationMode), - }), -); -const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect( - CodexTurnStartParamsWithCollaborationMode, -); -const CodexChildResumeMetadata = Schema.Struct({ - thread: Schema.Struct({ id: Schema.String }), - model: Schema.String, - reasoningEffort: Schema.optionalKey(Schema.NullOr(Schema.String)), -}); -const decodeCodexChildResumeMetadata = Schema.decodeUnknownEffect(CodexChildResumeMetadata); - -export type CodexTurnStartParamsWithCollaborationMode = - typeof CodexTurnStartParamsWithCollaborationMode.Type; - -export type CodexResumeCursor = typeof CodexResumeCursorSchema.Type; -type CodexServiceTier = NonNullable; -type CodexThreadItem = - | EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number]["items"][number] - | EffectCodexSchema.V2ThreadRollbackResponse["thread"]["turns"][number]["items"][number]; - -export interface CodexSessionRuntimeOptions { - readonly threadId: ThreadId; - readonly providerInstanceId?: ProviderInstanceId; - readonly binaryPath: string; - readonly homePath?: string; - readonly launchArgs?: string; - readonly environment?: NodeJS.ProcessEnv; - readonly cwd: string; - readonly runtimeMode: RuntimeMode; - readonly model?: string; - readonly serviceTier?: CodexServiceTier | undefined; - readonly resumeCursor?: CodexResumeCursor; - readonly appServerArgs?: ReadonlyArray; - /** Capabilities the session's `t3-code` MCP credential grants; drives the prompt blocks. */ - readonly mcpCapabilities?: ReadonlySet; -} - -export interface CodexSessionRuntimeSendTurnInput { - readonly input?: string; - readonly attachments?: ReadonlyArray<{ - readonly type: "localImage"; - readonly path: string; - }>; - readonly model?: string; - readonly serviceTier?: CodexServiceTier | undefined; - readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort | undefined; - readonly interactionMode?: ProviderInteractionMode; -} - -export interface CodexThreadTurnSnapshot { - readonly id: TurnId; - readonly items: ReadonlyArray; -} - -export interface CodexThreadSnapshot { - readonly threadId: string; - readonly turns: ReadonlyArray; -} - -export interface CodexSessionRuntimeShape { - readonly start: () => Effect.Effect; - readonly getSession: Effect.Effect; - readonly sendTurn: ( - input: CodexSessionRuntimeSendTurnInput, - ) => Effect.Effect; - readonly compactThread: Effect.Effect; - readonly interruptTurn: (turnId?: TurnId) => Effect.Effect; - readonly readThread: Effect.Effect; - readonly rollbackThread: ( - numTurns: number, - ) => Effect.Effect; - readonly uploadFeedback: ( - reason?: string, - ) => Effect.Effect; - readonly respondToRequest: ( - requestId: ApprovalRequestId, - decision: ProviderApprovalDecision, - ) => Effect.Effect; - readonly respondToUserInput: ( - requestId: ApprovalRequestId, - answers: ProviderUserInputAnswers, - ) => Effect.Effect; - readonly events: Stream.Stream; - readonly close: Effect.Effect; -} - -export type CodexSessionRuntimeError = - | CodexErrors.CodexAppServerError - | CodexSessionRuntimePendingApprovalNotFoundError - | CodexSessionRuntimePendingUserInputNotFoundError - | CodexSessionRuntimeInvalidUserInputAnswersError - | CodexSessionRuntimeThreadIdMissingError; - -export class CodexSessionRuntimePendingApprovalNotFoundError extends Schema.TaggedError()( - "CodexSessionRuntimePendingApprovalNotFoundError", - { - requestId: Schema.String, - }, -) { - override get message(): string { - return `Unknown pending Codex approval request: ${this.requestId}`; - } -} - -export class CodexSessionRuntimePendingUserInputNotFoundError extends Schema.TaggedError()( - "CodexSessionRuntimePendingUserInputNotFoundError", - { - requestId: Schema.String, - }, -) { - override get message(): string { - return `Unknown pending Codex user input request: ${this.requestId}`; - } -} - -export class CodexSessionRuntimeInvalidUserInputAnswersError extends Schema.TaggedError()( - "CodexSessionRuntimeInvalidUserInputAnswersError", - { - questionId: Schema.String, - }, -) { - override get message(): string { - return `Invalid Codex user input answers for question '${this.questionId}'`; - } -} - -export class CodexSessionRuntimeThreadIdMissingError extends Schema.TaggedError()( - "CodexSessionRuntimeThreadIdMissingError", - { - threadId: Schema.String, - }, -) { - override get message(): string { - return `Codex session is missing a provider thread id for ${this.threadId}`; - } -} - -interface PendingApproval { - readonly requestId: ApprovalRequestId; - readonly jsonRpcId: string; - readonly requestKind: ProviderRequestKind; - readonly turnId: TurnId | undefined; - readonly itemId: ProviderItemId | undefined; - readonly decision: Deferred.Deferred; -} - -interface ApprovalCorrelation { - readonly requestId: ApprovalRequestId; - readonly requestKind: ProviderRequestKind; - readonly turnId: TurnId | undefined; - readonly itemId: ProviderItemId | undefined; -} - -interface PendingUserInput { - readonly requestId: ApprovalRequestId; - readonly turnId: TurnId | undefined; - readonly itemId: ProviderItemId | undefined; - readonly answers: Deferred.Deferred; -} - -type McpElicitationPersistenceDecision = Extract< - ProviderApprovalDecision, - "acceptForSession" | "acceptAlways" ->; - -function mcpElicitationPersistenceDecision( - value: string, -): McpElicitationPersistenceDecision | null { - const normalized = value.toLowerCase(); - if (normalized.includes("session")) return "acceptForSession"; - if ( - normalized.includes("always") || - normalized.includes("permanent") || - normalized.includes("forever") || - normalized.includes("persistent") - ) { - return "acceptAlways"; - } - return null; -} - -function mcpElicitationFormFields(payload: EffectCodexSchema.McpServerElicitationRequestParams) { - if (payload.mode === "url" || !isMcpElicitationForm(payload.requestedSchema)) { - return undefined; - } - return payload.requestedSchema; -} - -function mcpElicitationFieldOptions(field: typeof McpElicitationFormField.Type) { - if (field.oneOf) { - return field.oneOf.map((option) => ({ value: option.const, label: option.title })); - } - return (field.enum ?? []).map((value, index) => ({ - value, - label: field.enumNames?.[index], - })); -} - -function isMcpElicitationPersistenceField( - key: string, - field: typeof McpElicitationFormField.Type, -): boolean { - return ( - mcpElicitationPersistenceDecision(key) !== null || - key.toLowerCase() === "persist" || - mcpElicitationPersistenceDecision(field.title ?? "") !== null || - mcpElicitationPersistenceDecision(field.description ?? "") !== null - ); -} - -/** Returns the app and approval choices advertised by an MCP elicitation. */ -export function describeMcpElicitation( - payload: EffectCodexSchema.McpServerElicitationRequestParams, -): { readonly appName: string; readonly options: ReadonlyArray } { - const metadata = isMcpElicitationMetadata(payload._meta) ? payload._meta : undefined; - const appName = - metadata?.app_name ?? - metadata?.appName ?? - metadata?.app ?? - metadata?.target?.app ?? - metadata?.target?.name ?? - metadata?.tool_params?.app_name ?? - metadata?.tool_params?.app ?? - payload.message.match(/^Allow ChatGPT to use (.+?)\?$/i)?.[1] ?? - metadata?.connector_name ?? - metadata?.connectorName ?? - payload.serverName; - const persistenceOptions = new Map(); - const persist = metadata?.persist; - for (const value of typeof persist === "string" ? [persist] : (persist ?? [])) { - const decision = mcpElicitationPersistenceDecision(value); - if (decision) persistenceOptions.set(decision, ""); - } - if (metadata?.allowPersistentApproval) { - persistenceOptions.set("acceptAlways", ""); - } - - const form = mcpElicitationFormFields(payload); - for (const [key, field] of Object.entries(form?.properties ?? {})) { - for (const option of mcpElicitationFieldOptions(field)) { - const decision = mcpElicitationPersistenceDecision(option.value); - if (decision) persistenceOptions.set(decision, option.label ?? ""); - } - if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) { - persistenceOptions.set("acceptAlways", field.title ?? ""); - } - } - - return { - appName, - options: [ - { decision: "cancel", label: "Cancel" }, - { decision: "decline", label: "Decline" }, - ...(persistenceOptions.has("acceptForSession") && - toMcpElicitationResponse(payload, "acceptForSession").action === "accept" - ? [ - { - decision: "acceptForSession" as const, - label: persistenceOptions.get("acceptForSession") || "Always allow this session", - }, - ] - : []), - ...(persistenceOptions.has("acceptAlways") && - toMcpElicitationResponse(payload, "acceptAlways").action === "accept" - ? [ - { - decision: "acceptAlways" as const, - label: persistenceOptions.get("acceptAlways") || "Always allow", - }, - ] - : []), - { decision: "accept", label: "Approve" }, - ], - }; -} - -/** Converts a T3 approval decision into the MCP elicitation wire response. */ -export function toMcpElicitationResponse( - payload: EffectCodexSchema.McpServerElicitationRequestParams, - decision: ProviderApprovalDecision, -): EffectCodexSchema.McpServerElicitationRequestResponse { - if (decision === "decline" || decision === "cancel") { - return { action: decision }; - } - - if (payload.mode === "url") { - return { action: "decline" }; - } - - const persist = - decision === "acceptForSession" - ? "session" - : decision === "acceptAlways" - ? "always" - : undefined; - const form = mcpElicitationFormFields(payload); - const content: Record = {}; - - for (const [key, field] of Object.entries(form?.properties ?? {})) { - const options = mcpElicitationFieldOptions(field); - const chosenOption = options.find((option) => - persist - ? mcpElicitationPersistenceDecision(option.value) === decision - : /once|accept|approve|allow/i.test(option.value) && - mcpElicitationPersistenceDecision(option.value) === null, - ); - if (chosenOption) { - content[key] = chosenOption.value; - } else if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) { - content[key] = decision === "acceptAlways"; - } else if (field.default !== undefined && field.default !== null) { - content[key] = field.default; - } - } - - if (form?.required?.some((key) => !Object.hasOwn(content, key))) { - return { action: "decline" }; - } - - return { - action: "accept", - ...(persist ? { _meta: { persist } } : {}), - ...(form ? { content } : {}), - }; -} - -type CodexServerNotification = { - readonly [M in CodexRpc.ServerNotificationMethod]: { - readonly method: M; - readonly params: CodexRpc.ServerNotificationParamsByMethod[M]; - }; -}[CodexRpc.ServerNotificationMethod]; - -function makeCodexServerNotification( - method: M, - params: CodexRpc.ServerNotificationParamsByMethod[M], -): CodexServerNotification { - return { method, params } as CodexServerNotification; -} - -function normalizeCodexModelSlug( - model: string | undefined | null, - preferredId?: string, -): string | undefined { - const normalized = normalizeModelSlug(model); - if (!normalized) { - return undefined; - } - if (preferredId?.endsWith("-codex") && preferredId !== normalized) { - return preferredId; - } - return normalized; -} - -function readResumeCursorThreadId( - resumeCursor: ProviderSession["resumeCursor"], -): string | undefined { - return isCodexResumeCursorSchema(resumeCursor) ? resumeCursor.threadId : undefined; -} - -function runtimeModeToThreadConfig(input: RuntimeMode): { - readonly approvalPolicy: EffectCodexSchema.V2ThreadStartParams__AskForApproval; - readonly sandbox: EffectCodexSchema.V2ThreadStartParams__SandboxMode; - // Always explicit: omitting the field on resume keeps the thread's previous - // reviewer, which would leave auto_review sticky after switching modes. - readonly approvalsReviewer: EffectCodexSchema.V2ThreadStartParams__ApprovalsReviewer; -} { - switch (input) { - case "approval-required": - return { - approvalPolicy: "untrusted", - sandbox: "read-only", - approvalsReviewer: "user", - }; - case "auto-accept-edits": - return { - approvalPolicy: "on-request", - sandbox: "workspace-write", - approvalsReviewer: "user", - }; - case "auto": - return { - approvalPolicy: "on-request", - sandbox: "workspace-write", - approvalsReviewer: "auto_review", - }; - case "full-access": - default: - return { - approvalPolicy: "never", - sandbox: "danger-full-access", - approvalsReviewer: "user", - }; - } -} - -function buildThreadStartParams(input: { - readonly cwd: string; - readonly runtimeMode: RuntimeMode; - readonly model: string | undefined; - readonly serviceTier: CodexServiceTier | undefined; -}): EffectCodexSchema.V2ThreadStartParams { - const config = runtimeModeToThreadConfig(input.runtimeMode); - return { - cwd: input.cwd, - approvalPolicy: config.approvalPolicy, - sandbox: config.sandbox, - approvalsReviewer: config.approvalsReviewer, - ...(input.model ? { model: input.model } : {}), - ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), - }; -} - -function runtimeModeToTurnSandboxPolicy( - input: RuntimeMode, -): EffectCodexSchema.V2TurnStartParams__SandboxPolicy { - switch (input) { - case "approval-required": - return { - type: "readOnly", - }; - case "auto-accept-edits": - case "auto": - return { - type: "workspaceWrite", - }; - case "full-access": - default: - return { - type: "dangerFullAccess", - }; - } -} - -function buildCodexCollaborationMode(input: { - readonly interactionMode?: ProviderInteractionMode; - readonly model?: string; - readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; - readonly browserToolsAvailable?: boolean | T3CodeToolAvailability; -}): EffectCodexSchema.V2TurnStartParams__CollaborationMode | undefined { - if (input.interactionMode === undefined) { - return undefined; - } - const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL; - const reasoningEffort = input.effort ?? "medium"; - return { - mode: input.interactionMode, - settings: { - model, - reasoning_effort: reasoningEffort, - developer_instructions: buildCodexDeveloperInstructions( - input.interactionMode, - { model, reasoningEffort }, - input.browserToolsAvailable ?? true, - ), - }, - }; -} - -// Match the skill grammar used by Claude/Cursor, leaving currency amounts as prose. -const SKILL_MENTION_PATTERN = - /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; - -export function buildTurnStartParams(input: { - readonly threadId: string; - readonly runtimeMode: RuntimeMode; - readonly prompt?: string; - readonly attachments?: ReadonlyArray<{ - readonly type: "localImage"; - readonly path: string; - }>; - readonly model?: string; - readonly serviceTier?: CodexServiceTier; - readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; - readonly interactionMode?: ProviderInteractionMode; - /** Defaults to true so callers that predate the agent-access gate are unchanged. */ - readonly browserToolsAvailable?: boolean | T3CodeToolAvailability; -}): Effect.Effect< - CodexTurnStartParamsWithCollaborationMode, - CodexErrors.CodexAppServerProtocolParseError -> { - const turnInput: Array = []; - if (input.prompt) { - turnInput.push({ - type: "text", - text: input.prompt.replace(SKILL_MENTION_PATTERN, "$1$$$2"), - }); - } - for (const attachment of input.attachments ?? []) { - turnInput.push(attachment); - } - - const config = runtimeModeToThreadConfig(input.runtimeMode); - const collaborationMode = buildCodexCollaborationMode({ - ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), - ...(input.model ? { model: input.model } : {}), - ...(input.effort ? { effort: input.effort } : {}), - browserToolsAvailable: input.browserToolsAvailable ?? true, - }); - - return decodeCodexTurnStartParamsWithCollaborationMode({ - threadId: input.threadId, - input: turnInput, - approvalPolicy: config.approvalPolicy, - approvalsReviewer: config.approvalsReviewer, - sandboxPolicy: runtimeModeToTurnSandboxPolicy(input.runtimeMode), - ...(input.model ? { model: input.model } : {}), - ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), - ...(input.effort ? { effort: input.effort } : {}), - ...(collaborationMode ? { collaborationMode } : {}), - }).pipe( - Effect.mapError((cause) => - CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( - "decode-request-payload", - cause, - { method: "turn/start" }, - ), - ), - ); -} - -function classifyCodexStderrLine(rawLine: string): { readonly message: string } | null { - const line = rawLine.replaceAll(ANSI_ESCAPE_REGEX, "").trim(); - if (!line) { - return null; - } - - const match = line.match(CODEX_STDERR_LOG_REGEX); - if (match) { - const level = match[1]; - if (level && level !== "ERROR") { - return null; - } - if (BENIGN_ERROR_LOG_SNIPPETS.some((snippet) => line.includes(snippet))) { - return null; - } - } - - return { message: line }; -} - -export function isRecoverableThreadResumeError(error: unknown): boolean { - const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); - if (!message.includes("thread")) { - return false; - } - return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => message.includes(snippet)); -} - -const CodexThreadResumeMetadata = Schema.Struct({ - cwd: Schema.String, - model: Schema.String, - thread: Schema.Struct({ id: Schema.String }), -}); -const decodeCodexThreadResumeMetadata = Schema.decodeUnknownEffect(CodexThreadResumeMetadata); - -interface CodexThreadOpenClient { - readonly raw: { - readonly request: ( - method: "thread/resume", - payload: CodexRpc.ClientRequestParamsByMethod["thread/resume"] & { - readonly excludeTurns?: boolean; - }, - ) => Effect.Effect; - }; - readonly request: ( - method: "thread/start", - payload: CodexRpc.ClientRequestParamsByMethod["thread/start"], - ) => Effect.Effect< - CodexRpc.ClientRequestResponsesByMethod["thread/start"], - CodexErrors.CodexAppServerError - >; -} - -export const openCodexThread = (input: { - readonly client: CodexThreadOpenClient; - readonly threadId: ThreadId; - readonly runtimeMode: RuntimeMode; - readonly cwd: string; - readonly requestedModel: string | undefined; - readonly serviceTier: CodexServiceTier | undefined; - readonly resumeThreadId: string | undefined; -}): Effect.Effect => { - const resumeThreadId = input.resumeThreadId; - const startParams = buildThreadStartParams({ - cwd: input.cwd, - runtimeMode: input.runtimeMode, - model: input.requestedModel, - serviceTier: input.serviceTier, - }); - - if (resumeThreadId === undefined) { - return input.client.request("thread/start", startParams); - } - - // Older providers may still return history despite excludeTurns. Only the - // session metadata is needed here, so unrelated historical items cannot - // prevent resuming a valid provider thread. - return input.client.raw - .request("thread/resume", { - threadId: resumeThreadId, - ...startParams, - excludeTurns: true, - }) - .pipe( - Effect.flatMap((response) => - decodeCodexThreadResumeMetadata(response).pipe( - Effect.mapError((error) => - CodexErrors.CodexAppServerRequestError.invalidPayload( - "thread/resume", - "decode-payload", - error, - ), - ), - ), - ), - Effect.catchIf(isRecoverableThreadResumeError, (error) => - Effect.logWarning("codex app-server thread resume fell back to fresh start", { - threadId: input.threadId, - requestedRuntimeMode: input.runtimeMode, - resumeThreadId, - recoverable: true, - cause: error, - }).pipe(Effect.andThen(input.client.request("thread/start", startParams))), - ), - ); -}; - -function readNotificationThreadId(notification: CodexServerNotification): string | undefined { - switch (notification.method) { - case "thread/started": - return notification.params.thread.id; - case "error": - case "thread/status/changed": - case "thread/archived": - case "thread/unarchived": - case "thread/closed": - case "thread/name/updated": - case "thread/settings/updated": - case "thread/tokenUsage/updated": - case "model/rerouted": - case "turn/started": - case "hook/started": - case "turn/completed": - case "hook/completed": - case "turn/diff/updated": - case "turn/plan/updated": - case "item/started": - case "item/autoApprovalReview/started": - case "item/autoApprovalReview/completed": - case "item/completed": - case "rawResponseItem/completed": - case "item/agentMessage/delta": - case "item/plan/delta": - case "item/commandExecution/outputDelta": - case "item/commandExecution/terminalInteraction": - case "item/fileChange/outputDelta": - case "item/fileChange/patchUpdated": - case "serverRequest/resolved": - case "item/mcpToolCall/progress": - case "item/reasoning/summaryTextDelta": - case "item/reasoning/summaryPartAdded": - case "item/reasoning/textDelta": - case "thread/compacted": - case "thread/realtime/started": - case "thread/realtime/itemAdded": - case "thread/realtime/transcript/delta": - case "thread/realtime/transcript/done": - case "thread/realtime/outputAudio/delta": - case "thread/realtime/sdp": - case "thread/realtime/error": - case "thread/realtime/closed": - return notification.params.threadId; - default: - return undefined; - } -} - -export function makeMemoryConsolidationNotificationFilter(): ( - notification: CodexServerNotification, -) => boolean { - const threadIds = new Set(); - - return (notification) => { - if (notification.method === "thread/started") { - const thread = notification.params.thread; - const source = thread.source; - if ( - thread.threadSource === "memory_consolidation" || - (typeof source === "object" && - source !== null && - "subAgent" in source && - source.subAgent === "memory_consolidation") - ) { - threadIds.add(thread.id); - return true; - } - } - - const params = notification.params; - const threadId = - notification.method === "thread/started" - ? notification.params.thread.id - : "threadId" in params && typeof params.threadId === "string" - ? params.threadId - : undefined; - if (!threadId || !threadIds.has(threadId)) { - return false; - } - - if (notification.method === "serverRequest/resolved") { - return false; - } - - if (notification.method === "thread/closed") { - threadIds.delete(threadId); - } - return true; - }; -} - -function readRouteFields(notification: CodexServerNotification): { - readonly turnId: TurnId | undefined; - readonly itemId: ProviderItemId | undefined; -} { - switch (notification.method) { - case "thread/started": - return { - turnId: undefined, - itemId: undefined, - }; - case "turn/started": - case "turn/completed": - return { - turnId: TurnId.make(notification.params.turn.id), - itemId: undefined, - }; - case "error": - return { - turnId: TurnId.make(notification.params.turnId), - itemId: undefined, - }; - case "turn/diff/updated": - case "turn/plan/updated": - return { - turnId: TurnId.make(notification.params.turnId), - itemId: undefined, - }; - case "serverRequest/resolved": - return { - turnId: undefined, - itemId: undefined, - }; - case "item/started": - case "item/completed": - return { - turnId: TurnId.make(notification.params.turnId), - itemId: ProviderItemId.make(notification.params.item.id), - }; - case "item/agentMessage/delta": - case "item/plan/delta": - case "item/commandExecution/outputDelta": - case "item/commandExecution/terminalInteraction": - case "item/fileChange/outputDelta": - case "item/fileChange/patchUpdated": - case "item/reasoning/summaryTextDelta": - case "item/reasoning/summaryPartAdded": - case "item/reasoning/textDelta": - return { - turnId: TurnId.make(notification.params.turnId), - itemId: ProviderItemId.make(notification.params.itemId), - }; - default: - return { - turnId: undefined, - itemId: undefined, - }; - } -} - -/** - * Native collab child-agent tracking (multi-agent v2). Under v2 subagents are - * full app-server threads: identity arrives on `thread/started` with - * source.subAgent.thread_spawn, lifecycle on `subAgentActivity` items and the - * child thread's own turn/status/tokenUsage notifications. The runtime - * registers children from those explicit signals, intercepts their - * notifications before parent-timeline mapping, and re-emits them as - * synthetic `collabAgent/*` provider events the adapter turns into task.* - * runtime events (timelineBypass keeps them out of the parent chat). - * - * WIP, probe-gated: registration is deliberately explicit-signals-only. The - * spec's "provisionally treat unknown foreign thread ids as v2 children" rule - * needs a live wire capture of the packaged binary before it lands — blind - * capture risks eating unrelated traffic. Until then a child whose first - * notification precedes registration passes through as today (no regression - * vs main, which passes everything through). - */ -interface CollabChildAgentState { - readonly agentThreadId: string; - readonly nickname: string | undefined; - readonly role: string | undefined; - readonly agentPath: string | undefined; - readonly depth: number | undefined; - readonly parentThreadId: string | undefined; - /** - * Parent canonical turn active when the child registered. Stamped on every - * synthetic collabAgent/* event so clients can batch a fleet by its spawn - * turn — without it, separate fleets in one thread collapsed into a single - * "direct:no-turn" CTA (review finding). - */ - readonly spawnTurnId: TurnId | undefined; -} - -interface CollabChildMetadataState { - readonly model: string | undefined; - readonly effort: string | undefined; - readonly lookupStarted: boolean; - readonly closed: boolean; -} - -function collabChildIdentity( - child: CollabChildAgentState, - metadata: CollabChildMetadataState | undefined, -) { - return { - agentThreadId: child.agentThreadId, - ...(child.nickname ? { nickname: child.nickname } : {}), - ...(child.role ? { role: child.role } : {}), - ...(child.agentPath ? { agentPath: child.agentPath } : {}), - ...(metadata?.model ? { model: metadata.model } : {}), - ...(metadata?.effort ? { effort: metadata.effort } : {}), - }; -} - -function nonEmptyMetadataValue(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function readThreadSpawnSource(thread: { readonly source: unknown }): - | { - nickname: string | undefined; - role: string | undefined; - agentPath: string | undefined; - depth: number | undefined; - parentThreadId: string | undefined; - } - | undefined { - const source = thread.source; - if (typeof source !== "object" || source === null || !("subAgent" in source)) { - return undefined; - } - const subAgent = (source as { subAgent: unknown }).subAgent; - if (typeof subAgent !== "object" || subAgent === null || !("thread_spawn" in subAgent)) { - return undefined; - } - const spawn = (subAgent as { thread_spawn: unknown }).thread_spawn; - if (typeof spawn !== "object" || spawn === null) { - return undefined; - } - const record = spawn as Record; - return { - nickname: typeof record.agent_nickname === "string" ? record.agent_nickname : undefined, - role: typeof record.agent_role === "string" ? record.agent_role : undefined, - agentPath: typeof record.agent_path === "string" ? record.agent_path : undefined, - depth: typeof record.depth === "number" ? record.depth : undefined, - parentThreadId: - typeof record.parent_thread_id === "string" ? record.parent_thread_id : undefined, - }; -} - -function rememberCollabReceiverTurns( - collabReceiverTurns: Map, - notification: CodexServerNotification, - parentTurnId: TurnId | undefined, -): void { - if (!parentTurnId) { - return; - } - - if (notification.method !== "item/started" && notification.method !== "item/completed") { - return; - } - - if (notification.params.item.type !== "collabAgentToolCall") { - return; - } - - for (const receiverThreadId of notification.params.item.receiverThreadIds) { - collabReceiverTurns.set(receiverThreadId, parentTurnId); - } -} - -function shouldSuppressChildConversationNotification( - method: CodexRpc.ServerNotificationMethod, -): boolean { - return ( - method === "thread/started" || - method === "thread/status/changed" || - method === "thread/archived" || - method === "thread/unarchived" || - method === "thread/closed" || - method === "thread/compacted" || - method === "thread/name/updated" || - method === "thread/settings/updated" || - method === "thread/tokenUsage/updated" || - method === "model/rerouted" || - method === "turn/started" || - method === "turn/completed" || - method === "turn/plan/updated" || - method === "item/plan/delta" - ); -} - -/** - * How a notification addressed to a REGISTERED child thread is handled. - * - * Exported and pure so the routing table can be asserted against captured - * wire traces (see codexMultiAgentWire.json) rather than only read. - * - * - "agent-event": map to a synthetic collabAgent/* event (Agents surface). - * - "parent": pass through to the parent path — it carries state the parent - * still owns (approval correlation cleanup). - * - "drop": genuine child chatter with no parent meaning (deltas, name and - * plan updates). - * - * Default is "drop" ONLY for the enumerated chatter; anything unrecognized - * routes to "parent" so new wire methods surface instead of vanishing - * (two shipped bugs came from a catch-all that swallowed everything). - */ -export type CodexChildNotificationRoute = "agent-event" | "parent" | "drop"; - -const CHILD_AGENT_EVENT_METHODS: ReadonlySet = new Set([ - "turn/started", - "turn/completed", - "thread/status/changed", - "thread/tokenUsage/updated", - "thread/settings/updated", - "model/rerouted", - "item/started", - "item/completed", - "thread/closed", - "error", -]); - -const CHILD_CHATTER_METHODS: ReadonlySet = new Set([ - "item/agentMessage/delta", - "item/reasoning/textDelta", - "item/reasoning/summaryTextDelta", - "item/reasoning/summaryPartAdded", - "item/commandExecution/outputDelta", - "item/fileChange/outputDelta", - "item/fileChange/patchUpdated", - "item/plan/delta", - "turn/plan/updated", - "turn/diff/updated", - "thread/name/updated", - "rawResponseItem/completed", - // Child-owned thread lifecycle: the parent adapter maps these onto the - // PARENT thread (archived/compacted state), so a child compacting would - // rewrite the parent. Mirrors the v1 suppressor list — dropping them is - // the pre-existing behavior for collab children (review finding). - "thread/archived", - "thread/unarchived", - "thread/compacted", - // Registration path 1 handles a child's first thread/started; a repeat - // must not reach the parent (it would restart the parent's thread state). - "thread/started", -]); - -export function routeCodexChildNotification(method: string): CodexChildNotificationRoute { - if (CHILD_AGENT_EVENT_METHODS.has(method)) { - return "agent-event"; - } - if (CHILD_CHATTER_METHODS.has(method)) { - return "drop"; - } - // Unknown or parent-owned (serverRequest/resolved, approvals, …). - return "parent"; -} - -function toCodexUserInputAnswer( - questionId: string, - value: ProviderUserInputAnswers[string], -): Effect.Effect< - EffectCodexSchema.ToolRequestUserInputResponse__ToolRequestUserInputAnswer, - CodexSessionRuntimeInvalidUserInputAnswersError -> { - if (typeof value === "string") { - return Effect.succeed({ answers: [value] }); - } - if (Array.isArray(value)) { - const answers = value.filter((entry): entry is string => typeof entry === "string"); - return Effect.succeed({ answers }); - } - if (isCodexUserInputAnswerObject(value)) { - return Effect.succeed({ answers: value.answers }); - } - return Effect.fail(new CodexSessionRuntimeInvalidUserInputAnswersError({ questionId })); -} - -function toCodexUserInputAnswers( - answers: ProviderUserInputAnswers, -): Effect.Effect< - EffectCodexSchema.ToolRequestUserInputResponse["answers"], - CodexSessionRuntimeInvalidUserInputAnswersError -> { - return Effect.forEach( - Object.entries(answers), - ([questionId, value]) => - toCodexUserInputAnswer(questionId, value).pipe( - Effect.map((answer) => [questionId, answer] as const), - ), - { concurrency: 1 }, - ).pipe(Effect.map((entries) => Object.fromEntries(entries))); -} - -function currentProviderThreadId(session: ProviderSession): string | undefined { - return readResumeCursorThreadId(session.resumeCursor); -} - -function updateSession( - sessionRef: Ref.Ref, - updates: Partial | ((session: ProviderSession) => Partial), -): Effect.Effect { - return Effect.gen(function* () { - const updatedAt = DateTime.formatIso(yield* DateTime.now); - yield* Ref.update(sessionRef, (session) => ({ - ...session, - ...(typeof updates === "function" ? updates(session) : updates), - updatedAt, - })); - }); -} - -function parseThreadSnapshot( - response: EffectCodexSchema.V2ThreadReadResponse | EffectCodexSchema.V2ThreadRollbackResponse, -): CodexThreadSnapshot { - return { - threadId: response.thread.id, - turns: response.thread.turns.map((turn) => ({ - id: TurnId.make(turn.id), - items: turn.items, - })), - }; -} - -const CodexThreadHistoryMetadata = Schema.Struct({ - thread: Schema.Struct({ - historyMode: Schema.optionalKey(Schema.Literals(["legacy", "paginated"])), - }), -}); -const CodexTurnsPage = Schema.Struct({ - data: Schema.Array(EffectCodexSchema.V2ThreadReadResponse__Turn), - nextCursor: Schema.NullOr(Schema.String), -}); -const decodeCodexHistoryMetadata = Schema.decodeUnknownEffect(CodexThreadHistoryMetadata); -const decodeCodexTurnsPage = Schema.decodeUnknownEffect(CodexTurnsPage); -type CodexHistoryClient = { - readonly raw: Pick; - readonly request: CodexClient.CodexAppServerClient["Service"]["request"]; -}; - -const readCodexHistoryMode = Effect.fn("readCodexHistoryMode")(function* ( - client: CodexHistoryClient, - threadId: string, -) { - const response = yield* client.raw.request("thread/read", { threadId, includeTurns: false }); - const metadata = yield* decodeCodexHistoryMetadata(response).pipe( - Effect.mapError((error) => - CodexErrors.CodexAppServerRequestError.invalidPayload("thread/read", "decode-payload", error), - ), - ); - return metadata.thread.historyMode; -}); - -export const readCodexThread = Effect.fn("readCodexThread")(function* ( - client: CodexHistoryClient, - threadId: string, -): Effect.fn.Return { - if ((yield* readCodexHistoryMode(client, threadId)) !== "paginated") { - return parseThreadSnapshot( - yield* client.request("thread/read", { threadId, includeTurns: true }), - ); - } - const turns: Array = []; - const requestedCursors = new Set(); - let cursor: string | null = null; - do { - if (requestedCursors.has(cursor)) { - return yield* CodexErrors.CodexAppServerRequestError.internalError( - "Thread history pagination repeated a cursor.", - undefined, - { method: "thread/turns/list", operation: "decode-payload" }, - ); - } - requestedCursors.add(cursor); - const response: unknown = yield* client.raw.request("thread/turns/list", { - threadId, - cursor, - limit: 100, - sortDirection: "asc", - itemsView: "full", - }); - const page = yield* decodeCodexTurnsPage(response).pipe( - Effect.mapError((error) => - CodexErrors.CodexAppServerRequestError.invalidPayload( - "thread/turns/list", - "decode-payload", - error, - ), - ), - ); - turns.push(...page.data.map((turn) => ({ id: TurnId.make(turn.id), items: turn.items }))); - cursor = page.nextCursor; - } while (cursor !== null); - return { threadId, turns }; -}); - -export const rollbackCodexThread = Effect.fn("rollbackCodexThread")(function* ( - client: CodexHistoryClient, - threadId: string, - numTurns: number, -): Effect.fn.Return { - if ((yield* readCodexHistoryMode(client, threadId)) !== "paginated") { - return parseThreadSnapshot(yield* client.request("thread/rollback", { threadId, numTurns })); - } - // Paginated threads replace history at a turn boundary instead of supporting - // the legacy count-based rollback endpoint. - const snapshot = yield* readCodexThread(client, threadId); - const retainedCount = Math.max(0, snapshot.turns.length - numTurns); - const firstRemoved = snapshot.turns[retainedCount]; - if (firstRemoved) { - yield* client.raw.request("thread/revert", { threadId, beforeTurnId: firstRemoved.id }); - } - return { threadId, turns: snapshot.turns.slice(0, retainedCount) }; -}); - -export const makeCodexSessionRuntime = ( - options: CodexSessionRuntimeOptions, -): Effect.Effect< - CodexSessionRuntimeShape, - CodexErrors.CodexAppServerError, - ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope -> => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const runtimeScope = yield* Scope.Scope; - const crypto = yield* Crypto.Crypto; - const events = yield* Queue.unbounded(); - const pendingApprovalsRef = yield* Ref.make(new Map()); - const approvalCorrelationsRef = yield* Ref.make(new Map()); - const pendingUserInputsRef = yield* Ref.make(new Map()); - const collabReceiverTurnsRef = yield* Ref.make(new Map()); - const collabChildAgentsRef = yield* Ref.make(new Map()); - const collabChildMetadataRef = yield* Ref.make(new Map()); - /** Child provider-thread id → its currently running provider turn id. */ - const collabChildLiveTurnsRef = yield* Ref.make(new Map()); - const suppressMemoryConsolidationNotification = makeMemoryConsolidationNotificationFilter(); - const closedRef = yield* Ref.make(false); - - // `~` is not shell-expanded when env vars are set via - // `child_process.spawn`; `expandHomePath` lets a configured - // `CODEX_HOME=~/.codex_work` reach codex as an absolute path. - const resolvedHomePath = options.homePath ? expandHomePath(options.homePath) : undefined; - const env = { - ...options.environment, - ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), - }; - const extendEnv = options.environment === undefined; - const appServerArgs = codexSessionAppServerArgs(options.appServerArgs, options.launchArgs); - const spawnCommand = yield* resolveSpawnCommand(options.binaryPath, appServerArgs, { - env, - extendEnv, - }); - const child = yield* spawner - .spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - cwd: options.cwd, - env, - extendEnv, - forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, - shell: spawnCommand.shell, - }), - ) - .pipe( - Effect.provideService(Scope.Scope, runtimeScope), - Effect.mapError( - (cause) => - new CodexErrors.CodexAppServerSpawnError({ - command: `${options.binaryPath} app-server`, - cause, - }), - ), - ); - - const clientContext = yield* CodexClient.layerChildProcess(child).pipe( - Layer.build, - Effect.provideService(Scope.Scope, runtimeScope), - ); - const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( - Effect.provide(clientContext), - ); - const serverNotifications = yield* Queue.unbounded(); - const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - const randomUUIDv4 = (purpose: CodexErrors.CodexAppServerIdentifierPurpose) => - crypto.randomUUIDv4.pipe( - Effect.mapError( - (cause) => - new CodexErrors.CodexAppServerIdentifierGenerationError({ - purpose, - cause, - }), - ), - ); - - const sessionCreatedAt = yield* nowIso; - const initialSession = { - provider: PROVIDER, - ...(options.providerInstanceId ? { providerInstanceId: options.providerInstanceId } : {}), - status: "connecting", - runtimeMode: options.runtimeMode, - cwd: options.cwd, - ...(options.model ? { model: options.model } : {}), - threadId: options.threadId, - ...(options.resumeCursor !== undefined ? { resumeCursor: options.resumeCursor } : {}), - createdAt: sessionCreatedAt, - updatedAt: sessionCreatedAt, - } satisfies ProviderSession; - const sessionRef = yield* Ref.make(initialSession); - const offerEvent = (event: ProviderEvent) => Queue.offer(events, event).pipe(Effect.asVoid); - - const emitEvent = (event: Omit) => - Effect.gen(function* () { - const id = yield* randomUUIDv4("provider-event"); - return yield* offerEvent({ - id: EventId.make(id), - provider: PROVIDER, - ...(options.providerInstanceId ? { providerInstanceId: options.providerInstanceId } : {}), - createdAt: yield* nowIso, - ...event, - }); - }); - const emitSessionEvent = (method: string, message: string) => - emitEvent({ - kind: "session", - threadId: options.threadId, - method, - message, - }); - - const updateCollabChildMetadata = ( - agentThreadId: string, - update: { readonly model?: string; readonly effort?: string }, - overwriteKnown: boolean, - ) => - Ref.modify(collabChildMetadataRef, (current) => { - const previous = current.get(agentThreadId) ?? { - model: undefined, - effort: undefined, - lookupStarted: false, - closed: false, - }; - const model = - update.model && (overwriteKnown || !previous.model) ? update.model : previous.model; - const effort = - update.effort && (overwriteKnown || !previous.effort) ? update.effort : previous.effort; - const changed = model !== previous.model || effort !== previous.effort; - if (!changed) { - return [false, current] as const; - } - const next = new Map(current); - next.set(agentThreadId, { ...previous, model, effort }); - return [true, next] as const; - }); - - const markCollabChildClosed = (agentThreadId: string) => - Ref.update(collabChildMetadataRef, (current) => { - const previous = current.get(agentThreadId) ?? { - model: undefined, - effort: undefined, - lookupStarted: false, - closed: false, - }; - if (previous.closed) { - return current; - } - const next = new Map(current); - next.set(agentThreadId, { ...previous, closed: true }); - return next; - }); - - const markCollabChildOpen = (agentThreadId: string) => - Ref.update(collabChildMetadataRef, (current) => { - const previous = current.get(agentThreadId); - if (!previous?.closed) { - return current; - } - const next = new Map(current); - next.set(agentThreadId, { ...previous, closed: false }); - return next; - }); - - const emitCollabChildMetadataUpdated = Effect.fn( - "CodexSessionRuntime.emitCollabChildMetadataUpdated", - )(function* (agentThreadId: string) { - const child = (yield* Ref.get(collabChildAgentsRef)).get(agentThreadId); - const metadata = (yield* Ref.get(collabChildMetadataRef)).get(agentThreadId); - if (!child || metadata?.closed) { - return; - } - yield* emitEvent({ - kind: "notification", - threadId: options.threadId, - ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), - method: "collabAgent/metadataUpdated", - payload: collabChildIdentity(child, metadata), - }); - }); - - const startCollabChildMetadataLookup = Effect.fn( - "CodexSessionRuntime.startCollabChildMetadataLookup", - )(function* (agentThreadId: string) { - const shouldStart = yield* Ref.modify(collabChildMetadataRef, (current) => { - const previous = current.get(agentThreadId) ?? { - model: undefined, - effort: undefined, - lookupStarted: false, - closed: false, - }; - if (previous.lookupStarted || previous.closed) { - return [false, current] as const; - } - const next = new Map(current); - next.set(agentThreadId, { ...previous, lookupStarted: true }); - return [true, next] as const; - }); - if (!shouldStart) { - return; - } - - // The child is already loaded. This rejoins it without starting a turn, - // and excludeTurns avoids loading or replaying its history. - yield* client.raw - .request("thread/resume", { threadId: agentThreadId, excludeTurns: true }) - .pipe( - Effect.flatMap(decodeCodexChildResumeMetadata), - Effect.timeout("5 seconds"), - Effect.flatMap((response) => - Effect.gen(function* () { - if (response.thread.id !== agentThreadId) { - return; - } - const child = (yield* Ref.get(collabChildAgentsRef)).get(agentThreadId); - const metadata = (yield* Ref.get(collabChildMetadataRef)).get(agentThreadId); - if (!child || metadata?.closed) { - return; - } - const model = nonEmptyMetadataValue(response.model); - const effort = nonEmptyMetadataValue(response.reasoningEffort); - const changed = yield* updateCollabChildMetadata( - agentThreadId, - { - ...(model ? { model } : {}), - ...(effort ? { effort } : {}), - }, - false, - ); - if (changed) { - yield* emitCollabChildMetadataUpdated(agentThreadId); - } - }), - ), - Effect.catch(() => Effect.void), - Effect.forkIn(runtimeScope), - ); - }); - - const settlePendingApprovals = (decision: ProviderApprovalDecision) => - Ref.get(pendingApprovalsRef).pipe( - Effect.flatMap((pendingApprovals) => - Effect.forEach( - Array.from(pendingApprovals.values()), - (pendingApproval) => - Deferred.succeed(pendingApproval.decision, decision).pipe(Effect.ignore), - { discard: true }, - ), - ), - ); - - const settlePendingUserInputs = (answers: ProviderUserInputAnswers) => - Ref.get(pendingUserInputsRef).pipe( - Effect.flatMap((pendingUserInputs) => - Effect.forEach( - Array.from(pendingUserInputs.values()), - (pendingUserInput) => - Deferred.succeed(pendingUserInput.answers, answers).pipe(Effect.ignore), - { discard: true }, - ), - ), - ); - - /** - * Registers v2 collab children and re-emits their notifications as - * synthetic `collabAgent/*` events for the adapter's task.* synthesis. - * Returns true when the notification was fully handled (must not reach - * parent-timeline mapping). - */ - const interceptCollabChildNotification = (notification: CodexServerNotification) => - Effect.gen(function* () { - // Registration path 1: child thread announces itself with a - // subAgent thread_spawn source. - if (notification.method === "thread/started") { - const thread = notification.params.thread; - const spawn = readThreadSpawnSource(thread); - if (!spawn) { - return false; - } - const rootProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); - if (thread.id === rootProviderThreadId) { - return false; - } - // Merge with any subAgentActivity registration that got here - // first. spawnTurnId is REGISTRATION-time-only on both paths: for - // an already-known child we keep its value (set or unset) — a - // later thread/started during an unrelated parent turn must not - // backfill that turn as the spawn batch, which would stamp an old - // child onto a new fleet's CTA (review finding). Only a genuinely - // new registration captures the current turn. - const existingChild = (yield* Ref.get(collabChildAgentsRef)).get(thread.id); - const spawnTurnId = existingChild - ? existingChild.spawnTurnId - : ((yield* Ref.get(sessionRef)).activeTurnId ?? undefined); - const state: CollabChildAgentState = { - agentThreadId: thread.id, - nickname: spawn.nickname ?? thread.agentNickname ?? existingChild?.nickname, - role: spawn.role ?? thread.agentRole ?? existingChild?.role, - agentPath: spawn.agentPath ?? existingChild?.agentPath, - depth: spawn.depth ?? existingChild?.depth, - parentThreadId: - spawn.parentThreadId ?? thread.parentThreadId ?? existingChild?.parentThreadId, - spawnTurnId, - }; - yield* Ref.update(collabChildAgentsRef, (current) => { - const next = new Map(current); - next.set(thread.id, state); - return next; - }); - const metadata = (yield* Ref.get(collabChildMetadataRef)).get(thread.id); - yield* emitEvent({ - kind: "notification", - threadId: options.threadId, - method: "collabAgent/started", - ...(state.spawnTurnId ? { turnId: state.spawnTurnId } : {}), - payload: { - ...collabChildIdentity(state, metadata), - ...(state.depth !== undefined ? { depth: state.depth } : {}), - ...(state.parentThreadId ? { parentThreadId: state.parentThreadId } : {}), - }, - }); - yield* startCollabChildMetadataLookup(thread.id); - return true; - } - - // Registration path 2: parent-side subAgentActivity item names the - // child thread (may arrive before or after thread/started). - if ( - (notification.method === "item/started" || notification.method === "item/completed") && - notification.params.item.type === "subAgentActivity" - ) { - const item = notification.params.item; - // Never register the session's ROOT thread as its own child. The - // wire emits subAgentActivity {agentPath: "/root", interacted} - // about the root during collab runs; registering it intercepted - // every subsequent root notification — including the final - // assistant message and turn/completed — so the thread hung - // "working" after all subagents finished (live-probe finding). - const rootProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); - if ( - item.agentThreadId === rootProviderThreadId || - item.agentPath === "/root" || - item.agentPath === "/" - ) { - return false; - } - const activitySpawnTurnId = (yield* Ref.get(sessionRef)).activeTurnId ?? undefined; - yield* Ref.update(collabChildAgentsRef, (current) => { - const existing = current.get(item.agentThreadId); - const next = new Map(current); - // Merge-late semantics: when thread/started registered first, a - // later subAgentActivity still carries the real agentPath (and a - // derived nickname) — fill missing fields, never clobber known - // ones. spawnTurnId is registration-time-only: for an already - // registered child, a later activity during an UNRELATED turn - // must not backfill that turn as the spawn batch (review - // finding); an unset spawn turn stays unset. - next.set(item.agentThreadId, { - agentThreadId: item.agentThreadId, - nickname: - existing?.nickname ?? - item.agentPath.split("/").findLast((segment) => segment.length > 0), - role: existing?.role, - agentPath: existing?.agentPath ?? item.agentPath, - depth: existing?.depth, - parentThreadId: existing?.parentThreadId, - spawnTurnId: existing ? existing.spawnTurnId : activitySpawnTurnId, - }); - return next; - }); - const registeredChild = (yield* Ref.get(collabChildAgentsRef)).get(item.agentThreadId); - const metadata = (yield* Ref.get(collabChildMetadataRef)).get(item.agentThreadId); - yield* emitEvent({ - kind: "notification", - threadId: options.threadId, - method: "collabAgent/activity", - ...(registeredChild?.spawnTurnId ? { turnId: registeredChild.spawnTurnId } : {}), - payload: { - ...(registeredChild - ? collabChildIdentity(registeredChild, metadata) - : { agentThreadId: item.agentThreadId, agentPath: item.agentPath }), - activityKind: item.kind, - }, - }); - if (item.kind === "started") { - yield* startCollabChildMetadataLookup(item.agentThreadId); - } - return true; - } - - // Interception: notifications addressed to a registered child thread - // become agent-scoped synthetic events instead of parent chatter. - const providerConversationId = readNotificationThreadId(notification); - if (!providerConversationId) { - return false; - } - // Belt-and-braces: the root thread's traffic must never be - // intercepted, whatever the registry says. - const interceptRootId = currentProviderThreadId(yield* Ref.get(sessionRef)); - if (providerConversationId === interceptRootId) { - return false; - } - - if ( - interceptRootId !== undefined && - (notification.method === "thread/settings/updated" || - notification.method === "model/rerouted") - ) { - const model = nonEmptyMetadataValue( - notification.method === "thread/settings/updated" - ? notification.params.threadSettings.model - : notification.params.toModel, - ); - const effort = - notification.method === "thread/settings/updated" - ? nonEmptyMetadataValue(notification.params.threadSettings.effort) - : undefined; - const changed = yield* updateCollabChildMetadata( - providerConversationId, - { - ...(model ? { model } : {}), - ...(effort ? { effort } : {}), - }, - true, - ); - if (changed && (yield* Ref.get(collabChildAgentsRef)).has(providerConversationId)) { - yield* emitCollabChildMetadataUpdated(providerConversationId); - } - return true; - } - - const children = yield* Ref.get(collabChildAgentsRef); - const child = children.get(providerConversationId); - if (!child) { - return false; - } - const metadata = (yield* Ref.get(collabChildMetadataRef)).get(child.agentThreadId); - const childIdentity = collabChildIdentity(child, metadata); - switch (notification.method) { - case "turn/started": { - yield* markCollabChildOpen(child.agentThreadId); - const childTurnId = - typeof (notification.params as { turn?: { id?: unknown } }).turn?.id === "string" - ? ((notification.params as { turn: { id: string } }).turn.id as string) - : undefined; - if (childTurnId) { - yield* Ref.update(collabChildLiveTurnsRef, (current) => { - const next = new Map(current); - next.set(child.agentThreadId, childTurnId); - return next; - }); - } - yield* emitEvent({ - kind: "notification", - threadId: options.threadId, - ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), - method: "collabAgent/turnStarted", - payload: childIdentity, - }); - return true; - } - case "turn/completed": - yield* Ref.update(collabChildLiveTurnsRef, (current) => { - const next = new Map(current); - next.delete(child.agentThreadId); - return next; - }); - yield* emitEvent({ - kind: "notification", - threadId: options.threadId, - ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), - method: "collabAgent/turnCompleted", - payload: { - ...childIdentity, - turn: notification.params.turn, - }, - }); - return true; - case "thread/status/changed": - yield* emitEvent({ - kind: "notification", - threadId: options.threadId, - ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), - method: "collabAgent/statusChanged", - payload: { - ...childIdentity, - status: notification.params.status, - }, - }); - return true; - case "thread/tokenUsage/updated": - yield* emitEvent({ - kind: "notification", - threadId: options.threadId, - ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), - method: "collabAgent/tokenUsage", - payload: { - ...childIdentity, - tokenUsage: notification.params.tokenUsage, - }, - }); - return true; - case "item/started": - case "item/completed": - yield* emitEvent({ - kind: "notification", - threadId: options.threadId, - ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), - method: "collabAgent/item", - payload: { - ...childIdentity, - item: notification.params.item, - }, - }); - return true; - case "thread/closed": - // The child is gone: drop its live-turn entry so a later Stop - // doesn't waste a turn/interrupt RPC on a closed thread before - // reaching the parent (review finding). - yield* Ref.update(collabChildLiveTurnsRef, (current) => { - const next = new Map(current); - next.delete(child.agentThreadId); - return next; - }); - yield* markCollabChildClosed(child.agentThreadId); - yield* emitEvent({ - kind: "notification", - threadId: options.threadId, - ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), - method: "collabAgent/closed", - payload: childIdentity, - }); - return true; - case "error": { - // A child error must surface as a failed agent, not vanish into - // the default swallow (review finding: the child stayed - // "running" forever). Retryable errors (willRetry) keep the - // child RUNNING and interruptible — mirroring the root error - // handler; settling it would orphan a still-live child from - // Stop (review finding). Terminal errors clean up the live turn - // like thread/closed and reuse the statusChanged systemError - // path. - const willRetry = (notification.params as { willRetry?: boolean }).willRetry === true; - if (willRetry) { - return true; - } - yield* Ref.update(collabChildLiveTurnsRef, (current) => { - const next = new Map(current); - next.delete(child.agentThreadId); - return next; - }); - yield* emitEvent({ - kind: "notification", - threadId: options.threadId, - ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}), - method: "collabAgent/statusChanged", - payload: { - ...childIdentity, - status: { type: "systemError" }, - }, - }); - return true; - } - default: - // Routing table decides (single source of truth, asserted - // against captured wire traces): enumerated chatter is dropped, - // everything else — including methods this build has never seen - // — falls through to the parent path rather than vanishing. - return routeCodexChildNotification(notification.method) === "drop"; - } - }); - - const handleRawNotification = (notification: CodexServerNotification) => - Effect.gen(function* () { - const isMemoryConsolidationNotification = - suppressMemoryConsolidationNotification(notification); - - const payload = notification.params; - const route = readRouteFields(notification); - const collabReceiverTurns = yield* Ref.get(collabReceiverTurnsRef); - const childParentTurnId = (() => { - const providerConversationId = readNotificationThreadId(notification); - return providerConversationId - ? collabReceiverTurns.get(providerConversationId) - : undefined; - })(); - - rememberCollabReceiverTurns(collabReceiverTurns, notification, route.turnId); - // Interception FIRST: a registered v2 child is usually also in the - // receiver-turn map (collabAgentToolCall.receiverThreadIds), and the - // legacy suppressor below would drop its lifecycle before it could - // become synthetic collabAgent events (review finding). The - // suppressor still covers UNREGISTERED children. - if (yield* interceptCollabChildNotification(notification)) { - yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); - return; - } - - // Suppression applies to receiver-map children (v1) AND to any - // conversation that is not the root thread. The live capture - // (codexMultiAgentWire.json) shows a child's thread/status/changed - // arriving BEFORE anything registers the child — pre-registration - // lifecycle must not reach the parent path, where the adapter maps - // thread/* onto parent session state. Root-id-known guard keeps the - // root's own early notifications flowing during session open. - const suppressRootId = currentProviderThreadId(yield* Ref.get(sessionRef)); - const foreignConversation = (() => { - const providerConversationId = readNotificationThreadId(notification); - return ( - providerConversationId !== undefined && - suppressRootId !== undefined && - providerConversationId !== suppressRootId - ); - })(); - if ( - (childParentTurnId !== undefined || foreignConversation) && - shouldSuppressChildConversationNotification(notification.method) - ) { - // Stop-everything must not depend on registration timing: a - // child's turn/started can arrive before the subAgentActivity that - // registers it (captured ordering), and suppressing it without - // remembering the live turn would leave that child running after - // Stop (review finding). Track live turns for ANY foreign - // conversation; interrupts are best-effort per child, so a - // false-positive entry costs one ignored RPC at worst. - const foreignThreadId = readNotificationThreadId(notification); - if (foreignThreadId !== undefined) { - if (notification.method === "turn/started") { - const foreignTurnId = - typeof (notification.params as { turn?: { id?: unknown } }).turn?.id === "string" - ? (notification.params as { turn: { id: string } }).turn.id - : undefined; - if (foreignTurnId) { - yield* Ref.update(collabChildLiveTurnsRef, (current) => { - const next = new Map(current); - next.set(foreignThreadId, foreignTurnId); - return next; - }); - } - } else if ( - notification.method === "turn/completed" || - notification.method === "thread/closed" - ) { - yield* Ref.update(collabChildLiveTurnsRef, (current) => { - const next = new Map(current); - next.delete(foreignThreadId); - return next; - }); - } - } - yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); - return; - } - - if (isMemoryConsolidationNotification) { - return; - } - - let requestId: ApprovalRequestId | undefined; - let requestKind: ProviderRequestKind | undefined; - let turnId = childParentTurnId ?? route.turnId; - let itemId = route.itemId; - - if (notification.method === "serverRequest/resolved") { - const rawRequestId = - typeof notification.params.requestId === "string" - ? notification.params.requestId - : String(notification.params.requestId); - const correlation = rawRequestId - ? (yield* Ref.get(approvalCorrelationsRef)).get(rawRequestId) - : undefined; - if (correlation) { - requestId = correlation.requestId; - requestKind = correlation.requestKind; - turnId = correlation.turnId ?? turnId; - itemId = correlation.itemId ?? itemId; - yield* Ref.update(approvalCorrelationsRef, (current) => { - const next = new Map(current); - next.delete(rawRequestId); - return next; - }); - } - } - - yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); - yield* emitEvent({ - kind: "notification", - threadId: options.threadId, - method: notification.method, - ...(turnId ? { turnId } : {}), - ...(itemId ? { itemId } : {}), - ...(requestId ? { requestId } : {}), - ...(requestKind ? { requestKind } : {}), - ...(notification.method === "item/agentMessage/delta" - ? { textDelta: notification.params.delta } - : {}), - ...(payload !== undefined ? { payload } : {}), - }); - }); - - const currentSessionProviderThreadId = Effect.map(Ref.get(sessionRef), currentProviderThreadId); - - yield* client.handleServerNotification("thread/started", (payload) => - currentSessionProviderThreadId.pipe( - Effect.flatMap((providerThreadId) => { - if (providerThreadId && payload.thread.id !== providerThreadId) { - return Effect.void; - } - return updateSession(sessionRef, { - resumeCursor: { threadId: payload.thread.id }, - }); - }), - ), - ); - - yield* client.handleServerNotification("turn/started", (payload) => - currentSessionProviderThreadId.pipe( - Effect.flatMap((providerThreadId) => { - if (providerThreadId && payload.threadId !== providerThreadId) { - return Effect.void; - } - return updateSession(sessionRef, { - status: "running", - activeTurnId: TurnId.make(payload.turn.id), - }); - }), - ), - ); - - yield* client.handleServerNotification("turn/completed", (payload) => - currentSessionProviderThreadId.pipe( - Effect.flatMap((providerThreadId) => { - if (providerThreadId && payload.threadId !== providerThreadId) { - return Effect.void; - } - const lastError = - payload.turn.status === "failed" && "error" in payload.turn && payload.turn.error - ? payload.turn.error.message - : undefined; - return updateSession(sessionRef, { - status: payload.turn.status === "failed" ? "error" : "ready", - activeTurnId: undefined, - ...(lastError ? { lastError } : {}), - }); - }), - ), - ); - - yield* client.handleServerNotification("error", (payload) => - currentSessionProviderThreadId.pipe( - Effect.flatMap((providerThreadId) => { - const payloadThreadId = payload.threadId; - if (providerThreadId && payloadThreadId && payloadThreadId !== providerThreadId) { - return Effect.void; - } - const errorMessage = payload.error.message; - const willRetry = payload.willRetry; - return updateSession(sessionRef, { - status: willRetry ? "running" : "error", - ...(errorMessage ? { lastError: errorMessage } : {}), - }); - }), - ), - ); - - yield* client.handleServerRequest("item/commandExecution/requestApproval", (payload) => - Effect.gen(function* () { - const requestId = ApprovalRequestId.make(yield* randomUUIDv4("command-approval-request")); - const turnId = TurnId.make(payload.turnId); - const itemId = ProviderItemId.make(payload.itemId); - const decision = yield* Deferred.make(); - - yield* Ref.update(pendingApprovalsRef, (current) => { - const next = new Map(current); - next.set(requestId, { - requestId, - jsonRpcId: payload.approvalId ?? payload.itemId, - requestKind: "command", - turnId, - itemId, - decision, - }); - return next; - }); - yield* Ref.update(approvalCorrelationsRef, (current) => { - const next = new Map(current); - next.set(payload.approvalId ?? payload.itemId, { - requestId, - requestKind: "command", - turnId, - itemId, - }); - return next; - }); - - yield* emitEvent({ - kind: "request", - threadId: options.threadId, - method: "item/commandExecution/requestApproval", - requestId, - requestKind: "command", - ...(turnId ? { turnId } : {}), - ...(itemId ? { itemId } : {}), - payload, - }); - - const resolved = yield* Deferred.await(decision).pipe( - Effect.ensuring( - Ref.update(pendingApprovalsRef, (current) => { - const next = new Map(current); - next.delete(requestId); - return next; - }), - ), - ); - return { - decision: resolved === "acceptAlways" ? "acceptForSession" : resolved, - } satisfies EffectCodexSchema.CommandExecutionRequestApprovalResponse; - }), - ); - - yield* client.handleServerRequest("item/fileChange/requestApproval", (payload) => - Effect.gen(function* () { - const requestId = ApprovalRequestId.make( - yield* randomUUIDv4("file-change-approval-request"), - ); - const turnId = TurnId.make(payload.turnId); - const itemId = ProviderItemId.make(payload.itemId); - const decision = yield* Deferred.make(); - - yield* Ref.update(pendingApprovalsRef, (current) => { - const next = new Map(current); - next.set(requestId, { - requestId, - jsonRpcId: payload.itemId, - requestKind: "file-change", - turnId, - itemId, - decision, - }); - return next; - }); - yield* Ref.update(approvalCorrelationsRef, (current) => { - const next = new Map(current); - next.set(payload.itemId, { - requestId, - requestKind: "file-change", - turnId, - itemId, - }); - return next; - }); - - yield* emitEvent({ - kind: "request", - threadId: options.threadId, - method: "item/fileChange/requestApproval", - requestId, - requestKind: "file-change", - ...(turnId ? { turnId } : {}), - ...(itemId ? { itemId } : {}), - payload, - }); - - const resolved = yield* Deferred.await(decision).pipe( - Effect.ensuring( - Ref.update(pendingApprovalsRef, (current) => { - const next = new Map(current); - next.delete(requestId); - return next; - }), - ), - ); - return { - decision: resolved === "acceptAlways" ? "acceptForSession" : resolved, - } satisfies EffectCodexSchema.FileChangeRequestApprovalResponse; - }), - ); - - yield* client.handleServerRequest("mcpServer/elicitation/request", (payload) => - Effect.gen(function* () { - if (toMcpElicitationResponse(payload, "accept").action !== "accept") { - yield* Effect.logWarning("Declined an unsupported MCP elicitation.", { - serverName: payload.serverName, - mode: payload.mode, - }); - return { - action: "decline", - } satisfies EffectCodexSchema.McpServerElicitationRequestResponse; - } - - const requestId = ApprovalRequestId.make(yield* randomUUIDv4("mcp-elicitation-request")); - const turnId = payload.turnId - ? TurnId.make(payload.turnId) - : (yield* Ref.get(sessionRef)).activeTurnId; - const jsonRpcId = payload.mode === "url" ? payload.elicitationId : requestId; - const decision = yield* Deferred.make(); - - yield* Ref.update(pendingApprovalsRef, (current) => { - const next = new Map(current); - next.set(requestId, { - requestId, - jsonRpcId, - requestKind: "mcp-elicitation", - turnId, - itemId: undefined, - decision, - }); - return next; - }); - yield* Ref.update(approvalCorrelationsRef, (current) => { - const next = new Map(current); - next.set(jsonRpcId, { - requestId, - requestKind: "mcp-elicitation", - turnId, - itemId: undefined, - }); - return next; - }); - - yield* emitEvent({ - kind: "request", - threadId: options.threadId, - method: "mcpServer/elicitation/request", - requestId, - requestKind: "mcp-elicitation", - ...(turnId ? { turnId } : {}), - payload, - }); - - const resolved = yield* Deferred.await(decision).pipe( - Effect.ensuring( - Ref.update(pendingApprovalsRef, (current) => { - const next = new Map(current); - next.delete(requestId); - return next; - }), - ), - ); - return toMcpElicitationResponse(payload, resolved); - }), - ); - - yield* client.handleServerRequest("item/permissions/requestApproval", (payload) => - Effect.gen(function* () { - const requestId = ApprovalRequestId.make( - yield* randomUUIDv4("app-permission-approval-request"), - ); - const turnId = TurnId.make(payload.turnId); - const itemId = ProviderItemId.make(payload.itemId); - const decision = yield* Deferred.make(); - - yield* Ref.update(pendingApprovalsRef, (current) => { - const next = new Map(current); - next.set(requestId, { - requestId, - jsonRpcId: payload.itemId, - requestKind: "permission", - turnId, - itemId, - decision, - }); - return next; - }); - yield* Ref.update(approvalCorrelationsRef, (current) => { - const next = new Map(current); - next.set(payload.itemId, { - requestId, - requestKind: "permission", - turnId, - itemId, - }); - return next; - }); - - yield* emitEvent({ - kind: "request", - threadId: options.threadId, - method: "item/permissions/requestApproval", - requestId, - requestKind: "permission", - ...(turnId ? { turnId } : {}), - ...(itemId ? { itemId } : {}), - payload, - }); - - const resolved = yield* Deferred.await(decision).pipe( - Effect.ensuring( - Ref.update(pendingApprovalsRef, (current) => { - const next = new Map(current); - next.delete(requestId); - return next; - }), - ), - ); - // Approving grants the requested profile; denying answers with an - // empty grant so the app-server treats the permission as withheld. - const grantedPermissions = - resolved === "accept" || resolved === "acceptForSession" ? payload.permissions : {}; - return { - permissions: grantedPermissions, - ...(resolved === "acceptForSession" ? { scope: "session" as const } : {}), - } satisfies EffectCodexSchema.PermissionsRequestApprovalResponse; - }), - ); - - yield* client.handleServerRequest("item/tool/requestUserInput", (payload) => - Effect.gen(function* () { - const requestId = ApprovalRequestId.make(yield* randomUUIDv4("user-input-request")); - const turnId = TurnId.make(payload.turnId); - const itemId = ProviderItemId.make(payload.itemId); - const answers = yield* Deferred.make(); - - yield* Ref.update(pendingUserInputsRef, (current) => { - const next = new Map(current); - next.set(requestId, { - requestId, - turnId, - itemId, - answers, - }); - return next; - }); - - yield* emitEvent({ - kind: "request", - threadId: options.threadId, - method: "item/tool/requestUserInput", - requestId, - ...(turnId ? { turnId } : {}), - ...(itemId ? { itemId } : {}), - payload, - }); - - const resolvedAnswers = yield* Deferred.await(answers).pipe( - Effect.ensuring( - Ref.update(pendingUserInputsRef, (current) => { - const next = new Map(current); - next.delete(requestId); - return next; - }), - ), - ); - - return { - answers: yield* toCodexUserInputAnswers(resolvedAnswers).pipe( - Effect.mapError((error) => - CodexErrors.CodexAppServerRequestError.invalidParams(error.message, { - questionId: error.questionId, - }), - ), - ), - } satisfies EffectCodexSchema.ToolRequestUserInputResponse; - }), - ); - - yield* client.handleUnknownServerRequest((method) => - Effect.fail(CodexErrors.CodexAppServerRequestError.methodNotFound(method)), - ); - - const registerServerNotification = (method: M) => - client.handleServerNotification(method, (params) => - Queue.offer(serverNotifications, makeCodexServerNotification(method, params)).pipe( - Effect.asVoid, - ), - ); - - yield* Effect.forEach( - Object.values( - CodexRpc.SERVER_NOTIFICATION_METHODS, - ) as ReadonlyArray, - registerServerNotification, - { concurrency: 1, discard: true }, - ); - - yield* Stream.fromQueue(serverNotifications).pipe( - Stream.runForEach(handleRawNotification), - Effect.forkIn(runtimeScope), - ); - - const stderrRemainderRef = yield* Ref.make(""); - yield* child.stderr.pipe( - Stream.decodeText(), - Stream.runForEach((chunk) => - Ref.modify(stderrRemainderRef, (current) => { - const combined = current + chunk; - const lines = combined.split("\n"); - const remainder = lines.pop() ?? ""; - return [lines.map((line) => line.replace(/\r$/, "")), remainder] as const; - }).pipe( - Effect.flatMap((lines) => - Effect.forEach( - lines, - (line) => { - const classified = classifyCodexStderrLine(line); - if (!classified) { - return Effect.void; - } - return emitEvent({ - kind: "notification", - threadId: options.threadId, - method: "process/stderr", - message: classified.message, - }); - }, - { discard: true }, - ), - ), - ), - ), - Effect.forkIn(runtimeScope), - ); - - yield* child.exitCode.pipe( - Effect.flatMap((exitCode) => - Ref.get(closedRef).pipe( - Effect.flatMap((closed) => { - if (closed) { - return Effect.void; - } - const nextStatus = exitCode === 0 ? "closed" : "error"; - return updateSession(sessionRef, { - status: nextStatus, - activeTurnId: undefined, - }).pipe( - Effect.andThen( - emitSessionEvent( - "session/exited", - exitCode === 0 - ? "Codex App Server exited." - : `Codex App Server exited with code ${exitCode}.`, - ), - ), - ); - }), - ), - ), - Effect.forkIn(runtimeScope), - ); - - const start = Effect.fn("CodexSessionRuntime.start")(function* () { - yield* emitSessionEvent("session/connecting", "Starting Codex App Server session."); - yield* client.request("initialize", buildCodexInitializeParams()); - yield* client.notify("initialized", undefined); - - const requestedModel = normalizeCodexModelSlug(options.model); - - const opened = yield* openCodexThread({ - client, - threadId: options.threadId, - runtimeMode: options.runtimeMode, - cwd: options.cwd, - requestedModel, - serviceTier: options.serviceTier, - resumeThreadId: readResumeCursorThreadId(options.resumeCursor), - }); - - const providerThreadId = opened.thread.id; - const session = { - ...(yield* Ref.get(sessionRef)), - status: "ready", - cwd: opened.cwd, - model: opened.model, - resumeCursor: { threadId: providerThreadId }, - updatedAt: yield* nowIso, - } satisfies ProviderSession; - yield* Ref.set(sessionRef, session); - yield* emitSessionEvent("session/ready", "Codex App Server session ready."); - return session; - }); - - const readProviderThreadId = Effect.gen(function* () { - const providerThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); - if (!providerThreadId) { - return yield* new CodexSessionRuntimeThreadIdMissingError({ - threadId: options.threadId, - }); - } - return providerThreadId; - }); - - const close = Effect.gen(function* () { - const alreadyClosed = yield* Ref.getAndSet(closedRef, true); - if (alreadyClosed) { - return; - } - yield* settlePendingApprovals("cancel"); - yield* settlePendingUserInputs({}); - yield* updateSession(sessionRef, { - status: "closed", - activeTurnId: undefined, - }); - yield* emitSessionEvent("session/closed", "Session stopped").pipe( - Effect.catch((cause) => - Effect.logError("Failed to emit Codex session closed event.", { cause }), - ), - ); - yield* Scope.close(runtimeScope, Exit.void); - yield* Queue.shutdown(serverNotifications); - yield* Queue.shutdown(events); - }); - - return { - start, - getSession: Ref.get(sessionRef), - compactThread: Effect.gen(function* () { - const providerThreadId = yield* readProviderThreadId; - yield* client.request("thread/compact/start", { threadId: providerThreadId }); - }), - sendTurn: (input) => - Effect.gen(function* () { - const providerThreadId = yield* readProviderThreadId; - if (hasConfiguredMcpServer(options.appServerArgs)) { - yield* client.request("config/mcpServer/reload", undefined).pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to refresh Codex MCP tool catalog before turn.", { - cause, - }), - ), - ); - } - const normalizedModel = normalizeCodexModelSlug( - input.model ?? (yield* Ref.get(sessionRef)).model, - ); - const params = yield* buildTurnStartParams({ - threadId: providerThreadId, - runtimeMode: options.runtimeMode, - ...(input.input ? { prompt: input.input } : {}), - ...(input.attachments ? { attachments: input.attachments } : {}), - ...(normalizedModel ? { model: normalizedModel } : {}), - ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), - ...(input.effort ? { effort: input.effort } : {}), - ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), - // Derived from the session's own credential rather than the - // setting, so the prompt describes the tools this turn actually - // has even if the setting changed after the session started. - browserToolsAvailable: configuredMcpToolAvailability( - options.appServerArgs, - options.mcpCapabilities, - ), - }); - const rawResponse = yield* client.raw.request("turn/start", params); - const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( - Effect.mapError((error) => - CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( - "decode-response-payload", - error, - { method: "turn/start" }, - ), - ), - ); - const turnId = TurnId.make(response.turn.id); - yield* updateSession(sessionRef, (session) => ({ - status: "running", - // Codex accepts follow-ups while the current turn is still - // running. The response contains the queued turn id, but - // turn/interrupt only accepts the id that is active now. - activeTurnId: session.activeTurnId ?? turnId, - ...(normalizedModel ? { model: normalizedModel } : {}), - })); - const resumedProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); - return { - threadId: options.threadId, - turnId, - ...(resumedProviderThreadId - ? { resumeCursor: { threadId: resumedProviderThreadId } } - : {}), - } satisfies ProviderTurnStartResult; - }), - interruptTurn: (turnId) => - Effect.gen(function* () { - const providerThreadId = yield* readProviderThreadId; - const session = yield* Ref.get(sessionRef); - // Settle parked approvals FIRST. The transport answers server - // requests inline on its stdin read loop, so a pending - // command/file/app-permission prompt blocks every incoming message, - // including the turn/interrupt response itself - cancelling after - // the RPC would deadlock Stop exactly when a card is open. Settling - // releases the handler, which answers the peer and unblocks the - // loop before the interrupts below are sent. - yield* settlePendingApprovals("cancel"); - // Pending user-input prompts block the same way; settle them too. - yield* settlePendingUserInputs({}); - // Stop-everything: children are full threads with their own turns; - // interrupting only the parent leaves the fleet running. Interrupt - // each live child turn first, best-effort per child, BOUNDED: the - // transport awaits an unbounded Deferred per request, so a wedged - // child would otherwise block the parent interrupt forever — - // exactly during the runaway fleet where Stop matters most - // (review finding). Per-child and overall deadlines guarantee the - // parent interrupt below always runs. - const liveChildTurns = yield* Ref.get(collabChildLiveTurnsRef); - yield* Effect.forEach( - Array.from(liveChildTurns.entries()), - ([childThreadId, childTurnId]) => - client - .request("turn/interrupt", { - threadId: childThreadId, - turnId: childTurnId, - }) - .pipe(Effect.timeoutOption("3 seconds"), Effect.ignore), - { concurrency: 8, discard: true }, - ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); - const effectiveTurnId = turnId ?? session.activeTurnId; - if (!effectiveTurnId) { - return; - } - yield* client.request("turn/interrupt", { - threadId: providerThreadId, - turnId: effectiveTurnId, - }); - }), - readThread: Effect.gen(function* () { - const providerThreadId = yield* readProviderThreadId; - return yield* readCodexThread(client, providerThreadId); - }), - rollbackThread: (numTurns) => - Effect.gen(function* () { - const providerThreadId = yield* readProviderThreadId; - const snapshot = yield* rollbackCodexThread(client, providerThreadId, numTurns); - yield* updateSession(sessionRef, { - status: "ready", - activeTurnId: undefined, - }); - return snapshot; - }), - uploadFeedback: (reason) => - Effect.gen(function* () { - const providerThreadId = yield* readProviderThreadId; - return yield* client.request("feedback/upload", { - classification: "bug", - includeLogs: true, - ...(reason ? { reason } : {}), - threadId: providerThreadId, - }); - }), - respondToRequest: (requestId, decision) => - Effect.gen(function* () { - const pending = (yield* Ref.get(pendingApprovalsRef)).get(requestId); - if (!pending) { - return yield* new CodexSessionRuntimePendingApprovalNotFoundError({ - requestId, - }); - } - yield* Ref.update(pendingApprovalsRef, (current) => { - const next = new Map(current); - next.delete(requestId); - return next; - }); - yield* Deferred.succeed(pending.decision, decision); - yield* emitEvent({ - kind: "notification", - threadId: options.threadId, - method: "item/requestApproval/decision", - requestId: pending.requestId, - requestKind: pending.requestKind, - ...(pending.turnId ? { turnId: pending.turnId } : {}), - ...(pending.itemId ? { itemId: pending.itemId } : {}), - payload: { - requestId: pending.requestId, - requestKind: pending.requestKind, - decision, - }, - }); - }), - respondToUserInput: (requestId, answers) => - Effect.gen(function* () { - const pending = (yield* Ref.get(pendingUserInputsRef)).get(requestId); - if (!pending) { - return yield* new CodexSessionRuntimePendingUserInputNotFoundError({ - requestId, - }); - } - const codexAnswers = yield* toCodexUserInputAnswers(answers); - yield* Ref.update(pendingUserInputsRef, (current) => { - const next = new Map(current); - next.delete(requestId); - return next; - }); - yield* Deferred.succeed(pending.answers, answers); - yield* emitEvent({ - kind: "notification", - threadId: options.threadId, - method: "item/tool/requestUserInput/answered", - requestId: pending.requestId, - ...(pending.turnId ? { turnId: pending.turnId } : {}), - ...(pending.itemId ? { itemId: pending.itemId } : {}), - payload: { - answers: codexAnswers, - }, - }); - }), - events: Stream.fromQueue(events), - close, - } satisfies CodexSessionRuntimeShape; - }); diff --git a/apps/server/src/provider/Layers/codexLaunchArgs.ts b/apps/server/src/provider/Layers/codexLaunchArgs.ts index f64ff263e0e3..33def5ce9a61 100644 --- a/apps/server/src/provider/Layers/codexLaunchArgs.ts +++ b/apps/server/src/provider/Layers/codexLaunchArgs.ts @@ -37,11 +37,3 @@ export const codexExecLaunchArgs = (launchArgs?: string) => { return execArgs; }; - -export const codexSessionAppServerArgs = ( - appServerArgs: ReadonlyArray | undefined, - launchArgs: string | undefined, -) => { - const launchAppServerArgs = codexAppServerArgs(launchArgs); - return appServerArgs ? [...launchAppServerArgs, ...appServerArgs] : launchAppServerArgs; -}; diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.cmd b/apps/server/src/provider/testFixtures/codexCollabMockPeer.cmd deleted file mode 100644 index 18220e2c7f69..000000000000 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.cmd +++ /dev/null @@ -1,8 +0,0 @@ -@echo off -rem Wrapper so CodexSessionRuntime can spawn the mock peer on Windows: the -rem runtime always passes "app-server" as the first argument; drop it and -rem run the .mjs peer with node. "shift /1" leaves %0 alone so %~dp0 still -rem names this file's directory. -shift /1 -node "%~dp0codexCollabMockPeer.mjs" %1 %2 %3 %4 %5 %6 %7 %8 %9 -exit /b %ERRORLEVEL% diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 440f1558a684..5fc43d18a5c4 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -1,6 +1,6 @@ -// Minimal codex app-server stand-in for runtime-level collab tests. -// Speaks just enough of the protocol for CodexSessionRuntime to start a -// session, using REAL captured responses (codexMultiAgentWire.json), then +// Minimal codex app-server stand-in, spawned as the Codex binary by the +// provider readiness probe tests. Answers the handshake and, for session +// requests, returns REAL captured responses (codexMultiAgentWire.json), then // replays a scripted multi-agent notification sequence read from the // T3_CODEX_COLLAB_SCRIPT env var (a JSON file path) when the first turn // starts. Runs as a plain Node process — stdlib only. diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.sh b/apps/server/src/provider/testFixtures/codexCollabMockPeer.sh index f6a680a49925..3f76d7eec160 100755 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.sh +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Wrapper so CodexSessionRuntime can spawn the mock peer: the runtime always +# Wrapper so the Codex provider probe can spawn the mock peer: it always # passes "app-server" as the first argument (real codex CLI subcommand); # discard it and exec node on the .mjs peer. shift diff --git a/docs/orchestration-v2/thread-lineage-and-context-transfer.md b/docs/orchestration-v2/thread-lineage-and-context-transfer.md index 278bf594c5eb..bf2a117fec20 100644 --- a/docs/orchestration-v2/thread-lineage-and-context-transfer.md +++ b/docs/orchestration-v2/thread-lineage-and-context-transfer.md @@ -220,7 +220,7 @@ Provider adapters own native details. The orchestrator owns the relationship, so For Codex, native `thread/fork` accepts an inclusive `lastTurnId` boundary. When the app source point is a completed provider turn with a native turn reference, the Codex adapter passes that native id so the provider creates the fork at the requested point directly. This is the only viable path on paginated Codex threads, which reject `thread/rollback`. If no native turn reference is available, the adapter falls back to forking the latest native state and rolling back the fork by the number of later terminal provider turns — and reports an explicit failure when the forked thread uses paginated history, since that fallback cannot be honored there. The orchestrator still passes a provider-neutral source point and source provider-turn history; it does not encode Codex boundary or rollback policy. -The same paginated-history constraint applies to direct checkpoint rollback: `thread/rollback` only works on legacy-history Codex threads. The V2 adapter probes `historyMode` before rolling back and fails explicitly on paginated threads rather than sending a request Codex will reject. The V1 session runtime implements the paginated equivalent via `thread/turns/list` + `thread/revert`; porting that path into V2 is still open. +The same paginated-history constraint applies to direct checkpoint rollback: `thread/rollback` only works on legacy-history Codex threads. The V2 adapter probes `historyMode` before rolling back and fails explicitly on paginated threads rather than sending a request Codex will reject. The paginated equivalent (page `thread/turns/list` to find the first removed turn, then `thread/revert` with `beforeTurnId`) is not implemented yet. ## Data Ownership diff --git a/vite.config.ts b/vite.config.ts index 25e4472d98ec..73c3185ca99a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -284,7 +284,6 @@ export default defineConfig({ "apps/server/src/orchestration/commandInvariants.test.ts": 5, "apps/server/src/orchestration/projector.test.ts": 20, "apps/server/src/provider/Layers/CodexAdapter.test.ts": 1, - "apps/server/src/provider/Layers/CodexSessionRuntime.test.ts": 5, "apps/server/src/provider/Layers/CursorAdapter.test.ts": 1, "apps/server/src/provider/Layers/CursorProvider.test.ts": 1, "apps/server/src/provider/Layers/ProviderService.test.ts": 2,