| author | |
| committer | |
| log | 484662b2d4bccc1841bb421c9cfd43433b788fd4 |
| tree | effb3778fb951254675c319428f03f24c0c038fa |
| parent | 2cd04f08c171ad69b7ca47ace875173181a054ea |
13 files changed, 257 insertions(+), 59 deletions(-)
apps/server/src/codexAppServerManager.test.ts-6| ... | ... | @@ -201,9 +201,6 @@ describe("sendTurn", () => { |
| 201 | 201 | expect(result).toEqual({ |
| 202 | 202 | threadId: "thread_1", |
| 203 | 203 | turnId: "turn_1", |
| 204 | resumeCursor: { | |
| 205 | threadId: "thread_1", | |
| 206 | }, | |
| 207 | 204 | }); |
| 208 | 205 | expect(requireSession).toHaveBeenCalledWith("sess_1"); |
| 209 | 206 | expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { |
| ... | ... | @@ -225,9 +222,6 @@ describe("sendTurn", () => { |
| 225 | 222 | expect(updateSession).toHaveBeenCalledWith(context, { |
| 226 | 223 | status: "running", |
| 227 | 224 | activeTurnId: "turn_1", |
| 228 | resumeCursor: { | |
| 229 | threadId: "thread_1", | |
| 230 | }, | |
| 231 | 225 | }); |
| 232 | 226 | }); |
| 233 | 227 |
apps/server/src/codexAppServerManager.ts+32-13| ... | ... | @@ -193,8 +193,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 193 | 193 | updatedAt: now, |
| 194 | 194 | }; |
| 195 | 195 | |
| 196 | const codexBinaryPath = input.codexBinaryPath ?? "codex"; | |
| 197 | const codexHomePath = input.codexHomePath; | |
| 196 | const codexOptions = readCodexProviderOptions(input); | |
| 197 | const codexBinaryPath = codexOptions.binaryPath ?? "codex"; | |
| 198 | const codexHomePath = codexOptions.homePath; | |
| 198 | 199 | const child = spawn(codexBinaryPath, ["app-server"], { |
| 199 | 200 | cwd: resolvedCwd, |
| 200 | 201 | env: { |
| ... | ... | @@ -374,13 +375,17 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 374 | 375 | this.updateSession(context, { |
| 375 | 376 | status: "running", |
| 376 | 377 | activeTurnId: turnId, |
| 377 | resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId }, | |
| 378 | ...(context.session.resumeCursor !== undefined | |
| 379 | ? { resumeCursor: context.session.resumeCursor } | |
| 380 | : {}), | |
| 378 | 381 | }); |
| 379 | 382 | |
| 380 | 383 | return { |
| 381 | 384 | threadId: context.session.threadId, |
| 382 | 385 | turnId, |
| 383 | resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId }, | |
| 386 | ...(context.session.resumeCursor !== undefined | |
| 387 | ? { resumeCursor: context.session.resumeCursor } | |
| 388 | : {}), | |
| 384 | 389 | }; |
| 385 | 390 | } |
| 386 | 391 | |
| ... | ... | @@ -1005,16 +1010,30 @@ function toProviderThreadId(value: string | undefined): ProviderThreadId | undef |
| 1005 | 1010 | return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe); |
| 1006 | 1011 | } |
| 1007 | 1012 | |
| 1008 | function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined { | |
| 1009 | if ( | |
| 1010 | !input.resumeCursor || | |
| 1011 | typeof input.resumeCursor !== "object" || | |
| 1012 | Array.isArray(input.resumeCursor) | |
| 1013 | ) { | |
| 1014 | return input.resumeThreadId; | |
| 1013 | function readCodexProviderOptions(input: ProviderSessionStartInput): { | |
| 1014 | readonly binaryPath?: string; | |
| 1015 | readonly homePath?: string; | |
| 1016 | } { | |
| 1017 | const options = input.providerOptions?.codex; | |
| 1018 | if (!options) { | |
| 1019 | return {}; | |
| 1020 | } | |
| 1021 | return { | |
| 1022 | ...(options.binaryPath ? { binaryPath: options.binaryPath } : {}), | |
| 1023 | ...(options.homePath ? { homePath: options.homePath } : {}), | |
| 1024 | }; | |
| 1025 | } | |
| 1026 | ||
| 1027 | function readResumeCursorThreadId(resumeCursor: unknown): ProviderThreadId | undefined { | |
| 1028 | if (!resumeCursor || typeof resumeCursor !== "object" || Array.isArray(resumeCursor)) { | |
| 1029 | return undefined; | |
| 1015 | 1030 | } |
| 1016 | const rawThreadId = (input.resumeCursor as Record<string, unknown>).threadId; | |
| 1017 | return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : input.resumeThreadId; | |
| 1031 | const rawThreadId = (resumeCursor as Record<string, unknown>).threadId; | |
| 1032 | return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : undefined; | |
| 1033 | } | |
| 1034 | ||
| 1035 | function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined { | |
| 1036 | return readResumeCursorThreadId(input.resumeCursor); | |
| 1018 | 1037 | } |
| 1019 | 1038 | |
| 1020 | 1039 | function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined { |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+27-19| ... | ... | @@ -1,8 +1,4 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | ||
| 5 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; | |
| 1 | import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts"; | |
| 6 | 2 | import { |
| 7 | 3 | ApprovalRequestId, |
| 8 | 4 | CommandId, |
| ... | ... | @@ -88,16 +84,24 @@ describe("ProviderCommandReactor", () => { |
| 88 | 84 | createdStateDirs.add(stateDir); |
| 89 | 85 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 90 | 86 | let nextSessionIndex = 1; |
| 91 | const startSession = vi.fn((_: unknown, __: unknown) => { | |
| 87 | const runtimeSessions: Array<ProviderSession> = []; | |
| 88 | const startSession = vi.fn((_: unknown, input: unknown) => { | |
| 92 | 89 | const sessionIndex = nextSessionIndex++; |
| 93 | return Effect.succeed({ | |
| 90 | const resumeCursor = | |
| 91 | typeof input === "object" && input !== null && "resumeCursor" in input | |
| 92 | ? input.resumeCursor | |
| 93 | : undefined; | |
| 94 | const session: ProviderSession = { | |
| 94 | 95 | sessionId: asSessionId(`sess-${sessionIndex}`), |
| 95 | 96 | provider: "codex" as const, |
| 96 | 97 | status: "ready" as const, |
| 97 | 98 | threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`), |
| 99 | resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` }, | |
| 98 | 100 | createdAt: now, |
| 99 | 101 | updatedAt: now, |
| 100 | }); | |
| 102 | }; | |
| 103 | runtimeSessions.push(session); | |
| 104 | return Effect.succeed(session); | |
| 101 | 105 | }); |
| 102 | 106 | const sendTurn = vi.fn((_: unknown) => |
| 103 | 107 | Effect.succeed({ |
| ... | ... | @@ -107,15 +111,19 @@ describe("ProviderCommandReactor", () => { |
| 107 | 111 | ); |
| 108 | 112 | const interruptTurn = vi.fn((_: unknown) => Effect.void); |
| 109 | 113 | const respondToRequest = vi.fn((_: unknown) => Effect.void); |
| 110 | const stopSession = vi.fn((_: unknown) => Effect.void); | |
| 111 | const renameBranch = vi.fn((_: unknown) => | |
| 112 | Effect.succeed({ | |
| 113 | branch: "t3code/generated-name", | |
| 114 | }), | |
| 115 | ); | |
| 116 | const generateBranchName = vi.fn<TextGenerationShape["generateBranchName"]>(() => | |
| 117 | Effect.succeed({ | |
| 118 | branch: "generated-name", | |
| 114 | const stopSession = vi.fn((input: unknown) => | |
| 115 | Effect.sync(() => { | |
| 116 | const sessionId = | |
| 117 | typeof input === "object" && input !== null && "sessionId" in input | |
| 118 | ? (input as { sessionId?: ProviderSessionId }).sessionId | |
| 119 | : undefined; | |
| 120 | if (!sessionId) { | |
| 121 | return; | |
| 122 | } | |
| 123 | const index = runtimeSessions.findIndex((session) => session.sessionId === sessionId); | |
| 124 | if (index >= 0) { | |
| 125 | runtimeSessions.splice(index, 1); | |
| 126 | } | |
| 119 | 127 | }), |
| 120 | 128 | ); |
| 121 | 129 | |
| ... | ... | @@ -126,7 +134,7 @@ describe("ProviderCommandReactor", () => { |
| 126 | 134 | interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], |
| 127 | 135 | respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], |
| 128 | 136 | stopSession: stopSession as ProviderServiceShape["stopSession"], |
| 129 | listSessions: () => Effect.succeed([]), | |
| 137 | listSessions: () => Effect.succeed(runtimeSessions), | |
| 130 | 138 | rollbackConversation: () => unsupported(), |
| 131 | 139 | stopAll: () => Effect.void, |
| 132 | 140 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), |
| ... | ... | @@ -566,7 +574,7 @@ describe("ProviderCommandReactor", () => { |
| 566 | 574 | |
| 567 | 575 | expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); |
| 568 | 576 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ |
| 569 | resumeThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | |
| 577 | resumeCursor: { opaque: "cursor-1" }, | |
| 570 | 578 | approvalPolicy: "on-request", |
| 571 | 579 | sandboxMode: "workspace-write", |
| 572 | 580 | }); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+10-6| ... | ... | @@ -6,10 +6,10 @@ import { |
| 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"; |
| ... | ... | @@ -191,12 +191,17 @@ const make = Effect.gen(function* () { |
| 191 | 191 | projects: readModel.projects, |
| 192 | 192 | }); |
| 193 | 193 | |
| 194 | const startProviderSession = (resumeThreadId?: ProviderThreadId | null) => | |
| 194 | const resolveResumeCursorForSession = (sessionId: ProviderSessionId) => | |
| 195 | providerService.listSessions().pipe( | |
| 196 | Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)?.resumeCursor), | |
| 197 | ); | |
| 198 | ||
| 199 | const startProviderSession = (resumeCursor?: unknown) => | |
| 195 | 200 | providerService.startSession(threadId, { |
| 196 | 201 | ...(preferredProvider ? { provider: preferredProvider } : {}), |
| 197 | 202 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), |
| 198 | 203 | ...(thread.model ? { model: thread.model } : {}), |
| 199 | ...(resumeThreadId ? { resumeThreadId } : {}), | |
| 204 | ...(resumeCursor !== undefined ? { resumeCursor } : {}), | |
| 200 | 205 | approvalPolicy: desiredApprovalPolicy, |
| 201 | 206 | sandboxMode: desiredSandboxMode, |
| 202 | 207 | }); |
| ... | ... | @@ -232,9 +237,8 @@ const make = Effect.gen(function* () { |
| 232 | 237 | return existingSessionId; |
| 233 | 238 | } |
| 234 | 239 | |
| 235 | const restartedSession = yield* startProviderSession( | |
| 236 | thread.session?.providerThreadId ?? null, | |
| 237 | ); | |
| 240 | const resumeCursor = yield* resolveResumeCursorForSession(existingSessionId); | |
| 241 | const restartedSession = yield* startProviderSession(resumeCursor); | |
| 238 | 242 | yield* bindSessionToThread(restartedSession); |
| 239 | 243 | yield* providerService.stopSession({ sessionId: existingSessionId }).pipe( |
| 240 | 244 | Effect.catchCause((cause) => |
apps/server/src/orchestration/decider.projectScripts.test.ts+2| ... | ... | @@ -150,6 +150,7 @@ describe("decider project scripts", () => { |
| 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", () => { |
| 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", |
apps/server/src/orchestration/decider.ts+1| ... | ... | @@ -251,6 +251,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 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, |
apps/server/src/provider/Layers/ProviderService.test.ts+7-3| ... | ... | @@ -61,11 +61,13 @@ function makeFakeCodexAdapter() { |
| 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( |
| 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( |
| 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) => { |
| 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) => { |
| 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)) { |
apps/server/src/provider/Layers/ProviderService.ts+1-1| ... | ... | @@ -283,7 +283,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 283 | 283 | } as const; |
| 284 | 284 | } |
| 285 | 285 | |
| 286 | if (!resumeThreadId && !hasResumeCursor) { | |
| 286 | if (!hasResumeCursor) { | |
| 287 | 287 | return yield* toValidationError( |
| 288 | 288 | input.operation, |
| 289 | 289 | `Cannot recover stale session '${input.staleSessionId}' because no provider resume state is persisted.`, |
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+2-2| ... | ... | @@ -119,7 +119,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 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 |
| 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", |
packages/contracts/src/orchestration.test.ts+83| ... | ... | @@ -2,14 +2,24 @@ import { assert, it } from "@effect/vitest"; |
| 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", () => |
| 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 | ); |
packages/contracts/src/orchestration.ts+23-6| ... | ... | @@ -31,6 +31,7 @@ export const ORCHESTRATION_WS_CHANNELS = { |
| 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([ |
| 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"]); |
| ... | ... | @@ -160,8 +163,12 @@ export const OrchestrationSession = Schema.Struct({ |
| 160 | 163 | providerName: Schema.NullOr(TrimmedNonEmptyString), |
| 161 | 164 | providerSessionId: Schema.NullOr(ProviderSessionId), |
| 162 | 165 | providerThreadId: Schema.NullOr(ProviderThreadId), |
| 163 | approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")), | |
| 164 | sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")), | |
| 166 | approvalPolicy: ProviderApprovalPolicy.pipe( | |
| 167 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY), | |
| 168 | ), | |
| 169 | sandboxMode: ProviderSandboxMode.pipe( | |
| 170 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE), | |
| 171 | ), | |
| 165 | 172 | activeTurnId: Schema.NullOr(TurnId), |
| 166 | 173 | lastError: Schema.NullOr(TrimmedNonEmptyString), |
| 167 | 174 | updatedAt: IsoDateTime, |
| ... | ... | @@ -317,11 +324,16 @@ export const ThreadTurnStartCommand = Schema.Struct({ |
| 317 | 324 | text: Schema.String, |
| 318 | 325 | attachments: Schema.Array(ChatAttachment), |
| 319 | 326 | }), |
| 327 | provider: Schema.optional(ProviderKind), | |
| 320 | 328 | model: Schema.optional(TrimmedNonEmptyString), |
| 321 | 329 | effort: Schema.optional(TrimmedNonEmptyString), |
| 322 | 330 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| 323 | approvalPolicy: ProviderApprovalPolicy, | |
| 324 | sandboxMode: ProviderSandboxMode, | |
| 331 | approvalPolicy: ProviderApprovalPolicy.pipe( | |
| 332 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY), | |
| 333 | ), | |
| 334 | sandboxMode: ProviderSandboxMode.pipe( | |
| 335 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE), | |
| 336 | ), | |
| 325 | 337 | createdAt: IsoDateTime, |
| 326 | 338 | }); |
| 327 | 339 | |
| ... | ... | @@ -567,11 +579,16 @@ export const ThreadMessageSentPayload = Schema.Struct({ |
| 567 | 579 | export const ThreadTurnStartRequestedPayload = Schema.Struct({ |
| 568 | 580 | threadId: ThreadId, |
| 569 | 581 | messageId: MessageId, |
| 582 | provider: Schema.optional(ProviderKind), | |
| 570 | 583 | model: Schema.optional(TrimmedNonEmptyString), |
| 571 | 584 | effort: Schema.optional(TrimmedNonEmptyString), |
| 572 | 585 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| 573 | approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")), | |
| 574 | sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")), | |
| 586 | approvalPolicy: ProviderApprovalPolicy.pipe( | |
| 587 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY), | |
| 588 | ), | |
| 589 | sandboxMode: ProviderSandboxMode.pipe( | |
| 590 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE), | |
| 591 | ), | |
| 575 | 592 | createdAt: IsoDateTime, |
| 576 | 593 | }); |
| 577 | 594 |
packages/contracts/src/provider.test.ts created+49| ... | ... | @@ -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 | }); |
packages/contracts/src/provider.ts+20-3| ... | ... | @@ -49,14 +49,31 @@ export const ProviderSession = Schema.Struct({ |
| 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 | }); |