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