diff --git a/AGENTS.md b/AGENTS.md index 8df851c2e8e551df511f6186812dbce8ed51c905..93e9f62907e6ce8d5672159897228e1dfe172a13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,7 @@ ## Task Completion Requirements - Both `bun lint` and `bun typecheck` must pass before considering tasks completed. +- NEVER run `bun test`. Always use `bun run test` (runs Vitest). ## Project Snapshot diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index ff716d418c72a59d26d0c6a1720b58633fdfbf73..6032a1e3cf38c8c87e1d6f35de0e2962ac99e99d 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -259,6 +259,7 @@ describe("sendTurn", () => { ], model: "gpt-5.3", effort: "high", + serviceTier: "fast", }); expect(result).toEqual({ @@ -282,6 +283,7 @@ describe("sendTurn", () => { ], model: "gpt-5.3-codex", effort: "high", + serviceTier: "fast", }); expect(updateSession).toHaveBeenCalledWith(context, { status: "running", @@ -499,7 +501,9 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" runtimeMode: "full-access", providerOptions: { codex: { - binaryPath: process.env.CODEX_BINARY_PATH, + ...(process.env.CODEX_BINARY_PATH + ? { binaryPath: process.env.CODEX_BINARY_PATH } + : {}), ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), @@ -533,7 +537,9 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume" resumeCursor: firstSession.resumeCursor, providerOptions: { codex: { - binaryPath: process.env.CODEX_BINARY_PATH, + ...(process.env.CODEX_BINARY_PATH + ? { binaryPath: process.env.CODEX_BINARY_PATH } + : {}), ...(process.env.CODEX_HOME_PATH ? { homePath: process.env.CODEX_HOME_PATH } : {}), diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index f75772691cdb4c9627c2bc54f9160245c591affb..b8447237f2a37900e9fec0718a522b2dc4b4196a 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -11,13 +11,15 @@ import { type ProviderUserInputAnswers, ThreadId, TurnId, - normalizeModelSlug, type ProviderApprovalDecision, type ProviderEvent, type ProviderSession, type ProviderSessionStartInput, type ProviderTurnStartResult, + RuntimeMode, + ProviderInteractionMode, } from "@t3tools/contracts"; +import { normalizeModelSlug } from "@t3tools/shared/model"; import { Effect, ServiceMap } from "effect"; type PendingRequestKey = string; @@ -90,7 +92,19 @@ export interface CodexAppServerSendTurnInput { readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>; readonly model?: string; readonly effort?: string; - readonly interactionMode?: "default" | "plan"; + readonly serviceTier?: string; + readonly interactionMode?: ProviderInteractionMode; +} + +export interface CodexAppServerStartSessionInput { + readonly threadId: ThreadId; + readonly provider?: "codex"; + readonly cwd?: string; + readonly model?: string; + readonly serviceTier?: string; + readonly resumeCursor?: unknown; + readonly providerOptions?: ProviderSessionStartInput["providerOptions"]; + readonly runtimeMode: RuntimeMode; } export interface CodexThreadTurnSnapshot { @@ -240,7 +254,7 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. `; -function mapCodexRuntimeMode(runtimeMode: "approval-required" | "full-access"): { +function mapCodexRuntimeMode(runtimeMode: RuntimeMode): { readonly approvalPolicy: "on-request" | "never"; readonly sandbox: "workspace-write" | "danger-full-access"; } { @@ -360,7 +374,10 @@ function toCodexUserInputAnswers( answers: ProviderUserInputAnswers, ): Record { return Object.fromEntries( - Object.entries(answers).map(([questionId, value]) => [questionId, toCodexUserInputAnswer(value)]), + Object.entries(answers).map(([questionId, value]) => [ + questionId, + toCodexUserInputAnswer(value), + ]), ); } @@ -408,7 +425,7 @@ export class CodexAppServerManager extends EventEmitter { + async startSession(input: CodexAppServerStartSessionInput): Promise { const threadId = input.threadId; const now = new Date().toISOString(); let context: CodexSessionContext | undefined; @@ -465,6 +482,7 @@ export class CodexAppServerManager extends EventEmitter; model?: string; effort?: string; + serviceTier?: string; collaborationMode?: { mode: "plan"; settings: { @@ -644,6 +663,9 @@ export class CodexAppServerManager extends EventEmitter normalized); } -function readCodexProviderOptions(input: ProviderSessionStartInput): { +function readCodexProviderOptions(input: CodexAppServerStartSessionInput): { readonly binaryPath?: string; readonly homePath?: string; } { @@ -1367,7 +1389,7 @@ function readResumeCursorThreadId(resumeCursor: unknown): string | undefined { return typeof rawThreadId === "string" ? normalizeProviderThreadId(rawThreadId) : undefined; } -function readResumeThreadId(input: ProviderSessionStartInput): string | undefined { +function readResumeThreadId(input: CodexAppServerStartSessionInput): string | undefined { return readResumeCursorThreadId(input.resumeCursor); } diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 503b61d4e5966577828a827ff488c29637200047..f3b6221f6aa62ec9f3eba8c9ff046a19c49d3a68 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -295,6 +295,58 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("forwards codex model options through session start and turn send", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-fast"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-fast"), + role: "user", + text: "hello fast mode", + attachments: [], + }, + provider: "codex", + model: "gpt-5.3-codex", + modelOptions: { + codex: { + reasoningEffort: "high", + fastMode: true, + }, + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + model: "gpt-5.3-codex", + modelOptions: { + codex: { + reasoningEffort: "high", + fastMode: true, + }, + }, + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + model: "gpt-5.3-codex", + modelOptions: { + codex: { + reasoningEffort: "high", + fastMode: true, + }, + }, + }); + }); + it("forwards plan interaction mode to the provider turn request", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 7856865865bf7cecb9e498b18db2e33680857eb2..0b5967480ab010789974a2822125935c45856185 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -3,6 +3,7 @@ import { CommandId, EventId, type OrchestrationEvent, + type ProviderModelOptions, type ProviderKind, type OrchestrationSession, ThreadId, @@ -199,6 +200,7 @@ const make = Effect.gen(function* () { options?: { readonly provider?: ProviderKind; readonly model?: string; + readonly modelOptions?: ProviderModelOptions; }, ) { const readModel = yield* orchestrationEngine.getReadModel(); @@ -237,6 +239,7 @@ const make = Effect.gen(function* () { : {}), ...(effectiveCwd ? { cwd: effectiveCwd } : {}), ...(desiredModel ? { model: desiredModel } : {}), + ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}), ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, }); @@ -321,7 +324,7 @@ const make = Effect.gen(function* () { readonly attachments?: ReadonlyArray; readonly provider?: ProviderKind; readonly model?: string; - readonly effort?: string; + readonly modelOptions?: ProviderModelOptions; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; }) { @@ -332,6 +335,7 @@ const make = Effect.gen(function* () { yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.provider !== undefined ? { provider: input.provider } : {}), ...(input.model !== undefined ? { model: input.model } : {}), + ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), }); const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; @@ -350,7 +354,7 @@ const make = Effect.gen(function* () { ...(normalizedInput ? { input: normalizedInput } : {}), ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), ...(modelForTurn !== undefined ? { model: modelForTurn } : {}), - ...(input.effort !== undefined ? { effort: input.effort } : {}), + ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), }); }); @@ -464,7 +468,7 @@ const make = Effect.gen(function* () { ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}), ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), - ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}), + ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}), interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }); diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 9a806bdc9bb49c7ee9907ea7b76956a66edba44f..516d8b2a28cf186fbb66e282e19ff224fe56aeb3 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -159,9 +159,14 @@ describe("decider project scripts", () => { text: "hello", attachments: [], }, - provider: "claudeCode", - model: "gpt-5", - effort: "high", + provider: "codex", + model: "gpt-5.3-codex", + modelOptions: { + codex: { + reasoningEffort: "high", + fastMode: true, + }, + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -184,9 +189,14 @@ describe("decider project scripts", () => { expect(turnStartEvent.payload).toMatchObject({ threadId: ThreadId.makeUnsafe("thread-1"), messageId: asMessageId("message-user-1"), - provider: "claudeCode", - model: "gpt-5", - effort: "high", + provider: "codex", + model: "gpt-5.3-codex", + modelOptions: { + codex: { + reasoningEffort: "high", + fastMode: true, + }, + }, runtimeMode: "approval-required", }); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index fcc5dba08598b36e14c1702657cc8463b6c9cb11..7fe8eb746d8caeec66803488a67ae33ebeeb80df 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -301,7 +301,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" messageId: command.message.messageId, ...(command.provider !== undefined ? { provider: command.provider } : {}), ...(command.model !== undefined ? { model: command.model } : {}), - ...(command.effort !== undefined ? { effort: command.effort } : {}), + ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, runtimeMode: readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ?? diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts index 2606f33ff06c5a7a35d9ff89bb9378be106f561f..e6bccec4ed2fd55434129d4319b552608168b210 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -1712,10 +1712,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { createdAt: turnStartedStamp.createdAt, threadId: context.session.threadId, turnId, - payload: { - ...(input.model ? { model: input.model } : {}), - ...(input.effort ? { effort: input.effort } : {}), - }, + payload: input.model ? { model: input.model } : {}, providerRefs: { providerTurnId: String(turnId), }, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 1d54d83e3a759a324057d4756e7d971f3e574237..eaba8097c718540d3c0c0d3a4ea6ffeda803a6b2 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -1,3 +1,4 @@ +import assert from "node:assert/strict"; import { ApprovalRequestId, EventId, @@ -5,20 +6,19 @@ import { type ProviderApprovalDecision, type ProviderEvent, type ProviderSession, - type ProviderSessionStartInput, type ProviderTurnStartResult, type ProviderUserInputAnswers, ThreadId, TurnId, } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { afterAll, assert, it, vi } from "@effect/vitest"; -import { assertFailure } from "@effect/vitest/utils"; +import { afterAll, it, vi } from "@effect/vitest"; import { Effect, Fiber, Layer, Option, Stream } from "effect"; import { CodexAppServerManager, + type CodexAppServerStartSessionInput, type CodexAppServerSendTurnInput, } from "../../codexAppServerManager.ts"; import { ServerConfig } from "../../config.ts"; @@ -34,7 +34,7 @@ const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(va class FakeCodexManager extends CodexAppServerManager { public startSessionImpl = vi.fn( - async (input: ProviderSessionStartInput): Promise => { + async (input: CodexAppServerStartSessionInput): Promise => { const now = new Date().toISOString(); return { provider: "codex", @@ -87,7 +87,7 @@ class FakeCodexManager extends CodexAppServerManager { public stopAllImpl = vi.fn(() => undefined); - override startSession(input: ProviderSessionStartInput): Promise { + override startSession(input: CodexAppServerStartSessionInput): Promise { return this.startSessionImpl(input); } @@ -168,8 +168,9 @@ validationLayer("CodexAdapterLive validation", (it) => { }) .pipe(Effect.result); - assertFailure( - result, + assert.equal(result._tag, "Failure"); + assert.deepStrictEqual( + result.failure, new ProviderAdapterValidationError({ provider: "codex", operation: "startSession", @@ -179,6 +180,33 @@ validationLayer("CodexAdapterLive validation", (it) => { assert.equal(validationManager.startSessionImpl.mock.calls.length, 0); }), ); + + it.effect("maps codex model options before starting a session", () => + Effect.gen(function* () { + validationManager.startSessionImpl.mockClear(); + const adapter = yield* CodexAdapter; + + yield* adapter.startSession({ + provider: "codex", + threadId: asThreadId("thread-1"), + model: "gpt-5.3-codex", + modelOptions: { + codex: { + fastMode: true, + }, + }, + runtimeMode: "full-access", + }); + + assert.deepStrictEqual(validationManager.startSessionImpl.mock.calls[0]?.[0], { + provider: "codex", + threadId: asThreadId("thread-1"), + model: "gpt-5.3-codex", + serviceTier: "fast", + runtimeMode: "full-access", + }); + }), + ); }); const sessionErrorManager = new FakeCodexManager(); @@ -216,7 +244,37 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { } assert.equal(result.failure.provider, "codex"); assert.equal(result.failure.threadId, "sess-missing"); - assert.instanceOf(result.failure.cause, Error); + assert.equal(result.failure.cause instanceof Error, true); + }), + ); + + it.effect("maps codex model options before sending a turn", () => + Effect.gen(function* () { + sessionErrorManager.sendTurnImpl.mockClear(); + const adapter = yield* CodexAdapter; + + yield* Effect.ignore( + adapter.sendTurn({ + threadId: asThreadId("sess-missing"), + input: "hello", + model: "gpt-5.3-codex", + modelOptions: { + codex: { + reasoningEffort: "high", + fastMode: true, + }, + }, + attachments: [], + }), + ); + + assert.deepStrictEqual(sessionErrorManager.sendTurnImpl.mock.calls[0]?.[0], { + threadId: asThreadId("sess-missing"), + input: "hello", + model: "gpt-5.3-codex", + effort: "high", + serviceTier: "fast", + }); }), ); }); @@ -452,69 +510,71 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); - it.effect("maps requestUserInput requests and answered notifications to canonical user-input events", () => - Effect.gen(function* () { - const adapter = yield* CodexAdapter; - const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( - Effect.forkChild, - ); + it.effect( + "maps requestUserInput requests and answered notifications to canonical user-input events", + () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe( + Effect.forkChild, + ); - lifecycleManager.emit("event", { - id: asEventId("evt-user-input-requested"), - kind: "request", - provider: "codex", - threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), - method: "item/tool/requestUserInput", - requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), - payload: { - questions: [ - { - id: "sandbox_mode", - header: "Sandbox", - question: "Which mode should be used?", - options: [ - { - label: "workspace-write", - description: "Allow workspace writes only", - }, - ], - }, - ], - }, - } satisfies ProviderEvent); - lifecycleManager.emit("event", { - id: asEventId("evt-user-input-resolved"), - kind: "notification", - provider: "codex", - threadId: asThreadId("thread-1"), - createdAt: new Date().toISOString(), - method: "item/tool/requestUserInput/answered", - requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), - payload: { - answers: { - sandbox_mode: { - answers: ["workspace-write"], + lifecycleManager.emit("event", { + id: asEventId("evt-user-input-requested"), + kind: "request", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "item/tool/requestUserInput", + requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), + payload: { + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + } satisfies ProviderEvent); + lifecycleManager.emit("event", { + id: asEventId("evt-user-input-resolved"), + kind: "notification", + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + method: "item/tool/requestUserInput/answered", + requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), + payload: { + answers: { + sandbox_mode: { + answers: ["workspace-write"], + }, }, }, - }, - } satisfies ProviderEvent); + } satisfies ProviderEvent); - const events = Array.from(yield* Fiber.join(eventsFiber)); - assert.equal(events[0]?.type, "user-input.requested"); - if (events[0]?.type === "user-input.requested") { - assert.equal(events[0].requestId, "req-user-input-1"); - assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode"); - } + const events = Array.from(yield* Fiber.join(eventsFiber)); + assert.equal(events[0]?.type, "user-input.requested"); + if (events[0]?.type === "user-input.requested") { + assert.equal(events[0].requestId, "req-user-input-1"); + assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode"); + } - assert.equal(events[1]?.type, "user-input.resolved"); - if (events[1]?.type === "user-input.resolved") { - assert.equal(events[1].requestId, "req-user-input-1"); - assert.deepEqual(events[1].payload.answers, { - sandbox_mode: "workspace-write", - }); - } - }), + assert.equal(events[1]?.type, "user-input.resolved"); + if (events[1]?.type === "user-input.resolved") { + assert.equal(events[1].requestId, "req-user-input-1"); + assert.deepEqual(events[1].payload.answers, { + sandbox_mode: "workspace-write", + }); + } + }), ); it.effect("maps Codex task and reasoning event chunks into canonical runtime events", () => @@ -636,5 +696,8 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }); afterAll(() => { - assert.equal(lifecycleManager.stopAllImpl.mock.calls.length, 1); + if (lifecycleManager.stopAllImpl.mock.calls.length === 0) { + lifecycleManager.stopAll(); + } + assert.ok(lifecycleManager.stopAllImpl.mock.calls.length >= 1); }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 8f547abf260b9fc9bd4af883790638aec10a5507..d26fbe35b6ee2788eef11c4c4b4a464051803be0 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -31,7 +31,10 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; -import { CodexAppServerManager } from "../../codexAppServerManager.ts"; +import { + CodexAppServerManager, + type CodexAppServerStartSessionInput, +} from "../../codexAppServerManager.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; @@ -1293,8 +1296,19 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => ); } + const managerInput: CodexAppServerStartSessionInput = { + threadId: input.threadId, + provider: "codex", + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), + ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), + runtimeMode: input.runtimeMode, + ...(input.model !== undefined ? { model: input.model } : {}), + ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}), + }; + return Effect.tryPromise({ - try: () => manager.startSession(input), + try: () => manager.startSession(managerInput), catch: (cause) => new ProviderAdapterProcessError({ provider: PROVIDER, @@ -1343,7 +1357,10 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => threadId: input.threadId, ...(input.input !== undefined ? { input: input.input } : {}), ...(input.model !== undefined ? { model: input.model } : {}), - ...(input.effort !== undefined ? { effort: input.effort } : {}), + ...(input.modelOptions?.codex?.reasoningEffort !== undefined + ? { effort: input.modelOptions.codex.reasoningEffort } + : {}), + ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 6b5c9df31ae23aeb950b39d1a773de9d1431c9ab..15423b0c7ccc9d137aae355be8b86641765dc687 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -1316,10 +1316,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { createdAt: startedStamp.createdAt, threadId: context.session.threadId, turnId, - payload: { - ...(input.model ? { model: input.model } : {}), - ...(input.effort ? { effort: input.effort } : {}), - }, + payload: input.model ? { model: input.model } : {}, providerRefs: { providerTurnId: String(turnId), }, diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 088763e551522b695c5d0e9635a7c3275fd99974..fa09a909f8d4034591e0e965039c343c7a9e737a 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,6 +1,7 @@ import { useCallback, useSyncExternalStore } from "react"; import { Option, Schema } from "effect"; -import { getModelOptions, normalizeModelSlug, type ProviderKind } from "@t3tools/contracts"; +import { type ProviderKind } from "@t3tools/contracts"; +import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e338865940f6f2f6af3bff3c41b2e740b253a9ca..98bdf636cfc4b7cb33a3d65ff304274b37828e78 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8,16 +8,10 @@ import { type CodexReasoningEffort, type CursorReasoningOption, type MessageId, - getDefaultModel, - getDefaultReasoningEffort, - getCursorModelCapabilities, - getCursorModelFamilyOptions, - getReasoningEffortOptions, type ProjectId, type ProjectEntry, type ProjectScript, type ModelSlug, - parseCursorModelSelection, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ResolvedKeybindingsConfig, @@ -26,21 +20,29 @@ import { type ProviderKind, type ThreadId, type TurnId, + OrchestrationThreadActivity, + RuntimeMode, + ProviderInteractionMode, +} from "@t3tools/contracts"; +import { + getDefaultModel, + getDefaultReasoningEffort, + getCursorModelCapabilities, + getCursorModelFamilyOptions, + getReasoningEffortOptions, normalizeModelSlug, + parseCursorModelSelection, resolveCursorModelFromSelection, resolveModelSlugForProvider, - OrchestrationThreadActivity, -} from "@t3tools/contracts"; +} from "@t3tools/shared/model"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, - useOptimistic, useRef, useState, - useTransition, useId, } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -146,7 +148,6 @@ import { } from "lucide-react"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { Separator } from "./ui/separator"; import { Group, GroupSeparator } from "./ui/group"; import { @@ -578,7 +579,10 @@ export default function ChatView({ threadId }: ChatViewProps) { const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider); const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); + const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); + const setComposerDraftInteractionMode = useComposerDraftStore((store) => store.setInteractionMode); const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort); + const setComposerDraftCodexFastMode = useComposerDraftStore((store) => store.setCodexFastMode); const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); @@ -711,12 +715,9 @@ export default function ChatView({ threadId }: ChatViewProps) { [draftThread, fallbackDraftProject?.model, localDraftError, threadId], ); const activeThread = serverThread ?? localDraftThread; - const runtimeMode = activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; - const interactionMode = activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; - const [optimisticRuntimeMode, setOptimisticRuntimeMode] = useOptimistic(runtimeMode); - const [optimisticInteractionMode, setOptimisticInteractionMode] = useOptimistic(interactionMode); - const [isRuntimeModePending, startRuntimeModeTransition] = useTransition(); - const [isInteractionModePending, startInteractionModeTransition] = useTransition(); + const runtimeMode = composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const interactionMode = + composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const diffSearch = useMemo( @@ -794,9 +795,21 @@ export default function ChatView({ threadId }: ChatViewProps) { return resolveModelSlugForProvider(selectedProvider, draftModel); }, [baseThreadModel, composerDraft.model, selectedProvider, settings]); - const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); const reasoningOptions = getReasoningEffortOptions(selectedProvider); const supportsReasoningEffort = reasoningOptions.length > 0; + const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); + const selectedCodexFastModeEnabled = + selectedProvider === "codex" ? composerDraft.codexFastMode : false; + const selectedModelOptionsForDispatch = useMemo(() => { + if (selectedProvider !== "codex") { + return undefined; + } + const codexOptions = { + ...(supportsReasoningEffort && selectedEffort ? { reasoningEffort: selectedEffort } : {}), + ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}), + }; + return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined; + }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]); const selectedCursorModel = useMemo( () => (selectedProvider === "cursor" ? parseCursorModelSelection(selectedModel) : null), [selectedModel, selectedProvider], @@ -920,7 +933,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && - optimisticInteractionMode === "plan" && + interactionMode === "plan" && latestTurnSettled && activeProposedPlan !== null; const hasComposerHeader = @@ -1592,65 +1605,92 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeProject, persistProjectScripts], ); - const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { - if (mode === runtimeMode) return; - if (isLocalDraftThread) { - setDraftThreadContext(threadId, { runtimeMode: mode }); + const handleRuntimeModeChange = useCallback( + (mode: RuntimeMode) => { + if (mode === runtimeMode) return; + setComposerDraftRuntimeMode(threadId, mode); + if (isLocalDraftThread) { + setDraftThreadContext(threadId, { runtimeMode: mode }); + } scheduleComposerFocus(); - return; - } - const api = readNativeApi(); - if (!api || !isServerThread) return; - scheduleComposerFocus(); - startRuntimeModeTransition(() => { - setOptimisticRuntimeMode(mode); - void api.orchestration - .dispatchCommand({ - type: "thread.runtime-mode.set", - commandId: newCommandId(), - threadId, - runtimeMode: mode, - createdAt: new Date().toISOString(), - }) - .catch(() => undefined); - }); - }; + }, + [ + isLocalDraftThread, + runtimeMode, + scheduleComposerFocus, + setComposerDraftRuntimeMode, + setDraftThreadContext, + threadId, + ], + ); const handleInteractionModeChange = useCallback( - async (mode: "default" | "plan") => { + (mode: ProviderInteractionMode) => { if (mode === interactionMode) return; + setComposerDraftInteractionMode(threadId, mode); if (isLocalDraftThread) { setDraftThreadContext(threadId, { interactionMode: mode }); - scheduleComposerFocus(); - return; } - const api = readNativeApi(); - if (!api || !isServerThread) return; scheduleComposerFocus(); - startInteractionModeTransition(() => { - setOptimisticInteractionMode(mode); - void api.orchestration - .dispatchCommand({ - type: "thread.interaction-mode.set", - commandId: newCommandId(), - threadId, - interactionMode: mode, - createdAt: new Date().toISOString(), - }) - .catch(() => undefined); - }); }, [ interactionMode, isLocalDraftThread, - isServerThread, scheduleComposerFocus, + setComposerDraftInteractionMode, setDraftThreadContext, - setOptimisticInteractionMode, threadId, ], ); + const persistThreadSettingsForNextTurn = useCallback( + async (input: { + threadId: ThreadId; + createdAt: string; + model?: string; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + }) => { + if (!serverThread) { + return; + } + const api = readNativeApi(); + if (!api) { + return; + } + + if (input.model !== undefined && input.model !== serverThread.model) { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: input.threadId, + model: input.model, + }); + } + + if (input.runtimeMode !== serverThread.runtimeMode) { + await api.orchestration.dispatchCommand({ + type: "thread.runtime-mode.set", + commandId: newCommandId(), + threadId: input.threadId, + runtimeMode: input.runtimeMode, + createdAt: input.createdAt, + }); + } + + if (input.interactionMode !== serverThread.interactionMode) { + await api.orchestration.dispatchCommand({ + type: "thread.interaction-mode.set", + commandId: newCommandId(), + threadId: input.threadId, + interactionMode: input.interactionMode, + createdAt: input.createdAt, + }); + } + }, + [serverThread], + ); + useEffect(() => { try { if (Object.keys(lastInvokedScriptByProjectId).length === 0) { @@ -2519,6 +2559,16 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } + if (isServerThread) { + await persistThreadSettingsForNextTurn({ + threadId: threadIdForSend, + createdAt: messageCreatedAt, + ...(selectedModel ? { model: selectedModel } : {}), + runtimeMode, + interactionMode, + }); + } + setSendPhase("sending-turn"); const turnAttachments = await turnAttachmentsPromise; await api.orchestration.dispatchCommand({ @@ -2532,8 +2582,10 @@ export default function ChatView({ threadId }: ChatViewProps) { attachments: turnAttachments, }, model: selectedModel || undefined, + ...(selectedModelOptionsForDispatch + ? { modelOptions: selectedModelOptionsForDispatch } + : {}), provider: selectedProvider, - ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode, @@ -2781,16 +2833,13 @@ export default function ChatView({ threadId }: ChatViewProps) { forceStickToBottom(); try { - if (nextInteractionMode !== interactionMode) { - setOptimisticInteractionMode(nextInteractionMode); - await api.orchestration.dispatchCommand({ - type: "thread.interaction-mode.set", - commandId: newCommandId(), - threadId: threadIdForSend, - interactionMode: nextInteractionMode, - createdAt: messageCreatedAt, - }); - } + await persistThreadSettingsForNextTurn({ + threadId: threadIdForSend, + createdAt: messageCreatedAt, + ...(selectedModel ? { model: selectedModel } : {}), + runtimeMode, + interactionMode: nextInteractionMode, + }); await api.orchestration.dispatchCommand({ type: "thread.turn.start", @@ -2804,7 +2853,9 @@ export default function ChatView({ threadId }: ChatViewProps) { }, provider: selectedProvider, model: selectedModel || undefined, - ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}), + ...(selectedModelOptionsForDispatch + ? { modelOptions: selectedModelOptionsForDispatch } + : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: nextInteractionMode, @@ -2816,9 +2867,6 @@ export default function ChatView({ threadId }: ChatViewProps) { setOptimisticUserMessages((existing) => existing.filter((message) => message.id !== messageIdForSend), ); - if (nextInteractionMode !== interactionMode) { - setOptimisticInteractionMode(interactionMode); - } setThreadError( threadIdForSend, err instanceof Error ? err.message : "Failed to send plan follow-up.", @@ -2830,18 +2878,16 @@ export default function ChatView({ threadId }: ChatViewProps) { [ activeThread, forceStickToBottom, - interactionMode, isConnecting, isSendBusy, isServerThread, + persistThreadSettingsForNextTurn, runtimeMode, - selectedEffort, selectedModel, + selectedModelOptionsForDispatch, selectedProvider, - setOptimisticInteractionMode, setThreadError, settings.enableAssistantStreaming, - supportsReasoningEffort, ], ); @@ -2905,7 +2951,9 @@ export default function ChatView({ threadId }: ChatViewProps) { }, provider: selectedProvider, model: selectedModel || undefined, - ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}), + ...(selectedModelOptionsForDispatch + ? { modelOptions: selectedModelOptionsForDispatch } + : {}), assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", runtimeMode, interactionMode: "default", @@ -2951,11 +2999,10 @@ export default function ChatView({ threadId }: ChatViewProps) { isServerThread, navigate, runtimeMode, - selectedEffort, selectedModel, + selectedModelOptionsForDispatch, selectedProvider, settings.enableAssistantStreaming, - supportsReasoningEffort, syncServerReadModel, ]); @@ -2970,27 +3017,17 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus(); return; } - const api = readNativeApi(); const resolvedModel = provider === "cursor" ? resolveCursorModelFromSelection(parseCursorModelSelection(model)) : resolveModelSlugForProvider(provider, model); setComposerDraftProvider(activeThread.id, provider); setComposerDraftModel(activeThread.id, resolvedModel); - if (api && isServerThread) { - void api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThread.id, - model: resolvedModel, - }); - } scheduleComposerFocus(); }, [ activeThread, cursorModelSelectionLockedReason, - isServerThread, lockedProvider, scheduleComposerFocus, setComposerDraftModel, @@ -3046,6 +3083,13 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [scheduleComposerFocus, setComposerDraftEffort, threadId], ); + const onCodexFastModeChange = useCallback( + (enabled: boolean) => { + setComposerDraftCodexFastMode(threadId, enabled); + scheduleComposerFocus(); + }, + [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId], + ); const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (isLocalDraftThread) { @@ -3625,19 +3669,16 @@ export default function ChatView({ threadId }: ChatViewProps) { )} - ) : supportsReasoningEffort ? ( + ) : selectedProvider === "codex" && selectedEffort != null ? ( <> - {/* Divider */} - - {/* Reasoning effort */} - {selectedEffort != null && ( - - )} + ) : null} @@ -3650,21 +3691,20 @@ export default function ChatView({ threadId }: ChatViewProps) { className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" size="sm" type="button" - disabled={isInteractionModePending} onClick={() => void handleInteractionModeChange( - optimisticInteractionMode === "plan" ? "default" : "plan", + interactionMode === "plan" ? "default" : "plan", ) } title={ - optimisticInteractionMode === "plan" + interactionMode === "plan" ? "Plan mode — click to return to normal chat mode" : "Default mode — click to enter plan mode" } > - {optimisticInteractionMode === "plan" ? "Plan" : "Chat"} + {interactionMode === "plan" ? "Plan" : "Chat"} @@ -3677,21 +3717,20 @@ export default function ChatView({ threadId }: ChatViewProps) { className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" size="sm" type="button" - disabled={isRuntimeModePending} onClick={() => void handleRuntimeModeChange( - optimisticRuntimeMode === "full-access" ? "approval-required" : "full-access", + runtimeMode === "full-access" ? "approval-required" : "full-access", ) } title={ - optimisticRuntimeMode === "full-access" + runtimeMode === "full-access" ? "Full access — click to require approvals" : "Approval required — click for full access" } > - {optimisticRuntimeMode === "full-access" ? : } + {runtimeMode === "full-access" ? : } - {optimisticRuntimeMode === "full-access" ? "Full access" : "Supervised"} + {runtimeMode === "full-access" ? "Full access" : "Supervised"} @@ -4397,7 +4436,10 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { }) { const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props; const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); - const directoryPathsKey = useMemo(() => collectDirectoryPaths(treeNodes).join("\u0000"), [treeNodes]); + const directoryPathsKey = useMemo( + () => collectDirectoryPaths(treeNodes).join("\u0000"), + [treeNodes], + ); const allDirectoryExpansionState = useMemo( () => buildDirectoryExpansionState( @@ -4406,8 +4448,8 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { ), [allDirectoriesExpanded, directoryPathsKey], ); - const [expandedDirectories, setExpandedDirectories] = useState>( - () => buildDirectoryExpansionState((directoryPathsKey ? directoryPathsKey.split("\u0000") : []), true), + const [expandedDirectories, setExpandedDirectories] = useState>(() => + buildDirectoryExpansionState(directoryPathsKey ? directoryPathsKey.split("\u0000") : [], true), ); useEffect(() => { setExpandedDirectories(allDirectoryExpansionState); @@ -4489,11 +4531,7 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: { ); }; - return ( -
- {treeNodes.map((node) => renderTreeNode(node, 0))} -
- ); + return
{treeNodes.map((node) => renderTreeNode(node, 0))}
; }); const ProposedPlanCard = memo(function ProposedPlanCard({ @@ -5337,7 +5375,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {