diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 31d394c3ec..721aff2a54 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -400,6 +400,41 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("maps raw codex/event/plan_delta to canonical turn.proposed.delta", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + lifecycleManager.emit("event", { + id: asEventId("evt-raw-plan-delta"), + kind: "notification", + provider: "codex", + createdAt: new Date().toISOString(), + method: "codex/event/plan_delta", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + payload: { + msg: { + turn_id: "turn-1", + delta: "- Step 1: read the file", + }, + }, + } satisfies ProviderEvent); + + const firstEvent = yield* Fiber.join(firstEventFiber); + + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + assert.equal(firstEvent.value.type, "turn.proposed.delta"); + if (firstEvent.value.type !== "turn.proposed.delta") { + return; + } + assert.equal(firstEvent.value.payload.delta, "- Step 1: read the file"); + }), + ); + it.effect("maps session/closed lifecycle events to canonical session.exited runtime events", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/codexEventMapping.test.ts b/apps/server/src/provider/Layers/codexEventMapping.test.ts new file mode 100644 index 0000000000..1334b1e2cc --- /dev/null +++ b/apps/server/src/provider/Layers/codexEventMapping.test.ts @@ -0,0 +1,116 @@ +/** + * Unit tests for codexEventMapping — calls mapToRuntimeEvents directly + * without the full adapter/stream infrastructure. + */ +import { describe, it, expect } from "bun:test"; +import { mapToRuntimeEvents } from "./codexEventMapping.ts"; +import { + EventId, + type ProviderEvent, + ProviderItemId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; + +const threadId = ThreadId.makeUnsafe("thread-1"); + +function makeEvent(overrides: Partial): ProviderEvent { + return { + id: EventId.makeUnsafe("evt-1"), + kind: "notification", + provider: "codex", + createdAt: new Date().toISOString(), + threadId, + ...overrides, + } as ProviderEvent; +} + +describe("codexEventMapping — codex/event/plan_delta", () => { + it("maps plan_delta with msg.delta to turn.proposed.delta", () => { + const events = mapToRuntimeEvents( + makeEvent({ + method: "codex/event/plan_delta", + turnId: TurnId.makeUnsafe("turn-1"), + payload: { + msg: { + turn_id: "turn-1", + delta: "- Step 1: read the file", + }, + }, + }), + threadId, + ); + + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe("turn.proposed.delta"); + if (events[0]!.type === "turn.proposed.delta") { + expect(events[0]!.payload.delta).toBe("- Step 1: read the file"); + } + }); + + it("maps plan_delta with msg.text fallback", () => { + const events = mapToRuntimeEvents( + makeEvent({ + method: "codex/event/plan_delta", + payload: { + msg: { + text: "plan text via text field", + }, + }, + }), + threadId, + ); + + expect(events).toHaveLength(1); + if (events[0]!.type === "turn.proposed.delta") { + expect(events[0]!.payload.delta).toBe("plan text via text field"); + } + }); + + it("maps plan_delta with msg.content.text fallback", () => { + const events = mapToRuntimeEvents( + makeEvent({ + method: "codex/event/plan_delta", + payload: { + msg: { + content: { text: "nested content text" }, + }, + }, + }), + threadId, + ); + + expect(events).toHaveLength(1); + if (events[0]!.type === "turn.proposed.delta") { + expect(events[0]!.payload.delta).toBe("nested content text"); + } + }); + + it("returns empty for plan_delta with no extractable delta", () => { + const events = mapToRuntimeEvents( + makeEvent({ + method: "codex/event/plan_delta", + payload: { + msg: {}, + }, + }), + threadId, + ); + + expect(events).toHaveLength(0); + }); + + it("returns empty for plan_delta with empty delta string", () => { + const events = mapToRuntimeEvents( + makeEvent({ + method: "codex/event/plan_delta", + payload: { + msg: { delta: "" }, + }, + }), + threadId, + ); + + expect(events).toHaveLength(0); + }); +}); diff --git a/apps/server/src/provider/Layers/codexEventMapping.ts b/apps/server/src/provider/Layers/codexEventMapping.ts index 213b33d4fd..fd2d5e1ad4 100644 --- a/apps/server/src/provider/Layers/codexEventMapping.ts +++ b/apps/server/src/provider/Layers/codexEventMapping.ts @@ -1362,6 +1362,22 @@ export function mapToRuntimeEvents( ]; } + if (event.method === "codex/event/plan_delta") { + const msg = codexEventMessage(payload); + const delta = + asString(msg?.delta) ?? asString(msg?.text) ?? asString(asObject(msg?.content)?.text); + if (!delta || delta.length === 0) { + return []; + } + return [ + { + ...codexEventBase(event, canonicalThreadId), + type: "turn.proposed.delta" as const, + payload: { delta }, + }, + ]; + } + // Log unmapped events to aid debugging — skip known-noisy non-critical ones. if (event.method && !QUIET_UNMAPPED_EVENTS.has(event.method)) { console.debug(`[codexEventMapping] unmapped event: ${event.method}`, event.payload ?? "");