diff --git a/docs/opencodez.md b/docs/opencodez.md index 9b73befbc..c5ae359b8 100644 --- a/docs/opencodez.md +++ b/docs/opencodez.md @@ -431,51 +431,44 @@ same media estimate at both boundaries instead of being counted as base64 text before provider lowering. The active runner freezes the prepared effective System, transformed options, -model profile, and model-visible tool schemas used for sampling, then reuses that -snapshot for mid-turn compact while lowering the current history with all newly completed -tool work. Manual compaction and restart recovery reconstruct the same context -through the shared preparation boundary because no in-memory sampling snapshot -exists. If user steering arrives around compaction, it remains pending: it is -excluded from both compact input and the first mandatory post-compact -continuation, then admitted normally. This boundary remains tied to the original -active turn even if preflight underestimates the request and the provider itself -reports overflow; frozen request controls are then combined with history that -still excludes the pending steer. Before transport, OpenCodez estimates the -complete compact payload using a model-visible cost for inline images instead of -counting their base64 encoding as text. Text accounting uses UTF-8 bytes and -truncates only at Unicode code-point boundaries. Automatically resized images -use Codex's 1,844-token estimate; `detail: "original"` uses the provider's -conservative 10,000-patch maximum. Only when the payload would exceed the -Codex-safe request window does OpenCodez replace older tool outputs across the -request with a bounded marker. Compaction uses the effective profile returned by -the shared request preparation, including a configured working window. A -persisted active-turn profile also survives reconstruction after restart; manual -compaction without an active turn resolves the selected model with current -configuration. The payload builder never independently re-resolves the catalog, -so pre-turn, manual, and mid-turn compaction cannot silently revert to the -default working window. The authenticated Codex model catalog supplies the -current context and automatic compaction limit. Without a context override, its -fallback profiles use a `272000` context and a `244800` trigger. Only -verbatim tool outputs receive a second conservative estimate because Codex caps -them on insertion while OpenCode preserves them durably. Ordinary text is not -globally doubled and can use the full safe window. Every tool result in the -active parallel-output batch keeps its image input even if its text must be -bounded, so the latest visual state cannot be lost merely because a short sibling -result was serialized later. If bounding all eligible tool outputs still cannot -fit the safe request window, OpenCodez fails before network I/O. The session error -explicitly says that nothing was sent to OpenAI and identifies the model, -estimated input size, and safe request budget. This is a local preflight failure, -not a provider rejection. User messages, tool calls, and pending input are never -summarized or dropped locally, -and the durable local history remains unchanged. Each compaction stream retries -transient failure at most twice per transport inside one 20-minute cancellable -operation. A valid server `Retry-After` value replaces the local exponential -delay for that retry, and Stop does not leave a request running in the background. - -The `prepared remote compaction` log records the selected model, phase, -estimated tokens, request budget, and number of shortened tool outputs once per -prepared request. It contains no conversation content and makes local size -checks distinguishable from transport or provider failures. +model profile, and model-visible tool schemas used for sampling. Mid-turn compact +reuses those controls with the current history, including newly completed tool +work. Manual compaction and restart recovery reconstruct the same context through +the shared preparation boundary. The compact request carries the Codex capability +and turn profile through the authenticated adapter, so it uses the same Responses +Lite shaping, WebSocket continuation, and HTTP fallback as sampling. System +instructions appear once, assistant phases are preserved, and tool images remain +native image inputs. An already consumed sampling handoff is never replayed. + +If user steering arrives around compaction, it remains pending until after the +first mandatory post-compact continuation. A successful compact replaces the +model-visible window with bounded retained user messages and the new opaque +state. Reconnect and restart rebuild that window plus its new tail, without +restoring the original archive. + +Local byte-based estimates are diagnostic, not a reason to reject a compact +request or discard tool results before sending it. In particular, the serialized +length of encrypted reasoning does not measure its model token cost. OpenCodez +first sends the complete active compact input. If OpenAI reports context overflow, +it bounds eligible tool outputs and retries once. That reduction uses a 90% input +window target, separate from an earlier automatic-compaction threshold. It never +locally rejects the retry merely because the estimate still exceeds that target. +A second overflow remains a provider error. Every tool result in the active +parallel-output batch keeps its images during reduction; user messages, tool +calls, and opaque state are preserved. The saved compacted working window is +replaced only after a completed compaction response. + +The effective profile comes from shared request preparation, including configured +working windows and persisted active-turn settings. The payload builder does not +independently resolve the catalog. Without a context override, fallback profiles +use a `272000` context and a `244800` trigger. Transient failures still use at most +two retries per transport within one 20-minute cancellable operation. A valid +server `Retry-After` replaces the local delay, and Stop cancels the operation. + +The `prepared remote compaction` log records the model, phase, diagnostic input +estimate, and reduction target. A separate retry log identifies confirmed provider +overflow and the number of bounded outputs. Neither log contains conversation +content. Remote output follows pinned Codex V2 semantics: additional stream output items are ignored, exactly one completed opaque compaction item is installed as-is, @@ -560,11 +553,11 @@ to `200000` lowers it further to `200000`. The catalog's automatic-compaction limit is already a trigger, not another context window: it caps the result without being multiplied by the threshold -again. The request budget is separate: remote compaction can use 90% of the -effective input window regardless of an earlier configured trigger. Lowering -`token_limit` therefore starts compaction sooner without forcing the existing -history into that smaller request budget. Neither calculation raises a model's -advertised input limit. +again. The tool-output reduction target is separate: after a provider-reported +overflow, it uses 90% of the effective input window regardless of an earlier +configured trigger. Lowering `token_limit` starts compaction sooner without +shrinking the window available to its request. The provider decides whether the +request fits; a local byte estimate does not impose another input ceiling. The trigger compares the last provider usage plus estimates for context not yet accounted for, including newly completed tool results and retained reasoning @@ -616,9 +609,10 @@ OpenCodez config boundary: has a shorter turn-scoped lifetime, while the transport connection has a longer independent lifetime. - `compact.ts` builds the canonical V2 trigger request with the existing - Responses lowering and prepared effective model profile, bounds only oversized - tool output, requires exactly one completed `compaction`/`compaction_summary` - item with encrypted content, and installs bounded retained user context. + Responses lowering and prepared effective model profile, bounds tool output + only after provider overflow, requires exactly one completed + `compaction`/`compaction_summary` item with encrypted content, and installs + bounded retained user context. - `compaction.ts` finds persisted compaction items without writing a second state store, keeps the bounded turn-settings journal and frozen catalog profile, and checks model/backend-snapshot compatibility. A random one-shot diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 4936d31c9..36424d3ac 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -12,7 +12,6 @@ import { type LLMRequest, type ProviderMetadata, type ReasoningPart, - type TextPart, type ToolCallPart, type ToolDefinition, type ToolContent, @@ -78,7 +77,11 @@ const OpenAIResponsesFunctionCallOutput = Schema.Union([ const OpenAIResponsesInputItem = Schema.Union([ Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }), - Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }), + Schema.Struct({ + role: Schema.tag("assistant"), + content: Schema.Array(OpenAIResponsesOutputText), + phase: Schema.optionalKey(Schema.Literals(["commentary", "final_answer"])), + }), OpenAIResponsesReasoningItem, OpenAIResponsesItemReference, Schema.Struct({ @@ -99,7 +102,7 @@ type OpenAIResponsesInputItem = Schema.Schema.Type encrypted_content?: string | null } @@ -282,17 +285,18 @@ const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({ const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | undefined => { const openai = part.providerMetadata?.openai - if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string" || openai.itemId.length === 0) - return undefined + if (!ProviderShared.isRecord(openai)) return undefined + const id = typeof openai.itemId === "string" && openai.itemId.length > 0 ? openai.itemId : undefined const encryptedContent = typeof openai.reasoningEncryptedContent === "string" ? openai.reasoningEncryptedContent : openai.reasoningEncryptedContent === null ? null : undefined + if (!id && typeof encryptedContent !== "string") return undefined return { type: "reasoning", - id: openai.itemId, + ...(id ? { id } : {}), summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [], encrypted_content: encryptedContent, } @@ -368,24 +372,30 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ } if (message.role === "assistant") { - const content: TextPart[] = [] const reasoningItems: Record = {} const reasoningReferences = new Set() const hostedToolReferences = new Set() - const flushText = () => { - if (content.length === 0) return - input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) }) - content.splice(0, content.length) - } for (const part of message.content) { if (part.type === "text") { - content.push(part) + const openai = part.providerMetadata?.openai + input.push({ + role: "assistant", + content: [{ type: "output_text", text: part.text }], + ...(ProviderShared.isRecord(openai) && (openai.phase === "commentary" || openai.phase === "final_answer") + ? { phase: openai.phase } + : {}), + }) continue } if (part.type === "reasoning") { - flushText() const reasoning = lowerReasoning(part) if (!reasoning) continue + // Stateless SDK preparation strips item IDs. Each encrypted part is + // then a self-contained replay item, including split summaries. + if (!reasoning.id) { + input.push(reasoning) + continue + } if (store !== false) { if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id }) reasoningReferences.add(reasoning.id) @@ -408,13 +418,11 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ continue } if (part.type === "tool-call") { - flushText() if (part.providerExecuted === true) continue input.push(lowerToolCall(part)) continue } if (part.type === "tool-result" && part.providerExecuted === true) { - flushText() const itemID = hostedToolItemID(part) if (store !== false && itemID && !hostedToolReferences.has(itemID)) input.push({ type: "item_reference", id: itemID }) @@ -428,7 +436,6 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ "tool-result", ]) } - flushText() continue } diff --git a/packages/opencode/src/opencodez/codex-responses/compact.ts b/packages/opencode/src/opencodez/codex-responses/compact.ts index 93e482685..4c428a63e 100644 --- a/packages/opencode/src/opencodez/codex-responses/compact.ts +++ b/packages/opencode/src/opencodez/codex-responses/compact.ts @@ -3,7 +3,8 @@ import type { Provider } from "@/provider/provider" import { LLMNative } from "@/session/llm/native-request" import { OpenAIResponses } from "@opencode-ai/llm/protocols/openai-responses" import { ProviderTransform } from "@/provider/transform" -import type { ModelMessage, Tool } from "ai" +import type { LLMRequestPrep } from "@/session/llm/request" +import { isContextOverflow } from "@opencode-ai/llm" import { OpenCodezSettings } from "@opencode-ai/core/opencodez/settings" import { CodexResponsesTransport } from "./transport" import { CodexResponsesAttempt } from "./attempt" @@ -30,21 +31,21 @@ const IMAGE_INPUT_TOKENS = 1_844 const ORIGINAL_IMAGE_MAX_TOKENS = 10_000 const RETAINED_MESSAGE_TOKEN_BUDGET = 64_000 -function toCompactInput(input: { - model: Provider.Model - system: string[] - messages: ModelMessage[] - tools: Record - options: Record -}) { +function toCompactInput(input: { model: Provider.Model; prepared: LLMRequestPrep.Prepared }) { const request = LLMNative.request({ model: input.model, - system: input.system, - messages: input.messages, - tools: input.tools, + // OAuth sampling already carries this System in options.instructions. + system: typeof input.prepared.params.options.instructions === "string" ? [] : input.prepared.system, + messages: ProviderTransform.message(input.prepared.messages, input.model, input.prepared.messageTransformOptions), + tools: input.prepared.tools, + toolChoice: input.prepared.toolChoice ?? "auto", + temperature: input.prepared.params.temperature, + topP: input.prepared.params.topP, + topK: input.prepared.params.topK, + maxOutputTokens: input.prepared.params.maxOutputTokens, // Compact input must remain self-contained under ZDR. A streamed V2 // compaction cannot rehydrate non-persisted reasoning items by rs_* id. - providerOptions: ProviderTransform.providerOptions(input.model, { ...input.options, store: false }), + providerOptions: ProviderTransform.providerOptions(input.model, { ...input.prepared.params.options, store: false }), headers: {}, }) return OpenAIResponses.protocol.body.from(request) @@ -53,10 +54,7 @@ function toCompactInput(input: { export function compact(input: { model: Provider.Model provider: Provider.Info - system: string[] - messages: ModelMessage[] - tools: Record - options: Record + prepared: LLMRequestPrep.Prepared items: unknown[] sessionID: string accountKey: string @@ -77,23 +75,16 @@ export function compact(input: { const request = compactBody(body, [...input.items.filter((item) => !isSystemMessage(item)), ...body.input]) // Preparation owns the effective profile, including context overrides and // frozen turn settings. Re-resolving here would restore the default window. - const bounded = trimOutputs( - request, - OpenCodezSettings.responsesCompactionPayloadLimit(input.model.limit, input.turnProfile?.contextWindow), - input.preserveActiveToolMedia, + const target = OpenCodezSettings.responsesCompactionPayloadLimit( + input.model.limit, + input.turnProfile?.contextWindow, ) yield* Effect.logInfo("prepared remote compaction", { model: input.model.api.id, phase: input.compaction.phase, - trimmed_outputs: bounded.rewritten, - estimated_tokens: bounded.estimate, - limit: bounded.limit, + estimated_tokens: estimateInput(request), + target, }) - if (bounded.estimate > bounded.limit) { - throw new Error( - `Compaction was not sent to OpenAI: the estimated input (${bounded.estimate} tokens) exceeds the safe request budget (${bounded.limit} tokens) for ${input.model.api.id}, even after shortening tool results. Local session history is unchanged.`, - ) - } const baseURL = typeof input.provider.options.baseURL === "string" @@ -101,11 +92,18 @@ export function compact(input: { : "https://api.openai.com/v1" const fetcher: typeof fetch = typeof input.provider.options.fetch === "function" ? input.provider.options.fetch : fetch - const payload = yield* Effect.tryPromise({ + const send = Effect.tryPromise({ try: (signal) => fetcher(`${baseURL}/responses`, { method: "POST", headers: { + // A sampling snapshot may contain an already consumed one-shot + // handoff. Compact installs active.items directly into its input. + ...Object.fromEntries( + Object.entries(input.prepared.headers).filter( + ([key]) => key.toLowerCase() !== CodexResponsesCompaction.HEADER, + ), + ), "content-type": "application/json", "x-session-affinity": input.sessionID, ...(input.turnID ? { [CodexResponsesTransport.TURN_ID_HEADER]: input.turnID } : {}), @@ -130,6 +128,8 @@ export function compact(input: { `OpenAI remote compaction failed (${response.status})${detail ? `: ${detail}` : ""}`, response.status === 429 || response.status >= 500, retryDelay(response), + undefined, + contextOverflow(detail), ) } return parseCompactionResponse(response, request.input) @@ -155,12 +155,34 @@ export function compact(input: { ), ), ), - while: (error) => error.retryable, + while: (error) => error.retryable && !error.contextOverflow, }), ) + // Encrypted state is not ordinary prompt text. A coarse local estimate + // cannot veto a request or justify discarding tool work before OpenAI sees it. + // Retry a confirmed overflow once with bounded tool outputs, on a fresh full + // request selected by the normal continuation compatibility check. + const payload = yield* send.pipe( + Effect.map((value) => ({ ...value, trimmedOutputs: 0 })), + Effect.catchIf( + (error) => error.contextOverflow, + (error) => + Effect.gen(function* () { + const bounded = trimOutputs(request, target, input.preserveActiveToolMedia) + if (bounded.rewritten === 0) return yield* Effect.fail(error) + yield* Effect.logInfo("retrying remote compaction after provider context overflow", { + model: input.model.api.id, + trimmed_outputs: bounded.rewritten, + estimated_tokens: bounded.estimate, + target, + }) + return { ...(yield* send), trimmedOutputs: bounded.rewritten } + }), + ), + ) return { items: payload.items, - trimmedOutputs: bounded.rewritten, + trimmedOutputs: payload.trimmedOutputs, usage: payload.usage, } satisfies Result }).pipe(Effect.catchCause((cause) => Effect.fail(Cause.squash(cause)))) @@ -196,7 +218,7 @@ function trimOutputs( const indexes = body.input.flatMap((item, index) => (isToolOutput(item) ? [index] : [])) const latest = indexes.at(-1) const activeTurn = preserveActiveToolMedia - ? body.input.findLastIndex((item) => isRecord(item) && item.type === "message" && item.role === "user") + ? body.input.findLastIndex((item) => isRecord(item) && item.role === "user") : -1 const candidates = indexes .flatMap((index) => { @@ -218,7 +240,8 @@ function trimOutputs( return b.saved - a.saved || a.index - b.index }) for (const candidate of candidates) { - if (estimate <= contextWindow) break + // The provider already confirmed overflow, even if this heuristic is low. + if (rewritten > 0 && estimate <= contextWindow) break body.input[candidate.index] = candidate.replacement rewritten++ estimate = Math.max(0, estimate - candidate.saved) @@ -296,7 +319,7 @@ function number(value: unknown) { } function isSystemMessage(value: unknown) { - return isRecord(value) && value.type === "message" && value.role === "system" + return isRecord(value) && value.role === "system" } async function parseCompactionResponse(response: Response, input: unknown[]) { @@ -325,6 +348,9 @@ async function parseCompactionResponse(response: Response, input: unknown[]) { throw new RemoteCompactionError( `OpenAI remote compaction failed: ${detail}`, CodexResponsesAttempt.retryableEvent(event), + undefined, + undefined, + contextOverflow(error), ) } if (event.type === "response.output_item.done" && CodexResponsesCompaction.isOpaqueItem(event.item)) @@ -403,15 +429,16 @@ function retainUserMessages(input: unknown[]): Record[] { let remaining = RETAINED_MESSAGE_TOKEN_BUDGET const retained: Record[] = [] for (const item of input.toReversed()) { - if (!isRecord(item) || item.type !== "message" || item.role !== "user") continue + if (!isRecord(item) || item.role !== "user") continue if (remaining <= 0) break - const estimate = Math.max(1, estimateInput(item)) + const message = { ...item, type: "message" } + const estimate = Math.max(1, estimateInput(message)) if (estimate <= remaining) { - retained.push(item) + retained.push(message) remaining -= estimate continue } - const truncated = truncateMessage(item, remaining) + const truncated = truncateMessage(message, remaining) if (truncated) retained.push(truncated) remaining = 0 } @@ -474,11 +501,17 @@ class RemoteCompactionError extends Error { readonly retryable: boolean, readonly retryDelayMs?: number, options?: ErrorOptions, + readonly contextOverflow = false, ) { super(message, options) } } +function contextOverflow(value: unknown) { + const text = typeof value === "string" ? value : (JSON.stringify(value) ?? "") + return isContextOverflow(text) || text.includes('"context_window_exceeded"') +} + function retryDelay(response: Response) { const value = response.headers.get("retry-after") if (!value) return undefined diff --git a/packages/opencode/src/opencodez/codex-responses/session-compaction.ts b/packages/opencode/src/opencodez/codex-responses/session-compaction.ts index 44cdc869a..3fb674fd7 100644 --- a/packages/opencode/src/opencodez/codex-responses/session-compaction.ts +++ b/packages/opencode/src/opencodez/codex-responses/session-compaction.ts @@ -529,10 +529,7 @@ export function make(deps: Dependencies) { return yield* CodexResponsesCompact.compact({ model: attemptModel, provider: providerInfo, - system: prepared.system, - messages: prepared.messages, - tools: prepared.tools, - options: prepared.params.options, + prepared, items: active.items, sessionID: input.sessionID, accountKey, diff --git a/packages/opencode/src/session/llm/native-request.ts b/packages/opencode/src/session/llm/native-request.ts index b7f30e24c..ff6ae7f66 100644 --- a/packages/opencode/src/session/llm/native-request.ts +++ b/packages/opencode/src/session/llm/native-request.ts @@ -1,4 +1,4 @@ -import type { JsonSchema, LLMRequest, ProviderMetadata } from "@opencode-ai/llm" +import type { JsonSchema, LLMRequest, ProviderMetadata, ToolContent } from "@opencode-ai/llm" import { LLM, Message, SystemPart, ToolCallPart, ToolDefinition, ToolResultPart } from "@opencode-ai/llm" import { AmazonBedrock, @@ -66,17 +66,42 @@ const mediaPart = (part: Record) => { const toolResult = (part: Record) => { const output = isRecord(part.output) ? part.output : { type: "json", value: part.output } - const type = output.type === "text" ? "text" : output.type === "error-text" ? "error" : "json" + const type = + output.type === "content" + ? "content" + : output.type === "text" + ? "text" + : output.type === "error-text" + ? "error" + : "json" return ToolResultPart.make({ id: typeof part.toolCallId === "string" ? part.toolCallId : "", name: typeof part.toolName === "string" ? part.toolName : "", - result: "value" in output ? output.value : output, + result: + type === "content" && Array.isArray(output.value) + ? output.value.map(toolContent) + : "value" in output + ? output.value + : output, resultType: type, providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined, providerMetadata: partProviderMetadata(part), }) } +function toolContent(part: unknown): ToolContent { + if (!isRecord(part)) throw new Error("Native LLM tool content must be an object") + if (part.type === "text" && typeof part.text === "string") return { type: "text", text: part.text } + if (part.type === "media" && typeof part.mediaType === "string" && typeof part.data === "string") { + return { + type: "file", + mime: part.mediaType, + uri: part.data.startsWith("data:") ? part.data : `data:${part.mediaType};base64,${part.data}`, + } + } + throw new Error(`Native LLM request adapter does not support ${String(part.type)} tool content`) +} + const contentPart = (part: unknown) => { if (!isRecord(part)) throw new Error("Native LLM request adapter only supports object content parts") if (part.type === "text") return textPart(part) diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 1acc37b4d..f8bfc1538 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -38,6 +38,7 @@ type PrepareInput = { readonly permission?: PermissionV1.Ruleset readonly system: string[] readonly messages: ModelMessage[] + readonly toolChoice?: "auto" | "required" | "none" readonly small?: boolean readonly tools: Record readonly provider: Provider.Info @@ -60,6 +61,7 @@ export type Prepared = { readonly system: string[] readonly messages: ModelMessage[] readonly tools: Record + readonly toolChoice?: "auto" | "required" | "none" readonly params: { readonly temperature?: number readonly topP?: number @@ -310,6 +312,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre : {}), system, messages, + toolChoice: input.toolChoice, tools: Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b))), params, messageTransformOptions: options, diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index 45593ab67..d013d6e51 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -259,6 +259,8 @@ describe("plugin.codex", () => { codexApiEndpoint: server.url, experimentalWebSockets: true, }) + const config = { model: "openai/gpt-5.4", opencodez: { responses: { wire: "legacy" } } } + await hooks.config?.(config) const loaded = await hooks.auth!.loader!( async () => ({ diff --git a/packages/opencode/test/session/remote-compaction.test.ts b/packages/opencode/test/session/remote-compaction.test.ts new file mode 100644 index 000000000..124a7000a --- /dev/null +++ b/packages/opencode/test/session/remote-compaction.test.ts @@ -0,0 +1,461 @@ +import { expect, test } from "bun:test" +import { createOpenAI } from "@ai-sdk/openai" +import { jsonSchema, streamText, tool, type ModelMessage } from "ai" +import { Effect } from "effect" +import type { Auth } from "@/auth" +import type { Provider } from "@/provider/provider" +import { ProviderTransform } from "@/provider/transform" +import type { LLMRequestPrep } from "@/session/llm/request" +import { CodexAuthPlugin } from "@/plugin/openai/codex" +import { CodexResponsesCatalog } from "@/opencodez/codex-responses/catalog" +import { CodexResponsesCapability } from "@/opencodez/codex-responses/capability" +import { CodexResponsesCompact } from "@/opencodez/codex-responses/compact" +import { CodexResponsesCompaction } from "@/opencodez/codex-responses/compaction" +import { CodexResponsesProtocol } from "@/opencodez/codex-responses/protocol" +import { CodexResponsesTransport } from "@/opencodez/codex-responses/transport" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { isRecord } from "@/util/record" + +const model: Provider.Model = { + id: ModelV2.ID.make("gpt-6.1-sol"), + providerID: ProviderV2.ID.make("openai"), + api: { id: "gpt-6.1-sol", npm: "@ai-sdk/openai", url: "https://api.openai.com/v1" }, + name: "Sol", + family: "gpt-sol", + capabilities: { + temperature: false, + reasoning: true, + attachment: true, + toolcall: true, + interleaved: false, + input: { text: true, image: true, audio: false, video: false, pdf: false }, + output: { text: true, image: false, audio: false, video: false, pdf: false }, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 1050000, input: 872000, output: 128000 }, + options: {}, + headers: {}, + status: "active", + release_date: "2026-09-29", +} +const profile: CodexResponsesCatalog.Profile = { + modelID: model.api.id, + contextWindow: 872000, + maxContextWindow: 872000, + autoCompactTokenLimit: 784800, + compHash: "3000", + responsesLite: true, +} +const user: ModelMessage = { role: "user", content: "Keep the current task" } +const assistant: ModelMessage = { + role: "assistant", + content: [{ type: "text", text: "Work completed", providerOptions: { openai: { phase: "commentary" } } }], +} +const output = { + type: "message", + role: "assistant", + phase: "commentary", + content: [{ type: "output_text", text: "Work completed" }], +} +const opaque = { type: "compaction", encrypted_content: "compacted-state" } +type Capture = { via: "http" | "ws"; body: Record; input: unknown[] } + +function prepared(messages: ModelMessage[]): LLMRequestPrep.Prepared { + return { + codexResponses: true, + codexResponsesProfile: profile, + system: ["Keep these System instructions exactly once"], + messages, + tools: { + inspect: tool({ + description: "Inspect state", + inputSchema: jsonSchema({ type: "object", properties: {} }), + strict: false, + }), + }, + toolChoice: "auto", + params: { + maxOutputTokens: 16000, + options: { + store: false, + instructions: "Keep these System instructions exactly once", + promptCacheKey: "compact-test", + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + serviceTier: "priority", + }, + }, + messageTransformOptions: { store: false }, + headers: { + [CodexResponsesCapability.HEADER]: "true", + [CodexResponsesTransport.TURN_PROFILE_HEADER]: CodexResponsesCatalog.encodeProfile(profile), + // A captured sampling handoff has already been consumed at this point. + [CodexResponsesCompaction.HEADER]: "already-consumed", + }, + } +} + +async function sdkBody(input: LLMRequestPrep.Prepared) { + let body: Record | undefined + const sdk = createOpenAI({ + apiKey: "test-key", + fetch: Object.assign( + async (_url: RequestInfo | URL, init?: RequestInit) => { + const value: unknown = JSON.parse(String(init?.body)) + if (!isRecord(value)) throw new Error("Expected SDK request body") + body = value + throw new Error("Request captured without sending") + }, + { preconnect: fetch.preconnect }, + ), + }) + await streamText({ + model: sdk.responses(model.api.id), + messages: ProviderTransform.message(input.messages, model, input.messageTransformOptions), + tools: input.tools, + toolChoice: input.toolChoice, + ...input.params, + providerOptions: ProviderTransform.providerOptions(model, input.params.options), + maxRetries: 0, + onError() {}, + }).consumeStream() + if (!body) throw new Error("SDK did not prepare a request") + return body +} + +async function fixture(options: { overflows?: number; streamOverflow?: boolean; httpOnly?: boolean } = {}) { + const captures: Capture[] = [] + const id = crypto.randomUUID() + let count = 0 + const events = (capture: Capture) => { + const compact = capture.input.some((item) => isRecord(item) && item.type === "compaction_trigger") + const failed = compact && ++count <= (options.overflows ?? 0) + if (failed) + return [ + { + type: "response.failed", + response: { error: { code: "context_length_exceeded", message: "Input exceeds context window" } }, + }, + ] + const item = compact ? opaque : output + return [ + { type: "response.output_item.done", item }, + { + type: "response.completed", + response: { + id: `resp_${crypto.randomUUID()}`, + output: [item], + usage: { input_tokens: 200, output_tokens: 10, total_tokens: 210 }, + }, + }, + ] + } + const capture = (value: unknown, via: Capture["via"]): Capture => { + if (!isRecord(value) || !Array.isArray(value.input)) throw new Error("Invalid Responses request") + const result = { via, body: value, input: value.input } + captures.push(result) + return result + } + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request, server) { + if (request.headers.get("upgrade") === "websocket") { + if (options.httpOnly) return new Response("Use HTTP", { status: 426 }) + if (server.upgrade(request)) return + } + if (new URL(request.url).pathname.endsWith("models")) return Response.json({ models: [] }) + const entry = capture(await request.json(), "http") + const output = events(entry) + if (output[0]?.type === "response.failed" && !options.streamOverflow) { + return Response.json( + { error: { code: "context_length_exceeded", message: "Input exceeds context window" } }, + { status: 400 }, + ) + } + return new Response(output.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }) + }, + websocket: { + message(socket, message) { + for (const event of events(capture(JSON.parse(message.toString()), "ws"))) socket.send(JSON.stringify(event)) + }, + }, + }) + const auth: Auth.Info = { + type: "oauth", + access: "fixture-access", + refresh: "fixture-refresh", + expires: Date.now() + 3600000, + accountId: id, + } + const plugin = await CodexAuthPlugin({} as never, { + codexApiEndpoint: `http://127.0.0.1:${server.port}/backend-api/codex/responses`, + }) + if (!plugin.auth?.loader) throw new Error("Missing OAuth loader") + const loaded = await plugin.auth.loader(async () => auth, { id: model.providerID, models: {} } as never) + if (typeof loaded.fetch !== "function") throw new Error("Missing authenticated fetch") + const fetcher: typeof fetch = Object.assign( + async (url: RequestInfo | URL, init?: RequestInit) => { + const response: unknown = await loaded.fetch(url, init) + if (!(response instanceof Response)) throw new Error("Invalid authenticated response") + return response + }, + { preconnect: fetch.preconnect }, + ) + const provider: Provider.Info = { + id: model.providerID, + name: "OpenAI", + source: "custom", + env: [], + options: { fetch: fetcher }, + models: {}, + } + return { + captures, + async send(body: Record) { + const response = await fetcher("https://api.openai.com/v1/responses", { + method: "POST", + body: JSON.stringify(body), + signal: AbortSignal.timeout(10000), + headers: { + "x-session-affinity": id, + "content-type": "application/json", + [CodexResponsesCapability.HEADER]: "true", + [CodexResponsesTransport.TURN_PROFILE_HEADER]: CodexResponsesCatalog.encodeProfile(profile), + [CodexResponsesTransport.TURN_ID_HEADER]: "turn-test", + }, + }) + await response.text() + }, + compact(input: LLMRequestPrep.Prepared, items: unknown[] = []) { + return Effect.runPromise( + CodexResponsesCompact.compact({ + model, + provider, + prepared: input, + items, + sessionID: id, + accountKey: CodexResponsesProtocol.accountKey(id, auth.access) ?? id, + turnProfile: profile, + windowID: id, + turnID: "turn-test", + compaction: { trigger: "auto", reason: "context_limit", phase: "mid_turn" }, + preserveActiveToolMedia: true, + abort: AbortSignal.timeout(10000), + }), + ) + }, + async [Symbol.asyncDispose]() { + await plugin.dispose?.() + server.stop(true) + }, + } +} + +test("compact reuses the sampling wire and replaces the working window before continuing", async () => { + await using server = await fixture() + const sampling = prepared([user]) + await server.send(await sdkBody(sampling)) + const result = await server.compact(prepared([user, assistant])) + const compact = server.captures.at(-1) + expect(compact?.via).toBe("ws") + expect(compact?.body.previous_response_id).toBeString() + expect(compact?.input).toEqual([{ type: "compaction_trigger" }]) + expect(result.items).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "Keep the current task" }] }, + opaque, + ]) + + const next = await sdkBody(prepared([{ role: "user", content: "New tail" }])) + if (!Array.isArray(next.input)) throw new Error("Missing input") + next.input = [...result.items, ...next.input] + await server.send(next) + const reset = server.captures.at(-1) + expect(reset?.body.previous_response_id).toBeUndefined() + expect(reset?.input.some((item) => isRecord(item) && item.type === "compaction")).toBe(true) + expect(JSON.stringify(reset?.input)).not.toContain("Work completed") + + const followup = structuredClone(next) + if (!Array.isArray(followup.input)) throw new Error("Missing input") + followup.input.push(output, { role: "user", content: [{ type: "input_text", text: "Next delta" }] }) + await server.send(followup) + expect(server.captures.at(-1)?.body.previous_response_id).toBeString() + expect(server.captures.at(-1)?.input).toEqual([ + { role: "user", content: [{ type: "input_text", text: "Next delta" }] }, + ]) +}) + +test("large encrypted state cannot veto compact or preemptively clear tool results", async () => { + await using server = await fixture() + const messages: ModelMessage[] = [ + user, + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "summary", + providerOptions: { openai: { itemId: "rs_test", reasoningEncryptedContent: "x".repeat(4200000) } }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_test", + toolName: "inspect", + output: { type: "text", value: "Complete tool work" }, + }, + ], + }, + ] + const result = await server.compact(prepared(messages)) + expect(result.trimmedOutputs).toBe(0) + expect(server.captures).toHaveLength(1) + expect(JSON.stringify(server.captures[0]?.input)).toContain("Complete tool work") +}) + +test("compact matches sampling with split reasoning, phased text, and tool media", async () => { + await using server = await fixture() + const history: ModelMessage[] = [ + user, + { + role: "assistant", + content: [ + ...["First summary", "Second summary"].map((text) => ({ + type: "reasoning" as const, + text, + providerOptions: { openai: { itemId: "rs_split", reasoningEncryptedContent: "encrypted-reasoning" } }, + })), + { type: "text", text: "Inspecting", providerOptions: { openai: { phase: "commentary" } } }, + { type: "tool-call", toolCallId: "call_image", toolName: "inspect", input: {} }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_image", + toolName: "inspect", + output: { + type: "content", + value: [ + { type: "text", text: "Current image" }, + { type: "media", mediaType: "image/png", data: "AQID" }, + ], + }, + }, + ], + }, + ] + await server.send(await sdkBody(prepared(history))) + await server.compact(prepared([...history, assistant])) + expect(server.captures.at(-1)?.body.previous_response_id).toBeString() + expect(server.captures.at(-1)?.input).toEqual([{ type: "compaction_trigger" }]) +}) + +for (const httpOnly of [false, true]) { + test(`confirmed ${httpOnly ? "HTTP" : "stream"} overflow bounds tool text once and preserves active images`, async () => { + await using server = await fixture({ overflows: 1, httpOnly, streamOverflow: !httpOnly }) + const messages: ModelMessage[] = [ + user, + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "summary", + providerOptions: { openai: { itemId: "rs_test", reasoningEncryptedContent: "x".repeat(4200000) } }, + }, + ], + }, + { + role: "tool", + content: ["first", "second"].map((id) => ({ + type: "tool-result", + toolCallId: id, + toolName: "inspect", + output: { + type: "content", + value: [ + { type: "text", text: "Tool result ".repeat(1000) }, + { type: "media", mediaType: "image/png", data: "AQID" }, + ], + }, + })), + }, + ] + const result = await server.compact(prepared(messages)) + expect(result.trimmedOutputs).toBe(2) + expect(server.captures).toHaveLength(2) + for (const entry of server.captures) { + const outputs = entry.input.filter((item) => isRecord(item) && item.type === "function_call_output") + expect(outputs).toHaveLength(2) + for (const item of outputs) { + if (!isRecord(item) || !Array.isArray(item.output)) throw new Error("Tool media was serialized as text") + expect(item.output).toContainEqual({ type: "input_image", image_url: "data:image/png;base64,AQID" }) + } + } + expect(JSON.stringify(server.captures[1]?.input)).not.toContain("Tool result Tool result") + expect(server.captures[1]?.body.previous_response_id).toBeUndefined() + }) +} + +test("a second provider overflow stops without an unbounded compact loop", async () => { + await using server = await fixture({ overflows: 10 }) + const input = prepared([ + user, + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "summary", + providerOptions: { openai: { itemId: "rs_test", reasoningEncryptedContent: "x".repeat(4200000) } }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_test", + toolName: "inspect", + output: { type: "text", value: "Tool result ".repeat(1000) }, + }, + ], + }, + ]) + await expect(server.compact(input)).rejects.toThrow("Input exceeds context window") + expect(server.captures).toHaveLength(2) +}) + +test("provider overflow still reduces an output when the local estimate is below target", async () => { + await using server = await fixture({ overflows: 1 }) + const result = await server.compact( + prepared([ + user, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_test", + toolName: "inspect", + output: { type: "text", value: "Tool result ".repeat(1000) }, + }, + ], + }, + ]), + ) + expect(result.trimmedOutputs).toBe(1) + expect(server.captures).toHaveLength(2) +})