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
204 expect(result).toEqual({204 expect(result).toEqual({
205 threadId: "thread_1",205 threadId: "thread_1",
206 turnId: "turn_1",206 turnId: "turn_1",
207 resumeCursor: {
208 threadId: "thread_1",
209 },
210 });207 });
211 expect(requireSession).toHaveBeenCalledWith("sess_1");208 expect(requireSession).toHaveBeenCalledWith("sess_1");
212 expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {209 expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
@@ -228,9 +225,6 @@ describe("sendTurn", () => { · 15 unmodified lines
228 expect(updateSession).toHaveBeenCalledWith(context, {225 expect(updateSession).toHaveBeenCalledWith(context, {
229 status: "running",226 status: "running",
230 activeTurnId: "turn_1",227 activeTurnId: "turn_1",
231 resumeCursor: {
232 threadId: "thread_1",
233 },
234 });228 });
235 });229 });
236 230
apps/server/src/codexAppServerManager.ts+32−13
@@ -169,8 +169,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 168 unmodified lines
169 updatedAt: now,169 updatedAt: now,
170 };170 };
171 171
172 const codexBinaryPath = input.codexBinaryPath ?? "codex";172 const codexOptions = readCodexProviderOptions(input);
173 const codexHomePath = input.codexHomePath;173 const codexBinaryPath = codexOptions.binaryPath ?? "codex";
174 const codexHomePath = codexOptions.homePath;
174 const child = spawn(codexBinaryPath, ["app-server"], {175 const child = spawn(codexBinaryPath, ["app-server"], {
175 cwd: resolvedCwd,176 cwd: resolvedCwd,
176 env: {177 env: {
@@ -349,13 +350,17 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 172 unmodified lines
349 this.updateSession(context, {350 this.updateSession(context, {
350 status: "running",351 status: "running",
351 activeTurnId: turnId,352 activeTurnId: turnId,
352 resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId },353 ...(context.session.resumeCursor !== undefined
354 ? { resumeCursor: context.session.resumeCursor }
355 : {}),
353 });356 });
354 357
355 return {358 return {
356 threadId: context.session.threadId,359 threadId: context.session.threadId,
357 turnId,360 turnId,
358 resumeCursor: context.session.resumeCursor ?? { threadId: context.session.threadId },361 ...(context.session.resumeCursor !== undefined
362 ? { resumeCursor: context.session.resumeCursor }
363 : {}),
359 };364 };
360 }365 }
361 366
@@ -980,16 +985,30 @@ function toProviderThreadId(value: string | undefined): ProviderThreadId | undef · 618 unmodified lines
980 return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe);985 return brandIfNonEmpty(value, ProviderThreadId.makeUnsafe);
981}986}
982 987
988function 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
1002function 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
983function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined {1010function readResumeThreadId(input: ProviderSessionStartInput): ProviderThreadId | undefined {
984 if (1011 return readResumeCursorThreadId(input.resumeCursor);
985 !input.resumeCursor ||
986 typeof input.resumeCursor !== "object" ||
987 Array.isArray(input.resumeCursor)
988 ) {
989 return input.resumeThreadId;
990 }
991 const rawThreadId = (input.resumeCursor as Record<string, unknown>).threadId;
992 return typeof rawThreadId === "string" ? toProviderThreadId(rawThreadId) : input.resumeThreadId;
993}1012}
994 1013
995function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined {1014function toProviderTurnId(value: string | undefined): ProviderTurnId | undefined {
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+29−7
@@ -1,4 +1,4 @@
1import type { ProviderRuntimeEvent } from "@t3tools/contracts";1import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
2import {2import {
3 ApprovalRequestId,3 ApprovalRequestId,
4 CommandId,4 CommandId,
@@ -72,16 +72,24 @@ describe("ProviderCommandReactor", () => { · 67 unmodified lines
72 const now = new Date().toISOString();72 const now = new Date().toISOString();
73 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());73 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
74 let nextSessionIndex = 1;74 let nextSessionIndex = 1;
75 const startSession = vi.fn((_: unknown, __: unknown) => {75 const runtimeSessions: Array<ProviderSession> = [];
76 const startSession = vi.fn((_: unknown, input: unknown) => {
76 const sessionIndex = nextSessionIndex++;77 const sessionIndex = nextSessionIndex++;
77 return Effect.succeed({78 const resumeCursor =
79 typeof input === "object" && input !== null && "resumeCursor" in input
80 ? input.resumeCursor
81 : undefined;
82 const session: ProviderSession = {
78 sessionId: asSessionId(`sess-${sessionIndex}`),83 sessionId: asSessionId(`sess-${sessionIndex}`),
79 provider: "codex" as const,84 provider: "codex" as const,
80 status: "ready" as const,85 status: "ready" as const,
81 threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`),86 threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`),
87 resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` },
82 createdAt: now,88 createdAt: now,
83 updatedAt: now,89 updatedAt: now,
84 });90 };
91 runtimeSessions.push(session);
92 return Effect.succeed(session);
85 });93 });
86 const sendTurn = vi.fn((_: unknown) =>94 const sendTurn = vi.fn((_: unknown) =>
87 Effect.succeed({95 Effect.succeed({
@@ -91,7 +99,21 @@ describe("ProviderCommandReactor", () => { · 3 unmodified lines
91 );99 );
92 const interruptTurn = vi.fn((_: unknown) => Effect.void);100 const interruptTurn = vi.fn((_: unknown) => Effect.void);
93 const respondToRequest = vi.fn((_: unknown) => Effect.void);101 const respondToRequest = vi.fn((_: unknown) => Effect.void);
94 const stopSession = vi.fn((_: unknown) => Effect.void);102 const stopSession = vi.fn((input: unknown) =>
103 Effect.sync(() => {
104 const sessionId =
105 typeof input === "object" && input !== null && "sessionId" in input
106 ? (input as { sessionId?: ProviderSessionId }).sessionId
107 : undefined;
108 if (!sessionId) {
109 return;
110 }
111 const index = runtimeSessions.findIndex((session) => session.sessionId === sessionId);
112 if (index >= 0) {
113 runtimeSessions.splice(index, 1);
114 }
115 }),
116 );
95 117
96 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;118 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
97 const service: ProviderServiceShape = {119 const service: ProviderServiceShape = {
@@ -100,7 +122,7 @@ describe("ProviderCommandReactor", () => { · 2 unmodified lines
100 interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"],122 interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"],
101 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],123 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],
102 stopSession: stopSession as ProviderServiceShape["stopSession"],124 stopSession: stopSession as ProviderServiceShape["stopSession"],
103 listSessions: () => Effect.succeed([]),125 listSessions: () => Effect.succeed(runtimeSessions),
104 rollbackConversation: () => unsupported(),126 rollbackConversation: () => unsupported(),
105 stopAll: () => Effect.void,127 stopAll: () => Effect.void,
106 streamEvents: Stream.fromPubSub(runtimeEventPubSub),128 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
@@ -289,7 +311,7 @@ describe("ProviderCommandReactor", () => { · 182 unmodified lines
289 311
290 expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") });312 expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") });
291 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({313 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
292 resumeThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),314 resumeCursor: { opaque: "cursor-1" },
293 approvalPolicy: "on-request",315 approvalPolicy: "on-request",
294 sandboxMode: "workspace-write",316 sandboxMode: "workspace-write",
295 });317 });
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+10−4
@@ -6,10 +6,10 @@ import { · 5 unmodified lines
6 type ProviderApprovalPolicy,6 type ProviderApprovalPolicy,
7 type ProviderKind,7 type ProviderKind,
8 type ProviderSandboxMode,8 type ProviderSandboxMode,
9 type ProviderSessionId,
9 type OrchestrationSession,10 type OrchestrationSession,
10 type ThreadId,11 type ThreadId,
11 type ProviderSession,12 type ProviderSession,
12 type ProviderThreadId,
13 type TurnId,13 type TurnId,
14} from "@t3tools/contracts";14} from "@t3tools/contracts";
15import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";15import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";
@@ -158,12 +158,17 @@ const make = Effect.gen(function* () { · 142 unmodified lines
158 projects: readModel.projects,158 projects: readModel.projects,
159 });159 });
160 160
161 const startProviderSession = (resumeThreadId?: ProviderThreadId | null) =>161 const resolveResumeCursorForSession = (sessionId: ProviderSessionId) =>
162 providerService.listSessions().pipe(
163 Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)?.resumeCursor),
164 );
165
166 const startProviderSession = (resumeCursor?: unknown) =>
162 providerService.startSession(threadId, {167 providerService.startSession(threadId, {
163 ...(preferredProvider ? { provider: preferredProvider } : {}),168 ...(preferredProvider ? { provider: preferredProvider } : {}),
164 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),169 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
165 ...(thread.model ? { model: thread.model } : {}),170 ...(thread.model ? { model: thread.model } : {}),
166 ...(resumeThreadId ? { resumeThreadId } : {}),171 ...(resumeCursor !== undefined ? { resumeCursor } : {}),
167 approvalPolicy: desiredApprovalPolicy,172 approvalPolicy: desiredApprovalPolicy,
168 sandboxMode: desiredSandboxMode,173 sandboxMode: desiredSandboxMode,
169 });174 });
@@ -198,7 +203,8 @@ const make = Effect.gen(function* () { · 28 unmodified lines
198 return existingSessionId;203 return existingSessionId;
199 }204 }
200 205
201 const restartedSession = yield* startProviderSession(thread.session?.providerThreadId ?? null);206 const resumeCursor = yield* resolveResumeCursorForSession(existingSessionId);
207 const restartedSession = yield* startProviderSession(resumeCursor);
202 yield* bindSessionToThread(restartedSession);208 yield* bindSessionToThread(restartedSession);
203 yield* providerService.stopSession({ sessionId: existingSessionId }).pipe(209 yield* providerService.stopSession({ sessionId: existingSessionId }).pipe(
204 Effect.catchCause((cause) =>210 Effect.catchCause((cause) =>
apps/server/src/orchestration/decider.projectScripts.test.ts+2−0
@@ -150,6 +150,7 @@ describe("decider project scripts", () => { · 149 unmodified lines
150 text: "hello",150 text: "hello",
151 attachments: [],151 attachments: [],
152 },152 },
153 provider: "claudeCode",
153 model: "gpt-5",154 model: "gpt-5",
154 effort: "high",155 effort: "high",
155 approvalPolicy: "on-request",156 approvalPolicy: "on-request",
@@ -174,6 +175,7 @@ describe("decider project scripts", () => { · 18 unmodified lines
174 expect(turnStartEvent.payload).toMatchObject({175 expect(turnStartEvent.payload).toMatchObject({
175 threadId: ThreadId.makeUnsafe("thread-1"),176 threadId: ThreadId.makeUnsafe("thread-1"),
176 messageId: asMessageId("message-user-1"),177 messageId: asMessageId("message-user-1"),
178 provider: "claudeCode",
177 model: "gpt-5",179 model: "gpt-5",
178 effort: "high",180 effort: "high",
179 approvalPolicy: "on-request",181 approvalPolicy: "on-request",
apps/server/src/orchestration/decider.ts+1−0
@@ -251,6 +251,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" · 250 unmodified lines
251 payload: {251 payload: {
252 threadId: command.threadId,252 threadId: command.threadId,
253 messageId: command.message.messageId,253 messageId: command.message.messageId,
254 ...(command.provider !== undefined ? { provider: command.provider } : {}),
254 ...(command.model !== undefined ? { model: command.model } : {}),255 ...(command.model !== undefined ? { model: command.model } : {}),
255 ...(command.effort !== undefined ? { effort: command.effort } : {}),256 ...(command.effort !== undefined ? { effort: command.effort } : {}),
256 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,257 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,
apps/server/src/provider/Layers/ProviderService.test.ts+7−3
@@ -61,11 +61,13 @@ function makeFakeCodexAdapter() { · 60 unmodified lines
61 Effect.sync(() => {61 Effect.sync(() => {
62 const now = new Date().toISOString();62 const now = new Date().toISOString();
63 const next = nextSession;63 const next = nextSession;
64 const threadId = ProviderThreadId.makeUnsafe(`thread-${next}`);
64 const session: ProviderSession = {65 const session: ProviderSession = {
65 sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`),66 sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`),
66 provider: "codex",67 provider: "codex",
67 status: "ready",68 status: "ready",
68 threadId: input.resumeThreadId ?? ProviderThreadId.makeUnsafe(`thread-${next}`),69 threadId,
70 resumeCursor: input.resumeCursor ?? { opaque: `cursor-${next}` },
69 cwd: input.cwd ?? process.cwd(),71 cwd: input.cwd ?? process.cwd(),
70 createdAt: now,72 createdAt: now,
71 updatedAt: now,73 updatedAt: now,
@@ -344,7 +346,7 @@ it.effect( · 272 unmodified lines
344 assert.equal(Option.isSome(persistedAfterStopAll), true);346 assert.equal(Option.isSome(persistedAfterStopAll), true);
345 if (Option.isSome(persistedAfterStopAll)) {347 if (Option.isSome(persistedAfterStopAll)) {
346 assert.equal(persistedAfterStopAll.value.status, "stopped");348 assert.equal(persistedAfterStopAll.value.status, "stopped");
347 assert.equal(persistedAfterStopAll.value.resumeCursor, null);349 assert.deepEqual(persistedAfterStopAll.value.resumeCursor, startedSession.resumeCursor);
348 }350 }
349 351
350 const secondCodex = makeFakeCodexAdapter();352 const secondCodex = makeFakeCodexAdapter();
@@ -380,6 +382,7 @@ it.effect( · 29 unmodified lines
380 provider: "codex",382 provider: "codex",
381 cwd: "/tmp/project",383 cwd: "/tmp/project",
382 resumeThreadId: startedSession.threadId,384 resumeThreadId: startedSession.threadId,
385 resumeCursor: startedSession.resumeCursor,
383 },386 },
384 ],387 ],
385 ]);388 ]);
@@ -468,6 +471,7 @@ routing.layer("ProviderServiceLive routing", (it) => { · 82 unmodified lines
468 provider: "codex",471 provider: "codex",
469 cwd: "/tmp/project",472 cwd: "/tmp/project",
470 resumeThreadId: initial.threadId,473 resumeThreadId: initial.threadId,
474 resumeCursor: initial.resumeCursor,
471 },475 },
472 ],476 ],
473 ]);477 ]);
@@ -547,7 +551,7 @@ routing.layer("ProviderServiceLive routing", (it) => { · 73 unmodified lines
547 assert.equal(Option.isSome(runningRuntime), true);551 assert.equal(Option.isSome(runningRuntime), true);
548 if (Option.isSome(runningRuntime)) {552 if (Option.isSome(runningRuntime)) {
549 assert.equal(runningRuntime.value.status, "running");553 assert.equal(runningRuntime.value.status, "running");
550 assert.equal(runningRuntime.value.resumeCursor, null);554 assert.deepEqual(runningRuntime.value.resumeCursor, session.resumeCursor);
551 const payload = runningRuntime.value.runtimePayload;555 const payload = runningRuntime.value.runtimePayload;
552 assert.equal(payload !== null && typeof payload === "object", true);556 assert.equal(payload !== null && typeof payload === "object", true);
553 if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) {557 if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) {
apps/server/src/provider/Layers/ProviderService.ts+1−1
@@ -271,7 +271,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 270 unmodified lines
271 } as const;271 } as const;
272 }272 }
273 273
274 if (!resumeThreadId && !hasResumeCursor) {274 if (!hasResumeCursor) {
275 return yield* toValidationError(275 return yield* toValidationError(
276 input.operation,276 input.operation,
277 `Cannot recover stale session '${input.staleSessionId}' because no provider resume state is persisted.`,277 `Cannot recover stale session '${input.staleSessionId}' because no provider resume state is persisted.`,
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+2−2
@@ -119,7 +119,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL · 118 unmodified lines
119 providerThreadId,119 providerThreadId,
120 status: "starting",120 status: "starting",
121 resumeCursor: {121 resumeCursor: {
122 resumeThreadId: "provider-thread-runtime",122 threadId: "provider-thread-runtime",
123 },123 },
124 runtimePayload: {124 runtimePayload: {
125 cwd: "/tmp/project",125 cwd: "/tmp/project",
@@ -145,7 +145,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL · 19 unmodified lines
145 assert.equal(runtime.value.providerThreadId, providerThreadId);145 assert.equal(runtime.value.providerThreadId, providerThreadId);
146 assert.equal(runtime.value.status, "running");146 assert.equal(runtime.value.status, "running");
147 assert.deepEqual(runtime.value.resumeCursor, {147 assert.deepEqual(runtime.value.resumeCursor, {
148 resumeThreadId: providerThreadId,148 threadId: providerThreadId,
149 });149 });
150 assert.deepEqual(runtime.value.runtimePayload, {150 assert.deepEqual(runtime.value.runtimePayload, {
151 cwd: "/tmp/project",151 cwd: "/tmp/project",
packages/contracts/src/orchestration.test.ts+83−0
@@ -2,14 +2,24 @@ import { assert, it } from "@effect/vitest"; · 1 unmodified line
2import { Effect, Schema } from "effect";2import { Effect, Schema } from "effect";
3 3
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";
9 14
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);
13 23
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", () => · 68 unmodified lines
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 = { · 30 unmodified lines
31 31
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([ · 1 unmodified line
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"]);
@@ -142,8 +145,12 @@ export const OrchestrationSession = Schema.Struct({ · 92 unmodified lines
142 providerName: Schema.NullOr(TrimmedNonEmptyString),145 providerName: Schema.NullOr(TrimmedNonEmptyString),
143 providerSessionId: Schema.NullOr(ProviderSessionId),146 providerSessionId: Schema.NullOr(ProviderSessionId),
144 providerThreadId: Schema.NullOr(ProviderThreadId),147 providerThreadId: Schema.NullOr(ProviderThreadId),
145 approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")),148 approvalPolicy: ProviderApprovalPolicy.pipe(
146 sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")),149 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY),
150 ),
151 sandboxMode: ProviderSandboxMode.pipe(
152 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE),
153 ),
147 activeTurnId: Schema.NullOr(TurnId),154 activeTurnId: Schema.NullOr(TurnId),
148 lastError: Schema.NullOr(TrimmedNonEmptyString),155 lastError: Schema.NullOr(TrimmedNonEmptyString),
149 updatedAt: IsoDateTime,156 updatedAt: IsoDateTime,
@@ -299,11 +306,16 @@ export const ThreadTurnStartCommand = Schema.Struct({ · 149 unmodified lines
299 text: Schema.String,306 text: Schema.String,
300 attachments: Schema.Array(ChatAttachment),307 attachments: Schema.Array(ChatAttachment),
301 }),308 }),
309 provider: Schema.optional(ProviderKind),
302 model: Schema.optional(TrimmedNonEmptyString),310 model: Schema.optional(TrimmedNonEmptyString),
303 effort: Schema.optional(TrimmedNonEmptyString),311 effort: Schema.optional(TrimmedNonEmptyString),
304 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),312 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
305 approvalPolicy: ProviderApprovalPolicy,313 approvalPolicy: ProviderApprovalPolicy.pipe(
306 sandboxMode: ProviderSandboxMode,314 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY),
315 ),
316 sandboxMode: ProviderSandboxMode.pipe(
317 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE),
318 ),
307 createdAt: IsoDateTime,319 createdAt: IsoDateTime,
308});320});
309 321
@@ -515,11 +527,16 @@ export const ThreadMessageSentPayload = Schema.Struct({ · 205 unmodified lines
515export const ThreadTurnStartRequestedPayload = Schema.Struct({527export const ThreadTurnStartRequestedPayload = Schema.Struct({
516 threadId: ThreadId,528 threadId: ThreadId,
517 messageId: MessageId,529 messageId: MessageId,
530 provider: Schema.optional(ProviderKind),
518 model: Schema.optional(TrimmedNonEmptyString),531 model: Schema.optional(TrimmedNonEmptyString),
519 effort: Schema.optional(TrimmedNonEmptyString),532 effort: Schema.optional(TrimmedNonEmptyString),
520 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),533 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
521 approvalPolicy: ProviderApprovalPolicy.pipe(Schema.withDecodingDefault(() => "on-failure")),534 approvalPolicy: ProviderApprovalPolicy.pipe(
522 sandboxMode: ProviderSandboxMode.pipe(Schema.withDecodingDefault(() => "workspace-write")),535 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_APPROVAL_POLICY),
536 ),
537 sandboxMode: ProviderSandboxMode.pipe(
538 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_SANDBOX_MODE),
539 ),
523 createdAt: IsoDateTime,540 createdAt: IsoDateTime,
524});541});
525 542
packages/contracts/src/provider.test.ts+49−0
@@ -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({ · 48 unmodified lines
49});49});
50export type ProviderSession = typeof ProviderSession.Type;50export type ProviderSession = typeof ProviderSession.Type;
51 51
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});