authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-04 13:13:22-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log689dda59e997337103c76ed93af0574dcc554d26
tree449afaae49ff8f37ca9f611396abf186a11b70a9
parent0a67f9dd39d9df21dcbd56f28c7c56fa5358282e

Persist thread runtime mode and handle mode-switch session restarts

- Add `thread.runtime-mode.set` -> `thread.runtime-mode-set` flow in decider/reactors/projectors - Persist runtime mode on thread/session projections with new DB migrations - Update provider and Codex integration tests to verify thread continuity across mode changes Co-authored-by: codex <codex@users.noreply.github.com>

51 files changed, 743 insertions(+), 173 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+46-17
......@@ -37,6 +37,8 @@ import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
3737import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
3838import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
3939import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
40import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts";
41import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts";
4042import { ProviderService } from "../src/provider/Services/ProviderService.ts";
4143import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
4244import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
......@@ -187,6 +189,7 @@ export interface OrchestrationIntegrationHarness {
187189
188190interface MakeOrchestrationIntegrationHarnessOptions {
189191 readonly provider?: "codex" | "claudeCode";
192 readonly realCodex?: boolean;
190193}
191194
192195export const makeOrchestrationIntegrationHarness = (
......@@ -195,10 +198,21 @@ export const makeOrchestrationIntegrationHarness = (
195198 Effect.gen(function* () {
196199 const sleep = (ms: number) => Effect.sleep(ms);
197200 const provider = options?.provider ?? "codex";
198 const adapterHarness = yield* makeTestProviderAdapterHarness({
199 provider,
200 });
201
201 const useRealCodex = options?.realCodex === true;
202 const adapterHarness = useRealCodex
203 ? null
204 : yield* makeTestProviderAdapterHarness({
205 provider,
206 });
207 const fakeRegistry = adapterHarness
208 ? Layer.succeed(ProviderAdapterRegistry, {
209 getByProvider: (resolvedProvider) =>
210 resolvedProvider === adapterHarness.provider
211 ? Effect.succeed(adapterHarness.adapter)
212 : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })),
213 listProviders: () => Effect.succeed([adapterHarness.provider]),
214 } as typeof ProviderAdapterRegistry.Service)
215 : null;
202216 const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-"));
203217 const workspaceDir = path.join(rootDir, "workspace");
204218 const stateDir = path.join(rootDir, "state");
......@@ -207,14 +221,6 @@ export const makeOrchestrationIntegrationHarness = (
207221 fs.mkdirSync(stateDir, { recursive: true });
208222 initializeGitWorkspace(workspaceDir);
209223
210 const registry: typeof ProviderAdapterRegistry.Service = {
211 getByProvider: (provider) =>
212 provider === adapterHarness.provider
213 ? Effect.succeed(adapterHarness.adapter)
214 : Effect.fail(new ProviderUnsupportedError({ provider })),
215 listProviders: () => Effect.succeed([adapterHarness.provider]),
216 };
217
218224 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
219225 const orchestrationLayer = OrchestrationEngineLive.pipe(
220226 Layer.provide(OrchestrationProjectionPipelineLive),
......@@ -224,10 +230,33 @@ export const makeOrchestrationIntegrationHarness = (
224230 const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
225231 Layer.provide(ProviderSessionRuntimeRepositoryLive),
226232 );
227 const providerLayer = makeProviderServiceLive().pipe(
228 Layer.provide(providerSessionDirectoryLayer),
229 Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)),
233 const realCodexRegistry = Layer.effect(
234 ProviderAdapterRegistry,
235 Effect.gen(function* () {
236 const codexAdapter = yield* CodexAdapter;
237 return {
238 getByProvider: (resolvedProvider) =>
239 resolvedProvider === "codex"
240 ? Effect.succeed(codexAdapter)
241 : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })),
242 listProviders: () => Effect.succeed(["codex"] as const),
243 } as typeof ProviderAdapterRegistry.Service;
244 }),
245 ).pipe(
246 Layer.provide(makeCodexAdapterLive()),
247 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)),
248 Layer.provideMerge(NodeServices.layer),
249 Layer.provideMerge(providerSessionDirectoryLayer),
230250 );
251 const providerLayer = useRealCodex
252 ? makeProviderServiceLive().pipe(
253 Layer.provide(providerSessionDirectoryLayer),
254 Layer.provide(realCodexRegistry),
255 )
256 : makeProviderServiceLive().pipe(
257 Layer.provide(providerSessionDirectoryLayer),
258 Layer.provide(fakeRegistry!),
259 );
231260
232261 const runtimeServicesLayer = Layer.mergeAll(
233262 orchestrationLayer,
......@@ -407,8 +436,8 @@ export const makeOrchestrationIntegrationHarness = (
407436 return {
408437 rootDir,
409438 workspaceDir,
410 dbPath,
411 adapterHarness,
439 dbPath,
440 adapterHarness: adapterHarness as TestProviderAdapterHarness,
412441 engine,
413442 snapshotQuery,
414443 providerService,
apps/server/integration/TestProviderAdapter.integration.ts+1
......@@ -264,6 +264,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
264264 sessionId,
265265 provider,
266266 status: "ready",
267 runtimeMode: input.runtimeMode,
267268 threadId,
268269 cwd: input.cwd,
269270 resumeCursor: input.resumeCursor ?? { sessionId },
apps/server/integration/orchestrationEngine.integration.test.ts+102
......@@ -97,6 +97,16 @@ function withHarness<A, E>(
9797 );
9898}
9999
100function withRealCodexHarness<A, E>(
101 use: (harness: OrchestrationIntegrationHarness) => Effect.Effect<A, E>,
102) {
103 return Effect.acquireUseRelease(
104 makeOrchestrationIntegrationHarness({ provider: "codex", realCodex: true }),
105 use,
106 (harness) => harness.dispose,
107 );
108}
109
100110const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
101111 Effect.gen(function* () {
102112 const createdAt = nowIso();
......@@ -118,6 +128,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
118128 projectId: PROJECT_ID,
119129 title: "Integration Thread",
120130 model: "gpt-5-codex",
131 runtimeMode: "approval-required",
121132 branch: null,
122133 worktreePath: harness.workspaceDir,
123134 createdAt,
......@@ -216,6 +227,97 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
216227 ),
217228);
218229
230it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
231 "keeps the same Codex provider thread across runtime mode switches",
232 () =>
233 withRealCodexHarness((harness) =>
234 Effect.gen(function* () {
235 const createdAt = nowIso();
236
237 yield* harness.engine.dispatch({
238 type: "project.create",
239 commandId: CommandId.makeUnsafe("cmd-project-create-real-codex"),
240 projectId: PROJECT_ID,
241 title: "Integration Project",
242 workspaceRoot: harness.workspaceDir,
243 defaultModel: "gpt-5.3-codex",
244 createdAt,
245 });
246
247 yield* harness.engine.dispatch({
248 type: "thread.create",
249 commandId: CommandId.makeUnsafe("cmd-thread-create-real-codex"),
250 threadId: THREAD_ID,
251 projectId: PROJECT_ID,
252 title: "Integration Thread",
253 model: "gpt-5.3-codex",
254 runtimeMode: "full-access",
255 branch: null,
256 worktreePath: harness.workspaceDir,
257 createdAt,
258 });
259
260 yield* harness.engine.dispatch({
261 type: "thread.turn.start",
262 commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-1"),
263 threadId: THREAD_ID,
264 message: {
265 messageId: asMessageId("msg-real-codex-1"),
266 role: "user",
267 text: "Reply with exactly ALPHA.",
268 attachments: [],
269 },
270 runtimeMode: "full-access",
271 createdAt: nowIso(),
272 });
273
274 const firstThread = yield* harness.waitForThread(
275 THREAD_ID,
276 (entry) =>
277 entry.session?.status === "ready" &&
278 entry.session.providerName === "codex" &&
279 entry.session.providerThreadId !== null &&
280 entry.messages.some(
281 (message) => message.role === "assistant" && message.streaming === false,
282 ),
283 180_000,
284 );
285
286 const originalProviderThreadId = firstThread.session?.providerThreadId;
287 assert.isNotNull(originalProviderThreadId);
288
289 yield* harness.engine.dispatch({
290 type: "thread.turn.start",
291 commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-2"),
292 threadId: THREAD_ID,
293 message: {
294 messageId: asMessageId("msg-real-codex-2"),
295 role: "user",
296 text: "Reply with exactly BETA.",
297 attachments: [],
298 },
299 runtimeMode: "approval-required",
300 createdAt: nowIso(),
301 });
302
303 const secondThread = yield* harness.waitForThread(
304 THREAD_ID,
305 (entry) =>
306 entry.session?.status === "ready" &&
307 entry.session.providerName === "codex" &&
308 entry.session.providerThreadId !== null &&
309 entry.session.runtimeMode === "approval-required" &&
310 entry.messages.some(
311 (message) => message.role === "assistant" && message.text.includes("BETA"),
312 ),
313 180_000,
314 );
315
316 assert.equal(secondThread.session?.providerThreadId, originalProviderThreadId);
317 }),
318 ),
319);
320
219321it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
220322 withHarness((harness) =>
221323 Effect.gen(function* () {
apps/server/integration/providerService.integration.test.ts+4
......@@ -122,6 +122,7 @@ it.effect("replays typed runtime fixture events", () =>
122122 {
123123 provider: "codex",
124124 cwd: fixture.cwd,
125 runtimeMode: "full-access",
125126 },
126127 );
127128 assert.equal((session.threadId ?? "").length > 0, true);
......@@ -155,6 +156,7 @@ it.effect("replays file-changing fixture turn events", () =>
155156 {
156157 provider: "codex",
157158 cwd: fixture.cwd,
159 runtimeMode: "full-access",
158160 },
159161 );
160162 assert.equal((session.threadId ?? "").length > 0, true);
......@@ -192,6 +194,7 @@ it.effect("runs multi-turn tool/approval flow", () =>
192194 {
193195 provider: "codex",
194196 cwd: fixture.cwd,
197 runtimeMode: "full-access",
195198 },
196199 );
197200 assert.equal((session.threadId ?? "").length > 0, true);
......@@ -244,6 +247,7 @@ it.effect("rolls back provider conversation state only", () =>
244247 {
245248 provider: "codex",
246249 cwd: fixture.cwd,
250 runtimeMode: "full-access",
247251 },
248252 );
249253 assert.equal((session.threadId ?? "").length > 0, true);
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+1
......@@ -43,6 +43,7 @@ function makeSnapshot(input: {
4343 projectId: input.projectId,
4444 title: "Thread",
4545 model: "gpt-5-codex",
46 runtimeMode: "full-access",
4647 branch: null,
4748 worktreePath: input.worktreePath,
4849 latestTurn: {
apps/server/src/codexAppServerManager.test.ts+86
......@@ -1,4 +1,8 @@
11import { describe, expect, it, vi } from "vitest";
2import { randomUUID } from "node:crypto";
3import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4import os from "node:os";
5import path from "node:path";
26import { ProviderSessionId } from "@t3tools/contracts";
37
48import {
......@@ -165,6 +169,7 @@ describe("startSession", () => {
165169 await expect(
166170 manager.startSession({
167171 provider: "codex",
172 runtimeMode: "full-access",
168173 }),
169174 ).rejects.toThrow("cwd missing");
170175 expect(events).toHaveLength(1);
......@@ -347,3 +352,84 @@ describe("thread checkpoint control", () => {
347352 });
348353 });
349354});
355
356describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => {
357 it(
358 "keeps prior thread history when resuming with a changed runtime mode",
359 async () => {
360 const workspaceDir = mkdtempSync(path.join(os.tmpdir(), "codex-live-resume-"));
361 writeFileSync(path.join(workspaceDir, "README.md"), "hello\n", "utf8");
362
363 const manager = new CodexAppServerManager();
364
365 try {
366 const firstSession = await manager.startSession({
367 provider: "codex",
368 cwd: workspaceDir,
369 runtimeMode: "full-access",
370 providerOptions: {
371 codex: {
372 binaryPath: process.env.CODEX_BINARY_PATH,
373 ...(process.env.CODEX_HOME_PATH
374 ? { homePath: process.env.CODEX_HOME_PATH }
375 : {}),
376 },
377 },
378 });
379
380 const firstTurn = await manager.sendTurn({
381 sessionId: firstSession.sessionId,
382 input: `Reply with exactly the word ALPHA ${randomUUID()}`,
383 });
384
385 expect(firstTurn.threadId).toBe(firstSession.threadId);
386
387 await vi.waitFor(async () => {
388 const snapshot = await manager.readThread(firstSession.sessionId);
389 expect(snapshot.turns.length).toBeGreaterThan(0);
390 }, { timeout: 120_000, interval: 1_000 });
391
392 const firstSnapshot = await manager.readThread(firstSession.sessionId);
393 const originalThreadId = firstSnapshot.threadId;
394 const originalTurnCount = firstSnapshot.turns.length;
395
396 manager.stopSession(firstSession.sessionId);
397
398 const resumedSession = await manager.startSession({
399 provider: "codex",
400 cwd: workspaceDir,
401 runtimeMode: "approval-required",
402 resumeCursor: firstSession.resumeCursor,
403 providerOptions: {
404 codex: {
405 binaryPath: process.env.CODEX_BINARY_PATH,
406 ...(process.env.CODEX_HOME_PATH
407 ? { homePath: process.env.CODEX_HOME_PATH }
408 : {}),
409 },
410 },
411 });
412
413 expect(resumedSession.threadId).toBe(originalThreadId);
414
415 const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.sessionId);
416 expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId);
417 expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount);
418
419 await manager.sendTurn({
420 sessionId: resumedSession.sessionId,
421 input: `Reply with exactly the word BETA ${randomUUID()}`,
422 });
423
424 await vi.waitFor(async () => {
425 const snapshot = await manager.readThread(resumedSession.sessionId);
426 expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount);
427 }, { timeout: 120_000, interval: 1_000 });
428 } finally {
429 manager.stopAll();
430 rmSync(workspaceDir, { recursive: true, force: true });
431 }
432 },
433 180_000,
434 );
435});
apps/server/src/codexAppServerManager.ts+1
......@@ -204,6 +204,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
204204 sessionId,
205205 provider: "codex",
206206 status: "connecting",
207 runtimeMode: input.runtimeMode,
207208 model: normalizeCodexModelSlug(input.model),
208209 cwd: resolvedCwd,
209210 createdAt: now,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+2
......@@ -80,6 +80,7 @@ function createProviderServiceHarness(
8080 sessionId: asSessionId("sess-1"),
8181 provider: providerName,
8282 status: "ready",
83 runtimeMode: "full-access",
8384 threadId: asProviderThreadId("provider-thread-1"),
8485 cwd: sessionCwd,
8586 createdAt: now,
......@@ -293,6 +294,7 @@ describe("CheckpointReactor", () => {
293294 projectId: asProjectId("project-1"),
294295 title: "Thread",
295296 model: "gpt-5-codex",
297 runtimeMode: "approval-required",
296298 branch: null,
297299 worktreePath: options?.threadWorktreePath ?? cwd,
298300 createdAt,
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+10
......@@ -80,6 +80,7 @@ describe("OrchestrationEngine", () => {
8080 projectId: asProjectId("project-1"),
8181 title: "Thread",
8282 model: "gpt-5-codex",
83 runtimeMode: "approval-required",
8384 branch: null,
8485 worktreePath: null,
8586 createdAt,
......@@ -131,6 +132,7 @@ describe("OrchestrationEngine", () => {
131132 projectId: asProjectId("project-replay"),
132133 title: "replay",
133134 model: "gpt-5-codex",
135 runtimeMode: "approval-required",
134136 branch: null,
135137 worktreePath: null,
136138 createdAt,
......@@ -191,6 +193,7 @@ describe("OrchestrationEngine", () => {
191193 projectId: asProjectId("project-stream"),
192194 title: "domain-stream",
193195 model: "gpt-5-codex",
196 runtimeMode: "approval-required",
194197 branch: null,
195198 worktreePath: null,
196199 createdAt,
......@@ -234,6 +237,7 @@ describe("OrchestrationEngine", () => {
234237 projectId: asProjectId("project-turn-diff"),
235238 title: "Turn diff thread",
236239 model: "gpt-5-codex",
240 runtimeMode: "approval-required",
237241 branch: null,
238242 worktreePath: null,
239243 createdAt,
......@@ -341,6 +345,7 @@ describe("OrchestrationEngine", () => {
341345 projectId: asProjectId("project-flaky"),
342346 title: "flaky-fail",
343347 model: "gpt-5-codex",
348 runtimeMode: "approval-required",
344349 branch: null,
345350 worktreePath: null,
346351 createdAt,
......@@ -356,6 +361,7 @@ describe("OrchestrationEngine", () => {
356361 projectId: asProjectId("project-flaky"),
357362 title: "flaky-ok",
358363 model: "gpt-5-codex",
364 runtimeMode: "approval-required",
359365 branch: null,
360366 worktreePath: null,
361367 createdAt,
......@@ -419,6 +425,7 @@ describe("OrchestrationEngine", () => {
419425 projectId: asProjectId("project-atomic"),
420426 title: "atomic",
421427 model: "gpt-5-codex",
428 runtimeMode: "approval-required",
422429 branch: null,
423430 worktreePath: null,
424431 createdAt,
......@@ -551,6 +558,7 @@ describe("OrchestrationEngine", () => {
551558 projectId: asProjectId("project-sync"),
552559 title: "sync-before",
553560 model: "gpt-5-codex",
561 runtimeMode: "approval-required",
554562 branch: null,
555563 worktreePath: null,
556564 createdAt,
......@@ -628,6 +636,7 @@ describe("OrchestrationEngine", () => {
628636 projectId: asProjectId("project-duplicate"),
629637 title: "duplicate",
630638 model: "gpt-5-codex",
639 runtimeMode: "approval-required",
631640 branch: null,
632641 worktreePath: null,
633642 createdAt,
......@@ -643,6 +652,7 @@ describe("OrchestrationEngine", () => {
643652 projectId: asProjectId("project-duplicate"),
644653 title: "duplicate",
645654 model: "gpt-5-codex",
655 runtimeMode: "approval-required",
646656 branch: null,
647657 worktreePath: null,
648658 createdAt,
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+16
......@@ -396,6 +396,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
396396 projectId: event.payload.projectId,
397397 title: event.payload.title,
398398 model: event.payload.model,
399 runtimeMode: event.payload.runtimeMode,
399400 branch: event.payload.branch,
400401 worktreePath: event.payload.worktreePath,
401402 latestTurnId: null,
......@@ -425,6 +426,21 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
425426 return;
426427 }
427428
429 case "thread.runtime-mode-set": {
430 const existingRow = yield* projectionThreadRepository.getById({
431 threadId: event.payload.threadId,
432 });
433 if (Option.isNone(existingRow)) {
434 return;
435 }
436 yield* projectionThreadRepository.upsert({
437 ...existingRow.value,
438 runtimeMode: event.payload.runtimeMode,
439 updatedAt: event.payload.updatedAt,
440 });
441 return;
442 }
443
428444 case "thread.deleted": {
429445 attachmentSideEffects.deletedThreadIds.add(event.payload.threadId);
430446 const existingRow = yield* projectionThreadRepository.getById({
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+1
......@@ -239,6 +239,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
239239 projectId: asProjectId("project-1"),
240240 title: "Thread 1",
241241 model: "gpt-5-codex",
242 runtimeMode: "full-access",
242243 branch: null,
243244 worktreePath: null,
244245 latestTurn: {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+2
......@@ -153,6 +153,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
153153 project_id AS "projectId",
154154 title,
155155 model,
156 runtime_mode AS "runtimeMode",
156157 branch,
157158 worktree_path AS "worktreePath",
158159 latest_turn_id AS "latestTurnId",
......@@ -485,6 +486,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
485486 projectId: row.projectId,
486487 title: row.title,
487488 model: row.model,
489 runtimeMode: row.runtimeMode,
488490 branch: row.branch,
489491 worktreePath: row.worktreePath,
490492 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+25-12
......@@ -114,6 +114,13 @@ describe("ProviderCommandReactor", () => {
114114 sessionId: asSessionId(`sess-${sessionIndex}`),
115115 provider,
116116 status: "ready" as const,
117 runtimeMode:
118 typeof input === "object" &&
119 input !== null &&
120 "runtimeMode" in input &&
121 (input.runtimeMode === "approval-required" || input.runtimeMode === "full-access")
122 ? input.runtimeMode
123 : "full-access",
117124 ...(model !== undefined ? { model } : {}),
118125 threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`),
119126 resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` },
......@@ -226,6 +233,7 @@ describe("ProviderCommandReactor", () => {
226233 projectId: asProjectId("project-1"),
227234 title: "Thread",
228235 model: "gpt-5-codex",
236 runtimeMode: "approval-required",
229237 branch: null,
230238 worktreePath: null,
231239 createdAt: now,
......@@ -497,7 +505,7 @@ describe("ProviderCommandReactor", () => {
497505 });
498506 });
499507
500 it("restarts the provider session when runtime mode changes", async () => {
508 it("restarts the provider session when runtime mode is updated on the thread", async () => {
501509 const harness = await createHarness();
502510 const now = new Date().toISOString();
503511
......@@ -520,6 +528,19 @@ describe("ProviderCommandReactor", () => {
520528 await waitFor(() => harness.startSession.mock.calls.length === 1);
521529 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
522530
531 await Effect.runPromise(
532 harness.engine.dispatch({
533 type: "thread.runtime-mode.set",
534 commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-1"),
535 threadId: ThreadId.makeUnsafe("thread-1"),
536 runtimeMode: "approval-required",
537 createdAt: now,
538 }),
539 );
540
541 await waitFor(() => harness.startSession.mock.calls.length === 2);
542 await waitFor(() => harness.stopSession.mock.calls.length === 1);
543
523544 await Effect.runPromise(
524545 harness.engine.dispatch({
525546 type: "thread.turn.start",
......@@ -531,13 +552,11 @@ describe("ProviderCommandReactor", () => {
531552 text: "second",
532553 attachments: [],
533554 },
534 runtimeMode: "approval-required",
555 runtimeMode: "full-access",
535556 createdAt: now,
536557 }),
537558 );
538559
539 await waitFor(() => harness.stopSession.mock.calls.length === 1);
540 await waitFor(() => harness.startSession.mock.calls.length === 2);
541560 await waitFor(() => harness.sendTurn.mock.calls.length === 2);
542561
543562 expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") });
......@@ -642,15 +661,9 @@ describe("ProviderCommandReactor", () => {
642661
643662 await Effect.runPromise(
644663 harness.engine.dispatch({
645 type: "thread.turn.start",
646 commandId: CommandId.makeUnsafe("cmd-turn-start-restart-failure-2"),
664 type: "thread.runtime-mode.set",
665 commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-restart-failure"),
647666 threadId: ThreadId.makeUnsafe("thread-1"),
648 message: {
649 messageId: asMessageId("user-message-restart-failure-2"),
650 role: "user",
651 text: "second",
652 attachments: [],
653 },
654667 runtimeMode: "approval-required",
655668 createdAt: now,
656669 }),
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+12-8
......@@ -28,6 +28,7 @@ type ProviderIntentEvent = Extract<
2828 OrchestrationEvent,
2929 {
3030 type:
31 | "thread.runtime-mode-set"
3132 | "thread.turn-start-requested"
3233 | "thread.turn-interrupt-requested"
3334 | "thread.approval-response-requested"
......@@ -197,7 +198,6 @@ const make = Effect.gen(function* () {
197198 options?: {
198199 readonly provider?: ProviderKind;
199200 readonly model?: string;
200 readonly runtimeMode?: RuntimeMode;
201201 },
202202 ) {
203203 const readModel = yield* orchestrationEngine.getReadModel();
......@@ -206,8 +206,7 @@ const make = Effect.gen(function* () {
206206 return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`));
207207 }
208208
209 const desiredRuntimeMode =
210 options?.runtimeMode ?? thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
209 const desiredRuntimeMode = thread.runtimeMode;
211210 const currentProvider: ProviderKind | undefined =
212211 thread.session?.providerName === "codex" ||
213212 thread.session?.providerName === "claudeCode" ||
......@@ -260,8 +259,7 @@ const make = Effect.gen(function* () {
260259
261260 const existingSessionId = thread.session?.providerSessionId;
262261 if (existingSessionId) {
263 const runtimeModeChanged =
264 options?.runtimeMode !== undefined && options.runtimeMode !== thread.session?.runtimeMode;
262 const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode;
265263 const providerChanged = options?.provider !== undefined && options.provider !== currentProvider;
266264 const activeSession = yield* resolveActiveSession(existingSessionId);
267265 const sessionModelSwitch =
......@@ -316,7 +314,6 @@ const make = Effect.gen(function* () {
316314 readonly provider?: ProviderKind;
317315 readonly model?: string;
318316 readonly effort?: string;
319 readonly runtimeMode: RuntimeMode;
320317 readonly createdAt: string;
321318 }) {
322319 const thread = yield* resolveThread(input.threadId);
......@@ -326,7 +323,6 @@ const make = Effect.gen(function* () {
326323 const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt, {
327324 ...(input.provider !== undefined ? { provider: input.provider } : {}),
328325 ...(input.model !== undefined ? { model: input.model } : {}),
329 runtimeMode: input.runtimeMode,
330326 });
331327 const normalizedInput = toNonEmptyProviderInput(input.messageText);
332328 const normalizedAttachments = input.attachments ?? [];
......@@ -459,7 +455,6 @@ const make = Effect.gen(function* () {
459455 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),
460456 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
461457 ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}),
462 runtimeMode: event.payload.runtimeMode,
463458 createdAt: event.payload.createdAt,
464459 });
465460 });
......@@ -569,6 +564,14 @@ const make = Effect.gen(function* () {
569564 const processDomainEvent = (event: ProviderIntentEvent) =>
570565 Effect.gen(function* () {
571566 switch (event.type) {
567 case "thread.runtime-mode-set": {
568 const thread = yield* resolveThread(event.payload.threadId);
569 if (!thread?.session || thread.session.status === "stopped") {
570 return;
571 }
572 yield* ensureSessionForThread(event.payload.threadId, event.occurredAt);
573 return;
574 }
572575 case "thread.turn-start-requested":
573576 yield* processTurnStartRequested(event);
574577 return;
......@@ -608,6 +611,7 @@ const make = Effect.gen(function* () {
608611 yield* Effect.forkScoped(
609612 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
610613 if (
614 event.type !== "thread.runtime-mode-set" &&
611615 event.type !== "thread.turn-start-requested" &&
612616 event.type !== "thread.turn-interrupt-requested" &&
613617 event.type !== "thread.approval-response-requested" &&
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+1
......@@ -167,6 +167,7 @@ describe("ProviderRuntimeIngestion", () => {
167167 projectId: asProjectId("project-1"),
168168 title: "Thread",
169169 model: "gpt-5-codex",
170 runtimeMode: "approval-required",
170171 branch: null,
171172 worktreePath: null,
172173 createdAt,
apps/server/src/orchestration/Schemas.ts+2
......@@ -4,6 +4,7 @@ import {
44 ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema,
55 ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema,
66 ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema,
7 ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema,
78 ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema,
89 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
910 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
......@@ -24,6 +25,7 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema;
2425
2526export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema;
2627export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema;
28export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema;
2729export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;
2830
2931export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
apps/server/src/orchestration/commandInvariants.test.ts+4
......@@ -50,6 +50,7 @@ const readModel: OrchestrationReadModel = {
5050 projectId: ProjectId.makeUnsafe("project-a"),
5151 title: "Thread A",
5252 model: "gpt-5-codex",
53 runtimeMode: "full-access",
5354 branch: null,
5455 worktreePath: null,
5556 createdAt: now,
......@@ -66,6 +67,7 @@ const readModel: OrchestrationReadModel = {
6667 projectId: ProjectId.makeUnsafe("project-b"),
6768 title: "Thread B",
6869 model: "gpt-5-codex",
70 runtimeMode: "full-access",
6971 branch: null,
7072 worktreePath: null,
7173 createdAt: now,
......@@ -137,6 +139,7 @@ describe("commandInvariants", () => {
137139 projectId: ProjectId.makeUnsafe("project-a"),
138140 title: "new",
139141 model: "gpt-5-codex",
142 runtimeMode: "full-access",
140143 branch: null,
141144 worktreePath: null,
142145 createdAt: now,
......@@ -156,6 +159,7 @@ describe("commandInvariants", () => {
156159 projectId: ProjectId.makeUnsafe("project-a"),
157160 title: "dup",
158161 model: "gpt-5-codex",
162 runtimeMode: "full-access",
159163 branch: null,
160164 worktreePath: null,
161165 createdAt: now,
apps/server/src/orchestration/decider.projectScripts.test.ts+77
......@@ -130,6 +130,7 @@ describe("decider project scripts", () => {
130130 projectId: asProjectId("project-1"),
131131 title: "Thread",
132132 model: "gpt-5-codex",
133 runtimeMode: "approval-required",
133134 branch: null,
134135 worktreePath: null,
135136 createdAt: now,
......@@ -180,4 +181,80 @@ describe("decider project scripts", () => {
180181 runtimeMode: "approval-required",
181182 });
182183 });
184
185 it("emits thread.runtime-mode-set from thread.runtime-mode.set", async () => {
186 const now = new Date().toISOString();
187 const initial = createEmptyReadModel(now);
188 const withProject = await Effect.runPromise(
189 projectEvent(initial, {
190 sequence: 1,
191 eventId: asEventId("evt-project-create"),
192 aggregateKind: "project",
193 aggregateId: asProjectId("project-1"),
194 type: "project.created",
195 occurredAt: now,
196 commandId: CommandId.makeUnsafe("cmd-project-create"),
197 causationEventId: null,
198 correlationId: CommandId.makeUnsafe("cmd-project-create"),
199 metadata: {},
200 payload: {
201 projectId: asProjectId("project-1"),
202 title: "Project",
203 workspaceRoot: "/tmp/project",
204 defaultModel: null,
205 scripts: [],
206 createdAt: now,
207 updatedAt: now,
208 },
209 }),
210 );
211 const readModel = await Effect.runPromise(
212 projectEvent(withProject, {
213 sequence: 2,
214 eventId: asEventId("evt-thread-create"),
215 aggregateKind: "thread",
216 aggregateId: ThreadId.makeUnsafe("thread-1"),
217 type: "thread.created",
218 occurredAt: now,
219 commandId: CommandId.makeUnsafe("cmd-thread-create"),
220 causationEventId: null,
221 correlationId: CommandId.makeUnsafe("cmd-thread-create"),
222 metadata: {},
223 payload: {
224 threadId: ThreadId.makeUnsafe("thread-1"),
225 projectId: asProjectId("project-1"),
226 title: "Thread",
227 model: "gpt-5-codex",
228 runtimeMode: "full-access",
229 branch: null,
230 worktreePath: null,
231 createdAt: now,
232 updatedAt: now,
233 },
234 }),
235 );
236
237 const result = await Effect.runPromise(
238 decideOrchestrationCommand({
239 command: {
240 type: "thread.runtime-mode.set",
241 commandId: CommandId.makeUnsafe("cmd-runtime-mode-set"),
242 threadId: ThreadId.makeUnsafe("thread-1"),
243 runtimeMode: "approval-required",
244 createdAt: now,
245 },
246 readModel,
247 }),
248 );
249
250 const singleResult = Array.isArray(result) ? null : result;
251 if (singleResult === null) {
252 throw new Error("Expected a single runtime-mode-set event.");
253 }
254 expect(singleResult.type).toBe("thread.runtime-mode-set");
255 expect(singleResult.payload).toMatchObject({
256 threadId: ThreadId.makeUnsafe("thread-1"),
257 runtimeMode: "approval-required",
258 });
259 });
183260});
apps/server/src/orchestration/decider.ts+27-1
......@@ -157,6 +157,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
157157 projectId: command.projectId,
158158 title: command.title,
159159 model: command.model,
160 runtimeMode: command.runtimeMode,
160161 branch: command.branch,
161162 worktreePath: command.worktreePath,
162163 createdAt: command.createdAt,
......@@ -213,6 +214,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
213214 };
214215 }
215216
217 case "thread.runtime-mode.set": {
218 yield* requireThread({
219 readModel,
220 command,
221 threadId: command.threadId,
222 });
223 const occurredAt = nowIso();
224 return {
225 ...withEventBase({
226 aggregateKind: "thread",
227 aggregateId: command.threadId,
228 occurredAt,
229 commandId: command.commandId,
230 }),
231 type: "thread.runtime-mode-set",
232 payload: {
233 threadId: command.threadId,
234 runtimeMode: command.runtimeMode,
235 updatedAt: occurredAt,
236 },
237 };
238 }
239
216240 case "thread.turn.start": {
217241 yield* requireThread({
218242 readModel,
......@@ -255,7 +279,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
255279 ...(command.model !== undefined ? { model: command.model } : {}),
256280 ...(command.effort !== undefined ? { effort: command.effort } : {}),
257281 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,
258 runtimeMode: command.runtimeMode,
282 runtimeMode:
283 readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ??
284 command.runtimeMode,
259285 createdAt: command.createdAt,
260286 },
261287 };
apps/server/src/orchestration/projector.test.ts+60
......@@ -57,6 +57,7 @@ describe("orchestration projector", () => {
5757 projectId: "project-1",
5858 title: "demo",
5959 model: "gpt-5-codex",
60 runtimeMode: "full-access",
6061 branch: null,
6162 worktreePath: null,
6263 createdAt: now,
......@@ -73,6 +74,7 @@ describe("orchestration projector", () => {
7374 projectId: "project-1",
7475 title: "demo",
7576 model: "gpt-5-codex",
77 runtimeMode: "full-access",
7678 branch: null,
7779 worktreePath: null,
7880 latestTurn: null,
......@@ -167,6 +169,7 @@ describe("orchestration projector", () => {
167169 projectId: "project-1",
168170 title: "demo",
169171 model: "gpt-5.3-codex",
172 runtimeMode: "full-access",
170173 branch: null,
171174 worktreePath: null,
172175 createdAt,
......@@ -209,6 +212,59 @@ describe("orchestration projector", () => {
209212 expect(thread?.session?.status).toBe("running");
210213 });
211214
215 it("updates canonical thread runtime mode from thread.runtime-mode-set", async () => {
216 const createdAt = "2026-02-23T08:00:00.000Z";
217 const updatedAt = "2026-02-23T08:00:05.000Z";
218 const model = createEmptyReadModel(createdAt);
219
220 const afterCreate = await Effect.runPromise(
221 projectEvent(
222 model,
223 makeEvent({
224 sequence: 1,
225 type: "thread.created",
226 aggregateKind: "thread",
227 aggregateId: "thread-1",
228 occurredAt: createdAt,
229 commandId: "cmd-create",
230 payload: {
231 threadId: "thread-1",
232 projectId: "project-1",
233 title: "demo",
234 model: "gpt-5.3-codex",
235 runtimeMode: "full-access",
236 branch: null,
237 worktreePath: null,
238 createdAt,
239 updatedAt: createdAt,
240 },
241 }),
242 ),
243 );
244
245 const afterUpdate = await Effect.runPromise(
246 projectEvent(
247 afterCreate,
248 makeEvent({
249 sequence: 2,
250 type: "thread.runtime-mode-set",
251 aggregateKind: "thread",
252 aggregateId: "thread-1",
253 occurredAt: updatedAt,
254 commandId: "cmd-runtime-mode-set",
255 payload: {
256 threadId: "thread-1",
257 runtimeMode: "approval-required",
258 updatedAt,
259 },
260 }),
261 ),
262 );
263
264 expect(afterUpdate.threads[0]?.runtimeMode).toBe("approval-required");
265 expect(afterUpdate.threads[0]?.updatedAt).toBe(updatedAt);
266 });
267
212268 it("marks assistant messages completed with non-streaming updates", async () => {
213269 const createdAt = "2026-02-23T09:00:00.000Z";
214270 const deltaAt = "2026-02-23T09:00:01.000Z";
......@@ -230,6 +286,7 @@ describe("orchestration projector", () => {
230286 projectId: "project-1",
231287 title: "demo",
232288 model: "gpt-5.3-codex",
289 runtimeMode: "full-access",
233290 branch: null,
234291 worktreePath: null,
235292 createdAt,
......@@ -313,6 +370,7 @@ describe("orchestration projector", () => {
313370 projectId: "project-1",
314371 title: "demo",
315372 model: "gpt-5.3-codex",
373 runtimeMode: "full-access",
316374 branch: null,
317375 worktreePath: null,
318376 createdAt,
......@@ -524,6 +582,7 @@ describe("orchestration projector", () => {
524582 projectId: "project-1",
525583 title: "demo",
526584 model: "gpt-5.3-codex",
585 runtimeMode: "full-access",
527586 branch: null,
528587 worktreePath: null,
529588 createdAt,
......@@ -673,6 +732,7 @@ describe("orchestration projector", () => {
673732 projectId: "project-1",
674733 title: "capped",
675734 model: "gpt-5-codex",
735 runtimeMode: "full-access",
676736 branch: null,
677737 worktreePath: null,
678738 createdAt,
apps/server/src/orchestration/projector.ts+18
......@@ -17,6 +17,7 @@ import {
1717 ThreadCreatedPayload,
1818 ThreadDeletedPayload,
1919 ThreadMetaUpdatedPayload,
20 ThreadRuntimeModeSetPayload,
2021 ThreadRevertedPayload,
2122 ThreadSessionSetPayload,
2223 ThreadTurnDiffCompletedPayload,
......@@ -241,6 +242,7 @@ export function projectEvent(
241242 projectId: payload.projectId,
242243 title: payload.title,
243244 model: payload.model,
245 runtimeMode: payload.runtimeMode,
244246 branch: payload.branch,
245247 worktreePath: payload.worktreePath,
246248 latestTurn: null,
......@@ -289,6 +291,22 @@ export function projectEvent(
289291 })),
290292 );
291293
294 case "thread.runtime-mode-set":
295 return decodeForEvent(
296 ThreadRuntimeModeSetPayload,
297 event.payload,
298 event.type,
299 "payload",
300 ).pipe(
301 Effect.map((payload) => ({
302 ...nextBase,
303 threads: updateThread(nextBase.threads, payload.threadId, {
304 runtimeMode: payload.runtimeMode,
305 updatedAt: payload.updatedAt,
306 }),
307 })),
308 );
309
292310 case "thread.message-sent":
293311 return Effect.gen(function* () {
294312 const payload = yield* decodeForEvent(
apps/server/src/persistence/Layers/ProjectionThreads.ts+5
......@@ -24,6 +24,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
2424 project_id,
2525 title,
2626 model,
27 runtime_mode,
2728 branch,
2829 worktree_path,
2930 latest_turn_id,
......@@ -36,6 +37,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
3637 ${row.projectId},
3738 ${row.title},
3839 ${row.model},
40 ${row.runtimeMode},
3941 ${row.branch},
4042 ${row.worktreePath},
4143 ${row.latestTurnId},
......@@ -48,6 +50,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
4850 project_id = excluded.project_id,
4951 title = excluded.title,
5052 model = excluded.model,
53 runtime_mode = excluded.runtime_mode,
5154 branch = excluded.branch,
5255 worktree_path = excluded.worktree_path,
5356 latest_turn_id = excluded.latest_turn_id,
......@@ -67,6 +70,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
6770 project_id AS "projectId",
6871 title,
6972 model,
73 runtime_mode AS "runtimeMode",
7074 branch,
7175 worktree_path AS "worktreePath",
7276 latest_turn_id AS "latestTurnId",
......@@ -88,6 +92,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
8892 project_id AS "projectId",
8993 title,
9094 model,
95 runtime_mode AS "runtimeMode",
9196 branch,
9297 worktree_path AS "worktreePath",
9398 latest_turn_id AS "latestTurnId",
apps/server/src/persistence/Layers/ProviderSessionRuntime.ts+5
......@@ -49,6 +49,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
4949 provider_name,
5050 adapter_key,
5151 provider_thread_id,
52 runtime_mode,
5253 status,
5354 last_seen_at,
5455 resume_cursor_json,
......@@ -60,6 +61,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
6061 ${runtime.providerName},
6162 ${runtime.adapterKey},
6263 ${runtime.providerThreadId},
64 ${runtime.runtimeMode},
6365 ${runtime.status},
6466 ${runtime.lastSeenAt},
6567 ${runtime.resumeCursor},
......@@ -71,6 +73,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
7173 provider_name = excluded.provider_name,
7274 adapter_key = excluded.adapter_key,
7375 provider_thread_id = excluded.provider_thread_id,
76 runtime_mode = excluded.runtime_mode,
7477 status = excluded.status,
7578 last_seen_at = excluded.last_seen_at,
7679 resume_cursor_json = excluded.resume_cursor_json,
......@@ -89,6 +92,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
8992 provider_name AS "providerName",
9093 adapter_key AS "adapterKey",
9194 provider_thread_id AS "providerThreadId",
95 runtime_mode AS "runtimeMode",
9296 status,
9397 last_seen_at AS "lastSeenAt",
9498 resume_cursor_json AS "resumeCursor",
......@@ -109,6 +113,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
109113 provider_name AS "providerName",
110114 adapter_key AS "adapterKey",
111115 provider_thread_id AS "providerThreadId",
116 runtime_mode AS "runtimeMode",
112117 status,
113118 last_seen_at AS "lastSeenAt",
114119 resume_cursor_json AS "resumeCursor",
apps/server/src/persistence/Migrations.ts+12-1
......@@ -20,6 +20,10 @@ import Migration0005 from "./Migrations/005_Projections.ts";
2020import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts";
2121import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts";
2222import Migration0008 from "./Migrations/008_ProjectionThreadActivitySequence.ts";
23import Migration0009 from "./Migrations/009_ProviderSessionRuntimeMode.ts";
24import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts";
25import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts";
26import { Effect } from "effect";
2327
2428/**
2529 * Migration loader with all migrations defined inline.
......@@ -40,6 +44,9 @@ const loader = Migrator.fromRecord({
4044 "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006,
4145 "7_ProjectionThreadMessageAttachments": Migration0007,
4246 "8_ProjectionThreadActivitySequence": Migration0008,
47 "9_ProviderSessionRuntimeMode": Migration0009,
48 "10_ProjectionThreadsRuntimeMode": Migration0010,
49 "11_OrchestrationThreadCreatedRuntimeMode": Migration0011,
4350});
4451
4552/**
......@@ -58,7 +65,11 @@ const run = Migrator.make({});
5865 *
5966 * @returns Effect containing array of executed migrations
6067 */
61export const runMigrations = run({ loader });
68export const runMigrations = Effect.gen(function* () {
69 yield* Effect.log("Running migrations...");
70 yield* run({ loader });
71 yield* Effect.log("Migrations ran successfully");
72});
6273
6374/**
6475 * Layer that runs migrations when the layer is built.
apps/server/src/persistence/Migrations/009_ProviderSessionRuntimeMode.ts created+17
......@@ -0,0 +1,17 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as Effect from "effect/Effect";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 ALTER TABLE provider_session_runtime
9 ADD COLUMN runtime_mode TEXT NOT NULL DEFAULT 'full-access'
10 `;
11
12 yield* sql`
13 UPDATE provider_session_runtime
14 SET runtime_mode = 'full-access'
15 WHERE runtime_mode IS NULL
16 `;
17});
apps/server/src/persistence/Migrations/010_ProjectionThreadsRuntimeMode.ts created+17
......@@ -0,0 +1,17 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 ALTER TABLE projection_threads
9 ADD COLUMN runtime_mode TEXT NOT NULL DEFAULT 'full-access'
10 `;
11
12 yield* sql`
13 UPDATE projection_threads
14 SET runtime_mode = 'full-access'
15 WHERE runtime_mode IS NULL
16 `;
17});
apps/server/src/persistence/Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts created+13
......@@ -0,0 +1,13 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 UPDATE orchestration_events
9 SET payload_json = json_set(payload_json, '$.runtimeMode', 'full-access')
10 WHERE event_type = 'thread.created'
11 AND json_type(payload_json, '$.runtimeMode') IS NULL
12 `;
13});
apps/server/src/persistence/Services/ProjectionThreads.ts+2-1
......@@ -6,7 +6,7 @@
66 *
77 * @module ProjectionThreadRepository
88 */
9import { IsoDateTime, ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
9import { IsoDateTime, ProjectId, RuntimeMode, ThreadId, TurnId } from "@t3tools/contracts";
1010import { Option, Schema, ServiceMap } from "effect";
1111import type { Effect } from "effect";
1212
......@@ -17,6 +17,7 @@ export const ProjectionThread = Schema.Struct({
1717 projectId: ProjectId,
1818 title: Schema.String,
1919 model: Schema.String,
20 runtimeMode: RuntimeMode,
2021 branch: Schema.NullOr(Schema.String),
2122 worktreePath: Schema.NullOr(Schema.String),
2223 latestTurnId: Schema.NullOr(TurnId),
apps/server/src/persistence/Services/ProviderSessionRuntime.ts+2
......@@ -10,6 +10,7 @@ import {
1010 ProviderSessionId,
1111 ProviderSessionRuntimeStatus,
1212 ProviderThreadId,
13 RuntimeMode,
1314 ThreadId,
1415} from "@t3tools/contracts";
1516import { Option, Schema, ServiceMap } from "effect";
......@@ -23,6 +24,7 @@ export const ProviderSessionRuntime = Schema.Struct({
2324 providerName: Schema.String,
2425 adapterKey: Schema.String,
2526 providerThreadId: Schema.NullOr(ProviderThreadId),
27 runtimeMode: RuntimeMode,
2628 status: ProviderSessionRuntimeStatus,
2729 lastSeenAt: IsoDateTime,
2830 resumeCursor: Schema.NullOr(Schema.Unknown),
apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts+15-2
......@@ -166,7 +166,9 @@ describe("ClaudeCodeAdapterLive", () => {
166166 const harness = makeHarness();
167167 return Effect.gen(function* () {
168168 const adapter = yield* ClaudeCodeAdapter;
169 const result = yield* adapter.startSession({ provider: "codex" }).pipe(Effect.result);
169 const result = yield* adapter
170 .startSession({ provider: "codex", runtimeMode: "full-access" })
171 .pipe(Effect.result);
170172
171173 assert.equal(result._tag, "Failure");
172174 if (result._tag !== "Failure") {
......@@ -240,6 +242,7 @@ describe("ClaudeCodeAdapterLive", () => {
240242 const session = yield* adapter.startSession({
241243 provider: "claudeCode",
242244 model: "claude-sonnet-4-5",
245 runtimeMode: "full-access",
243246 });
244247
245248 const turn = yield* adapter.sendTurn({
......@@ -374,6 +377,7 @@ describe("ClaudeCodeAdapterLive", () => {
374377
375378 const session = yield* adapter.startSession({
376379 provider: "claudeCode",
380 runtimeMode: "full-access",
377381 });
378382
379383 const turn = yield* adapter.sendTurn({
......@@ -461,6 +465,7 @@ describe("ClaudeCodeAdapterLive", () => {
461465
462466 const session = yield* adapter.startSession({
463467 provider: "claudeCode",
468 runtimeMode: "full-access",
464469 });
465470
466471 const turn = yield* adapter.sendTurn({
......@@ -529,6 +534,7 @@ describe("ClaudeCodeAdapterLive", () => {
529534
530535 const session = yield* adapter.startSession({
531536 provider: "claudeCode",
537 runtimeMode: "full-access",
532538 });
533539 assert.equal(session.threadId, undefined);
534540
......@@ -679,6 +685,7 @@ describe("ClaudeCodeAdapterLive", () => {
679685 resumeSessionAt: "assistant-99",
680686 turnCount: 3,
681687 },
688 runtimeMode: "full-access",
682689 });
683690
684691 assert.equal(session.threadId, "resume-thread-1");
......@@ -705,6 +712,7 @@ describe("ClaudeCodeAdapterLive", () => {
705712
706713 const session = yield* adapter.startSession({
707714 provider: "claudeCode",
715 runtimeMode: "full-access",
708716 });
709717
710718 assert.equal(
......@@ -727,6 +735,7 @@ describe("ClaudeCodeAdapterLive", () => {
727735
728736 const session = yield* adapter.startSession({
729737 provider: "claudeCode",
738 runtimeMode: "full-access",
730739 });
731740
732741 const firstTurn = yield* adapter.sendTurn({
......@@ -802,7 +811,10 @@ describe("ClaudeCodeAdapterLive", () => {
802811 return Effect.gen(function* () {
803812 const adapter = yield* ClaudeCodeAdapter;
804813
805 const session = yield* adapter.startSession({ provider: "claudeCode" });
814 const session = yield* adapter.startSession({
815 provider: "claudeCode",
816 runtimeMode: "full-access",
817 });
806818 yield* adapter.sendTurn({
807819 sessionId: session.sessionId,
808820 input: "hello",
......@@ -841,6 +853,7 @@ describe("ClaudeCodeAdapterLive", () => {
841853
842854 const session = yield* adapter.startSession({
843855 provider: "claudeCode",
856 runtimeMode: "full-access",
844857 });
845858 const turn = yield* adapter.sendTurn({
846859 sessionId: session.sessionId,
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+1
......@@ -1632,6 +1632,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
16321632 sessionId,
16331633 provider: PROVIDER,
16341634 status: "ready",
1635 runtimeMode: input.runtimeMode,
16351636 ...(input.cwd ? { cwd: input.cwd } : {}),
16361637 ...(input.model ? { model: input.model } : {}),
16371638 ...(threadId ? { threadId } : {}),
apps/server/src/provider/Layers/CodexAdapter.test.ts+2
......@@ -40,6 +40,7 @@ class FakeCodexManager extends CodexAppServerManager {
4040 sessionId: asSessionId("sess-1"),
4141 provider: "codex",
4242 status: "ready",
43 runtimeMode: input.runtimeMode,
4344 threadId: ProviderThreadId.makeUnsafe("thread-1"),
4445 cwd: input.cwd,
4546 createdAt: now,
......@@ -148,6 +149,7 @@ validationLayer("CodexAdapterLive validation", (it) => {
148149 const result = yield* adapter
149150 .startSession({
150151 provider: "claudeCode",
152 runtimeMode: "full-access",
151153 })
152154 .pipe(Effect.result);
153155
apps/server/src/provider/Layers/CursorAdapter.test.ts+10-1
......@@ -255,7 +255,9 @@ describe("CursorAdapterLive", () => {
255255
256256 return Effect.gen(function* () {
257257 const adapter = yield* CursorAdapter;
258 const result = yield* adapter.startSession({ provider: "codex" }).pipe(Effect.result);
258 const result = yield* adapter
259 .startSession({ provider: "codex", runtimeMode: "full-access" })
260 .pipe(Effect.result);
259261
260262 assert.equal(result._tag, "Failure");
261263 if (result._tag !== "Failure") {
......@@ -289,6 +291,7 @@ describe("CursorAdapterLive", () => {
289291 const session = yield* adapter.startSession({
290292 provider: "cursor",
291293 cwd: "/tmp/project",
294 runtimeMode: "full-access",
292295 });
293296
294297 const turn = yield* adapter.sendTurn({
......@@ -353,6 +356,7 @@ describe("CursorAdapterLive", () => {
353356 yield* adapter.startSession({
354357 provider: "cursor",
355358 model: "composer-1.5",
359 runtimeMode: "full-access",
356360 });
357361
358362 assert.deepEqual(createProcessInput?.model, "composer-1.5");
......@@ -385,6 +389,7 @@ describe("CursorAdapterLive", () => {
385389 const session = yield* adapter.startSession({
386390 provider: "cursor",
387391 cwd: "/tmp/project",
392 runtimeMode: "full-access",
388393 });
389394
390395 assert.equal(nativeEvents.length > 0, true);
......@@ -408,6 +413,7 @@ describe("CursorAdapterLive", () => {
408413 resumeCursor: {
409414 acpSessionId: "acp-session-resume",
410415 },
416 runtimeMode: "full-access",
411417 });
412418
413419 const methods = new Set(fake.requests.map((request) => request.method));
......@@ -441,6 +447,7 @@ describe("CursorAdapterLive", () => {
441447 resumeCursor: {
442448 sessionId: "acp-session-legacy",
443449 },
450 runtimeMode: "full-access",
444451 });
445452
446453 const loadRequest = fake.requests.find((request) => request.method === "session/load");
......@@ -555,6 +562,7 @@ describe("CursorAdapterLive", () => {
555562 const adapter = yield* CursorAdapter;
556563 const session = yield* adapter.startSession({
557564 provider: "cursor",
565 runtimeMode: "full-access",
558566 });
559567
560568 yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain);
......@@ -600,6 +608,7 @@ describe("CursorAdapterLive", () => {
600608
601609 const session = yield* adapter.startSession({
602610 provider: "cursor",
611 runtimeMode: "full-access",
603612 });
604613
605614 yield* adapter.sendTurn({
apps/server/src/provider/Layers/CursorAdapter.ts+2-1
......@@ -1040,6 +1040,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
10401040 sessionId,
10411041 provider: PROVIDER,
10421042 status: "connecting",
1043 runtimeMode: input.runtimeMode,
10431044 ...(input.cwd ? { cwd: input.cwd } : {}),
10441045 ...(input.model ? { model: input.model } : {}),
10451046 createdAt: startedAt,
......@@ -1048,7 +1049,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
10481049
10491050 const context: CursorSessionContext = {
10501051 session,
1051 runtimeMode: input.runtimeMode ?? "full-access",
1052 runtimeMode: input.runtimeMode,
10521053 child,
10531054 output,
10541055 pending: new Map(),
apps/server/src/provider/Layers/ProviderService.test.ts+14
......@@ -80,6 +80,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
8080 sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`),
8181 provider,
8282 status: "ready",
83 runtimeMode: input.runtimeMode,
8384 threadId,
8485 resumeCursor: input.resumeCursor ?? { opaque: `cursor-${next}` },
8586 cwd: input.cwd ?? process.cwd(),
......@@ -350,6 +351,7 @@ it.effect(
350351 return yield* provider.startSession(asThreadId("thread-1"), {
351352 provider: "codex",
352353 cwd: "/tmp/project",
354 runtimeMode: "full-access",
353355 });
354356 }).pipe(Effect.provide(firstProviderLayer));
355357
......@@ -429,6 +431,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
429431 const session = yield* provider.startSession(asThreadId("thread-1"), {
430432 provider: "codex",
431433 cwd: "/tmp/project",
434 runtimeMode: "full-access",
432435 });
433436 assert.equal(session.provider, "codex");
434437
......@@ -481,6 +484,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
481484 const session = yield* provider.startSession(asThreadId("thread-claude"), {
482485 provider: "claudeCode",
483486 cwd: "/tmp/project-claude",
487 runtimeMode: "full-access",
484488 });
485489
486490 assert.equal(session.provider, "claudeCode");
......@@ -502,6 +506,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
502506 const initial = yield* provider.startSession(asThreadId("thread-1"), {
503507 provider: "codex",
504508 cwd: "/tmp/project",
509 runtimeMode: "full-access",
505510 });
506511 yield* routing.codex.stopSession(initial.sessionId);
507512 routing.codex.startSession.mockClear();
......@@ -540,6 +545,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
540545 const initial = yield* provider.startSession(asThreadId("thread-1"), {
541546 provider: "codex",
542547 cwd: "/tmp/project-send-turn",
548 runtimeMode: "full-access",
543549 });
544550
545551 yield* provider.stopAll();
......@@ -577,9 +583,11 @@ routing.layer("ProviderServiceLive routing", (it) => {
577583
578584 yield* provider.startSession(asThreadId("thread-1"), {
579585 provider: "codex",
586 runtimeMode: "full-access",
580587 });
581588 yield* provider.startSession(asThreadId("thread-2"), {
582589 provider: "codex",
590 runtimeMode: "full-access",
583591 });
584592
585593 yield* provider.stopAll();
......@@ -596,6 +604,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
596604
597605 const session = yield* provider.startSession(asThreadId("thread-1"), {
598606 provider: "codex",
607 runtimeMode: "full-access",
599608 });
600609 yield* provider.sendTurn({
601610 sessionId: session.sessionId,
......@@ -647,6 +656,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
647656 const provider = yield* ProviderService;
648657 const session = yield* provider.startSession(asThreadId("thread-1"), {
649658 provider: "codex",
659 runtimeMode: "full-access",
650660 });
651661
652662 const eventsRef = yield* Ref.make<Array<ProviderRuntimeEvent>>([]);
......@@ -684,6 +694,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
684694 const provider = yield* ProviderService;
685695 const session = yield* provider.startSession(asThreadId("thread-seq"), {
686696 provider: "codex",
697 runtimeMode: "full-access",
687698 });
688699
689700 const receivedRef = yield* Ref.make<Array<ProviderRuntimeEvent>>([]);
......@@ -740,6 +751,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
740751 const provider = yield* ProviderService;
741752 const session = yield* provider.startSession(asThreadId("thread-1"), {
742753 provider: "codex",
754 runtimeMode: "full-access",
743755 });
744756
745757 const receivedByHealthy: string[] = [];
......@@ -845,6 +857,7 @@ validation.layer("ProviderServiceLive validation", (it) => {
845857 sessionId: asSessionId("sess-missing-thread"),
846858 provider: "codex",
847859 status: "ready",
860 runtimeMode: input.runtimeMode,
848861 cwd: input.cwd ?? process.cwd(),
849862 createdAt: now,
850863 updatedAt: now,
......@@ -855,6 +868,7 @@ validation.layer("ProviderServiceLive validation", (it) => {
855868 const session = yield* provider.startSession(asThreadId("thread-missing"), {
856869 provider: "codex",
857870 cwd: "/tmp/project",
871 runtimeMode: "full-access",
858872 });
859873
860874 assert.equal(session.sessionId, asSessionId("sess-missing-thread"));
apps/server/src/provider/Layers/ProviderService.ts+2-1
......@@ -180,6 +180,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
180180 provider: session.provider,
181181 threadId,
182182 ...(providerThreadId !== undefined ? { providerThreadId } : {}),
183 runtimeMode: session.runtimeMode,
183184 status: toRuntimeStatus(session),
184185 ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}),
185186 runtimePayload: toRuntimePayloadFromSession(session),
......@@ -296,6 +297,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
296297 ...(persistedCwd ? { cwd: persistedCwd } : {}),
297298 ...(resumeThreadId ? { resumeThreadId } : {}),
298299 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),
300 runtimeMode: input.binding.runtimeMode ?? "full-access",
299301 });
300302 if (resumed.provider !== adapter.provider) {
301303 return yield* toValidationError(
......@@ -415,7 +417,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
415417 const input = {
416418 ...parsed,
417419 provider: parsed.provider ?? "codex",
418 runtimeMode: parsed.runtimeMode ?? "full-access",
419420 };
420421 const adapter = yield* registry.getByProvider(input.provider);
421422 const session = yield* adapter.startSession(input);
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+2
......@@ -75,6 +75,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
7575 threadId: value.threadId,
7676 adapterKey: value.adapterKey,
7777 providerThreadId: value.providerThreadId,
78 runtimeMode: value.runtimeMode,
7879 status: value.status,
7980 resumeCursor: value.resumeCursor,
8081 runtimePayload: value.runtimePayload,
......@@ -112,6 +113,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
112113 binding.providerThreadId !== undefined
113114 ? binding.providerThreadId
114115 : (existingRuntime?.providerThreadId ?? null),
116 runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access",
115117 status: binding.status ?? existingRuntime?.status ?? "running",
116118 lastSeenAt: now,
117119 resumeCursor:
apps/server/src/provider/Services/ProviderSessionDirectory.ts+2
......@@ -12,6 +12,7 @@ import type {
1212 ProviderSessionId,
1313 ProviderSessionRuntimeStatus,
1414 ProviderThreadId,
15 RuntimeMode,
1516 ThreadId,
1617} from "@t3tools/contracts";
1718import { Option, ServiceMap } from "effect";
......@@ -32,6 +33,7 @@ export interface ProviderSessionBinding {
3233 readonly status?: ProviderSessionRuntimeStatus;
3334 readonly resumeCursor?: unknown | null;
3435 readonly runtimePayload?: unknown | null;
36 readonly runtimeMode?: RuntimeMode;
3537}
3638
3739export type ProviderSessionDirectoryReadError =
apps/server/src/wsServer.test.ts+1
......@@ -1157,6 +1157,7 @@ describe("WebSocket Server", () => {
11571157 sessionId,
11581158 provider: "codex",
11591159 status: "ready",
1160 runtimeMode: "full-access",
11601161 threadId: asProviderThreadId("provider-thread-1"),
11611162 createdAt: new Date().toISOString(),
11621163 updatedAt: new Date().toISOString(),
apps/server/src/wsServer.ts+1
......@@ -595,6 +595,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
595595 projectId: bootstrapProjectId,
596596 title: "New thread",
597597 model: bootstrapProjectDefaultModel,
598 runtimeMode: "full-access",
598599 branch: null,
599600 worktreePath: null,
600601 createdAt,
apps/web/src/components/ChatView.tsx+43-33
......@@ -31,7 +31,17 @@ import {
3131 resolveModelSlugForProvider,
3232 OrchestrationThreadActivity,
3333} from "@t3tools/contracts";
34import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
34import {
35 memo,
36 useCallback,
37 useEffect,
38 useLayoutEffect,
39 useMemo,
40 useOptimistic,
41 useRef,
42 useState,
43 useTransition,
44} from "react";
3545import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3646import { useDebouncedValue } from "@tanstack/react-pacer";
3747import { useNavigate, useSearch } from "@tanstack/react-router";
......@@ -69,6 +79,7 @@ import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "..
6979import { useStore } from "../store";
7080import { truncateTitle } from "../truncateTitle";
7181import {
82 DEFAULT_RUNTIME_MODE,
7283 DEFAULT_THREAD_TERMINAL_ID,
7384 MAX_THREAD_TERMINAL_COUNT,
7485 type ChatMessage,
......@@ -267,6 +278,7 @@ function buildLocalDraftThread(
267278 projectId: draftThread.projectId,
268279 title: "New thread",
269280 model: fallbackModel,
281 runtimeMode: draftThread.runtimeMode,
270282 session: null,
271283 messages: [],
272284 error,
......@@ -500,10 +512,8 @@ interface ChatViewProps {
500512export default function ChatView({ threadId }: ChatViewProps) {
501513 const threads = useStore((store) => store.threads);
502514 const projects = useStore((store) => store.projects);
503 const runtimeMode = useStore((store) => store.runtimeMode);
504515 const markThreadVisited = useStore((store) => store.markThreadVisited);
505516 const setStoreThreadError = useStore((store) => store.setError);
506 const setStoreRuntimeMode = useStore((store) => store.setRuntimeMode);
507517 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
508518 const { settings } = useAppSettings();
509519 const navigate = useNavigate();
......@@ -549,7 +559,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
549559 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
550560 const [isConnecting, _setIsConnecting] = useState(false);
551561 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
552 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
553562 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
554563 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
555564 const [nowTick, setNowTick] = useState(() => Date.now());
......@@ -642,6 +651,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
642651 [draftThread, fallbackDraftProject?.model, localDraftError, threadId],
643652 );
644653 const activeThread = serverThread ?? localDraftThread;
654 const runtimeMode = activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
655 const [optimisticRuntimeMode, setOptimisticRuntimeMode] = useOptimistic(runtimeMode);
656 const [isRuntimeModePending, startRuntimeModeTransition] = useTransition();
645657 const isServerThread = serverThread !== undefined;
646658 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
647659 const diffSearch = useMemo(
......@@ -1410,31 +1422,26 @@ export default function ChatView({ threadId }: ChatViewProps) {
14101422
14111423 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
14121424 if (mode === runtimeMode) return;
1413 setStoreRuntimeMode(mode);
1414 scheduleComposerFocus();
1425 if (isLocalDraftThread) {
1426 setDraftThreadContext(threadId, { runtimeMode: mode });
1427 scheduleComposerFocus();
1428 return;
1429 }
14151430 const api = readNativeApi();
1416 if (!api) return;
1417
1418 const runningThreadIds = threads
1419 .filter((thread) => thread.session !== null && thread.session.status !== "closed")
1420 .map((thread) => thread.id);
1421
1422 if (runningThreadIds.length === 0) return;
1423
1424 setIsSwitchingRuntimeMode(true);
1425 await Promise.all(
1426 runningThreadIds.map((threadId) =>
1427 api.orchestration
1428 .dispatchCommand({
1429 type: "thread.session.stop",
1430 commandId: newCommandId(),
1431 threadId,
1432 createdAt: new Date().toISOString(),
1433 })
1434 .catch(() => undefined),
1435 ),
1436 );
1437 setIsSwitchingRuntimeMode(false);
1431 if (!api || !isServerThread) return;
1432 scheduleComposerFocus();
1433 startRuntimeModeTransition(() => {
1434 setOptimisticRuntimeMode(mode);
1435 void api.orchestration
1436 .dispatchCommand({
1437 type: "thread.runtime-mode.set",
1438 commandId: newCommandId(),
1439 threadId,
1440 runtimeMode: mode,
1441 createdAt: new Date().toISOString(),
1442 })
1443 .catch(() => undefined);
1444 });
14381445 };
14391446
14401447 useEffect(() => {
......@@ -2186,6 +2193,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
21862193 projectId: activeProject.id,
21872194 title,
21882195 model: threadCreateModel,
2196 runtimeMode,
21892197 branch: nextThreadBranch,
21902198 worktreePath: nextThreadWorktreePath,
21912199 createdAt: activeThread.createdAt,
......@@ -2941,21 +2949,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
29412949 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
29422950 size="sm"
29432951 type="button"
2944 disabled={isSwitchingRuntimeMode}
2952 disabled={isRuntimeModePending}
29452953 onClick={() =>
29462954 void handleRuntimeModeChange(
2947 runtimeMode === "full-access" ? "approval-required" : "full-access",
2955 optimisticRuntimeMode === "full-access"
2956 ? "approval-required"
2957 : "full-access",
29482958 )
29492959 }
29502960 title={
2951 runtimeMode === "full-access"
2961 optimisticRuntimeMode === "full-access"
29522962 ? "Full access — click to require approvals"
29532963 : "Approval required — click for full access"
29542964 }
29552965 >
2956 {runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
2966 {optimisticRuntimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
29572967 <span className="sr-only sm:not-sr-only">
2958 {runtimeMode === "full-access" ? "Full access" : "Supervised"}
2968 {optimisticRuntimeMode === "full-access" ? "Full access" : "Supervised"}
29592969 </span>
29602970 </Button>
29612971 </div>
apps/web/src/components/Sidebar.tsx+2
......@@ -8,6 +8,7 @@ import {
88} from "lucide-react";
99import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1010import {
11 DEFAULT_RUNTIME_MODE,
1112 DEFAULT_MODEL,
1213 type DesktopUpdateState,
1314 ProjectId,
......@@ -435,6 +436,7 @@ export default function Sidebar() {
435436 branch: options?.branch ?? null,
436437 worktreePath: options?.worktreePath ?? null,
437438 envMode: options?.envMode ?? "local",
439 runtimeMode: DEFAULT_RUNTIME_MODE,
438440 });
439441
440442 await navigate({
apps/web/src/composerDraftStore.ts+16-1
......@@ -6,8 +6,9 @@ import {
66 normalizeModelSlug,
77 type CodexReasoningEffort,
88 type ProviderKind,
9 type RuntimeMode,
910} from "@t3tools/contracts";
10import type { ChatImageAttachment } from "./types";
11import { DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
1112import { create } from "zustand";
1213import { createJSONStorage, persist } from "zustand/middleware";
1314
......@@ -38,6 +39,7 @@ interface PersistedComposerThreadDraftState {
3839interface PersistedDraftThreadState {
3940 projectId: ProjectId;
4041 createdAt: string;
42 runtimeMode: RuntimeMode;
4143 branch: string | null;
4244 worktreePath: string | null;
4345 envMode: DraftThreadEnvMode;
......@@ -62,6 +64,7 @@ interface ComposerThreadDraftState {
6264export interface DraftThreadState {
6365 projectId: ProjectId;
6466 createdAt: string;
67 runtimeMode: RuntimeMode;
6568 branch: string | null;
6669 worktreePath: string | null;
6770 envMode: DraftThreadEnvMode;
......@@ -85,6 +88,7 @@ interface ComposerDraftStoreState {
8588 worktreePath?: string | null;
8689 createdAt?: string;
8790 envMode?: DraftThreadEnvMode;
91 runtimeMode?: RuntimeMode;
8892 },
8993 ) => void;
9094 setDraftThreadContext: (
......@@ -95,6 +99,7 @@ interface ComposerDraftStoreState {
9599 projectId?: ProjectId;
96100 createdAt?: string;
97101 envMode?: DraftThreadEnvMode;
102 runtimeMode?: RuntimeMode;
98103 },
99104 ) => void;
100105 clearProjectDraftThreadId: (projectId: ProjectId) => void;
......@@ -260,6 +265,11 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
260265 typeof createdAt === "string" && createdAt.length > 0
261266 ? createdAt
262267 : new Date().toISOString(),
268 runtimeMode:
269 candidateDraftThread.runtimeMode === "approval-required" ||
270 candidateDraftThread.runtimeMode === "full-access"
271 ? candidateDraftThread.runtimeMode
272 : DEFAULT_RUNTIME_MODE,
263273 branch: typeof branch === "string" ? branch : null,
264274 worktreePath: normalizedWorktreePath,
265275 envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),
......@@ -286,6 +296,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
286296 draftThreadsByThreadId[threadId as ThreadId] = {
287297 projectId: projectId as ProjectId,
288298 createdAt: new Date().toISOString(),
299 runtimeMode: DEFAULT_RUNTIME_MODE,
289300 branch: null,
290301 worktreePath: null,
291302 envMode: "local",
......@@ -488,6 +499,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
488499 const nextDraftThread: DraftThreadState = {
489500 projectId,
490501 createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),
502 runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
491503 branch:
492504 options?.branch === undefined
493505 ? (existingThread?.branch ?? null)
......@@ -502,6 +514,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
502514 existingThread &&
503515 existingThread.projectId === nextDraftThread.projectId &&
504516 existingThread.createdAt === nextDraftThread.createdAt &&
517 existingThread.runtimeMode === nextDraftThread.runtimeMode &&
505518 existingThread.branch === nextDraftThread.branch &&
506519 existingThread.worktreePath === nextDraftThread.worktreePath &&
507520 existingThread.envMode === nextDraftThread.envMode;
......@@ -556,6 +569,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
556569 options.createdAt === undefined
557570 ? existing.createdAt
558571 : options.createdAt || existing.createdAt,
572 runtimeMode: options.runtimeMode ?? existing.runtimeMode,
559573 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),
560574 worktreePath: nextWorktreePath,
561575 envMode:
......@@ -565,6 +579,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
565579 const isUnchanged =
566580 nextDraftThread.projectId === existing.projectId &&
567581 nextDraftThread.createdAt === existing.createdAt &&
582 nextDraftThread.runtimeMode === existing.runtimeMode &&
568583 nextDraftThread.branch === existing.branch &&
569584 nextDraftThread.worktreePath === existing.worktreePath &&
570585 nextDraftThread.envMode === existing.envMode;
apps/web/src/routes/_chat.settings.tsx-57
......@@ -8,7 +8,6 @@ import { isElectron } from "../env";
88import { useTheme } from "../hooks/useTheme";
99import { serverConfigQueryOptions } from "../lib/serverReactQuery";
1010import { ensureNativeApi } from "../nativeApi";
11import { useStore } from "../store";
1211import { preferredTerminalEditor } from "../terminal-links";
1312import { Button } from "../components/ui/button";
1413import { Input } from "../components/ui/input";
......@@ -33,19 +32,6 @@ const THEME_OPTIONS = [
3332 },
3433] as const;
3534
36const RUNTIME_MODE_OPTIONS = [
37 {
38 value: "full-access",
39 label: "Full access",
40 description: "Allow commands to run without confirmation prompts.",
41 },
42 {
43 value: "approval-required",
44 label: "Supervised",
45 description: "Require approval prompts before command execution.",
46 },
47] as const;
48
4935const MODEL_PROVIDER_SETTINGS: Array<{
5036 provider: ProviderKind;
5137 title: string;
......@@ -120,8 +106,6 @@ function patchCustomModels(provider: ProviderKind, models: string[]) {
120106
121107function SettingsRouteView() {
122108 const { theme, setTheme, resolvedTheme } = useTheme();
123 const runtimeMode = useStore((store) => store.runtimeMode);
124 const setRuntimeMode = useStore((store) => store.setRuntimeMode);
125109 const { settings, defaults, updateSettings } = useAppSettings();
126110 const serverConfigQuery = useQuery(serverConfigQueryOptions());
127111 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
......@@ -469,47 +453,6 @@ function SettingsRouteView() {
469453 </div>
470454 </section>
471455
472 <section className="rounded-2xl border border-border bg-card p-5">
473 <div className="mb-4">
474 <h2 className="text-sm font-medium text-foreground">Runtime Mode</h2>
475 <p className="mt-1 text-xs text-muted-foreground">
476 Select the default execution policy for this client.
477 </p>
478 </div>
479
480 <div className="space-y-2" role="radiogroup" aria-label="Runtime mode preference">
481 {RUNTIME_MODE_OPTIONS.map((option) => {
482 const selected = runtimeMode === option.value;
483 return (
484 <button
485 key={option.value}
486 type="button"
487 role="radio"
488 aria-checked={selected}
489 className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${
490 selected
491 ? "border-primary/60 bg-primary/8 text-foreground"
492 : "border-border bg-background text-muted-foreground hover:bg-accent"
493 }`}
494 onClick={() => {
495 setRuntimeMode(option.value);
496 }}
497 >
498 <span className="flex flex-col">
499 <span className="text-sm font-medium">{option.label}</span>
500 <span className="text-xs">{option.description}</span>
501 </span>
502 {selected ? (
503 <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
504 Selected
505 </span>
506 ) : null}
507 </button>
508 );
509 })}
510 </div>
511 </section>
512
513456 <section className="rounded-2xl border border-border bg-card p-5">
514457 <div className="mb-4">
515458 <h2 className="text-sm font-medium text-foreground">Responses</h2>
apps/web/src/store.test.ts+3-2
......@@ -2,7 +2,7 @@ import { ProjectId, ThreadId, TurnId, type OrchestrationReadModel } from "@t3too
22import { describe, expect, it } from "vitest";
33
44import { markThreadUnread, syncServerReadModel, type AppState } from "./store";
5import type { Thread } from "./types";
5import { DEFAULT_RUNTIME_MODE, type Thread } from "./types";
66
77function makeThread(overrides: Partial<Thread> = {}): Thread {
88 return {
......@@ -11,6 +11,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
1111 projectId: ProjectId.makeUnsafe("project-1"),
1212 title: "Thread",
1313 model: "gpt-5-codex",
14 runtimeMode: DEFAULT_RUNTIME_MODE,
1415 session: null,
1516 messages: [],
1617 turnDiffSummaries: [],
......@@ -38,7 +39,6 @@ function makeState(thread: Thread): AppState {
3839 ],
3940 threads: [thread],
4041 threadsHydrated: true,
41 runtimeMode: "full-access",
4242 };
4343}
4444
......@@ -48,6 +48,7 @@ function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"
4848 projectId: ProjectId.makeUnsafe("project-1"),
4949 title: "Thread",
5050 model: "gpt-5.3-codex",
51 runtimeMode: DEFAULT_RUNTIME_MODE,
5152 branch: null,
5253 worktreePath: null,
5354 latestTurn: null,
apps/web/src/store.ts+11-32
......@@ -13,10 +13,8 @@ import {
1313} from "@t3tools/contracts";
1414import { create } from "zustand";
1515import {
16 DEFAULT_RUNTIME_MODE,
1716 type ChatMessage,
1817 type Project,
19 type RuntimeMode,
2018 type Thread,
2119} from "./types";
2220
......@@ -26,10 +24,9 @@ export interface AppState {
2624 projects: Project[];
2725 threads: Thread[];
2826 threadsHydrated: boolean;
29 runtimeMode: RuntimeMode;
3027}
3128
32const PERSISTED_STATE_KEY = "t3code:renderer-state:v7";
29const PERSISTED_STATE_KEY = "t3code:renderer-state:v8";
3330const LEGACY_PERSISTED_STATE_KEYS = [
3431 "t3code:renderer-state:v6",
3532 "t3code:renderer-state:v5",
......@@ -45,7 +42,6 @@ const initialState: AppState = {
4542 projects: [],
4643 threads: [],
4744 threadsHydrated: false,
48 runtimeMode: DEFAULT_RUNTIME_MODE,
4945};
5046const persistedExpandedProjectCwds = new Set<string>();
5147
......@@ -56,23 +52,14 @@ function readPersistedState(): AppState {
5652 try {
5753 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
5854 if (!raw) return initialState;
59 const parsed = JSON.parse(raw) as {
60 runtimeMode?: RuntimeMode;
61 expandedProjectCwds?: string[];
62 };
55 const parsed = JSON.parse(raw) as { expandedProjectCwds?: string[] };
6356 persistedExpandedProjectCwds.clear();
6457 for (const cwd of parsed.expandedProjectCwds ?? []) {
6558 if (typeof cwd === "string" && cwd.length > 0) {
6659 persistedExpandedProjectCwds.add(cwd);
6760 }
6861 }
69 return {
70 ...initialState,
71 runtimeMode:
72 parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access"
73 ? parsed.runtimeMode
74 : DEFAULT_RUNTIME_MODE,
75 };
62 return { ...initialState };
7663 } catch {
7764 return initialState;
7865 }
......@@ -84,7 +71,6 @@ function persistState(state: AppState): void {
8471 window.localStorage.setItem(
8572 PERSISTED_STATE_KEY,
8673 JSON.stringify({
87 runtimeMode: state.runtimeMode,
8874 expandedProjectCwds: state.projects
8975 .filter((project) => project.expanded)
9076 .map((project) => project.cwd),
......@@ -265,12 +251,13 @@ export function syncServerReadModel(
265251 projectId: thread.projectId,
266252 title: thread.title,
267253 model: resolveModelSlugForProvider(
268 inferProviderForThreadModel({
269 model: thread.model,
270 sessionProviderName: thread.session?.providerName ?? null,
271 }),
272 thread.model,
273 ),
254 inferProviderForThreadModel({
255 model: thread.model,
256 sessionProviderName: thread.session?.providerName ?? null,
257 }),
258 thread.model,
259 ),
260 runtimeMode: thread.runtimeMode,
274261 session: thread.session
275262 ? {
276263 sessionId:
......@@ -415,11 +402,6 @@ export function setThreadBranch(
415402 return threads === state.threads ? state : { ...state, threads };
416403}
417404
418export function setRuntimeMode(state: AppState, mode: RuntimeMode): AppState {
419 if (state.runtimeMode === mode) return state;
420 return { ...state, runtimeMode: mode };
421}
422
423405// ── Zustand store ────────────────────────────────────────────────────
424406
425407interface AppStore extends AppState {
......@@ -434,7 +416,6 @@ interface AppStore extends AppState {
434416 branch: string | null,
435417 worktreePath: string | null,
436418 ) => void;
437 setRuntimeMode: (mode: RuntimeMode) => void;
438419}
439420
440421export const useStore = create<AppStore>((set) => ({
......@@ -453,11 +434,9 @@ export const useStore = create<AppStore>((set) => ({
453434 set((state) => setError(state, threadId, error)),
454435 setThreadBranch: (threadId, branch, worktreePath) =>
455436 set((state) => setThreadBranch(state, threadId, branch, worktreePath)),
456 setRuntimeMode: (mode) =>
457 set((state) => setRuntimeMode(state, mode)),
458437}));
459438
460// Persist on every state change (only runtimeMode + expandedProjectCwds)
439// Persist on every state change
461440useStore.subscribe((state) => persistState(state));
462441
463442export function StoreProvider({ children }: { children: ReactNode }) {
apps/web/src/types.ts+1
......@@ -79,6 +79,7 @@ export interface Thread {
7979 projectId: ProjectId;
8080 title: string;
8181 model: string;
82 runtimeMode: RuntimeMode;
8283 session: ThreadSession | null;
8384 messages: ChatMessage[];
8485 error: string | null;
apps/web/src/worktreeCleanup.test.ts+2-1
......@@ -1,7 +1,7 @@
11import { ProjectId, ThreadId } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
4import type { Thread } from "./types";
4import { DEFAULT_RUNTIME_MODE, type Thread } from "./types";
55import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup";
66
77function makeThread(overrides: Partial<Thread> = {}): Thread {
......@@ -11,6 +11,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
1111 projectId: ProjectId.makeUnsafe("project-1"),
1212 title: "Thread",
1313 model: "gpt-5.3-codex",
14 runtimeMode: DEFAULT_RUNTIME_MODE,
1415 session: null,
1516 messages: [],
1617 turnDiffSummaries: [],
packages/contracts/src/orchestration.ts+30
......@@ -223,6 +223,7 @@ export const OrchestrationThread = Schema.Struct({
223223 projectId: ProjectId,
224224 title: TrimmedNonEmptyString,
225225 model: TrimmedNonEmptyString,
226 runtimeMode: RuntimeMode,
226227 branch: Schema.NullOr(TrimmedNonEmptyString),
227228 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
228229 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
......@@ -277,6 +278,7 @@ export const ThreadCreateCommand = Schema.Struct({
277278 projectId: ProjectId,
278279 title: TrimmedNonEmptyString,
279280 model: TrimmedNonEmptyString,
281 runtimeMode: RuntimeMode,
280282 branch: Schema.NullOr(TrimmedNonEmptyString),
281283 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
282284 createdAt: IsoDateTime,
......@@ -298,6 +300,14 @@ export const ThreadMetaUpdateCommand = Schema.Struct({
298300 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
299301});
300302
303export const ThreadRuntimeModeSetCommand = Schema.Struct({
304 type: Schema.Literal("thread.runtime-mode.set"),
305 commandId: CommandId,
306 threadId: ThreadId,
307 runtimeMode: RuntimeMode,
308 createdAt: IsoDateTime,
309});
310
301311export const ThreadTurnStartCommand = Schema.Struct({
302312 type: Schema.Literal("thread.turn.start"),
303313 commandId: CommandId,
......@@ -373,6 +383,7 @@ export const DispatchableClientOrchestrationCommand = Schema.Union([
373383 ThreadCreateCommand,
374384 ThreadDeleteCommand,
375385 ThreadMetaUpdateCommand,
386 ThreadRuntimeModeSetCommand,
376387 ThreadTurnStartCommand,
377388 ThreadTurnInterruptCommand,
378389 ThreadApprovalRespondCommand,
......@@ -389,6 +400,7 @@ export const ClientOrchestrationCommand = Schema.Union([
389400 ThreadCreateCommand,
390401 ThreadDeleteCommand,
391402 ThreadMetaUpdateCommand,
403 ThreadRuntimeModeSetCommand,
392404 ClientThreadTurnStartCommand,
393405 ThreadTurnInterruptCommand,
394406 ThreadApprovalRespondCommand,
......@@ -477,6 +489,7 @@ export const OrchestrationEventType = Schema.Literals([
477489 "thread.created",
478490 "thread.deleted",
479491 "thread.meta-updated",
492 "thread.runtime-mode-set",
480493 "thread.message-sent",
481494 "thread.turn-start-requested",
482495 "thread.turn-interrupt-requested",
......@@ -523,6 +536,7 @@ export const ThreadCreatedPayload = Schema.Struct({
523536 projectId: ProjectId,
524537 title: TrimmedNonEmptyString,
525538 model: TrimmedNonEmptyString,
539 runtimeMode: RuntimeMode,
526540 branch: Schema.NullOr(TrimmedNonEmptyString),
527541 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
528542 createdAt: IsoDateTime,
......@@ -543,6 +557,12 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({
543557 updatedAt: IsoDateTime,
544558});
545559
560export const ThreadRuntimeModeSetPayload = Schema.Struct({
561 threadId: ThreadId,
562 runtimeMode: RuntimeMode,
563 updatedAt: IsoDateTime,
564});
565
546566export const ThreadMessageSentPayload = Schema.Struct({
547567 threadId: ThreadId,
548568 messageId: MessageId,
......@@ -684,6 +704,11 @@ export const OrchestrationEvent = Schema.Union([
684704 type: Schema.Literal("thread.meta-updated"),
685705 payload: ThreadMetaUpdatedPayload,
686706 }),
707 Schema.Struct({
708 ...EventBaseFields,
709 type: Schema.Literal("thread.runtime-mode-set"),
710 payload: ThreadRuntimeModeSetPayload,
711 }),
687712 Schema.Struct({
688713 ...EventBaseFields,
689714 type: Schema.Literal("thread.message-sent"),
......@@ -768,6 +793,11 @@ export const OrchestrationPersistedEvent = Schema.Union([
768793 eventType: Schema.Literal("thread.meta-updated"),
769794 payload: ThreadMetaUpdatedPayload,
770795 }),
796 Schema.Struct({
797 ...PersistedEventBaseFields,
798 eventType: Schema.Literal("thread.runtime-mode-set"),
799 payload: ThreadRuntimeModeSetPayload,
800 }),
771801 Schema.Struct({
772802 ...PersistedEventBaseFields,
773803 eventType: Schema.Literal("thread.message-sent"),
packages/contracts/src/provider.test.ts+10-1
......@@ -11,6 +11,7 @@ describe("ProviderSessionStartInput", () => {
1111 provider: "codex",
1212 cwd: "/tmp/workspace",
1313 model: "gpt-5.3-codex",
14 runtimeMode: "full-access",
1415 providerOptions: {
1516 codex: {
1617 binaryPath: "/usr/local/bin/codex",
......@@ -18,11 +19,19 @@ describe("ProviderSessionStartInput", () => {
1819 },
1920 },
2021 });
21 expect(parsed.runtimeMode).toBeUndefined();
22 expect(parsed.runtimeMode).toBe("full-access");
2223 expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex");
2324 expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex");
2425 });
2526
27 it("rejects payloads without runtime mode", () => {
28 expect(() =>
29 decodeProviderSessionStartInput({
30 provider: "codex",
31 }),
32 ).toThrow();
33 });
34
2635 it("accepts claude runtime knobs", () => {
2736 const parsed = decodeProviderSessionStartInput({
2837 provider: "claudeCode",
packages/contracts/src/provider.ts+2-1
......@@ -37,6 +37,7 @@ export const ProviderSession = Schema.Struct({
3737 sessionId: ProviderSessionId,
3838 provider: ProviderKind,
3939 status: ProviderSessionStatus,
40 runtimeMode: RuntimeMode,
4041 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
4142 model: Schema.optional(TrimmedNonEmptyStringSchema),
4243 threadId: Schema.optional(ProviderThreadId),
......@@ -79,7 +80,7 @@ export const ProviderSessionStartInput = Schema.Struct({
7980 model: Schema.optional(TrimmedNonEmptyStringSchema),
8081 resumeCursor: Schema.optional(Schema.Unknown),
8182 providerOptions: Schema.optional(ProviderStartOptions),
82 runtimeMode: Schema.optional(RuntimeMode),
83 runtimeMode: RuntimeMode,
8384});
8485export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type;
8586