diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index 52e1becfb..e72ec06aa 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -662,7 +662,7 @@ async function processStream( grammarToolInputProperties: ReadonlyMap, options?: OpenAICodexResponsesOptions, ): Promise { - await processResponsesStream(mapCodexEvents(parseSSE(response, options?.signal)), output, stream, model, { + await processResponsesStream(mapCodexEvents(parseSSE(response, options?.signal), output), output, stream, model, { serviceTier: options?.serviceTier, grammarToolInputProperties, resolveServiceTier: resolveCodexServiceTier, @@ -719,7 +719,10 @@ function extractCodexEventError(event: Record): { code?: string }; } -async function* mapCodexEvents(events: AsyncIterable>): AsyncGenerator { +async function* mapCodexEvents( + events: AsyncIterable>, + output: AssistantMessage, +): AsyncGenerator { for await (const event of events) { const type = typeof event.type === "string" ? event.type : undefined; if (!type) continue; @@ -740,7 +743,10 @@ async function* mapCodexEvents(events: AsyncIterable>): } if (type === "response.done" || type === "response.completed" || type === "response.incomplete") { - const response = (event as { response?: { status?: unknown } }).response; + const response = (event as { response?: { status?: unknown; end_turn?: unknown } }).response; + if (typeof response?.end_turn === "boolean") { + output.endTurn = response.end_turn; + } const normalizedResponse = response ? { ...response, status: normalizeCodexStatus(response.status) } : response; @@ -1504,7 +1510,7 @@ async function processWebSocketStream( socket.send(JSON.stringify({ type: "response.create", ...requestBody })); await processResponsesStream( startWebSocketOutputOnFirstEvent( - mapCodexEvents(parseWebSocket(socket, options?.signal, idleTimeoutMs)), + mapCodexEvents(parseWebSocket(socket, options?.signal, idleTimeoutMs), output), onStart, ), output, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 75e1d6f3c..5bf6c9fa6 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -424,6 +424,11 @@ export interface AssistantMessage { deferred?: DeferredHandle; errorMessage?: string; rawStopReason?: string; + /** + * Provider indication of whether the model explicitly ended its turn. + * Preserved for debugging and does not currently affect agent control flow. + */ + endTurn?: boolean; timestamp: number; // Unix timestamp in milliseconds } diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index a8423c887..ccd7f51c6 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -49,9 +49,11 @@ function decodeCodexRequestBody(body: RequestInit["body"] | undefined): Record { process.env.PI_CODING_AGENT_DIR = tempDir; const token = mockToken(); const encoder = new TextEncoder(); - const sse = buildSSEPayload({ status: "completed", includeDone: true }); + const sse = buildSSEPayload({ status: "completed", includeDone: true, endTurn: false }); const stream = new ReadableStream({ start(controller) { @@ -263,6 +266,7 @@ describe("openai-codex streaming", () => { expect(result.content.find((c) => c.type === "text")?.text).toBe("Hello"); expect(result.stopReason).toBe("stop"); + expect(result.endTurn).toBe(false); }); it("maps response.incomplete to stopReason length even when the SSE body stays open", async () => { @@ -1273,6 +1277,7 @@ describe("openai-codex streaming", () => { type: "response.completed", response: { status: "completed", + end_turn: false, usage: { input_tokens: 5, output_tokens: 3, @@ -1317,12 +1322,13 @@ describe("openai-codex streaming", () => { messages: [{ role: "user", content: "Say hello", timestamp: 1 }], }; - await streamSimpleOpenAICodexResponses(model, context, { + const result = await streamSimpleOpenAICodexResponses(model, context, { apiKey: token, sessionId: "session-auto", transport: "auto", }).result(); + expect(result.endTurn).toBe(false); expect(sentBodies).toHaveLength(1); expect(capturedWebSocketHeaders?.["session-id"]).toBe("session-auto"); expect(capturedWebSocketHeaders?.session_id).toBeUndefined(); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 99b5a948e..6180cf407 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -94,6 +94,7 @@ type _AiAssistantMessageFieldsAccountedFor = Assert< | "deferred" | "errorMessage" | "rawStopReason" + | "endTurn" | "timestamp" > >;