Last active
March 7, 2026 04:45
-
-
Save scode/3c872a5a1a3257f3c627d0c6d2c5729c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| commit fdfaf8150288279683b3441f80ac665bd44cbd93 | |
| Author: Peter Schuller <peter.schuller@infidyne.com> | |
| Date: Fri Mar 6 20:45:11 2026 -0800 | |
| ai slop w/o steering or validating at all: "fix" https://github.com/pingdotgg/t3code/issues/263 | |
| diff --git a/apps/server/src/checkpointing/Utils.test.ts b/apps/server/src/checkpointing/Utils.test.ts | |
| new file mode 100644 | |
| index 00000000..559b4b3e | |
| --- /dev/null | |
| +++ b/apps/server/src/checkpointing/Utils.test.ts | |
| @@ -0,0 +1,42 @@ | |
| +import assert from "node:assert/strict"; | |
| +import { describe, it } from "vitest"; | |
| +import { ProjectId } from "@t3tools/contracts"; | |
| +import { resolveThreadWorkspaceCwd } from "./Utils"; | |
| + | |
| +describe("resolveThreadWorkspaceCwd", () => { | |
| + it("expands tilde-prefixed worktree paths", () => { | |
| + const cwd = resolveThreadWorkspaceCwd({ | |
| + thread: { | |
| + projectId: ProjectId.makeUnsafe("project-1"), | |
| + worktreePath: "~/git/saltybox", | |
| + }, | |
| + projects: [ | |
| + { | |
| + id: ProjectId.makeUnsafe("project-1"), | |
| + workspaceRoot: "/tmp/unused", | |
| + }, | |
| + ], | |
| + }); | |
| + | |
| + assert.equal(cwd?.startsWith("/"), true); | |
| + assert.equal(cwd?.includes("git/saltybox"), true); | |
| + }); | |
| + | |
| + it("expands tilde-prefixed workspace roots", () => { | |
| + const cwd = resolveThreadWorkspaceCwd({ | |
| + thread: { | |
| + projectId: ProjectId.makeUnsafe("project-1"), | |
| + worktreePath: null, | |
| + }, | |
| + projects: [ | |
| + { | |
| + id: ProjectId.makeUnsafe("project-1"), | |
| + workspaceRoot: "~/git/saltybox", | |
| + }, | |
| + ], | |
| + }); | |
| + | |
| + assert.equal(cwd?.startsWith("/"), true); | |
| + assert.equal(cwd?.includes("git/saltybox"), true); | |
| + }); | |
| +}); | |
| diff --git a/apps/server/src/checkpointing/Utils.ts b/apps/server/src/checkpointing/Utils.ts | |
| index 3cd92f85..c803a928 100644 | |
| --- a/apps/server/src/checkpointing/Utils.ts | |
| +++ b/apps/server/src/checkpointing/Utils.ts | |
| @@ -1,5 +1,6 @@ | |
| import { Encoding } from "effect"; | |
| import { CheckpointRef, ProjectId, type ThreadId } from "@t3tools/contracts"; | |
| +import { expandHomePathSync } from "../os-jank"; | |
| export const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; | |
| @@ -21,8 +22,10 @@ export function resolveThreadWorkspaceCwd(input: { | |
| }): string | undefined { | |
| const worktreeCwd = input.thread.worktreePath ?? undefined; | |
| if (worktreeCwd) { | |
| - return worktreeCwd; | |
| + return expandHomePathSync(worktreeCwd); | |
| } | |
| - return input.projects.find((project) => project.id === input.thread.projectId)?.workspaceRoot; | |
| + const workspaceRoot = input.projects.find((project) => project.id === input.thread.projectId) | |
| + ?.workspaceRoot; | |
| + return workspaceRoot ? expandHomePathSync(workspaceRoot) : undefined; | |
| } | |
| diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts | |
| index c80b9f75..b98e6378 100644 | |
| --- a/apps/server/src/codexAppServerManager.ts | |
| +++ b/apps/server/src/codexAppServerManager.ts | |
| @@ -21,6 +21,7 @@ import { | |
| } from "@t3tools/contracts"; | |
| import { normalizeModelSlug } from "@t3tools/shared/model"; | |
| import { Effect, ServiceMap } from "effect"; | |
| +import { expandHomePathSync } from "./os-jank"; | |
| type PendingRequestKey = string; | |
| @@ -519,7 +520,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | |
| let context: CodexSessionContext | undefined; | |
| try { | |
| - const resolvedCwd = input.cwd ?? process.cwd(); | |
| + const resolvedCwd = expandHomePathSync(input.cwd ?? process.cwd()); | |
| const session: ProviderSession = { | |
| provider: "codex", | |
| @@ -533,8 +534,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | |
| }; | |
| const codexOptions = readCodexProviderOptions(input); | |
| - const codexBinaryPath = codexOptions.binaryPath ?? "codex"; | |
| - const codexHomePath = codexOptions.homePath; | |
| + const codexBinaryPath = expandHomePathSync(codexOptions.binaryPath ?? "codex"); | |
| + const codexHomePath = codexOptions.homePath | |
| + ? expandHomePathSync(codexOptions.homePath) | |
| + : undefined; | |
| const child = spawn(codexBinaryPath, ["app-server"], { | |
| cwd: resolvedCwd, | |
| env: { | |
| diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts | |
| index 4f352435..95cd588c 100644 | |
| --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts | |
| +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts | |
| @@ -344,6 +344,45 @@ describe("ProviderCommandReactor", () => { | |
| }); | |
| }); | |
| + it("forwards codex launch options through session start", 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-provider-options"), | |
| + threadId: ThreadId.makeUnsafe("thread-1"), | |
| + message: { | |
| + messageId: asMessageId("user-message-provider-options"), | |
| + role: "user", | |
| + text: "hello custom codex", | |
| + attachments: [], | |
| + }, | |
| + provider: "codex", | |
| + providerOptions: { | |
| + codex: { | |
| + binaryPath: "/opt/homebrew/bin/codex", | |
| + homePath: "/tmp/custom-codex-home", | |
| + }, | |
| + }, | |
| + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | |
| + runtimeMode: "approval-required", | |
| + createdAt: now, | |
| + }), | |
| + ); | |
| + | |
| + await waitFor(() => harness.startSession.mock.calls.length === 1); | |
| + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ | |
| + providerOptions: { | |
| + codex: { | |
| + binaryPath: "/opt/homebrew/bin/codex", | |
| + homePath: "/tmp/custom-codex-home", | |
| + }, | |
| + }, | |
| + }); | |
| + }); | |
| + | |
| 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 d34791bc..ae4617ff 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 ProviderStartOptions, | |
| type ProviderModelOptions, | |
| type ProviderKind, | |
| type ProviderServiceTier, | |
| @@ -202,6 +203,7 @@ const make = Effect.gen(function* () { | |
| readonly provider?: ProviderKind; | |
| readonly model?: string; | |
| readonly modelOptions?: ProviderModelOptions; | |
| + readonly providerOptions?: ProviderStartOptions; | |
| readonly serviceTier?: ProviderServiceTier | null; | |
| }, | |
| ) { | |
| @@ -239,6 +241,9 @@ const make = Effect.gen(function* () { | |
| ...(desiredModel ? { model: desiredModel } : {}), | |
| ...(options?.serviceTier !== undefined ? { serviceTier: options.serviceTier } : {}), | |
| ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}), | |
| + ...(options?.providerOptions !== undefined | |
| + ? { providerOptions: options.providerOptions } | |
| + : {}), | |
| ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), | |
| runtimeMode: desiredRuntimeMode, | |
| }); | |
| @@ -325,6 +330,7 @@ const make = Effect.gen(function* () { | |
| readonly model?: string; | |
| readonly serviceTier?: ProviderServiceTier | null; | |
| readonly modelOptions?: ProviderModelOptions; | |
| + readonly providerOptions?: ProviderStartOptions; | |
| readonly interactionMode?: "default" | "plan"; | |
| readonly createdAt: string; | |
| }) { | |
| @@ -337,6 +343,7 @@ const make = Effect.gen(function* () { | |
| ...(input.model !== undefined ? { model: input.model } : {}), | |
| ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}), | |
| ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), | |
| + ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), | |
| }); | |
| const normalizedInput = toNonEmptyProviderInput(input.messageText); | |
| const normalizedAttachments = input.attachments ?? []; | |
| @@ -472,6 +479,9 @@ const make = Effect.gen(function* () { | |
| ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), | |
| ...(event.payload.serviceTier !== undefined ? { serviceTier: event.payload.serviceTier } : {}), | |
| ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}), | |
| + ...(event.payload.providerOptions !== undefined | |
| + ? { providerOptions: event.payload.providerOptions } | |
| + : {}), | |
| interactionMode: event.payload.interactionMode, | |
| createdAt: event.payload.createdAt, | |
| }); | |
| diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts | |
| index f0364164..47bfc3d9 100644 | |
| --- a/apps/server/src/orchestration/decider.ts | |
| +++ b/apps/server/src/orchestration/decider.ts | |
| @@ -303,6 +303,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | |
| ...(command.model !== undefined ? { model: command.model } : {}), | |
| ...(command.serviceTier !== undefined ? { serviceTier: command.serviceTier } : {}), | |
| ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), | |
| + ...(command.providerOptions !== undefined | |
| + ? { providerOptions: command.providerOptions } | |
| + : {}), | |
| assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, | |
| runtimeMode: | |
| readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ?? | |
| diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts | |
| index 3f5e2129..65593bb1 100644 | |
| --- a/apps/server/src/os-jank.ts | |
| +++ b/apps/server/src/os-jank.ts | |
| @@ -2,6 +2,18 @@ import * as OS from "node:os"; | |
| import { Effect, Path } from "effect"; | |
| import { execFileSync } from "node:child_process"; | |
| +export function expandHomePathSync(input: string): string { | |
| + if (input === "~") { | |
| + return OS.homedir(); | |
| + } | |
| + | |
| + if (input.startsWith("~/") || input.startsWith("~\\")) { | |
| + return `${OS.homedir()}${input.slice(1)}`; | |
| + } | |
| + | |
| + return input; | |
| +} | |
| + | |
| export function fixPath(): void { | |
| if (process.platform !== "darwin") return; | |
| @@ -20,14 +32,7 @@ export function fixPath(): void { | |
| } | |
| export const expandHomePath = Effect.fn(function* (input: string) { | |
| - const { join, sep } = yield* Path.Path; | |
| - if (input === "~") { | |
| - return OS.homedir(); | |
| - } | |
| - if (input.startsWith(`~${sep}`)) { | |
| - return join(OS.homedir(), input.slice(sep.length)); | |
| - } | |
| - return input; | |
| + return expandHomePathSync(input); | |
| }); | |
| export const resolveStateDir = Effect.fn(function* (raw: string | undefined) { | |
| diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts | |
| index 2c30b7cc..98da500f 100644 | |
| --- a/apps/server/src/wsServer.test.ts | |
| +++ b/apps/server/src/wsServer.test.ts | |
| @@ -1143,6 +1143,61 @@ describe("WebSocket Server", () => { | |
| expect(response.error?.message).toContain("exceeds current turn count"); | |
| }); | |
| + it("expands tilde-prefixed workspace and worktree paths in dispatched commands", async () => { | |
| + server = await createTestServer({ cwd: "/test" }); | |
| + const addr = server.address(); | |
| + const port = typeof addr === "object" && addr !== null ? addr.port : 0; | |
| + | |
| + const ws = await connectWs(port); | |
| + connections.push(ws); | |
| + await waitForMessage(ws); | |
| + | |
| + const createdAt = new Date().toISOString(); | |
| + const projectRoot = "~/tmp/ws-tilde-project"; | |
| + const worktreePath = "~/tmp/ws-tilde-worktree"; | |
| + | |
| + const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | |
| + type: "project.create", | |
| + commandId: "cmd-tilde-project-create", | |
| + projectId: "project-tilde", | |
| + title: "Tilde Project", | |
| + workspaceRoot: projectRoot, | |
| + defaultModel: "gpt-5-codex", | |
| + createdAt, | |
| + }); | |
| + expect(createProjectResponse.error).toBeUndefined(); | |
| + | |
| + const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | |
| + type: "thread.create", | |
| + commandId: "cmd-tilde-thread-create", | |
| + threadId: "thread-tilde", | |
| + projectId: "project-tilde", | |
| + title: "Tilde Thread", | |
| + model: "gpt-5-codex", | |
| + runtimeMode: "full-access", | |
| + interactionMode: "default", | |
| + branch: null, | |
| + worktreePath, | |
| + createdAt, | |
| + }); | |
| + expect(createThreadResponse.error).toBeUndefined(); | |
| + | |
| + const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot, {}); | |
| + expect(snapshotResponse.error).toBeUndefined(); | |
| + | |
| + const snapshot = snapshotResponse.result as | |
| + | { | |
| + projects?: Array<{ id: string; workspaceRoot: string }>; | |
| + threads?: Array<{ id: string; worktreePath: string | null }>; | |
| + } | |
| + | undefined; | |
| + const project = snapshot?.projects?.find((entry: { id: string }) => entry.id === "project-tilde"); | |
| + const thread = snapshot?.threads?.find((entry: { id: string }) => entry.id === "thread-tilde"); | |
| + | |
| + expect(project?.workspaceRoot).toBe(path.join(os.homedir(), "tmp/ws-tilde-project")); | |
| + expect(thread?.worktreePath).toBe(path.join(os.homedir(), "tmp/ws-tilde-worktree")); | |
| + }); | |
| + | |
| it("keeps orchestration domain push behavior for provider runtime events", async () => { | |
| const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | |
| const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { | |
| diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts | |
| index 934ff92a..a1bdbbbf 100644 | |
| --- a/apps/server/src/wsServer.ts | |
| +++ b/apps/server/src/wsServer.ts | |
| @@ -72,6 +72,7 @@ import { | |
| } from "./attachmentStore.ts"; | |
| import { parseBase64DataUrl } from "./imageMime.ts"; | |
| import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; | |
| +import { expandHomePath } from "./os-jank"; | |
| /** | |
| * ServerShape - Service API for server lifecycle control. | |
| @@ -298,83 +299,136 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | |
| const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { | |
| readonly command: ClientOrchestrationCommand; | |
| }) { | |
| - if (input.command.type !== "thread.turn.start") { | |
| - return input.command as OrchestrationCommand; | |
| - } | |
| - const turnStartCommand = input.command; | |
| - | |
| - const normalizedAttachments = yield* Effect.forEach( | |
| - turnStartCommand.message.attachments, | |
| - (attachment) => | |
| - Effect.gen(function* () { | |
| - const parsed = parseBase64DataUrl(attachment.dataUrl); | |
| - if (!parsed || !parsed.mimeType.startsWith("image/")) { | |
| - return yield* new RouteRequestError({ | |
| - message: `Invalid image attachment payload for '${attachment.name}'.`, | |
| - }); | |
| - } | |
| + const normalizeAbsolutePath = (value: string) => | |
| + expandHomePath(value.trim()).pipe(Effect.map((expanded) => path.resolve(expanded))); | |
| + const normalizeOptionalAbsolutePath = (value: string | null) => | |
| + value === null ? Effect.succeed(null) : normalizeAbsolutePath(value); | |
| - const bytes = Buffer.from(parsed.base64, "base64"); | |
| - if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { | |
| - return yield* new RouteRequestError({ | |
| - message: `Image attachment '${attachment.name}' is empty or too large.`, | |
| - }); | |
| - } | |
| + switch (input.command.type) { | |
| + case "project.create": | |
| + return { | |
| + ...input.command, | |
| + workspaceRoot: yield* normalizeAbsolutePath(input.command.workspaceRoot), | |
| + } satisfies OrchestrationCommand; | |
| + case "project.meta.update": | |
| + return { | |
| + ...input.command, | |
| + ...(input.command.workspaceRoot !== undefined | |
| + ? { workspaceRoot: yield* normalizeAbsolutePath(input.command.workspaceRoot) } | |
| + : {}), | |
| + } satisfies OrchestrationCommand; | |
| + case "thread.create": | |
| + return { | |
| + ...input.command, | |
| + worktreePath: yield* normalizeOptionalAbsolutePath(input.command.worktreePath), | |
| + } satisfies OrchestrationCommand; | |
| + case "thread.meta.update": | |
| + return { | |
| + ...input.command, | |
| + ...(input.command.worktreePath !== undefined | |
| + ? { worktreePath: yield* normalizeOptionalAbsolutePath(input.command.worktreePath) } | |
| + : {}), | |
| + } satisfies OrchestrationCommand; | |
| + case "thread.turn.start": { | |
| + const turnStartCommand = input.command; | |
| + const normalizedAttachments = yield* Effect.forEach( | |
| + turnStartCommand.message.attachments, | |
| + (attachment) => | |
| + Effect.gen(function* () { | |
| + const parsed = parseBase64DataUrl(attachment.dataUrl); | |
| + if (!parsed || !parsed.mimeType.startsWith("image/")) { | |
| + return yield* new RouteRequestError({ | |
| + message: `Invalid image attachment payload for '${attachment.name}'.`, | |
| + }); | |
| + } | |
| - const attachmentId = createAttachmentId(turnStartCommand.threadId); | |
| - if (!attachmentId) { | |
| - return yield* new RouteRequestError({ | |
| - message: "Failed to create a safe attachment id.", | |
| - }); | |
| - } | |
| + const bytes = Buffer.from(parsed.base64, "base64"); | |
| + if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { | |
| + return yield* new RouteRequestError({ | |
| + message: `Image attachment '${attachment.name}' is empty or too large.`, | |
| + }); | |
| + } | |
| - const persistedAttachment = { | |
| - type: "image" as const, | |
| - id: attachmentId, | |
| - name: attachment.name, | |
| - mimeType: parsed.mimeType.toLowerCase(), | |
| - sizeBytes: bytes.byteLength, | |
| - }; | |
| - | |
| - const attachmentPath = resolveAttachmentPath({ | |
| - stateDir: serverConfig.stateDir, | |
| - attachment: persistedAttachment, | |
| - }); | |
| - if (!attachmentPath) { | |
| - return yield* new RouteRequestError({ | |
| - message: `Failed to resolve persisted path for '${attachment.name}'.`, | |
| - }); | |
| - } | |
| + const attachmentId = createAttachmentId(turnStartCommand.threadId); | |
| + if (!attachmentId) { | |
| + return yield* new RouteRequestError({ | |
| + message: "Failed to create a safe attachment id.", | |
| + }); | |
| + } | |
| - yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe( | |
| - Effect.mapError( | |
| - () => | |
| - new RouteRequestError({ | |
| - message: `Failed to create attachment directory for '${attachment.name}'.`, | |
| - }), | |
| - ), | |
| - ); | |
| - yield* fileSystem.writeFile(attachmentPath, bytes).pipe( | |
| - Effect.mapError( | |
| - () => | |
| - new RouteRequestError({ | |
| - message: `Failed to persist attachment '${attachment.name}'.`, | |
| - }), | |
| - ), | |
| - ); | |
| + const persistedAttachment = { | |
| + type: "image" as const, | |
| + id: attachmentId, | |
| + name: attachment.name, | |
| + mimeType: parsed.mimeType.toLowerCase(), | |
| + sizeBytes: bytes.byteLength, | |
| + }; | |
| - return persistedAttachment; | |
| - }), | |
| - { concurrency: 1 }, | |
| - ); | |
| + const attachmentPath = resolveAttachmentPath({ | |
| + stateDir: serverConfig.stateDir, | |
| + attachment: persistedAttachment, | |
| + }); | |
| + if (!attachmentPath) { | |
| + return yield* new RouteRequestError({ | |
| + message: `Failed to resolve persisted path for '${attachment.name}'.`, | |
| + }); | |
| + } | |
| - return { | |
| - ...turnStartCommand, | |
| - message: { | |
| - ...turnStartCommand.message, | |
| - attachments: normalizedAttachments, | |
| - }, | |
| - } satisfies OrchestrationCommand; | |
| + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe( | |
| + Effect.mapError( | |
| + () => | |
| + new RouteRequestError({ | |
| + message: `Failed to create attachment directory for '${attachment.name}'.`, | |
| + }), | |
| + ), | |
| + ); | |
| + yield* fileSystem.writeFile(attachmentPath, bytes).pipe( | |
| + Effect.mapError( | |
| + () => | |
| + new RouteRequestError({ | |
| + message: `Failed to persist attachment '${attachment.name}'.`, | |
| + }), | |
| + ), | |
| + ); | |
| + | |
| + return persistedAttachment; | |
| + }), | |
| + { concurrency: 1 }, | |
| + ); | |
| + | |
| + return { | |
| + ...turnStartCommand, | |
| + ...(turnStartCommand.providerOptions?.codex | |
| + ? { | |
| + providerOptions: { | |
| + codex: { | |
| + ...(turnStartCommand.providerOptions.codex.binaryPath | |
| + ? { | |
| + binaryPath: yield* expandHomePath( | |
| + turnStartCommand.providerOptions.codex.binaryPath, | |
| + ), | |
| + } | |
| + : {}), | |
| + ...(turnStartCommand.providerOptions.codex.homePath | |
| + ? { | |
| + homePath: yield* expandHomePath( | |
| + turnStartCommand.providerOptions.codex.homePath, | |
| + ), | |
| + } | |
| + : {}), | |
| + }, | |
| + }, | |
| + } | |
| + : {}), | |
| + message: { | |
| + ...turnStartCommand.message, | |
| + attachments: normalizedAttachments, | |
| + }, | |
| + } satisfies OrchestrationCommand; | |
| + } | |
| + default: | |
| + return input.command as OrchestrationCommand; | |
| + } | |
| }); | |
| // HTTP server — serves static files or redirects to Vite dev server | |
| diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts | |
| index e58f7af4..f5df5ad1 100644 | |
| --- a/apps/web/src/appSettings.ts | |
| +++ b/apps/web/src/appSettings.ts | |
| @@ -1,6 +1,10 @@ | |
| import { useCallback, useSyncExternalStore } from "react"; | |
| import { Option, Schema } from "effect"; | |
| -import { type ProviderKind, type ProviderServiceTier } from "@t3tools/contracts"; | |
| +import { | |
| + type ProviderKind, | |
| + type ProviderServiceTier, | |
| + type ProviderStartOptions, | |
| +} from "@t3tools/contracts"; | |
| import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; | |
| const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; | |
| @@ -57,6 +61,21 @@ export function resolveAppServiceTier(serviceTier: AppServiceTier): ProviderServ | |
| return serviceTier === "auto" ? null : serviceTier; | |
| } | |
| +export function resolveCodexProviderOptions(settings: AppSettings): ProviderStartOptions | undefined { | |
| + const binaryPath = settings.codexBinaryPath.trim(); | |
| + const homePath = settings.codexHomePath.trim(); | |
| + if (binaryPath.length === 0 && homePath.length === 0) { | |
| + return undefined; | |
| + } | |
| + | |
| + return { | |
| + codex: { | |
| + ...(binaryPath.length > 0 ? { binaryPath } : {}), | |
| + ...(homePath.length > 0 ? { homePath } : {}), | |
| + }, | |
| + }; | |
| +} | |
| + | |
| export function shouldShowFastTierIcon( | |
| model: string | null | undefined, | |
| serviceTier: AppServiceTier, | |
| diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx | |
| index 46231694..b154bb4d 100644 | |
| --- a/apps/web/src/components/ChatView.tsx | |
| +++ b/apps/web/src/components/ChatView.tsx | |
| @@ -201,6 +201,7 @@ import { | |
| getAppModelOptions, | |
| resolveAppModelSelection, | |
| resolveAppServiceTier, | |
| + resolveCodexProviderOptions, | |
| shouldShowFastTierIcon, | |
| type AppServiceTier, | |
| useAppSettings, | |
| @@ -573,6 +574,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | |
| const setStoreThreadError = useStore((store) => store.setError); | |
| const setStoreThreadBranch = useStore((store) => store.setThreadBranch); | |
| const { settings } = useAppSettings(); | |
| + const codexProviderOptionsForDispatch = resolveCodexProviderOptions(settings); | |
| const navigate = useNavigate(); | |
| const rawSearch = useSearch({ | |
| strict: false, | |
| @@ -2564,6 +2566,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | |
| ...(selectedModelOptionsForDispatch | |
| ? { modelOptions: selectedModelOptionsForDispatch } | |
| : {}), | |
| + ...(codexProviderOptionsForDispatch | |
| + ? { providerOptions: codexProviderOptionsForDispatch } | |
| + : {}), | |
| provider: selectedProvider, | |
| assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", | |
| runtimeMode, | |
| @@ -2839,6 +2844,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | |
| ...(selectedModelOptionsForDispatch | |
| ? { modelOptions: selectedModelOptionsForDispatch } | |
| : {}), | |
| + ...(codexProviderOptionsForDispatch | |
| + ? { providerOptions: codexProviderOptionsForDispatch } | |
| + : {}), | |
| assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", | |
| runtimeMode, | |
| interactionMode: nextInteractionMode, | |
| @@ -2868,6 +2876,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | |
| runtimeMode, | |
| selectedModel, | |
| selectedModelOptionsForDispatch, | |
| + codexProviderOptionsForDispatch, | |
| selectedProvider, | |
| setComposerDraftInteractionMode, | |
| setThreadError, | |
| @@ -2938,6 +2947,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | |
| ...(selectedModelOptionsForDispatch | |
| ? { modelOptions: selectedModelOptionsForDispatch } | |
| : {}), | |
| + ...(codexProviderOptionsForDispatch | |
| + ? { providerOptions: codexProviderOptionsForDispatch } | |
| + : {}), | |
| assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", | |
| runtimeMode, | |
| interactionMode: "default", | |
| @@ -2985,6 +2997,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | |
| runtimeMode, | |
| selectedModel, | |
| selectedModelOptionsForDispatch, | |
| + codexProviderOptionsForDispatch, | |
| selectedProvider, | |
| settings.enableAssistantStreaming, | |
| syncServerReadModel, | |
| diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts | |
| index 0f37a935..436a01e7 100644 | |
| --- a/packages/contracts/src/index.ts | |
| +++ b/packages/contracts/src/index.ts | |
| @@ -2,6 +2,7 @@ export * from "./baseSchemas"; | |
| export * from "./ipc"; | |
| export * from "./terminal"; | |
| export * from "./provider"; | |
| +export * from "./providerOptions"; | |
| export * from "./providerRuntime"; | |
| export * from "./model"; | |
| export * from "./ws"; | |
| diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts | |
| index 25a641ed..478226ba 100644 | |
| --- a/packages/contracts/src/orchestration.test.ts | |
| +++ b/packages/contracts/src/orchestration.test.ts | |
| @@ -186,6 +186,31 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () => | |
| }), | |
| ); | |
| +it.effect("accepts provider-scoped launch options in thread.turn.start", () => | |
| + Effect.gen(function* () { | |
| + const parsed = yield* decodeThreadTurnStartCommand({ | |
| + type: "thread.turn.start", | |
| + commandId: "cmd-turn-provider-options", | |
| + threadId: "thread-1", | |
| + message: { | |
| + messageId: "msg-provider-options", | |
| + role: "user", | |
| + text: "hello", | |
| + attachments: [], | |
| + }, | |
| + providerOptions: { | |
| + codex: { | |
| + binaryPath: "~/bin/codex", | |
| + homePath: "~/.codex-alt", | |
| + }, | |
| + }, | |
| + createdAt: "2026-01-01T00:00:00.000Z", | |
| + }); | |
| + assert.strictEqual(parsed.providerOptions?.codex?.binaryPath, "~/bin/codex"); | |
| + assert.strictEqual(parsed.providerOptions?.codex?.homePath, "~/.codex-alt"); | |
| + }), | |
| +); | |
| + | |
| it.effect( | |
| "decodes thread.turn-start-requested defaults for provider, runtime mode, and interaction mode", | |
| () => | |
| diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts | |
| index aa7bd827..3e3553c8 100644 | |
| --- a/packages/contracts/src/orchestration.ts | |
| +++ b/packages/contracts/src/orchestration.ts | |
| @@ -1,5 +1,6 @@ | |
| import { Option, Schema, SchemaIssue, Struct } from "effect"; | |
| import { ProviderModelOptions } from "./model"; | |
| +import { ProviderStartOptions } from "./providerOptions"; | |
| import { | |
| ApprovalRequestId, | |
| CheckpointRef, | |
| @@ -366,6 +367,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ | |
| model: Schema.optional(TrimmedNonEmptyString), | |
| serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), | |
| modelOptions: Schema.optional(ProviderModelOptions), | |
| + providerOptions: Schema.optional(ProviderStartOptions), | |
| assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | |
| runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), | |
| interactionMode: ProviderInteractionMode.pipe( | |
| @@ -388,6 +390,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ | |
| model: Schema.optional(TrimmedNonEmptyString), | |
| serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), | |
| modelOptions: Schema.optional(ProviderModelOptions), | |
| + providerOptions: Schema.optional(ProviderStartOptions), | |
| assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | |
| runtimeMode: RuntimeMode, | |
| interactionMode: ProviderInteractionMode, | |
| @@ -668,6 +671,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ | |
| model: Schema.optional(TrimmedNonEmptyString), | |
| serviceTier: Schema.optional(Schema.NullOr(ProviderServiceTier)), | |
| modelOptions: Schema.optional(ProviderModelOptions), | |
| + providerOptions: Schema.optional(ProviderStartOptions), | |
| assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | |
| runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), | |
| interactionMode: ProviderInteractionMode.pipe( | |
| diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts | |
| index 9ca7068a..79d278a2 100644 | |
| --- a/packages/contracts/src/provider.ts | |
| +++ b/packages/contracts/src/provider.ts | |
| @@ -1,6 +1,7 @@ | |
| import { Schema } from "effect"; | |
| import { TrimmedNonEmptyString } from "./baseSchemas"; | |
| import { ProviderModelOptions } from "./model"; | |
| +import { ProviderStartOptions } from "./providerOptions"; | |
| import { | |
| ApprovalRequestId, | |
| EventId, | |
| @@ -48,15 +49,6 @@ export const ProviderSession = Schema.Struct({ | |
| }); | |
| export type ProviderSession = typeof ProviderSession.Type; | |
| -const CodexProviderStartOptions = Schema.Struct({ | |
| - binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), | |
| - homePath: Schema.optional(TrimmedNonEmptyStringSchema), | |
| -}); | |
| - | |
| -const ProviderStartOptions = Schema.Struct({ | |
| - codex: Schema.optional(CodexProviderStartOptions), | |
| -}); | |
| - | |
| export const ProviderSessionStartInput = Schema.Struct({ | |
| threadId: ThreadId, | |
| provider: Schema.optional(ProviderKind), | |
| diff --git a/packages/contracts/src/providerOptions.ts b/packages/contracts/src/providerOptions.ts | |
| new file mode 100644 | |
| index 00000000..f332c7dd | |
| --- /dev/null | |
| +++ b/packages/contracts/src/providerOptions.ts | |
| @@ -0,0 +1,13 @@ | |
| +import { Schema } from "effect"; | |
| +import { TrimmedNonEmptyString } from "./baseSchemas"; | |
| + | |
| +export const CodexProviderStartOptions = Schema.Struct({ | |
| + binaryPath: Schema.optional(TrimmedNonEmptyString), | |
| + homePath: Schema.optional(TrimmedNonEmptyString), | |
| +}); | |
| +export type CodexProviderStartOptions = typeof CodexProviderStartOptions.Type; | |
| + | |
| +export const ProviderStartOptions = Schema.Struct({ | |
| + codex: Schema.optional(CodexProviderStartOptions), | |
| +}); | |
| +export type ProviderStartOptions = typeof ProviderStartOptions.Type; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment