Unified · Split
| @@ -204,9 +204,6 @@ describe("sendTurn", () => { · 203 unmodified lines | ||
| 204 | 204 | expect(result).toEqual({ |
| 205 | 205 | threadId: "thread_1", |
| 206 | 206 | turnId: "turn_1", |
| 207 | − resumeCursor: { | |
| 208 | − threadId: "thread_1", | |
| 209 | − }, | |
| 210 | 207 | }); |
| 211 | 208 | expect(requireSession).toHaveBeenCalledWith("sess_1"); |
| 212 | 209 | expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { |
| @@ -228,9 +225,6 @@ describe("sendTurn", () => { · 15 unmodified lines | ||
| 228 | 225 | expect(updateSession).toHaveBeenCalledWith(context, { |
| 229 | 226 | status: "running", |
| 230 | 227 | activeTurnId: "turn_1", |
| 231 | − resumeCursor: { | |
| 232 | − threadId: "thread_1", | |
| 233 | − }, | |
| 234 | 228 | }); |
| 235 | 229 | }); |
| 236 | 230 | |
| @@ -169,8 +169,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 168 unmodified lines | ||
| 169 | 169 | updatedAt: now, |
| 170 | 170 | }; |
| 171 | 171 | |
| 172 | − const codexBinaryPath = input.codexBinaryPath ?? "codex"; | |
| 173 | − const codexHomePath = input.codexHomePath; | |
| 172 | + const codexOptions = readCodexProviderOptions(input); | |
| 173 | + const codexBinaryPath = codexOptions.binaryPath ?? "codex"; | |
| 174 | + const codexHomePath = codexOptions.homePath; | |
| 174 | 175 | const child = spawn(codexBinaryPath, ["app-server"], { |
| 175 | 176 | cwd: resolvedCwd, |
| 176 | 177 | env: { |
| @@ -349,13 +350,17 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 172 unmodified lines | ||
| 349 | 350 | this.updateSession(context, { |
| 350 | 351 | status: "running", |
| 351 | 352 | activeTurnId: turnId, |
| 352 | − resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId }, | |
| 353 | + ...(context.session.resumeCursor !== undefined | |
| 354 | + ? { resumeCursor: context.session.resumeCursor } | |
| 355 | + : {}), | |
| 353 | 356 | }); |
| 354 | 357 | |
| 355 | 358 | return { |
| 356 | 359 | threadId: context.session.threadId, |
| 357 | 360 | turnId, |
| 358 | − resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId }, | |
| 361 | + ...(context.session.resumeCursor !== undefined | |
| 362 | + ? { resumeCursor: context.session.resumeCursor } | |
| 363 | + : {}), | |
| 359 | 364 | }; |
| 360 | 365 | } |
| 361 | 366 | |
| @@ -980,16 +985,30 @@ function toProviderThreadId(value: string | undefined): ProviderThreadId | undef · 618 unmodified lines | ||
| 980 | 985 | return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe); |
| 981 | 986 | } |
| 982 | 987 | |
| 988 | +function readCodexProviderOptions(input: ProviderSessionStartInput): { | |
| 989 | + readonly binaryPath?: string; | |
| 990 | + readonly homePath?: string; | |
| 991 | +} { | |
| 992 | + const options = input.providerOptions?.codex; | |
| 993 | + if (!options) { | |
| 994 | + return {}; | |
| 995 | + } | |
| 996 | + return { | |
| 997 | + ...(options.binaryPath ? { binaryPath: options.binaryPath } : {}), | |
| 998 | + ...(options.homePath ? { homePath: options.homePath } : {}), | |
| 999 | + }; | |
| 1000 | +} | |
| 1001 | + | |
| 1002 | +function readResumeCursorThreadId(resumeCursor: unknown): ProviderThreadId | undefined { | |
| 1003 | + if (!resumeCursor || typeof resumeCursor !== "object" || Array.isArray(resumeCursor)) { | |
| 1004 | + return undefined; | |
| 1005 | + } | |
| 1006 | + const rawThreadId = (resumeCursor as Record<string, unknown>).threadId; | |
| 1007 | + return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : undefined; | |
| 1008 | +} | |
| 1009 | + | |
| 983 | 1010 | function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined { |
| 984 | − if ( | |
| 985 | − !input.resumeCursor || | |
| 986 | − typeof input.resumeCursor !== "object" || | |
| 987 | − Array.isArray(input.resumeCursor) | |
| 988 | − ) { | |
| 989 | − return input.resumeThreadId; | |
| 990 | − } | |
| 991 | − const rawThreadId = (input.resumeCursor as Record<string, unknown>).threadId; | |
| 992 | − return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : input.resumeThreadId; | |
| 1011 | + return readResumeCursorThreadId(input.resumeCursor); | |
| 993 | 1012 | } |
| 994 | 1013 | |
| 995 | 1014 | function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined { |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import type { ProviderRuntimeEvent } from "@t3tools/contracts"; | |
| 1 | +import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; | |
| 2 | 2 | import { |
| 3 | 3 | ApprovalRequestId, |
| 4 | 4 | CommandId, |
| @@ -72,16 +72,24 @@ describe("ProviderCommandReactor", () => { · 67 unmodified lines | ||
| 72 | 72 | const now = new Date().toISOString(); |
| 73 | 73 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 74 | 74 | let nextSessionIndex = 1; |
| 75 | − const startSession = vi.fn((_: unknown, __: unknown) => { | |
| 75 | + const runtimeSessions: Array<ProviderSession> = []; | |
| 76 | + const startSession = vi.fn((_: unknown, input: unknown) => { | |
| 76 | 77 | const sessionIndex = nextSessionIndex++; |
| 77 | − return Effect.succeed({ | |
| 78 | + const resumeCursor = | |
| 79 | + typeof input === "object" && input !== null && "resumeCursor" in input | |
| 80 | + ? input.resumeCursor | |
| 81 | + : undefined; | |
| 82 | + const session: ProviderSession = { | |
| 78 | 83 | sessionId: asSessionId(`sess-${sessionIndex}`), |
| 79 | 84 | provider: "codex" as const, |
| 80 | 85 | status: "ready" as const, |
| 81 | 86 | threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`), |
| 87 | + resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` }, | |
| 82 | 88 | createdAt: now, |
| 83 | 89 | updatedAt: now, |
| 84 | − }); | |
| 90 | + }; | |
| 91 | + runtimeSessions.push(session); | |
| 92 | + return Effect.succeed(session); | |
| 85 | 93 | }); |
| 86 | 94 | const sendTurn = vi.fn((_: unknown) => |
| 87 | 95 | Effect.succeed({ |
| @@ -91,7 +99,21 @@ describe("ProviderCommandReactor", () => { · 3 unmodified lines | ||
| 91 | 99 | ); |
| 92 | 100 | const interruptTurn = vi.fn((_: unknown) => Effect.void); |
| 93 | 101 | const respondToRequest = vi.fn((_: unknown) => Effect.void); |
| 94 | − const stopSession = vi.fn((_: unknown) => Effect.void); | |
| 102 | + const stopSession = vi.fn((input: unknown) => | |
| 103 | + Effect.sync(() => { | |
| 104 | + const sessionId = | |
| 105 | + typeof input === "object" && input !== null && "sessionId" in input | |
| 106 | + ? (input as { sessionId?: ProviderSessionId }).sessionId | |
| 107 | + : undefined; | |
| 108 | + if (!sessionId) { | |
| 109 | + return; | |
| 110 | + } | |
| 111 | + const index = runtimeSessions.findIndex((session) => session.sessionId === sessionId); | |
| 112 | + if (index >= 0) { | |
| 113 | + runtimeSessions.splice(index, 1); | |
| 114 | + } | |
| 115 | + }), | |
| 116 | + ); | |
| 95 | 117 | |
| 96 | 118 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; |
| 97 | 119 | const service: ProviderServiceShape = { |
| @@ -100,7 +122,7 @@ describe("ProviderCommandReactor", () => { · 2 unmodified lines | ||
| 100 | 122 | interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], |
| 101 | 123 | respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], |
| 102 | 124 | stopSession: stopSession as ProviderServiceShape["stopSession"], |
| 103 | − listSessions: () => Effect.succeed([]), | |
| 125 | + listSessions: () => Effect.succeed(runtimeSessions), | |
| 104 | 126 | rollbackConversation: () => unsupported(), |
| 105 | 127 | stopAll: () => Effect.void, |
| 106 | 128 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), |
| @@ -289,7 +311,7 @@ describe("ProviderCommandReactor", () => { · 182 unmodified lines | ||
| 289 | 311 | |
| 290 | 312 | expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); |
| 291 | 313 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ |
| 292 | − resumeThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 314 | + resumeCursor: { opaque: "cursor-1" }, | |
| 293 | 315 | approvalPolicy: "on-request", |
| 294 | 316 | sandboxMode: "workspace-write", |
| 295 | 317 | }); |
| @@ -6,10 +6,10 @@ import { · 5 unmodified lines | ||
| 6 | 6 | type ProviderApprovalPolicy, |
| 7 | 7 | type ProviderKind, |
| 8 | 8 | type ProviderSandboxMode, |
| 9 | + type ProviderSessionId, | |
| 9 | 10 | type OrchestrationSession, |
| 10 | 11 | type ThreadId, |
| 11 | 12 | type ProviderSession, |
| 12 | − type ProviderThreadId, | |
| 13 | 13 | type TurnId, |
| 14 | 14 | } from "@t3tools/contracts"; |
| 15 | 15 | import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect"; |
| @@ -158,12 +158,17 @@ const make = Effect.gen(function* () { · 142 unmodified lines | ||
| 158 | 158 | projects: readModel.projects, |
| 159 | 159 | }); |
| 160 | 160 | |
| 161 | − const startProviderSession = (resumeThreadId?: ProviderThreadId | null) => | |
| 161 | + const resolveResumeCursorForSession = (sessionId: ProviderSessionId) => | |
| 162 | + providerService.listSessions().pipe( | |
| 163 | + Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)?.resumeCursor), | |
| 164 | + ); | |
| 165 | + | |
| 166 | + const startProviderSession = (resumeCursor?: unknown) => | |
| 162 | 167 | providerService.startSession(threadId, { |
| 163 | 168 | ...(preferredProvider ? { provider: preferredProvider } : {}), |
| 164 | 169 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), |
| 165 | 170 | ...(thread.model ? { model: thread.model } : {}), |
| 166 | − ...(resumeThreadId ? { resumeThreadId } : {}), | |
| 171 | + ...(resumeCursor !== undefined ? { resumeCursor } : {}), | |
| 167 | 172 | approvalPolicy: desiredApprovalPolicy, |
| 168 | 173 | sandboxMode: desiredSandboxMode, |
| 169 | 174 | }); |
| @@ -198,7 +203,8 @@ const make = Effect.gen(function* () { · 28 unmodified lines | ||
| 198 | 203 | return existingSessionId; |
| 199 | 204 | } |
| 200 | 205 | |
| 201 | − const restartedSession = yield* startProviderSession(thread.session?.providerThreadId ?? null); | |
| 206 | + const resumeCursor = yield* resolveResumeCursorForSession(existingSessionId); | |
| 207 | + const restartedSession = yield* startProviderSession(resumeCursor); | |
| 202 | 208 | yield* bindSessionToThread(restartedSession); |
| 203 | 209 | yield* providerService.stopSession({ sessionId: existingSessionId }).pipe( |
| 204 | 210 | Effect.catchCause((cause) => |
| @@ -150,6 +150,7 @@ describe("decider project scripts", () => { · 149 unmodified lines | ||
| 150 | 150 | text: "hello", |
| 151 | 151 | attachments: [], |
| 152 | 152 | }, |
| 153 | + provider: "claudeCode", | |
| 153 | 154 | model: "gpt-5", |
| 154 | 155 | effort: "high", |
| 155 | 156 | approvalPolicy: "on-request", |
| @@ -174,6 +175,7 @@ describe("decider project scripts", () => { · 18 unmodified lines | ||
| 174 | 175 | expect(turnStartEvent.payload).toMatchObject({ |
| 175 | 176 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 176 | 177 | messageId: asMessageId("message-user-1"), |
| 178 | + provider: "claudeCode", | |
| 177 | 179 | model: "gpt-5", |
| 178 | 180 | effort: "high", |
| 179 | 181 | approvalPolicy: "on-request", |
| @@ -251,6 +251,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 250 unmodified lines | ||
| 251 | 251 | payload: { |
| 252 | 252 | threadId: command.threadId, |
| 253 | 253 | messageId: command.message.messageId, |
| 254 | + ...(command.provider !== undefined ? { provider: command.provider } : {}), | |
| 254 | 255 | ...(command.model !== undefined ? { model: command.model } : {}), |
| 255 | 256 | ...(command.effort !== undefined ? { effort: command.effort } : {}), |
| 256 | 257 | assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, |
| @@ -61,11 +61,13 @@ function makeFakeCodexAdapter() { · 60 unmodified lines | ||
| 61 | 61 | Effect.sync(() => { |
| 62 | 62 | const now = new Date().toISOString(); |
| 63 | 63 | const next = nextSession; |
| 64 | + const threadId = ProviderThreadId.makeUnsafe(`thread-${next}`); | |
| 64 | 65 | const session: ProviderSession = { |
| 65 | 66 | sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`), |
| 66 | 67 | provider: "codex", |
| 67 | 68 | status: "ready", |
| 68 | − threadId: input.resumeThreadId ?? ProviderThreadId.makeUnsafe(`thread-${next}`), | |
| 69 | + threadId, | |
| 70 | + resumeCursor: input.resumeCursor ?? { opaque: `cursor-${next}` }, | |
| 69 | 71 | cwd: input.cwd ?? process.cwd(), |
| 70 | 72 | createdAt: now, |
| 71 | 73 | updatedAt: now, |
| @@ -344,7 +346,7 @@ it.effect( · 272 unmodified lines | ||
| 344 | 346 | assert.equal(Option.isSome(persistedAfterStopAll), true); |
| 345 | 347 | if (Option.isSome(persistedAfterStopAll)) { |
| 346 | 348 | assert.equal(persistedAfterStopAll.value.status, "stopped"); |
| 347 | − assert.equal(persistedAfterStopAll.value.resumeCursor, null); | |
| 349 | + assert.deepEqual(persistedAfterStopAll.value.resumeCursor, startedSession.resumeCursor); | |
| 348 | 350 | } |
| 349 | 351 | |
| 350 | 352 | const secondCodex = makeFakeCodexAdapter(); |
| @@ -380,6 +382,7 @@ it.effect( · 29 unmodified lines | ||
| 380 | 382 | provider: "codex", |
| 381 | 383 | cwd: "/tmp/project", |
| 382 | 384 | resumeThreadId: startedSession.threadId, |
| 385 | + resumeCursor: startedSession.resumeCursor, | |
| 383 | 386 | }, |
| 384 | 387 | ], |
| 385 | 388 | ]); |
| @@ -468,6 +471,7 @@ routing.layer("ProviderServiceLive routing", (it) => { · 82 unmodified lines | ||
| 468 | 471 | provider: "codex", |
| 469 | 472 | cwd: "/tmp/project", |
| 470 | 473 | resumeThreadId: initial.threadId, |
| 474 | + resumeCursor: initial.resumeCursor, | |
| 471 | 475 | }, |
| 472 | 476 | ], |
| 473 | 477 | ]); |
| @@ -547,7 +551,7 @@ routing.layer("ProviderServiceLive routing", (it) => { · 73 unmodified lines | ||
| 547 | 551 | assert.equal(Option.isSome(runningRuntime), true); |
| 548 | 552 | if (Option.isSome(runningRuntime)) { |
| 549 | 553 | assert.equal(runningRuntime.value.status, "running"); |
| 550 | − assert.equal(runningRuntime.value.resumeCursor, null); | |
| 554 | + assert.deepEqual(runningRuntime.value.resumeCursor, session.resumeCursor); | |
| 551 | 555 | const payload = runningRuntime.value.runtimePayload; |
| 552 | 556 | assert.equal(payload !== null && typeof payload === "object", true); |
| 553 | 557 | if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { |
| @@ -271,7 +271,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 270 unmodified lines | ||
| 271 | 271 | } as const; |
| 272 | 272 | } |
| 273 | 273 | |
| 274 | − if (!resumeThreadId && !hasResumeCursor) { | |
| 274 | + if (!hasResumeCursor) { | |
| 275 | 275 | return yield* toValidationError( |
| 276 | 276 | input.operation, |
| 277 | 277 | `Cannot recover stale session '${input.staleSessionId}' because no provider resume state is persisted.`, |
| @@ -119,7 +119,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL · 118 unmodified lines | ||
| 119 | 119 | providerThreadId, |
| 120 | 120 | status: "starting", |
| 121 | 121 | resumeCursor: { |
| 122 | − resumeThreadId: "provider-thread-runtime", | |
| 122 | + threadId: "provider-thread-runtime", | |
| 123 | 123 | }, |
| 124 | 124 | runtimePayload: { |
| 125 | 125 | cwd: "/tmp/project", |
| @@ -145,7 +145,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL · 19 unmodified lines | ||
| 145 | 145 | assert.equal(runtime.value.providerThreadId, providerThreadId); |
| 146 | 146 | assert.equal(runtime.value.status, "running"); |
| 147 | 147 | assert.deepEqual(runtime.value.resumeCursor, { |
| 148 | − resumeThreadId: providerThreadId, | |
| 148 | + threadId: providerThreadId, | |
| 149 | 149 | }); |
| 150 | 150 | assert.deepEqual(runtime.value.runtimePayload, { |
| 151 | 151 | cwd: "/tmp/project", |
| @@ -2,14 +2,24 @@ import { assert, it } from "@effect/vitest"; · 1 unmodified line | ||
| 2 | 2 | import { Effect, Schema } from "effect"; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | + DEFAULT_PROVIDER_APPROVAL_POLICY, | |
| 6 | + DEFAULT_PROVIDER_SANDBOX_MODE, | |
| 5 | 7 | OrchestrationGetTurnDiffInput, |
| 8 | + OrchestrationSession, | |
| 6 | 9 | ProjectCreateCommand, |
| 10 | + ThreadTurnStartCommand, | |
| 7 | 11 | ThreadTurnDiff, |
| 12 | + ThreadTurnStartRequestedPayload, | |
| 8 | 13 | } from "./orchestration"; |
| 9 | 14 | |
| 10 | 15 | const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); |
| 11 | 16 | const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); |
| 12 | 17 | const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); |
| 18 | +const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand); | |
| 19 | +const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( | |
| 20 | + ThreadTurnStartRequestedPayload, | |
| 21 | +); | |
| 22 | +const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); | |
| 13 | 23 | |
| 14 | 24 | it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => |
| 15 | 25 | Effect.gen(function* () { |
| @@ -84,3 +94,76 @@ it.effect("rejects command fields that become empty after trim", () => · 68 unmodified lines | ||
| 84 | 94 | assert.strictEqual(result._tag, "Failure"); |
| 85 | 95 | }), |
| 86 | 96 | ); |
| 97 | + | |
| 98 | +it.effect("decodes thread.turn.start defaults for provider and runtime mode", () => | |
| 99 | + Effect.gen(function* () { | |
| 100 | + const parsed = yield* decodeThreadTurnStartCommand({ | |
| 101 | + type: "thread.turn.start", | |
| 102 | + commandId: "cmd-turn-1", | |
| 103 | + threadId: "thread-1", | |
| 104 | + message: { | |
| 105 | + messageId: "msg-1", | |
| 106 | + role: "user", | |
| 107 | + text: "hello", | |
| 108 | + attachments: [], | |
| 109 | + }, | |
| 110 | + createdAt: "2026-01-01T00:00:00.000Z", | |
| 111 | + }); | |
| 112 | + assert.strictEqual(parsed.provider, undefined); | |
| 113 | + assert.strictEqual(parsed.approvalPolicy, DEFAULT_PROVIDER_APPROVAL_POLICY); | |
| 114 | + assert.strictEqual(parsed.sandboxMode, DEFAULT_PROVIDER_SANDBOX_MODE); | |
| 115 | + }), | |
| 116 | +); | |
| 117 | + | |
| 118 | +it.effect("preserves explicit provider and runtime mode in thread.turn.start", () => | |
| 119 | + Effect.gen(function* () { | |
| 120 | + const parsed = yield* decodeThreadTurnStartCommand({ | |
| 121 | + type: "thread.turn.start", | |
| 122 | + commandId: "cmd-turn-2", | |
| 123 | + threadId: "thread-1", | |
| 124 | + message: { | |
| 125 | + messageId: "msg-2", | |
| 126 | + role: "user", | |
| 127 | + text: "hello", | |
| 128 | + attachments: [], | |
| 129 | + }, | |
| 130 | + provider: "claudeCode", | |
| 131 | + approvalPolicy: "never", | |
| 132 | + sandboxMode: "danger-full-access", | |
| 133 | + createdAt: "2026-01-01T00:00:00.000Z", | |
| 134 | + }); | |
| 135 | + assert.strictEqual(parsed.provider, "claudeCode"); | |
| 136 | + assert.strictEqual(parsed.approvalPolicy, "never"); | |
| 137 | + assert.strictEqual(parsed.sandboxMode, "danger-full-access"); | |
| 138 | + }), | |
| 139 | +); | |
| 140 | + | |
| 141 | +it.effect("decodes thread.turn-start-requested defaults for provider and runtime mode", () => | |
| 142 | + Effect.gen(function* () { | |
| 143 | + const parsed = yield* decodeThreadTurnStartRequestedPayload({ | |
| 144 | + threadId: "thread-1", | |
| 145 | + messageId: "msg-1", | |
| 146 | + createdAt: "2026-01-01T00:00:00.000Z", | |
| 147 | + }); | |
| 148 | + assert.strictEqual(parsed.provider, undefined); | |
| 149 | + assert.strictEqual(parsed.approvalPolicy, DEFAULT_PROVIDER_APPROVAL_POLICY); | |
| 150 | + assert.strictEqual(parsed.sandboxMode, DEFAULT_PROVIDER_SANDBOX_MODE); | |
| 151 | + }), | |
| 152 | +); | |
| 153 | + | |
| 154 | +it.effect("decodes orchestration session runtime mode defaults", () => | |
| 155 | + Effect.gen(function* () { | |
| 156 | + const parsed = yield* decodeOrchestrationSession({ | |
| 157 | + threadId: "thread-1", | |
| 158 | + status: "idle", | |
| 159 | + providerName: null, | |
| 160 | + providerSessionId: null, | |
| 161 | + providerThreadId: null, | |
| 162 | + activeTurnId: null, | |
| 163 | + lastError: null, | |
| 164 | + updatedAt: "2026-01-01T00:00:00.000Z", | |
| 165 | + }); | |
| 166 | + assert.strictEqual(parsed.approvalPolicy, DEFAULT_PROVIDER_APPROVAL_POLICY); | |
| 167 | + assert.strictEqual(parsed.sandboxMode, DEFAULT_PROVIDER_SANDBOX_MODE); | |
| 168 | + }), | |
| 169 | +); | |
| @@ -31,6 +31,7 @@ export const ORCHESTRATION_WS_CHANNELS = { · 30 unmodified lines | ||
| 31 | 31 | |
| 32 | 32 | export const ProviderKind = Schema.Literals(["codex", "claudeCode"]); |
| 33 | 33 | export type ProviderKind = typeof ProviderKind.Type; |
| 34 | +export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; | |
| 34 | 35 | export const ProviderApprovalPolicy = Schema.Literals([ |
| 35 | 36 | "untrusted", |
| 36 | 37 | "on-failure", |
| @@ -38,12 +39,14 @@ export const ProviderApprovalPolicy = Schema.Literals([ · 1 unmodified line | ||
| 38 | 39 | "never", |
| 39 | 40 | ]); |
| 40 | 41 | export type ProviderApprovalPolicy = typeof ProviderApprovalPolicy.Type; |
| 42 | +export const DEFAULT_PROVIDER_APPROVAL_POLICY: ProviderApprovalPolicy = "on-failure"; | |
| 41 | 43 | export const ProviderSandboxMode = Schema.Literals([ |
| 42 | 44 | "read-only", |
| 43 | 45 | "workspace-write", |
| 44 | 46 | "danger-full-access", |
| 45 | 47 | ]); |
| 46 | 48 | export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; |
| 49 | +export const DEFAULT_PROVIDER_SANDBOX_MODE: ProviderSandboxMode = "workspace-write"; | |
| 47 | 50 | export const ProviderRequestKind = Schema.Literals(["command", "file-change"]); |
| 48 | 51 | export type ProviderRequestKind = typeof ProviderRequestKind.Type; |
| 49 | 52 | export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); |
| @@ -142,8 +145,12 @@ export const OrchestrationSession = Schema.Struct({ · 92 unmodified lines | ||
| 142 | 145 | providerName: Schema.NullOr(TrimmedNonEmptyString), |
| 143 | 146 | providerSessionId: Schema.NullOr(ProviderSessionId), |
| 144 | 147 | providerThreadId: Schema.NullOr(ProviderThreadId), |
| 145 | − approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")), | |
| 146 | − sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")), | |
| 148 | + approvalPolicy: ProviderApprovalPolicy.pipe( | |
| 149 | + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY), | |
| 150 | + ), | |
| 151 | + sandboxMode: ProviderSandboxMode.pipe( | |
| 152 | + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE), | |
| 153 | + ), | |
| 147 | 154 | activeTurnId: Schema.NullOr(TurnId), |
| 148 | 155 | lastError: Schema.NullOr(TrimmedNonEmptyString), |
| 149 | 156 | updatedAt: IsoDateTime, |
| @@ -299,11 +306,16 @@ export const ThreadTurnStartCommand = Schema.Struct({ · 149 unmodified lines | ||
| 299 | 306 | text: Schema.String, |
| 300 | 307 | attachments: Schema.Array(ChatAttachment), |
| 301 | 308 | }), |
| 309 | + provider: Schema.optional(ProviderKind), | |
| 302 | 310 | model: Schema.optional(TrimmedNonEmptyString), |
| 303 | 311 | effort: Schema.optional(TrimmedNonEmptyString), |
| 304 | 312 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| 305 | − approvalPolicy: ProviderApprovalPolicy, | |
| 306 | − sandboxMode: ProviderSandboxMode, | |
| 313 | + approvalPolicy: ProviderApprovalPolicy.pipe( | |
| 314 | + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY), | |
| 315 | + ), | |
| 316 | + sandboxMode: ProviderSandboxMode.pipe( | |
| 317 | + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE), | |
| 318 | + ), | |
| 307 | 319 | createdAt: IsoDateTime, |
| 308 | 320 | }); |
| 309 | 321 | |
| @@ -515,11 +527,16 @@ export const ThreadMessageSentPayload = Schema.Struct({ · 205 unmodified lines | ||
| 515 | 527 | export const ThreadTurnStartRequestedPayload = Schema.Struct({ |
| 516 | 528 | threadId: ThreadId, |
| 517 | 529 | messageId: MessageId, |
| 530 | + provider: Schema.optional(ProviderKind), | |
| 518 | 531 | model: Schema.optional(TrimmedNonEmptyString), |
| 519 | 532 | effort: Schema.optional(TrimmedNonEmptyString), |
| 520 | 533 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| 521 | − approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")), | |
| 522 | − sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")), | |
| 534 | + approvalPolicy: ProviderApprovalPolicy.pipe( | |
| 535 | + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY), | |
| 536 | + ), | |
| 537 | + sandboxMode: ProviderSandboxMode.pipe( | |
| 538 | + Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE), | |
| 539 | + ), | |
| 523 | 540 | createdAt: IsoDateTime, |
| 524 | 541 | }); |
| 525 | 542 | |
| @@ -0,0 +1,49 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { Schema } from "effect"; | |
| 3 | + | |
| 4 | +import { ProviderSessionStartInput } from "./provider"; | |
| 5 | + | |
| 6 | +const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput); | |
| 7 | + | |
| 8 | +describe("ProviderSessionStartInput", () => { | |
| 9 | + it("accepts codex-compatible payloads", () => { | |
| 10 | + const parsed = decodeProviderSessionStartInput({ | |
| 11 | + provider: "codex", | |
| 12 | + cwd: "/tmp/workspace", | |
| 13 | + model: "gpt-5.3-codex", | |
| 14 | + providerOptions: { | |
| 15 | + codex: { | |
| 16 | + binaryPath: "/usr/local/bin/codex", | |
| 17 | + homePath: "/tmp/.codex", | |
| 18 | + }, | |
| 19 | + }, | |
| 20 | + }); | |
| 21 | + expect(parsed.approvalPolicy).toBeUndefined(); | |
| 22 | + expect(parsed.sandboxMode).toBeUndefined(); | |
| 23 | + expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex"); | |
| 24 | + expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex"); | |
| 25 | + }); | |
| 26 | + | |
| 27 | + it("accepts claude runtime knobs", () => { | |
| 28 | + const parsed = decodeProviderSessionStartInput({ | |
| 29 | + provider: "claudeCode", | |
| 30 | + cwd: "/tmp/workspace", | |
| 31 | + model: "claude-sonnet-4-5", | |
| 32 | + providerOptions: { | |
| 33 | + claudeCode: { | |
| 34 | + binaryPath: "/usr/local/bin/claude", | |
| 35 | + permissionMode: "plan", | |
| 36 | + maxThinkingTokens: 12_000, | |
| 37 | + }, | |
| 38 | + }, | |
| 39 | + approvalPolicy: "never", | |
| 40 | + sandboxMode: "danger-full-access", | |
| 41 | + }); | |
| 42 | + expect(parsed.provider).toBe("claudeCode"); | |
| 43 | + expect(parsed.providerOptions?.claudeCode?.binaryPath).toBe("/usr/local/bin/claude"); | |
| 44 | + expect(parsed.providerOptions?.claudeCode?.permissionMode).toBe("plan"); | |
| 45 | + expect(parsed.providerOptions?.claudeCode?.maxThinkingTokens).toBe(12_000); | |
| 46 | + expect(parsed.approvalPolicy).toBe("never"); | |
| 47 | + expect(parsed.sandboxMode).toBe("danger-full-access"); | |
| 48 | + }); | |
| 49 | +}); | |
| @@ -49,14 +49,31 @@ export const ProviderSession = Schema.Struct({ · 48 unmodified lines | ||
| 49 | 49 | }); |
| 50 | 50 | export type ProviderSession = typeof ProviderSession.Type; |
| 51 | 51 | |
| 52 | +export const CodexProviderStartOptions = Schema.Struct({ | |
| 53 | + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 54 | + homePath: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 55 | +}); | |
| 56 | +export type CodexProviderStartOptions = typeof CodexProviderStartOptions.Type; | |
| 57 | + | |
| 58 | +export const ClaudeCodeProviderStartOptions = Schema.Struct({ | |
| 59 | + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 60 | + permissionMode: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 61 | + maxThinkingTokens: Schema.optional(NonNegativeInt), | |
| 62 | +}); | |
| 63 | +export type ClaudeCodeProviderStartOptions = typeof ClaudeCodeProviderStartOptions.Type; | |
| 64 | + | |
| 65 | +export const ProviderStartOptions = Schema.Struct({ | |
| 66 | + codex: Schema.optional(CodexProviderStartOptions), | |
| 67 | + claudeCode: Schema.optional(ClaudeCodeProviderStartOptions), | |
| 68 | +}); | |
| 69 | +export type ProviderStartOptions = typeof ProviderStartOptions.Type; | |
| 70 | + | |
| 52 | 71 | export const ProviderSessionStartInput = Schema.Struct({ |
| 53 | 72 | provider: Schema.optional(ProviderKind), |
| 54 | 73 | cwd: Schema.optional(TrimmedNonEmptyStringSchema), |
| 55 | 74 | model: Schema.optional(TrimmedNonEmptyStringSchema), |
| 56 | − resumeThreadId: Schema.optional(ProviderThreadId), | |
| 57 | 75 | resumeCursor: Schema.optional(Schema.Unknown), |
| 58 | − codexBinaryPath: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 59 | − codexHomePath: Schema.optional(TrimmedNonEmptyStringSchema), | |
| 76 | + providerOptions: Schema.optional(ProviderStartOptions), | |
| 60 | 77 | approvalPolicy: Schema.optional(ProviderApprovalPolicy), |
| 61 | 78 | sandboxMode: Schema.optional(ProviderSandboxMode), |
| 62 | 79 | }); |