authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 00:47:43-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log484662b2d4bccc1841bb421c9cfd43433b788fd4
treeeffb3778fb951254675c319428f03f24c0c038fa
parent2cd04f08c171ad69b7ca47ace875173181a054ea

redo contracts a bit


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 });
233227
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 };
195195
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 });
379382
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 }
386391
...@@ -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}
10071012
1008function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined {1013function 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
1027function 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
1035function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined {
1036 return readResumeCursorThreadId(input.resumeCursor);
1018}1037}
10191038
1020function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined {1039function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined {
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+27-19
...@@ -1,8 +1,4 @@...@@ -1,8 +1,4 @@
1import fs from "node:fs";1import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
2import os from "node:os";
3import path from "node:path";
4
5import type { ProviderRuntimeEvent } from "@t3tools/contracts";
6import {2import {
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 );
121129
...@@ -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", () => {
566574
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";
15import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";15import { 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 });
193193
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 }
234239
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 }
349351
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 }
285285
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";
2import { Effect, Schema } from "effect";2import { Effect, Schema } from "effect";
33
4import {4import {
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";
914
10const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);15const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
11const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);16const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);
12const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);17const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);
18const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand);
19const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect(
20 ThreadTurnStartRequestedPayload,
21);
22const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession);
1323
14it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>24it.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
98it.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
118it.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
141it.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
154it.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 = {
3131
32export const ProviderKind = Schema.Literals(["codex", "claudeCode"]);32export const ProviderKind = Schema.Literals(["codex", "claudeCode"]);
33export type ProviderKind = typeof ProviderKind.Type;33export type ProviderKind = typeof ProviderKind.Type;
34export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
34export const ProviderApprovalPolicy = Schema.Literals([35export 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]);
40export type ProviderApprovalPolicy = typeof ProviderApprovalPolicy.Type;41export type ProviderApprovalPolicy = typeof ProviderApprovalPolicy.Type;
42export const DEFAULT_PROVIDER_APPROVAL_POLICY: ProviderApprovalPolicy = "on-failure";
41export const ProviderSandboxMode = Schema.Literals([43export 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]);
46export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;48export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;
49export const DEFAULT_PROVIDER_SANDBOX_MODE: ProviderSandboxMode = "workspace-write";
47export const ProviderRequestKind = Schema.Literals(["command", "file-change"]);50export const ProviderRequestKind = Schema.Literals(["command", "file-change"]);
48export type ProviderRequestKind = typeof ProviderRequestKind.Type;51export type ProviderRequestKind = typeof ProviderRequestKind.Type;
49export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]);52export 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});
327339
...@@ -567,11 +579,16 @@ export const ThreadMessageSentPayload = Schema.Struct({...@@ -567,11 +579,16 @@ export const ThreadMessageSentPayload = Schema.Struct({
567export const ThreadTurnStartRequestedPayload = Schema.Struct({579export 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});
577594
packages/contracts/src/provider.test.ts created+49
...@@ -0,0 +1,49 @@
1import { describe, expect, it } from "vitest";
2import { Schema } from "effect";
3
4import { ProviderSessionStartInput } from "./provider";
5
6const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput);
7
8describe("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});
50export type ProviderSession = typeof ProviderSession.Type;50export type ProviderSession = typeof ProviderSession.Type;
5151
52export const CodexProviderStartOptions = Schema.Struct({
53 binaryPath: Schema.optional(TrimmedNonEmptyStringSchema),
54 homePath: Schema.optional(TrimmedNonEmptyStringSchema),
55});
56export type CodexProviderStartOptions = typeof CodexProviderStartOptions.Type;
57
58export const ClaudeCodeProviderStartOptions = Schema.Struct({
59 binaryPath: Schema.optional(TrimmedNonEmptyStringSchema),
60 permissionMode: Schema.optional(TrimmedNonEmptyStringSchema),
61 maxThinkingTokens: Schema.optional(NonNegativeInt),
62});
63export type ClaudeCodeProviderStartOptions = typeof ClaudeCodeProviderStartOptions.Type;
64
65export const ProviderStartOptions = Schema.Struct({
66 codex: Schema.optional(CodexProviderStartOptions),
67 claudeCode: Schema.optional(ClaudeCodeProviderStartOptions),
68});
69export type ProviderStartOptions = typeof ProviderStartOptions.Type;
70
52export const ProviderSessionStartInput = Schema.Struct({71export 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});