From 4c8fed54c1b0f3f45b3092b548c54014ac2f9593 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:19:30 +0200 Subject: [PATCH 01/14] fix(context): apply Claude and Codex window choices --- .../server/src/provider/ClaudeModelCatalog.ts | 2 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 47 ++++++++++++++++++- .../src/provider/Layers/ClaudeAdapter.ts | 31 +++++++++++- .../src/provider/Layers/CodexAdapter.test.ts | 26 ++++++++++ .../src/provider/Layers/CodexAdapter.ts | 18 ++++++- .../CodexCollabRuntime.integration.test.ts | 46 ++++++++++++++++++ .../src/provider/Layers/CodexProvider.test.ts | 29 ++++++++++++ .../src/provider/Layers/CodexProvider.ts | 12 +++++ .../Layers/CodexSessionRuntime.test.ts | 30 ++++++++++++ .../provider/Layers/CodexSessionRuntime.ts | 42 +++++++++++++++-- .../testFixtures/codexCollabMockPeer.mjs | 22 +++++++++ apps/web/src/components/chat/ChatComposer.tsx | 11 ++++- .../chat/ContextWindowMeter.logic.test.ts | 28 +++++++++++ .../chat/ContextWindowMeter.logic.ts | 13 +++++ packages/shared/src/model.ts | 6 +++ 15 files changed, 352 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index e601e9dc9392..1fb80cb4a57b 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -215,7 +215,7 @@ export function isClaudeCatalogUltracodeEffort(effort: string | null | undefined return effort === "ultracode"; } -function resolveClaudeCatalogContextWindow( +export function resolveClaudeCatalogContextWindow( catalog: ClaudeModelCatalog, modelSelection: ModelSelection | undefined, ): string | undefined { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index ca596e6501cf..c519ea08bfc4 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -46,6 +46,7 @@ import { SYNTHETIC_CLAUDE_STANDARD_MODEL, SYNTHETIC_CLAUDE_THINKING_MODEL, } from "../ClaudeModelCatalog.testFixtures.ts"; +import { BUNDLED_CLAUDE_MODEL_CATALOG } from "../ClaudeModelCatalog.ts"; import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import type { ClaudeScopedLimitNames } from "./claudeUsageLimits.ts"; @@ -172,6 +173,7 @@ function makeHarness(config?: { readonly environment?: ClaudeAdapterLiveOptions["environment"]; readonly getSessionMessages?: ClaudeAdapterLiveOptions["getSessionMessages"]; readonly forkSession?: ClaudeAdapterLiveOptions["forkSession"]; + readonly modelCatalog?: typeof BUNDLED_CLAUDE_MODEL_CATALOG; }) { const query = new FakeClaudeQuery(); const queries = [query]; @@ -186,11 +188,12 @@ function makeHarness(config?: { ...(config?.environment ? { environment: config.environment } : {}), ...(config?.instanceId ? { instanceId: config.instanceId } : {}), ...(config?.scopedLimitNames ? { scopedLimitNames: config.scopedLimitNames } : {}), - modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), + modelCatalog: Effect.succeed(config?.modelCatalog ?? SYNTHETIC_CLAUDE_MODEL_CATALOG), ...(config?.getSessionMessages ? { getSessionMessages: config.getSessionMessages } : {}), ...(config?.forkSession ? { forkSession: config.forkSession } : {}), createQuery: (input) => { - if (createInput && config?.getSessionMessages) queries.push(new FakeClaudeQuery()); + if (createInput && (config?.getSessionMessages || config?.modelCatalog)) + queries.push(new FakeClaudeQuery()); createInput = input; return queries.at(-1)!; }, @@ -7411,6 +7414,46 @@ describe("ClaudeAdapterLive", () => { ); }); + for (const [from, to] of [ + ["1m", "200k"], + ["200k", "1m"], + ] as const) { + it.effect(`restarts Claude when the context changes from ${from} to ${to}`, () => { + const harness = makeHarness({ modelCatalog: BUNDLED_CLAUDE_MODEL_CATALOG }); + const selection = (window: string) => + createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-5-5", [ + { id: "contextWindow", value: window }, + ]); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + modelSelection: selection(from), + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Continue", + modelSelection: selection(to), + attachments: [], + }); + assert.equal(harness.query.closeCalls, 1); + assert.equal( + harness.getLastCreateQueryInput()?.options.env?.CLAUDE_CODE_DISABLE_1M_CONTEXT, + to === "200k" ? "1" : undefined, + ); + assert.equal( + harness.getLastCreateQueryInput()?.options.model, + to === "1m" ? "claude-opus-5-5[1m]" : "claude-opus-5-5", + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + } + it.effect("sets plan permission mode on sendTurn when interactionMode is plan", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 13645a33a05b..9d64501c271b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -101,6 +101,7 @@ import { isClaudeCatalogUltracodeEffort, normalizeClaudeCatalogEffort, resolveClaudeCatalogApiModelId, + resolveClaudeCatalogContextWindow, resolveClaudeCatalogContextWindowTokens, resolveClaudeCatalogEffort, resolveClaudeModelSlug, @@ -4849,6 +4850,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const apiModelId = modelSelection ? resolveClaudeCatalogApiModelId(modelCatalog, modelSelection) : undefined; + const contextWindowChoice = resolveClaudeCatalogContextWindow(modelCatalog, modelSelection); const initialContextWindow = selectedClaudeContextWindow(modelCatalog, modelSelection); const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort"); const effort = @@ -4947,7 +4949,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( canUseTool, onUserDialog, supportedDialogKinds: ["resume_return"], - env: McpProviderSession.withAgentDeviceEnvironment(claudeEnvironment, mcpSession), + env: { + ...McpProviderSession.withAgentDeviceEnvironment(claudeEnvironment, mcpSession), + ...(contextWindowChoice === "200k" + ? { CLAUDE_CODE_DISABLE_1M_CONTEXT: "1" } + : contextWindowChoice === "1m" + ? { CLAUDE_CODE_DISABLE_1M_CONTEXT: undefined } + : {}), + }, additionalDirectories, ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), ...(mcpSession @@ -5137,7 +5146,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ); const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { - const context = yield* requireSession(input.threadId); + let context = yield* requireSession(input.threadId); const modelCatalog = yield* modelCatalogEffect; const selectedModel = input.modelSelection !== undefined && input.modelSelection.instanceId === boundInstanceId @@ -5146,6 +5155,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const modelSelection = selectedModel ? { ...selectedModel, model: resolveClaudeModelSlug(modelCatalog, selectedModel.model) } : undefined; + const contextChoiceChanged = + modelSelection !== undefined && + (resolveClaudeCatalogContextWindow(modelCatalog, context.startInput.modelSelection) === + "200k") !== + (resolveClaudeCatalogContextWindow(modelCatalog, modelSelection) === "200k"); + if (contextChoiceChanged && context.turnState?.synthetic !== true && context.turnState) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/setContextWindow", + detail: "Finish the current turn before changing the context window.", + }); + } if (modelSelection) { context.startInput = { ...context.startInput, modelSelection }; } @@ -5160,6 +5181,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (context.turnState && steeringTurnState === null) { yield* completeTurn(context, "completed"); } + if (contextChoiceChanged) { + const resumeCursor = context.session.resumeCursor; + yield* stopSessionInternal(context, { emitExitEvent: false }); + yield* startSession({ ...context.startInput, resumeCursor }); + context = yield* requireSession(input.threadId); + } if (modelSelection?.model) { const apiModelId = resolveClaudeCatalogApiModelId(modelCatalog, modelSelection); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 9f464bdaa177..c2e174bfa56e 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -448,6 +448,32 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect("passes expanded and default context choices to the runtime", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("thread-context-choice"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + runtimeMode: "full-access", + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-6-astra", [ + { id: "contextWindow", value: "1m" }, + ]), + }); + const runtime = sessionRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + NodeAssert.equal(runtime.options.contextWindow, "1m"); + + yield* adapter.sendTurn({ + threadId, + input: "continue", + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-6-astra"), + attachments: [], + }); + NodeAssert.equal(runtime.sendTurnImpl.mock.calls.at(-1)?.[0].contextWindow, "default"); + }), + ); + it.effect("passes image attachments to Codex by path instead of base64", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 0ecc9693ab04..0a09c31d81d3 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -44,7 +44,10 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { + getModelSelectionStringOptionValue, + supportsCodexExpandedContext, +} from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; @@ -2271,6 +2274,12 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; + const contextWindow = + input.modelSelection?.instanceId === boundInstanceId && + supportsCodexExpandedContext(input.modelSelection.model) + ? (getModelSelectionStringOptionValue(input.modelSelection, "contextWindow") ?? + "default") + : undefined; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, @@ -2287,6 +2296,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(input.modelSelection?.instanceId === boundInstanceId ? { model: input.modelSelection.model } : {}), + ...(contextWindow ? { contextWindow } : {}), ...(serviceTier ? { serviceTier } : {}), ...(mcpSession ? { @@ -2535,12 +2545,18 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; + const contextWindow = + input.modelSelection?.instanceId === boundInstanceId && + supportsCodexExpandedContext(input.modelSelection.model) + ? (getModelSelectionStringOptionValue(input.modelSelection, "contextWindow") ?? "default") + : undefined; return yield* session.runtime .sendTurn({ ...(input.input !== undefined ? { input: input.input } : {}), ...(input.modelSelection?.instanceId === boundInstanceId ? { model: input.modelSelection.model } : {}), + ...(contextWindow ? { contextWindow } : {}), ...(reasoningEffort ? { effort: reasoningEffort as EffectCodexSchema.V2TurnStartParams__ReasoningEffort, diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 2a9fb56c186a..03680e1f0ff0 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -166,6 +166,52 @@ const peerPath = NodePath.join( ); describe("CodexSessionRuntime collab integration", () => { + it.effect("forks an existing thread when its context window changes", () => + Effect.gen(function* () { + NodeFS.writeFileSync( + scriptPath, + JSON.stringify({ + rootThreadId: ROOT, + recordRequests: true, + recordTurnStarts: true, + notifications: [], + }), + "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-context-choice"), + binaryPath: peerPath, + cwd: NodeOS.tmpdir(), + runtimeMode: "full-access", + model: "gpt-6-astra", + contextWindow: "default", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + yield* runtime.start(); + const turn = yield* runtime.sendTurn({ + input: "continue", + model: "gpt-6-astra", + contextWindow: "1m", + }); + const requests = readRecordedRequests(); + assert.equal(requests[0]?.method, "thread/fork"); + assert.equal(requests[0]?.params.threadId, ROOT); + assert.equal(requests[0]?.params.model, "gpt-6-astra"); + assert.deepEqual(requests[0]?.params.config, { model_context_window: 1_050_000 }); + assert.equal(requests[1]?.params.threadId, "expanded-thread"); + assert.equal((turn.resumeCursor as { threadId: string }).threadId, "expanded-thread"); + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("looks up child model metadata once after activity registration", () => Effect.gen(function* () { const script = { diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 0c7a40d9bd9e..d7b5af846e87 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -64,6 +64,35 @@ it("maps current Codex model capability fields", () => { ]); }); +it("offers Default and 1M context on supported GPT models", () => { + const model = { + additionalSpeedTiers: [], + defaultReasoningEffort: "medium" as const, + description: "Test model", + displayName: "GPT-6 Astra", + hidden: false, + id: "gpt-6-astra", + isDefault: true, + model: "gpt-6-astra", + supportedReasoningEfforts: [], + }; + assert.deepEqual(mapCodexModelCapabilities(model).optionDescriptors, [ + { + id: "contextWindow", + label: "Context Window", + type: "select", + options: [ + { id: "default", label: "Default", isDefault: true }, + { id: "1m", label: "1M" }, + ], + }, + ]); + assert.deepEqual( + mapCodexModelCapabilities({ ...model, id: "gpt-test", model: "gpt-test" }).optionDescriptors, + [], + ); +}); + it("uses standard routing when the catalog has no default service tier", () => { const capabilities = mapCodexModelCapabilities({ additionalSpeedTiers: ["fast"], diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index cdf40f73b1bd..5fafd8e1b482 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -29,6 +29,7 @@ import { codexModelFamily, createModelCapabilities, readCustomModelEntries, + supportsCodexExpandedContext, } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; @@ -184,6 +185,17 @@ export function mapCodexModelCapabilities( ...(defaultReasoning ? { currentValue: defaultReasoning } : {}), }); } + if (supportsCodexExpandedContext(model.model)) { + optionDescriptors.push({ + id: "contextWindow", + label: "Context Window", + type: "select", + options: [ + { id: "default", label: "Default", isDefault: true }, + { id: "1m", label: "1M" }, + ], + }); + } if (serviceTiers.length > 0) { optionDescriptors.push({ id: "serviceTier", diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index ec113ab7c521..0d1f029f73fc 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -889,6 +889,36 @@ describe("isRecoverableThreadResumeError", () => { }); describe("openCodexThread", () => { + for (const [choice, tokens] of [ + ["default", 272_000], + ["1m", 1_050_000], + ] as const) { + it.effect(`starts supported GPT models with the ${choice} window`, () => + Effect.gen(function* () { + let params: unknown; + yield* openCodexThread({ + client: { + request: (_method, payload) => { + params = payload; + return Effect.succeed(makeThreadOpenResponse("expanded-thread")); + }, + raw: { request: () => Effect.die("A new thread must not resume") }, + }, + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: "gpt-6-astra", + contextWindow: choice, + serviceTier: undefined, + resumeThreadId: undefined, + }); + NodeAssert.deepEqual((params as { config?: unknown }).config, { + model_context_window: tokens, + }); + }), + ); + } + it.effect("resumes metadata when historical turns contain unknown error values", () => Effect.gen(function* () { const response = makeThreadOpenResponse("saved-thread"); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 674d23327b65..69dbeef604bd 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -18,7 +18,7 @@ import { TurnId, } from "@t3tools/contracts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { normalizeModelSlug, supportsCodexExpandedContext } from "@t3tools/shared/model"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -176,6 +176,7 @@ export interface CodexSessionRuntimeOptions { readonly cwd: string; readonly runtimeMode: RuntimeMode; readonly model?: string; + readonly contextWindow?: string | undefined; readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; readonly appServerArgs?: ReadonlyArray; @@ -190,6 +191,7 @@ export interface CodexSessionRuntimeSendTurnInput { readonly path: string; }>; readonly model?: string; + readonly contextWindow?: string | undefined; readonly serviceTier?: CodexServiceTier | undefined; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort | undefined; readonly interactionMode?: ProviderInteractionMode; @@ -546,19 +548,30 @@ function buildThreadStartParams(input: { readonly cwd: string; readonly runtimeMode: RuntimeMode; readonly model: string | undefined; + readonly contextWindow?: string | undefined; readonly serviceTier: CodexServiceTier | undefined; -}): EffectCodexSchema.V2ThreadStartParams { +}) { const config = runtimeModeToThreadConfig(input.runtimeMode); + const contextWindow = codexContextWindowChoice(input.model, input.contextWindow); return { cwd: input.cwd, approvalPolicy: config.approvalPolicy, sandbox: config.sandbox, approvalsReviewer: config.approvalsReviewer, ...(input.model ? { model: input.model } : {}), + ...(contextWindow + ? { config: { model_context_window: contextWindow === "1m" ? 1_050_000 : 272_000 } } + : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), }; } +function codexContextWindowChoice(model: string | undefined, choice: string | undefined) { + return model && supportsCodexExpandedContext(model) && (choice === "1m" || choice === "default") + ? choice + : null; +} + function runtimeModeToTurnSandboxPolicy( input: RuntimeMode, ): EffectCodexSchema.V2TurnStartParams__SandboxPolicy { @@ -726,6 +739,7 @@ export const openCodexThread = (input: { readonly runtimeMode: RuntimeMode; readonly cwd: string; readonly requestedModel: string | undefined; + readonly contextWindow?: string | undefined; readonly serviceTier: CodexServiceTier | undefined; readonly resumeThreadId: string | undefined; }): Effect.Effect => { @@ -734,6 +748,7 @@ export const openCodexThread = (input: { cwd: input.cwd, runtimeMode: input.runtimeMode, model: input.requestedModel, + contextWindow: input.contextWindow, serviceTier: input.serviceTier, }); @@ -1379,6 +1394,7 @@ export const makeCodexSessionRuntime = ( updatedAt: sessionCreatedAt, } satisfies ProviderSession; const sessionRef = yield* Ref.make(initialSession); + let activeContextWindow = codexContextWindowChoice(options.model, options.contextWindow); const offerEvent = (event: ProviderEvent) => Queue.offer(events, event).pipe(Effect.asVoid); const emitEvent = (event: Omit) => @@ -2442,6 +2458,7 @@ export const makeCodexSessionRuntime = ( runtimeMode: options.runtimeMode, cwd: options.cwd, requestedModel, + contextWindow: options.contextWindow, serviceTier: options.serviceTier, resumeThreadId: readResumeCursorThreadId(options.resumeCursor), }); @@ -2500,7 +2517,7 @@ export const makeCodexSessionRuntime = ( }), sendTurn: (input) => Effect.gen(function* () { - const providerThreadId = yield* readProviderThreadId; + let providerThreadId = yield* readProviderThreadId; if (hasConfiguredMcpServer(options.appServerArgs)) { yield* client.request("config/mcpServer/reload", undefined).pipe( Effect.catch((cause) => @@ -2513,6 +2530,25 @@ export const makeCodexSessionRuntime = ( const normalizedModel = normalizeCodexModelSlug( input.model ?? (yield* Ref.get(sessionRef)).model, ); + const selectedContextWindow = + input.contextWindow === undefined + ? activeContextWindow + : codexContextWindowChoice(normalizedModel, input.contextWindow); + if (selectedContextWindow !== activeContextWindow) { + const forked = yield* client.request("thread/fork", { + threadId: providerThreadId, + ...buildThreadStartParams({ + cwd: options.cwd, + runtimeMode: options.runtimeMode, + model: normalizedModel, + contextWindow: selectedContextWindow ?? undefined, + serviceTier: input.serviceTier ?? options.serviceTier, + }), + }); + providerThreadId = forked.thread.id; + yield* updateSession(sessionRef, { resumeCursor: { threadId: providerThreadId } }); + activeContextWindow = selectedContextWindow; + } const params = yield* buildTurnStartParams({ threadId: providerThreadId, runtimeMode: options.runtimeMode, diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 440f1558a684..6a5cb6c8d97d 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -95,6 +95,22 @@ rl.on("line", (line) => { write({ id, result: fixture.responses.threadStart }); return; } + if (method === "thread/fork") { + if (script.recordRequests) { + NodeFS.appendFileSync( + `${process.env.T3_CODEX_COLLAB_SCRIPT}.requests`, + `${JSON.stringify({ method, params: message.params })}\n`, + ); + } + write({ + id, + result: { + ...fixture.responses.threadStart, + thread: { ...fixture.responses.threadStart.thread, id: "expanded-thread" }, + }, + }); + return; + } if (method === "thread/resume") { if (script.recordRequests) { NodeFS.appendFileSync( @@ -144,6 +160,12 @@ rl.on("line", (line) => { return; } if (method === "turn/start") { + if (script.recordTurnStarts) { + NodeFS.appendFileSync( + `${process.env.T3_CODEX_COLLAB_SCRIPT}.requests`, + `${JSON.stringify({ method, params: message.params })}\n`, + ); + } const turnId = script.turnIds?.[turnStartCount]; const turn = turnId ? { ...fixture.responses.turnStart.turn, id: turnId } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b377439882d5..8778580a88f7 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -279,6 +279,7 @@ import { ContextWindowMeter, ContextWindowMeterPlaceholder } from "./ContextWind import { providerSupportsManualCompaction, resolveContextWindowModelDisplayName, + sameContextWindowSelection, shouldReserveContextWindowMeter, } from "./ContextWindowMeter.logic"; import { @@ -2079,6 +2080,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), [activeThreadModelSelection, modelOptionsByInstance], ); + const displayedContextWindow = sameContextWindowSelection( + activeThreadModelSelection, + selectedModelSelection, + ) + ? activeContextWindow + : null; const reserveContextWindowMeter = shouldReserveContextWindowMeter({ meterEnabled: settings.contextWindowMeterEnabled, detailLoading: props.threadSyncPhase === "loading", @@ -6796,7 +6803,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) "relative", isComposerResting && "flex min-w-0 items-center gap-1", isComposerResting && - ((settings.contextWindowMeterEnabled && activeContextWindow) || + ((settings.contextWindowMeterEnabled && displayedContextWindow) || reserveContextWindowMeter ? "pr-28" : showComposerAttachAction @@ -7013,7 +7020,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { + const active = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-5-5", + options: [{ id: "contextWindow", value: "200k" }], + }; + + it("hides old usage when the model or context window changes", () => { + expect( + sameContextWindowSelection(active, { + ...active, + options: [{ id: "contextWindow", value: "1m" }], + }), + ).toBe(false); + expect(sameContextWindowSelection(active, { ...active, model: "claude-opus-5" })).toBe(false); + }); + + it("keeps usage for a reasoning-only change", () => { + expect( + sameContextWindowSelection(active, { + ...active, + options: [...active.options, { id: "effort", value: "high" }], + }), + ).toBe(true); + }); +}); + function claudeProvider(input: { instanceId: string; continuationGroupKey: string; diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index a04582a0426d..f8882c11d613 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -1,4 +1,5 @@ import type { ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, isClaudeResumeCompactionQuestion, @@ -12,6 +13,18 @@ import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils const CLAUDE_RESUME_COMPACTION_MINUTES = 70; const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; +export function sameContextWindowSelection( + active: ModelSelection | null | undefined, + selected: ModelSelection, +): boolean { + return ( + active?.instanceId === selected.instanceId && + active.model === selected.model && + getModelSelectionStringOptionValue(active, "contextWindow") === + getModelSelectionStringOptionValue(selected, "contextWindow") + ); +} + export function providerSupportsManualCompaction( provider: ProviderInstanceEntry | null | undefined, ): boolean { diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index b3c278a1ac36..793087d961df 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -230,6 +230,12 @@ export function codexModelFamily(slug: string): string { return slug.startsWith("openai.gpt-") ? slug.slice("openai.".length) : slug; } +export function supportsCodexExpandedContext(slug: string): boolean { + return /^(?:gpt-6-(?:astra|sol|luna)|gpt-5\.(?:4|5|6(?:-(?:sol|terra|luna))?))$/.test( + codexModelFamily(slug), + ); +} + export function normalizeModelSlug( model: string | null | undefined, provider: ProviderDriverKind = DEFAULT_PROVIDER_DRIVER_KIND, From 307388818b82f528bcd4ecbe0501eb0649164a5a Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:27:35 +0200 Subject: [PATCH 02/14] fix(test): allow JSON in Codex mock fixture --- .../src/provider/Layers/CodexCollabRuntime.integration.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 03680e1f0ff0..2db355929de2 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -170,6 +170,7 @@ describe("CodexSessionRuntime collab integration", () => { Effect.gen(function* () { NodeFS.writeFileSync( scriptPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ rootThreadId: ROOT, recordRequests: true, From c39f5bffea9132a51b7e2ea615bbbda249d3ce6f Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:34:35 +0200 Subject: [PATCH 03/14] fix(codex): serialize context forks and stop targeting --- .../CodexCollabRuntime.integration.test.ts | 21 ++++++++---- .../provider/Layers/CodexSessionRuntime.ts | 34 ++++++++++++------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 2db355929de2..c4aa8ff19323 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -197,18 +197,27 @@ describe("CodexSessionRuntime collab integration", () => { environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, }); yield* runtime.start(); - const turn = yield* runtime.sendTurn({ - input: "continue", - model: "gpt-6-astra", - contextWindow: "1m", - }); + const turns = yield* Effect.all( + [ + runtime.sendTurn({ input: "first", model: "gpt-6-astra", contextWindow: "1m" }), + runtime.sendTurn({ input: "second", model: "gpt-6-astra", contextWindow: "1m" }), + ], + { concurrency: 2 }, + ); const requests = readRecordedRequests(); assert.equal(requests[0]?.method, "thread/fork"); assert.equal(requests[0]?.params.threadId, ROOT); assert.equal(requests[0]?.params.model, "gpt-6-astra"); assert.deepEqual(requests[0]?.params.config, { model_context_window: 1_050_000 }); assert.equal(requests[1]?.params.threadId, "expanded-thread"); - assert.equal((turn.resumeCursor as { threadId: string }).threadId, "expanded-thread"); + assert.equal(requests[2]?.params.threadId, "expanded-thread"); + assert.equal((turns[0].resumeCursor as { threadId: string }).threadId, "expanded-thread"); + assert.equal((turns[1].resumeCursor as { threadId: string }).threadId, "expanded-thread"); + const error = yield* Effect.flip( + runtime.sendTurn({ input: "switch back", model: "gpt-6-astra", contextWindow: "default" }), + ); + assert.equal(error._tag, "CodexAppServerRequestError"); + assert.equal(readRecordedRequests().length, 3); yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 69dbeef604bd..ce1e963169ac 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -29,6 +29,7 @@ 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 Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as CodexClient from "effect-codex-app-server/client"; @@ -1394,6 +1395,7 @@ export const makeCodexSessionRuntime = ( updatedAt: sessionCreatedAt, } satisfies ProviderSession; const sessionRef = yield* Ref.make(initialSession); + const sendTurnSemaphore = yield* Semaphore.make(1); let activeContextWindow = codexContextWindowChoice(options.model, options.contextWindow); const offerEvent = (event: ProviderEvent) => Queue.offer(events, event).pipe(Effect.asVoid); @@ -2535,6 +2537,11 @@ export const makeCodexSessionRuntime = ( ? activeContextWindow : codexContextWindowChoice(normalizedModel, input.contextWindow); if (selectedContextWindow !== activeContextWindow) { + if ((yield* Ref.get(sessionRef)).activeTurnId) { + return yield* CodexErrors.CodexAppServerRequestError.invalidParams( + "Finish the current turn before changing the context window.", + ); + } const forked = yield* client.request("thread/fork", { threadId: providerThreadId, ...buildThreadStartParams({ @@ -2546,7 +2553,10 @@ export const makeCodexSessionRuntime = ( }), }); providerThreadId = forked.thread.id; - yield* updateSession(sessionRef, { resumeCursor: { threadId: providerThreadId } }); + yield* updateSession(sessionRef, { + resumeCursor: { threadId: providerThreadId }, + activeTurnId: undefined, + }); activeContextWindow = selectedContextWindow; } const params = yield* buildTurnStartParams({ @@ -2593,11 +2603,9 @@ export const makeCodexSessionRuntime = ( ? { resumeCursor: { threadId: resumedProviderThreadId } } : {}), } satisfies ProviderTurnStartResult; - }), + }).pipe(sendTurnSemaphore.withPermits(1)), 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, @@ -2628,14 +2636,16 @@ export const makeCodexSessionRuntime = ( .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, - }); + yield* Effect.gen(function* () { + const providerThreadId = yield* readProviderThreadId; + const session = yield* Ref.get(sessionRef); + const effectiveTurnId = session.activeTurnId ?? turnId; + if (!effectiveTurnId) return; + yield* client.request("turn/interrupt", { + threadId: providerThreadId, + turnId: effectiveTurnId, + }); + }).pipe(sendTurnSemaphore.withPermits(1)); }), readThread: Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; From 8c223ece9a3abf68a7985c8af40ca3173d5acffc Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:46:49 +0200 Subject: [PATCH 04/14] fix(codex): reset unsupported model context and keep Stop responsive --- .../CodexCollabRuntime.integration.test.ts | 72 +++++++++++++++++++ .../provider/Layers/CodexSessionRuntime.ts | 30 ++++---- .../testFixtures/codexCollabMockPeer.mjs | 8 +++ 3 files changed, 97 insertions(+), 13 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index c4aa8ff19323..0f42f1fa8c0c 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -222,6 +222,78 @@ describe("CodexSessionRuntime collab integration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("resets the expanded window when switching to an unsupported model", () => + Effect.gen(function* () { + NodeFS.writeFileSync( + scriptPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ rootThreadId: ROOT, recordRequests: true, notifications: [] }), + "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-unsupported-context"), + binaryPath: peerPath, + cwd: NodeOS.tmpdir(), + runtimeMode: "full-access", + model: "gpt-6-astra", + contextWindow: "1m", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + yield* runtime.start(); + yield* runtime.sendTurn({ input: "continue", model: "custom-model" }); + const [fork] = readRecordedRequests(); + assert.isDefined(fork); + assert.equal(fork.method, "thread/fork"); + assert.equal(fork.params.model, "custom-model"); + assert.notProperty(fork.params, "config"); + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("does not block Stop on a stalled turn start", () => + Effect.gen(function* () { + NodeFS.writeFileSync( + scriptPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ rootThreadId: ROOT, hangTurnStartResponse: true, notifications: [] }), + "utf8", + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(scriptPath, { force: true })), + ); + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-stalled-turn-start"), + binaryPath: peerPath, + cwd: NodeOS.tmpdir(), + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + const observed = yield* runtime.events.pipe( + Stream.filter( + (event) => + event.method === "serverRequest/resolved" && + (event.payload as { requestId?: string }).requestId === "turn-start-observed", + ), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + ); + yield* runtime.start(); + const sending = yield* runtime.sendTurn({ input: "continue" }).pipe(Effect.forkScoped); + yield* Fiber.join(observed); + yield* runtime.interruptTurn(); + yield* runtime.close; + yield* Fiber.interrupt(sending); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("looks up child model metadata once after activity registration", () => Effect.gen(function* () { const script = { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index ce1e963169ac..c8f84bcfff1f 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -2532,10 +2532,10 @@ export const makeCodexSessionRuntime = ( const normalizedModel = normalizeCodexModelSlug( input.model ?? (yield* Ref.get(sessionRef)).model, ); - const selectedContextWindow = - input.contextWindow === undefined - ? activeContextWindow - : codexContextWindowChoice(normalizedModel, input.contextWindow); + const selectedContextWindow = codexContextWindowChoice( + normalizedModel, + input.contextWindow ?? activeContextWindow ?? undefined, + ); if (selectedContextWindow !== activeContextWindow) { if ((yield* Ref.get(sessionRef)).activeTurnId) { return yield* CodexErrors.CodexAppServerRequestError.invalidParams( @@ -2636,16 +2636,20 @@ export const makeCodexSessionRuntime = ( .pipe(Effect.timeoutOption("3 seconds"), Effect.ignore), { concurrency: 8, discard: true }, ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); - yield* Effect.gen(function* () { - const providerThreadId = yield* readProviderThreadId; - const session = yield* Ref.get(sessionRef); - const effectiveTurnId = session.activeTurnId ?? turnId; - if (!effectiveTurnId) return; - yield* client.request("turn/interrupt", { - threadId: providerThreadId, - turnId: effectiveTurnId, + const session = yield* Ref.get(sessionRef); + const providerThreadId = currentProviderThreadId(session); + if (!providerThreadId) { + return yield* new CodexSessionRuntimeThreadIdMissingError({ + threadId: options.threadId, }); - }).pipe(sendTurnSemaphore.withPermits(1)); + } + const effectiveTurnId = + session.activeTurnId ?? (session.status === "running" ? turnId : undefined); + if (!effectiveTurnId) return; + yield* client.request("turn/interrupt", { + threadId: providerThreadId, + turnId: effectiveTurnId, + }); }), readThread: Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 6a5cb6c8d97d..e4d144a88578 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -166,6 +166,14 @@ rl.on("line", (line) => { `${JSON.stringify({ method, params: message.params })}\n`, ); } + if (script.hangTurnStartResponse) { + write({ + jsonrpc: "2.0", + method: "serverRequest/resolved", + params: { threadId: script.rootThreadId, requestId: "turn-start-observed" }, + }); + return; + } const turnId = script.turnIds?.[turnStartCount]; const turn = turnId ? { ...fixture.responses.turnStart.turn, id: turnId } From c1dfb7d5e181ed89a1ba939f292d32dc8b170df9 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:56:07 +0200 Subject: [PATCH 05/14] fix(context): preserve Claude work and use native GPT defaults --- .../src/provider/Layers/ClaudeAdapter.test.ts | 75 +++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 7 +- .../CodexCollabRuntime.integration.test.ts | 9 +++ .../Layers/CodexSessionRuntime.test.ts | 9 ++- .../provider/Layers/CodexSessionRuntime.ts | 8 +- .../chat/ContextWindowMeter.logic.test.ts | 7 ++ .../chat/ContextWindowMeter.logic.ts | 4 +- 7 files changed, 107 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index c519ea08bfc4..05ee0e5a3a31 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -7432,12 +7432,33 @@ describe("ClaudeAdapterLive", () => { runtimeMode: "full-access", modelSelection: selection(from), }); + const firstTurn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "First", + modelSelection: selection(from), + attachments: [], + }); + const completed = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "550e8400-e29b-41d4-a716-446655440010", + uuid: "result-context", + } as unknown as SDKMessage); + yield* Fiber.join(completed); yield* adapter.sendTurn({ threadId: session.threadId, input: "Continue", modelSelection: selection(to), attachments: [], }); + const thread = yield* adapter.readThread(session.threadId); + assert.equal(String(thread.turns[0]?.id), String(firstTurn.turnId)); assert.equal(harness.query.closeCalls, 1); assert.equal( harness.getLastCreateQueryInput()?.options.env?.CLAUDE_CODE_DISABLE_1M_CONTEXT, @@ -7454,6 +7475,60 @@ describe("ClaudeAdapterLive", () => { }); } + it.effect("keeps live Claude background tasks when a window change is requested", () => { + const harness = makeHarness({ modelCatalog: BUNDLED_CLAUDE_MODEL_CATALOG }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const selection = (window: string) => + createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-5-5", [ + { id: "contextWindow", value: window }, + ]); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + modelSelection: selection("1m"), + }); + yield* adapter.sendTurn({ threadId: session.threadId, input: "Work", attachments: [] }); + const completed = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-context", + description: "Background work", + task_type: "local_agent", + tool_use_id: "tool-context", + uuid: "task-context-uuid", + session_id: "550e8400-e29b-41d4-a716-446655440010", + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "550e8400-e29b-41d4-a716-446655440010", + uuid: "result-context-task", + } as unknown as SDKMessage); + yield* Fiber.join(completed); + const error = yield* Effect.flip( + adapter.sendTurn({ + threadId: session.threadId, + input: "Switch", + modelSelection: selection("200k"), + attachments: [], + }), + ); + assert.match(error.message, /Finish the current turn/); + assert.equal(harness.query.closeCalls, 0); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("sets plan permission mode on sendTurn when interactionMode is plan", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 9d64501c271b..8670cf577f74 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -5160,7 +5160,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( (resolveClaudeCatalogContextWindow(modelCatalog, context.startInput.modelSelection) === "200k") !== (resolveClaudeCatalogContextWindow(modelCatalog, modelSelection) === "200k"); - if (contextChoiceChanged && context.turnState?.synthetic !== true && context.turnState) { + if ( + contextChoiceChanged && + ((context.turnState && !context.turnState.synthetic) || context.liveTaskIds.size > 0) + ) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "turn/setContextWindow", @@ -5183,9 +5186,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } if (contextChoiceChanged) { const resumeCursor = context.session.resumeCursor; + const retainedTurns = context.turns; yield* stopSessionInternal(context, { emitExitEvent: false }); yield* startSession({ ...context.startInput, resumeCursor }); context = yield* requireSession(input.threadId); + context.turns.push(...retainedTurns); } if (modelSelection?.model) { diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 0f42f1fa8c0c..b6397c817159 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -253,6 +253,15 @@ describe("CodexSessionRuntime collab integration", () => { assert.equal(fork.method, "thread/fork"); assert.equal(fork.params.model, "custom-model"); assert.notProperty(fork.params, "config"); + yield* runtime.sendTurn({ + input: "back to default", + model: "gpt-6-astra", + contextWindow: "default", + }); + assert.equal( + readRecordedRequests().filter((request) => request.method === "thread/fork").length, + 1, + ); yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 0d1f029f73fc..584a9e885a85 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -890,7 +890,7 @@ describe("isRecoverableThreadResumeError", () => { describe("openCodexThread", () => { for (const [choice, tokens] of [ - ["default", 272_000], + ["default", undefined], ["1m", 1_050_000], ] as const) { it.effect(`starts supported GPT models with the ${choice} window`, () => @@ -912,9 +912,10 @@ describe("openCodexThread", () => { serviceTier: undefined, resumeThreadId: undefined, }); - NodeAssert.deepEqual((params as { config?: unknown }).config, { - model_context_window: tokens, - }); + NodeAssert.deepEqual( + (params as { config?: unknown }).config, + tokens === undefined ? undefined : { model_context_window: tokens }, + ); }), ); } diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index c8f84bcfff1f..33b6cfef4b6f 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -560,9 +560,7 @@ function buildThreadStartParams(input: { sandbox: config.sandbox, approvalsReviewer: config.approvalsReviewer, ...(input.model ? { model: input.model } : {}), - ...(contextWindow - ? { config: { model_context_window: contextWindow === "1m" ? 1_050_000 : 272_000 } } - : {}), + ...(contextWindow === "1m" ? { config: { model_context_window: 1_050_000 } } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), }; } @@ -2536,7 +2534,7 @@ export const makeCodexSessionRuntime = ( normalizedModel, input.contextWindow ?? activeContextWindow ?? undefined, ); - if (selectedContextWindow !== activeContextWindow) { + if ((selectedContextWindow === "1m") !== (activeContextWindow === "1m")) { if ((yield* Ref.get(sessionRef)).activeTurnId) { return yield* CodexErrors.CodexAppServerRequestError.invalidParams( "Finish the current turn before changing the context window.", @@ -2557,8 +2555,8 @@ export const makeCodexSessionRuntime = ( resumeCursor: { threadId: providerThreadId }, activeTurnId: undefined, }); - activeContextWindow = selectedContextWindow; } + activeContextWindow = selectedContextWindow; const params = yield* buildTurnStartParams({ threadId: providerThreadId, runtimeMode: options.runtimeMode, diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts index cef4fbeff774..bd3f5e5f3bc5 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.test.ts @@ -36,6 +36,13 @@ describe("sameContextWindowSelection", () => { }), ).toBe(true); }); + + it("treats omitted and explicit default windows as the same choice", () => { + const selected = { ...active, model: "gpt-6-astra", options: [] }; + const explicit = { ...selected, options: [{ id: "contextWindow", value: "default" }] }; + expect(sameContextWindowSelection(selected, explicit)).toBe(true); + expect(sameContextWindowSelection(explicit, selected)).toBe(true); + }); }); function claudeProvider(input: { diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index f8882c11d613..8e32fcaecf66 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -20,8 +20,8 @@ export function sameContextWindowSelection( return ( active?.instanceId === selected.instanceId && active.model === selected.model && - getModelSelectionStringOptionValue(active, "contextWindow") === - getModelSelectionStringOptionValue(selected, "contextWindow") + (getModelSelectionStringOptionValue(active, "contextWindow") ?? "default") === + (getModelSelectionStringOptionValue(selected, "contextWindow") ?? "default") ); } From 3afde430b0027d49c1a918d2c7768c6ebf3256ab Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:04:59 +0200 Subject: [PATCH 06/14] fix(claude): restart when selecting 1M from unset window --- .../src/provider/Layers/ClaudeAdapter.test.ts | 5 +++-- apps/server/src/provider/Layers/ClaudeAdapter.ts | 13 ++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 05ee0e5a3a31..d50b537f860d 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -7417,6 +7417,7 @@ describe("ClaudeAdapterLive", () => { for (const [from, to] of [ ["1m", "200k"], ["200k", "1m"], + ["unset", "1m"], ] as const) { it.effect(`restarts Claude when the context changes from ${from} to ${to}`, () => { const harness = makeHarness({ modelCatalog: BUNDLED_CLAUDE_MODEL_CATALOG }); @@ -7430,12 +7431,12 @@ describe("ClaudeAdapterLive", () => { threadId: THREAD_ID, provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", - modelSelection: selection(from), + ...(from === "unset" ? {} : { modelSelection: selection(from) }), }); const firstTurn = yield* adapter.sendTurn({ threadId: session.threadId, input: "First", - modelSelection: selection(from), + ...(from === "unset" ? {} : { modelSelection: selection(from) }), attachments: [], }); const completed = yield* Stream.filter( diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 8670cf577f74..6e10f3312df2 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -5155,11 +5155,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const modelSelection = selectedModel ? { ...selectedModel, model: resolveClaudeModelSlug(modelCatalog, selectedModel.model) } : undefined; + const currentContextChoice = resolveClaudeCatalogContextWindow( + modelCatalog, + context.startInput.modelSelection, + ); + const nextContextChoice = resolveClaudeCatalogContextWindow(modelCatalog, modelSelection); const contextChoiceChanged = modelSelection !== undefined && - (resolveClaudeCatalogContextWindow(modelCatalog, context.startInput.modelSelection) === - "200k") !== - (resolveClaudeCatalogContextWindow(modelCatalog, modelSelection) === "200k"); + (currentContextChoice === "200k" || + currentContextChoice === "1m" || + nextContextChoice === "200k" || + nextContextChoice === "1m") && + currentContextChoice !== nextContextChoice; if ( contextChoiceChanged && ((context.turnState && !context.turnState.synthetic) || context.liveTaskIds.size > 0) From ff0bad45c7536e22a474d2c1744233cda0f57cc8 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:38:28 +0200 Subject: [PATCH 07/14] fix(codex): protect queued turns and Stop during context forks --- .../CodexCollabRuntime.integration.test.ts | 111 ++++++++++++++++++ .../provider/Layers/CodexSessionRuntime.ts | 50 ++++++-- .../testFixtures/codexCollabMockPeer.mjs | 30 ++++- 3 files changed, 180 insertions(+), 11 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index b6397c817159..568ff6cd5f54 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -266,6 +266,117 @@ describe("CodexSessionRuntime collab integration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("keeps a queued turn on its original thread until it completes", () => + Effect.gen(function* () { + NodeFS.writeFileSync( + scriptPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + rootThreadId: ROOT, + recordRequests: true, + recordTurnStarts: true, + holdTurnOpen: true, + onlyFirstTurnStarts: true, + completeFirstTurnOnSecondStart: true, + turnIds: ["first-turn", "queued-turn"], + notifications: [], + }), + "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-queued-context"), + binaryPath: peerPath, + cwd: NodeOS.tmpdir(), + runtimeMode: "full-access", + model: "gpt-6-astra", + contextWindow: "default", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + const completed = yield* runtime.events.pipe( + Stream.filter((event) => event.method === "turn/completed"), + Stream.runHead, + Effect.forkScoped, + ); + yield* runtime.start(); + yield* runtime.sendTurn({ input: "first", model: "gpt-6-astra" }); + yield* runtime.sendTurn({ input: "queued", model: "gpt-6-astra" }); + yield* Fiber.join(completed); + assert.isUndefined((yield* runtime.getSession).activeTurnId); + const error = yield* Effect.flip( + runtime.sendTurn({ input: "switch", model: "gpt-6-astra", contextWindow: "1m" }), + ); + assert.equal(error._tag, "CodexAppServerRequestError"); + assert.equal( + readRecordedRequests().filter((request) => request.method === "thread/fork").length, + 0, + ); + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("stops a turn before it starts when Stop arrives during a fork", () => + Effect.gen(function* () { + NodeFS.writeFileSync( + scriptPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + rootThreadId: ROOT, + recordRequests: true, + recordTurnStarts: true, + deferForkResponseUntilRead: true, + notifications: [], + }), + "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-stopped-fork"), + binaryPath: peerPath, + cwd: NodeOS.tmpdir(), + runtimeMode: "full-access", + model: "gpt-6-astra", + contextWindow: "default", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + const observed = yield* runtime.events.pipe( + Stream.filter( + (event) => + event.method === "serverRequest/resolved" && + (event.payload as { requestId?: string }).requestId === "fork-observed", + ), + Stream.runHead, + Effect.forkScoped, + ); + yield* runtime.start(); + const sending = yield* runtime + .sendTurn({ input: "switch", model: "gpt-6-astra", contextWindow: "1m" }) + .pipe(Effect.forkScoped); + yield* Fiber.join(observed); + yield* runtime.interruptTurn(); + yield* runtime.readThread.pipe(Effect.ignore); + const error = yield* Fiber.join(sending).pipe(Effect.flip); + assert.equal(error._tag, "CodexAppServerRequestError"); + assert.deepEqual( + readRecordedRequests().map((request) => request.method), + ["thread/fork"], + ); + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("does not block Stop on a stalled turn start", () => Effect.gen(function* () { NodeFS.writeFileSync( diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 33b6cfef4b6f..5aa934927e59 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -1394,7 +1394,10 @@ export const makeCodexSessionRuntime = ( } satisfies ProviderSession; const sessionRef = yield* Ref.make(initialSession); const sendTurnSemaphore = yield* Semaphore.make(1); + const outstandingTurnIds = new Set(); + const completedBeforeResponse = new Set(); let activeContextWindow = codexContextWindowChoice(options.model, options.contextWindow); + let stopEpoch = 0; const offerEvent = (event: ProviderEvent) => Queue.offer(events, event).pipe(Effect.asVoid); const emitEvent = (event: Omit) => @@ -2025,6 +2028,7 @@ export const makeCodexSessionRuntime = ( if (providerThreadId && payload.threadId !== providerThreadId) { return Effect.void; } + outstandingTurnIds.add(payload.turn.id); return updateSession(sessionRef, { status: "running", activeTurnId: TurnId.make(payload.turn.id), @@ -2039,6 +2043,9 @@ export const makeCodexSessionRuntime = ( if (providerThreadId && payload.threadId !== providerThreadId) { return Effect.void; } + if (!outstandingTurnIds.delete(payload.turn.id)) { + completedBeforeResponse.add(payload.turn.id); + } const lastError = payload.turn.status === "failed" && "error" in payload.turn && payload.turn.error ? payload.turn.error.message @@ -2515,8 +2522,9 @@ export const makeCodexSessionRuntime = ( const providerThreadId = yield* readProviderThreadId; yield* client.request("thread/compact/start", { threadId: providerThreadId }); }), - sendTurn: (input) => - Effect.gen(function* () { + sendTurn: (input) => { + const sendEpoch = stopEpoch; + return Effect.gen(function* () { let providerThreadId = yield* readProviderThreadId; if (hasConfiguredMcpServer(options.appServerArgs)) { yield* client.request("config/mcpServer/reload", undefined).pipe( @@ -2535,7 +2543,7 @@ export const makeCodexSessionRuntime = ( input.contextWindow ?? activeContextWindow ?? undefined, ); if ((selectedContextWindow === "1m") !== (activeContextWindow === "1m")) { - if ((yield* Ref.get(sessionRef)).activeTurnId) { + if ((yield* Ref.get(sessionRef)).activeTurnId || outstandingTurnIds.size > 0) { return yield* CodexErrors.CodexAppServerRequestError.invalidParams( "Finish the current turn before changing the context window.", ); @@ -2574,6 +2582,11 @@ export const makeCodexSessionRuntime = ( options.mcpCapabilities, ), }); + if (sendEpoch !== stopEpoch) { + return yield* CodexErrors.CodexAppServerRequestError.invalidParams( + "Turn was stopped before it started.", + ); + } const rawResponse = yield* client.raw.request("turn/start", params); const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( Effect.mapError((error) => @@ -2585,12 +2598,29 @@ export const makeCodexSessionRuntime = ( ), ); const turnId = TurnId.make(response.turn.id); + const completedEarly = completedBeforeResponse.delete(response.turn.id); + completedBeforeResponse.clear(); + if (!completedEarly) { + outstandingTurnIds.add(response.turn.id); + } + if (sendEpoch !== stopEpoch) { + yield* client + .request("turn/interrupt", { threadId: providerThreadId, turnId }) + .pipe(Effect.ignore); + return yield* CodexErrors.CodexAppServerRequestError.invalidParams( + "Turn was stopped before it started.", + ); + } 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, + ...(!completedEarly + ? { + 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)); @@ -2601,9 +2631,11 @@ export const makeCodexSessionRuntime = ( ? { resumeCursor: { threadId: resumedProviderThreadId } } : {}), } satisfies ProviderTurnStartResult; - }).pipe(sendTurnSemaphore.withPermits(1)), + }).pipe(sendTurnSemaphore.withPermits(1)); + }, interruptTurn: (turnId) => Effect.gen(function* () { + stopEpoch += 1; // 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, diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index e4d144a88578..41df635414a8 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -18,6 +18,8 @@ const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); let turnStartCount = 0; let activeTurn; +let firstTurn; +let pendingForkResponse; // Server->client requests the runtime must answer (approval prompts), keyed // by the numeric JSON-RPC id this peer allocated for them. const openServerRequests = new Map(); @@ -102,13 +104,29 @@ rl.on("line", (line) => { `${JSON.stringify({ method, params: message.params })}\n`, ); } - write({ + const response = { id, result: { ...fixture.responses.threadStart, thread: { ...fixture.responses.threadStart.thread, id: "expanded-thread" }, }, - }); + }; + if (script.deferForkResponseUntilRead) { + pendingForkResponse = response; + write({ + jsonrpc: "2.0", + method: "serverRequest/resolved", + params: { threadId: script.rootThreadId, requestId: "fork-observed" }, + }); + return; + } + write(response); + return; + } + if (method === "thread/read" && pendingForkResponse) { + write(pendingForkResponse); + pendingForkResponse = undefined; + write({ id, result: { thread: fixture.responses.threadStart.thread } }); return; } if (method === "thread/resume") { @@ -180,8 +198,16 @@ rl.on("line", (line) => { : fixture.responses.turnStart.turn; activeTurn = turn; turnStartCount += 1; + if (turnStartCount === 1) firstTurn = turn; write({ id, result: { ...fixture.responses.turnStart, turn } }); const rootThreadId = script.rootThreadId; + if (script.completeFirstTurnOnSecondStart && turnStartCount === 2) { + write({ + jsonrpc: "2.0", + method: "turn/completed", + params: { threadId: rootThreadId, turn: { ...firstTurn, status: "completed" } }, + }); + } if (script.onlyFirstTurnStarts !== true || turnStartCount === 1) { write({ jsonrpc: "2.0", From f24af302179f49632b16b18591d523e8f157d03b Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:51:57 +0200 Subject: [PATCH 08/14] fix(codex): bound stopped turns and avoid stale completions --- .../CodexCollabRuntime.integration.test.ts | 27 +++++++++++++++---- .../provider/Layers/CodexSessionRuntime.ts | 15 +++++++---- .../testFixtures/codexCollabMockPeer.mjs | 15 +++++++++-- 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 568ff6cd5f54..61033781c353 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -365,10 +365,15 @@ describe("CodexSessionRuntime collab integration", () => { .sendTurn({ input: "switch", model: "gpt-6-astra", contextWindow: "1m" }) .pipe(Effect.forkScoped); yield* Fiber.join(observed); + const queued = yield* runtime + .sendTurn({ input: "queued switch", model: "gpt-6-astra", contextWindow: "1m" }) + .pipe(Effect.forkScoped); yield* runtime.interruptTurn(); yield* runtime.readThread.pipe(Effect.ignore); const error = yield* Fiber.join(sending).pipe(Effect.flip); assert.equal(error._tag, "CodexAppServerRequestError"); + const queuedError = yield* Fiber.join(queued).pipe(Effect.flip); + assert.equal(queuedError._tag, "CodexAppServerRequestError"); assert.deepEqual( readRecordedRequests().map((request) => request.method), ["thread/fork"], @@ -377,12 +382,17 @@ describe("CodexSessionRuntime collab integration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.effect("does not block Stop on a stalled turn start", () => + it.live("does not block Stop on a stalled turn start or interrupt", () => Effect.gen(function* () { NodeFS.writeFileSync( scriptPath, // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ rootThreadId: ROOT, hangTurnStartResponse: true, notifications: [] }), + JSON.stringify({ + rootThreadId: ROOT, + deferTurnStartResponseUntilRead: true, + hangInterruptFor: ROOT, + notifications: [], + }), "utf8", ); yield* Effect.addFinalizer(() => @@ -401,16 +411,23 @@ describe("CodexSessionRuntime collab integration", () => { event.method === "serverRequest/resolved" && (event.payload as { requestId?: string }).requestId === "turn-start-observed", ), - Stream.take(1), - Stream.runCollect, + Stream.runHead, Effect.forkScoped, ); yield* runtime.start(); const sending = yield* runtime.sendTurn({ input: "continue" }).pipe(Effect.forkScoped); yield* Fiber.join(observed); yield* runtime.interruptTurn(); + yield* runtime.readThread.pipe(Effect.ignore); + const result = yield* Fiber.join(sending).pipe( + Effect.exit, + Effect.timeoutOption("8 seconds"), + ); + assert.equal(result._tag, "Some"); + if (result._tag === "Some") { + assert.equal(result.value._tag, "Failure"); + } yield* runtime.close; - yield* Fiber.interrupt(sending); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 5aa934927e59..8b4997763fc5 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -2043,9 +2043,8 @@ export const makeCodexSessionRuntime = ( if (providerThreadId && payload.threadId !== providerThreadId) { return Effect.void; } - if (!outstandingTurnIds.delete(payload.turn.id)) { - completedBeforeResponse.add(payload.turn.id); - } + outstandingTurnIds.delete(payload.turn.id); + completedBeforeResponse.add(payload.turn.id); const lastError = payload.turn.status === "failed" && "error" in payload.turn && payload.turn.error ? payload.turn.error.message @@ -2548,6 +2547,11 @@ export const makeCodexSessionRuntime = ( "Finish the current turn before changing the context window.", ); } + if (sendEpoch !== stopEpoch) { + return yield* CodexErrors.CodexAppServerRequestError.invalidParams( + "Turn was stopped before it started.", + ); + } const forked = yield* client.request("thread/fork", { threadId: providerThreadId, ...buildThreadStartParams({ @@ -2587,6 +2591,7 @@ export const makeCodexSessionRuntime = ( "Turn was stopped before it started.", ); } + completedBeforeResponse.clear(); const rawResponse = yield* client.raw.request("turn/start", params); const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( Effect.mapError((error) => @@ -2606,13 +2611,13 @@ export const makeCodexSessionRuntime = ( if (sendEpoch !== stopEpoch) { yield* client .request("turn/interrupt", { threadId: providerThreadId, turnId }) - .pipe(Effect.ignore); + .pipe(Effect.timeoutOption("3 seconds"), Effect.ignore); return yield* CodexErrors.CodexAppServerRequestError.invalidParams( "Turn was stopped before it started.", ); } yield* updateSession(sessionRef, (session) => ({ - ...(!completedEarly + ...(outstandingTurnIds.has(response.turn.id) ? { status: "running", // Codex accepts follow-ups while the current turn is still diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 41df635414a8..06072c498d3b 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -20,6 +20,7 @@ let turnStartCount = 0; let activeTurn; let firstTurn; let pendingForkResponse; +let pendingTurnStartResponse; // Server->client requests the runtime must answer (approval prompts), keyed // by the numeric JSON-RPC id this peer allocated for them. const openServerRequests = new Map(); @@ -123,9 +124,10 @@ rl.on("line", (line) => { write(response); return; } - if (method === "thread/read" && pendingForkResponse) { - write(pendingForkResponse); + if (method === "thread/read" && (pendingForkResponse || pendingTurnStartResponse)) { + write(pendingForkResponse ?? pendingTurnStartResponse); pendingForkResponse = undefined; + pendingTurnStartResponse = undefined; write({ id, result: { thread: fixture.responses.threadStart.thread } }); return; } @@ -199,6 +201,15 @@ rl.on("line", (line) => { activeTurn = turn; turnStartCount += 1; if (turnStartCount === 1) firstTurn = turn; + if (script.deferTurnStartResponseUntilRead) { + pendingTurnStartResponse = { id, result: { ...fixture.responses.turnStart, turn } }; + write({ + jsonrpc: "2.0", + method: "serverRequest/resolved", + params: { threadId: script.rootThreadId, requestId: "turn-start-observed" }, + }); + return; + } write({ id, result: { ...fixture.responses.turnStart, turn } }); const rootThreadId = script.rootThreadId; if (script.completeFirstTurnOnSecondStart && turnStartCount === 2) { From 7e90300462b8e96e22c0d3e4ba1b0175b8c30eb3 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:05:10 +0200 Subject: [PATCH 09/14] fix(codex): close session when Stop cannot be confirmed --- .../Layers/CodexCollabRuntime.integration.test.ts | 12 +++++------- .../src/provider/Layers/CodexSessionRuntime.ts | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 61033781c353..0e6085a3f413 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -419,14 +419,12 @@ describe("CodexSessionRuntime collab integration", () => { yield* Fiber.join(observed); yield* runtime.interruptTurn(); yield* runtime.readThread.pipe(Effect.ignore); - const result = yield* Fiber.join(sending).pipe( - Effect.exit, - Effect.timeoutOption("8 seconds"), - ); - assert.equal(result._tag, "Some"); - if (result._tag === "Some") { - assert.equal(result.value._tag, "Failure"); + const error = yield* Fiber.join(sending).pipe(Effect.flip, Effect.timeoutOption("8 seconds")); + assert.equal(error._tag, "Some"); + if (error._tag === "Some") { + assert.match(error.value.message, /Could not confirm the turn stopped/); } + assert.equal((yield* runtime.getSession).status, "closed"); yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 8b4997763fc5..c59a546941da 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -2609,9 +2609,19 @@ export const makeCodexSessionRuntime = ( outstandingTurnIds.add(response.turn.id); } if (sendEpoch !== stopEpoch) { - yield* client + const interrupted = yield* client .request("turn/interrupt", { threadId: providerThreadId, turnId }) - .pipe(Effect.timeoutOption("3 seconds"), Effect.ignore); + .pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + Effect.timeoutOption("3 seconds"), + ); + if (interrupted._tag === "None" || !interrupted.value) { + yield* close; + return yield* CodexErrors.CodexAppServerRequestError.invalidParams( + "Could not confirm the turn stopped; the session was closed.", + ); + } return yield* CodexErrors.CodexAppServerRequestError.invalidParams( "Turn was stopped before it started.", ); From 4692017c0cacf0272eab2c5253328091188c1a17 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:12:54 +0200 Subject: [PATCH 10/14] ci: retry flaky VCS test shard From 7237fd5e89ba1bbf810c36d8025febef6578bc66 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:04:59 +0200 Subject: [PATCH 11/14] feat(context): discover Codex limits for new GPT models --- .../src/provider/Layers/CodexAdapter.test.ts | 21 ++++ .../src/provider/Layers/CodexAdapter.ts | 22 ++-- .../CodexCollabRuntime.integration.test.ts | 6 +- .../src/provider/Layers/CodexProvider.test.ts | 37 ++++++- .../src/provider/Layers/CodexProvider.ts | 102 ++++++++++++++++-- .../Layers/CodexSessionRuntime.test.ts | 9 +- .../provider/Layers/CodexSessionRuntime.ts | 26 ++--- packages/shared/src/model.test.ts | 12 +++ packages/shared/src/model.ts | 19 ++++ 9 files changed, 218 insertions(+), 36 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index c2e174bfa56e..9e64839e37f2 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -471,6 +471,27 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { attachments: [], }); NodeAssert.equal(runtime.sendTurnImpl.mock.calls.at(-1)?.[0].contextWindow, "default"); + + const expanded = "expanded:gpt-7:872000"; + yield* adapter.sendTurn({ + threadId, + input: "future model", + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-7", [ + { id: "contextWindow", value: expanded }, + ]), + attachments: [], + }); + NodeAssert.equal(runtime.sendTurnImpl.mock.calls.at(-1)?.[0].contextWindow, expanded); + + yield* adapter.sendTurn({ + threadId, + input: "another model", + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-8", [ + { id: "contextWindow", value: expanded }, + ]), + attachments: [], + }); + NodeAssert.equal(runtime.sendTurnImpl.mock.calls.at(-1)?.[0].contextWindow, undefined); }), ); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 0a09c31d81d3..590fb247890f 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -46,6 +46,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { getModelSelectionStringOptionValue, + resolveCodexContextWindowChoice, supportsCodexExpandedContext, } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; @@ -2275,10 +2276,14 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; const contextWindow = - input.modelSelection?.instanceId === boundInstanceId && - supportsCodexExpandedContext(input.modelSelection.model) - ? (getModelSelectionStringOptionValue(input.modelSelection, "contextWindow") ?? - "default") + input.modelSelection?.instanceId === boundInstanceId + ? (resolveCodexContextWindowChoice( + input.modelSelection.model, + getModelSelectionStringOptionValue(input.modelSelection, "contextWindow") ?? + (supportsCodexExpandedContext(input.modelSelection.model) + ? "default" + : undefined), + ) ?? undefined) : undefined; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const runtimeInput: CodexSessionRuntimeOptions = { @@ -2546,9 +2551,12 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; const contextWindow = - input.modelSelection?.instanceId === boundInstanceId && - supportsCodexExpandedContext(input.modelSelection.model) - ? (getModelSelectionStringOptionValue(input.modelSelection, "contextWindow") ?? "default") + input.modelSelection?.instanceId === boundInstanceId + ? (resolveCodexContextWindowChoice( + input.modelSelection.model, + getModelSelectionStringOptionValue(input.modelSelection, "contextWindow") ?? + (supportsCodexExpandedContext(input.modelSelection.model) ? "default" : undefined), + ) ?? undefined) : undefined; return yield* session.runtime .sendTurn({ diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 0e6085a3f413..a7b78878110f 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -242,8 +242,8 @@ describe("CodexSessionRuntime collab integration", () => { binaryPath: peerPath, cwd: NodeOS.tmpdir(), runtimeMode: "full-access", - model: "gpt-6-astra", - contextWindow: "1m", + model: "gpt-7", + contextWindow: "expanded:gpt-7:872000", environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, }); yield* runtime.start(); @@ -255,7 +255,7 @@ describe("CodexSessionRuntime collab integration", () => { assert.notProperty(fork.params, "config"); yield* runtime.sendTurn({ input: "back to default", - model: "gpt-6-astra", + model: "gpt-7", contextWindow: "default", }); assert.equal( diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index d7b5af846e87..0939aabfd374 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,10 @@ import { assert, it } from "@effect/vitest"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { + applyPreferredCodexDefaultModel, + mapCodexModelCapabilities, + parseCodexModelContextLimits, +} from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -91,6 +95,37 @@ it("offers Default and 1M context on supported GPT models", () => { mapCodexModelCapabilities({ ...model, id: "gpt-test", model: "gpt-test" }).optionDescriptors, [], ); + + const limits = parseCodexModelContextLimits(`{ + "models": [ + { "slug": "gpt-test", "context_window": 272000, "max_context_window": 872000 }, + { "slug": "gpt-6-astra", "context_window": 272000, "max_context_window": 272000 } + ] + }`); + assert.deepEqual( + mapCodexModelCapabilities({ ...model, model: "gpt-test" }, limits.get("gpt-test")) + .optionDescriptors, + [ + { + id: "contextWindow", + label: "Context Window", + type: "select", + options: [ + { + id: "default", + label: "Default", + isDefault: true, + description: "272K tokens from Codex.", + }, + { id: "expanded:gpt-test:872000", label: "Expanded · 872K" }, + ], + }, + ], + ); + assert.deepEqual( + mapCodexModelCapabilities(model, limits.get("gpt-6-astra")).optionDescriptors, + [], + ); }); it("uses standard routing when the catalog has no default service tier", () => { diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 5fafd8e1b482..48cbf961027f 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -1,3 +1,9 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +// @effect-diagnostics-next-line nodeBuiltinImport:off +import * as NodePath from "node:path"; + import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -32,6 +38,7 @@ import { supportsCodexExpandedContext, } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { formatTokens } from "@t3tools/shared/usageFormat"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; import { AUTH_PROBE_TIMEOUT_MS, @@ -91,6 +98,45 @@ const REASONING_EFFORT_LABELS: Readonly> = { const DEFAULT_SERVICE_TIER_ID = "default"; +const CodexModelCache = Schema.Struct({ + models: Schema.Array( + Schema.Struct({ + slug: Schema.String, + context_window: Schema.optionalKey(Schema.NullOr(Schema.Finite)), + max_context_window: Schema.optionalKey(Schema.NullOr(Schema.Finite)), + }), + ), +}); + +type CodexContextLimits = { readonly defaultTokens: number; readonly maxTokens: number }; + +const decodeCodexModelCacheJson = Schema.decodeUnknownOption( + Schema.fromJsonString(CodexModelCache), +); + +export function parseCodexModelContextLimits( + input: string, +): ReadonlyMap { + const cache = decodeCodexModelCacheJson(input); + const limits = new Map(); + if (Option.isNone(cache)) return limits; + for (const model of cache.value.models) { + const defaultTokens = model.context_window; + const maxTokens = model.max_context_window ?? defaultTokens; + if ( + typeof defaultTokens === "number" && + typeof maxTokens === "number" && + Number.isSafeInteger(defaultTokens) && + Number.isSafeInteger(maxTokens) && + defaultTokens > 0 && + maxTokens >= defaultTokens + ) { + limits.set(model.slug, { defaultTokens, maxTokens }); + } + } + return limits; +} + function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; } @@ -145,6 +191,7 @@ function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) export function mapCodexModelCapabilities( model: CodexSchema.V2ModelListResponse__Model, + contextLimits?: CodexContextLimits, ): ModelCapabilities { const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => reasoningEffort === @@ -185,14 +232,38 @@ export function mapCodexModelCapabilities( ...(defaultReasoning ? { currentValue: defaultReasoning } : {}), }); } - if (supportsCodexExpandedContext(model.model)) { + const knownExpanded = supportsCodexExpandedContext(model.model); + if ( + (contextLimits ? contextLimits.maxTokens > contextLimits.defaultTokens : knownExpanded) && + (knownExpanded || codexModelFamily(model.model).startsWith("gpt-")) + ) { optionDescriptors.push({ id: "contextWindow", label: "Context Window", type: "select", options: [ - { id: "default", label: "Default", isDefault: true }, - { id: "1m", label: "1M" }, + { + id: "default", + label: "Default", + isDefault: true, + ...(contextLimits + ? { description: `${formatTokens(contextLimits.defaultTokens)} tokens from Codex.` } + : {}), + }, + knownExpanded + ? { + id: "1m", + label: "1M", + ...(contextLimits && contextLimits.maxTokens < 1_050_000 + ? { + description: `Codex caps this at ${formatTokens(contextLimits.maxTokens)} tokens.`, + } + : {}), + } + : { + id: `expanded:${model.model}:${contextLimits!.maxTokens}`, + label: `Expanded · ${formatTokens(contextLimits!.maxTokens)}`, + }, ], }); } @@ -231,14 +302,15 @@ const toDisplayName = (model: CodexSchema.V2ModelListResponse__Model): string => }; function parseCodexModelListResponse( - response: CodexSchema.V2ModelListResponse, + models: ReadonlyArray, + contextLimits: ReadonlyMap, ): ReadonlyArray { - return response.data.map((model) => ({ + return models.map((model) => ({ slug: model.model, name: toDisplayName(model), isCustom: false, ...(model.isDefault ? { isDefault: true } : {}), - capabilities: mapCodexModelCapabilities(model), + capabilities: mapCodexModelCapabilities(model, contextLimits.get(model.model)), })); } @@ -337,7 +409,7 @@ function parseCodexSkillsListResponse( const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( client: CodexClient.CodexAppServerClient["Service"], ) { - const models: ServerProviderModel[] = []; + const models: CodexSchema.V2ModelListResponse__Model[] = []; let cursor: string | null | undefined = undefined; do { @@ -345,7 +417,7 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( "model/list", cursor ? { cursor } : {}, ); - models.push(...parseCodexModelListResponse(response)); + models.push(...response.data); cursor = response.nextCursor; } while (cursor); @@ -474,13 +546,25 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun ], { concurrency: "unbounded" }, ); + const home = + input.homePath?.trim() || + input.environment?.CODEX_HOME?.trim() || + process.env.CODEX_HOME?.trim() || + NodePath.join(NodeOS.homedir(), ".codex"); + const cachePath = NodePath.join(NodePath.resolve(expandHomePath(home)), "models_cache.json"); + const contextLimits = yield* Effect.tryPromise(async () => + parseCodexModelContextLimits(await NodeFSP.readFile(cachePath, "utf8")), + ).pipe(Effect.orElseSucceed(() => new Map())); return { account: accountResponse, rateLimits, version, models: applyPreferredCodexDefaultModel( - appendCustomCodexModels(models, input.customModels ?? []), + appendCustomCodexModels( + parseCodexModelListResponse(models, contextLimits), + input.customModels ?? [], + ), ), skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), } satisfies CodexAppServerProviderSnapshot; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 584a9e885a85..657238d78526 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -889,9 +889,10 @@ describe("isRecoverableThreadResumeError", () => { }); describe("openCodexThread", () => { - for (const [choice, tokens] of [ - ["default", undefined], - ["1m", 1_050_000], + for (const [choice, tokens, model] of [ + ["default", undefined, "gpt-6-astra"], + ["1m", 1_050_000, "gpt-6-astra"], + ["expanded:gpt-7:872000", 872_000, "gpt-7"], ] as const) { it.effect(`starts supported GPT models with the ${choice} window`, () => Effect.gen(function* () { @@ -907,7 +908,7 @@ describe("openCodexThread", () => { threadId: ThreadId.make("thread-1"), runtimeMode: "full-access", cwd: "/tmp/project", - requestedModel: "gpt-6-astra", + requestedModel: model, contextWindow: choice, serviceTier: undefined, resumeThreadId: undefined, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index c59a546941da..d037b7894add 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -18,7 +18,11 @@ import { TurnId, } from "@t3tools/contracts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { normalizeModelSlug, supportsCodexExpandedContext } from "@t3tools/shared/model"; +import { + codexContextWindowTokens, + normalizeModelSlug, + resolveCodexContextWindowChoice, +} from "@t3tools/shared/model"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -553,24 +557,19 @@ function buildThreadStartParams(input: { readonly serviceTier: CodexServiceTier | undefined; }) { const config = runtimeModeToThreadConfig(input.runtimeMode); - const contextWindow = codexContextWindowChoice(input.model, input.contextWindow); + const contextWindow = resolveCodexContextWindowChoice(input.model, input.contextWindow); + const contextWindowTokens = codexContextWindowTokens(contextWindow); return { cwd: input.cwd, approvalPolicy: config.approvalPolicy, sandbox: config.sandbox, approvalsReviewer: config.approvalsReviewer, ...(input.model ? { model: input.model } : {}), - ...(contextWindow === "1m" ? { config: { model_context_window: 1_050_000 } } : {}), + ...(contextWindowTokens ? { config: { model_context_window: contextWindowTokens } } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), }; } -function codexContextWindowChoice(model: string | undefined, choice: string | undefined) { - return model && supportsCodexExpandedContext(model) && (choice === "1m" || choice === "default") - ? choice - : null; -} - function runtimeModeToTurnSandboxPolicy( input: RuntimeMode, ): EffectCodexSchema.V2TurnStartParams__SandboxPolicy { @@ -1396,7 +1395,7 @@ export const makeCodexSessionRuntime = ( const sendTurnSemaphore = yield* Semaphore.make(1); const outstandingTurnIds = new Set(); const completedBeforeResponse = new Set(); - let activeContextWindow = codexContextWindowChoice(options.model, options.contextWindow); + let activeContextWindow = resolveCodexContextWindowChoice(options.model, options.contextWindow); let stopEpoch = 0; const offerEvent = (event: ProviderEvent) => Queue.offer(events, event).pipe(Effect.asVoid); @@ -2537,11 +2536,14 @@ export const makeCodexSessionRuntime = ( const normalizedModel = normalizeCodexModelSlug( input.model ?? (yield* Ref.get(sessionRef)).model, ); - const selectedContextWindow = codexContextWindowChoice( + const selectedContextWindow = resolveCodexContextWindowChoice( normalizedModel, input.contextWindow ?? activeContextWindow ?? undefined, ); - if ((selectedContextWindow === "1m") !== (activeContextWindow === "1m")) { + if ( + codexContextWindowTokens(selectedContextWindow) !== + codexContextWindowTokens(activeContextWindow) + ) { if ((yield* Ref.get(sessionRef)).activeTurnId || outstandingTurnIds.size > 0) { return yield* CodexErrors.CodexAppServerRequestError.invalidParams( "Finish the current turn before changing the context window.", diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index d2c64e3d9458..ab6cde59ef02 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -7,15 +7,27 @@ import { buildProviderOptionSelectionsFromDescriptors, createModelCapabilities, createModelSelection, + codexContextWindowTokens, getModelSelectionBooleanOptionValue, getModelSelectionStringOptionValue, getProviderOptionDescriptors, readCustomModelEntries, + resolveCodexContextWindowChoice, toCustomModelSetting, getProviderOptionBooleanSelectionValue, getProviderOptionStringSelectionValue, } from "./model.ts"; +it("keeps Codex expanded choices tied to their model and exact provider limit", () => { + const choice = "expanded:gpt-7:872000"; + expect(resolveCodexContextWindowChoice("gpt-7", choice)).toBe(choice); + expect(codexContextWindowTokens(choice)).toBe(872_000); + expect(resolveCodexContextWindowChoice("gpt-8", choice)).toBeNull(); + expect(resolveCodexContextWindowChoice("gpt-7", "expanded:gpt-7:NaN")).toBeNull(); + expect(resolveCodexContextWindowChoice("gpt-7", "default")).toBe("default"); + expect(codexContextWindowTokens("default")).toBeUndefined(); +}); + const codexCaps: ModelCapabilities = createModelCapabilities({ optionDescriptors: [ { diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 793087d961df..02111e25f691 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -236,6 +236,25 @@ export function supportsCodexExpandedContext(slug: string): boolean { ); } +export function resolveCodexContextWindowChoice( + model: string | undefined, + choice: string | undefined, +): string | null { + if (!model) return null; + if (choice === "default") return choice; + if (choice === "1m") return supportsCodexExpandedContext(model) ? choice : null; + const prefix = `expanded:${model}:`; + if (!choice?.startsWith(prefix)) return null; + const tokens = Number(choice.slice(prefix.length)); + return Number.isSafeInteger(tokens) && tokens > 0 ? choice : null; +} + +export function codexContextWindowTokens(choice: string | null): number | undefined { + if (choice === "1m") return 1_050_000; + if (!choice?.startsWith("expanded:")) return undefined; + return Number(choice.slice(choice.lastIndexOf(":") + 1)); +} + export function normalizeModelSlug( model: string | null | undefined, provider: ProviderDriverKind = DEFAULT_PROVIDER_DRIVER_KIND, From 45cde4b1979a42ac34cac95f2e4f0b7f85bf38d9 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:12:37 +0200 Subject: [PATCH 12/14] chore(context): explain Codex cache imports --- apps/server/src/provider/Layers/CodexProvider.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 48cbf961027f..d23e8aa70976 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -1,6 +1,8 @@ +// Provider status probes have no filesystem service; read Codex model cache directly. // @effect-diagnostics-next-line nodeBuiltinImport:off import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; +// Provider status probes have no path service; resolve the active Codex home directly. // @effect-diagnostics-next-line nodeBuiltinImport:off import * as NodePath from "node:path"; From 6f9c80f9050f34197a4873e07f3af68d8ab8519d Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:43:20 +0200 Subject: [PATCH 13/14] fix(models): defer Claude and Codex effort to provider settings --- .../src/provider/ClaudeModelCatalog.test.ts | 26 +- .../server/src/provider/ClaudeModelCatalog.ts | 24 +- .../provider/CodexDeveloperInstructions.ts | 2 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 154 +++------- .../src/provider/Layers/ClaudeAdapter.ts | 43 +-- .../src/provider/Layers/CodexAdapter.test.ts | 51 +--- .../src/provider/Layers/CodexAdapter.ts | 34 --- .../CodexCollabRuntime.integration.test.ts | 263 ------------------ .../src/provider/Layers/CodexProvider.test.ts | 75 +---- .../src/provider/Layers/CodexProvider.ts | 148 +--------- .../Layers/CodexSessionRuntime.test.ts | 41 +-- .../provider/Layers/CodexSessionRuntime.ts | 134 ++------- .../testFixtures/codexCollabMockPeer.mjs | 67 ----- .../CodexTextGeneration.test.ts | 60 ++-- .../src/textGeneration/CodexTextGeneration.ts | 8 +- apps/web/src/components/chat/ChatComposer.tsx | 11 +- .../chat/ContextWindowMeter.logic.test.ts | 35 --- .../chat/ContextWindowMeter.logic.ts | 13 - docs/user/composer.md | 5 +- docs/user/keybindings.md | 2 +- docs/user/providers-claude.md | 2 +- packages/contracts/src/model.ts | 1 - packages/contracts/src/settings.ts | 8 +- packages/shared/src/model.test.ts | 12 - packages/shared/src/model.ts | 25 -- 25 files changed, 140 insertions(+), 1104 deletions(-) diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index d5c9d8f53d1e..3aa8d2abfcd0 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -5,8 +5,10 @@ import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; import type { ModelManifestData } from "./ModelManifest.ts"; import { formatClaudeVersionUpgradeMessage, + getClaudeCatalogModelCapabilities, normalizeClaudeCatalogEffort, resolveClaudeCatalogApiModelId, + resolveClaudeCatalogContextWindowTokens, resolveClaudeCatalogEffort, resolveClaudeModelCatalog, resolveClaudeModelsForVersion, @@ -80,7 +82,7 @@ describe("Claude model catalog", () => { ); }); - it("resolves aliases and declarative adapter mappings", () => { + it("resolves aliases without exposing provider-managed effort or context options", () => { const base = manifest(); const input: ModelManifestData = { ...base, @@ -107,12 +109,24 @@ describe("Claude model catalog", () => { "claude-synthetic-next", ); assert.strictEqual(normalizeClaudeCatalogEffort(catalog, "extreme", "synthetic"), "high"); + assert.deepStrictEqual( + getClaudeCatalogModelCapabilities(catalog, "synthetic").optionDescriptors, + [], + ); + assert.strictEqual(resolveClaudeCatalogEffort(catalog, "synthetic", "extreme"), undefined); assert.strictEqual( resolveClaudeCatalogApiModelId(catalog, { instanceId: ProviderInstanceId.make("claudeAgent"), model: "synthetic", }), - "claude-synthetic-next[large]", + "claude-synthetic-next", + ); + assert.strictEqual( + resolveClaudeCatalogContextWindowTokens(catalog, { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "synthetic", + }), + undefined, ); }); @@ -137,7 +151,7 @@ describe("Claude model catalog", () => { assert.isFalse(hasValidClaudeManifestAdapters(malformed)); }); - it("appends custom models with their own descriptors and keeps bare slugs opaque", () => { + it("hides provider-managed options on custom models and keeps bare slugs opaque", () => { const catalog = scopeClaudeModelCatalog(resolveClaudeModelCatalog(manifest()), [ "synthetic", { @@ -163,15 +177,13 @@ describe("Claude model catalog", () => { assert.strictEqual(resolveClaudeModelSlug(catalog, "synthetic"), "synthetic"); assert.strictEqual(resolveClaudeCatalogEffort(catalog, "synthetic", "extreme"), undefined); - // The entry with descriptors resolves user-defined effort ids and passes - // them through untouched (no effortMap, no model suffix). assert.strictEqual( resolveClaudeCatalogEffort(catalog, "claude-custom-tuned", "brutal"), - "brutal", + undefined, ); assert.strictEqual( resolveClaudeCatalogEffort(catalog, "claude-custom-tuned", "bogus"), - "gentle", + undefined, ); assert.strictEqual( normalizeClaudeCatalogEffort(catalog, "brutal", "claude-custom-tuned"), diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index 1fb80cb4a57b..6203741a6af8 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -29,6 +29,18 @@ import { const CLAUDE = ProviderDriverKind.make("claudeAgent"); const EMPTY_CAPABILITIES: ModelCapabilities = { optionDescriptors: [] }; +function useProviderConfiguredOptions(model: ServerProviderModel): ServerProviderModel { + const descriptors = model.capabilities?.optionDescriptors; + if (!descriptors) return model; + return { + ...model, + capabilities: { + ...model.capabilities, + optionDescriptors: descriptors.filter(({ id }) => id !== "effort" && id !== "contextWindow"), + }, + }; +} + export interface ClaudeCatalogModel { readonly model: ServerProviderModel; readonly runtime: ClaudeCodeProfile; @@ -49,7 +61,7 @@ function tryResolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelC const adapter = decodeClaudeModelAdapter(entry.adapter ?? {}); if (Option.isNone(profile) || Option.isNone(adapter)) return null; models.push({ - model: entry.model, + model: useProviderConfiguredOptions(entry.model), runtime: profile.value.claudeCode ?? {}, compatibility: adapter.value.claudeCode ?? {}, }); @@ -75,8 +87,8 @@ export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MO * Scope the catalog to one instance's settings: custom model slugs stay opaque * (a built-in alias they shadow is dropped, canonical slugs and capabilities * are preserved), and custom entries that declare their own capabilities are - * appended so the adapter resolves effort / fast mode / thinking against the - * user's descriptors instead of the empty default. Custom entries carry no + * appended so the adapter resolves fast mode / thinking against the user's + * descriptors instead of the empty default. Custom entries carry no * runtime profile, so option values pass through to Claude Code verbatim. */ export function scopeClaudeModelCatalog( @@ -104,12 +116,12 @@ export function scopeClaudeModelCatalog( for (const entry of customEntries) { if (!entry.capabilities || builtInSlugs.has(entry.slug)) continue; customCatalogModels.push({ - model: { + model: useProviderConfiguredOptions({ slug: entry.slug, name: entry.name, isCustom: true, capabilities: entry.capabilities, - }, + }), runtime: {}, compatibility: {}, }); @@ -215,7 +227,7 @@ export function isClaudeCatalogUltracodeEffort(effort: string | null | undefined return effort === "ultracode"; } -export function resolveClaudeCatalogContextWindow( +function resolveClaudeCatalogContextWindow( catalog: ClaudeModelCatalog, modelSelection: ModelSelection | undefined, ): string | undefined { diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 6a7fee351bce..22010c3c9d89 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -194,7 +194,7 @@ ${browserToolInstructions(browserToolsAvailable)} export interface CodexRuntimeInfo { readonly model: string; - readonly reasoningEffort: string; + readonly reasoningEffort?: string; } export function buildCodexDeveloperInstructions( diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index d50b537f860d..b5ac965309c6 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -39,6 +39,7 @@ import * as TestClock from "effect/testing/TestClock"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; import { SYNTHETIC_CLAUDE_CAPABLE_MODEL, SYNTHETIC_CLAUDE_COLLIDING_ALIAS, @@ -46,7 +47,6 @@ import { SYNTHETIC_CLAUDE_STANDARD_MODEL, SYNTHETIC_CLAUDE_THINKING_MODEL, } from "../ClaudeModelCatalog.testFixtures.ts"; -import { BUNDLED_CLAUDE_MODEL_CATALOG } from "../ClaudeModelCatalog.ts"; import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import type { ClaudeScopedLimitNames } from "./claudeUsageLimits.ts"; @@ -168,12 +168,12 @@ function makeHarness(config?: { readonly cwd?: string; readonly baseDir?: string; readonly claudeConfig?: Partial; + readonly modelCatalog?: ClaudeModelCatalog; readonly instanceId?: ProviderInstanceId; readonly scopedLimitNames?: ClaudeAdapterLiveOptions["scopedLimitNames"]; readonly environment?: ClaudeAdapterLiveOptions["environment"]; readonly getSessionMessages?: ClaudeAdapterLiveOptions["getSessionMessages"]; readonly forkSession?: ClaudeAdapterLiveOptions["forkSession"]; - readonly modelCatalog?: typeof BUNDLED_CLAUDE_MODEL_CATALOG; }) { const query = new FakeClaudeQuery(); const queries = [query]; @@ -192,8 +192,7 @@ function makeHarness(config?: { ...(config?.getSessionMessages ? { getSessionMessages: config.getSessionMessages } : {}), ...(config?.forkSession ? { forkSession: config.forkSession } : {}), createQuery: (input) => { - if (createInput && (config?.getSessionMessages || config?.modelCatalog)) - queries.push(new FakeClaudeQuery()); + if (createInput && config?.getSessionMessages) queries.push(new FakeClaudeQuery()); createInput = input; return queries.at(-1)!; }, @@ -593,6 +592,37 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("uses Claude settings for effort and context despite saved T3 choices", () => { + const harness = makeHarness({ + modelCatalog: BUNDLED_CLAUDE_MODEL_CATALOG, + environment: { ...process.env, CLAUDE_CODE_DISABLE_1M_CONTEXT: "1" }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-opus-5-5", + [ + { id: "effort", value: "max" }, + { id: "contextWindow", value: "1m" }, + ], + ), + runtimeMode: "full-access", + }); + + const options = harness.getLastCreateQueryInput()?.options; + assert.equal(options?.model, "claude-opus-5-5"); + assert.equal(options?.effort, undefined); + assert.equal(options?.env?.CLAUDE_CODE_DISABLE_1M_CONTEXT, "1"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("runs Claude SDK sessions with the configured CLAUDE_CONFIG_DIR", () => { const harness = makeHarness({ claudeConfig: { homePath: "~/.claude-work" } }); return Effect.gen(function* () { @@ -7414,122 +7444,6 @@ describe("ClaudeAdapterLive", () => { ); }); - for (const [from, to] of [ - ["1m", "200k"], - ["200k", "1m"], - ["unset", "1m"], - ] as const) { - it.effect(`restarts Claude when the context changes from ${from} to ${to}`, () => { - const harness = makeHarness({ modelCatalog: BUNDLED_CLAUDE_MODEL_CATALOG }); - const selection = (window: string) => - createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-5-5", [ - { id: "contextWindow", value: window }, - ]); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - runtimeMode: "full-access", - ...(from === "unset" ? {} : { modelSelection: selection(from) }), - }); - const firstTurn = yield* adapter.sendTurn({ - threadId: session.threadId, - input: "First", - ...(from === "unset" ? {} : { modelSelection: selection(from) }), - attachments: [], - }); - const completed = yield* Stream.filter( - adapter.streamEvents, - (event) => event.type === "turn.completed", - ).pipe(Stream.runHead, Effect.forkChild); - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - session_id: "550e8400-e29b-41d4-a716-446655440010", - uuid: "result-context", - } as unknown as SDKMessage); - yield* Fiber.join(completed); - yield* adapter.sendTurn({ - threadId: session.threadId, - input: "Continue", - modelSelection: selection(to), - attachments: [], - }); - const thread = yield* adapter.readThread(session.threadId); - assert.equal(String(thread.turns[0]?.id), String(firstTurn.turnId)); - assert.equal(harness.query.closeCalls, 1); - assert.equal( - harness.getLastCreateQueryInput()?.options.env?.CLAUDE_CODE_DISABLE_1M_CONTEXT, - to === "200k" ? "1" : undefined, - ); - assert.equal( - harness.getLastCreateQueryInput()?.options.model, - to === "1m" ? "claude-opus-5-5[1m]" : "claude-opus-5-5", - ); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - } - - it.effect("keeps live Claude background tasks when a window change is requested", () => { - const harness = makeHarness({ modelCatalog: BUNDLED_CLAUDE_MODEL_CATALOG }); - return Effect.gen(function* () { - const adapter = yield* ClaudeAdapter; - const selection = (window: string) => - createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-5-5", [ - { id: "contextWindow", value: window }, - ]); - const session = yield* adapter.startSession({ - threadId: THREAD_ID, - provider: ProviderDriverKind.make("claudeAgent"), - runtimeMode: "full-access", - modelSelection: selection("1m"), - }); - yield* adapter.sendTurn({ threadId: session.threadId, input: "Work", attachments: [] }); - const completed = yield* Stream.filter( - adapter.streamEvents, - (event) => event.type === "turn.completed", - ).pipe(Stream.runHead, Effect.forkChild); - harness.query.emit({ - type: "system", - subtype: "task_started", - task_id: "task-context", - description: "Background work", - task_type: "local_agent", - tool_use_id: "tool-context", - uuid: "task-context-uuid", - session_id: "550e8400-e29b-41d4-a716-446655440010", - } as unknown as SDKMessage); - harness.query.emit({ - type: "result", - subtype: "success", - is_error: false, - errors: [], - session_id: "550e8400-e29b-41d4-a716-446655440010", - uuid: "result-context-task", - } as unknown as SDKMessage); - yield* Fiber.join(completed); - const error = yield* Effect.flip( - adapter.sendTurn({ - threadId: session.threadId, - input: "Switch", - modelSelection: selection("200k"), - attachments: [], - }), - ); - assert.match(error.message, /Finish the current turn/); - assert.equal(harness.query.closeCalls, 0); - }).pipe( - Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(harness.layer), - ); - }); - it.effect("sets plan permission mode on sendTurn when interactionMode is plan", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 6e10f3312df2..13645a33a05b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -101,7 +101,6 @@ import { isClaudeCatalogUltracodeEffort, normalizeClaudeCatalogEffort, resolveClaudeCatalogApiModelId, - resolveClaudeCatalogContextWindow, resolveClaudeCatalogContextWindowTokens, resolveClaudeCatalogEffort, resolveClaudeModelSlug, @@ -4850,7 +4849,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const apiModelId = modelSelection ? resolveClaudeCatalogApiModelId(modelCatalog, modelSelection) : undefined; - const contextWindowChoice = resolveClaudeCatalogContextWindow(modelCatalog, modelSelection); const initialContextWindow = selectedClaudeContextWindow(modelCatalog, modelSelection); const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort"); const effort = @@ -4949,14 +4947,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( canUseTool, onUserDialog, supportedDialogKinds: ["resume_return"], - env: { - ...McpProviderSession.withAgentDeviceEnvironment(claudeEnvironment, mcpSession), - ...(contextWindowChoice === "200k" - ? { CLAUDE_CODE_DISABLE_1M_CONTEXT: "1" } - : contextWindowChoice === "1m" - ? { CLAUDE_CODE_DISABLE_1M_CONTEXT: undefined } - : {}), - }, + env: McpProviderSession.withAgentDeviceEnvironment(claudeEnvironment, mcpSession), additionalDirectories, ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), ...(mcpSession @@ -5146,7 +5137,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ); const sendTurn: ClaudeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { - let context = yield* requireSession(input.threadId); + const context = yield* requireSession(input.threadId); const modelCatalog = yield* modelCatalogEffect; const selectedModel = input.modelSelection !== undefined && input.modelSelection.instanceId === boundInstanceId @@ -5155,28 +5146,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const modelSelection = selectedModel ? { ...selectedModel, model: resolveClaudeModelSlug(modelCatalog, selectedModel.model) } : undefined; - const currentContextChoice = resolveClaudeCatalogContextWindow( - modelCatalog, - context.startInput.modelSelection, - ); - const nextContextChoice = resolveClaudeCatalogContextWindow(modelCatalog, modelSelection); - const contextChoiceChanged = - modelSelection !== undefined && - (currentContextChoice === "200k" || - currentContextChoice === "1m" || - nextContextChoice === "200k" || - nextContextChoice === "1m") && - currentContextChoice !== nextContextChoice; - if ( - contextChoiceChanged && - ((context.turnState && !context.turnState.synthetic) || context.liveTaskIds.size > 0) - ) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "turn/setContextWindow", - detail: "Finish the current turn before changing the context window.", - }); - } if (modelSelection) { context.startInput = { ...context.startInput, modelSelection }; } @@ -5191,14 +5160,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (context.turnState && steeringTurnState === null) { yield* completeTurn(context, "completed"); } - if (contextChoiceChanged) { - const resumeCursor = context.session.resumeCursor; - const retainedTurns = context.turns; - yield* stopSessionInternal(context, { emitExitEvent: false }); - yield* startSession({ ...context.startInput, resumeCursor }); - context = yield* requireSession(input.threadId); - context.turns.push(...retainedTurns); - } if (modelSelection?.model) { const apiModelId = resolveClaudeCatalogApiModelId(modelCatalog, modelSelection); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 9e64839e37f2..19f15b7c1635 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -415,7 +415,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); - it.effect("maps codex model options before sending a turn", () => + it.effect("keeps Codex effort in config while mapping service tier", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; yield* adapter.startSession({ @@ -442,59 +442,11 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { NodeAssert.deepStrictEqual(runtime.sendTurnImpl.mock.calls[0]?.[0], { input: "hello", model: "gpt-5.3-codex", - effort: "high", serviceTier: "priority", }); }), ); - it.effect("passes expanded and default context choices to the runtime", () => - Effect.gen(function* () { - const adapter = yield* CodexAdapter; - const threadId = asThreadId("thread-context-choice"); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("codex"), - threadId, - runtimeMode: "full-access", - modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-6-astra", [ - { id: "contextWindow", value: "1m" }, - ]), - }); - const runtime = sessionRuntimeFactory.lastRuntime; - NodeAssert.ok(runtime); - NodeAssert.equal(runtime.options.contextWindow, "1m"); - - yield* adapter.sendTurn({ - threadId, - input: "continue", - modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-6-astra"), - attachments: [], - }); - NodeAssert.equal(runtime.sendTurnImpl.mock.calls.at(-1)?.[0].contextWindow, "default"); - - const expanded = "expanded:gpt-7:872000"; - yield* adapter.sendTurn({ - threadId, - input: "future model", - modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-7", [ - { id: "contextWindow", value: expanded }, - ]), - attachments: [], - }); - NodeAssert.equal(runtime.sendTurnImpl.mock.calls.at(-1)?.[0].contextWindow, expanded); - - yield* adapter.sendTurn({ - threadId, - input: "another model", - modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-8", [ - { id: "contextWindow", value: expanded }, - ]), - attachments: [], - }); - NodeAssert.equal(runtime.sendTurnImpl.mock.calls.at(-1)?.[0].contextWindow, undefined); - }), - ); - it.effect("passes image attachments to Codex by path instead of base64", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; @@ -655,7 +607,6 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { NodeAssert.deepStrictEqual(runtime.sendTurnImpl.mock.calls[0]?.[0], { input: "hello", model: "gpt-5.3-codex", - effort: "high", serviceTier: "flex", }); }).pipe(Effect.provide(customLayer)); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 590fb247890f..551bd6e1b8e4 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -44,11 +44,6 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; -import { - getModelSelectionStringOptionValue, - resolveCodexContextWindowChoice, - supportsCodexExpandedContext, -} from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; @@ -2275,16 +2270,6 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; - const contextWindow = - input.modelSelection?.instanceId === boundInstanceId - ? (resolveCodexContextWindowChoice( - input.modelSelection.model, - getModelSelectionStringOptionValue(input.modelSelection, "contextWindow") ?? - (supportsCodexExpandedContext(input.modelSelection.model) - ? "default" - : undefined), - ) ?? undefined) - : undefined; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, @@ -2301,7 +2286,6 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(input.modelSelection?.instanceId === boundInstanceId ? { model: input.modelSelection.model } : {}), - ...(contextWindow ? { contextWindow } : {}), ...(serviceTier ? { serviceTier } : {}), ...(mcpSession ? { @@ -2542,34 +2526,16 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); const session = yield* requireSession(input.threadId); - const reasoningEffort = - input.modelSelection?.instanceId === boundInstanceId - ? getModelSelectionStringOptionValue(input.modelSelection, "reasoningEffort") - : undefined; const serviceTier = input.modelSelection?.instanceId === boundInstanceId ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; - const contextWindow = - input.modelSelection?.instanceId === boundInstanceId - ? (resolveCodexContextWindowChoice( - input.modelSelection.model, - getModelSelectionStringOptionValue(input.modelSelection, "contextWindow") ?? - (supportsCodexExpandedContext(input.modelSelection.model) ? "default" : undefined), - ) ?? undefined) - : undefined; return yield* session.runtime .sendTurn({ ...(input.input !== undefined ? { input: input.input } : {}), ...(input.modelSelection?.instanceId === boundInstanceId ? { model: input.modelSelection.model } : {}), - ...(contextWindow ? { contextWindow } : {}), - ...(reasoningEffort - ? { - effort: reasoningEffort as EffectCodexSchema.V2TurnStartParams__ReasoningEffort, - } - : {}), ...(serviceTier ? { serviceTier } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index a7b78878110f..2a9fb56c186a 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -166,269 +166,6 @@ const peerPath = NodePath.join( ); describe("CodexSessionRuntime collab integration", () => { - it.effect("forks an existing thread when its context window changes", () => - Effect.gen(function* () { - NodeFS.writeFileSync( - scriptPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - rootThreadId: ROOT, - recordRequests: true, - recordTurnStarts: true, - notifications: [], - }), - "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-context-choice"), - binaryPath: peerPath, - cwd: NodeOS.tmpdir(), - runtimeMode: "full-access", - model: "gpt-6-astra", - contextWindow: "default", - environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, - }); - yield* runtime.start(); - const turns = yield* Effect.all( - [ - runtime.sendTurn({ input: "first", model: "gpt-6-astra", contextWindow: "1m" }), - runtime.sendTurn({ input: "second", model: "gpt-6-astra", contextWindow: "1m" }), - ], - { concurrency: 2 }, - ); - const requests = readRecordedRequests(); - assert.equal(requests[0]?.method, "thread/fork"); - assert.equal(requests[0]?.params.threadId, ROOT); - assert.equal(requests[0]?.params.model, "gpt-6-astra"); - assert.deepEqual(requests[0]?.params.config, { model_context_window: 1_050_000 }); - assert.equal(requests[1]?.params.threadId, "expanded-thread"); - assert.equal(requests[2]?.params.threadId, "expanded-thread"); - assert.equal((turns[0].resumeCursor as { threadId: string }).threadId, "expanded-thread"); - assert.equal((turns[1].resumeCursor as { threadId: string }).threadId, "expanded-thread"); - const error = yield* Effect.flip( - runtime.sendTurn({ input: "switch back", model: "gpt-6-astra", contextWindow: "default" }), - ); - assert.equal(error._tag, "CodexAppServerRequestError"); - assert.equal(readRecordedRequests().length, 3); - yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("resets the expanded window when switching to an unsupported model", () => - Effect.gen(function* () { - NodeFS.writeFileSync( - scriptPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ rootThreadId: ROOT, recordRequests: true, notifications: [] }), - "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-unsupported-context"), - binaryPath: peerPath, - cwd: NodeOS.tmpdir(), - runtimeMode: "full-access", - model: "gpt-7", - contextWindow: "expanded:gpt-7:872000", - environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, - }); - yield* runtime.start(); - yield* runtime.sendTurn({ input: "continue", model: "custom-model" }); - const [fork] = readRecordedRequests(); - assert.isDefined(fork); - assert.equal(fork.method, "thread/fork"); - assert.equal(fork.params.model, "custom-model"); - assert.notProperty(fork.params, "config"); - yield* runtime.sendTurn({ - input: "back to default", - model: "gpt-7", - contextWindow: "default", - }); - assert.equal( - readRecordedRequests().filter((request) => request.method === "thread/fork").length, - 1, - ); - yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("keeps a queued turn on its original thread until it completes", () => - Effect.gen(function* () { - NodeFS.writeFileSync( - scriptPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - rootThreadId: ROOT, - recordRequests: true, - recordTurnStarts: true, - holdTurnOpen: true, - onlyFirstTurnStarts: true, - completeFirstTurnOnSecondStart: true, - turnIds: ["first-turn", "queued-turn"], - notifications: [], - }), - "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-queued-context"), - binaryPath: peerPath, - cwd: NodeOS.tmpdir(), - runtimeMode: "full-access", - model: "gpt-6-astra", - contextWindow: "default", - environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, - }); - const completed = yield* runtime.events.pipe( - Stream.filter((event) => event.method === "turn/completed"), - Stream.runHead, - Effect.forkScoped, - ); - yield* runtime.start(); - yield* runtime.sendTurn({ input: "first", model: "gpt-6-astra" }); - yield* runtime.sendTurn({ input: "queued", model: "gpt-6-astra" }); - yield* Fiber.join(completed); - assert.isUndefined((yield* runtime.getSession).activeTurnId); - const error = yield* Effect.flip( - runtime.sendTurn({ input: "switch", model: "gpt-6-astra", contextWindow: "1m" }), - ); - assert.equal(error._tag, "CodexAppServerRequestError"); - assert.equal( - readRecordedRequests().filter((request) => request.method === "thread/fork").length, - 0, - ); - yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("stops a turn before it starts when Stop arrives during a fork", () => - Effect.gen(function* () { - NodeFS.writeFileSync( - scriptPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - rootThreadId: ROOT, - recordRequests: true, - recordTurnStarts: true, - deferForkResponseUntilRead: true, - notifications: [], - }), - "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-stopped-fork"), - binaryPath: peerPath, - cwd: NodeOS.tmpdir(), - runtimeMode: "full-access", - model: "gpt-6-astra", - contextWindow: "default", - environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, - }); - const observed = yield* runtime.events.pipe( - Stream.filter( - (event) => - event.method === "serverRequest/resolved" && - (event.payload as { requestId?: string }).requestId === "fork-observed", - ), - Stream.runHead, - Effect.forkScoped, - ); - yield* runtime.start(); - const sending = yield* runtime - .sendTurn({ input: "switch", model: "gpt-6-astra", contextWindow: "1m" }) - .pipe(Effect.forkScoped); - yield* Fiber.join(observed); - const queued = yield* runtime - .sendTurn({ input: "queued switch", model: "gpt-6-astra", contextWindow: "1m" }) - .pipe(Effect.forkScoped); - yield* runtime.interruptTurn(); - yield* runtime.readThread.pipe(Effect.ignore); - const error = yield* Fiber.join(sending).pipe(Effect.flip); - assert.equal(error._tag, "CodexAppServerRequestError"); - const queuedError = yield* Fiber.join(queued).pipe(Effect.flip); - assert.equal(queuedError._tag, "CodexAppServerRequestError"); - assert.deepEqual( - readRecordedRequests().map((request) => request.method), - ["thread/fork"], - ); - yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.live("does not block Stop on a stalled turn start or interrupt", () => - Effect.gen(function* () { - NodeFS.writeFileSync( - scriptPath, - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - rootThreadId: ROOT, - deferTurnStartResponseUntilRead: true, - hangInterruptFor: ROOT, - notifications: [], - }), - "utf8", - ); - yield* Effect.addFinalizer(() => - Effect.sync(() => NodeFS.rmSync(scriptPath, { force: true })), - ); - const runtime = yield* makeCodexSessionRuntime({ - threadId: ThreadId.make("thread-stalled-turn-start"), - binaryPath: peerPath, - cwd: NodeOS.tmpdir(), - runtimeMode: "full-access", - environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, - }); - const observed = yield* runtime.events.pipe( - Stream.filter( - (event) => - event.method === "serverRequest/resolved" && - (event.payload as { requestId?: string }).requestId === "turn-start-observed", - ), - Stream.runHead, - Effect.forkScoped, - ); - yield* runtime.start(); - const sending = yield* runtime.sendTurn({ input: "continue" }).pipe(Effect.forkScoped); - yield* Fiber.join(observed); - yield* runtime.interruptTurn(); - yield* runtime.readThread.pipe(Effect.ignore); - const error = yield* Fiber.join(sending).pipe(Effect.flip, Effect.timeoutOption("8 seconds")); - assert.equal(error._tag, "Some"); - if (error._tag === "Some") { - assert.match(error.value.message, /Could not confirm the turn stopped/); - } - assert.equal((yield* runtime.getSession).status, "closed"); - yield* runtime.close; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - it.effect("looks up child model metadata once after activity registration", () => Effect.gen(function* () { const script = { diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 0939aabfd374..f1c4266dfa10 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,12 +1,8 @@ import { assert, it } from "@effect/vitest"; -import { - applyPreferredCodexDefaultModel, - mapCodexModelCapabilities, - parseCodexModelContextLimits, -} from "./CodexProvider.ts"; +import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; -it("maps current Codex model capability fields", () => { +it("exposes service tiers while leaving reasoning effort to Codex config", () => { const capabilities = mapCodexModelCapabilities({ additionalSpeedTiers: [], defaultReasoningEffort: "super-high", @@ -38,13 +34,6 @@ it("maps current Codex model capability fields", () => { }); assert.deepStrictEqual(capabilities.optionDescriptors, [ - { - id: "reasoningEffort", - label: "Reasoning", - type: "select", - options: [{ id: "super-high", label: "super-high", isDefault: true }], - currentValue: "super-high", - }, { id: "serviceTier", label: "Service Tier", @@ -68,66 +57,6 @@ it("maps current Codex model capability fields", () => { ]); }); -it("offers Default and 1M context on supported GPT models", () => { - const model = { - additionalSpeedTiers: [], - defaultReasoningEffort: "medium" as const, - description: "Test model", - displayName: "GPT-6 Astra", - hidden: false, - id: "gpt-6-astra", - isDefault: true, - model: "gpt-6-astra", - supportedReasoningEfforts: [], - }; - assert.deepEqual(mapCodexModelCapabilities(model).optionDescriptors, [ - { - id: "contextWindow", - label: "Context Window", - type: "select", - options: [ - { id: "default", label: "Default", isDefault: true }, - { id: "1m", label: "1M" }, - ], - }, - ]); - assert.deepEqual( - mapCodexModelCapabilities({ ...model, id: "gpt-test", model: "gpt-test" }).optionDescriptors, - [], - ); - - const limits = parseCodexModelContextLimits(`{ - "models": [ - { "slug": "gpt-test", "context_window": 272000, "max_context_window": 872000 }, - { "slug": "gpt-6-astra", "context_window": 272000, "max_context_window": 272000 } - ] - }`); - assert.deepEqual( - mapCodexModelCapabilities({ ...model, model: "gpt-test" }, limits.get("gpt-test")) - .optionDescriptors, - [ - { - id: "contextWindow", - label: "Context Window", - type: "select", - options: [ - { - id: "default", - label: "Default", - isDefault: true, - description: "272K tokens from Codex.", - }, - { id: "expanded:gpt-test:872000", label: "Expanded · 872K" }, - ], - }, - ], - ); - assert.deepEqual( - mapCodexModelCapabilities(model, limits.get("gpt-6-astra")).optionDescriptors, - [], - ); -}); - it("uses standard routing when the catalog has no default service tier", () => { const capabilities = mapCodexModelCapabilities({ additionalSpeedTiers: ["fast"], diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index d23e8aa70976..6fa204c41c28 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -1,11 +1,3 @@ -// Provider status probes have no filesystem service; read Codex model cache directly. -// @effect-diagnostics-next-line nodeBuiltinImport:off -import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; -// Provider status probes have no path service; resolve the active Codex home directly. -// @effect-diagnostics-next-line nodeBuiltinImport:off -import * as NodePath from "node:path"; - import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -37,10 +29,8 @@ import { codexModelFamily, createModelCapabilities, readCustomModelEntries, - supportsCodexExpandedContext, } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { formatTokens } from "@t3tools/shared/usageFormat"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; import { AUTH_PROBE_TIMEOUT_MS, @@ -87,62 +77,8 @@ export interface CodexAppServerProviderSnapshot { readonly skills: ReadonlyArray; } -const REASONING_EFFORT_LABELS: Readonly> = { - none: "None", - minimal: "Minimal", - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", - max: "Max", - ultra: "Ultra", -}; - const DEFAULT_SERVICE_TIER_ID = "default"; -const CodexModelCache = Schema.Struct({ - models: Schema.Array( - Schema.Struct({ - slug: Schema.String, - context_window: Schema.optionalKey(Schema.NullOr(Schema.Finite)), - max_context_window: Schema.optionalKey(Schema.NullOr(Schema.Finite)), - }), - ), -}); - -type CodexContextLimits = { readonly defaultTokens: number; readonly maxTokens: number }; - -const decodeCodexModelCacheJson = Schema.decodeUnknownOption( - Schema.fromJsonString(CodexModelCache), -); - -export function parseCodexModelContextLimits( - input: string, -): ReadonlyMap { - const cache = decodeCodexModelCacheJson(input); - const limits = new Map(); - if (Option.isNone(cache)) return limits; - for (const model of cache.value.models) { - const defaultTokens = model.context_window; - const maxTokens = model.max_context_window ?? defaultTokens; - if ( - typeof defaultTokens === "number" && - typeof maxTokens === "number" && - Number.isSafeInteger(defaultTokens) && - Number.isSafeInteger(maxTokens) && - defaultTokens > 0 && - maxTokens >= defaultTokens - ) { - limits.set(model.slug, { defaultTokens, maxTokens }); - } - } - return limits; -} - -function reasoningEffortLabel(reasoningEffort: string): string { - return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; -} - function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["account"]) { if (!account) return undefined; if (account.type === "apiKey") return "OpenAI API Key"; @@ -193,22 +129,7 @@ function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) export function mapCodexModelCapabilities( model: CodexSchema.V2ModelListResponse__Model, - contextLimits?: CodexContextLimits, ): ModelCapabilities { - const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => - reasoningEffort === - (codexModelFamily(model.model) === "gpt-6-astra" ? "medium" : model.defaultReasoningEffort) - ? { - id: reasoningEffort, - label: reasoningEffortLabel(reasoningEffort), - isDefault: true, - } - : { - id: reasoningEffort, - label: reasoningEffortLabel(reasoningEffort), - }, - ); - const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id; const serviceTiers = model.serviceTiers && model.serviceTiers.length > 0 ? model.serviceTiers @@ -225,50 +146,6 @@ export function mapCodexModelCapabilities( const defaultServiceTier = catalogDefaultServiceTier ?? DEFAULT_SERVICE_TIER_ID; const optionDescriptors: ProviderOptionDescriptor[] = []; - if (reasoningOptions.length > 0) { - optionDescriptors.push({ - id: "reasoningEffort", - label: "Reasoning", - type: "select", - options: reasoningOptions, - ...(defaultReasoning ? { currentValue: defaultReasoning } : {}), - }); - } - const knownExpanded = supportsCodexExpandedContext(model.model); - if ( - (contextLimits ? contextLimits.maxTokens > contextLimits.defaultTokens : knownExpanded) && - (knownExpanded || codexModelFamily(model.model).startsWith("gpt-")) - ) { - optionDescriptors.push({ - id: "contextWindow", - label: "Context Window", - type: "select", - options: [ - { - id: "default", - label: "Default", - isDefault: true, - ...(contextLimits - ? { description: `${formatTokens(contextLimits.defaultTokens)} tokens from Codex.` } - : {}), - }, - knownExpanded - ? { - id: "1m", - label: "1M", - ...(contextLimits && contextLimits.maxTokens < 1_050_000 - ? { - description: `Codex caps this at ${formatTokens(contextLimits.maxTokens)} tokens.`, - } - : {}), - } - : { - id: `expanded:${model.model}:${contextLimits!.maxTokens}`, - label: `Expanded · ${formatTokens(contextLimits!.maxTokens)}`, - }, - ], - }); - } if (serviceTiers.length > 0) { optionDescriptors.push({ id: "serviceTier", @@ -304,15 +181,14 @@ const toDisplayName = (model: CodexSchema.V2ModelListResponse__Model): string => }; function parseCodexModelListResponse( - models: ReadonlyArray, - contextLimits: ReadonlyMap, + response: CodexSchema.V2ModelListResponse, ): ReadonlyArray { - return models.map((model) => ({ + return response.data.map((model) => ({ slug: model.model, name: toDisplayName(model), isCustom: false, ...(model.isDefault ? { isDefault: true } : {}), - capabilities: mapCodexModelCapabilities(model, contextLimits.get(model.model)), + capabilities: mapCodexModelCapabilities(model), })); } @@ -411,7 +287,7 @@ function parseCodexSkillsListResponse( const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( client: CodexClient.CodexAppServerClient["Service"], ) { - const models: CodexSchema.V2ModelListResponse__Model[] = []; + const models: ServerProviderModel[] = []; let cursor: string | null | undefined = undefined; do { @@ -419,7 +295,7 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( "model/list", cursor ? { cursor } : {}, ); - models.push(...response.data); + models.push(...parseCodexModelListResponse(response)); cursor = response.nextCursor; } while (cursor); @@ -548,25 +424,13 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun ], { concurrency: "unbounded" }, ); - const home = - input.homePath?.trim() || - input.environment?.CODEX_HOME?.trim() || - process.env.CODEX_HOME?.trim() || - NodePath.join(NodeOS.homedir(), ".codex"); - const cachePath = NodePath.join(NodePath.resolve(expandHomePath(home)), "models_cache.json"); - const contextLimits = yield* Effect.tryPromise(async () => - parseCodexModelContextLimits(await NodeFSP.readFile(cachePath, "utf8")), - ).pipe(Effect.orElseSucceed(() => new Map())); return { account: accountResponse, rateLimits, version, models: applyPreferredCodexDefaultModel( - appendCustomCodexModels( - parseCodexModelListResponse(models, contextLimits), - input.customModels ?? [], - ), + appendCustomCodexModels(models, input.customModels ?? []), ), skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), } satisfies CodexAppServerProviderSnapshot; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 657238d78526..75675494f147 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -277,17 +277,15 @@ describe("buildTurnStartParams", () => { 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", () => { + it("leaves effort to Codex config when no override is given", () => { const params = Effect.runSync( buildTurnStartParams({ threadId: "provider-thread-1", @@ -299,8 +297,9 @@ describe("buildTurnStartParams", () => { 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`)); + NodeAssert.equal(settings?.reasoning_effort, undefined); + NodeAssert.ok(settings?.developer_instructions?.includes(`as ${DEFAULT_MODEL}`)); + NodeAssert.ok(!settings?.developer_instructions?.includes("reasoning effort")); }); it.effect("routes approvals to the auto reviewer in auto mode", () => @@ -889,38 +888,6 @@ describe("isRecoverableThreadResumeError", () => { }); describe("openCodexThread", () => { - for (const [choice, tokens, model] of [ - ["default", undefined, "gpt-6-astra"], - ["1m", 1_050_000, "gpt-6-astra"], - ["expanded:gpt-7:872000", 872_000, "gpt-7"], - ] as const) { - it.effect(`starts supported GPT models with the ${choice} window`, () => - Effect.gen(function* () { - let params: unknown; - yield* openCodexThread({ - client: { - request: (_method, payload) => { - params = payload; - return Effect.succeed(makeThreadOpenResponse("expanded-thread")); - }, - raw: { request: () => Effect.die("A new thread must not resume") }, - }, - threadId: ThreadId.make("thread-1"), - runtimeMode: "full-access", - cwd: "/tmp/project", - requestedModel: model, - contextWindow: choice, - serviceTier: undefined, - resumeThreadId: undefined, - }); - NodeAssert.deepEqual( - (params as { config?: unknown }).config, - tokens === undefined ? undefined : { model_context_window: tokens }, - ); - }), - ); - } - it.effect("resumes metadata when historical turns contain unknown error values", () => Effect.gen(function* () { const response = makeThreadOpenResponse("saved-thread"); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index d037b7894add..5ed529ad8b32 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -18,11 +18,7 @@ import { TurnId, } from "@t3tools/contracts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { - codexContextWindowTokens, - normalizeModelSlug, - resolveCodexContextWindowChoice, -} from "@t3tools/shared/model"; +import { normalizeModelSlug } from "@t3tools/shared/model"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -33,7 +29,6 @@ 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 Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as CodexClient from "effect-codex-app-server/client"; @@ -181,7 +176,6 @@ export interface CodexSessionRuntimeOptions { readonly cwd: string; readonly runtimeMode: RuntimeMode; readonly model?: string; - readonly contextWindow?: string | undefined; readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; readonly appServerArgs?: ReadonlyArray; @@ -196,7 +190,6 @@ export interface CodexSessionRuntimeSendTurnInput { readonly path: string; }>; readonly model?: string; - readonly contextWindow?: string | undefined; readonly serviceTier?: CodexServiceTier | undefined; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort | undefined; readonly interactionMode?: ProviderInteractionMode; @@ -553,19 +546,15 @@ function buildThreadStartParams(input: { readonly cwd: string; readonly runtimeMode: RuntimeMode; readonly model: string | undefined; - readonly contextWindow?: string | undefined; readonly serviceTier: CodexServiceTier | undefined; -}) { +}): EffectCodexSchema.V2ThreadStartParams { const config = runtimeModeToThreadConfig(input.runtimeMode); - const contextWindow = resolveCodexContextWindowChoice(input.model, input.contextWindow); - const contextWindowTokens = codexContextWindowTokens(contextWindow); return { cwd: input.cwd, approvalPolicy: config.approvalPolicy, sandbox: config.sandbox, approvalsReviewer: config.approvalsReviewer, ...(input.model ? { model: input.model } : {}), - ...(contextWindowTokens ? { config: { model_context_window: contextWindowTokens } } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), }; } @@ -601,15 +590,14 @@ function buildCodexCollaborationMode(input: { return undefined; } const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL; - const reasoningEffort = input.effort ?? "medium"; return { mode: input.interactionMode, settings: { model, - reasoning_effort: reasoningEffort, + ...(input.effort ? { reasoning_effort: input.effort } : {}), developer_instructions: buildCodexDeveloperInstructions( input.interactionMode, - { model, reasoningEffort }, + { model, ...(input.effort ? { reasoningEffort: input.effort } : {}) }, input.browserToolsAvailable ?? true, ), }, @@ -737,7 +725,6 @@ export const openCodexThread = (input: { readonly runtimeMode: RuntimeMode; readonly cwd: string; readonly requestedModel: string | undefined; - readonly contextWindow?: string | undefined; readonly serviceTier: CodexServiceTier | undefined; readonly resumeThreadId: string | undefined; }): Effect.Effect => { @@ -746,7 +733,6 @@ export const openCodexThread = (input: { cwd: input.cwd, runtimeMode: input.runtimeMode, model: input.requestedModel, - contextWindow: input.contextWindow, serviceTier: input.serviceTier, }); @@ -1392,11 +1378,6 @@ export const makeCodexSessionRuntime = ( updatedAt: sessionCreatedAt, } satisfies ProviderSession; const sessionRef = yield* Ref.make(initialSession); - const sendTurnSemaphore = yield* Semaphore.make(1); - const outstandingTurnIds = new Set(); - const completedBeforeResponse = new Set(); - let activeContextWindow = resolveCodexContextWindowChoice(options.model, options.contextWindow); - let stopEpoch = 0; const offerEvent = (event: ProviderEvent) => Queue.offer(events, event).pipe(Effect.asVoid); const emitEvent = (event: Omit) => @@ -2027,7 +2008,6 @@ export const makeCodexSessionRuntime = ( if (providerThreadId && payload.threadId !== providerThreadId) { return Effect.void; } - outstandingTurnIds.add(payload.turn.id); return updateSession(sessionRef, { status: "running", activeTurnId: TurnId.make(payload.turn.id), @@ -2042,8 +2022,6 @@ export const makeCodexSessionRuntime = ( if (providerThreadId && payload.threadId !== providerThreadId) { return Effect.void; } - outstandingTurnIds.delete(payload.turn.id); - completedBeforeResponse.add(payload.turn.id); const lastError = payload.turn.status === "failed" && "error" in payload.turn && payload.turn.error ? payload.turn.error.message @@ -2463,7 +2441,6 @@ export const makeCodexSessionRuntime = ( runtimeMode: options.runtimeMode, cwd: options.cwd, requestedModel, - contextWindow: options.contextWindow, serviceTier: options.serviceTier, resumeThreadId: readResumeCursorThreadId(options.resumeCursor), }); @@ -2520,10 +2497,9 @@ export const makeCodexSessionRuntime = ( const providerThreadId = yield* readProviderThreadId; yield* client.request("thread/compact/start", { threadId: providerThreadId }); }), - sendTurn: (input) => { - const sendEpoch = stopEpoch; - return Effect.gen(function* () { - let providerThreadId = yield* readProviderThreadId; + sendTurn: (input) => + Effect.gen(function* () { + const providerThreadId = yield* readProviderThreadId; if (hasConfiguredMcpServer(options.appServerArgs)) { yield* client.request("config/mcpServer/reload", undefined).pipe( Effect.catch((cause) => @@ -2536,41 +2512,6 @@ export const makeCodexSessionRuntime = ( const normalizedModel = normalizeCodexModelSlug( input.model ?? (yield* Ref.get(sessionRef)).model, ); - const selectedContextWindow = resolveCodexContextWindowChoice( - normalizedModel, - input.contextWindow ?? activeContextWindow ?? undefined, - ); - if ( - codexContextWindowTokens(selectedContextWindow) !== - codexContextWindowTokens(activeContextWindow) - ) { - if ((yield* Ref.get(sessionRef)).activeTurnId || outstandingTurnIds.size > 0) { - return yield* CodexErrors.CodexAppServerRequestError.invalidParams( - "Finish the current turn before changing the context window.", - ); - } - if (sendEpoch !== stopEpoch) { - return yield* CodexErrors.CodexAppServerRequestError.invalidParams( - "Turn was stopped before it started.", - ); - } - const forked = yield* client.request("thread/fork", { - threadId: providerThreadId, - ...buildThreadStartParams({ - cwd: options.cwd, - runtimeMode: options.runtimeMode, - model: normalizedModel, - contextWindow: selectedContextWindow ?? undefined, - serviceTier: input.serviceTier ?? options.serviceTier, - }), - }); - providerThreadId = forked.thread.id; - yield* updateSession(sessionRef, { - resumeCursor: { threadId: providerThreadId }, - activeTurnId: undefined, - }); - } - activeContextWindow = selectedContextWindow; const params = yield* buildTurnStartParams({ threadId: providerThreadId, runtimeMode: options.runtimeMode, @@ -2588,12 +2529,6 @@ export const makeCodexSessionRuntime = ( options.mcpCapabilities, ), }); - if (sendEpoch !== stopEpoch) { - return yield* CodexErrors.CodexAppServerRequestError.invalidParams( - "Turn was stopped before it started.", - ); - } - completedBeforeResponse.clear(); const rawResponse = yield* client.raw.request("turn/start", params); const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( Effect.mapError((error) => @@ -2605,39 +2540,12 @@ export const makeCodexSessionRuntime = ( ), ); const turnId = TurnId.make(response.turn.id); - const completedEarly = completedBeforeResponse.delete(response.turn.id); - completedBeforeResponse.clear(); - if (!completedEarly) { - outstandingTurnIds.add(response.turn.id); - } - if (sendEpoch !== stopEpoch) { - const interrupted = yield* client - .request("turn/interrupt", { threadId: providerThreadId, turnId }) - .pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - Effect.timeoutOption("3 seconds"), - ); - if (interrupted._tag === "None" || !interrupted.value) { - yield* close; - return yield* CodexErrors.CodexAppServerRequestError.invalidParams( - "Could not confirm the turn stopped; the session was closed.", - ); - } - return yield* CodexErrors.CodexAppServerRequestError.invalidParams( - "Turn was stopped before it started.", - ); - } yield* updateSession(sessionRef, (session) => ({ - ...(outstandingTurnIds.has(response.turn.id) - ? { - 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, - } - : {}), + 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)); @@ -2648,11 +2556,11 @@ export const makeCodexSessionRuntime = ( ? { resumeCursor: { threadId: resumedProviderThreadId } } : {}), } satisfies ProviderTurnStartResult; - }).pipe(sendTurnSemaphore.withPermits(1)); - }, + }), interruptTurn: (turnId) => Effect.gen(function* () { - stopEpoch += 1; + 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, @@ -2683,16 +2591,10 @@ export const makeCodexSessionRuntime = ( .pipe(Effect.timeoutOption("3 seconds"), Effect.ignore), { concurrency: 8, discard: true }, ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); - const session = yield* Ref.get(sessionRef); - const providerThreadId = currentProviderThreadId(session); - if (!providerThreadId) { - return yield* new CodexSessionRuntimeThreadIdMissingError({ - threadId: options.threadId, - }); + const effectiveTurnId = turnId ?? session.activeTurnId; + if (!effectiveTurnId) { + return; } - const effectiveTurnId = - session.activeTurnId ?? (session.status === "running" ? turnId : undefined); - if (!effectiveTurnId) return; yield* client.request("turn/interrupt", { threadId: providerThreadId, turnId: effectiveTurnId, diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 06072c498d3b..440f1558a684 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -18,9 +18,6 @@ const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); let turnStartCount = 0; let activeTurn; -let firstTurn; -let pendingForkResponse; -let pendingTurnStartResponse; // Server->client requests the runtime must answer (approval prompts), keyed // by the numeric JSON-RPC id this peer allocated for them. const openServerRequests = new Map(); @@ -98,39 +95,6 @@ rl.on("line", (line) => { write({ id, result: fixture.responses.threadStart }); return; } - if (method === "thread/fork") { - if (script.recordRequests) { - NodeFS.appendFileSync( - `${process.env.T3_CODEX_COLLAB_SCRIPT}.requests`, - `${JSON.stringify({ method, params: message.params })}\n`, - ); - } - const response = { - id, - result: { - ...fixture.responses.threadStart, - thread: { ...fixture.responses.threadStart.thread, id: "expanded-thread" }, - }, - }; - if (script.deferForkResponseUntilRead) { - pendingForkResponse = response; - write({ - jsonrpc: "2.0", - method: "serverRequest/resolved", - params: { threadId: script.rootThreadId, requestId: "fork-observed" }, - }); - return; - } - write(response); - return; - } - if (method === "thread/read" && (pendingForkResponse || pendingTurnStartResponse)) { - write(pendingForkResponse ?? pendingTurnStartResponse); - pendingForkResponse = undefined; - pendingTurnStartResponse = undefined; - write({ id, result: { thread: fixture.responses.threadStart.thread } }); - return; - } if (method === "thread/resume") { if (script.recordRequests) { NodeFS.appendFileSync( @@ -180,45 +144,14 @@ rl.on("line", (line) => { return; } if (method === "turn/start") { - if (script.recordTurnStarts) { - NodeFS.appendFileSync( - `${process.env.T3_CODEX_COLLAB_SCRIPT}.requests`, - `${JSON.stringify({ method, params: message.params })}\n`, - ); - } - if (script.hangTurnStartResponse) { - write({ - jsonrpc: "2.0", - method: "serverRequest/resolved", - params: { threadId: script.rootThreadId, requestId: "turn-start-observed" }, - }); - return; - } const turnId = script.turnIds?.[turnStartCount]; const turn = turnId ? { ...fixture.responses.turnStart.turn, id: turnId } : fixture.responses.turnStart.turn; activeTurn = turn; turnStartCount += 1; - if (turnStartCount === 1) firstTurn = turn; - if (script.deferTurnStartResponseUntilRead) { - pendingTurnStartResponse = { id, result: { ...fixture.responses.turnStart, turn } }; - write({ - jsonrpc: "2.0", - method: "serverRequest/resolved", - params: { threadId: script.rootThreadId, requestId: "turn-start-observed" }, - }); - return; - } write({ id, result: { ...fixture.responses.turnStart, turn } }); const rootThreadId = script.rootThreadId; - if (script.completeFirstTurnOnSecondStart && turnStartCount === 2) { - write({ - jsonrpc: "2.0", - method: "turn/completed", - params: { threadId: rootThreadId, turn: { ...firstTurn, status: "completed" } }, - }); - } if (script.onlyFirstTurnStarts !== true || turnStartCount === 1) { write({ jsonrpc: "2.0", diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 91c9cb94b5d5..5142e7cfb7e5 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -32,7 +32,6 @@ interface FakeCodexInput { stderr?: string; requireImage?: boolean; requireServiceTier?: string; - requireReasoningEffort?: string; forbidReasoningEffort?: boolean; requireArg?: string; forbidArg?: string; @@ -48,7 +47,6 @@ function makeFakeCodexBinary(dir: string, input: FakeCodexInput) { const check = JSON.stringify({ requireImage: input.requireImage ?? false, requireServiceTier: input.requireServiceTier ?? null, - requireReasoningEffort: input.requireReasoningEffort ?? null, forbidReasoningEffort: input.forbidReasoningEffort ?? false, requireArg: input.requireArg ?? null, forbidArg: input.forbidArg ?? null, @@ -106,12 +104,6 @@ function makeFakeCodexBinary(dir: string, input: FakeCodexInput) { ") {", ' fail("unexpected service tier config: " + seenServiceTier, 5);', "}", - "if (", - " check.requireReasoningEffort !== null &&", - ' seenReasoningEffort !== `model_reasoning_effort="${check.requireReasoningEffort}"`', - ") {", - ' fail("unexpected reasoning effort config: " + seenReasoningEffort, 6);', - "}", "if (check.forbidReasoningEffort && seenReasoningEffort.length > 0) {", ' fail("reasoning effort config should be omitted: " + seenReasoningEffort, 7);', "}", @@ -209,31 +201,29 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { ), ); - it.effect( - "forwards codex service tier and non-default reasoning effort into codex exec config", - () => - withFakeCodexEnv( - { - output: JSON.stringify({ - subject: "Add important change", - body: "", - }), - requireServiceTier: "priority", - requireReasoningEffort: "xhigh", - stdinMustNotContain: "branch must be a short semantic git branch fragment", - }, - (textGeneration) => - textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ - { id: "reasoningEffort", value: "xhigh" }, - { id: "serviceTier", value: "priority" }, - ]), - }), - ), + it.effect("forwards Codex service tier but leaves effort to config", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add important change", + body: "", + }), + requireServiceTier: "priority", + forbidReasoningEffort: true, + stdinMustNotContain: "branch must be a short semantic git branch fragment", + }, + (textGeneration) => + textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "xhigh" }, + { id: "serviceTier", value: "priority" }, + ]), + }), + ), ); it.effect("passes exec-safe launch args into codex exec", () => @@ -281,14 +271,14 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { ), ); - it.effect("defaults git text generation codex effort to low", () => + it.effect("leaves Git text generation effort to Codex config", () => withFakeCodexEnv( { output: JSON.stringify({ subject: "Add important change", body: "", }), - requireReasoningEffort: "low", + forbidReasoningEffort: true, }, (textGeneration) => textGeneration.generateCommitMessage({ diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 4c9ac59d8422..5f3991b371ad 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -9,7 +9,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { type CodexSettings, - DEFAULT_TEXT_GENERATION_REASONING_EFFORT, type ModelSelection, type ServerProviderModel, TextGenerationError, @@ -35,7 +34,7 @@ import { sanitizeThreadTitle, toJsonSchemaObject, } from "./TextGenerationUtils.ts"; -import { codexModelFamily, getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { codexModelFamily } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts"; const CODEX_TIMEOUT_MS = 180_000; @@ -189,9 +188,6 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func )?.slug ?? requestedModel; const launchArgs = resolveCodexLaunchArgs(codexConfig.launchArgs, resolvedEnvironment); - const reasoningEffort = - getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? - DEFAULT_TEXT_GENERATION_REASONING_EFFORT; const serviceTier = getCodexServiceTierOptionValue(modelSelection); const spawnCommand = yield* resolveSpawnCommand( codexConfig.binaryPath || "codex", @@ -204,8 +200,6 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func "read-only", "--model", model, - "--config", - `model_reasoning_effort="${reasoningEffort}"`, ...(serviceTier ? ["--config", `service_tier="${serviceTier}"`] : []), "--output-schema", schemaPath, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8778580a88f7..b377439882d5 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -279,7 +279,6 @@ import { ContextWindowMeter, ContextWindowMeterPlaceholder } from "./ContextWind import { providerSupportsManualCompaction, resolveContextWindowModelDisplayName, - sameContextWindowSelection, shouldReserveContextWindowMeter, } from "./ContextWindowMeter.logic"; import { @@ -2080,12 +2079,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), [activeThreadModelSelection, modelOptionsByInstance], ); - const displayedContextWindow = sameContextWindowSelection( - activeThreadModelSelection, - selectedModelSelection, - ) - ? activeContextWindow - : null; const reserveContextWindowMeter = shouldReserveContextWindowMeter({ meterEnabled: settings.contextWindowMeterEnabled, detailLoading: props.threadSyncPhase === "loading", @@ -6803,7 +6796,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) "relative", isComposerResting && "flex min-w-0 items-center gap-1", isComposerResting && - ((settings.contextWindowMeterEnabled && displayedContextWindow) || + ((settings.contextWindowMeterEnabled && activeContextWindow) || reserveContextWindowMeter ? "pr-28" : showComposerAttachAction @@ -7020,7 +7013,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { - const active = { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-opus-5-5", - options: [{ id: "contextWindow", value: "200k" }], - }; - - it("hides old usage when the model or context window changes", () => { - expect( - sameContextWindowSelection(active, { - ...active, - options: [{ id: "contextWindow", value: "1m" }], - }), - ).toBe(false); - expect(sameContextWindowSelection(active, { ...active, model: "claude-opus-5" })).toBe(false); - }); - - it("keeps usage for a reasoning-only change", () => { - expect( - sameContextWindowSelection(active, { - ...active, - options: [...active.options, { id: "effort", value: "high" }], - }), - ).toBe(true); - }); - - it("treats omitted and explicit default windows as the same choice", () => { - const selected = { ...active, model: "gpt-6-astra", options: [] }; - const explicit = { ...selected, options: [{ id: "contextWindow", value: "default" }] }; - expect(sameContextWindowSelection(selected, explicit)).toBe(true); - expect(sameContextWindowSelection(explicit, selected)).toBe(true); - }); -}); - function claudeProvider(input: { instanceId: string; continuationGroupKey: string; diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index 8e32fcaecf66..a04582a0426d 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -1,5 +1,4 @@ import type { ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { CLAUDE_RESUME_COMPACTION_NEVER_ANSWER, isClaudeResumeCompactionQuestion, @@ -13,18 +12,6 @@ import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils const CLAUDE_RESUME_COMPACTION_MINUTES = 70; const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; -export function sameContextWindowSelection( - active: ModelSelection | null | undefined, - selected: ModelSelection, -): boolean { - return ( - active?.instanceId === selected.instanceId && - active.model === selected.model && - (getModelSelectionStringOptionValue(active, "contextWindow") ?? "default") === - (getModelSelectionStringOptionValue(selected, "contextWindow") ?? "default") - ); -} - export function providerSupportsManualCompaction( provider: ProviderInstanceEntry | null | undefined, ): boolean { diff --git a/docs/user/composer.md b/docs/user/composer.md index 6e699d0669c1..ad2aa94fffea 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -69,7 +69,10 @@ T3 Code remembers your provider, model, and model options for new threads. A project's configured model takes precedence; resetting that project setting returns to the remembered selection. -Leaving reasoning level or service tier unset uses the provider's own configuration. +For Codex, set reasoning effort and context length in `~/.codex/config.toml`. For +Claude Code, use `~/.claude/settings.json` for effort and its `env` settings for +1M context support. T3 Code shows the context window reported by the provider. +Leaving service tier unset uses the provider's configuration. ## Quote an assistant response diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index ad1b157bbb75..7f827cc06694 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -15,7 +15,7 @@ use `mod+Shift+Enter` for the opposite action. In a new thread, `mod+Enter` keep starting the thread in the background. Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. -Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the +Use `mod+shift+e` for available model traits, `mod+shift+a` for access mode, `mod+shift+x` for the workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the current checkout, a new worktree, and the previous worktree when available. Use `mod+shift+l` to reuse the previous worktree directly. diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index cce194dbd2b4..dc748ab7873b 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -93,7 +93,7 @@ list, open that Claude instance in **Settings > Providers** and add its full mod ID with **Add custom model**. Then select it in the chat model picker. `ANTHROPIC_DEFAULT_*_MODEL` variables map Claude Code aliases such as `sonnet`; they do not replace the explicit model ID selected in T3 Code. Custom models may have -fewer effort, thinking, or context controls than built-in models. +fewer T3 Code controls than built-in models. Verify the model used in OpenRouter's activity dashboard. For current compatibility requirements, use the diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 31e5f9d63305..b480e343e373 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -164,7 +164,6 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray = [ export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-6-luna"; /** Keep the official Antigravity session's current model. Never send this ID to ACP. */ export const ANTIGRAVITY_DEFAULT_MODEL = "antigravity-default"; -export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low"; export const DEFAULT_MODEL_BY_PROVIDER: Partial> = { [CODEX_DRIVER_KIND]: DEFAULT_MODEL, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3e301201910c..95a67712e82c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -17,7 +17,6 @@ import { KeybindingShortcut } from "./keybindings.ts"; import { CustomModelSetting, DEFAULT_TEXT_GENERATION_MODEL, - DEFAULT_TEXT_GENERATION_REASONING_EFFORT, ProviderOptionSelections, } from "./model.ts"; import { @@ -1227,12 +1226,7 @@ export const ServerSettings = Schema.Struct({ Effect.succeed({ instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_TEXT_GENERATION_MODEL, - options: [ - { - id: "reasoningEffort", - value: DEFAULT_TEXT_GENERATION_REASONING_EFFORT, - }, - ], + options: [], }), ), ), diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index ab6cde59ef02..d2c64e3d9458 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -7,27 +7,15 @@ import { buildProviderOptionSelectionsFromDescriptors, createModelCapabilities, createModelSelection, - codexContextWindowTokens, getModelSelectionBooleanOptionValue, getModelSelectionStringOptionValue, getProviderOptionDescriptors, readCustomModelEntries, - resolveCodexContextWindowChoice, toCustomModelSetting, getProviderOptionBooleanSelectionValue, getProviderOptionStringSelectionValue, } from "./model.ts"; -it("keeps Codex expanded choices tied to their model and exact provider limit", () => { - const choice = "expanded:gpt-7:872000"; - expect(resolveCodexContextWindowChoice("gpt-7", choice)).toBe(choice); - expect(codexContextWindowTokens(choice)).toBe(872_000); - expect(resolveCodexContextWindowChoice("gpt-8", choice)).toBeNull(); - expect(resolveCodexContextWindowChoice("gpt-7", "expanded:gpt-7:NaN")).toBeNull(); - expect(resolveCodexContextWindowChoice("gpt-7", "default")).toBe("default"); - expect(codexContextWindowTokens("default")).toBeUndefined(); -}); - const codexCaps: ModelCapabilities = createModelCapabilities({ optionDescriptors: [ { diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 02111e25f691..b3c278a1ac36 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -230,31 +230,6 @@ export function codexModelFamily(slug: string): string { return slug.startsWith("openai.gpt-") ? slug.slice("openai.".length) : slug; } -export function supportsCodexExpandedContext(slug: string): boolean { - return /^(?:gpt-6-(?:astra|sol|luna)|gpt-5\.(?:4|5|6(?:-(?:sol|terra|luna))?))$/.test( - codexModelFamily(slug), - ); -} - -export function resolveCodexContextWindowChoice( - model: string | undefined, - choice: string | undefined, -): string | null { - if (!model) return null; - if (choice === "default") return choice; - if (choice === "1m") return supportsCodexExpandedContext(model) ? choice : null; - const prefix = `expanded:${model}:`; - if (!choice?.startsWith(prefix)) return null; - const tokens = Number(choice.slice(prefix.length)); - return Number.isSafeInteger(tokens) && tokens > 0 ? choice : null; -} - -export function codexContextWindowTokens(choice: string | null): number | undefined { - if (choice === "1m") return 1_050_000; - if (!choice?.startsWith("expanded:")) return undefined; - return Number(choice.slice(choice.lastIndexOf(":") + 1)); -} - export function normalizeModelSlug( model: string | null | undefined, provider: ProviderDriverKind = DEFAULT_PROVIDER_DRIVER_KIND, From 36d1bc455c66be33a74ad4b3f378ab3590e7faf6 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:10:56 +0200 Subject: [PATCH 14/14] fix(codex): stop the active turn before queued follow-ups --- .../Layers/CodexCollabRuntime.integration.test.ts | 9 +++++++-- apps/server/src/provider/Layers/CodexSessionRuntime.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 2a9fb56c186a..cdc3b4636947 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -14,7 +14,12 @@ 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 { + type ProviderApprovalDecision, + type ProviderEvent, + ThreadId, + TurnId, +} from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -741,7 +746,7 @@ describe("CodexSessionRuntime collab integration", () => { yield* runtime.start(); yield* runtime.sendTurn({ input: "keep working" }); yield* runtime.sendTurn({ input: "queued follow-up" }); - yield* runtime.interruptTurn(); + yield* runtime.interruptTurn(TurnId.make(queuedTurnId)); const interrupts = NodeFS.readFileSync(interruptsPath, "utf8") .trim() diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 5ed529ad8b32..120164c3e2f5 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -2591,7 +2591,7 @@ export const makeCodexSessionRuntime = ( .pipe(Effect.timeoutOption("3 seconds"), Effect.ignore), { concurrency: 8, discard: true }, ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); - const effectiveTurnId = turnId ?? session.activeTurnId; + const effectiveTurnId = session.activeTurnId ?? turnId; if (!effectiveTurnId) { return; }