History

redo contracts a bit

Julius Marminge committed 8 months ago

Commit 2ed16e51 parent 37eb49213 files · +259 −45Browse files

Unified · Split

13 files
  1. apps/server/src/codexAppServerManager.test.ts +0 −6
  2. apps/server/src/codexAppServerManager.ts +32 −13
  3. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +29 −7
  4. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +10 −4
  5. apps/server/src/orchestration/decider.projectScripts.test.ts +2 −0
  6. apps/server/src/orchestration/decider.ts +1 −0
  7. apps/server/src/provider/Layers/ProviderService.test.ts +7 −3
  8. apps/server/src/provider/Layers/ProviderService.ts +1 −1
  9. apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +2 −2
  10. packages/contracts/src/orchestration.test.ts +83 −0
  11. packages/contracts/src/orchestration.ts +23 −6
  12. packages/contracts/src/provider.test.ts +49 −0
  13. packages/contracts/src/provider.ts +20 −3
apps/server/src/codexAppServerManager.test.ts+0−6
@@ -204,9 +204,6 @@ describe("sendTurn", () => { · 203 unmodified lines
204204 expect(result).toEqual({
205205 threadId: "thread_1",
206206 turnId: "turn_1",
207− resumeCursor: {
208− threadId: "thread_1",
209− },
210207 });
211208 expect(requireSession).toHaveBeenCalledWith("sess_1");
212209 expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
@@ -228,9 +225,6 @@ describe("sendTurn", () => { · 15 unmodified lines
228225 expect(updateSession).toHaveBeenCalledWith(context, {
229226 status: "running",
230227 activeTurnId: "turn_1",
231− resumeCursor: {
232− threadId: "thread_1",
233− },
234228 });
235229 });
236230
apps/server/src/codexAppServerManager.ts+32−13
@@ -169,8 +169,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 168 unmodified lines
169169 updatedAt: now,
170170 };
171171
172− const codexBinaryPath = input.codexBinaryPath ?? "codex";
173− const codexHomePath = input.codexHomePath;
172+ const codexOptions = readCodexProviderOptions(input);
173+ const codexBinaryPath = codexOptions.binaryPath ?? "codex";
174+ const codexHomePath = codexOptions.homePath;
174175 const child = spawn(codexBinaryPath, ["app-server"], {
175176 cwd: resolvedCwd,
176177 env: {
@@ -349,13 +350,17 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 172 unmodified lines
349350 this.updateSession(context, {
350351 status: "running",
351352 activeTurnId: turnId,
352− resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId },
353+ ...(context.session.resumeCursor !== undefined
354+ ? { resumeCursor: context.session.resumeCursor }
355+ : {}),
353356 });
354357
355358 return {
356359 threadId: context.session.threadId,
357360 turnId,
358− resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId },
361+ ...(context.session.resumeCursor !== undefined
362+ ? { resumeCursor: context.session.resumeCursor }
363+ : {}),
359364 };
360365 }
361366
@@ -980,16 +985,30 @@ function toProviderThreadId(value: string | undefined): ProviderThreadId | undef · 618 unmodified lines
980985 return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe);
981986 }
982987
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+
9831010 function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined {
984− if (
985− !input.resumeCursor ||
986− typeof input.resumeCursor !== "object" ||
987− Array.isArray(input.resumeCursor)
988− ) {
989− return input.resumeThreadId;
990− }
991− const rawThreadId = (input.resumeCursor as Record<string, unknown>).threadId;
992− return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : input.resumeThreadId;
1011+ return readResumeCursorThreadId(input.resumeCursor);
9931012 }
9941013
9951014 function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined {
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+29−7
@@ -1,4 +1,4 @@
1−import type { ProviderRuntimeEvent } from "@t3tools/contracts";
1+import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
22 import {
33 ApprovalRequestId,
44 CommandId,
@@ -72,16 +72,24 @@ describe("ProviderCommandReactor", () => { · 67 unmodified lines
7272 const now = new Date().toISOString();
7373 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
7474 let nextSessionIndex = 1;
75− const startSession = vi.fn((_: unknown, __: unknown) => {
75+ const runtimeSessions: Array<ProviderSession> = [];
76+ const startSession = vi.fn((_: unknown, input: unknown) => {
7677 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 = {
7883 sessionId: asSessionId(`sess-${sessionIndex}`),
7984 provider: "codex" as const,
8085 status: "ready" as const,
8186 threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`),
87+ resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` },
8288 createdAt: now,
8389 updatedAt: now,
84− });
90+ };
91+ runtimeSessions.push(session);
92+ return Effect.succeed(session);
8593 });
8694 const sendTurn = vi.fn((_: unknown) =>
8795 Effect.succeed({
@@ -91,7 +99,21 @@ describe("ProviderCommandReactor", () => { · 3 unmodified lines
9199 );
92100 const interruptTurn = vi.fn((_: unknown) => Effect.void);
93101 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+ );
95117
96118 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
97119 const service: ProviderServiceShape = {
@@ -100,7 +122,7 @@ describe("ProviderCommandReactor", () => { · 2 unmodified lines
100122 interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"],
101123 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],
102124 stopSession: stopSession as ProviderServiceShape["stopSession"],
103− listSessions: () => Effect.succeed([]),
125+ listSessions: () => Effect.succeed(runtimeSessions),
104126 rollbackConversation: () => unsupported(),
105127 stopAll: () => Effect.void,
106128 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
@@ -289,7 +311,7 @@ describe("ProviderCommandReactor", () => { · 182 unmodified lines
289311
290312 expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") });
291313 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
292− resumeThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
314+ resumeCursor: { opaque: "cursor-1" },
293315 approvalPolicy: "on-request",
294316 sandboxMode: "workspace-write",
295317 });
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+10−4
@@ -6,10 +6,10 @@ import { · 5 unmodified lines
66 type ProviderApprovalPolicy,
77 type ProviderKind,
88 type ProviderSandboxMode,
9+ type ProviderSessionId,
910 type OrchestrationSession,
1011 type ThreadId,
1112 type ProviderSession,
12− type ProviderThreadId,
1313 type TurnId,
1414 } from "@t3tools/contracts";
1515 import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";
@@ -158,12 +158,17 @@ const make = Effect.gen(function* () { · 142 unmodified lines
158158 projects: readModel.projects,
159159 });
160160
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) =>
162167 providerService.startSession(threadId, {
163168 ...(preferredProvider ? { provider: preferredProvider } : {}),
164169 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
165170 ...(thread.model ? { model: thread.model } : {}),
166− ...(resumeThreadId ? { resumeThreadId } : {}),
171+ ...(resumeCursor !== undefined ? { resumeCursor } : {}),
167172 approvalPolicy: desiredApprovalPolicy,
168173 sandboxMode: desiredSandboxMode,
169174 });
@@ -198,7 +203,8 @@ const make = Effect.gen(function* () { · 28 unmodified lines
198203 return existingSessionId;
199204 }
200205
201− const restartedSession = yield* startProviderSession(thread.session?.providerThreadId ?? null);
206+ const resumeCursor = yield* resolveResumeCursorForSession(existingSessionId);
207+ const restartedSession = yield* startProviderSession(resumeCursor);
202208 yield* bindSessionToThread(restartedSession);
203209 yield* providerService.stopSession({ sessionId: existingSessionId }).pipe(
204210 Effect.catchCause((cause) =>
apps/server/src/orchestration/decider.projectScripts.test.ts+2−0
@@ -150,6 +150,7 @@ describe("decider project scripts", () => { · 149 unmodified lines
150150 text: "hello",
151151 attachments: [],
152152 },
153+ provider: "claudeCode",
153154 model: "gpt-5",
154155 effort: "high",
155156 approvalPolicy: "on-request",
@@ -174,6 +175,7 @@ describe("decider project scripts", () => { · 18 unmodified lines
174175 expect(turnStartEvent.payload).toMatchObject({
175176 threadId: ThreadId.makeUnsafe("thread-1"),
176177 messageId: asMessageId("message-user-1"),
178+ provider: "claudeCode",
177179 model: "gpt-5",
178180 effort: "high",
179181 approvalPolicy: "on-request",
apps/server/src/orchestration/decider.ts+1−0
@@ -251,6 +251,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 250 unmodified lines
251251 payload: {
252252 threadId: command.threadId,
253253 messageId: command.message.messageId,
254+ ...(command.provider !== undefined ? { provider: command.provider } : {}),
254255 ...(command.model !== undefined ? { model: command.model } : {}),
255256 ...(command.effort !== undefined ? { effort: command.effort } : {}),
256257 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,
apps/server/src/provider/Layers/ProviderService.test.ts+7−3
@@ -61,11 +61,13 @@ function makeFakeCodexAdapter() { · 60 unmodified lines
6161 Effect.sync(() => {
6262 const now = new Date().toISOString();
6363 const next = nextSession;
64+ const threadId = ProviderThreadId.makeUnsafe(`thread-${next}`);
6465 const session: ProviderSession = {
6566 sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`),
6667 provider: "codex",
6768 status: "ready",
68− threadId: input.resumeThreadId ?? ProviderThreadId.makeUnsafe(`thread-${next}`),
69+ threadId,
70+ resumeCursor: input.resumeCursor ?? { opaque: `cursor-${next}` },
6971 cwd: input.cwd ?? process.cwd(),
7072 createdAt: now,
7173 updatedAt: now,
@@ -344,7 +346,7 @@ it.effect( · 272 unmodified lines
344346 assert.equal(Option.isSome(persistedAfterStopAll), true);
345347 if (Option.isSome(persistedAfterStopAll)) {
346348 assert.equal(persistedAfterStopAll.value.status, "stopped");
347− assert.equal(persistedAfterStopAll.value.resumeCursor, null);
349+ assert.deepEqual(persistedAfterStopAll.value.resumeCursor, startedSession.resumeCursor);
348350 }
349351
350352 const secondCodex = makeFakeCodexAdapter();
@@ -380,6 +382,7 @@ it.effect( · 29 unmodified lines
380382 provider: "codex",
381383 cwd: "/tmp/project",
382384 resumeThreadId: startedSession.threadId,
385+ resumeCursor: startedSession.resumeCursor,
383386 },
384387 ],
385388 ]);
@@ -468,6 +471,7 @@ routing.layer("ProviderServiceLive routing", (it) => { · 82 unmodified lines
468471 provider: "codex",
469472 cwd: "/tmp/project",
470473 resumeThreadId: initial.threadId,
474+ resumeCursor: initial.resumeCursor,
471475 },
472476 ],
473477 ]);
@@ -547,7 +551,7 @@ routing.layer("ProviderServiceLive routing", (it) => { · 73 unmodified lines
547551 assert.equal(Option.isSome(runningRuntime), true);
548552 if (Option.isSome(runningRuntime)) {
549553 assert.equal(runningRuntime.value.status, "running");
550− assert.equal(runningRuntime.value.resumeCursor, null);
554+ assert.deepEqual(runningRuntime.value.resumeCursor, session.resumeCursor);
551555 const payload = runningRuntime.value.runtimePayload;
552556 assert.equal(payload !== null && typeof payload === "object", true);
553557 if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) {
apps/server/src/provider/Layers/ProviderService.ts+1−1
@@ -271,7 +271,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 270 unmodified lines
271271 } as const;
272272 }
273273
274− if (!resumeThreadId && !hasResumeCursor) {
274+ if (!hasResumeCursor) {
275275 return yield* toValidationError(
276276 input.operation,
277277 `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 · 118 unmodified lines
119119 providerThreadId,
120120 status: "starting",
121121 resumeCursor: {
122− resumeThreadId: "provider-thread-runtime",
122+ threadId: "provider-thread-runtime",
123123 },
124124 runtimePayload: {
125125 cwd: "/tmp/project",
@@ -145,7 +145,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL · 19 unmodified lines
145145 assert.equal(runtime.value.providerThreadId, providerThreadId);
146146 assert.equal(runtime.value.status, "running");
147147 assert.deepEqual(runtime.value.resumeCursor, {
148− resumeThreadId: providerThreadId,
148+ threadId: providerThreadId,
149149 });
150150 assert.deepEqual(runtime.value.runtimePayload, {
151151 cwd: "/tmp/project",
packages/contracts/src/orchestration.test.ts+83−0
@@ -2,14 +2,24 @@ import { assert, it } from "@effect/vitest"; · 1 unmodified line
22 import { Effect, Schema } from "effect";
33
44 import {
5+ DEFAULT_PROVIDER_APPROVAL_POLICY,
6+ DEFAULT_PROVIDER_SANDBOX_MODE,
57 OrchestrationGetTurnDiffInput,
8+ OrchestrationSession,
69 ProjectCreateCommand,
10+ ThreadTurnStartCommand,
711 ThreadTurnDiff,
12+ ThreadTurnStartRequestedPayload,
813 } from "./orchestration";
914
1015 const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
1116 const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);
1217 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);
1323
1424 it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>
1525 Effect.gen(function* () {
@@ -84,3 +94,76 @@ it.effect("rejects command fields that become empty after trim", () => · 68 unmodified lines
8494 assert.strictEqual(result._tag, "Failure");
8595 }),
8696 );
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 = { · 30 unmodified lines
3131
3232 export const ProviderKind = Schema.Literals(["codex", "claudeCode"]);
3333 export type ProviderKind = typeof ProviderKind.Type;
34+export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
3435 export const ProviderApprovalPolicy = Schema.Literals([
3536 "untrusted",
3637 "on-failure",
@@ -38,12 +39,14 @@ export const ProviderApprovalPolicy = Schema.Literals([ · 1 unmodified line
3839 "never",
3940 ]);
4041 export type ProviderApprovalPolicy = typeof ProviderApprovalPolicy.Type;
42+export const DEFAULT_PROVIDER_APPROVAL_POLICY: ProviderApprovalPolicy = "on-failure";
4143 export const ProviderSandboxMode = Schema.Literals([
4244 "read-only",
4345 "workspace-write",
4446 "danger-full-access",
4547 ]);
4648 export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;
49+export const DEFAULT_PROVIDER_SANDBOX_MODE: ProviderSandboxMode = "workspace-write";
4750 export const ProviderRequestKind = Schema.Literals(["command", "file-change"]);
4851 export type ProviderRequestKind = typeof ProviderRequestKind.Type;
4952 export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]);
@@ -142,8 +145,12 @@ export const OrchestrationSession = Schema.Struct({ · 92 unmodified lines
142145 providerName: Schema.NullOr(TrimmedNonEmptyString),
143146 providerSessionId: Schema.NullOr(ProviderSessionId),
144147 providerThreadId: Schema.NullOr(ProviderThreadId),
145− approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")),
146− sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")),
148+ approvalPolicy: ProviderApprovalPolicy.pipe(
149+ Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY),
150+ ),
151+ sandboxMode: ProviderSandboxMode.pipe(
152+ Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE),
153+ ),
147154 activeTurnId: Schema.NullOr(TurnId),
148155 lastError: Schema.NullOr(TrimmedNonEmptyString),
149156 updatedAt: IsoDateTime,
@@ -299,11 +306,16 @@ export const ThreadTurnStartCommand = Schema.Struct({ · 149 unmodified lines
299306 text: Schema.String,
300307 attachments: Schema.Array(ChatAttachment),
301308 }),
309+ provider: Schema.optional(ProviderKind),
302310 model: Schema.optional(TrimmedNonEmptyString),
303311 effort: Schema.optional(TrimmedNonEmptyString),
304312 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
305− approvalPolicy: ProviderApprovalPolicy,
306− sandboxMode: ProviderSandboxMode,
313+ approvalPolicy: ProviderApprovalPolicy.pipe(
314+ Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY),
315+ ),
316+ sandboxMode: ProviderSandboxMode.pipe(
317+ Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE),
318+ ),
307319 createdAt: IsoDateTime,
308320 });
309321
@@ -515,11 +527,16 @@ export const ThreadMessageSentPayload = Schema.Struct({ · 205 unmodified lines
515527 export const ThreadTurnStartRequestedPayload = Schema.Struct({
516528 threadId: ThreadId,
517529 messageId: MessageId,
530+ provider: Schema.optional(ProviderKind),
518531 model: Schema.optional(TrimmedNonEmptyString),
519532 effort: Schema.optional(TrimmedNonEmptyString),
520533 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
521− approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")),
522− sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")),
534+ approvalPolicy: ProviderApprovalPolicy.pipe(
535+ Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY),
536+ ),
537+ sandboxMode: ProviderSandboxMode.pipe(
538+ Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE),
539+ ),
523540 createdAt: IsoDateTime,
524541 });
525542
packages/contracts/src/provider.test.ts+49−0
@@ -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({ · 48 unmodified lines
4949 });
5050 export type ProviderSession = typeof ProviderSession.Type;
5151
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+
5271 export const ProviderSessionStartInput = Schema.Struct({
5372 provider: Schema.optional(ProviderKind),
5473 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
5574 model: Schema.optional(TrimmedNonEmptyStringSchema),
56− resumeThreadId: Schema.optional(ProviderThreadId),
5775 resumeCursor: Schema.optional(Schema.Unknown),
58− codexBinaryPath: Schema.optional(TrimmedNonEmptyStringSchema),
59− codexHomePath: Schema.optional(TrimmedNonEmptyStringSchema),
76+ providerOptions: Schema.optional(ProviderStartOptions),
6077 approvalPolicy: Schema.optional(ProviderApprovalPolicy),
6178 sandboxMode: Schema.optional(ProviderSandboxMode),
6279 });