| author | |
| committer | |
| log | 689dda59e997337103c76ed93af0574dcc554d26 |
| tree | 449afaae49ff8f37ca9f611396abf186a11b70a9 |
| parent | 0a67f9dd39d9df21dcbd56f28c7c56fa5358282e |
- 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"; |
| 37 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; | 37 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; |
| 38 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; | 38 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; |
| 39 | import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; | 39 | import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; |
| 40 | import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts"; | ||
| 41 | import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; | ||
| 40 | import { ProviderService } from "../src/provider/Services/ProviderService.ts"; | 42 | import { ProviderService } from "../src/provider/Services/ProviderService.ts"; |
| 41 | import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; | 43 | import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; |
| 42 | import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; | 44 | import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; |
| ... | @@ -187,6 +189,7 @@ export interface OrchestrationIntegrationHarness { | ... | @@ -187,6 +189,7 @@ export interface OrchestrationIntegrationHarness { |
| 187 | 189 | ||
| 188 | interface MakeOrchestrationIntegrationHarnessOptions { | 190 | interface MakeOrchestrationIntegrationHarnessOptions { |
| 189 | readonly provider?: "codex" | "claudeCode"; | 191 | readonly provider?: "codex" | "claudeCode"; |
| 192 | readonly realCodex?: boolean; | ||
| 190 | } | 193 | } |
| 191 | 194 | ||
| 192 | export const makeOrchestrationIntegrationHarness = ( | 195 | export 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 |
| 201 | 204 | : yield* makeTestProviderAdapterHarness({ | |
| 205 | provider, | ||
| 206 | }); | ||
| 207 | const fakeRegistry = adapterHarness | ||
| 208 | ? Layer.succeed(ProviderAdapterRegistry, { | ||
| 209 | getByProvider: (resolvedProvider) => | ||
| 210 | resolvedProvider === adapterHarness.provider | ||
| 211 | ? Effect.succeed(adapterHarness.adapter) | ||
| 212 | : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })), | ||
| 213 | listProviders: () => Effect.succeed([adapterHarness.provider]), | ||
| 214 | } as typeof ProviderAdapterRegistry.Service) | ||
| 215 | : null; | ||
| 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); |
| 209 | 223 | ||
| 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 | ); | ||
| 231 | 260 | ||
| 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 | } |
| 99 | 99 | ||
| 100 | function 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 | |||
| 100 | const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => | 110 | const 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 | ); |
| 218 | 229 | ||
| 230 | it.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 | |||
| 219 | it.live("runs multi-turn file edits and persists checkpoint diffs", () => | 321 | it.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 @@ |
| 1 | import { describe, expect, it, vi } from "vitest"; | 1 | import { describe, expect, it, vi } from "vitest"; |
| 2 | import { randomUUID } from "node:crypto"; | ||
| 3 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | ||
| 4 | import os from "node:os"; | ||
| 5 | import path from "node:path"; | ||
| 2 | import { ProviderSessionId } from "@t3tools/contracts"; | 6 | import { ProviderSessionId } from "@t3tools/contracts"; |
| 3 | 7 | ||
| 4 | import { | 8 | import { |
| ... | @@ -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 | |||
| 356 | describe.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 | } |
| 427 | 428 | ||
| 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 | }); |
| 499 | 507 | ||
| 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(); |
| 503 | 511 | ||
| ... | @@ -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); |
| 522 | 530 | ||
| 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 | ); |
| 538 | 559 | ||
| 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); |
| 542 | 561 | ||
| 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", () => { |
| 642 | 661 | ||
| 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 | } |
| 208 | 208 | ||
| 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* () { |
| 260 | 259 | ||
| 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; |
| 24 | 25 | ||
| 25 | export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; | 26 | export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; |
| 26 | export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; | 27 | export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; |
| 28 | export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; | ||
| 27 | export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; | 29 | export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; |
| 28 | 30 | ||
| 29 | export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; | 31 | export 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 | } |
| 215 | 216 | ||
| 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 | }); |
| 211 | 214 | ||
| 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 | ); |
| 291 | 293 | ||
| 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"; |
| 20 | import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts"; | 20 | import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts"; |
| 21 | import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts"; | 21 | import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts"; |
| 22 | import Migration0008 from "./Migrations/008_ProjectionThreadActivitySequence.ts"; | 22 | import Migration0008 from "./Migrations/008_ProjectionThreadActivitySequence.ts"; |
| 23 | import Migration0009 from "./Migrations/009_ProviderSessionRuntimeMode.ts"; | ||
| 24 | import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts"; | ||
| 25 | import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts"; | ||
| 26 | import { Effect } from "effect"; | ||
| 23 | 27 | ||
| 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 | }); |
| 44 | 51 | ||
| 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 migrations | 66 | * @returns Effect containing array of executed migrations |
| 60 | */ | 67 | */ |
| 61 | export const runMigrations = run({ loader }); | 68 | export const runMigrations = Effect.gen(function* () { |
| 69 | yield* Effect.log("Running migrations..."); | ||
| 70 | yield* run({ loader }); | ||
| 71 | yield* Effect.log("Migrations ran successfully"); | ||
| 72 | }); | ||
| 62 | 73 | ||
| 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 @@ | ||
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | |||
| 4 | export 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 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 3 | |||
| 4 | export 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 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 3 | |||
| 4 | export 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 ProjectionThreadRepository | 7 | * @module ProjectionThreadRepository |
| 8 | */ | 8 | */ |
| 9 | import { IsoDateTime, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; | 9 | import { IsoDateTime, ProjectId, RuntimeMode, ThreadId, TurnId } from "@t3tools/contracts"; |
| 10 | import { Option, Schema, ServiceMap } from "effect"; | 10 | import { Option, Schema, ServiceMap } from "effect"; |
| 11 | import type { Effect } from "effect"; | 11 | import type { Effect } from "effect"; |
| 12 | 12 | ||
| ... | @@ -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"; |
| 15 | import { Option, Schema, ServiceMap } from "effect"; | 16 | import { 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); | ||
| 170 | 172 | ||
| 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 | }); |
| 244 | 247 | ||
| 245 | const turn = yield* adapter.sendTurn({ | 248 | const turn = yield* adapter.sendTurn({ |
| ... | @@ -374,6 +377,7 @@ describe("ClaudeCodeAdapterLive", () => { | ... | @@ -374,6 +377,7 @@ describe("ClaudeCodeAdapterLive", () => { |
| 374 | 377 | ||
| 375 | const session = yield* adapter.startSession({ | 378 | const session = yield* adapter.startSession({ |
| 376 | provider: "claudeCode", | 379 | provider: "claudeCode", |
| 380 | runtimeMode: "full-access", | ||
| 377 | }); | 381 | }); |
| 378 | 382 | ||
| 379 | const turn = yield* adapter.sendTurn({ | 383 | const turn = yield* adapter.sendTurn({ |
| ... | @@ -461,6 +465,7 @@ describe("ClaudeCodeAdapterLive", () => { | ... | @@ -461,6 +465,7 @@ describe("ClaudeCodeAdapterLive", () => { |
| 461 | 465 | ||
| 462 | const session = yield* adapter.startSession({ | 466 | const session = yield* adapter.startSession({ |
| 463 | provider: "claudeCode", | 467 | provider: "claudeCode", |
| 468 | runtimeMode: "full-access", | ||
| 464 | }); | 469 | }); |
| 465 | 470 | ||
| 466 | const turn = yield* adapter.sendTurn({ | 471 | const turn = yield* adapter.sendTurn({ |
| ... | @@ -529,6 +534,7 @@ describe("ClaudeCodeAdapterLive", () => { | ... | @@ -529,6 +534,7 @@ describe("ClaudeCodeAdapterLive", () => { |
| 529 | 534 | ||
| 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); |
| 534 | 540 | ||
| ... | @@ -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 | }); |
| 683 | 690 | ||
| 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", () => { |
| 705 | 712 | ||
| 706 | const session = yield* adapter.startSession({ | 713 | const session = yield* adapter.startSession({ |
| 707 | provider: "claudeCode", | 714 | provider: "claudeCode", |
| 715 | runtimeMode: "full-access", | ||
| 708 | }); | 716 | }); |
| 709 | 717 | ||
| 710 | assert.equal( | 718 | assert.equal( |
| ... | @@ -727,6 +735,7 @@ describe("ClaudeCodeAdapterLive", () => { | ... | @@ -727,6 +735,7 @@ describe("ClaudeCodeAdapterLive", () => { |
| 727 | 735 | ||
| 728 | const session = yield* adapter.startSession({ | 736 | const session = yield* adapter.startSession({ |
| 729 | provider: "claudeCode", | 737 | provider: "claudeCode", |
| 738 | runtimeMode: "full-access", | ||
| 730 | }); | 739 | }); |
| 731 | 740 | ||
| 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; |
| 804 | 813 | ||
| 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", () => { |
| 841 | 853 | ||
| 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* adapter | 149 | 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); |
| 153 | 155 |
apps/server/src/provider/Layers/CursorAdapter.test.ts+10-1| ... | @@ -255,7 +255,9 @@ describe("CursorAdapterLive", () => { | ... | @@ -255,7 +255,9 @@ describe("CursorAdapterLive", () => { |
| 255 | 255 | ||
| 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); | ||
| 259 | 261 | ||
| 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 | }); |
| 293 | 296 | ||
| 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 | }); |
| 357 | 361 | ||
| 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 | }); |
| 389 | 394 | ||
| 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 | }); |
| 412 | 418 | ||
| 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 | }); |
| 445 | 452 | ||
| 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 | }); |
| 559 | 567 | ||
| 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", () => { |
| 600 | 608 | ||
| 601 | const session = yield* adapter.startSession({ | 609 | const session = yield* adapter.startSession({ |
| 602 | provider: "cursor", | 610 | provider: "cursor", |
| 611 | runtimeMode: "full-access", | ||
| 603 | }); | 612 | }); |
| 604 | 613 | ||
| 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) { |
| 1048 | 1049 | ||
| 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)); |
| 355 | 357 | ||
| ... | @@ -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"); |
| 434 | 437 | ||
| ... | @@ -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 | }); |
| 485 | 489 | ||
| 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 | }); |
| 544 | 550 | ||
| 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) => { |
| 577 | 583 | ||
| 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 | }); |
| 584 | 592 | ||
| 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) => { |
| 596 | 604 | ||
| 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 | }); |
| 651 | 661 | ||
| 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 | }); |
| 688 | 699 | ||
| 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 | }); |
| 744 | 756 | ||
| 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 | }); |
| 859 | 873 | ||
| 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 !== undefined | 113 | binding.providerThreadId !== undefined |
| 113 | ? binding.providerThreadId | 114 | ? 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"; |
| 17 | import { Option, ServiceMap } from "effect"; | 18 | import { 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 | } |
| 36 | 38 | ||
| 37 | export type ProviderSessionDirectoryReadError = | 39 | export 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"; |
| 34 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | 34 | import { |
| 35 | memo, | ||
| 36 | useCallback, | ||
| 37 | useEffect, | ||
| 38 | useLayoutEffect, | ||
| 39 | useMemo, | ||
| 40 | useOptimistic, | ||
| 41 | useRef, | ||
| 42 | useState, | ||
| 43 | useTransition, | ||
| 44 | } from "react"; | ||
| 35 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 45 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 36 | import { useDebouncedValue } from "@tanstack/react-pacer"; | 46 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 37 | import { useNavigate, useSearch } from "@tanstack/react-router"; | 47 | import { 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 ".. |
| 69 | import { useStore } from "../store"; | 79 | import { useStore } from "../store"; |
| 70 | import { truncateTitle } from "../truncateTitle"; | 80 | import { truncateTitle } from "../truncateTitle"; |
| 71 | import { | 81 | import { |
| 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 { |
| 500 | export default function ChatView({ threadId }: ChatViewProps) { | 512 | export 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) { |
| 1410 | 1422 | ||
| 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; |
| 1417 | 1432 | scheduleComposerFocus(); | |
| 1418 | const runningThreadIds = threads | 1433 | startRuntimeModeTransition(() => { |
| 1419 | .filter((thread) => thread.session !== null && thread.session.status !== "closed") | 1434 | setOptimisticRuntimeMode(mode); |
| 1420 | .map((thread) => thread.id); | 1435 | void api.orchestration |
| 1421 | 1436 | .dispatchCommand({ | |
| 1422 | if (runningThreadIds.length === 0) return; | 1437 | type: "thread.runtime-mode.set", |
| 1423 | 1438 | 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.orchestration | 1442 | }) |
| 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 | }; |
| 1439 | 1446 | ||
| 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"; |
| 9 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 9 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 10 | import { | 10 | import { |
| 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 | }); |
| 439 | 441 | ||
| 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"; |
| 10 | import type { ChatImageAttachment } from "./types"; | 11 | import { DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; |
| 11 | import { create } from "zustand"; | 12 | import { create } from "zustand"; |
| 12 | import { createJSONStorage, persist } from "zustand/middleware"; | 13 | import { createJSONStorage, persist } from "zustand/middleware"; |
| 13 | 14 | ||
| ... | @@ -38,6 +39,7 @@ interface PersistedComposerThreadDraftState { | ... | @@ -38,6 +39,7 @@ interface PersistedComposerThreadDraftState { |
| 38 | interface PersistedDraftThreadState { | 39 | interface 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 { |
| 62 | export interface DraftThreadState { | 64 | export 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 > 0 | 265 | typeof createdAt === "string" && createdAt.length > 0 |
| 261 | ? createdAt | 266 | ? 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 === undefined | 504 | 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 === undefined | 569 | options.createdAt === undefined |
| 557 | ? existing.createdAt | 570 | ? 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"; |
| 8 | import { useTheme } from "../hooks/useTheme"; | 8 | import { useTheme } from "../hooks/useTheme"; |
| 9 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 9 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 10 | import { ensureNativeApi } from "../nativeApi"; | 10 | import { ensureNativeApi } from "../nativeApi"; |
| 11 | import { useStore } from "../store"; | ||
| 12 | import { preferredTerminalEditor } from "../terminal-links"; | 11 | import { preferredTerminalEditor } from "../terminal-links"; |
| 13 | import { Button } from "../components/ui/button"; | 12 | import { Button } from "../components/ui/button"; |
| 14 | import { Input } from "../components/ui/input"; | 13 | import { 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; |
| 35 | 34 | ||
| 36 | const 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 | |||
| 49 | const MODEL_PROVIDER_SETTINGS: Array<{ | 35 | const 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[]) { |
| 120 | 106 | ||
| 121 | function SettingsRouteView() { | 107 | function 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> |
| 471 | 455 | ||
| 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 |
| 2 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | ||
| 4 | import { markThreadUnread, syncServerReadModel, type AppState } from "./store"; | 4 | import { markThreadUnread, syncServerReadModel, type AppState } from "./store"; |
| 5 | import type { Thread } from "./types"; | 5 | import { DEFAULT_RUNTIME_MODE, type Thread } from "./types"; |
| 6 | 6 | ||
| 7 | function makeThread(overrides: Partial<Thread> = {}): Thread { | 7 | function 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 | } |
| 44 | 44 | ||
| ... | @@ -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"; |
| 14 | import { create } from "zustand"; | 14 | import { create } from "zustand"; |
| 15 | import { | 15 | import { |
| 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"; |
| 22 | 20 | ||
| ... | @@ -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 | } |
| 31 | 28 | ||
| 32 | const PERSISTED_STATE_KEY = "t3code:renderer-state:v7"; | 29 | const PERSISTED_STATE_KEY = "t3code:renderer-state:v8"; |
| 33 | const LEGACY_PERSISTED_STATE_KEYS = [ | 30 | const 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 | }; |
| 50 | const persistedExpandedProjectCwds = new Set<string>(); | 46 | const persistedExpandedProjectCwds = new Set<string>(); |
| 51 | 47 | ||
| ... | @@ -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.projects | 74 | 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.session | 261 | 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 | } |
| 417 | 404 | ||
| 418 | export 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 ──────────────────────────────────────────────────── |
| 424 | 406 | ||
| 425 | interface AppStore extends AppState { | 407 | interface 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 | } |
| 439 | 420 | ||
| 440 | export const useStore = create<AppStore>((set) => ({ | 421 | export 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 | })); |
| 459 | 438 | ||
| 460 | // Persist on every state change (only runtimeMode + expandedProjectCwds) | 439 | // Persist on every state change |
| 461 | useStore.subscribe((state) => persistState(state)); | 440 | useStore.subscribe((state) => persistState(state)); |
| 462 | 441 | ||
| 463 | export function StoreProvider({ children }: { children: ReactNode }) { | 442 | export 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 @@ |
| 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | ||
| 4 | import type { Thread } from "./types"; | 4 | import { DEFAULT_RUNTIME_MODE, type Thread } from "./types"; |
| 5 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; | 5 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; |
| 6 | 6 | ||
| 7 | function makeThread(overrides: Partial<Thread> = {}): Thread { | 7 | function 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 | }); |
| 300 | 302 | ||
| 303 | export 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 | |||
| 301 | export const ThreadTurnStartCommand = Schema.Struct({ | 311 | export 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 | }); |
| 545 | 559 | ||
| 560 | export const ThreadRuntimeModeSetPayload = Schema.Struct({ | ||
| 561 | threadId: ThreadId, | ||
| 562 | runtimeMode: RuntimeMode, | ||
| 563 | updatedAt: IsoDateTime, | ||
| 564 | }); | ||
| 565 | |||
| 546 | export const ThreadMessageSentPayload = Schema.Struct({ | 566 | export 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 | }); |
| 25 | 26 | ||
| 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 | }); |
| 84 | export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; | 85 | export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type; |
| 85 | 86 |