diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b21f61ab8..34a9a32a4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog ## [Unreleased] +### Fixed + +- Preserved user-provided `session_id` and `x-client-request-id` headers in OpenAI Responses requests instead of overriding them with automatic session-derived values +- Stopped sending `session_id` and `x-client-request-id` headers for OpenAI Responses requests when `cacheRetention` is set to `none` +- Fixed direct OpenAI Responses requests to send `session_id` and `x-client-request-id` from the same session-derived value as `prompt_cache_key`, improving prompt cache affinity for append-only sessions ## [14.1.1] - 2026-04-14 diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 373203af4..f62999d87 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -170,7 +170,8 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( const { requestAbortController, requestSignal } = abortTracker; try { - // Create OpenAI client + // Keep request headers and prompt-cache routing on the same session-derived value. + const cacheSessionId = getOpenAIResponsesCacheSessionId(options); const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; const { client, copilotPremiumRequests, baseUrl } = createClient( model, @@ -178,6 +179,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = ( apiKey, options?.headers, options?.initiatorOverride, + cacheSessionId, ); const providerSessionState = getOpenAIResponsesProviderSessionState(model, options?.providerSessionState); const { params } = buildParams(model, context, options, providerSessionState, baseUrl); @@ -261,6 +263,7 @@ function createClient( apiKey?: string, extraHeaders?: Record, initiatorOverride?: MessageAttribution, + sessionId?: string, ): { client: OpenAI; copilotPremiumRequests: number | undefined; @@ -294,6 +297,10 @@ function createClient( copilotPremiumRequests = copilot.premiumRequests; baseUrl = resolveGitHubCopilotBaseUrl(model.baseUrl, rawApiKey) ?? model.baseUrl; } + if (sessionId && model.provider === "openai" && (baseUrl ?? "").toLowerCase().includes("api.openai.com")) { + headers.session_id ??= sessionId; + headers["x-client-request-id"] ??= sessionId; + } return { client: new OpenAI({ apiKey, @@ -307,6 +314,12 @@ function createClient( }; } +function getOpenAIResponsesCacheSessionId( + options: Pick | undefined, +): string | undefined { + return resolveCacheRetention(options?.cacheRetention) === "none" ? undefined : options?.sessionId; +} + function buildParams( model: Model<"openai-responses">, context: Context, @@ -334,7 +347,7 @@ function buildParams( } const cacheRetention = resolveCacheRetention(options?.cacheRetention); - const promptCacheKey = cacheRetention === "none" ? undefined : options?.sessionId; + const promptCacheKey = getOpenAIResponsesCacheSessionId(options); const params: OpenAIResponsesSamplingParams = { model: model.id, input: messages, diff --git a/packages/ai/test/openai-responses-cache-affinity.test.ts b/packages/ai/test/openai-responses-cache-affinity.test.ts new file mode 100644 index 000000000..f7bccf6f9 --- /dev/null +++ b/packages/ai/test/openai-responses-cache-affinity.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { getBundledModel } from "../src/models"; +import { type OpenAIResponsesOptions, streamOpenAIResponses } from "../src/providers/openai-responses"; +import type { Model } from "../src/types"; + +const originalFetch = global.fetch; +const model = getBundledModel("openai", "gpt-5-mini") as Model<"openai-responses">; + +function createSseResponse(events: unknown[]): Response { + const payload = `${events.map(event => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\n`; + return new Response(payload, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function getHeader(headers: RequestInit["headers"], name: string): string | null { + return new Headers(headers).get(name); +} + +async function captureOpenAIResponseHeaders( + options: OpenAIResponsesOptions, +): Promise<{ sessionId: string | null; clientRequestId: string | null }> { + const captured = { sessionId: null as string | null, clientRequestId: null as string | null }; + const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + captured.sessionId = getHeader(init?.headers, "session_id"); + captured.clientRequestId = getHeader(init?.headers, "x-client-request-id"); + return createSseResponse([ + { + type: "response.output_item.added", + item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] }, + }, + { type: "response.content_part.added", part: { type: "output_text", text: "" } }, + { type: "response.output_text.delta", delta: "Hello" }, + { + type: "response.output_item.done", + item: { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "Hello" }], + }, + }, + { + type: "response.completed", + response: { + status: "completed", + usage: { + input_tokens: 5, + output_tokens: 3, + total_tokens: 8, + input_tokens_details: { cached_tokens: 0 }, + }, + }, + }, + ]); + }); + global.fetch = Object.assign(fetchMock, { preconnect: originalFetch.preconnect }) as typeof fetch; + + const stream = streamOpenAIResponses( + model, + { + systemPrompt: "sys", + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }, + { apiKey: "test-key", ...options }, + ); + + for await (const event of stream) { + if (event.type === "done" || event.type === "error") break; + } + + return captured; +} + +afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +describe("openai-responses cache affinity", () => { + it("sets session routing headers for official OpenAI Responses requests with a sessionId", async () => { + const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }); + + expect(captured).toEqual({ sessionId: "session-123", clientRequestId: "session-123" }); + }); + + it("lets explicit headers override the default OpenAI session routing headers", async () => { + const captured = await captureOpenAIResponseHeaders({ + sessionId: "session-123", + headers: { + session_id: "override-session", + "x-client-request-id": "override-request", + }, + }); + + expect(captured).toEqual({ sessionId: "override-session", clientRequestId: "override-request" }); + }); + + it("omits OpenAI session routing headers when cache retention is disabled", async () => { + const captured = await captureOpenAIResponseHeaders({ cacheRetention: "none", sessionId: "session-123" }); + + expect(captured).toEqual({ sessionId: null, clientRequestId: null }); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a8d595971..ebf6cb074 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed newly generated session IDs to use UUIDv7 for new, forked, and branched sessions while preserving resumed session IDs + ## [14.1.1] - 2026-04-14 ### Breaking Changes diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index aca5d5bdd..9703bfb88 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -286,6 +286,10 @@ export type ReadonlySessionManager = Pick< | "putBlob" >; +function createSessionId(): string { + return Bun.randomUUIDv7(); +} + /** Generate a unique short ID (8 hex chars, collision-checked) */ function generateId(byId: { has(id: string): boolean }): string { for (let i = 0; i < 100; i++) { @@ -1500,7 +1504,7 @@ export class SessionManager { this.#fileEntries = await loadEntriesFromFile(this.#sessionFile, this.storage); if (this.#fileEntries.length > 0) { const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined; - this.#sessionId = header?.id ?? Snowflake.next(); + this.#sessionId = header?.id ?? createSessionId(); this.#sessionName = header?.title; this.#titleSource = header?.titleSource; @@ -1549,7 +1553,7 @@ export class SessionManager { this.#persistErrorReported = false; // Create new session ID and header - this.#sessionId = Snowflake.next(); + this.#sessionId = createSessionId(); const timestamp = new Date().toISOString(); const fileTimestamp = timestamp.replace(/[:.]/g, "-"); this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`); @@ -1680,7 +1684,7 @@ export class SessionManager { this.#persistChain = Promise.resolve(); this.#persistError = undefined; this.#persistErrorReported = false; - this.#sessionId = Snowflake.next(); + this.#sessionId = createSessionId(); this.#sessionName = undefined; this.#titleSource = undefined; const timestamp = new Date().toISOString(); @@ -2554,7 +2558,7 @@ export class SessionManager { // Filter out LabelEntry from path - we'll recreate them from the resolved map const pathWithoutLabels = branchPath.filter(e => e.type !== "label"); - const newSessionId = Snowflake.next(); + const newSessionId = createSessionId(); const timestamp = new Date().toISOString(); const fileTimestamp = timestamp.replace(/[:.]/g, "-"); const newSessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`); diff --git a/packages/coding-agent/test/session-manager/session-id.test.ts b/packages/coding-agent/test/session-manager/session-id.test.ts new file mode 100644 index 000000000..92f1e9d13 --- /dev/null +++ b/packages/coding-agent/test/session-manager/session-id.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +function expectUuidV7SessionId(session: SessionManager): string { + const sessionId = session.getSessionId(); + expect(sessionId).toMatch(UUID_V7_RE); + const header = session.getHeader(); + if (!header) throw new Error("Expected session header"); + expect(header.id).toBe(sessionId); + return sessionId; +} + +describe("SessionManager session ids", () => { + it("generates UUIDv7 ids for new in-memory sessions", () => { + const session = SessionManager.inMemory(); + + expectUuidV7SessionId(session); + }); + + it("generates a fresh UUIDv7 when starting a new session", async () => { + const session = SessionManager.inMemory(); + const firstId = expectUuidV7SessionId(session); + + await session.newSession(); + + const secondId = expectUuidV7SessionId(session); + expect(secondId).not.toBe(firstId); + }); + + it("generates a UUIDv7 when branching a session", () => { + const session = SessionManager.inMemory(); + session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + const branchPointId = session.appendMessage({ role: "user", content: "follow up", timestamp: 2 }); + const firstId = expectUuidV7SessionId(session); + + session.createBranchedSession(branchPointId); + + const branchedId = expectUuidV7SessionId(session); + expect(branchedId).not.toBe(firstId); + }); + + it("generates a UUIDv7 when forking a persisted session", async () => { + using tempDir = TempDir.createSync("@pi-session-id-fork-"); + const session = SessionManager.create(tempDir.path(), tempDir.path()); + session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + await session.flush(); + const firstId = expectUuidV7SessionId(session); + + const forkResult = await session.fork(); + if (!forkResult) throw new Error("Expected fork result"); + + const forkedId = expectUuidV7SessionId(session); + expect(forkedId).not.toBe(firstId); + expect(session.getHeader()?.parentSession).toBe(firstId); + }); + + it("preserves existing session ids when reopening a saved session", async () => { + using tempDir = TempDir.createSync("@pi-session-id-open-"); + const sessionFile = path.join(tempDir.path(), "existing.jsonl"); + const existingId = "existing-session-id"; + await Bun.write( + sessionFile, + `${JSON.stringify({ type: "session", id: existingId, timestamp: new Date().toISOString(), cwd: tempDir.path() })}\n`, + ); + + const session = await SessionManager.open(sessionFile, tempDir.path()); + + expect(session.getSessionId()).toBe(existingId); + expect(session.getHeader()?.id).toBe(existingId); + }); +});