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