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";...@@ -37,6 +37,8 @@ import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
37import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";37import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
38import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";38import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
39import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";39import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
40import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts";
41import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts";
40import { ProviderService } from "../src/provider/Services/ProviderService.ts";42import { ProviderService } from "../src/provider/Services/ProviderService.ts";
41import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";43import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
42import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";44import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
...@@ -187,6 +189,7 @@ export interface OrchestrationIntegrationHarness {...@@ -187,6 +189,7 @@ export interface OrchestrationIntegrationHarness {
187189
188interface MakeOrchestrationIntegrationHarnessOptions {190interface MakeOrchestrationIntegrationHarnessOptions {
189 readonly provider?: "codex" | "claudeCode";191 readonly provider?: "codex" | "claudeCode";
192 readonly realCodex?: boolean;
190}193}
191194
192export const makeOrchestrationIntegrationHarness = (195export const makeOrchestrationIntegrationHarness = (
...@@ -195,10 +198,21 @@ export const makeOrchestrationIntegrationHarness = (...@@ -195,10 +198,21 @@ export const makeOrchestrationIntegrationHarness = (
195 Effect.gen(function* () {198 Effect.gen(function* () {
196 const sleep = (ms: number) => Effect.sleep(ms);199 const sleep = (ms: number) => Effect.sleep(ms);
197 const provider = options?.provider ?? "codex";200 const provider = options?.provider ?? "codex";
198 const adapterHarness = yield* makeTestProviderAdapterHarness({201 const useRealCodex = options?.realCodex === true;
199 provider,202 const adapterHarness = useRealCodex
200 });203 ? null
201204 : 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;
202 const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-"));216 const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-"));
203 const workspaceDir = path.join(rootDir, "workspace");217 const workspaceDir = path.join(rootDir, "workspace");
204 const stateDir = path.join(rootDir, "state");218 const stateDir = path.join(rootDir, "state");
...@@ -207,14 +221,6 @@ export const makeOrchestrationIntegrationHarness = (...@@ -207,14 +221,6 @@ export const makeOrchestrationIntegrationHarness = (
207 fs.mkdirSync(stateDir, { recursive: true });221 fs.mkdirSync(stateDir, { recursive: true });
208 initializeGitWorkspace(workspaceDir);222 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
218 const persistenceLayer = makeSqlitePersistenceLive(dbPath);224 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
219 const orchestrationLayer = OrchestrationEngineLive.pipe(225 const orchestrationLayer = OrchestrationEngineLive.pipe(
220 Layer.provide(OrchestrationProjectionPipelineLive),226 Layer.provide(OrchestrationProjectionPipelineLive),
...@@ -224,10 +230,33 @@ export const makeOrchestrationIntegrationHarness = (...@@ -224,10 +230,33 @@ export const makeOrchestrationIntegrationHarness = (
224 const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(230 const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe(
225 Layer.provide(ProviderSessionRuntimeRepositoryLive),231 Layer.provide(ProviderSessionRuntimeRepositoryLive),
226 );232 );
227 const providerLayer = makeProviderServiceLive().pipe(233 const realCodexRegistry = Layer.effect(
228 Layer.provide(providerSessionDirectoryLayer),234 ProviderAdapterRegistry,
229 Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)),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),
230 );250 );
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
232 const runtimeServicesLayer = Layer.mergeAll(261 const runtimeServicesLayer = Layer.mergeAll(
233 orchestrationLayer,262 orchestrationLayer,
...@@ -407,8 +436,8 @@ export const makeOrchestrationIntegrationHarness = (...@@ -407,8 +436,8 @@ export const makeOrchestrationIntegrationHarness = (
407 return {436 return {
408 rootDir,437 rootDir,
409 workspaceDir,438 workspaceDir,
410 dbPath,439 dbPath,
411 adapterHarness,440 adapterHarness: adapterHarness as TestProviderAdapterHarness,
412 engine,441 engine,
413 snapshotQuery,442 snapshotQuery,
414 providerService,443 providerService,
apps/server/integration/TestProviderAdapter.integration.ts+1
...@@ -264,6 +264,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter...@@ -264,6 +264,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
264 sessionId,264 sessionId,
265 provider,265 provider,
266 status: "ready",266 status: "ready",
267 runtimeMode: input.runtimeMode,
267 threadId,268 threadId,
268 cwd: input.cwd,269 cwd: input.cwd,
269 resumeCursor: input.resumeCursor ?? { sessionId },270 resumeCursor: input.resumeCursor ?? { sessionId },
apps/server/integration/orchestrationEngine.integration.test.ts+102
...@@ -97,6 +97,16 @@ function withHarness<A, E>(...@@ -97,6 +97,16 @@ function withHarness<A, E>(
97 );97 );
98}98}
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
100const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>110const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
101 Effect.gen(function* () {111 Effect.gen(function* () {
102 const createdAt = nowIso();112 const createdAt = nowIso();
...@@ -118,6 +128,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>...@@ -118,6 +128,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
118 projectId: PROJECT_ID,128 projectId: PROJECT_ID,
119 title: "Integration Thread",129 title: "Integration Thread",
120 model: "gpt-5-codex",130 model: "gpt-5-codex",
131 runtimeMode: "approval-required",
121 branch: null,132 branch: null,
122 worktreePath: harness.workspaceDir,133 worktreePath: harness.workspaceDir,
123 createdAt,134 createdAt,
...@@ -216,6 +227,97 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +...@@ -216,6 +227,97 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite +
216 ),227 ),
217);228);
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
219it.live("runs multi-turn file edits and persists checkpoint diffs", () =>321it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
220 withHarness((harness) =>322 withHarness((harness) =>
221 Effect.gen(function* () {323 Effect.gen(function* () {
apps/server/integration/providerService.integration.test.ts+4
...@@ -122,6 +122,7 @@ it.effect("replays typed runtime fixture events", () =>...@@ -122,6 +122,7 @@ it.effect("replays typed runtime fixture events", () =>
122 {122 {
123 provider: "codex",123 provider: "codex",
124 cwd: fixture.cwd,124 cwd: fixture.cwd,
125 runtimeMode: "full-access",
125 },126 },
126 );127 );
127 assert.equal((session.threadId ?? "").length > 0, true);128 assert.equal((session.threadId ?? "").length > 0, true);
...@@ -155,6 +156,7 @@ it.effect("replays file-changing fixture turn events", () =>...@@ -155,6 +156,7 @@ it.effect("replays file-changing fixture turn events", () =>
155 {156 {
156 provider: "codex",157 provider: "codex",
157 cwd: fixture.cwd,158 cwd: fixture.cwd,
159 runtimeMode: "full-access",
158 },160 },
159 );161 );
160 assert.equal((session.threadId ?? "").length > 0, true);162 assert.equal((session.threadId ?? "").length > 0, true);
...@@ -192,6 +194,7 @@ it.effect("runs multi-turn tool/approval flow", () =>...@@ -192,6 +194,7 @@ it.effect("runs multi-turn tool/approval flow", () =>
192 {194 {
193 provider: "codex",195 provider: "codex",
194 cwd: fixture.cwd,196 cwd: fixture.cwd,
197 runtimeMode: "full-access",
195 },198 },
196 );199 );
197 assert.equal((session.threadId ?? "").length > 0, true);200 assert.equal((session.threadId ?? "").length > 0, true);
...@@ -244,6 +247,7 @@ it.effect("rolls back provider conversation state only", () =>...@@ -244,6 +247,7 @@ it.effect("rolls back provider conversation state only", () =>
244 {247 {
245 provider: "codex",248 provider: "codex",
246 cwd: fixture.cwd,249 cwd: fixture.cwd,
250 runtimeMode: "full-access",
247 },251 },
248 );252 );
249 assert.equal((session.threadId ?? "").length > 0, true);253 assert.equal((session.threadId ?? "").length > 0, true);
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+1
...@@ -43,6 +43,7 @@ function makeSnapshot(input: {...@@ -43,6 +43,7 @@ function makeSnapshot(input: {
43 projectId: input.projectId,43 projectId: input.projectId,
44 title: "Thread",44 title: "Thread",
45 model: "gpt-5-codex",45 model: "gpt-5-codex",
46 runtimeMode: "full-access",
46 branch: null,47 branch: null,
47 worktreePath: input.worktreePath,48 worktreePath: input.worktreePath,
48 latestTurn: {49 latestTurn: {
apps/server/src/codexAppServerManager.test.ts+86
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import { describe, expect, it, vi } from "vitest";1import { 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";
2import { ProviderSessionId } from "@t3tools/contracts";6import { ProviderSessionId } from "@t3tools/contracts";
37
4import {8import {
...@@ -165,6 +169,7 @@ describe("startSession", () => {...@@ -165,6 +169,7 @@ describe("startSession", () => {
165 await expect(169 await expect(
166 manager.startSession({170 manager.startSession({
167 provider: "codex",171 provider: "codex",
172 runtimeMode: "full-access",
168 }),173 }),
169 ).rejects.toThrow("cwd missing");174 ).rejects.toThrow("cwd missing");
170 expect(events).toHaveLength(1);175 expect(events).toHaveLength(1);
...@@ -347,3 +352,84 @@ describe("thread checkpoint control", () => {...@@ -347,3 +352,84 @@ describe("thread checkpoint control", () => {
347 });352 });
348 });353 });
349});354});
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...@@ -204,6 +204,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
204 sessionId,204 sessionId,
205 provider: "codex",205 provider: "codex",
206 status: "connecting",206 status: "connecting",
207 runtimeMode: input.runtimeMode,
207 model: normalizeCodexModelSlug(input.model),208 model: normalizeCodexModelSlug(input.model),
208 cwd: resolvedCwd,209 cwd: resolvedCwd,
209 createdAt: now,210 createdAt: now,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+2
...@@ -80,6 +80,7 @@ function createProviderServiceHarness(...@@ -80,6 +80,7 @@ function createProviderServiceHarness(
80 sessionId: asSessionId("sess-1"),80 sessionId: asSessionId("sess-1"),
81 provider: providerName,81 provider: providerName,
82 status: "ready",82 status: "ready",
83 runtimeMode: "full-access",
83 threadId: asProviderThreadId("provider-thread-1"),84 threadId: asProviderThreadId("provider-thread-1"),
84 cwd: sessionCwd,85 cwd: sessionCwd,
85 createdAt: now,86 createdAt: now,
...@@ -293,6 +294,7 @@ describe("CheckpointReactor", () => {...@@ -293,6 +294,7 @@ describe("CheckpointReactor", () => {
293 projectId: asProjectId("project-1"),294 projectId: asProjectId("project-1"),
294 title: "Thread",295 title: "Thread",
295 model: "gpt-5-codex",296 model: "gpt-5-codex",
297 runtimeMode: "approval-required",
296 branch: null,298 branch: null,
297 worktreePath: options?.threadWorktreePath ?? cwd,299 worktreePath: options?.threadWorktreePath ?? cwd,
298 createdAt,300 createdAt,
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+10
...@@ -80,6 +80,7 @@ describe("OrchestrationEngine", () => {...@@ -80,6 +80,7 @@ describe("OrchestrationEngine", () => {
80 projectId: asProjectId("project-1"),80 projectId: asProjectId("project-1"),
81 title: "Thread",81 title: "Thread",
82 model: "gpt-5-codex",82 model: "gpt-5-codex",
83 runtimeMode: "approval-required",
83 branch: null,84 branch: null,
84 worktreePath: null,85 worktreePath: null,
85 createdAt,86 createdAt,
...@@ -131,6 +132,7 @@ describe("OrchestrationEngine", () => {...@@ -131,6 +132,7 @@ describe("OrchestrationEngine", () => {
131 projectId: asProjectId("project-replay"),132 projectId: asProjectId("project-replay"),
132 title: "replay",133 title: "replay",
133 model: "gpt-5-codex",134 model: "gpt-5-codex",
135 runtimeMode: "approval-required",
134 branch: null,136 branch: null,
135 worktreePath: null,137 worktreePath: null,
136 createdAt,138 createdAt,
...@@ -191,6 +193,7 @@ describe("OrchestrationEngine", () => {...@@ -191,6 +193,7 @@ describe("OrchestrationEngine", () => {
191 projectId: asProjectId("project-stream"),193 projectId: asProjectId("project-stream"),
192 title: "domain-stream",194 title: "domain-stream",
193 model: "gpt-5-codex",195 model: "gpt-5-codex",
196 runtimeMode: "approval-required",
194 branch: null,197 branch: null,
195 worktreePath: null,198 worktreePath: null,
196 createdAt,199 createdAt,
...@@ -234,6 +237,7 @@ describe("OrchestrationEngine", () => {...@@ -234,6 +237,7 @@ describe("OrchestrationEngine", () => {
234 projectId: asProjectId("project-turn-diff"),237 projectId: asProjectId("project-turn-diff"),
235 title: "Turn diff thread",238 title: "Turn diff thread",
236 model: "gpt-5-codex",239 model: "gpt-5-codex",
240 runtimeMode: "approval-required",
237 branch: null,241 branch: null,
238 worktreePath: null,242 worktreePath: null,
239 createdAt,243 createdAt,
...@@ -341,6 +345,7 @@ describe("OrchestrationEngine", () => {...@@ -341,6 +345,7 @@ describe("OrchestrationEngine", () => {
341 projectId: asProjectId("project-flaky"),345 projectId: asProjectId("project-flaky"),
342 title: "flaky-fail",346 title: "flaky-fail",
343 model: "gpt-5-codex",347 model: "gpt-5-codex",
348 runtimeMode: "approval-required",
344 branch: null,349 branch: null,
345 worktreePath: null,350 worktreePath: null,
346 createdAt,351 createdAt,
...@@ -356,6 +361,7 @@ describe("OrchestrationEngine", () => {...@@ -356,6 +361,7 @@ describe("OrchestrationEngine", () => {
356 projectId: asProjectId("project-flaky"),361 projectId: asProjectId("project-flaky"),
357 title: "flaky-ok",362 title: "flaky-ok",
358 model: "gpt-5-codex",363 model: "gpt-5-codex",
364 runtimeMode: "approval-required",
359 branch: null,365 branch: null,
360 worktreePath: null,366 worktreePath: null,
361 createdAt,367 createdAt,
...@@ -419,6 +425,7 @@ describe("OrchestrationEngine", () => {...@@ -419,6 +425,7 @@ describe("OrchestrationEngine", () => {
419 projectId: asProjectId("project-atomic"),425 projectId: asProjectId("project-atomic"),
420 title: "atomic",426 title: "atomic",
421 model: "gpt-5-codex",427 model: "gpt-5-codex",
428 runtimeMode: "approval-required",
422 branch: null,429 branch: null,
423 worktreePath: null,430 worktreePath: null,
424 createdAt,431 createdAt,
...@@ -551,6 +558,7 @@ describe("OrchestrationEngine", () => {...@@ -551,6 +558,7 @@ describe("OrchestrationEngine", () => {
551 projectId: asProjectId("project-sync"),558 projectId: asProjectId("project-sync"),
552 title: "sync-before",559 title: "sync-before",
553 model: "gpt-5-codex",560 model: "gpt-5-codex",
561 runtimeMode: "approval-required",
554 branch: null,562 branch: null,
555 worktreePath: null,563 worktreePath: null,
556 createdAt,564 createdAt,
...@@ -628,6 +636,7 @@ describe("OrchestrationEngine", () => {...@@ -628,6 +636,7 @@ describe("OrchestrationEngine", () => {
628 projectId: asProjectId("project-duplicate"),636 projectId: asProjectId("project-duplicate"),
629 title: "duplicate",637 title: "duplicate",
630 model: "gpt-5-codex",638 model: "gpt-5-codex",
639 runtimeMode: "approval-required",
631 branch: null,640 branch: null,
632 worktreePath: null,641 worktreePath: null,
633 createdAt,642 createdAt,
...@@ -643,6 +652,7 @@ describe("OrchestrationEngine", () => {...@@ -643,6 +652,7 @@ describe("OrchestrationEngine", () => {
643 projectId: asProjectId("project-duplicate"),652 projectId: asProjectId("project-duplicate"),
644 title: "duplicate",653 title: "duplicate",
645 model: "gpt-5-codex",654 model: "gpt-5-codex",
655 runtimeMode: "approval-required",
646 branch: null,656 branch: null,
647 worktreePath: null,657 worktreePath: null,
648 createdAt,658 createdAt,
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+16
...@@ -396,6 +396,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -396,6 +396,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
396 projectId: event.payload.projectId,396 projectId: event.payload.projectId,
397 title: event.payload.title,397 title: event.payload.title,
398 model: event.payload.model,398 model: event.payload.model,
399 runtimeMode: event.payload.runtimeMode,
399 branch: event.payload.branch,400 branch: event.payload.branch,
400 worktreePath: event.payload.worktreePath,401 worktreePath: event.payload.worktreePath,
401 latestTurnId: null,402 latestTurnId: null,
...@@ -425,6 +426,21 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -425,6 +426,21 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
425 return;426 return;
426 }427 }
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
428 case "thread.deleted": {444 case "thread.deleted": {
429 attachmentSideEffects.deletedThreadIds.add(event.payload.threadId);445 attachmentSideEffects.deletedThreadIds.add(event.payload.threadId);
430 const existingRow = yield* projectionThreadRepository.getById({446 const existingRow = yield* projectionThreadRepository.getById({
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+1
...@@ -239,6 +239,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -239,6 +239,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
239 projectId: asProjectId("project-1"),239 projectId: asProjectId("project-1"),
240 title: "Thread 1",240 title: "Thread 1",
241 model: "gpt-5-codex",241 model: "gpt-5-codex",
242 runtimeMode: "full-access",
242 branch: null,243 branch: null,
243 worktreePath: null,244 worktreePath: null,
244 latestTurn: {245 latestTurn: {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+2
...@@ -153,6 +153,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -153,6 +153,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
153 project_id AS "projectId",153 project_id AS "projectId",
154 title,154 title,
155 model,155 model,
156 runtime_mode AS "runtimeMode",
156 branch,157 branch,
157 worktree_path AS "worktreePath",158 worktree_path AS "worktreePath",
158 latest_turn_id AS "latestTurnId",159 latest_turn_id AS "latestTurnId",
...@@ -485,6 +486,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -485,6 +486,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
485 projectId: row.projectId,486 projectId: row.projectId,
486 title: row.title,487 title: row.title,
487 model: row.model,488 model: row.model,
489 runtimeMode: row.runtimeMode,
488 branch: row.branch,490 branch: row.branch,
489 worktreePath: row.worktreePath,491 worktreePath: row.worktreePath,
490 latestTurn: latestTurnByThread.get(row.threadId) ?? null,492 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+25-12
...@@ -114,6 +114,13 @@ describe("ProviderCommandReactor", () => {...@@ -114,6 +114,13 @@ describe("ProviderCommandReactor", () => {
114 sessionId: asSessionId(`sess-${sessionIndex}`),114 sessionId: asSessionId(`sess-${sessionIndex}`),
115 provider,115 provider,
116 status: "ready" as const,116 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",
117 ...(model !== undefined ? { model } : {}),124 ...(model !== undefined ? { model } : {}),
118 threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`),125 threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`),
119 resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` },126 resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` },
...@@ -226,6 +233,7 @@ describe("ProviderCommandReactor", () => {...@@ -226,6 +233,7 @@ describe("ProviderCommandReactor", () => {
226 projectId: asProjectId("project-1"),233 projectId: asProjectId("project-1"),
227 title: "Thread",234 title: "Thread",
228 model: "gpt-5-codex",235 model: "gpt-5-codex",
236 runtimeMode: "approval-required",
229 branch: null,237 branch: null,
230 worktreePath: null,238 worktreePath: null,
231 createdAt: now,239 createdAt: now,
...@@ -497,7 +505,7 @@ describe("ProviderCommandReactor", () => {...@@ -497,7 +505,7 @@ describe("ProviderCommandReactor", () => {
497 });505 });
498 });506 });
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 () => {
501 const harness = await createHarness();509 const harness = await createHarness();
502 const now = new Date().toISOString();510 const now = new Date().toISOString();
503511
...@@ -520,6 +528,19 @@ describe("ProviderCommandReactor", () => {...@@ -520,6 +528,19 @@ describe("ProviderCommandReactor", () => {
520 await waitFor(() => harness.startSession.mock.calls.length === 1);528 await waitFor(() => harness.startSession.mock.calls.length === 1);
521 await waitFor(() => harness.sendTurn.mock.calls.length === 1);529 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
523 await Effect.runPromise(544 await Effect.runPromise(
524 harness.engine.dispatch({545 harness.engine.dispatch({
525 type: "thread.turn.start",546 type: "thread.turn.start",
...@@ -531,13 +552,11 @@ describe("ProviderCommandReactor", () => {...@@ -531,13 +552,11 @@ describe("ProviderCommandReactor", () => {
531 text: "second",552 text: "second",
532 attachments: [],553 attachments: [],
533 },554 },
534 runtimeMode: "approval-required",555 runtimeMode: "full-access",
535 createdAt: now,556 createdAt: now,
536 }),557 }),
537 );558 );
538559
539 await waitFor(() => harness.stopSession.mock.calls.length === 1);
540 await waitFor(() => harness.startSession.mock.calls.length === 2);
541 await waitFor(() => harness.sendTurn.mock.calls.length === 2);560 await waitFor(() => harness.sendTurn.mock.calls.length === 2);
542561
543 expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") });562 expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") });
...@@ -642,15 +661,9 @@ describe("ProviderCommandReactor", () => {...@@ -642,15 +661,9 @@ describe("ProviderCommandReactor", () => {
642661
643 await Effect.runPromise(662 await Effect.runPromise(
644 harness.engine.dispatch({663 harness.engine.dispatch({
645 type: "thread.turn.start",664 type: "thread.runtime-mode.set",
646 commandId: CommandId.makeUnsafe("cmd-turn-start-restart-failure-2"),665 commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-restart-failure"),
647 threadId: ThreadId.makeUnsafe("thread-1"),666 threadId: ThreadId.makeUnsafe("thread-1"),
648 message: {
649 messageId: asMessageId("user-message-restart-failure-2"),
650 role: "user",
651 text: "second",
652 attachments: [],
653 },
654 runtimeMode: "approval-required",667 runtimeMode: "approval-required",
655 createdAt: now,668 createdAt: now,
656 }),669 }),
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+12-8
...@@ -28,6 +28,7 @@ type ProviderIntentEvent = Extract<...@@ -28,6 +28,7 @@ type ProviderIntentEvent = Extract<
28 OrchestrationEvent,28 OrchestrationEvent,
29 {29 {
30 type:30 type:
31 | "thread.runtime-mode-set"
31 | "thread.turn-start-requested"32 | "thread.turn-start-requested"
32 | "thread.turn-interrupt-requested"33 | "thread.turn-interrupt-requested"
33 | "thread.approval-response-requested"34 | "thread.approval-response-requested"
...@@ -197,7 +198,6 @@ const make = Effect.gen(function* () {...@@ -197,7 +198,6 @@ const make = Effect.gen(function* () {
197 options?: {198 options?: {
198 readonly provider?: ProviderKind;199 readonly provider?: ProviderKind;
199 readonly model?: string;200 readonly model?: string;
200 readonly runtimeMode?: RuntimeMode;
201 },201 },
202 ) {202 ) {
203 const readModel = yield* orchestrationEngine.getReadModel();203 const readModel = yield* orchestrationEngine.getReadModel();
...@@ -206,8 +206,7 @@ const make = Effect.gen(function* () {...@@ -206,8 +206,7 @@ const make = Effect.gen(function* () {
206 return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`));206 return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`));
207 }207 }
208208
209 const desiredRuntimeMode =209 const desiredRuntimeMode = thread.runtimeMode;
210 options?.runtimeMode ?? thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
211 const currentProvider: ProviderKind | undefined =210 const currentProvider: ProviderKind | undefined =
212 thread.session?.providerName === "codex" ||211 thread.session?.providerName === "codex" ||
213 thread.session?.providerName === "claudeCode" ||212 thread.session?.providerName === "claudeCode" ||
...@@ -260,8 +259,7 @@ const make = Effect.gen(function* () {...@@ -260,8 +259,7 @@ const make = Effect.gen(function* () {
260259
261 const existingSessionId = thread.session?.providerSessionId;260 const existingSessionId = thread.session?.providerSessionId;
262 if (existingSessionId) {261 if (existingSessionId) {
263 const runtimeModeChanged =262 const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode;
264 options?.runtimeMode !== undefined && options.runtimeMode !== thread.session?.runtimeMode;
265 const providerChanged = options?.provider !== undefined && options.provider !== currentProvider;263 const providerChanged = options?.provider !== undefined && options.provider !== currentProvider;
266 const activeSession = yield* resolveActiveSession(existingSessionId);264 const activeSession = yield* resolveActiveSession(existingSessionId);
267 const sessionModelSwitch =265 const sessionModelSwitch =
...@@ -316,7 +314,6 @@ const make = Effect.gen(function* () {...@@ -316,7 +314,6 @@ const make = Effect.gen(function* () {
316 readonly provider?: ProviderKind;314 readonly provider?: ProviderKind;
317 readonly model?: string;315 readonly model?: string;
318 readonly effort?: string;316 readonly effort?: string;
319 readonly runtimeMode: RuntimeMode;
320 readonly createdAt: string;317 readonly createdAt: string;
321 }) {318 }) {
322 const thread = yield* resolveThread(input.threadId);319 const thread = yield* resolveThread(input.threadId);
...@@ -326,7 +323,6 @@ const make = Effect.gen(function* () {...@@ -326,7 +323,6 @@ const make = Effect.gen(function* () {
326 const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt, {323 const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt, {
327 ...(input.provider !== undefined ? { provider: input.provider } : {}),324 ...(input.provider !== undefined ? { provider: input.provider } : {}),
328 ...(input.model !== undefined ? { model: input.model } : {}),325 ...(input.model !== undefined ? { model: input.model } : {}),
329 runtimeMode: input.runtimeMode,
330 });326 });
331 const normalizedInput = toNonEmptyProviderInput(input.messageText);327 const normalizedInput = toNonEmptyProviderInput(input.messageText);
332 const normalizedAttachments = input.attachments ?? [];328 const normalizedAttachments = input.attachments ?? [];
...@@ -459,7 +455,6 @@ const make = Effect.gen(function* () {...@@ -459,7 +455,6 @@ const make = Effect.gen(function* () {
459 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),455 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),
460 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),456 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
461 ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}),457 ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}),
462 runtimeMode: event.payload.runtimeMode,
463 createdAt: event.payload.createdAt,458 createdAt: event.payload.createdAt,
464 });459 });
465 });460 });
...@@ -569,6 +564,14 @@ const make = Effect.gen(function* () {...@@ -569,6 +564,14 @@ const make = Effect.gen(function* () {
569 const processDomainEvent = (event: ProviderIntentEvent) =>564 const processDomainEvent = (event: ProviderIntentEvent) =>
570 Effect.gen(function* () {565 Effect.gen(function* () {
571 switch (event.type) {566 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 }
572 case "thread.turn-start-requested":575 case "thread.turn-start-requested":
573 yield* processTurnStartRequested(event);576 yield* processTurnStartRequested(event);
574 return;577 return;
...@@ -608,6 +611,7 @@ const make = Effect.gen(function* () {...@@ -608,6 +611,7 @@ const make = Effect.gen(function* () {
608 yield* Effect.forkScoped(611 yield* Effect.forkScoped(
609 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {612 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
610 if (613 if (
614 event.type !== "thread.runtime-mode-set" &&
611 event.type !== "thread.turn-start-requested" &&615 event.type !== "thread.turn-start-requested" &&
612 event.type !== "thread.turn-interrupt-requested" &&616 event.type !== "thread.turn-interrupt-requested" &&
613 event.type !== "thread.approval-response-requested" &&617 event.type !== "thread.approval-response-requested" &&
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+1
...@@ -167,6 +167,7 @@ describe("ProviderRuntimeIngestion", () => {...@@ -167,6 +167,7 @@ describe("ProviderRuntimeIngestion", () => {
167 projectId: asProjectId("project-1"),167 projectId: asProjectId("project-1"),
168 title: "Thread",168 title: "Thread",
169 model: "gpt-5-codex",169 model: "gpt-5-codex",
170 runtimeMode: "approval-required",
170 branch: null,171 branch: null,
171 worktreePath: null,172 worktreePath: null,
172 createdAt,173 createdAt,
apps/server/src/orchestration/Schemas.ts+2
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema,4 ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema,
5 ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema,5 ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema,
6 ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema,6 ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema,
7 ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema,
7 ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema,8 ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema,
8 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,9 ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema,
9 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,10 ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema,
...@@ -24,6 +25,7 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema;...@@ -24,6 +25,7 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema;
2425
25export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema;26export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema;
26export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema;27export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema;
28export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema;
27export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;29export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema;
2830
29export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;31export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema;
apps/server/src/orchestration/commandInvariants.test.ts+4
...@@ -50,6 +50,7 @@ const readModel: OrchestrationReadModel = {...@@ -50,6 +50,7 @@ const readModel: OrchestrationReadModel = {
50 projectId: ProjectId.makeUnsafe("project-a"),50 projectId: ProjectId.makeUnsafe("project-a"),
51 title: "Thread A",51 title: "Thread A",
52 model: "gpt-5-codex",52 model: "gpt-5-codex",
53 runtimeMode: "full-access",
53 branch: null,54 branch: null,
54 worktreePath: null,55 worktreePath: null,
55 createdAt: now,56 createdAt: now,
...@@ -66,6 +67,7 @@ const readModel: OrchestrationReadModel = {...@@ -66,6 +67,7 @@ const readModel: OrchestrationReadModel = {
66 projectId: ProjectId.makeUnsafe("project-b"),67 projectId: ProjectId.makeUnsafe("project-b"),
67 title: "Thread B",68 title: "Thread B",
68 model: "gpt-5-codex",69 model: "gpt-5-codex",
70 runtimeMode: "full-access",
69 branch: null,71 branch: null,
70 worktreePath: null,72 worktreePath: null,
71 createdAt: now,73 createdAt: now,
...@@ -137,6 +139,7 @@ describe("commandInvariants", () => {...@@ -137,6 +139,7 @@ describe("commandInvariants", () => {
137 projectId: ProjectId.makeUnsafe("project-a"),139 projectId: ProjectId.makeUnsafe("project-a"),
138 title: "new",140 title: "new",
139 model: "gpt-5-codex",141 model: "gpt-5-codex",
142 runtimeMode: "full-access",
140 branch: null,143 branch: null,
141 worktreePath: null,144 worktreePath: null,
142 createdAt: now,145 createdAt: now,
...@@ -156,6 +159,7 @@ describe("commandInvariants", () => {...@@ -156,6 +159,7 @@ describe("commandInvariants", () => {
156 projectId: ProjectId.makeUnsafe("project-a"),159 projectId: ProjectId.makeUnsafe("project-a"),
157 title: "dup",160 title: "dup",
158 model: "gpt-5-codex",161 model: "gpt-5-codex",
162 runtimeMode: "full-access",
159 branch: null,163 branch: null,
160 worktreePath: null,164 worktreePath: null,
161 createdAt: now,165 createdAt: now,
apps/server/src/orchestration/decider.projectScripts.test.ts+77
...@@ -130,6 +130,7 @@ describe("decider project scripts", () => {...@@ -130,6 +130,7 @@ describe("decider project scripts", () => {
130 projectId: asProjectId("project-1"),130 projectId: asProjectId("project-1"),
131 title: "Thread",131 title: "Thread",
132 model: "gpt-5-codex",132 model: "gpt-5-codex",
133 runtimeMode: "approval-required",
133 branch: null,134 branch: null,
134 worktreePath: null,135 worktreePath: null,
135 createdAt: now,136 createdAt: now,
...@@ -180,4 +181,80 @@ describe("decider project scripts", () => {...@@ -180,4 +181,80 @@ describe("decider project scripts", () => {
180 runtimeMode: "approval-required",181 runtimeMode: "approval-required",
181 });182 });
182 });183 });
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 });
183});260});
apps/server/src/orchestration/decider.ts+27-1
...@@ -157,6 +157,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -157,6 +157,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
157 projectId: command.projectId,157 projectId: command.projectId,
158 title: command.title,158 title: command.title,
159 model: command.model,159 model: command.model,
160 runtimeMode: command.runtimeMode,
160 branch: command.branch,161 branch: command.branch,
161 worktreePath: command.worktreePath,162 worktreePath: command.worktreePath,
162 createdAt: command.createdAt,163 createdAt: command.createdAt,
...@@ -213,6 +214,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -213,6 +214,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
213 };214 };
214 }215 }
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
216 case "thread.turn.start": {240 case "thread.turn.start": {
217 yield* requireThread({241 yield* requireThread({
218 readModel,242 readModel,
...@@ -255,7 +279,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -255,7 +279,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
255 ...(command.model !== undefined ? { model: command.model } : {}),279 ...(command.model !== undefined ? { model: command.model } : {}),
256 ...(command.effort !== undefined ? { effort: command.effort } : {}),280 ...(command.effort !== undefined ? { effort: command.effort } : {}),
257 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,281 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,
259 createdAt: command.createdAt,285 createdAt: command.createdAt,
260 },286 },
261 };287 };
apps/server/src/orchestration/projector.test.ts+60
...@@ -57,6 +57,7 @@ describe("orchestration projector", () => {...@@ -57,6 +57,7 @@ describe("orchestration projector", () => {
57 projectId: "project-1",57 projectId: "project-1",
58 title: "demo",58 title: "demo",
59 model: "gpt-5-codex",59 model: "gpt-5-codex",
60 runtimeMode: "full-access",
60 branch: null,61 branch: null,
61 worktreePath: null,62 worktreePath: null,
62 createdAt: now,63 createdAt: now,
...@@ -73,6 +74,7 @@ describe("orchestration projector", () => {...@@ -73,6 +74,7 @@ describe("orchestration projector", () => {
73 projectId: "project-1",74 projectId: "project-1",
74 title: "demo",75 title: "demo",
75 model: "gpt-5-codex",76 model: "gpt-5-codex",
77 runtimeMode: "full-access",
76 branch: null,78 branch: null,
77 worktreePath: null,79 worktreePath: null,
78 latestTurn: null,80 latestTurn: null,
...@@ -167,6 +169,7 @@ describe("orchestration projector", () => {...@@ -167,6 +169,7 @@ describe("orchestration projector", () => {
167 projectId: "project-1",169 projectId: "project-1",
168 title: "demo",170 title: "demo",
169 model: "gpt-5.3-codex",171 model: "gpt-5.3-codex",
172 runtimeMode: "full-access",
170 branch: null,173 branch: null,
171 worktreePath: null,174 worktreePath: null,
172 createdAt,175 createdAt,
...@@ -209,6 +212,59 @@ describe("orchestration projector", () => {...@@ -209,6 +212,59 @@ describe("orchestration projector", () => {
209 expect(thread?.session?.status).toBe("running");212 expect(thread?.session?.status).toBe("running");
210 });213 });
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
212 it("marks assistant messages completed with non-streaming updates", async () => {268 it("marks assistant messages completed with non-streaming updates", async () => {
213 const createdAt = "2026-02-23T09:00:00.000Z";269 const createdAt = "2026-02-23T09:00:00.000Z";
214 const deltaAt = "2026-02-23T09:00:01.000Z";270 const deltaAt = "2026-02-23T09:00:01.000Z";
...@@ -230,6 +286,7 @@ describe("orchestration projector", () => {...@@ -230,6 +286,7 @@ describe("orchestration projector", () => {
230 projectId: "project-1",286 projectId: "project-1",
231 title: "demo",287 title: "demo",
232 model: "gpt-5.3-codex",288 model: "gpt-5.3-codex",
289 runtimeMode: "full-access",
233 branch: null,290 branch: null,
234 worktreePath: null,291 worktreePath: null,
235 createdAt,292 createdAt,
...@@ -313,6 +370,7 @@ describe("orchestration projector", () => {...@@ -313,6 +370,7 @@ describe("orchestration projector", () => {
313 projectId: "project-1",370 projectId: "project-1",
314 title: "demo",371 title: "demo",
315 model: "gpt-5.3-codex",372 model: "gpt-5.3-codex",
373 runtimeMode: "full-access",
316 branch: null,374 branch: null,
317 worktreePath: null,375 worktreePath: null,
318 createdAt,376 createdAt,
...@@ -524,6 +582,7 @@ describe("orchestration projector", () => {...@@ -524,6 +582,7 @@ describe("orchestration projector", () => {
524 projectId: "project-1",582 projectId: "project-1",
525 title: "demo",583 title: "demo",
526 model: "gpt-5.3-codex",584 model: "gpt-5.3-codex",
585 runtimeMode: "full-access",
527 branch: null,586 branch: null,
528 worktreePath: null,587 worktreePath: null,
529 createdAt,588 createdAt,
...@@ -673,6 +732,7 @@ describe("orchestration projector", () => {...@@ -673,6 +732,7 @@ describe("orchestration projector", () => {
673 projectId: "project-1",732 projectId: "project-1",
674 title: "capped",733 title: "capped",
675 model: "gpt-5-codex",734 model: "gpt-5-codex",
735 runtimeMode: "full-access",
676 branch: null,736 branch: null,
677 worktreePath: null,737 worktreePath: null,
678 createdAt,738 createdAt,
apps/server/src/orchestration/projector.ts+18
...@@ -17,6 +17,7 @@ import {...@@ -17,6 +17,7 @@ import {
17 ThreadCreatedPayload,17 ThreadCreatedPayload,
18 ThreadDeletedPayload,18 ThreadDeletedPayload,
19 ThreadMetaUpdatedPayload,19 ThreadMetaUpdatedPayload,
20 ThreadRuntimeModeSetPayload,
20 ThreadRevertedPayload,21 ThreadRevertedPayload,
21 ThreadSessionSetPayload,22 ThreadSessionSetPayload,
22 ThreadTurnDiffCompletedPayload,23 ThreadTurnDiffCompletedPayload,
...@@ -241,6 +242,7 @@ export function projectEvent(...@@ -241,6 +242,7 @@ export function projectEvent(
241 projectId: payload.projectId,242 projectId: payload.projectId,
242 title: payload.title,243 title: payload.title,
243 model: payload.model,244 model: payload.model,
245 runtimeMode: payload.runtimeMode,
244 branch: payload.branch,246 branch: payload.branch,
245 worktreePath: payload.worktreePath,247 worktreePath: payload.worktreePath,
246 latestTurn: null,248 latestTurn: null,
...@@ -289,6 +291,22 @@ export function projectEvent(...@@ -289,6 +291,22 @@ export function projectEvent(
289 })),291 })),
290 );292 );
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
292 case "thread.message-sent":310 case "thread.message-sent":
293 return Effect.gen(function* () {311 return Effect.gen(function* () {
294 const payload = yield* decodeForEvent(312 const payload = yield* decodeForEvent(
apps/server/src/persistence/Layers/ProjectionThreads.ts+5
...@@ -24,6 +24,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -24,6 +24,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
24 project_id,24 project_id,
25 title,25 title,
26 model,26 model,
27 runtime_mode,
27 branch,28 branch,
28 worktree_path,29 worktree_path,
29 latest_turn_id,30 latest_turn_id,
...@@ -36,6 +37,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -36,6 +37,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
36 ${row.projectId},37 ${row.projectId},
37 ${row.title},38 ${row.title},
38 ${row.model},39 ${row.model},
40 ${row.runtimeMode},
39 ${row.branch},41 ${row.branch},
40 ${row.worktreePath},42 ${row.worktreePath},
41 ${row.latestTurnId},43 ${row.latestTurnId},
...@@ -48,6 +50,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -48,6 +50,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
48 project_id = excluded.project_id,50 project_id = excluded.project_id,
49 title = excluded.title,51 title = excluded.title,
50 model = excluded.model,52 model = excluded.model,
53 runtime_mode = excluded.runtime_mode,
51 branch = excluded.branch,54 branch = excluded.branch,
52 worktree_path = excluded.worktree_path,55 worktree_path = excluded.worktree_path,
53 latest_turn_id = excluded.latest_turn_id,56 latest_turn_id = excluded.latest_turn_id,
...@@ -67,6 +70,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -67,6 +70,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
67 project_id AS "projectId",70 project_id AS "projectId",
68 title,71 title,
69 model,72 model,
73 runtime_mode AS "runtimeMode",
70 branch,74 branch,
71 worktree_path AS "worktreePath",75 worktree_path AS "worktreePath",
72 latest_turn_id AS "latestTurnId",76 latest_turn_id AS "latestTurnId",
...@@ -88,6 +92,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -88,6 +92,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
88 project_id AS "projectId",92 project_id AS "projectId",
89 title,93 title,
90 model,94 model,
95 runtime_mode AS "runtimeMode",
91 branch,96 branch,
92 worktree_path AS "worktreePath",97 worktree_path AS "worktreePath",
93 latest_turn_id AS "latestTurnId",98 latest_turn_id AS "latestTurnId",
apps/server/src/persistence/Layers/ProviderSessionRuntime.ts+5
...@@ -49,6 +49,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {...@@ -49,6 +49,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
49 provider_name,49 provider_name,
50 adapter_key,50 adapter_key,
51 provider_thread_id,51 provider_thread_id,
52 runtime_mode,
52 status,53 status,
53 last_seen_at,54 last_seen_at,
54 resume_cursor_json,55 resume_cursor_json,
...@@ -60,6 +61,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {...@@ -60,6 +61,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
60 ${runtime.providerName},61 ${runtime.providerName},
61 ${runtime.adapterKey},62 ${runtime.adapterKey},
62 ${runtime.providerThreadId},63 ${runtime.providerThreadId},
64 ${runtime.runtimeMode},
63 ${runtime.status},65 ${runtime.status},
64 ${runtime.lastSeenAt},66 ${runtime.lastSeenAt},
65 ${runtime.resumeCursor},67 ${runtime.resumeCursor},
...@@ -71,6 +73,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {...@@ -71,6 +73,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
71 provider_name = excluded.provider_name,73 provider_name = excluded.provider_name,
72 adapter_key = excluded.adapter_key,74 adapter_key = excluded.adapter_key,
73 provider_thread_id = excluded.provider_thread_id,75 provider_thread_id = excluded.provider_thread_id,
76 runtime_mode = excluded.runtime_mode,
74 status = excluded.status,77 status = excluded.status,
75 last_seen_at = excluded.last_seen_at,78 last_seen_at = excluded.last_seen_at,
76 resume_cursor_json = excluded.resume_cursor_json,79 resume_cursor_json = excluded.resume_cursor_json,
...@@ -89,6 +92,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {...@@ -89,6 +92,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
89 provider_name AS "providerName",92 provider_name AS "providerName",
90 adapter_key AS "adapterKey",93 adapter_key AS "adapterKey",
91 provider_thread_id AS "providerThreadId",94 provider_thread_id AS "providerThreadId",
95 runtime_mode AS "runtimeMode",
92 status,96 status,
93 last_seen_at AS "lastSeenAt",97 last_seen_at AS "lastSeenAt",
94 resume_cursor_json AS "resumeCursor",98 resume_cursor_json AS "resumeCursor",
...@@ -109,6 +113,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {...@@ -109,6 +113,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () {
109 provider_name AS "providerName",113 provider_name AS "providerName",
110 adapter_key AS "adapterKey",114 adapter_key AS "adapterKey",
111 provider_thread_id AS "providerThreadId",115 provider_thread_id AS "providerThreadId",
116 runtime_mode AS "runtimeMode",
112 status,117 status,
113 last_seen_at AS "lastSeenAt",118 last_seen_at AS "lastSeenAt",
114 resume_cursor_json AS "resumeCursor",119 resume_cursor_json AS "resumeCursor",
apps/server/src/persistence/Migrations.ts+12-1
...@@ -20,6 +20,10 @@ import Migration0005 from "./Migrations/005_Projections.ts";...@@ -20,6 +20,10 @@ import Migration0005 from "./Migrations/005_Projections.ts";
20import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts";20import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts";
21import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts";21import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts";
22import Migration0008 from "./Migrations/008_ProjectionThreadActivitySequence.ts";22import 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
24/**28/**
25 * Migration loader with all migrations defined inline.29 * Migration loader with all migrations defined inline.
...@@ -40,6 +44,9 @@ const loader = Migrator.fromRecord({...@@ -40,6 +44,9 @@ const loader = Migrator.fromRecord({
40 "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006,44 "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006,
41 "7_ProjectionThreadMessageAttachments": Migration0007,45 "7_ProjectionThreadMessageAttachments": Migration0007,
42 "8_ProjectionThreadActivitySequence": Migration0008,46 "8_ProjectionThreadActivitySequence": Migration0008,
47 "9_ProviderSessionRuntimeMode": Migration0009,
48 "10_ProjectionThreadsRuntimeMode": Migration0010,
49 "11_OrchestrationThreadCreatedRuntimeMode": Migration0011,
43});50});
4451
45/**52/**
...@@ -58,7 +65,11 @@ const run = Migrator.make({});...@@ -58,7 +65,11 @@ const run = Migrator.make({});
58 *65 *
59 * @returns Effect containing array of executed migrations66 * @returns Effect containing array of executed migrations
60 */67 */
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
63/**74/**
64 * Layer that runs migrations when the layer is built.75 * 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 @@...@@ -6,7 +6,7 @@
6 *6 *
7 * @module ProjectionThreadRepository7 * @module ProjectionThreadRepository
8 */8 */
9import { IsoDateTime, ProjectId, ThreadId, TurnId } from "@t3tools/contracts";9import { IsoDateTime, ProjectId, RuntimeMode, ThreadId, TurnId } from "@t3tools/contracts";
10import { Option, Schema, ServiceMap } from "effect";10import { Option, Schema, ServiceMap } from "effect";
11import type { Effect } from "effect";11import type { Effect } from "effect";
1212
...@@ -17,6 +17,7 @@ export const ProjectionThread = Schema.Struct({...@@ -17,6 +17,7 @@ export const ProjectionThread = Schema.Struct({
17 projectId: ProjectId,17 projectId: ProjectId,
18 title: Schema.String,18 title: Schema.String,
19 model: Schema.String,19 model: Schema.String,
20 runtimeMode: RuntimeMode,
20 branch: Schema.NullOr(Schema.String),21 branch: Schema.NullOr(Schema.String),
21 worktreePath: Schema.NullOr(Schema.String),22 worktreePath: Schema.NullOr(Schema.String),
22 latestTurnId: Schema.NullOr(TurnId),23 latestTurnId: Schema.NullOr(TurnId),
apps/server/src/persistence/Services/ProviderSessionRuntime.ts+2
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 ProviderSessionId,10 ProviderSessionId,
11 ProviderSessionRuntimeStatus,11 ProviderSessionRuntimeStatus,
12 ProviderThreadId,12 ProviderThreadId,
13 RuntimeMode,
13 ThreadId,14 ThreadId,
14} from "@t3tools/contracts";15} from "@t3tools/contracts";
15import { Option, Schema, ServiceMap } from "effect";16import { Option, Schema, ServiceMap } from "effect";
...@@ -23,6 +24,7 @@ export const ProviderSessionRuntime = Schema.Struct({...@@ -23,6 +24,7 @@ export const ProviderSessionRuntime = Schema.Struct({
23 providerName: Schema.String,24 providerName: Schema.String,
24 adapterKey: Schema.String,25 adapterKey: Schema.String,
25 providerThreadId: Schema.NullOr(ProviderThreadId),26 providerThreadId: Schema.NullOr(ProviderThreadId),
27 runtimeMode: RuntimeMode,
26 status: ProviderSessionRuntimeStatus,28 status: ProviderSessionRuntimeStatus,
27 lastSeenAt: IsoDateTime,29 lastSeenAt: IsoDateTime,
28 resumeCursor: Schema.NullOr(Schema.Unknown),30 resumeCursor: Schema.NullOr(Schema.Unknown),
apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts+15-2
...@@ -166,7 +166,9 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -166,7 +166,9 @@ describe("ClaudeCodeAdapterLive", () => {
166 const harness = makeHarness();166 const harness = makeHarness();
167 return Effect.gen(function* () {167 return Effect.gen(function* () {
168 const adapter = yield* ClaudeCodeAdapter;168 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
171 assert.equal(result._tag, "Failure");173 assert.equal(result._tag, "Failure");
172 if (result._tag !== "Failure") {174 if (result._tag !== "Failure") {
...@@ -240,6 +242,7 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -240,6 +242,7 @@ describe("ClaudeCodeAdapterLive", () => {
240 const session = yield* adapter.startSession({242 const session = yield* adapter.startSession({
241 provider: "claudeCode",243 provider: "claudeCode",
242 model: "claude-sonnet-4-5",244 model: "claude-sonnet-4-5",
245 runtimeMode: "full-access",
243 });246 });
244247
245 const turn = yield* adapter.sendTurn({248 const turn = yield* adapter.sendTurn({
...@@ -374,6 +377,7 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -374,6 +377,7 @@ describe("ClaudeCodeAdapterLive", () => {
374377
375 const session = yield* adapter.startSession({378 const session = yield* adapter.startSession({
376 provider: "claudeCode",379 provider: "claudeCode",
380 runtimeMode: "full-access",
377 });381 });
378382
379 const turn = yield* adapter.sendTurn({383 const turn = yield* adapter.sendTurn({
...@@ -461,6 +465,7 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -461,6 +465,7 @@ describe("ClaudeCodeAdapterLive", () => {
461465
462 const session = yield* adapter.startSession({466 const session = yield* adapter.startSession({
463 provider: "claudeCode",467 provider: "claudeCode",
468 runtimeMode: "full-access",
464 });469 });
465470
466 const turn = yield* adapter.sendTurn({471 const turn = yield* adapter.sendTurn({
...@@ -529,6 +534,7 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -529,6 +534,7 @@ describe("ClaudeCodeAdapterLive", () => {
529534
530 const session = yield* adapter.startSession({535 const session = yield* adapter.startSession({
531 provider: "claudeCode",536 provider: "claudeCode",
537 runtimeMode: "full-access",
532 });538 });
533 assert.equal(session.threadId, undefined);539 assert.equal(session.threadId, undefined);
534540
...@@ -679,6 +685,7 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -679,6 +685,7 @@ describe("ClaudeCodeAdapterLive", () => {
679 resumeSessionAt: "assistant-99",685 resumeSessionAt: "assistant-99",
680 turnCount: 3,686 turnCount: 3,
681 },687 },
688 runtimeMode: "full-access",
682 });689 });
683690
684 assert.equal(session.threadId, "resume-thread-1");691 assert.equal(session.threadId, "resume-thread-1");
...@@ -705,6 +712,7 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -705,6 +712,7 @@ describe("ClaudeCodeAdapterLive", () => {
705712
706 const session = yield* adapter.startSession({713 const session = yield* adapter.startSession({
707 provider: "claudeCode",714 provider: "claudeCode",
715 runtimeMode: "full-access",
708 });716 });
709717
710 assert.equal(718 assert.equal(
...@@ -727,6 +735,7 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -727,6 +735,7 @@ describe("ClaudeCodeAdapterLive", () => {
727735
728 const session = yield* adapter.startSession({736 const session = yield* adapter.startSession({
729 provider: "claudeCode",737 provider: "claudeCode",
738 runtimeMode: "full-access",
730 });739 });
731740
732 const firstTurn = yield* adapter.sendTurn({741 const firstTurn = yield* adapter.sendTurn({
...@@ -802,7 +811,10 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -802,7 +811,10 @@ describe("ClaudeCodeAdapterLive", () => {
802 return Effect.gen(function* () {811 return Effect.gen(function* () {
803 const adapter = yield* ClaudeCodeAdapter;812 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 });
806 yield* adapter.sendTurn({818 yield* adapter.sendTurn({
807 sessionId: session.sessionId,819 sessionId: session.sessionId,
808 input: "hello",820 input: "hello",
...@@ -841,6 +853,7 @@ describe("ClaudeCodeAdapterLive", () => {...@@ -841,6 +853,7 @@ describe("ClaudeCodeAdapterLive", () => {
841853
842 const session = yield* adapter.startSession({854 const session = yield* adapter.startSession({
843 provider: "claudeCode",855 provider: "claudeCode",
856 runtimeMode: "full-access",
844 });857 });
845 const turn = yield* adapter.sendTurn({858 const turn = yield* adapter.sendTurn({
846 sessionId: session.sessionId,859 sessionId: session.sessionId,
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+1
...@@ -1632,6 +1632,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {...@@ -1632,6 +1632,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1632 sessionId,1632 sessionId,
1633 provider: PROVIDER,1633 provider: PROVIDER,
1634 status: "ready",1634 status: "ready",
1635 runtimeMode: input.runtimeMode,
1635 ...(input.cwd ? { cwd: input.cwd } : {}),1636 ...(input.cwd ? { cwd: input.cwd } : {}),
1636 ...(input.model ? { model: input.model } : {}),1637 ...(input.model ? { model: input.model } : {}),
1637 ...(threadId ? { threadId } : {}),1638 ...(threadId ? { threadId } : {}),
apps/server/src/provider/Layers/CodexAdapter.test.ts+2
...@@ -40,6 +40,7 @@ class FakeCodexManager extends CodexAppServerManager {...@@ -40,6 +40,7 @@ class FakeCodexManager extends CodexAppServerManager {
40 sessionId: asSessionId("sess-1"),40 sessionId: asSessionId("sess-1"),
41 provider: "codex",41 provider: "codex",
42 status: "ready",42 status: "ready",
43 runtimeMode: input.runtimeMode,
43 threadId: ProviderThreadId.makeUnsafe("thread-1"),44 threadId: ProviderThreadId.makeUnsafe("thread-1"),
44 cwd: input.cwd,45 cwd: input.cwd,
45 createdAt: now,46 createdAt: now,
...@@ -148,6 +149,7 @@ validationLayer("CodexAdapterLive validation", (it) => {...@@ -148,6 +149,7 @@ validationLayer("CodexAdapterLive validation", (it) => {
148 const result = yield* adapter149 const result = yield* adapter
149 .startSession({150 .startSession({
150 provider: "claudeCode",151 provider: "claudeCode",
152 runtimeMode: "full-access",
151 })153 })
152 .pipe(Effect.result);154 .pipe(Effect.result);
153155
apps/server/src/provider/Layers/CursorAdapter.test.ts+10-1
...@@ -255,7 +255,9 @@ describe("CursorAdapterLive", () => {...@@ -255,7 +255,9 @@ describe("CursorAdapterLive", () => {
255255
256 return Effect.gen(function* () {256 return Effect.gen(function* () {
257 const adapter = yield* CursorAdapter;257 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
260 assert.equal(result._tag, "Failure");262 assert.equal(result._tag, "Failure");
261 if (result._tag !== "Failure") {263 if (result._tag !== "Failure") {
...@@ -289,6 +291,7 @@ describe("CursorAdapterLive", () => {...@@ -289,6 +291,7 @@ describe("CursorAdapterLive", () => {
289 const session = yield* adapter.startSession({291 const session = yield* adapter.startSession({
290 provider: "cursor",292 provider: "cursor",
291 cwd: "/tmp/project",293 cwd: "/tmp/project",
294 runtimeMode: "full-access",
292 });295 });
293296
294 const turn = yield* adapter.sendTurn({297 const turn = yield* adapter.sendTurn({
...@@ -353,6 +356,7 @@ describe("CursorAdapterLive", () => {...@@ -353,6 +356,7 @@ describe("CursorAdapterLive", () => {
353 yield* adapter.startSession({356 yield* adapter.startSession({
354 provider: "cursor",357 provider: "cursor",
355 model: "composer-1.5",358 model: "composer-1.5",
359 runtimeMode: "full-access",
356 });360 });
357361
358 assert.deepEqual(createProcessInput?.model, "composer-1.5");362 assert.deepEqual(createProcessInput?.model, "composer-1.5");
...@@ -385,6 +389,7 @@ describe("CursorAdapterLive", () => {...@@ -385,6 +389,7 @@ describe("CursorAdapterLive", () => {
385 const session = yield* adapter.startSession({389 const session = yield* adapter.startSession({
386 provider: "cursor",390 provider: "cursor",
387 cwd: "/tmp/project",391 cwd: "/tmp/project",
392 runtimeMode: "full-access",
388 });393 });
389394
390 assert.equal(nativeEvents.length > 0, true);395 assert.equal(nativeEvents.length > 0, true);
...@@ -408,6 +413,7 @@ describe("CursorAdapterLive", () => {...@@ -408,6 +413,7 @@ describe("CursorAdapterLive", () => {
408 resumeCursor: {413 resumeCursor: {
409 acpSessionId: "acp-session-resume",414 acpSessionId: "acp-session-resume",
410 },415 },
416 runtimeMode: "full-access",
411 });417 });
412418
413 const methods = new Set(fake.requests.map((request) => request.method));419 const methods = new Set(fake.requests.map((request) => request.method));
...@@ -441,6 +447,7 @@ describe("CursorAdapterLive", () => {...@@ -441,6 +447,7 @@ describe("CursorAdapterLive", () => {
441 resumeCursor: {447 resumeCursor: {
442 sessionId: "acp-session-legacy",448 sessionId: "acp-session-legacy",
443 },449 },
450 runtimeMode: "full-access",
444 });451 });
445452
446 const loadRequest = fake.requests.find((request) => request.method === "session/load");453 const loadRequest = fake.requests.find((request) => request.method === "session/load");
...@@ -555,6 +562,7 @@ describe("CursorAdapterLive", () => {...@@ -555,6 +562,7 @@ describe("CursorAdapterLive", () => {
555 const adapter = yield* CursorAdapter;562 const adapter = yield* CursorAdapter;
556 const session = yield* adapter.startSession({563 const session = yield* adapter.startSession({
557 provider: "cursor",564 provider: "cursor",
565 runtimeMode: "full-access",
558 });566 });
559567
560 yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain);568 yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain);
...@@ -600,6 +608,7 @@ describe("CursorAdapterLive", () => {...@@ -600,6 +608,7 @@ describe("CursorAdapterLive", () => {
600608
601 const session = yield* adapter.startSession({609 const session = yield* adapter.startSession({
602 provider: "cursor",610 provider: "cursor",
611 runtimeMode: "full-access",
603 });612 });
604613
605 yield* adapter.sendTurn({614 yield* adapter.sendTurn({
apps/server/src/provider/Layers/CursorAdapter.ts+2-1
...@@ -1040,6 +1040,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1040,6 +1040,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1040 sessionId,1040 sessionId,
1041 provider: PROVIDER,1041 provider: PROVIDER,
1042 status: "connecting",1042 status: "connecting",
1043 runtimeMode: input.runtimeMode,
1043 ...(input.cwd ? { cwd: input.cwd } : {}),1044 ...(input.cwd ? { cwd: input.cwd } : {}),
1044 ...(input.model ? { model: input.model } : {}),1045 ...(input.model ? { model: input.model } : {}),
1045 createdAt: startedAt,1046 createdAt: startedAt,
...@@ -1048,7 +1049,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1048,7 +1049,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
10481049
1049 const context: CursorSessionContext = {1050 const context: CursorSessionContext = {
1050 session,1051 session,
1051 runtimeMode: input.runtimeMode ?? "full-access",1052 runtimeMode: input.runtimeMode,
1052 child,1053 child,
1053 output,1054 output,
1054 pending: new Map(),1055 pending: new Map(),
apps/server/src/provider/Layers/ProviderService.test.ts+14
...@@ -80,6 +80,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {...@@ -80,6 +80,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
80 sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`),80 sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`),
81 provider,81 provider,
82 status: "ready",82 status: "ready",
83 runtimeMode: input.runtimeMode,
83 threadId,84 threadId,
84 resumeCursor: input.resumeCursor ?? { opaque: `cursor-${next}` },85 resumeCursor: input.resumeCursor ?? { opaque: `cursor-${next}` },
85 cwd: input.cwd ?? process.cwd(),86 cwd: input.cwd ?? process.cwd(),
...@@ -350,6 +351,7 @@ it.effect(...@@ -350,6 +351,7 @@ it.effect(
350 return yield* provider.startSession(asThreadId("thread-1"), {351 return yield* provider.startSession(asThreadId("thread-1"), {
351 provider: "codex",352 provider: "codex",
352 cwd: "/tmp/project",353 cwd: "/tmp/project",
354 runtimeMode: "full-access",
353 });355 });
354 }).pipe(Effect.provide(firstProviderLayer));356 }).pipe(Effect.provide(firstProviderLayer));
355357
...@@ -429,6 +431,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -429,6 +431,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
429 const session = yield* provider.startSession(asThreadId("thread-1"), {431 const session = yield* provider.startSession(asThreadId("thread-1"), {
430 provider: "codex",432 provider: "codex",
431 cwd: "/tmp/project",433 cwd: "/tmp/project",
434 runtimeMode: "full-access",
432 });435 });
433 assert.equal(session.provider, "codex");436 assert.equal(session.provider, "codex");
434437
...@@ -481,6 +484,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -481,6 +484,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
481 const session = yield* provider.startSession(asThreadId("thread-claude"), {484 const session = yield* provider.startSession(asThreadId("thread-claude"), {
482 provider: "claudeCode",485 provider: "claudeCode",
483 cwd: "/tmp/project-claude",486 cwd: "/tmp/project-claude",
487 runtimeMode: "full-access",
484 });488 });
485489
486 assert.equal(session.provider, "claudeCode");490 assert.equal(session.provider, "claudeCode");
...@@ -502,6 +506,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -502,6 +506,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
502 const initial = yield* provider.startSession(asThreadId("thread-1"), {506 const initial = yield* provider.startSession(asThreadId("thread-1"), {
503 provider: "codex",507 provider: "codex",
504 cwd: "/tmp/project",508 cwd: "/tmp/project",
509 runtimeMode: "full-access",
505 });510 });
506 yield* routing.codex.stopSession(initial.sessionId);511 yield* routing.codex.stopSession(initial.sessionId);
507 routing.codex.startSession.mockClear();512 routing.codex.startSession.mockClear();
...@@ -540,6 +545,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -540,6 +545,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
540 const initial = yield* provider.startSession(asThreadId("thread-1"), {545 const initial = yield* provider.startSession(asThreadId("thread-1"), {
541 provider: "codex",546 provider: "codex",
542 cwd: "/tmp/project-send-turn",547 cwd: "/tmp/project-send-turn",
548 runtimeMode: "full-access",
543 });549 });
544550
545 yield* provider.stopAll();551 yield* provider.stopAll();
...@@ -577,9 +583,11 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -577,9 +583,11 @@ routing.layer("ProviderServiceLive routing", (it) => {
577583
578 yield* provider.startSession(asThreadId("thread-1"), {584 yield* provider.startSession(asThreadId("thread-1"), {
579 provider: "codex",585 provider: "codex",
586 runtimeMode: "full-access",
580 });587 });
581 yield* provider.startSession(asThreadId("thread-2"), {588 yield* provider.startSession(asThreadId("thread-2"), {
582 provider: "codex",589 provider: "codex",
590 runtimeMode: "full-access",
583 });591 });
584592
585 yield* provider.stopAll();593 yield* provider.stopAll();
...@@ -596,6 +604,7 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -596,6 +604,7 @@ routing.layer("ProviderServiceLive routing", (it) => {
596604
597 const session = yield* provider.startSession(asThreadId("thread-1"), {605 const session = yield* provider.startSession(asThreadId("thread-1"), {
598 provider: "codex",606 provider: "codex",
607 runtimeMode: "full-access",
599 });608 });
600 yield* provider.sendTurn({609 yield* provider.sendTurn({
601 sessionId: session.sessionId,610 sessionId: session.sessionId,
...@@ -647,6 +656,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -647,6 +656,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
647 const provider = yield* ProviderService;656 const provider = yield* ProviderService;
648 const session = yield* provider.startSession(asThreadId("thread-1"), {657 const session = yield* provider.startSession(asThreadId("thread-1"), {
649 provider: "codex",658 provider: "codex",
659 runtimeMode: "full-access",
650 });660 });
651661
652 const eventsRef = yield* Ref.make<Array<ProviderRuntimeEvent>>([]);662 const eventsRef = yield* Ref.make<Array<ProviderRuntimeEvent>>([]);
...@@ -684,6 +694,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -684,6 +694,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
684 const provider = yield* ProviderService;694 const provider = yield* ProviderService;
685 const session = yield* provider.startSession(asThreadId("thread-seq"), {695 const session = yield* provider.startSession(asThreadId("thread-seq"), {
686 provider: "codex",696 provider: "codex",
697 runtimeMode: "full-access",
687 });698 });
688699
689 const receivedRef = yield* Ref.make<Array<ProviderRuntimeEvent>>([]);700 const receivedRef = yield* Ref.make<Array<ProviderRuntimeEvent>>([]);
...@@ -740,6 +751,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -740,6 +751,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
740 const provider = yield* ProviderService;751 const provider = yield* ProviderService;
741 const session = yield* provider.startSession(asThreadId("thread-1"), {752 const session = yield* provider.startSession(asThreadId("thread-1"), {
742 provider: "codex",753 provider: "codex",
754 runtimeMode: "full-access",
743 });755 });
744756
745 const receivedByHealthy: string[] = [];757 const receivedByHealthy: string[] = [];
...@@ -845,6 +857,7 @@ validation.layer("ProviderServiceLive validation", (it) => {...@@ -845,6 +857,7 @@ validation.layer("ProviderServiceLive validation", (it) => {
845 sessionId: asSessionId("sess-missing-thread"),857 sessionId: asSessionId("sess-missing-thread"),
846 provider: "codex",858 provider: "codex",
847 status: "ready",859 status: "ready",
860 runtimeMode: input.runtimeMode,
848 cwd: input.cwd ?? process.cwd(),861 cwd: input.cwd ?? process.cwd(),
849 createdAt: now,862 createdAt: now,
850 updatedAt: now,863 updatedAt: now,
...@@ -855,6 +868,7 @@ validation.layer("ProviderServiceLive validation", (it) => {...@@ -855,6 +868,7 @@ validation.layer("ProviderServiceLive validation", (it) => {
855 const session = yield* provider.startSession(asThreadId("thread-missing"), {868 const session = yield* provider.startSession(asThreadId("thread-missing"), {
856 provider: "codex",869 provider: "codex",
857 cwd: "/tmp/project",870 cwd: "/tmp/project",
871 runtimeMode: "full-access",
858 });872 });
859873
860 assert.equal(session.sessionId, asSessionId("sess-missing-thread"));874 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) =>...@@ -180,6 +180,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
180 provider: session.provider,180 provider: session.provider,
181 threadId,181 threadId,
182 ...(providerThreadId !== undefined ? { providerThreadId } : {}),182 ...(providerThreadId !== undefined ? { providerThreadId } : {}),
183 runtimeMode: session.runtimeMode,
183 status: toRuntimeStatus(session),184 status: toRuntimeStatus(session),
184 ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}),185 ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}),
185 runtimePayload: toRuntimePayloadFromSession(session),186 runtimePayload: toRuntimePayloadFromSession(session),
...@@ -296,6 +297,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -296,6 +297,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
296 ...(persistedCwd ? { cwd: persistedCwd } : {}),297 ...(persistedCwd ? { cwd: persistedCwd } : {}),
297 ...(resumeThreadId ? { resumeThreadId } : {}),298 ...(resumeThreadId ? { resumeThreadId } : {}),
298 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),299 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),
300 runtimeMode: input.binding.runtimeMode ?? "full-access",
299 });301 });
300 if (resumed.provider !== adapter.provider) {302 if (resumed.provider !== adapter.provider) {
301 return yield* toValidationError(303 return yield* toValidationError(
...@@ -415,7 +417,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -415,7 +417,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
415 const input = {417 const input = {
416 ...parsed,418 ...parsed,
417 provider: parsed.provider ?? "codex",419 provider: parsed.provider ?? "codex",
418 runtimeMode: parsed.runtimeMode ?? "full-access",
419 };420 };
420 const adapter = yield* registry.getByProvider(input.provider);421 const adapter = yield* registry.getByProvider(input.provider);
421 const session = yield* adapter.startSession(input);422 const session = yield* adapter.startSession(input);
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+2
...@@ -75,6 +75,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () {...@@ -75,6 +75,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
75 threadId: value.threadId,75 threadId: value.threadId,
76 adapterKey: value.adapterKey,76 adapterKey: value.adapterKey,
77 providerThreadId: value.providerThreadId,77 providerThreadId: value.providerThreadId,
78 runtimeMode: value.runtimeMode,
78 status: value.status,79 status: value.status,
79 resumeCursor: value.resumeCursor,80 resumeCursor: value.resumeCursor,
80 runtimePayload: value.runtimePayload,81 runtimePayload: value.runtimePayload,
...@@ -112,6 +113,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () {...@@ -112,6 +113,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
112 binding.providerThreadId !== undefined113 binding.providerThreadId !== undefined
113 ? binding.providerThreadId114 ? binding.providerThreadId
114 : (existingRuntime?.providerThreadId ?? null),115 : (existingRuntime?.providerThreadId ?? null),
116 runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access",
115 status: binding.status ?? existingRuntime?.status ?? "running",117 status: binding.status ?? existingRuntime?.status ?? "running",
116 lastSeenAt: now,118 lastSeenAt: now,
117 resumeCursor:119 resumeCursor:
apps/server/src/provider/Services/ProviderSessionDirectory.ts+2
...@@ -12,6 +12,7 @@ import type {...@@ -12,6 +12,7 @@ import type {
12 ProviderSessionId,12 ProviderSessionId,
13 ProviderSessionRuntimeStatus,13 ProviderSessionRuntimeStatus,
14 ProviderThreadId,14 ProviderThreadId,
15 RuntimeMode,
15 ThreadId,16 ThreadId,
16} from "@t3tools/contracts";17} from "@t3tools/contracts";
17import { Option, ServiceMap } from "effect";18import { Option, ServiceMap } from "effect";
...@@ -32,6 +33,7 @@ export interface ProviderSessionBinding {...@@ -32,6 +33,7 @@ export interface ProviderSessionBinding {
32 readonly status?: ProviderSessionRuntimeStatus;33 readonly status?: ProviderSessionRuntimeStatus;
33 readonly resumeCursor?: unknown | null;34 readonly resumeCursor?: unknown | null;
34 readonly runtimePayload?: unknown | null;35 readonly runtimePayload?: unknown | null;
36 readonly runtimeMode?: RuntimeMode;
35}37}
3638
37export type ProviderSessionDirectoryReadError =39export type ProviderSessionDirectoryReadError =
apps/server/src/wsServer.test.ts+1
...@@ -1157,6 +1157,7 @@ describe("WebSocket Server", () => {...@@ -1157,6 +1157,7 @@ describe("WebSocket Server", () => {
1157 sessionId,1157 sessionId,
1158 provider: "codex",1158 provider: "codex",
1159 status: "ready",1159 status: "ready",
1160 runtimeMode: "full-access",
1160 threadId: asProviderThreadId("provider-thread-1"),1161 threadId: asProviderThreadId("provider-thread-1"),
1161 createdAt: new Date().toISOString(),1162 createdAt: new Date().toISOString(),
1162 updatedAt: new Date().toISOString(),1163 updatedAt: new Date().toISOString(),
apps/server/src/wsServer.ts+1
...@@ -595,6 +595,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -595,6 +595,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
595 projectId: bootstrapProjectId,595 projectId: bootstrapProjectId,
596 title: "New thread",596 title: "New thread",
597 model: bootstrapProjectDefaultModel,597 model: bootstrapProjectDefaultModel,
598 runtimeMode: "full-access",
598 branch: null,599 branch: null,
599 worktreePath: null,600 worktreePath: null,
600 createdAt,601 createdAt,
apps/web/src/components/ChatView.tsx+43-33
...@@ -31,7 +31,17 @@ import {...@@ -31,7 +31,17 @@ import {
31 resolveModelSlugForProvider,31 resolveModelSlugForProvider,
32 OrchestrationThreadActivity,32 OrchestrationThreadActivity,
33} from "@t3tools/contracts";33} 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";
35import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";45import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
36import { useDebouncedValue } from "@tanstack/react-pacer";46import { useDebouncedValue } from "@tanstack/react-pacer";
37import { useNavigate, useSearch } from "@tanstack/react-router";47import { useNavigate, useSearch } from "@tanstack/react-router";
...@@ -69,6 +79,7 @@ import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from ".....@@ -69,6 +79,7 @@ import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "..
69import { useStore } from "../store";79import { useStore } from "../store";
70import { truncateTitle } from "../truncateTitle";80import { truncateTitle } from "../truncateTitle";
71import {81import {
82 DEFAULT_RUNTIME_MODE,
72 DEFAULT_THREAD_TERMINAL_ID,83 DEFAULT_THREAD_TERMINAL_ID,
73 MAX_THREAD_TERMINAL_COUNT,84 MAX_THREAD_TERMINAL_COUNT,
74 type ChatMessage,85 type ChatMessage,
...@@ -267,6 +278,7 @@ function buildLocalDraftThread(...@@ -267,6 +278,7 @@ function buildLocalDraftThread(
267 projectId: draftThread.projectId,278 projectId: draftThread.projectId,
268 title: "New thread",279 title: "New thread",
269 model: fallbackModel,280 model: fallbackModel,
281 runtimeMode: draftThread.runtimeMode,
270 session: null,282 session: null,
271 messages: [],283 messages: [],
272 error,284 error,
...@@ -500,10 +512,8 @@ interface ChatViewProps {...@@ -500,10 +512,8 @@ interface ChatViewProps {
500export default function ChatView({ threadId }: ChatViewProps) {512export default function ChatView({ threadId }: ChatViewProps) {
501 const threads = useStore((store) => store.threads);513 const threads = useStore((store) => store.threads);
502 const projects = useStore((store) => store.projects);514 const projects = useStore((store) => store.projects);
503 const runtimeMode = useStore((store) => store.runtimeMode);
504 const markThreadVisited = useStore((store) => store.markThreadVisited);515 const markThreadVisited = useStore((store) => store.markThreadVisited);
505 const setStoreThreadError = useStore((store) => store.setError);516 const setStoreThreadError = useStore((store) => store.setError);
506 const setStoreRuntimeMode = useStore((store) => store.setRuntimeMode);
507 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);517 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
508 const { settings } = useAppSettings();518 const { settings } = useAppSettings();
509 const navigate = useNavigate();519 const navigate = useNavigate();
...@@ -549,7 +559,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -549,7 +559,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
549 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");559 const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
550 const [isConnecting, _setIsConnecting] = useState(false);560 const [isConnecting, _setIsConnecting] = useState(false);
551 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);561 const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false);
552 const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false);
553 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);562 const [respondingRequestIds, setRespondingRequestIds] = useState<ApprovalRequestId[]>([]);
554 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});563 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
555 const [nowTick, setNowTick] = useState(() => Date.now());564 const [nowTick, setNowTick] = useState(() => Date.now());
...@@ -642,6 +651,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -642,6 +651,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
642 [draftThread, fallbackDraftProject?.model, localDraftError, threadId],651 [draftThread, fallbackDraftProject?.model, localDraftError, threadId],
643 );652 );
644 const activeThread = serverThread ?? localDraftThread;653 const activeThread = serverThread ?? localDraftThread;
654 const runtimeMode = activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
655 const [optimisticRuntimeMode, setOptimisticRuntimeMode] = useOptimistic(runtimeMode);
656 const [isRuntimeModePending, startRuntimeModeTransition] = useTransition();
645 const isServerThread = serverThread !== undefined;657 const isServerThread = serverThread !== undefined;
646 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;658 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
647 const diffSearch = useMemo(659 const diffSearch = useMemo(
...@@ -1410,31 +1422,26 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1410,31 +1422,26 @@ export default function ChatView({ threadId }: ChatViewProps) {
14101422
1411 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {1423 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
1412 if (mode === runtimeMode) return;1424 if (mode === runtimeMode) return;
1413 setStoreRuntimeMode(mode);1425 if (isLocalDraftThread) {
1414 scheduleComposerFocus();1426 setDraftThreadContext(threadId, { runtimeMode: mode });
1427 scheduleComposerFocus();
1428 return;
1429 }
1415 const api = readNativeApi();1430 const api = readNativeApi();
1416 if (!api) return;1431 if (!api || !isServerThread) return;
14171432 scheduleComposerFocus();
1418 const runningThreadIds = threads1433 startRuntimeModeTransition(() => {
1419 .filter((thread) => thread.session !== null && thread.session.status !== "closed")1434 setOptimisticRuntimeMode(mode);
1420 .map((thread) => thread.id);1435 void api.orchestration
14211436 .dispatchCommand({
1422 if (runningThreadIds.length === 0) return;1437 type: "thread.runtime-mode.set",
14231438 commandId: newCommandId(),
1424 setIsSwitchingRuntimeMode(true);1439 threadId,
1425 await Promise.all(1440 runtimeMode: mode,
1426 runningThreadIds.map((threadId) =>1441 createdAt: new Date().toISOString(),
1427 api.orchestration1442 })
1428 .dispatchCommand({1443 .catch(() => undefined);
1429 type: "thread.session.stop",1444 });
1430 commandId: newCommandId(),
1431 threadId,
1432 createdAt: new Date().toISOString(),
1433 })
1434 .catch(() => undefined),
1435 ),
1436 );
1437 setIsSwitchingRuntimeMode(false);
1438 };1445 };
14391446
1440 useEffect(() => {1447 useEffect(() => {
...@@ -2186,6 +2193,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2186,6 +2193,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2186 projectId: activeProject.id,2193 projectId: activeProject.id,
2187 title,2194 title,
2188 model: threadCreateModel,2195 model: threadCreateModel,
2196 runtimeMode,
2189 branch: nextThreadBranch,2197 branch: nextThreadBranch,
2190 worktreePath: nextThreadWorktreePath,2198 worktreePath: nextThreadWorktreePath,
2191 createdAt: activeThread.createdAt,2199 createdAt: activeThread.createdAt,
...@@ -2941,21 +2949,23 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2941,21 +2949,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
2941 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"2949 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
2942 size="sm"2950 size="sm"
2943 type="button"2951 type="button"
2944 disabled={isSwitchingRuntimeMode}2952 disabled={isRuntimeModePending}
2945 onClick={() =>2953 onClick={() =>
2946 void handleRuntimeModeChange(2954 void handleRuntimeModeChange(
2947 runtimeMode === "full-access" ? "approval-required" : "full-access",2955 optimisticRuntimeMode === "full-access"
2956 ? "approval-required"
2957 : "full-access",
2948 )2958 )
2949 }2959 }
2950 title={2960 title={
2951 runtimeMode === "full-access"2961 optimisticRuntimeMode === "full-access"
2952 ? "Full access — click to require approvals"2962 ? "Full access — click to require approvals"
2953 : "Approval required — click for full access"2963 : "Approval required — click for full access"
2954 }2964 }
2955 >2965 >
2956 {runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}2966 {optimisticRuntimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
2957 <span className="sr-only sm:not-sr-only">2967 <span className="sr-only sm:not-sr-only">
2958 {runtimeMode === "full-access" ? "Full access" : "Supervised"}2968 {optimisticRuntimeMode === "full-access" ? "Full access" : "Supervised"}
2959 </span>2969 </span>
2960 </Button>2970 </Button>
2961 </div>2971 </div>
apps/web/src/components/Sidebar.tsx+2
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8} from "lucide-react";8} from "lucide-react";
9import { useCallback, useEffect, useMemo, useRef, useState } from "react";9import { useCallback, useEffect, useMemo, useRef, useState } from "react";
10import {10import {
11 DEFAULT_RUNTIME_MODE,
11 DEFAULT_MODEL,12 DEFAULT_MODEL,
12 type DesktopUpdateState,13 type DesktopUpdateState,
13 ProjectId,14 ProjectId,
...@@ -435,6 +436,7 @@ export default function Sidebar() {...@@ -435,6 +436,7 @@ export default function Sidebar() {
435 branch: options?.branch ?? null,436 branch: options?.branch ?? null,
436 worktreePath: options?.worktreePath ?? null,437 worktreePath: options?.worktreePath ?? null,
437 envMode: options?.envMode ?? "local",438 envMode: options?.envMode ?? "local",
439 runtimeMode: DEFAULT_RUNTIME_MODE,
438 });440 });
439441
440 await navigate({442 await navigate({
apps/web/src/composerDraftStore.ts+16-1
...@@ -6,8 +6,9 @@ import {...@@ -6,8 +6,9 @@ import {
6 normalizeModelSlug,6 normalizeModelSlug,
7 type CodexReasoningEffort,7 type CodexReasoningEffort,
8 type ProviderKind,8 type ProviderKind,
9 type RuntimeMode,
9} from "@t3tools/contracts";10} from "@t3tools/contracts";
10import type { ChatImageAttachment } from "./types";11import { DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
11import { create } from "zustand";12import { create } from "zustand";
12import { createJSONStorage, persist } from "zustand/middleware";13import { createJSONStorage, persist } from "zustand/middleware";
1314
...@@ -38,6 +39,7 @@ interface PersistedComposerThreadDraftState {...@@ -38,6 +39,7 @@ interface PersistedComposerThreadDraftState {
38interface PersistedDraftThreadState {39interface PersistedDraftThreadState {
39 projectId: ProjectId;40 projectId: ProjectId;
40 createdAt: string;41 createdAt: string;
42 runtimeMode: RuntimeMode;
41 branch: string | null;43 branch: string | null;
42 worktreePath: string | null;44 worktreePath: string | null;
43 envMode: DraftThreadEnvMode;45 envMode: DraftThreadEnvMode;
...@@ -62,6 +64,7 @@ interface ComposerThreadDraftState {...@@ -62,6 +64,7 @@ interface ComposerThreadDraftState {
62export interface DraftThreadState {64export interface DraftThreadState {
63 projectId: ProjectId;65 projectId: ProjectId;
64 createdAt: string;66 createdAt: string;
67 runtimeMode: RuntimeMode;
65 branch: string | null;68 branch: string | null;
66 worktreePath: string | null;69 worktreePath: string | null;
67 envMode: DraftThreadEnvMode;70 envMode: DraftThreadEnvMode;
...@@ -85,6 +88,7 @@ interface ComposerDraftStoreState {...@@ -85,6 +88,7 @@ interface ComposerDraftStoreState {
85 worktreePath?: string | null;88 worktreePath?: string | null;
86 createdAt?: string;89 createdAt?: string;
87 envMode?: DraftThreadEnvMode;90 envMode?: DraftThreadEnvMode;
91 runtimeMode?: RuntimeMode;
88 },92 },
89 ) => void;93 ) => void;
90 setDraftThreadContext: (94 setDraftThreadContext: (
...@@ -95,6 +99,7 @@ interface ComposerDraftStoreState {...@@ -95,6 +99,7 @@ interface ComposerDraftStoreState {
95 projectId?: ProjectId;99 projectId?: ProjectId;
96 createdAt?: string;100 createdAt?: string;
97 envMode?: DraftThreadEnvMode;101 envMode?: DraftThreadEnvMode;
102 runtimeMode?: RuntimeMode;
98 },103 },
99 ) => void;104 ) => void;
100 clearProjectDraftThreadId: (projectId: ProjectId) => void;105 clearProjectDraftThreadId: (projectId: ProjectId) => void;
...@@ -260,6 +265,11 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -260,6 +265,11 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
260 typeof createdAt === "string" && createdAt.length > 0265 typeof createdAt === "string" && createdAt.length > 0
261 ? createdAt266 ? createdAt
262 : new Date().toISOString(),267 : new Date().toISOString(),
268 runtimeMode:
269 candidateDraftThread.runtimeMode === "approval-required" ||
270 candidateDraftThread.runtimeMode === "full-access"
271 ? candidateDraftThread.runtimeMode
272 : DEFAULT_RUNTIME_MODE,
263 branch: typeof branch === "string" ? branch : null,273 branch: typeof branch === "string" ? branch : null,
264 worktreePath: normalizedWorktreePath,274 worktreePath: normalizedWorktreePath,
265 envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),275 envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath),
...@@ -286,6 +296,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -286,6 +296,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
286 draftThreadsByThreadId[threadId as ThreadId] = {296 draftThreadsByThreadId[threadId as ThreadId] = {
287 projectId: projectId as ProjectId,297 projectId: projectId as ProjectId,
288 createdAt: new Date().toISOString(),298 createdAt: new Date().toISOString(),
299 runtimeMode: DEFAULT_RUNTIME_MODE,
289 branch: null,300 branch: null,
290 worktreePath: null,301 worktreePath: null,
291 envMode: "local",302 envMode: "local",
...@@ -488,6 +499,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -488,6 +499,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
488 const nextDraftThread: DraftThreadState = {499 const nextDraftThread: DraftThreadState = {
489 projectId,500 projectId,
490 createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),501 createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(),
502 runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
491 branch:503 branch:
492 options?.branch === undefined504 options?.branch === undefined
493 ? (existingThread?.branch ?? null)505 ? (existingThread?.branch ?? null)
...@@ -502,6 +514,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -502,6 +514,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
502 existingThread &&514 existingThread &&
503 existingThread.projectId === nextDraftThread.projectId &&515 existingThread.projectId === nextDraftThread.projectId &&
504 existingThread.createdAt === nextDraftThread.createdAt &&516 existingThread.createdAt === nextDraftThread.createdAt &&
517 existingThread.runtimeMode === nextDraftThread.runtimeMode &&
505 existingThread.branch === nextDraftThread.branch &&518 existingThread.branch === nextDraftThread.branch &&
506 existingThread.worktreePath === nextDraftThread.worktreePath &&519 existingThread.worktreePath === nextDraftThread.worktreePath &&
507 existingThread.envMode === nextDraftThread.envMode;520 existingThread.envMode === nextDraftThread.envMode;
...@@ -556,6 +569,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -556,6 +569,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
556 options.createdAt === undefined569 options.createdAt === undefined
557 ? existing.createdAt570 ? existing.createdAt
558 : options.createdAt || existing.createdAt,571 : options.createdAt || existing.createdAt,
572 runtimeMode: options.runtimeMode ?? existing.runtimeMode,
559 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),573 branch: options.branch === undefined ? existing.branch : (options.branch ?? null),
560 worktreePath: nextWorktreePath,574 worktreePath: nextWorktreePath,
561 envMode:575 envMode:
...@@ -565,6 +579,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -565,6 +579,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
565 const isUnchanged =579 const isUnchanged =
566 nextDraftThread.projectId === existing.projectId &&580 nextDraftThread.projectId === existing.projectId &&
567 nextDraftThread.createdAt === existing.createdAt &&581 nextDraftThread.createdAt === existing.createdAt &&
582 nextDraftThread.runtimeMode === existing.runtimeMode &&
568 nextDraftThread.branch === existing.branch &&583 nextDraftThread.branch === existing.branch &&
569 nextDraftThread.worktreePath === existing.worktreePath &&584 nextDraftThread.worktreePath === existing.worktreePath &&
570 nextDraftThread.envMode === existing.envMode;585 nextDraftThread.envMode === existing.envMode;
apps/web/src/routes/_chat.settings.tsx-57
...@@ -8,7 +8,6 @@ import { isElectron } from "../env";...@@ -8,7 +8,6 @@ import { isElectron } from "../env";
8import { useTheme } from "../hooks/useTheme";8import { useTheme } from "../hooks/useTheme";
9import { serverConfigQueryOptions } from "../lib/serverReactQuery";9import { serverConfigQueryOptions } from "../lib/serverReactQuery";
10import { ensureNativeApi } from "../nativeApi";10import { ensureNativeApi } from "../nativeApi";
11import { useStore } from "../store";
12import { preferredTerminalEditor } from "../terminal-links";11import { preferredTerminalEditor } from "../terminal-links";
13import { Button } from "../components/ui/button";12import { Button } from "../components/ui/button";
14import { Input } from "../components/ui/input";13import { Input } from "../components/ui/input";
...@@ -33,19 +32,6 @@ const THEME_OPTIONS = [...@@ -33,19 +32,6 @@ const THEME_OPTIONS = [
33 },32 },
34] as const;33] 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
49const MODEL_PROVIDER_SETTINGS: Array<{35const MODEL_PROVIDER_SETTINGS: Array<{
50 provider: ProviderKind;36 provider: ProviderKind;
51 title: string;37 title: string;
...@@ -120,8 +106,6 @@ function patchCustomModels(provider: ProviderKind, models: string[]) {...@@ -120,8 +106,6 @@ function patchCustomModels(provider: ProviderKind, models: string[]) {
120106
121function SettingsRouteView() {107function SettingsRouteView() {
122 const { theme, setTheme, resolvedTheme } = useTheme();108 const { theme, setTheme, resolvedTheme } = useTheme();
123 const runtimeMode = useStore((store) => store.runtimeMode);
124 const setRuntimeMode = useStore((store) => store.setRuntimeMode);
125 const { settings, defaults, updateSettings } = useAppSettings();109 const { settings, defaults, updateSettings } = useAppSettings();
126 const serverConfigQuery = useQuery(serverConfigQueryOptions());110 const serverConfigQuery = useQuery(serverConfigQueryOptions());
127 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);111 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
...@@ -469,47 +453,6 @@ function SettingsRouteView() {...@@ -469,47 +453,6 @@ function SettingsRouteView() {
469 </div>453 </div>
470 </section>454 </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
513 <section className="rounded-2xl border border-border bg-card p-5">456 <section className="rounded-2xl border border-border bg-card p-5">
514 <div className="mb-4">457 <div className="mb-4">
515 <h2 className="text-sm font-medium text-foreground">Responses</h2>458 <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...@@ -2,7 +2,7 @@ import { ProjectId, ThreadId, TurnId, type OrchestrationReadModel } from "@t3too
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import { markThreadUnread, syncServerReadModel, type AppState } from "./store";4import { markThreadUnread, syncServerReadModel, type AppState } from "./store";
5import type { Thread } from "./types";5import { DEFAULT_RUNTIME_MODE, type Thread } from "./types";
66
7function makeThread(overrides: Partial<Thread> = {}): Thread {7function makeThread(overrides: Partial<Thread> = {}): Thread {
8 return {8 return {
...@@ -11,6 +11,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -11,6 +11,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
11 projectId: ProjectId.makeUnsafe("project-1"),11 projectId: ProjectId.makeUnsafe("project-1"),
12 title: "Thread",12 title: "Thread",
13 model: "gpt-5-codex",13 model: "gpt-5-codex",
14 runtimeMode: DEFAULT_RUNTIME_MODE,
14 session: null,15 session: null,
15 messages: [],16 messages: [],
16 turnDiffSummaries: [],17 turnDiffSummaries: [],
...@@ -38,7 +39,6 @@ function makeState(thread: Thread): AppState {...@@ -38,7 +39,6 @@ function makeState(thread: Thread): AppState {
38 ],39 ],
39 threads: [thread],40 threads: [thread],
40 threadsHydrated: true,41 threadsHydrated: true,
41 runtimeMode: "full-access",
42 };42 };
43}43}
4444
...@@ -48,6 +48,7 @@ function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"...@@ -48,6 +48,7 @@ function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"
48 projectId: ProjectId.makeUnsafe("project-1"),48 projectId: ProjectId.makeUnsafe("project-1"),
49 title: "Thread",49 title: "Thread",
50 model: "gpt-5.3-codex",50 model: "gpt-5.3-codex",
51 runtimeMode: DEFAULT_RUNTIME_MODE,
51 branch: null,52 branch: null,
52 worktreePath: null,53 worktreePath: null,
53 latestTurn: null,54 latestTurn: null,
apps/web/src/store.ts+11-32
...@@ -13,10 +13,8 @@ import {...@@ -13,10 +13,8 @@ import {
13} from "@t3tools/contracts";13} from "@t3tools/contracts";
14import { create } from "zustand";14import { create } from "zustand";
15import {15import {
16 DEFAULT_RUNTIME_MODE,
17 type ChatMessage,16 type ChatMessage,
18 type Project,17 type Project,
19 type RuntimeMode,
20 type Thread,18 type Thread,
21} from "./types";19} from "./types";
2220
...@@ -26,10 +24,9 @@ export interface AppState {...@@ -26,10 +24,9 @@ export interface AppState {
26 projects: Project[];24 projects: Project[];
27 threads: Thread[];25 threads: Thread[];
28 threadsHydrated: boolean;26 threadsHydrated: boolean;
29 runtimeMode: RuntimeMode;
30}27}
3128
32const PERSISTED_STATE_KEY = "t3code:renderer-state:v7";29const PERSISTED_STATE_KEY = "t3code:renderer-state:v8";
33const LEGACY_PERSISTED_STATE_KEYS = [30const LEGACY_PERSISTED_STATE_KEYS = [
34 "t3code:renderer-state:v6",31 "t3code:renderer-state:v6",
35 "t3code:renderer-state:v5",32 "t3code:renderer-state:v5",
...@@ -45,7 +42,6 @@ const initialState: AppState = {...@@ -45,7 +42,6 @@ const initialState: AppState = {
45 projects: [],42 projects: [],
46 threads: [],43 threads: [],
47 threadsHydrated: false,44 threadsHydrated: false,
48 runtimeMode: DEFAULT_RUNTIME_MODE,
49};45};
50const persistedExpandedProjectCwds = new Set<string>();46const persistedExpandedProjectCwds = new Set<string>();
5147
...@@ -56,23 +52,14 @@ function readPersistedState(): AppState {...@@ -56,23 +52,14 @@ function readPersistedState(): AppState {
56 try {52 try {
57 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);53 const raw = window.localStorage.getItem(PERSISTED_STATE_KEY);
58 if (!raw) return initialState;54 if (!raw) return initialState;
59 const parsed = JSON.parse(raw) as {55 const parsed = JSON.parse(raw) as { expandedProjectCwds?: string[] };
60 runtimeMode?: RuntimeMode;
61 expandedProjectCwds?: string[];
62 };
63 persistedExpandedProjectCwds.clear();56 persistedExpandedProjectCwds.clear();
64 for (const cwd of parsed.expandedProjectCwds ?? []) {57 for (const cwd of parsed.expandedProjectCwds ?? []) {
65 if (typeof cwd === "string" && cwd.length > 0) {58 if (typeof cwd === "string" && cwd.length > 0) {
66 persistedExpandedProjectCwds.add(cwd);59 persistedExpandedProjectCwds.add(cwd);
67 }60 }
68 }61 }
69 return {62 return { ...initialState };
70 ...initialState,
71 runtimeMode:
72 parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access"
73 ? parsed.runtimeMode
74 : DEFAULT_RUNTIME_MODE,
75 };
76 } catch {63 } catch {
77 return initialState;64 return initialState;
78 }65 }
...@@ -84,7 +71,6 @@ function persistState(state: AppState): void {...@@ -84,7 +71,6 @@ function persistState(state: AppState): void {
84 window.localStorage.setItem(71 window.localStorage.setItem(
85 PERSISTED_STATE_KEY,72 PERSISTED_STATE_KEY,
86 JSON.stringify({73 JSON.stringify({
87 runtimeMode: state.runtimeMode,
88 expandedProjectCwds: state.projects74 expandedProjectCwds: state.projects
89 .filter((project) => project.expanded)75 .filter((project) => project.expanded)
90 .map((project) => project.cwd),76 .map((project) => project.cwd),
...@@ -265,12 +251,13 @@ export function syncServerReadModel(...@@ -265,12 +251,13 @@ export function syncServerReadModel(
265 projectId: thread.projectId,251 projectId: thread.projectId,
266 title: thread.title,252 title: thread.title,
267 model: resolveModelSlugForProvider(253 model: resolveModelSlugForProvider(
268 inferProviderForThreadModel({254 inferProviderForThreadModel({
269 model: thread.model,255 model: thread.model,
270 sessionProviderName: thread.session?.providerName ?? null,256 sessionProviderName: thread.session?.providerName ?? null,
271 }),257 }),
272 thread.model,258 thread.model,
273 ),259 ),
260 runtimeMode: thread.runtimeMode,
274 session: thread.session261 session: thread.session
275 ? {262 ? {
276 sessionId:263 sessionId:
...@@ -415,11 +402,6 @@ export function setThreadBranch(...@@ -415,11 +402,6 @@ export function setThreadBranch(
415 return threads === state.threads ? state : { ...state, threads };402 return threads === state.threads ? state : { ...state, threads };
416}403}
417404
418export function setRuntimeMode(state: AppState, mode: RuntimeMode): AppState {
419 if (state.runtimeMode === mode) return state;
420 return { ...state, runtimeMode: mode };
421}
422
423// ── Zustand store ────────────────────────────────────────────────────405// ── Zustand store ────────────────────────────────────────────────────
424406
425interface AppStore extends AppState {407interface AppStore extends AppState {
...@@ -434,7 +416,6 @@ interface AppStore extends AppState {...@@ -434,7 +416,6 @@ interface AppStore extends AppState {
434 branch: string | null,416 branch: string | null,
435 worktreePath: string | null,417 worktreePath: string | null,
436 ) => void;418 ) => void;
437 setRuntimeMode: (mode: RuntimeMode) => void;
438}419}
439420
440export const useStore = create<AppStore>((set) => ({421export const useStore = create<AppStore>((set) => ({
...@@ -453,11 +434,9 @@ export const useStore = create<AppStore>((set) => ({...@@ -453,11 +434,9 @@ export const useStore = create<AppStore>((set) => ({
453 set((state) => setError(state, threadId, error)),434 set((state) => setError(state, threadId, error)),
454 setThreadBranch: (threadId, branch, worktreePath) =>435 setThreadBranch: (threadId, branch, worktreePath) =>
455 set((state) => setThreadBranch(state, threadId, branch, worktreePath)),436 set((state) => setThreadBranch(state, threadId, branch, worktreePath)),
456 setRuntimeMode: (mode) =>
457 set((state) => setRuntimeMode(state, mode)),
458}));437}));
459438
460// Persist on every state change (only runtimeMode + expandedProjectCwds)439// Persist on every state change
461useStore.subscribe((state) => persistState(state));440useStore.subscribe((state) => persistState(state));
462441
463export function StoreProvider({ children }: { children: ReactNode }) {442export function StoreProvider({ children }: { children: ReactNode }) {
apps/web/src/types.ts+1
...@@ -79,6 +79,7 @@ export interface Thread {...@@ -79,6 +79,7 @@ export interface Thread {
79 projectId: ProjectId;79 projectId: ProjectId;
80 title: string;80 title: string;
81 model: string;81 model: string;
82 runtimeMode: RuntimeMode;
82 session: ThreadSession | null;83 session: ThreadSession | null;
83 messages: ChatMessage[];84 messages: ChatMessage[];
84 error: string | null;85 error: string | null;
apps/web/src/worktreeCleanup.test.ts+2-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";1import { ProjectId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import type { Thread } from "./types";4import { DEFAULT_RUNTIME_MODE, type Thread } from "./types";
5import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup";5import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup";
66
7function makeThread(overrides: Partial<Thread> = {}): Thread {7function makeThread(overrides: Partial<Thread> = {}): Thread {
...@@ -11,6 +11,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -11,6 +11,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
11 projectId: ProjectId.makeUnsafe("project-1"),11 projectId: ProjectId.makeUnsafe("project-1"),
12 title: "Thread",12 title: "Thread",
13 model: "gpt-5.3-codex",13 model: "gpt-5.3-codex",
14 runtimeMode: DEFAULT_RUNTIME_MODE,
14 session: null,15 session: null,
15 messages: [],16 messages: [],
16 turnDiffSummaries: [],17 turnDiffSummaries: [],
packages/contracts/src/orchestration.ts+30
...@@ -223,6 +223,7 @@ export const OrchestrationThread = Schema.Struct({...@@ -223,6 +223,7 @@ export const OrchestrationThread = Schema.Struct({
223 projectId: ProjectId,223 projectId: ProjectId,
224 title: TrimmedNonEmptyString,224 title: TrimmedNonEmptyString,
225 model: TrimmedNonEmptyString,225 model: TrimmedNonEmptyString,
226 runtimeMode: RuntimeMode,
226 branch: Schema.NullOr(TrimmedNonEmptyString),227 branch: Schema.NullOr(TrimmedNonEmptyString),
227 worktreePath: Schema.NullOr(TrimmedNonEmptyString),228 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
228 latestTurn: Schema.NullOr(OrchestrationLatestTurn),229 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
...@@ -277,6 +278,7 @@ export const ThreadCreateCommand = Schema.Struct({...@@ -277,6 +278,7 @@ export const ThreadCreateCommand = Schema.Struct({
277 projectId: ProjectId,278 projectId: ProjectId,
278 title: TrimmedNonEmptyString,279 title: TrimmedNonEmptyString,
279 model: TrimmedNonEmptyString,280 model: TrimmedNonEmptyString,
281 runtimeMode: RuntimeMode,
280 branch: Schema.NullOr(TrimmedNonEmptyString),282 branch: Schema.NullOr(TrimmedNonEmptyString),
281 worktreePath: Schema.NullOr(TrimmedNonEmptyString),283 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
282 createdAt: IsoDateTime,284 createdAt: IsoDateTime,
...@@ -298,6 +300,14 @@ export const ThreadMetaUpdateCommand = Schema.Struct({...@@ -298,6 +300,14 @@ export const ThreadMetaUpdateCommand = Schema.Struct({
298 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),300 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
299});301});
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
301export const ThreadTurnStartCommand = Schema.Struct({311export const ThreadTurnStartCommand = Schema.Struct({
302 type: Schema.Literal("thread.turn.start"),312 type: Schema.Literal("thread.turn.start"),
303 commandId: CommandId,313 commandId: CommandId,
...@@ -373,6 +383,7 @@ export const DispatchableClientOrchestrationCommand = Schema.Union([...@@ -373,6 +383,7 @@ export const DispatchableClientOrchestrationCommand = Schema.Union([
373 ThreadCreateCommand,383 ThreadCreateCommand,
374 ThreadDeleteCommand,384 ThreadDeleteCommand,
375 ThreadMetaUpdateCommand,385 ThreadMetaUpdateCommand,
386 ThreadRuntimeModeSetCommand,
376 ThreadTurnStartCommand,387 ThreadTurnStartCommand,
377 ThreadTurnInterruptCommand,388 ThreadTurnInterruptCommand,
378 ThreadApprovalRespondCommand,389 ThreadApprovalRespondCommand,
...@@ -389,6 +400,7 @@ export const ClientOrchestrationCommand = Schema.Union([...@@ -389,6 +400,7 @@ export const ClientOrchestrationCommand = Schema.Union([
389 ThreadCreateCommand,400 ThreadCreateCommand,
390 ThreadDeleteCommand,401 ThreadDeleteCommand,
391 ThreadMetaUpdateCommand,402 ThreadMetaUpdateCommand,
403 ThreadRuntimeModeSetCommand,
392 ClientThreadTurnStartCommand,404 ClientThreadTurnStartCommand,
393 ThreadTurnInterruptCommand,405 ThreadTurnInterruptCommand,
394 ThreadApprovalRespondCommand,406 ThreadApprovalRespondCommand,
...@@ -477,6 +489,7 @@ export const OrchestrationEventType = Schema.Literals([...@@ -477,6 +489,7 @@ export const OrchestrationEventType = Schema.Literals([
477 "thread.created",489 "thread.created",
478 "thread.deleted",490 "thread.deleted",
479 "thread.meta-updated",491 "thread.meta-updated",
492 "thread.runtime-mode-set",
480 "thread.message-sent",493 "thread.message-sent",
481 "thread.turn-start-requested",494 "thread.turn-start-requested",
482 "thread.turn-interrupt-requested",495 "thread.turn-interrupt-requested",
...@@ -523,6 +536,7 @@ export const ThreadCreatedPayload = Schema.Struct({...@@ -523,6 +536,7 @@ export const ThreadCreatedPayload = Schema.Struct({
523 projectId: ProjectId,536 projectId: ProjectId,
524 title: TrimmedNonEmptyString,537 title: TrimmedNonEmptyString,
525 model: TrimmedNonEmptyString,538 model: TrimmedNonEmptyString,
539 runtimeMode: RuntimeMode,
526 branch: Schema.NullOr(TrimmedNonEmptyString),540 branch: Schema.NullOr(TrimmedNonEmptyString),
527 worktreePath: Schema.NullOr(TrimmedNonEmptyString),541 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
528 createdAt: IsoDateTime,542 createdAt: IsoDateTime,
...@@ -543,6 +557,12 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({...@@ -543,6 +557,12 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({
543 updatedAt: IsoDateTime,557 updatedAt: IsoDateTime,
544});558});
545559
560export const ThreadRuntimeModeSetPayload = Schema.Struct({
561 threadId: ThreadId,
562 runtimeMode: RuntimeMode,
563 updatedAt: IsoDateTime,
564});
565
546export const ThreadMessageSentPayload = Schema.Struct({566export const ThreadMessageSentPayload = Schema.Struct({
547 threadId: ThreadId,567 threadId: ThreadId,
548 messageId: MessageId,568 messageId: MessageId,
...@@ -684,6 +704,11 @@ export const OrchestrationEvent = Schema.Union([...@@ -684,6 +704,11 @@ export const OrchestrationEvent = Schema.Union([
684 type: Schema.Literal("thread.meta-updated"),704 type: Schema.Literal("thread.meta-updated"),
685 payload: ThreadMetaUpdatedPayload,705 payload: ThreadMetaUpdatedPayload,
686 }),706 }),
707 Schema.Struct({
708 ...EventBaseFields,
709 type: Schema.Literal("thread.runtime-mode-set"),
710 payload: ThreadRuntimeModeSetPayload,
711 }),
687 Schema.Struct({712 Schema.Struct({
688 ...EventBaseFields,713 ...EventBaseFields,
689 type: Schema.Literal("thread.message-sent"),714 type: Schema.Literal("thread.message-sent"),
...@@ -768,6 +793,11 @@ export const OrchestrationPersistedEvent = Schema.Union([...@@ -768,6 +793,11 @@ export const OrchestrationPersistedEvent = Schema.Union([
768 eventType: Schema.Literal("thread.meta-updated"),793 eventType: Schema.Literal("thread.meta-updated"),
769 payload: ThreadMetaUpdatedPayload,794 payload: ThreadMetaUpdatedPayload,
770 }),795 }),
796 Schema.Struct({
797 ...PersistedEventBaseFields,
798 eventType: Schema.Literal("thread.runtime-mode-set"),
799 payload: ThreadRuntimeModeSetPayload,
800 }),
771 Schema.Struct({801 Schema.Struct({
772 ...PersistedEventBaseFields,802 ...PersistedEventBaseFields,
773 eventType: Schema.Literal("thread.message-sent"),803 eventType: Schema.Literal("thread.message-sent"),
packages/contracts/src/provider.test.ts+10-1
...@@ -11,6 +11,7 @@ describe("ProviderSessionStartInput", () => {...@@ -11,6 +11,7 @@ describe("ProviderSessionStartInput", () => {
11 provider: "codex",11 provider: "codex",
12 cwd: "/tmp/workspace",12 cwd: "/tmp/workspace",
13 model: "gpt-5.3-codex",13 model: "gpt-5.3-codex",
14 runtimeMode: "full-access",
14 providerOptions: {15 providerOptions: {
15 codex: {16 codex: {
16 binaryPath: "/usr/local/bin/codex",17 binaryPath: "/usr/local/bin/codex",
...@@ -18,11 +19,19 @@ describe("ProviderSessionStartInput", () => {...@@ -18,11 +19,19 @@ describe("ProviderSessionStartInput", () => {
18 },19 },
19 },20 },
20 });21 });
21 expect(parsed.runtimeMode).toBeUndefined();22 expect(parsed.runtimeMode).toBe("full-access");
22 expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex");23 expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex");
23 expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex");24 expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex");
24 });25 });
2526
27 it("rejects payloads without runtime mode", () => {
28 expect(() =>
29 decodeProviderSessionStartInput({
30 provider: "codex",
31 }),
32 ).toThrow();
33 });
34
26 it("accepts claude runtime knobs", () => {35 it("accepts claude runtime knobs", () => {
27 const parsed = decodeProviderSessionStartInput({36 const parsed = decodeProviderSessionStartInput({
28 provider: "claudeCode",37 provider: "claudeCode",
packages/contracts/src/provider.ts+2-1
...@@ -37,6 +37,7 @@ export const ProviderSession = Schema.Struct({...@@ -37,6 +37,7 @@ export const ProviderSession = Schema.Struct({
37 sessionId: ProviderSessionId,37 sessionId: ProviderSessionId,
38 provider: ProviderKind,38 provider: ProviderKind,
39 status: ProviderSessionStatus,39 status: ProviderSessionStatus,
40 runtimeMode: RuntimeMode,
40 cwd: Schema.optional(TrimmedNonEmptyStringSchema),41 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
41 model: Schema.optional(TrimmedNonEmptyStringSchema),42 model: Schema.optional(TrimmedNonEmptyStringSchema),
42 threadId: Schema.optional(ProviderThreadId),43 threadId: Schema.optional(ProviderThreadId),
...@@ -79,7 +80,7 @@ export const ProviderSessionStartInput = Schema.Struct({...@@ -79,7 +80,7 @@ export const ProviderSessionStartInput = Schema.Struct({
79 model: Schema.optional(TrimmedNonEmptyStringSchema),80 model: Schema.optional(TrimmedNonEmptyStringSchema),
80 resumeCursor: Schema.optional(Schema.Unknown),81 resumeCursor: Schema.optional(Schema.Unknown),
81 providerOptions: Schema.optional(ProviderStartOptions),82 providerOptions: Schema.optional(ProviderStartOptions),
82 runtimeMode: Schema.optional(RuntimeMode),83 runtimeMode: RuntimeMode,
83});84});
84export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type;85export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type;
8586