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", () => {
201201 expect(result).toEqual({
202202 threadId: "thread_1",
203203 turnId: "turn_1",
204 resumeCursor: {
205 threadId: "thread_1",
206 },
207204 });
208205 expect(requireSession).toHaveBeenCalledWith("sess_1");
209206 expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
......@@ -225,9 +222,6 @@ describe("sendTurn", () => {
225222 expect(updateSession).toHaveBeenCalledWith(context, {
226223 status: "running",
227224 activeTurnId: "turn_1",
228 resumeCursor: {
229 threadId: "thread_1",
230 },
231225 });
232226 });
233227
apps/server/src/codexAppServerManager.ts+32-13
......@@ -193,8 +193,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
193193 updatedAt: now,
194194 };
195195
196 const codexBinaryPath = input.codexBinaryPath ?? "codex";
197 const codexHomePath = input.codexHomePath;
196 const codexOptions = readCodexProviderOptions(input);
197 const codexBinaryPath = codexOptions.binaryPath ?? "codex";
198 const codexHomePath = codexOptions.homePath;
198199 const child = spawn(codexBinaryPath, ["app-server"], {
199200 cwd: resolvedCwd,
200201 env: {
......@@ -374,13 +375,17 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
374375 this.updateSession(context, {
375376 status: "running",
376377 activeTurnId: turnId,
377 resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId },
378 ...(context.session.resumeCursor !== undefined
379 ? { resumeCursor: context.session.resumeCursor }
380 : {}),
378381 });
379382
380383 return {
381384 threadId: context.session.threadId,
382385 turnId,
383 resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId },
386 ...(context.session.resumeCursor !== undefined
387 ? { resumeCursor: context.session.resumeCursor }
388 : {}),
384389 };
385390 }
386391
......@@ -1005,16 +1010,30 @@ function toProviderThreadId(value: string | undefined): ProviderThreadId | undef
10051010 return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe);
10061011}
10071012
1008function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined {
1009 if (
1010 !input.resumeCursor ||
1011 typeof input.resumeCursor !== "object" ||
1012 Array.isArray(input.resumeCursor)
1013 ) {
1014 return input.resumeThreadId;
1013function readCodexProviderOptions(input: ProviderSessionStartInput): {
1014 readonly binaryPath?: string;
1015 readonly homePath?: string;
1016} {
1017 const options = input.providerOptions?.codex;
1018 if (!options) {
1019 return {};
1020 }
1021 return {
1022 ...(options.binaryPath ? { binaryPath: options.binaryPath } : {}),
1023 ...(options.homePath ? { homePath: options.homePath } : {}),
1024 };
1025}
1026
1027function readResumeCursorThreadId(resumeCursor: unknown): ProviderThreadId | undefined {
1028 if (!resumeCursor || typeof resumeCursor !== "object" || Array.isArray(resumeCursor)) {
1029 return undefined;
10151030 }
1016 const rawThreadId = (input.resumeCursor as Record<string, unknown>).threadId;
1017 return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : input.resumeThreadId;
1031 const rawThreadId = (resumeCursor as Record<string, unknown>).threadId;
1032 return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : undefined;
1033}
1034
1035function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined {
1036 return readResumeCursorThreadId(input.resumeCursor);
10181037}
10191038
10201039function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined {
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+27-19
......@@ -1,8 +1,4 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
5import type { ProviderRuntimeEvent } from "@t3tools/contracts";
1import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
62import {
73 ApprovalRequestId,
84 CommandId,
......@@ -88,16 +84,24 @@ describe("ProviderCommandReactor", () => {
8884 createdStateDirs.add(stateDir);
8985 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
9086 let nextSessionIndex = 1;
91 const startSession = vi.fn((_: unknown, __: unknown) => {
87 const runtimeSessions: Array<ProviderSession> = [];
88 const startSession = vi.fn((_: unknown, input: unknown) => {
9289 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 = {
9495 sessionId: asSessionId(`sess-${sessionIndex}`),
9596 provider: "codex" as const,
9697 status: "ready" as const,
9798 threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`),
99 resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` },
98100 createdAt: now,
99101 updatedAt: now,
100 });
102 };
103 runtimeSessions.push(session);
104 return Effect.succeed(session);
101105 });
102106 const sendTurn = vi.fn((_: unknown) =>
103107 Effect.succeed({
......@@ -107,15 +111,19 @@ describe("ProviderCommandReactor", () => {
107111 );
108112 const interruptTurn = vi.fn((_: unknown) => Effect.void);
109113 const respondToRequest = vi.fn((_: unknown) => Effect.void);
110 const stopSession = vi.fn((_: unknown) => Effect.void);
111 const renameBranch = vi.fn((_: unknown) =>
112 Effect.succeed({
113 branch: "t3code/generated-name",
114 }),
115 );
116 const generateBranchName = vi.fn<TextGenerationShape["generateBranchName"]>(() =>
117 Effect.succeed({
118 branch: "generated-name",
114 const stopSession = vi.fn((input: unknown) =>
115 Effect.sync(() => {
116 const sessionId =
117 typeof input === "object" && input !== null && "sessionId" in input
118 ? (input as { sessionId?: ProviderSessionId }).sessionId
119 : undefined;
120 if (!sessionId) {
121 return;
122 }
123 const index = runtimeSessions.findIndex((session) => session.sessionId === sessionId);
124 if (index >= 0) {
125 runtimeSessions.splice(index, 1);
126 }
119127 }),
120128 );
121129
......@@ -126,7 +134,7 @@ describe("ProviderCommandReactor", () => {
126134 interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"],
127135 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],
128136 stopSession: stopSession as ProviderServiceShape["stopSession"],
129 listSessions: () => Effect.succeed([]),
137 listSessions: () => Effect.succeed(runtimeSessions),
130138 rollbackConversation: () => unsupported(),
131139 stopAll: () => Effect.void,
132140 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
......@@ -566,7 +574,7 @@ describe("ProviderCommandReactor", () => {
566574
567575 expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") });
568576 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
569 resumeThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
577 resumeCursor: { opaque: "cursor-1" },
570578 approvalPolicy: "on-request",
571579 sandboxMode: "workspace-write",
572580 });
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+10-6
......@@ -6,10 +6,10 @@ import {
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";
1515import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";
......@@ -191,12 +191,17 @@ const make = Effect.gen(function* () {
191191 projects: readModel.projects,
192192 });
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) =>
195200 providerService.startSession(threadId, {
196201 ...(preferredProvider ? { provider: preferredProvider } : {}),
197202 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
198203 ...(thread.model ? { model: thread.model } : {}),
199 ...(resumeThreadId ? { resumeThreadId } : {}),
204 ...(resumeCursor !== undefined ? { resumeCursor } : {}),
200205 approvalPolicy: desiredApprovalPolicy,
201206 sandboxMode: desiredSandboxMode,
202207 });
......@@ -232,9 +237,8 @@ const make = Effect.gen(function* () {
232237 return existingSessionId;
233238 }
234239
235 const restartedSession = yield* startProviderSession(
236 thread.session?.providerThreadId ?? null,
237 );
240 const resumeCursor = yield* resolveResumeCursorForSession(existingSessionId);
241 const restartedSession = yield* startProviderSession(resumeCursor);
238242 yield* bindSessionToThread(restartedSession);
239243 yield* providerService.stopSession({ sessionId: existingSessionId }).pipe(
240244 Effect.catchCause((cause) =>
apps/server/src/orchestration/decider.projectScripts.test.ts+2
......@@ -150,6 +150,7 @@ describe("decider project scripts", () => {
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", () => {
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
......@@ -251,6 +251,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
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() {
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(
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(
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) => {
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) => {
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
......@@ -283,7 +283,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
283283 } as const;
284284 }
285285
286 if (!resumeThreadId && !hasResumeCursor) {
286 if (!hasResumeCursor) {
287287 return yield* toValidationError(
288288 input.operation,
289289 `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
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
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
......@@ -2,14 +2,24 @@ import { assert, it } from "@effect/vitest";
22import { Effect, Schema } from "effect";
33
44import {
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
1015const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
1116const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);
1217const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);
18const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand);
19const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect(
20 ThreadTurnStartRequestedPayload,
21);
22const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession);
1323
1424it.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", () =>
8494 assert.strictEqual(result._tag, "Failure");
8595 }),
8696);
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 = {
3131
3232export const ProviderKind = Schema.Literals(["codex", "claudeCode"]);
3333export type ProviderKind = typeof ProviderKind.Type;
34export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
3435export const ProviderApprovalPolicy = Schema.Literals([
3536 "untrusted",
3637 "on-failure",
......@@ -38,12 +39,14 @@ export const ProviderApprovalPolicy = Schema.Literals([
3839 "never",
3940]);
4041export type ProviderApprovalPolicy = typeof ProviderApprovalPolicy.Type;
42export const DEFAULT_PROVIDER_APPROVAL_POLICY: ProviderApprovalPolicy = "on-failure";
4143export const ProviderSandboxMode = Schema.Literals([
4244 "read-only",
4345 "workspace-write",
4446 "danger-full-access",
4547]);
4648export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;
49export const DEFAULT_PROVIDER_SANDBOX_MODE: ProviderSandboxMode = "workspace-write";
4750export const ProviderRequestKind = Schema.Literals(["command", "file-change"]);
4851export type ProviderRequestKind = typeof ProviderRequestKind.Type;
4952export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]);
......@@ -160,8 +163,12 @@ export const OrchestrationSession = Schema.Struct({
160163 providerName: Schema.NullOr(TrimmedNonEmptyString),
161164 providerSessionId: Schema.NullOr(ProviderSessionId),
162165 providerThreadId: Schema.NullOr(ProviderThreadId),
163 approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")),
164 sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")),
166 approvalPolicy: ProviderApprovalPolicy.pipe(
167 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY),
168 ),
169 sandboxMode: ProviderSandboxMode.pipe(
170 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE),
171 ),
165172 activeTurnId: Schema.NullOr(TurnId),
166173 lastError: Schema.NullOr(TrimmedNonEmptyString),
167174 updatedAt: IsoDateTime,
......@@ -317,11 +324,16 @@ export const ThreadTurnStartCommand = Schema.Struct({
317324 text: Schema.String,
318325 attachments: Schema.Array(ChatAttachment),
319326 }),
327 provider: Schema.optional(ProviderKind),
320328 model: Schema.optional(TrimmedNonEmptyString),
321329 effort: Schema.optional(TrimmedNonEmptyString),
322330 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
323 approvalPolicy: ProviderApprovalPolicy,
324 sandboxMode: ProviderSandboxMode,
331 approvalPolicy: ProviderApprovalPolicy.pipe(
332 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY),
333 ),
334 sandboxMode: ProviderSandboxMode.pipe(
335 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE),
336 ),
325337 createdAt: IsoDateTime,
326338});
327339
......@@ -567,11 +579,16 @@ export const ThreadMessageSentPayload = Schema.Struct({
567579export const ThreadTurnStartRequestedPayload = Schema.Struct({
568580 threadId: ThreadId,
569581 messageId: MessageId,
582 provider: Schema.optional(ProviderKind),
570583 model: Schema.optional(TrimmedNonEmptyString),
571584 effort: Schema.optional(TrimmedNonEmptyString),
572585 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
573 approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")),
574 sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")),
586 approvalPolicy: ProviderApprovalPolicy.pipe(
587 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY),
588 ),
589 sandboxMode: ProviderSandboxMode.pipe(
590 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE),
591 ),
575592 createdAt: IsoDateTime,
576593});
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({
4949});
5050export 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
5271export 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});