Unified · Split
| @@ -204,9 +204,6 @@ describe("sendTurn", () => { · 203 unmodified lines | |||
| 204 | expect(result).toEqual({ | 204 | expect(result).toEqual({ |
| 205 | threadId: "thread_1", | 205 | threadId: "thread_1", |
| 206 | turnId: "turn_1", | 206 | turnId: "turn_1", |
| 207 | resumeCursor: { | ||
| 208 | threadId: "thread_1", | ||
| 209 | }, | ||
| 210 | }); | 207 | }); |
| 211 | expect(requireSession).toHaveBeenCalledWith("sess_1"); | 208 | expect(requireSession).toHaveBeenCalledWith("sess_1"); |
| 212 | expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { | 209 | expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { |
| @@ -228,9 +225,6 @@ describe("sendTurn", () => { · 15 unmodified lines | |||
| 228 | expect(updateSession).toHaveBeenCalledWith(context, { | 225 | expect(updateSession).toHaveBeenCalledWith(context, { |
| 229 | status: "running", | 226 | status: "running", |
| 230 | activeTurnId: "turn_1", | 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 | updatedAt: now, | 169 | updatedAt: now, |
| 170 | }; | 170 | }; |
| 171 | 171 | ||
| 172 | const codexBinaryPath = input.codexBinaryPath ?? "codex"; | 172 | const codexOptions = readCodexProviderOptions(input); |
| 173 | const codexHomePath = input.codexHomePath; | 173 | const codexBinaryPath = codexOptions.binaryPath ?? "codex"; |
| 174 | const codexHomePath = codexOptions.homePath; | ||
| 174 | const child = spawn(codexBinaryPath, ["app-server"], { | 175 | const child = spawn(codexBinaryPath, ["app-server"], { |
| 175 | cwd: resolvedCwd, | 176 | cwd: resolvedCwd, |
| 176 | env: { | 177 | env: { |
| @@ -349,13 +350,17 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 172 unmodified lines | |||
| 349 | this.updateSession(context, { | 350 | this.updateSession(context, { |
| 350 | status: "running", | 351 | status: "running", |
| 351 | activeTurnId: turnId, | 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 | return { | 358 | return { |
| 356 | threadId: context.session.threadId, | 359 | threadId: context.session.threadId, |
| 357 | turnId, | 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 | return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe); | 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 | function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined { | 1010 | function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined { |
| 984 | if ( | 1011 | return readResumeCursorThreadId(input.resumeCursor); |
| 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; | ||
| 993 | } | 1012 | } |
| 994 | 1013 | ||
| 995 | function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined { | 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 | import { | 2 | import { |
| 3 | ApprovalRequestId, | 3 | ApprovalRequestId, |
| 4 | CommandId, | 4 | CommandId, |
| @@ -72,16 +72,24 @@ describe("ProviderCommandReactor", () => { · 67 unmodified lines | |||
| 72 | const now = new Date().toISOString(); | 72 | const now = new Date().toISOString(); |
| 73 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | 73 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 74 | let nextSessionIndex = 1; | 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 | const sessionIndex = nextSessionIndex++; | 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 | sessionId: asSessionId(`sess-${sessionIndex}`), | 83 | sessionId: asSessionId(`sess-${sessionIndex}`), |
| 79 | provider: "codex" as const, | 84 | provider: "codex" as const, |
| 80 | status: "ready" as const, | 85 | status: "ready" as const, |
| 81 | threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`), | 86 | threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`), |
| 87 | resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` }, | ||
| 82 | createdAt: now, | 88 | createdAt: now, |
| 83 | updatedAt: now, | 89 | updatedAt: now, |
| 84 | }); | 90 | }; |
| 91 | runtimeSessions.push(session); | ||
| 92 | return Effect.succeed(session); | ||
| 85 | }); | 93 | }); |
| 86 | const sendTurn = vi.fn((_: unknown) => | 94 | const sendTurn = vi.fn((_: unknown) => |
| 87 | Effect.succeed({ | 95 | Effect.succeed({ |
| @@ -91,7 +99,21 @@ describe("ProviderCommandReactor", () => { · 3 unmodified lines | |||
| 91 | ); | 99 | ); |
| 92 | const interruptTurn = vi.fn((_: unknown) => Effect.void); | 100 | const interruptTurn = vi.fn((_: unknown) => Effect.void); |
| 93 | const respondToRequest = vi.fn((_: unknown) => Effect.void); | 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 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; | 118 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; |
| 97 | const service: ProviderServiceShape = { | 119 | const service: ProviderServiceShape = { |
| @@ -100,7 +122,7 @@ describe("ProviderCommandReactor", () => { · 2 unmodified lines | |||
| 100 | interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], | 122 | interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"], |
| 101 | respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], | 123 | respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], |
| 102 | stopSession: stopSession as ProviderServiceShape["stopSession"], | 124 | stopSession: stopSession as ProviderServiceShape["stopSession"], |
| 103 | listSessions: () => Effect.succeed([]), | 125 | listSessions: () => Effect.succeed(runtimeSessions), |
| 104 | rollbackConversation: () => unsupported(), | 126 | rollbackConversation: () => unsupported(), |
| 105 | stopAll: () => Effect.void, | 127 | stopAll: () => Effect.void, |
| 106 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), | 128 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), |
| @@ -289,7 +311,7 @@ describe("ProviderCommandReactor", () => { · 182 unmodified lines | |||
| 289 | 311 | ||
| 290 | expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); | 312 | expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); |
| 291 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ | 313 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ |
| 292 | resumeThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), | 314 | resumeCursor: { opaque: "cursor-1" }, |
| 293 | approvalPolicy: "on-request", | 315 | approvalPolicy: "on-request", |
| 294 | sandboxMode: "workspace-write", | 316 | sandboxMode: "workspace-write", |
| 295 | }); | 317 | }); |
| @@ -6,10 +6,10 @@ import { · 5 unmodified lines | |||
| 6 | type ProviderApprovalPolicy, | 6 | type ProviderApprovalPolicy, |
| 7 | type ProviderKind, | 7 | type ProviderKind, |
| 8 | type ProviderSandboxMode, | 8 | type ProviderSandboxMode, |
| 9 | type ProviderSessionId, | ||
| 9 | type OrchestrationSession, | 10 | type OrchestrationSession, |
| 10 | type ThreadId, | 11 | type ThreadId, |
| 11 | type ProviderSession, | 12 | type ProviderSession, |
| 12 | type ProviderThreadId, | ||
| 13 | type TurnId, | 13 | type TurnId, |
| 14 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 15 | import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect"; | 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 | projects: readModel.projects, | 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 | providerService.startSession(threadId, { | 167 | providerService.startSession(threadId, { |
| 163 | ...(preferredProvider ? { provider: preferredProvider } : {}), | 168 | ...(preferredProvider ? { provider: preferredProvider } : {}), |
| 164 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), | 169 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), |
| 165 | ...(thread.model ? { model: thread.model } : {}), | 170 | ...(thread.model ? { model: thread.model } : {}), |
| 166 | ...(resumeThreadId ? { resumeThreadId } : {}), | 171 | ...(resumeCursor !== undefined ? { resumeCursor } : {}), |
| 167 | approvalPolicy: desiredApprovalPolicy, | 172 | approvalPolicy: desiredApprovalPolicy, |
| 168 | sandboxMode: desiredSandboxMode, | 173 | sandboxMode: desiredSandboxMode, |
| 169 | }); | 174 | }); |
| @@ -198,7 +203,8 @@ const make = Effect.gen(function* () { · 28 unmodified lines | |||
| 198 | return existingSessionId; | 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 | yield* bindSessionToThread(restartedSession); | 208 | yield* bindSessionToThread(restartedSession); |
| 203 | yield* providerService.stopSession({ sessionId: existingSessionId }).pipe( | 209 | yield* providerService.stopSession({ sessionId: existingSessionId }).pipe( |
| 204 | Effect.catchCause((cause) => | 210 | Effect.catchCause((cause) => |
| @@ -150,6 +150,7 @@ describe("decider project scripts", () => { · 149 unmodified lines | |||
| 150 | text: "hello", | 150 | text: "hello", |
| 151 | attachments: [], | 151 | attachments: [], |
| 152 | }, | 152 | }, |
| 153 | provider: "claudeCode", | ||
| 153 | model: "gpt-5", | 154 | model: "gpt-5", |
| 154 | effort: "high", | 155 | effort: "high", |
| 155 | approvalPolicy: "on-request", | 156 | approvalPolicy: "on-request", |
| @@ -174,6 +175,7 @@ describe("decider project scripts", () => { · 18 unmodified lines | |||
| 174 | expect(turnStartEvent.payload).toMatchObject({ | 175 | expect(turnStartEvent.payload).toMatchObject({ |
| 175 | threadId: ThreadId.makeUnsafe("thread-1"), | 176 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 176 | messageId: asMessageId("message-user-1"), | 177 | messageId: asMessageId("message-user-1"), |
| 178 | provider: "claudeCode", | ||
| 177 | model: "gpt-5", | 179 | model: "gpt-5", |
| 178 | effort: "high", | 180 | effort: "high", |
| 179 | approvalPolicy: "on-request", | 181 | approvalPolicy: "on-request", |
| @@ -251,6 +251,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 250 unmodified lines | |||
| 251 | payload: { | 251 | payload: { |
| 252 | threadId: command.threadId, | 252 | threadId: command.threadId, |
| 253 | messageId: command.message.messageId, | 253 | messageId: command.message.messageId, |
| 254 | ...(command.provider !== undefined ? { provider: command.provider } : {}), | ||
| 254 | ...(command.model !== undefined ? { model: command.model } : {}), | 255 | ...(command.model !== undefined ? { model: command.model } : {}), |
| 255 | ...(command.effort !== undefined ? { effort: command.effort } : {}), | 256 | ...(command.effort !== undefined ? { effort: command.effort } : {}), |
| 256 | assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, | 257 | assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, |
| @@ -61,11 +61,13 @@ function makeFakeCodexAdapter() { · 60 unmodified lines | |||
| 61 | Effect.sync(() => { | 61 | Effect.sync(() => { |
| 62 | const now = new Date().toISOString(); | 62 | const now = new Date().toISOString(); |
| 63 | const next = nextSession; | 63 | const next = nextSession; |
| 64 | const threadId = ProviderThreadId.makeUnsafe(`thread-${next}`); | ||
| 64 | const session: ProviderSession = { | 65 | const session: ProviderSession = { |
| 65 | sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`), | 66 | sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`), |
| 66 | provider: "codex", | 67 | provider: "codex", |
| 67 | status: "ready", | 68 | status: "ready", |
| 68 | threadId: input.resumeThreadId ?? ProviderThreadId.makeUnsafe(`thread-${next}`), | 69 | threadId, |
| 70 | resumeCursor: input.resumeCursor ?? { opaque: `cursor-${next}` }, | ||
| 69 | cwd: input.cwd ?? process.cwd(), | 71 | cwd: input.cwd ?? process.cwd(), |
| 70 | createdAt: now, | 72 | createdAt: now, |
| 71 | updatedAt: now, | 73 | updatedAt: now, |
| @@ -344,7 +346,7 @@ it.effect( · 272 unmodified lines | |||
| 344 | assert.equal(Option.isSome(persistedAfterStopAll), true); | 346 | assert.equal(Option.isSome(persistedAfterStopAll), true); |
| 345 | if (Option.isSome(persistedAfterStopAll)) { | 347 | if (Option.isSome(persistedAfterStopAll)) { |
| 346 | assert.equal(persistedAfterStopAll.value.status, "stopped"); | 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 | const secondCodex = makeFakeCodexAdapter(); | 352 | const secondCodex = makeFakeCodexAdapter(); |
| @@ -380,6 +382,7 @@ it.effect( · 29 unmodified lines | |||
| 380 | provider: "codex", | 382 | provider: "codex", |
| 381 | cwd: "/tmp/project", | 383 | cwd: "/tmp/project", |
| 382 | resumeThreadId: startedSession.threadId, | 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 | provider: "codex", | 471 | provider: "codex", |
| 469 | cwd: "/tmp/project", | 472 | cwd: "/tmp/project", |
| 470 | resumeThreadId: initial.threadId, | 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 | assert.equal(Option.isSome(runningRuntime), true); | 551 | assert.equal(Option.isSome(runningRuntime), true); |
| 548 | if (Option.isSome(runningRuntime)) { | 552 | if (Option.isSome(runningRuntime)) { |
| 549 | assert.equal(runningRuntime.value.status, "running"); | 553 | assert.equal(runningRuntime.value.status, "running"); |
| 550 | assert.equal(runningRuntime.value.resumeCursor, null); | 554 | assert.deepEqual(runningRuntime.value.resumeCursor, session.resumeCursor); |
| 551 | const payload = runningRuntime.value.runtimePayload; | 555 | const payload = runningRuntime.value.runtimePayload; |
| 552 | assert.equal(payload !== null && typeof payload === "object", true); | 556 | assert.equal(payload !== null && typeof payload === "object", true); |
| 553 | if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { | 557 | if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { |
| @@ -271,7 +271,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 270 unmodified lines | |||
| 271 | } as const; | 271 | } as const; |
| 272 | } | 272 | } |
| 273 | 273 | ||
| 274 | if (!resumeThreadId && !hasResumeCursor) { | 274 | if (!hasResumeCursor) { |
| 275 | return yield* toValidationError( | 275 | return yield* toValidationError( |
| 276 | input.operation, | 276 | input.operation, |
| 277 | `Cannot recover stale session '${input.staleSessionId}' because no provider resume state is persisted.`, | 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 | providerThreadId, | 119 | providerThreadId, |
| 120 | status: "starting", | 120 | status: "starting", |
| 121 | resumeCursor: { | 121 | resumeCursor: { |
| 122 | resumeThreadId: "provider-thread-runtime", | 122 | threadId: "provider-thread-runtime", |
| 123 | }, | 123 | }, |
| 124 | runtimePayload: { | 124 | runtimePayload: { |
| 125 | cwd: "/tmp/project", | 125 | cwd: "/tmp/project", |
| @@ -145,7 +145,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL · 19 unmodified lines | |||
| 145 | assert.equal(runtime.value.providerThreadId, providerThreadId); | 145 | assert.equal(runtime.value.providerThreadId, providerThreadId); |
| 146 | assert.equal(runtime.value.status, "running"); | 146 | assert.equal(runtime.value.status, "running"); |
| 147 | assert.deepEqual(runtime.value.resumeCursor, { | 147 | assert.deepEqual(runtime.value.resumeCursor, { |
| 148 | resumeThreadId: providerThreadId, | 148 | threadId: providerThreadId, |
| 149 | }); | 149 | }); |
| 150 | assert.deepEqual(runtime.value.runtimePayload, { | 150 | assert.deepEqual(runtime.value.runtimePayload, { |
| 151 | cwd: "/tmp/project", | 151 | cwd: "/tmp/project", |
| @@ -2,14 +2,24 @@ import { assert, it } from "@effect/vitest"; · 1 unmodified line | |||
| 2 | import { Effect, Schema } from "effect"; | 2 | import { Effect, Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | DEFAULT_PROVIDER_APPROVAL_POLICY, | ||
| 6 | DEFAULT_PROVIDER_SANDBOX_MODE, | ||
| 5 | OrchestrationGetTurnDiffInput, | 7 | OrchestrationGetTurnDiffInput, |
| 8 | OrchestrationSession, | ||
| 6 | ProjectCreateCommand, | 9 | ProjectCreateCommand, |
| 10 | ThreadTurnStartCommand, | ||
| 7 | ThreadTurnDiff, | 11 | ThreadTurnDiff, |
| 12 | ThreadTurnStartRequestedPayload, | ||
| 8 | } from "./orchestration"; | 13 | } from "./orchestration"; |
| 9 | 14 | ||
| 10 | const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); | 15 | const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); |
| 11 | const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); | 16 | const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); |
| 12 | const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); | 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 | it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => | 24 | it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => |
| 15 | Effect.gen(function* () { | 25 | Effect.gen(function* () { |
| @@ -84,3 +94,76 @@ it.effect("rejects command fields that become empty after trim", () => · 68 unmodified lines | |||
| 84 | assert.strictEqual(result._tag, "Failure"); | 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 | export const ProviderKind = Schema.Literals(["codex", "claudeCode"]); | 32 | export const ProviderKind = Schema.Literals(["codex", "claudeCode"]); |
| 33 | export type ProviderKind = typeof ProviderKind.Type; | 33 | export type ProviderKind = typeof ProviderKind.Type; |
| 34 | export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; | ||
| 34 | export const ProviderApprovalPolicy = Schema.Literals([ | 35 | export const ProviderApprovalPolicy = Schema.Literals([ |
| 35 | "untrusted", | 36 | "untrusted", |
| 36 | "on-failure", | 37 | "on-failure", |
| @@ -38,12 +39,14 @@ export const ProviderApprovalPolicy = Schema.Literals([ · 1 unmodified line | |||
| 38 | "never", | 39 | "never", |
| 39 | ]); | 40 | ]); |
| 40 | export type ProviderApprovalPolicy = typeof ProviderApprovalPolicy.Type; | 41 | export type ProviderApprovalPolicy = typeof ProviderApprovalPolicy.Type; |
| 42 | export const DEFAULT_PROVIDER_APPROVAL_POLICY: ProviderApprovalPolicy = "on-failure"; | ||
| 41 | export const ProviderSandboxMode = Schema.Literals([ | 43 | export const ProviderSandboxMode = Schema.Literals([ |
| 42 | "read-only", | 44 | "read-only", |
| 43 | "workspace-write", | 45 | "workspace-write", |
| 44 | "danger-full-access", | 46 | "danger-full-access", |
| 45 | ]); | 47 | ]); |
| 46 | export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; | 48 | export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; |
| 49 | export const DEFAULT_PROVIDER_SANDBOX_MODE: ProviderSandboxMode = "workspace-write"; | ||
| 47 | export const ProviderRequestKind = Schema.Literals(["command", "file-change"]); | 50 | export const ProviderRequestKind = Schema.Literals(["command", "file-change"]); |
| 48 | export type ProviderRequestKind = typeof ProviderRequestKind.Type; | 51 | export type ProviderRequestKind = typeof ProviderRequestKind.Type; |
| 49 | export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); | 52 | export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); |
| @@ -142,8 +145,12 @@ export const OrchestrationSession = Schema.Struct({ · 92 unmodified lines | |||
| 142 | providerName: Schema.NullOr(TrimmedNonEmptyString), | 145 | providerName: Schema.NullOr(TrimmedNonEmptyString), |
| 143 | providerSessionId: Schema.NullOr(ProviderSessionId), | 146 | providerSessionId: Schema.NullOr(ProviderSessionId), |
| 144 | providerThreadId: Schema.NullOr(ProviderThreadId), | 147 | providerThreadId: Schema.NullOr(ProviderThreadId), |
| 145 | approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")), | 148 | approvalPolicy: ProviderApprovalPolicy.pipe( |
| 146 | sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")), | 149 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY), |
| 150 | ), | ||
| 151 | sandboxMode: ProviderSandboxMode.pipe( | ||
| 152 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE), | ||
| 153 | ), | ||
| 147 | activeTurnId: Schema.NullOr(TurnId), | 154 | activeTurnId: Schema.NullOr(TurnId), |
| 148 | lastError: Schema.NullOr(TrimmedNonEmptyString), | 155 | lastError: Schema.NullOr(TrimmedNonEmptyString), |
| 149 | updatedAt: IsoDateTime, | 156 | updatedAt: IsoDateTime, |
| @@ -299,11 +306,16 @@ export const ThreadTurnStartCommand = Schema.Struct({ · 149 unmodified lines | |||
| 299 | text: Schema.String, | 306 | text: Schema.String, |
| 300 | attachments: Schema.Array(ChatAttachment), | 307 | attachments: Schema.Array(ChatAttachment), |
| 301 | }), | 308 | }), |
| 309 | provider: Schema.optional(ProviderKind), | ||
| 302 | model: Schema.optional(TrimmedNonEmptyString), | 310 | model: Schema.optional(TrimmedNonEmptyString), |
| 303 | effort: Schema.optional(TrimmedNonEmptyString), | 311 | effort: Schema.optional(TrimmedNonEmptyString), |
| 304 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | 312 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| 305 | approvalPolicy: ProviderApprovalPolicy, | 313 | approvalPolicy: ProviderApprovalPolicy.pipe( |
| 306 | sandboxMode: ProviderSandboxMode, | 314 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY), |
| 315 | ), | ||
| 316 | sandboxMode: ProviderSandboxMode.pipe( | ||
| 317 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE), | ||
| 318 | ), | ||
| 307 | createdAt: IsoDateTime, | 319 | createdAt: IsoDateTime, |
| 308 | }); | 320 | }); |
| 309 | 321 | ||
| @@ -515,11 +527,16 @@ export const ThreadMessageSentPayload = Schema.Struct({ · 205 unmodified lines | |||
| 515 | export const ThreadTurnStartRequestedPayload = Schema.Struct({ | 527 | export const ThreadTurnStartRequestedPayload = Schema.Struct({ |
| 516 | threadId: ThreadId, | 528 | threadId: ThreadId, |
| 517 | messageId: MessageId, | 529 | messageId: MessageId, |
| 530 | provider: Schema.optional(ProviderKind), | ||
| 518 | model: Schema.optional(TrimmedNonEmptyString), | 531 | model: Schema.optional(TrimmedNonEmptyString), |
| 519 | effort: Schema.optional(TrimmedNonEmptyString), | 532 | effort: Schema.optional(TrimmedNonEmptyString), |
| 520 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | 533 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| 521 | approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")), | 534 | approvalPolicy: ProviderApprovalPolicy.pipe( |
| 522 | sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")), | 535 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY), |
| 536 | ), | ||
| 537 | sandboxMode: ProviderSandboxMode.pipe( | ||
| 538 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE), | ||
| 539 | ), | ||
| 523 | createdAt: IsoDateTime, | 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 | export type ProviderSession = typeof ProviderSession.Type; | 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 | export const ProviderSessionStartInput = Schema.Struct({ | 71 | export const ProviderSessionStartInput = Schema.Struct({ |
| 53 | provider: Schema.optional(ProviderKind), | 72 | provider: Schema.optional(ProviderKind), |
| 54 | cwd: Schema.optional(TrimmedNonEmptyStringSchema), | 73 | cwd: Schema.optional(TrimmedNonEmptyStringSchema), |
| 55 | model: Schema.optional(TrimmedNonEmptyStringSchema), | 74 | model: Schema.optional(TrimmedNonEmptyStringSchema), |
| 56 | resumeThreadId: Schema.optional(ProviderThreadId), | ||
| 57 | resumeCursor: Schema.optional(Schema.Unknown), | 75 | resumeCursor: Schema.optional(Schema.Unknown), |
| 58 | codexBinaryPath: Schema.optional(TrimmedNonEmptyStringSchema), | 76 | providerOptions: Schema.optional(ProviderStartOptions), |
| 59 | codexHomePath: Schema.optional(TrimmedNonEmptyStringSchema), | ||
| 60 | approvalPolicy: Schema.optional(ProviderApprovalPolicy), | 77 | approvalPolicy: Schema.optional(ProviderApprovalPolicy), |
| 61 | sandboxMode: Schema.optional(ProviderSandboxMode), | 78 | sandboxMode: Schema.optional(ProviderSandboxMode), |
| 62 | }); | 79 | }); |