diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 7ec669d4c68b905d059bf605586d58a7bc3dc3f3..fb2d78b2883f3a52e6899f50cf8e75320e338b16 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -201,9 +201,6 @@ describe("sendTurn", () => { expect(result).toEqual({ threadId: "thread_1", turnId: "turn_1", - resumeCursor: { - threadId: "thread_1", - }, }); expect(requireSession).toHaveBeenCalledWith("sess_1"); expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { @@ -225,9 +222,6 @@ describe("sendTurn", () => { expect(updateSession).toHaveBeenCalledWith(context, { status: "running", activeTurnId: "turn_1", - resumeCursor: { - threadId: "thread_1", - }, }); }); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 7b19c7be5ce8c19daeef4063c7672dbb038e3a37..71b93df99a6406ddaa008c4d6b89e5739bb472c6 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -193,8 +193,9 @@ export class CodexAppServerManager extends EventEmitter).threadId; + return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : undefined; +} + function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined { - if ( - !input.resumeCursor || - typeof input.resumeCursor !== "object" || - Array.isArray(input.resumeCursor) - ) { - return input.resumeThreadId; - } - const rawThreadId = (input.resumeCursor as Record).threadId; - return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : input.resumeThreadId; + return readResumeCursorThreadId(input.resumeCursor); } function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 3abe82b5440cf2c98fa179be106f1b84f95e93d7..99ce71c2e0040adf26c3eb6964717f57ccb2d880 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1,8 +1,4 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import type { ProviderRuntimeEvent } from "@t3tools/contracts"; +import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; import { ApprovalRequestId, CommandId, @@ -88,16 +84,24 @@ describe("ProviderCommandReactor", () => { createdStateDirs.add(stateDir); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSessionIndex = 1; - const startSession = vi.fn((_: unknown, __: unknown) => { + const runtimeSessions: Array = []; + const startSession = vi.fn((_: unknown, input: unknown) => { const sessionIndex = nextSessionIndex++; - return Effect.succeed({ + const resumeCursor = + typeof input === "object" && input !== null && "resumeCursor" in input + ? input.resumeCursor + : undefined; + const session: ProviderSession = { sessionId: asSessionId(`sess-${sessionIndex}`), provider: "codex" as const, status: "ready" as const, threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`), + resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` }, createdAt: now, updatedAt: now, - }); + }; + runtimeSessions.push(session); + return Effect.succeed(session); }); const sendTurn = vi.fn((_: unknown) => Effect.succeed({ @@ -107,15 +111,19 @@ describe("ProviderCommandReactor", () => { ); const interruptTurn = vi.fn((_: unknown) => Effect.void); const respondToRequest = vi.fn((_: unknown) => Effect.void); - const stopSession = vi.fn((_: unknown) => Effect.void); - const renameBranch = vi.fn((_: unknown) => - Effect.succeed({ - branch: "t3code/generated-name", - }), - ); - const generateBranchName = vi.fn(() => - Effect.succeed({ - branch: "generated-name", + const stopSession = vi.fn((input: unknown) => + Effect.sync(() => { + const sessionId = + typeof input === "object" && input !== null && "sessionId" in input + ? (input as { sessionId?: ProviderSessionId }).sessionId + : undefined; + if (!sessionId) { + return; + } + const index = runtimeSessions.findIndex((session) => session.sessionId === sessionId); + if (index >= 0) { + runtimeSessions.splice(index, 1); + } }), ); @@ -126,7 +134,7 @@ describe("ProviderCommandReactor", () => { interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], stopSession: stopSession as ProviderServiceShape["stopSession"], - listSessions: () => Effect.succeed([]), + listSessions: () => Effect.succeed(runtimeSessions), rollbackConversation: () => unsupported(), stopAll: () => Effect.void, streamEvents: Stream.fromPubSub(runtimeEventPubSub), @@ -566,7 +574,7 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ - resumeThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), + resumeCursor: { opaque: "cursor-1" }, approvalPolicy: "on-request", sandboxMode: "workspace-write", }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index bf88e221b91c2ecec8e5fe9cd04aa0859b4ce8cc..e909050d10798ad7a46b1ba2d99dd72447004a21 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -6,10 +6,10 @@ import { type ProviderApprovalPolicy, type ProviderKind, type ProviderSandboxMode, + type ProviderSessionId, type OrchestrationSession, type ThreadId, type ProviderSession, - type ProviderThreadId, type TurnId, } from "@t3tools/contracts"; import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect"; @@ -191,12 +191,17 @@ const make = Effect.gen(function* () { projects: readModel.projects, }); - const startProviderSession = (resumeThreadId?: ProviderThreadId | null) => + const resolveResumeCursorForSession = (sessionId: ProviderSessionId) => + providerService.listSessions().pipe( + Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)?.resumeCursor), + ); + + const startProviderSession = (resumeCursor?: unknown) => providerService.startSession(threadId, { ...(preferredProvider ? { provider: preferredProvider } : {}), ...(effectiveCwd ? { cwd: effectiveCwd } : {}), ...(thread.model ? { model: thread.model } : {}), - ...(resumeThreadId ? { resumeThreadId } : {}), + ...(resumeCursor !== undefined ? { resumeCursor } : {}), approvalPolicy: desiredApprovalPolicy, sandboxMode: desiredSandboxMode, }); @@ -232,9 +237,8 @@ const make = Effect.gen(function* () { return existingSessionId; } - const restartedSession = yield* startProviderSession( - thread.session?.providerThreadId ?? null, - ); + const resumeCursor = yield* resolveResumeCursorForSession(existingSessionId); + const restartedSession = yield* startProviderSession(resumeCursor); yield* bindSessionToThread(restartedSession); yield* providerService.stopSession({ sessionId: existingSessionId }).pipe( Effect.catchCause((cause) => diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 6d528614de5d738e82708e4f4321c555bd5cd5ff..5842497dbfdd0f8e836481882ce17509f04db0ec 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -150,6 +150,7 @@ describe("decider project scripts", () => { text: "hello", attachments: [], }, + provider: "claudeCode", model: "gpt-5", effort: "high", approvalPolicy: "on-request", @@ -174,6 +175,7 @@ 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", approvalPolicy: "on-request", diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 45268a1132096afcdbd0e8a11b143044eac861f8..2c5218d3aca6b229c89bf4c5f20960fbb9444450 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -251,6 +251,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, messageId: command.message.messageId, + ...(command.provider !== undefined ? { provider: command.provider } : {}), ...(command.model !== undefined ? { model: command.model } : {}), ...(command.effort !== undefined ? { effort: command.effort } : {}), assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index b18a838939d31e2a6258f0c70798b2d64a80ba18..2904ba48cf6c6ff452ab1d883fb839d9f1d64a55 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -61,11 +61,13 @@ function makeFakeCodexAdapter() { Effect.sync(() => { const now = new Date().toISOString(); const next = nextSession; + const threadId = ProviderThreadId.makeUnsafe(`thread-${next}`); const session: ProviderSession = { sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`), provider: "codex", status: "ready", - threadId: input.resumeThreadId ?? ProviderThreadId.makeUnsafe(`thread-${next}`), + threadId, + resumeCursor: input.resumeCursor ?? { opaque: `cursor-${next}` }, cwd: input.cwd ?? process.cwd(), createdAt: now, updatedAt: now, @@ -344,7 +346,7 @@ it.effect( assert.equal(Option.isSome(persistedAfterStopAll), true); if (Option.isSome(persistedAfterStopAll)) { assert.equal(persistedAfterStopAll.value.status, "stopped"); - assert.equal(persistedAfterStopAll.value.resumeCursor, null); + assert.deepEqual(persistedAfterStopAll.value.resumeCursor, startedSession.resumeCursor); } const secondCodex = makeFakeCodexAdapter(); @@ -380,6 +382,7 @@ it.effect( provider: "codex", cwd: "/tmp/project", resumeThreadId: startedSession.threadId, + resumeCursor: startedSession.resumeCursor, }, ], ]); @@ -468,6 +471,7 @@ routing.layer("ProviderServiceLive routing", (it) => { provider: "codex", cwd: "/tmp/project", resumeThreadId: initial.threadId, + resumeCursor: initial.resumeCursor, }, ], ]); @@ -547,7 +551,7 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.equal(Option.isSome(runningRuntime), true); if (Option.isSome(runningRuntime)) { assert.equal(runningRuntime.value.status, "running"); - assert.equal(runningRuntime.value.resumeCursor, null); + assert.deepEqual(runningRuntime.value.resumeCursor, session.resumeCursor); const payload = runningRuntime.value.runtimePayload; assert.equal(payload !== null && typeof payload === "object", true); if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index bb58a7d93f1f14aee0b56189b219d133e610c124..06e97c8ab935b61ecf90f771940d83acb32906c6 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -283,7 +283,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => } as const; } - if (!resumeThreadId && !hasResumeCursor) { + if (!hasResumeCursor) { return yield* toValidationError( input.operation, `Cannot recover stale session '${input.staleSessionId}' because no provider resume state is persisted.`, diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 471b38a5484730c523fc6521dd7a26b275d19b1c..cd43805682ebff6b897e80943f237a93673c465f 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -119,7 +119,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL providerThreadId, status: "starting", resumeCursor: { - resumeThreadId: "provider-thread-runtime", + threadId: "provider-thread-runtime", }, runtimePayload: { cwd: "/tmp/project", @@ -145,7 +145,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL assert.equal(runtime.value.providerThreadId, providerThreadId); assert.equal(runtime.value.status, "running"); assert.deepEqual(runtime.value.resumeCursor, { - resumeThreadId: providerThreadId, + threadId: providerThreadId, }); assert.deepEqual(runtime.value.runtimePayload, { cwd: "/tmp/project", diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 29589c9e2c6873ffcd9f21ded0ce6d3e2cae4538..1e4d048bc5011b32f0dfcf884ded569d2ef04c05 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -2,14 +2,24 @@ import { assert, it } from "@effect/vitest"; import { Effect, Schema } from "effect"; import { + DEFAULT_PROVIDER_APPROVAL_POLICY, + DEFAULT_PROVIDER_SANDBOX_MODE, OrchestrationGetTurnDiffInput, + OrchestrationSession, ProjectCreateCommand, + ThreadTurnStartCommand, ThreadTurnDiff, + ThreadTurnStartRequestedPayload, } from "./orchestration"; const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); +const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand); +const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( + ThreadTurnStartRequestedPayload, +); +const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => Effect.gen(function* () { @@ -84,3 +94,76 @@ it.effect("rejects command fields that become empty after trim", () => assert.strictEqual(result._tag, "Failure"); }), ); + +it.effect("decodes thread.turn.start defaults for provider and runtime mode", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-1", + threadId: "thread-1", + message: { + messageId: "msg-1", + role: "user", + text: "hello", + attachments: [], + }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.provider, undefined); + assert.strictEqual(parsed.approvalPolicy, DEFAULT_PROVIDER_APPROVAL_POLICY); + assert.strictEqual(parsed.sandboxMode, DEFAULT_PROVIDER_SANDBOX_MODE); + }), +); + +it.effect("preserves explicit provider and runtime mode in thread.turn.start", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-2", + threadId: "thread-1", + message: { + messageId: "msg-2", + role: "user", + text: "hello", + attachments: [], + }, + provider: "claudeCode", + approvalPolicy: "never", + sandboxMode: "danger-full-access", + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.provider, "claudeCode"); + assert.strictEqual(parsed.approvalPolicy, "never"); + assert.strictEqual(parsed.sandboxMode, "danger-full-access"); + }), +); + +it.effect("decodes thread.turn-start-requested defaults for provider and runtime mode", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartRequestedPayload({ + threadId: "thread-1", + messageId: "msg-1", + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.provider, undefined); + assert.strictEqual(parsed.approvalPolicy, DEFAULT_PROVIDER_APPROVAL_POLICY); + assert.strictEqual(parsed.sandboxMode, DEFAULT_PROVIDER_SANDBOX_MODE); + }), +); + +it.effect("decodes orchestration session runtime mode defaults", () => + Effect.gen(function* () { + const parsed = yield* decodeOrchestrationSession({ + threadId: "thread-1", + status: "idle", + providerName: null, + providerSessionId: null, + providerThreadId: null, + activeTurnId: null, + lastError: null, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.approvalPolicy, DEFAULT_PROVIDER_APPROVAL_POLICY); + assert.strictEqual(parsed.sandboxMode, DEFAULT_PROVIDER_SANDBOX_MODE); + }), +); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index a6b1f821415b1b9f904069a4793187ff66ac51d9..66180d28aa4427a6406bf0a85c65b632849e8104 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -31,6 +31,7 @@ export const ORCHESTRATION_WS_CHANNELS = { export const ProviderKind = Schema.Literals(["codex", "claudeCode"]); export type ProviderKind = typeof ProviderKind.Type; +export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", "on-failure", @@ -38,12 +39,14 @@ export const ProviderApprovalPolicy = Schema.Literals([ "never", ]); export type ProviderApprovalPolicy = typeof ProviderApprovalPolicy.Type; +export const DEFAULT_PROVIDER_APPROVAL_POLICY: ProviderApprovalPolicy = "on-failure"; export const ProviderSandboxMode = Schema.Literals([ "read-only", "workspace-write", "danger-full-access", ]); export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; +export const DEFAULT_PROVIDER_SANDBOX_MODE: ProviderSandboxMode = "workspace-write"; export const ProviderRequestKind = Schema.Literals(["command", "file-change"]); export type ProviderRequestKind = typeof ProviderRequestKind.Type; export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); @@ -160,8 +163,12 @@ export const OrchestrationSession = Schema.Struct({ providerName: Schema.NullOr(TrimmedNonEmptyString), providerSessionId: Schema.NullOr(ProviderSessionId), providerThreadId: Schema.NullOr(ProviderThreadId), - approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")), - sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")), + approvalPolicy: ProviderApprovalPolicy.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY), + ), + sandboxMode: ProviderSandboxMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE), + ), activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(TrimmedNonEmptyString), updatedAt: IsoDateTime, @@ -317,11 +324,16 @@ export const ThreadTurnStartCommand = Schema.Struct({ text: Schema.String, attachments: Schema.Array(ChatAttachment), }), + provider: Schema.optional(ProviderKind), model: Schema.optional(TrimmedNonEmptyString), effort: Schema.optional(TrimmedNonEmptyString), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), - approvalPolicy: ProviderApprovalPolicy, - sandboxMode: ProviderSandboxMode, + approvalPolicy: ProviderApprovalPolicy.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY), + ), + sandboxMode: ProviderSandboxMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE), + ), createdAt: IsoDateTime, }); @@ -567,11 +579,16 @@ export const ThreadMessageSentPayload = Schema.Struct({ export const ThreadTurnStartRequestedPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, + provider: Schema.optional(ProviderKind), model: Schema.optional(TrimmedNonEmptyString), effort: Schema.optional(TrimmedNonEmptyString), assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), - approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")), - sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")), + approvalPolicy: ProviderApprovalPolicy.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY), + ), + sandboxMode: ProviderSandboxMode.pipe( + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE), + ), createdAt: IsoDateTime, }); diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9242b42c423a89700a28cfd0c8ea2cd31ef5c336 --- /dev/null +++ b/packages/contracts/src/provider.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { Schema } from "effect"; + +import { ProviderSessionStartInput } from "./provider"; + +const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput); + +describe("ProviderSessionStartInput", () => { + it("accepts codex-compatible payloads", () => { + const parsed = decodeProviderSessionStartInput({ + provider: "codex", + cwd: "/tmp/workspace", + model: "gpt-5.3-codex", + providerOptions: { + codex: { + binaryPath: "/usr/local/bin/codex", + homePath: "/tmp/.codex", + }, + }, + }); + expect(parsed.approvalPolicy).toBeUndefined(); + expect(parsed.sandboxMode).toBeUndefined(); + expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex"); + expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex"); + }); + + it("accepts claude runtime knobs", () => { + const parsed = decodeProviderSessionStartInput({ + provider: "claudeCode", + cwd: "/tmp/workspace", + model: "claude-sonnet-4-5", + providerOptions: { + claudeCode: { + binaryPath: "/usr/local/bin/claude", + permissionMode: "plan", + maxThinkingTokens: 12_000, + }, + }, + approvalPolicy: "never", + sandboxMode: "danger-full-access", + }); + expect(parsed.provider).toBe("claudeCode"); + expect(parsed.providerOptions?.claudeCode?.binaryPath).toBe("/usr/local/bin/claude"); + expect(parsed.providerOptions?.claudeCode?.permissionMode).toBe("plan"); + expect(parsed.providerOptions?.claudeCode?.maxThinkingTokens).toBe(12_000); + expect(parsed.approvalPolicy).toBe("never"); + expect(parsed.sandboxMode).toBe("danger-full-access"); + }); +}); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 639d5dcb3f9432946c5d32480ab9031d7dd35395..2ead00d322ea8f09c36ed96db5dd1d160d5c0f5c 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -49,14 +49,31 @@ export const ProviderSession = Schema.Struct({ }); export type ProviderSession = typeof ProviderSession.Type; +export const CodexProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), + homePath: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type CodexProviderStartOptions = typeof CodexProviderStartOptions.Type; + +export const ClaudeCodeProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), + permissionMode: Schema.optional(TrimmedNonEmptyStringSchema), + maxThinkingTokens: Schema.optional(NonNegativeInt), +}); +export type ClaudeCodeProviderStartOptions = typeof ClaudeCodeProviderStartOptions.Type; + +export const ProviderStartOptions = Schema.Struct({ + codex: Schema.optional(CodexProviderStartOptions), + claudeCode: Schema.optional(ClaudeCodeProviderStartOptions), +}); +export type ProviderStartOptions = typeof ProviderStartOptions.Type; + export const ProviderSessionStartInput = Schema.Struct({ provider: Schema.optional(ProviderKind), cwd: Schema.optional(TrimmedNonEmptyStringSchema), model: Schema.optional(TrimmedNonEmptyStringSchema), - resumeThreadId: Schema.optional(ProviderThreadId), resumeCursor: Schema.optional(Schema.Unknown), - codexBinaryPath: Schema.optional(TrimmedNonEmptyStringSchema), - codexHomePath: Schema.optional(TrimmedNonEmptyStringSchema), + providerOptions: Schema.optional(ProviderStartOptions), approvalPolicy: Schema.optional(ProviderApprovalPolicy), sandboxMode: Schema.optional(ProviderSandboxMode), });