diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index bec539b2661804954976dd2a09567de382a32a27..26f89a91fac896a5250a2173857fe9524a239e27 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -37,6 +37,8 @@ import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; +import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts"; +import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; import { ProviderService } from "../src/provider/Services/ProviderService.ts"; import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; @@ -187,6 +189,7 @@ export interface OrchestrationIntegrationHarness { interface MakeOrchestrationIntegrationHarnessOptions { readonly provider?: "codex" | "claudeCode"; + readonly realCodex?: boolean; } export const makeOrchestrationIntegrationHarness = ( @@ -195,10 +198,21 @@ export const makeOrchestrationIntegrationHarness = ( Effect.gen(function* () { const sleep = (ms: number) => Effect.sleep(ms); const provider = options?.provider ?? "codex"; - const adapterHarness = yield* makeTestProviderAdapterHarness({ - provider, - }); - + const useRealCodex = options?.realCodex === true; + const adapterHarness = useRealCodex + ? null + : yield* makeTestProviderAdapterHarness({ + provider, + }); + const fakeRegistry = adapterHarness + ? Layer.succeed(ProviderAdapterRegistry, { + getByProvider: (resolvedProvider) => + resolvedProvider === adapterHarness.provider + ? Effect.succeed(adapterHarness.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })), + listProviders: () => Effect.succeed([adapterHarness.provider]), + } as typeof ProviderAdapterRegistry.Service) + : null; const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-")); const workspaceDir = path.join(rootDir, "workspace"); const stateDir = path.join(rootDir, "state"); @@ -207,14 +221,6 @@ export const makeOrchestrationIntegrationHarness = ( fs.mkdirSync(stateDir, { recursive: true }); initializeGitWorkspace(workspaceDir); - const registry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === adapterHarness.provider - ? Effect.succeed(adapterHarness.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed([adapterHarness.provider]), - }; - const persistenceLayer = makeSqlitePersistenceLive(dbPath); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), @@ -224,10 +230,33 @@ export const makeOrchestrationIntegrationHarness = ( const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(ProviderSessionRuntimeRepositoryLive), ); - const providerLayer = makeProviderServiceLive().pipe( - Layer.provide(providerSessionDirectoryLayer), - Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), + const realCodexRegistry = Layer.effect( + ProviderAdapterRegistry, + Effect.gen(function* () { + const codexAdapter = yield* CodexAdapter; + return { + getByProvider: (resolvedProvider) => + resolvedProvider === "codex" + ? Effect.succeed(codexAdapter) + : Effect.fail(new ProviderUnsupportedError({ provider: resolvedProvider })), + listProviders: () => Effect.succeed(["codex"] as const), + } as typeof ProviderAdapterRegistry.Service; + }), + ).pipe( + Layer.provide(makeCodexAdapterLive()), + Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(providerSessionDirectoryLayer), ); + const providerLayer = useRealCodex + ? makeProviderServiceLive().pipe( + Layer.provide(providerSessionDirectoryLayer), + Layer.provide(realCodexRegistry), + ) + : makeProviderServiceLive().pipe( + Layer.provide(providerSessionDirectoryLayer), + Layer.provide(fakeRegistry!), + ); const runtimeServicesLayer = Layer.mergeAll( orchestrationLayer, @@ -407,8 +436,8 @@ export const makeOrchestrationIntegrationHarness = ( return { rootDir, workspaceDir, - dbPath, - adapterHarness, + dbPath, + adapterHarness: adapterHarness as TestProviderAdapterHarness, engine, snapshotQuery, providerService, diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 86f43d91358b96dd484ecb4ad5a9cde5db4d719a..6f14a9e1553cf00c057c11a056be38239faac3a6 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -264,6 +264,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter sessionId, provider, status: "ready", + runtimeMode: input.runtimeMode, threadId, cwd: input.cwd, resumeCursor: input.resumeCursor ?? { sessionId }, diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 559c26d8d659f904dbf79e5990e89fd5b1f14aa7..15b0ca8a9feeab90f93385e1d72df0f2ec0b45ab 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -97,6 +97,16 @@ function withHarness( ); } +function withRealCodexHarness( + use: (harness: OrchestrationIntegrationHarness) => Effect.Effect, +) { + return Effect.acquireUseRelease( + makeOrchestrationIntegrationHarness({ provider: "codex", realCodex: true }), + use, + (harness) => harness.dispose, + ); +} + const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => Effect.gen(function* () { const createdAt = nowIso(); @@ -118,6 +128,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => projectId: PROJECT_ID, title: "Integration Thread", model: "gpt-5-codex", + runtimeMode: "approval-required", branch: null, worktreePath: harness.workspaceDir, createdAt, @@ -216,6 +227,97 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + ), ); +it.live.skipIf(!process.env.CODEX_BINARY_PATH)( + "keeps the same Codex provider thread across runtime mode switches", + () => + withRealCodexHarness((harness) => + Effect.gen(function* () { + const createdAt = nowIso(); + + yield* harness.engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-project-create-real-codex"), + projectId: PROJECT_ID, + title: "Integration Project", + workspaceRoot: harness.workspaceDir, + defaultModel: "gpt-5.3-codex", + createdAt, + }); + + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-create-real-codex"), + threadId: THREAD_ID, + projectId: PROJECT_ID, + title: "Integration Thread", + model: "gpt-5.3-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: harness.workspaceDir, + createdAt, + }); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-1"), + threadId: THREAD_ID, + message: { + messageId: asMessageId("msg-real-codex-1"), + role: "user", + text: "Reply with exactly ALPHA.", + attachments: [], + }, + runtimeMode: "full-access", + createdAt: nowIso(), + }); + + const firstThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.status === "ready" && + entry.session.providerName === "codex" && + entry.session.providerThreadId !== null && + entry.messages.some( + (message) => message.role === "assistant" && message.streaming === false, + ), + 180_000, + ); + + const originalProviderThreadId = firstThread.session?.providerThreadId; + assert.isNotNull(originalProviderThreadId); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-2"), + threadId: THREAD_ID, + message: { + messageId: asMessageId("msg-real-codex-2"), + role: "user", + text: "Reply with exactly BETA.", + attachments: [], + }, + runtimeMode: "approval-required", + createdAt: nowIso(), + }); + + const secondThread = yield* harness.waitForThread( + THREAD_ID, + (entry) => + entry.session?.status === "ready" && + entry.session.providerName === "codex" && + entry.session.providerThreadId !== null && + entry.session.runtimeMode === "approval-required" && + entry.messages.some( + (message) => message.role === "assistant" && message.text.includes("BETA"), + ), + 180_000, + ); + + assert.equal(secondThread.session?.providerThreadId, originalProviderThreadId); + }), + ), +); + it.live("runs multi-turn file edits and persists checkpoint diffs", () => withHarness((harness) => Effect.gen(function* () { diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index be623e7da35c63f367e8b6cf094d5cb1fb617224..23f1345cfaa7b670fcf4f3f4ba73e14b0ce020ce 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -122,6 +122,7 @@ it.effect("replays typed runtime fixture events", () => { provider: "codex", cwd: fixture.cwd, + runtimeMode: "full-access", }, ); assert.equal((session.threadId ?? "").length > 0, true); @@ -155,6 +156,7 @@ it.effect("replays file-changing fixture turn events", () => { provider: "codex", cwd: fixture.cwd, + runtimeMode: "full-access", }, ); assert.equal((session.threadId ?? "").length > 0, true); @@ -192,6 +194,7 @@ it.effect("runs multi-turn tool/approval flow", () => { provider: "codex", cwd: fixture.cwd, + runtimeMode: "full-access", }, ); assert.equal((session.threadId ?? "").length > 0, true); @@ -244,6 +247,7 @@ it.effect("rolls back provider conversation state only", () => { provider: "codex", cwd: fixture.cwd, + runtimeMode: "full-access", }, ); assert.equal((session.threadId ?? "").length > 0, true); diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 786253d5a47c2eda6349c105e637154af9f0a10c..3961c3a48454ab8676c69ae6026d731e881007ae 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -43,6 +43,7 @@ function makeSnapshot(input: { projectId: input.projectId, title: "Thread", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: input.worktreePath, latestTurn: { diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index fb2d78b2883f3a52e6899f50cf8e75320e338b16..30835cc6c8e2cec7d2bf8f8875b85073d417f50b 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it, vi } from "vitest"; +import { randomUUID } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { ProviderSessionId } from "@t3tools/contracts"; import { @@ -165,6 +169,7 @@ describe("startSession", () => { await expect( manager.startSession({ provider: "codex", + runtimeMode: "full-access", }), ).rejects.toThrow("cwd missing"); expect(events).toHaveLength(1); @@ -347,3 +352,84 @@ describe("thread checkpoint control", () => { }); }); }); + +describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => { + it( + "keeps prior thread history when resuming with a changed runtime mode", + async () => { + const workspaceDir = mkdtempSync(path.join(os.tmpdir(), "codex-live-resume-")); + writeFileSync(path.join(workspaceDir, "README.md"), "hello\n", "utf8"); + + const manager = new CodexAppServerManager(); + + try { + const firstSession = await manager.startSession({ + provider: "codex", + cwd: workspaceDir, + runtimeMode: "full-access", + providerOptions: { + codex: { + binaryPath: process.env.CODEX_BINARY_PATH, + ...(process.env.CODEX_HOME_PATH + ? { homePath: process.env.CODEX_HOME_PATH } + : {}), + }, + }, + }); + + const firstTurn = await manager.sendTurn({ + sessionId: firstSession.sessionId, + input: `Reply with exactly the word ALPHA ${randomUUID()}`, + }); + + expect(firstTurn.threadId).toBe(firstSession.threadId); + + await vi.waitFor(async () => { + const snapshot = await manager.readThread(firstSession.sessionId); + expect(snapshot.turns.length).toBeGreaterThan(0); + }, { timeout: 120_000, interval: 1_000 }); + + const firstSnapshot = await manager.readThread(firstSession.sessionId); + const originalThreadId = firstSnapshot.threadId; + const originalTurnCount = firstSnapshot.turns.length; + + manager.stopSession(firstSession.sessionId); + + const resumedSession = await manager.startSession({ + provider: "codex", + cwd: workspaceDir, + runtimeMode: "approval-required", + resumeCursor: firstSession.resumeCursor, + providerOptions: { + codex: { + binaryPath: process.env.CODEX_BINARY_PATH, + ...(process.env.CODEX_HOME_PATH + ? { homePath: process.env.CODEX_HOME_PATH } + : {}), + }, + }, + }); + + expect(resumedSession.threadId).toBe(originalThreadId); + + const resumedSnapshotBeforeTurn = await manager.readThread(resumedSession.sessionId); + expect(resumedSnapshotBeforeTurn.threadId).toBe(originalThreadId); + expect(resumedSnapshotBeforeTurn.turns.length).toBeGreaterThanOrEqual(originalTurnCount); + + await manager.sendTurn({ + sessionId: resumedSession.sessionId, + input: `Reply with exactly the word BETA ${randomUUID()}`, + }); + + await vi.waitFor(async () => { + const snapshot = await manager.readThread(resumedSession.sessionId); + expect(snapshot.turns.length).toBeGreaterThan(originalTurnCount); + }, { timeout: 120_000, interval: 1_000 }); + } finally { + manager.stopAll(); + rmSync(workspaceDir, { recursive: true, force: true }); + } + }, + 180_000, + ); +}); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 3eea8a1cb866fb65e8c610705e9e5ae356c894b4..ba9bf2a33ade7001cba0323c56ede9c12939c9ed 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -204,6 +204,7 @@ export class CodexAppServerManager extends EventEmitter { projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", + runtimeMode: "approval-required", branch: null, worktreePath: options?.threadWorktreePath ?? cwd, createdAt, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 8131a6ed2d4a3d21482c2a3c8af1b29fb48773bf..8abb526a910582754a641f2a635cc19564757105 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -80,6 +80,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -131,6 +132,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-replay"), title: "replay", model: "gpt-5-codex", + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -191,6 +193,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-stream"), title: "domain-stream", model: "gpt-5-codex", + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -234,6 +237,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-turn-diff"), title: "Turn diff thread", model: "gpt-5-codex", + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -341,6 +345,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-flaky"), title: "flaky-fail", model: "gpt-5-codex", + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -356,6 +361,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-flaky"), title: "flaky-ok", model: "gpt-5-codex", + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -419,6 +425,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-atomic"), title: "atomic", model: "gpt-5-codex", + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -551,6 +558,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-sync"), title: "sync-before", model: "gpt-5-codex", + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -628,6 +636,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-duplicate"), title: "duplicate", model: "gpt-5-codex", + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, @@ -643,6 +652,7 @@ describe("OrchestrationEngine", () => { projectId: asProjectId("project-duplicate"), title: "duplicate", model: "gpt-5-codex", + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 0c3bff45636d0c88c8290e67fb7e6e52b92884df..72998e080741969dfe9bce1b31e1a5ca0fd239be 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -396,6 +396,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { projectId: event.payload.projectId, title: event.payload.title, model: event.payload.model, + runtimeMode: event.payload.runtimeMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, latestTurnId: null, @@ -425,6 +426,21 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { return; } + case "thread.runtime-mode-set": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + runtimeMode: event.payload.runtimeMode, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.deleted": { attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); const existingRow = yield* projectionThreadRepository.getById({ diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index bf4f15330b2d58b2758f30179dd5f0bf9a2587dc..40601092e7535a41c01c8b003dfac53cbff49d0d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -239,6 +239,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { projectId: asProjectId("project-1"), title: "Thread 1", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, latestTurn: { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index cdb129aff8e1974445a62f46ddce94ed661ca628..d2b3dae21bd2292c3dcb6f2090aad875a51334c7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -153,6 +153,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { project_id AS "projectId", title, model, + runtime_mode AS "runtimeMode", branch, worktree_path AS "worktreePath", latest_turn_id AS "latestTurnId", @@ -485,6 +486,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { projectId: row.projectId, title: row.title, model: row.model, + runtimeMode: row.runtimeMode, branch: row.branch, worktreePath: row.worktreePath, latestTurn: latestTurnByThread.get(row.threadId) ?? null, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 6ef0bea5661586bbc45c11d7815fd4ea39e9dabb..020f6c9fc6139f3e2788ef8976a9115f5b9794bb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -114,6 +114,13 @@ describe("ProviderCommandReactor", () => { sessionId: asSessionId(`sess-${sessionIndex}`), provider, status: "ready" as const, + runtimeMode: + typeof input === "object" && + input !== null && + "runtimeMode" in input && + (input.runtimeMode === "approval-required" || input.runtimeMode === "full-access") + ? input.runtimeMode + : "full-access", ...(model !== undefined ? { model } : {}), threadId: ProviderThreadId.makeUnsafe(`provider-thread-${sessionIndex}`), resumeCursor: resumeCursor ?? { opaque: `cursor-${sessionIndex}` }, @@ -226,6 +233,7 @@ describe("ProviderCommandReactor", () => { projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt: now, @@ -497,7 +505,7 @@ describe("ProviderCommandReactor", () => { }); }); - it("restarts the provider session when runtime mode changes", async () => { + it("restarts the provider session when runtime mode is updated on the thread", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -520,6 +528,19 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.runtime-mode.set", + commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 2); + await waitFor(() => harness.stopSession.mock.calls.length === 1); + await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", @@ -531,13 +552,11 @@ describe("ProviderCommandReactor", () => { text: "second", attachments: [], }, - runtimeMode: "approval-required", + runtimeMode: "full-access", createdAt: now, }), ); - await waitFor(() => harness.stopSession.mock.calls.length === 1); - await waitFor(() => harness.startSession.mock.calls.length === 2); await waitFor(() => harness.sendTurn.mock.calls.length === 2); expect(harness.stopSession.mock.calls[0]?.[0]).toEqual({ sessionId: asSessionId("sess-1") }); @@ -642,15 +661,9 @@ describe("ProviderCommandReactor", () => { await Effect.runPromise( harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-restart-failure-2"), + type: "thread.runtime-mode.set", + commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-restart-failure"), threadId: ThreadId.makeUnsafe("thread-1"), - message: { - messageId: asMessageId("user-message-restart-failure-2"), - role: "user", - text: "second", - attachments: [], - }, runtimeMode: "approval-required", createdAt: now, }), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index bff647a465cb322653f7e1278b315e9b25754f02..b0ae0685f13058eb272e3ff859465c379f542439 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -28,6 +28,7 @@ type ProviderIntentEvent = Extract< OrchestrationEvent, { type: + | "thread.runtime-mode-set" | "thread.turn-start-requested" | "thread.turn-interrupt-requested" | "thread.approval-response-requested" @@ -197,7 +198,6 @@ const make = Effect.gen(function* () { options?: { readonly provider?: ProviderKind; readonly model?: string; - readonly runtimeMode?: RuntimeMode; }, ) { const readModel = yield* orchestrationEngine.getReadModel(); @@ -206,8 +206,7 @@ const make = Effect.gen(function* () { return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`)); } - const desiredRuntimeMode = - options?.runtimeMode ?? thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const desiredRuntimeMode = thread.runtimeMode; const currentProvider: ProviderKind | undefined = thread.session?.providerName === "codex" || thread.session?.providerName === "claudeCode" || @@ -260,8 +259,7 @@ const make = Effect.gen(function* () { const existingSessionId = thread.session?.providerSessionId; if (existingSessionId) { - const runtimeModeChanged = - options?.runtimeMode !== undefined && options.runtimeMode !== thread.session?.runtimeMode; + const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; const providerChanged = options?.provider !== undefined && options.provider !== currentProvider; const activeSession = yield* resolveActiveSession(existingSessionId); const sessionModelSwitch = @@ -316,7 +314,6 @@ const make = Effect.gen(function* () { readonly provider?: ProviderKind; readonly model?: string; readonly effort?: string; - readonly runtimeMode: RuntimeMode; readonly createdAt: string; }) { const thread = yield* resolveThread(input.threadId); @@ -326,7 +323,6 @@ const make = Effect.gen(function* () { const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt, { ...(input.provider !== undefined ? { provider: input.provider } : {}), ...(input.model !== undefined ? { model: input.model } : {}), - runtimeMode: input.runtimeMode, }); const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; @@ -459,7 +455,6 @@ const make = Effect.gen(function* () { ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}), ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}), - runtimeMode: event.payload.runtimeMode, createdAt: event.payload.createdAt, }); }); @@ -569,6 +564,14 @@ const make = Effect.gen(function* () { const processDomainEvent = (event: ProviderIntentEvent) => Effect.gen(function* () { switch (event.type) { + case "thread.runtime-mode-set": { + const thread = yield* resolveThread(event.payload.threadId); + if (!thread?.session || thread.session.status === "stopped") { + return; + } + yield* ensureSessionForThread(event.payload.threadId, event.occurredAt); + return; + } case "thread.turn-start-requested": yield* processTurnStartRequested(event); return; @@ -608,6 +611,7 @@ const make = Effect.gen(function* () { yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { if ( + event.type !== "thread.runtime-mode-set" && event.type !== "thread.turn-start-requested" && event.type !== "thread.turn-interrupt-requested" && event.type !== "thread.approval-response-requested" && diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index eee39997a67552b7bdf0934bfe9763434cc75d97..e723313026efd12ceb106329d3a1a8ae8c52757d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -167,6 +167,7 @@ describe("ProviderRuntimeIngestion", () => { projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt, diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 03a5e35057f22925231be4e966520b6c71afe67d..37dac36dd4c6d80a5c7cb4ba950d058bf1ebbd77 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -4,6 +4,7 @@ import { ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, + ThreadRuntimeModeSetPayload as ContractsThreadRuntimeModeSetPayloadSchema, ThreadDeletedPayload as ContractsThreadDeletedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -24,6 +25,7 @@ export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; +export const ThreadRuntimeModeSetPayload = ContractsThreadRuntimeModeSetPayloadSchema; export const ThreadDeletedPayload = ContractsThreadDeletedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 1437bff296a0dda4058499e296769e500b5c2f2f..971a41e5d6cb52e07c881ed3e123b15eef1b5d7e 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -50,6 +50,7 @@ const readModel: OrchestrationReadModel = { projectId: ProjectId.makeUnsafe("project-a"), title: "Thread A", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, @@ -66,6 +67,7 @@ const readModel: OrchestrationReadModel = { projectId: ProjectId.makeUnsafe("project-b"), title: "Thread B", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, @@ -137,6 +139,7 @@ describe("commandInvariants", () => { projectId: ProjectId.makeUnsafe("project-a"), title: "new", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, @@ -156,6 +159,7 @@ describe("commandInvariants", () => { projectId: ProjectId.makeUnsafe("project-a"), title: "dup", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index f26c104aa1e9abccd7f924d8378f56a24c5c406b..25d95c50b86a8660417e1c04a434ebd928674e0e 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -130,6 +130,7 @@ describe("decider project scripts", () => { projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", + runtimeMode: "approval-required", branch: null, worktreePath: null, createdAt: now, @@ -180,4 +181,80 @@ describe("decider project scripts", () => { runtimeMode: "approval-required", }); }); + + it("emits thread.runtime-mode-set from thread.runtime-mode.set", async () => { + const now = new Date().toISOString(); + const initial = createEmptyReadModel(now); + const withProject = await Effect.runPromise( + projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create"), + aggregateKind: "project", + aggregateId: asProjectId("project-1"), + type: "project.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-project-create"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-project-create"), + metadata: {}, + payload: { + projectId: asProjectId("project-1"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }), + ); + const readModel = await Effect.runPromise( + projectEvent(withProject, { + sequence: 2, + eventId: asEventId("evt-thread-create"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-1"), + type: "thread.created", + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-thread-create"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-thread-create"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread", + model: "gpt-5-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + + const result = await Effect.runPromise( + decideOrchestrationCommand({ + command: { + type: "thread.runtime-mode.set", + commandId: CommandId.makeUnsafe("cmd-runtime-mode-set"), + threadId: ThreadId.makeUnsafe("thread-1"), + runtimeMode: "approval-required", + createdAt: now, + }, + readModel, + }), + ); + + const singleResult = Array.isArray(result) ? null : result; + if (singleResult === null) { + throw new Error("Expected a single runtime-mode-set event."); + } + expect(singleResult.type).toBe("thread.runtime-mode-set"); + expect(singleResult.payload).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + runtimeMode: "approval-required", + }); + }); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 2c6bdfee46887f0f14c52db6753138963bcab752..e9bcbdcd1e8755f4eadbb920b745699d1dd8ba1c 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -157,6 +157,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" projectId: command.projectId, title: command.title, model: command.model, + runtimeMode: command.runtimeMode, branch: command.branch, worktreePath: command.worktreePath, createdAt: command.createdAt, @@ -213,6 +214,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.runtime-mode.set": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = nowIso(); + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + }), + type: "thread.runtime-mode-set", + payload: { + threadId: command.threadId, + runtimeMode: command.runtimeMode, + updatedAt: occurredAt, + }, + }; + } + case "thread.turn.start": { yield* requireThread({ readModel, @@ -255,7 +279,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.model !== undefined ? { model: command.model } : {}), ...(command.effort !== undefined ? { effort: command.effort } : {}), assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE, - runtimeMode: command.runtimeMode, + runtimeMode: + readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ?? + command.runtimeMode, createdAt: command.createdAt, }, }; diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index d59fdf74ff2a4d90bf7ce37abb8b0f7df930ab64..ee42b48f3f5a43250d53355652a6d280cbaf504e 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -57,6 +57,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt: now, @@ -73,6 +74,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, latestTurn: null, @@ -167,6 +169,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", model: "gpt-5.3-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt, @@ -209,6 +212,59 @@ describe("orchestration projector", () => { expect(thread?.session?.status).toBe("running"); }); + it("updates canonical thread runtime mode from thread.runtime-mode-set", async () => { + const createdAt = "2026-02-23T08:00:00.000Z"; + const updatedAt = "2026-02-23T08:00:05.000Z"; + const model = createEmptyReadModel(createdAt); + + const afterCreate = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + model: "gpt-5.3-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ), + ); + + const afterUpdate = await Effect.runPromise( + projectEvent( + afterCreate, + makeEvent({ + sequence: 2, + type: "thread.runtime-mode-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: updatedAt, + commandId: "cmd-runtime-mode-set", + payload: { + threadId: "thread-1", + runtimeMode: "approval-required", + updatedAt, + }, + }), + ), + ); + + expect(afterUpdate.threads[0]?.runtimeMode).toBe("approval-required"); + expect(afterUpdate.threads[0]?.updatedAt).toBe(updatedAt); + }); + it("marks assistant messages completed with non-streaming updates", async () => { const createdAt = "2026-02-23T09:00:00.000Z"; const deltaAt = "2026-02-23T09:00:01.000Z"; @@ -230,6 +286,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", model: "gpt-5.3-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt, @@ -313,6 +370,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", model: "gpt-5.3-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt, @@ -524,6 +582,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "demo", model: "gpt-5.3-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt, @@ -673,6 +732,7 @@ describe("orchestration projector", () => { projectId: "project-1", title: "capped", model: "gpt-5-codex", + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index ac35b84118dfd368e6a1aaf38aa07551c7b57cf8..8861d038fc4c48a2d27f1d021116231b60948aa2 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -17,6 +17,7 @@ import { ThreadCreatedPayload, ThreadDeletedPayload, ThreadMetaUpdatedPayload, + ThreadRuntimeModeSetPayload, ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, @@ -241,6 +242,7 @@ export function projectEvent( projectId: payload.projectId, title: payload.title, model: payload.model, + runtimeMode: payload.runtimeMode, branch: payload.branch, worktreePath: payload.worktreePath, latestTurn: null, @@ -289,6 +291,22 @@ export function projectEvent( })), ); + case "thread.runtime-mode-set": + return decodeForEvent( + ThreadRuntimeModeSetPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + runtimeMode: payload.runtimeMode, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.message-sent": return Effect.gen(function* () { const payload = yield* decodeForEvent( diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 810deb3827dc097c573c7517bbfb4f2c60cb85b1..5b33c495bb51a56da2b2d4e56601ea0dd9095523 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -24,6 +24,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { project_id, title, model, + runtime_mode, branch, worktree_path, latest_turn_id, @@ -36,6 +37,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.projectId}, ${row.title}, ${row.model}, + ${row.runtimeMode}, ${row.branch}, ${row.worktreePath}, ${row.latestTurnId}, @@ -48,6 +50,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { project_id = excluded.project_id, title = excluded.title, model = excluded.model, + runtime_mode = excluded.runtime_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, latest_turn_id = excluded.latest_turn_id, @@ -67,6 +70,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { project_id AS "projectId", title, model, + runtime_mode AS "runtimeMode", branch, worktree_path AS "worktreePath", latest_turn_id AS "latestTurnId", @@ -88,6 +92,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { project_id AS "projectId", title, model, + runtime_mode AS "runtimeMode", branch, worktree_path AS "worktreePath", latest_turn_id AS "latestTurnId", diff --git a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts index 85c9bc1caaad320afe09f84892c220d48b45df5c..a978a0a68a7ae9291902108eae72a0577ae821e0 100644 --- a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts @@ -49,6 +49,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { provider_name, adapter_key, provider_thread_id, + runtime_mode, status, last_seen_at, resume_cursor_json, @@ -60,6 +61,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { ${runtime.providerName}, ${runtime.adapterKey}, ${runtime.providerThreadId}, + ${runtime.runtimeMode}, ${runtime.status}, ${runtime.lastSeenAt}, ${runtime.resumeCursor}, @@ -71,6 +73,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { provider_name = excluded.provider_name, adapter_key = excluded.adapter_key, provider_thread_id = excluded.provider_thread_id, + runtime_mode = excluded.runtime_mode, status = excluded.status, last_seen_at = excluded.last_seen_at, resume_cursor_json = excluded.resume_cursor_json, @@ -89,6 +92,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { provider_name AS "providerName", adapter_key AS "adapterKey", provider_thread_id AS "providerThreadId", + runtime_mode AS "runtimeMode", status, last_seen_at AS "lastSeenAt", resume_cursor_json AS "resumeCursor", @@ -109,6 +113,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { provider_name AS "providerName", adapter_key AS "adapterKey", provider_thread_id AS "providerThreadId", + runtime_mode AS "runtimeMode", status, last_seen_at AS "lastSeenAt", resume_cursor_json AS "resumeCursor", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 0a0a8226df79031ec3940cb670f6e2460cf7666c..ebe7ae214a0aa558040434abd387dcf21f18cf27 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -20,6 +20,10 @@ import Migration0005 from "./Migrations/005_Projections.ts"; import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts"; import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts"; import Migration0008 from "./Migrations/008_ProjectionThreadActivitySequence.ts"; +import Migration0009 from "./Migrations/009_ProviderSessionRuntimeMode.ts"; +import Migration0010 from "./Migrations/010_ProjectionThreadsRuntimeMode.ts"; +import Migration0011 from "./Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts"; +import { Effect } from "effect"; /** * Migration loader with all migrations defined inline. @@ -40,6 +44,9 @@ const loader = Migrator.fromRecord({ "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006, "7_ProjectionThreadMessageAttachments": Migration0007, "8_ProjectionThreadActivitySequence": Migration0008, + "9_ProviderSessionRuntimeMode": Migration0009, + "10_ProjectionThreadsRuntimeMode": Migration0010, + "11_OrchestrationThreadCreatedRuntimeMode": Migration0011, }); /** @@ -58,7 +65,11 @@ const run = Migrator.make({}); * * @returns Effect containing array of executed migrations */ -export const runMigrations = run({ loader }); +export const runMigrations = Effect.gen(function* () { + yield* Effect.log("Running migrations..."); + yield* run({ loader }); + yield* Effect.log("Migrations ran successfully"); +}); /** * Layer that runs migrations when the layer is built. diff --git a/apps/server/src/persistence/Migrations/009_ProviderSessionRuntimeMode.ts b/apps/server/src/persistence/Migrations/009_ProviderSessionRuntimeMode.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a71d70759b384e391ef1fb811c964e63d8963be --- /dev/null +++ b/apps/server/src/persistence/Migrations/009_ProviderSessionRuntimeMode.ts @@ -0,0 +1,17 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE provider_session_runtime + ADD COLUMN runtime_mode TEXT NOT NULL DEFAULT 'full-access' + `; + + yield* sql` + UPDATE provider_session_runtime + SET runtime_mode = 'full-access' + WHERE runtime_mode IS NULL + `; +}); diff --git a/apps/server/src/persistence/Migrations/010_ProjectionThreadsRuntimeMode.ts b/apps/server/src/persistence/Migrations/010_ProjectionThreadsRuntimeMode.ts new file mode 100644 index 0000000000000000000000000000000000000000..93d1f0a8ec73b604702f382413f89ba468b85c6c --- /dev/null +++ b/apps/server/src/persistence/Migrations/010_ProjectionThreadsRuntimeMode.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN runtime_mode TEXT NOT NULL DEFAULT 'full-access' + `; + + yield* sql` + UPDATE projection_threads + SET runtime_mode = 'full-access' + WHERE runtime_mode IS NULL + `; +}); diff --git a/apps/server/src/persistence/Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts b/apps/server/src/persistence/Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts new file mode 100644 index 0000000000000000000000000000000000000000..a1021a4da93b453a61ecd39087b2057c18e3fde3 --- /dev/null +++ b/apps/server/src/persistence/Migrations/011_OrchestrationThreadCreatedRuntimeMode.ts @@ -0,0 +1,13 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set(payload_json, '$.runtimeMode', 'full-access') + WHERE event_type = 'thread.created' + AND json_type(payload_json, '$.runtimeMode') IS NULL + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 512c72586a870747bec6b495e7a2c1a8bb0800bc..5421bfdf7b9b7e3d076bdc5b2611be8961d7fad6 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -6,7 +6,7 @@ * * @module ProjectionThreadRepository */ -import { IsoDateTime, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { IsoDateTime, ProjectId, RuntimeMode, ThreadId, TurnId } from "@t3tools/contracts"; import { Option, Schema, ServiceMap } from "effect"; import type { Effect } from "effect"; @@ -17,6 +17,7 @@ export const ProjectionThread = Schema.Struct({ projectId: ProjectId, title: Schema.String, model: Schema.String, + runtimeMode: RuntimeMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), latestTurnId: Schema.NullOr(TurnId), diff --git a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts index e0727a8acd92a23fc96659721eae862a403cca4a..25ddc1af3075ade86f7fe8360c064053955dee83 100644 --- a/apps/server/src/persistence/Services/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Services/ProviderSessionRuntime.ts @@ -10,6 +10,7 @@ import { ProviderSessionId, ProviderSessionRuntimeStatus, ProviderThreadId, + RuntimeMode, ThreadId, } from "@t3tools/contracts"; import { Option, Schema, ServiceMap } from "effect"; @@ -23,6 +24,7 @@ export const ProviderSessionRuntime = Schema.Struct({ providerName: Schema.String, adapterKey: Schema.String, providerThreadId: Schema.NullOr(ProviderThreadId), + runtimeMode: RuntimeMode, status: ProviderSessionRuntimeStatus, lastSeenAt: IsoDateTime, resumeCursor: Schema.NullOr(Schema.Unknown), diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts index 27c24cbe27175aa64e9310714a9dd44f56fc18c7..a1e3604f3f5ed525b78b084b4b844ab5f7d1c24f 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.test.ts @@ -166,7 +166,9 @@ describe("ClaudeCodeAdapterLive", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeCodeAdapter; - const result = yield* adapter.startSession({ provider: "codex" }).pipe(Effect.result); + const result = yield* adapter + .startSession({ provider: "codex", runtimeMode: "full-access" }) + .pipe(Effect.result); assert.equal(result._tag, "Failure"); if (result._tag !== "Failure") { @@ -240,6 +242,7 @@ describe("ClaudeCodeAdapterLive", () => { const session = yield* adapter.startSession({ provider: "claudeCode", model: "claude-sonnet-4-5", + runtimeMode: "full-access", }); const turn = yield* adapter.sendTurn({ @@ -374,6 +377,7 @@ describe("ClaudeCodeAdapterLive", () => { const session = yield* adapter.startSession({ provider: "claudeCode", + runtimeMode: "full-access", }); const turn = yield* adapter.sendTurn({ @@ -461,6 +465,7 @@ describe("ClaudeCodeAdapterLive", () => { const session = yield* adapter.startSession({ provider: "claudeCode", + runtimeMode: "full-access", }); const turn = yield* adapter.sendTurn({ @@ -529,6 +534,7 @@ describe("ClaudeCodeAdapterLive", () => { const session = yield* adapter.startSession({ provider: "claudeCode", + runtimeMode: "full-access", }); assert.equal(session.threadId, undefined); @@ -679,6 +685,7 @@ describe("ClaudeCodeAdapterLive", () => { resumeSessionAt: "assistant-99", turnCount: 3, }, + runtimeMode: "full-access", }); assert.equal(session.threadId, "resume-thread-1"); @@ -705,6 +712,7 @@ describe("ClaudeCodeAdapterLive", () => { const session = yield* adapter.startSession({ provider: "claudeCode", + runtimeMode: "full-access", }); assert.equal( @@ -727,6 +735,7 @@ describe("ClaudeCodeAdapterLive", () => { const session = yield* adapter.startSession({ provider: "claudeCode", + runtimeMode: "full-access", }); const firstTurn = yield* adapter.sendTurn({ @@ -802,7 +811,10 @@ describe("ClaudeCodeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeCodeAdapter; - const session = yield* adapter.startSession({ provider: "claudeCode" }); + const session = yield* adapter.startSession({ + provider: "claudeCode", + runtimeMode: "full-access", + }); yield* adapter.sendTurn({ sessionId: session.sessionId, input: "hello", @@ -841,6 +853,7 @@ describe("ClaudeCodeAdapterLive", () => { const session = yield* adapter.startSession({ provider: "claudeCode", + runtimeMode: "full-access", }); const turn = yield* adapter.sendTurn({ sessionId: session.sessionId, diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts index 5772018a6aa5d093dccc3ac209de3d1062ba9b92..f2d6787acd1047f56e2b084657d507a932867ea1 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -1632,6 +1632,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { sessionId, provider: PROVIDER, status: "ready", + runtimeMode: input.runtimeMode, ...(input.cwd ? { cwd: input.cwd } : {}), ...(input.model ? { model: input.model } : {}), ...(threadId ? { threadId } : {}), diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 7ae319265eed3366ebc2ad2fccac5c9e95c90e3a..fdb812c41ccf101db2d15aa7c8658cf9bcc39477 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -40,6 +40,7 @@ class FakeCodexManager extends CodexAppServerManager { sessionId: asSessionId("sess-1"), provider: "codex", status: "ready", + runtimeMode: input.runtimeMode, threadId: ProviderThreadId.makeUnsafe("thread-1"), cwd: input.cwd, createdAt: now, @@ -148,6 +149,7 @@ validationLayer("CodexAdapterLive validation", (it) => { const result = yield* adapter .startSession({ provider: "claudeCode", + runtimeMode: "full-access", }) .pipe(Effect.result); diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 0e9ca6d6f7e64326e7f622e75b20af4054875c03..c4583c25f76a69b78db08626a625c81dea205c15 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -255,7 +255,9 @@ describe("CursorAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* CursorAdapter; - const result = yield* adapter.startSession({ provider: "codex" }).pipe(Effect.result); + const result = yield* adapter + .startSession({ provider: "codex", runtimeMode: "full-access" }) + .pipe(Effect.result); assert.equal(result._tag, "Failure"); if (result._tag !== "Failure") { @@ -289,6 +291,7 @@ describe("CursorAdapterLive", () => { const session = yield* adapter.startSession({ provider: "cursor", cwd: "/tmp/project", + runtimeMode: "full-access", }); const turn = yield* adapter.sendTurn({ @@ -353,6 +356,7 @@ describe("CursorAdapterLive", () => { yield* adapter.startSession({ provider: "cursor", model: "composer-1.5", + runtimeMode: "full-access", }); assert.deepEqual(createProcessInput?.model, "composer-1.5"); @@ -385,6 +389,7 @@ describe("CursorAdapterLive", () => { const session = yield* adapter.startSession({ provider: "cursor", cwd: "/tmp/project", + runtimeMode: "full-access", }); assert.equal(nativeEvents.length > 0, true); @@ -408,6 +413,7 @@ describe("CursorAdapterLive", () => { resumeCursor: { acpSessionId: "acp-session-resume", }, + runtimeMode: "full-access", }); const methods = new Set(fake.requests.map((request) => request.method)); @@ -441,6 +447,7 @@ describe("CursorAdapterLive", () => { resumeCursor: { sessionId: "acp-session-legacy", }, + runtimeMode: "full-access", }); const loadRequest = fake.requests.find((request) => request.method === "session/load"); @@ -555,6 +562,7 @@ describe("CursorAdapterLive", () => { const adapter = yield* CursorAdapter; const session = yield* adapter.startSession({ provider: "cursor", + runtimeMode: "full-access", }); yield* Stream.take(adapter.streamEvents, 6).pipe(Stream.runDrain); @@ -600,6 +608,7 @@ describe("CursorAdapterLive", () => { const session = yield* adapter.startSession({ provider: "cursor", + runtimeMode: "full-access", }); yield* adapter.sendTurn({ diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 13ac149101a563acfab2c89020b47876f03a3d83..15814957ef5121c85b910b477e4428dba27f1b8f 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -1040,6 +1040,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { sessionId, provider: PROVIDER, status: "connecting", + runtimeMode: input.runtimeMode, ...(input.cwd ? { cwd: input.cwd } : {}), ...(input.model ? { model: input.model } : {}), createdAt: startedAt, @@ -1048,7 +1049,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const context: CursorSessionContext = { session, - runtimeMode: input.runtimeMode ?? "full-access", + runtimeMode: input.runtimeMode, child, output, pending: new Map(), diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index bb3d4a65bd5e33432198d4dcedbf4084c00f670f..e4c9491df56054d83b52a78c383e45d988ea13c4 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -80,6 +80,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { sessionId: ProviderSessionId.makeUnsafe(`sess-${next}`), provider, status: "ready", + runtimeMode: input.runtimeMode, threadId, resumeCursor: input.resumeCursor ?? { opaque: `cursor-${next}` }, cwd: input.cwd ?? process.cwd(), @@ -350,6 +351,7 @@ it.effect( return yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", cwd: "/tmp/project", + runtimeMode: "full-access", }); }).pipe(Effect.provide(firstProviderLayer)); @@ -429,6 +431,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const session = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", cwd: "/tmp/project", + runtimeMode: "full-access", }); assert.equal(session.provider, "codex"); @@ -481,6 +484,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const session = yield* provider.startSession(asThreadId("thread-claude"), { provider: "claudeCode", cwd: "/tmp/project-claude", + runtimeMode: "full-access", }); assert.equal(session.provider, "claudeCode"); @@ -502,6 +506,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const initial = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", cwd: "/tmp/project", + runtimeMode: "full-access", }); yield* routing.codex.stopSession(initial.sessionId); routing.codex.startSession.mockClear(); @@ -540,6 +545,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const initial = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", cwd: "/tmp/project-send-turn", + runtimeMode: "full-access", }); yield* provider.stopAll(); @@ -577,9 +583,11 @@ routing.layer("ProviderServiceLive routing", (it) => { yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + runtimeMode: "full-access", }); yield* provider.startSession(asThreadId("thread-2"), { provider: "codex", + runtimeMode: "full-access", }); yield* provider.stopAll(); @@ -596,6 +604,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const session = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + runtimeMode: "full-access", }); yield* provider.sendTurn({ sessionId: session.sessionId, @@ -647,6 +656,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + runtimeMode: "full-access", }); const eventsRef = yield* Ref.make>([]); @@ -684,6 +694,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-seq"), { provider: "codex", + runtimeMode: "full-access", }); const receivedRef = yield* Ref.make>([]); @@ -740,6 +751,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const provider = yield* ProviderService; const session = yield* provider.startSession(asThreadId("thread-1"), { provider: "codex", + runtimeMode: "full-access", }); const receivedByHealthy: string[] = []; @@ -845,6 +857,7 @@ validation.layer("ProviderServiceLive validation", (it) => { sessionId: asSessionId("sess-missing-thread"), provider: "codex", status: "ready", + runtimeMode: input.runtimeMode, cwd: input.cwd ?? process.cwd(), createdAt: now, updatedAt: now, @@ -855,6 +868,7 @@ validation.layer("ProviderServiceLive validation", (it) => { const session = yield* provider.startSession(asThreadId("thread-missing"), { provider: "codex", cwd: "/tmp/project", + runtimeMode: "full-access", }); assert.equal(session.sessionId, asSessionId("sess-missing-thread")); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 423a0e0405ade5b6df9be7edfc097b17449219c8..3fdccff4f10a24ad9b442dfe34ec4b4973be66a8 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -180,6 +180,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => provider: session.provider, threadId, ...(providerThreadId !== undefined ? { providerThreadId } : {}), + runtimeMode: session.runtimeMode, status: toRuntimeStatus(session), ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), runtimePayload: toRuntimePayloadFromSession(session), @@ -296,6 +297,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ...(persistedCwd ? { cwd: persistedCwd } : {}), ...(resumeThreadId ? { resumeThreadId } : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), + runtimeMode: input.binding.runtimeMode ?? "full-access", }); if (resumed.provider !== adapter.provider) { return yield* toValidationError( @@ -415,7 +417,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const input = { ...parsed, provider: parsed.provider ?? "codex", - runtimeMode: parsed.runtimeMode ?? "full-access", }; const adapter = yield* registry.getByProvider(input.provider); const session = yield* adapter.startSession(input); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index b95f40526d3b70e09163bd3f0047ef3e367e29d7..a3dc31134bb29d79171e9fd8410719e0d888e9c5 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -75,6 +75,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { threadId: value.threadId, adapterKey: value.adapterKey, providerThreadId: value.providerThreadId, + runtimeMode: value.runtimeMode, status: value.status, resumeCursor: value.resumeCursor, runtimePayload: value.runtimePayload, @@ -112,6 +113,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { binding.providerThreadId !== undefined ? binding.providerThreadId : (existingRuntime?.providerThreadId ?? null), + runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", status: binding.status ?? existingRuntime?.status ?? "running", lastSeenAt: now, resumeCursor: diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index 67628dfa33d39ba9597099fca06c2d1aef736b89..96bdcc23a5d2dd4feac9de1920e16c0afe25499f 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -12,6 +12,7 @@ import type { ProviderSessionId, ProviderSessionRuntimeStatus, ProviderThreadId, + RuntimeMode, ThreadId, } from "@t3tools/contracts"; import { Option, ServiceMap } from "effect"; @@ -32,6 +33,7 @@ export interface ProviderSessionBinding { readonly status?: ProviderSessionRuntimeStatus; readonly resumeCursor?: unknown | null; readonly runtimePayload?: unknown | null; + readonly runtimeMode?: RuntimeMode; } export type ProviderSessionDirectoryReadError = diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index b483284fb551b8e3aaaf26e3dd38bcd4a68b6e05..e368df9e4fd936e9fc23d005f7adb633d5ced439 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1157,6 +1157,7 @@ describe("WebSocket Server", () => { sessionId, provider: "codex", status: "ready", + runtimeMode: "full-access", threadId: asProviderThreadId("provider-thread-1"), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 50af3b8b10ef5fd0b168047e61bd45269e7e4581..ea4392d450c8eacaae766c0b7a2a4eb829972ec3 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -595,6 +595,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< projectId: bootstrapProjectId, title: "New thread", model: bootstrapProjectDefaultModel, + runtimeMode: "full-access", branch: null, worktreePath: null, createdAt, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7bc1f223758e0d78ab164577b30824c3e1fc4db2..cd1bbca6e141670f3df19431a0661bbc15674481 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -31,7 +31,17 @@ import { resolveModelSlugForProvider, OrchestrationThreadActivity, } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useOptimistic, + useRef, + useState, + useTransition, +} from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; @@ -69,6 +79,7 @@ import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from ".. import { useStore } from "../store"; import { truncateTitle } from "../truncateTitle"; import { + DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_THREAD_TERMINAL_COUNT, type ChatMessage, @@ -267,6 +278,7 @@ function buildLocalDraftThread( projectId: draftThread.projectId, title: "New thread", model: fallbackModel, + runtimeMode: draftThread.runtimeMode, session: null, messages: [], error, @@ -500,10 +512,8 @@ interface ChatViewProps { export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); - const runtimeMode = useStore((store) => store.runtimeMode); const markThreadVisited = useStore((store) => store.markThreadVisited); const setStoreThreadError = useStore((store) => store.setError); - const setStoreRuntimeMode = useStore((store) => store.setRuntimeMode); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); const navigate = useNavigate(); @@ -549,7 +559,6 @@ export default function ChatView({ threadId }: ChatViewProps) { const [sendPhase, setSendPhase] = useState("idle"); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); - const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [nowTick, setNowTick] = useState(() => Date.now()); @@ -642,6 +651,9 @@ export default function ChatView({ threadId }: ChatViewProps) { [draftThread, fallbackDraftProject?.model, localDraftError, threadId], ); const activeThread = serverThread ?? localDraftThread; + const runtimeMode = activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const [optimisticRuntimeMode, setOptimisticRuntimeMode] = useOptimistic(runtimeMode); + const [isRuntimeModePending, startRuntimeModeTransition] = useTransition(); const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const diffSearch = useMemo( @@ -1410,31 +1422,26 @@ export default function ChatView({ threadId }: ChatViewProps) { const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { if (mode === runtimeMode) return; - setStoreRuntimeMode(mode); + if (isLocalDraftThread) { + setDraftThreadContext(threadId, { runtimeMode: mode }); + scheduleComposerFocus(); + return; + } + const api = readNativeApi(); + if (!api || !isServerThread) return; scheduleComposerFocus(); - const api = readNativeApi(); - if (!api) return; - - const runningThreadIds = threads - .filter((thread) => thread.session !== null && thread.session.status !== "closed") - .map((thread) => thread.id); - - if (runningThreadIds.length === 0) return; - - setIsSwitchingRuntimeMode(true); - await Promise.all( - runningThreadIds.map((threadId) => - api.orchestration - .dispatchCommand({ - type: "thread.session.stop", - commandId: newCommandId(), - threadId, - createdAt: new Date().toISOString(), - }) - .catch(() => undefined), - ), - ); - setIsSwitchingRuntimeMode(false); + startRuntimeModeTransition(() => { + setOptimisticRuntimeMode(mode); + void api.orchestration + .dispatchCommand({ + type: "thread.runtime-mode.set", + commandId: newCommandId(), + threadId, + runtimeMode: mode, + createdAt: new Date().toISOString(), + }) + .catch(() => undefined); + }); }; useEffect(() => { @@ -2186,6 +2193,7 @@ export default function ChatView({ threadId }: ChatViewProps) { projectId: activeProject.id, title, model: threadCreateModel, + runtimeMode, branch: nextThreadBranch, worktreePath: nextThreadWorktreePath, createdAt: activeThread.createdAt, @@ -2941,21 +2949,23 @@ export default function ChatView({ threadId }: ChatViewProps) { className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3" size="sm" type="button" - disabled={isSwitchingRuntimeMode} + disabled={isRuntimeModePending} onClick={() => void handleRuntimeModeChange( - runtimeMode === "full-access" ? "approval-required" : "full-access", + optimisticRuntimeMode === "full-access" + ? "approval-required" + : "full-access", ) } title={ - runtimeMode === "full-access" + optimisticRuntimeMode === "full-access" ? "Full access — click to require approvals" : "Approval required — click for full access" } > - {runtimeMode === "full-access" ? : } + {optimisticRuntimeMode === "full-access" ? : } - {runtimeMode === "full-access" ? "Full access" : "Supervised"} + {optimisticRuntimeMode === "full-access" ? "Full access" : "Supervised"} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 018e89d14b64913fb2279be3da7219bf38fe8482..0207f289b579a45a6a58c7a189566a8a6014084d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -8,6 +8,7 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + DEFAULT_RUNTIME_MODE, DEFAULT_MODEL, type DesktopUpdateState, ProjectId, @@ -435,6 +436,7 @@ export default function Sidebar() { branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, envMode: options?.envMode ?? "local", + runtimeMode: DEFAULT_RUNTIME_MODE, }); await navigate({ diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 57c15cb1fe9b95597309915b6fc028a03ef87616..c597b648a6c53eff3272c593f2555f52225f958e 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -6,8 +6,9 @@ import { normalizeModelSlug, type CodexReasoningEffort, type ProviderKind, + type RuntimeMode, } from "@t3tools/contracts"; -import type { ChatImageAttachment } from "./types"; +import { DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -38,6 +39,7 @@ interface PersistedComposerThreadDraftState { interface PersistedDraftThreadState { projectId: ProjectId; createdAt: string; + runtimeMode: RuntimeMode; branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; @@ -62,6 +64,7 @@ interface ComposerThreadDraftState { export interface DraftThreadState { projectId: ProjectId; createdAt: string; + runtimeMode: RuntimeMode; branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; @@ -85,6 +88,7 @@ interface ComposerDraftStoreState { worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; + runtimeMode?: RuntimeMode; }, ) => void; setDraftThreadContext: ( @@ -95,6 +99,7 @@ interface ComposerDraftStoreState { projectId?: ProjectId; createdAt?: string; envMode?: DraftThreadEnvMode; + runtimeMode?: RuntimeMode; }, ) => void; clearProjectDraftThreadId: (projectId: ProjectId) => void; @@ -260,6 +265,11 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer typeof createdAt === "string" && createdAt.length > 0 ? createdAt : new Date().toISOString(), + runtimeMode: + candidateDraftThread.runtimeMode === "approval-required" || + candidateDraftThread.runtimeMode === "full-access" + ? candidateDraftThread.runtimeMode + : DEFAULT_RUNTIME_MODE, branch: typeof branch === "string" ? branch : null, worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), @@ -286,6 +296,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer draftThreadsByThreadId[threadId as ThreadId] = { projectId: projectId as ProjectId, createdAt: new Date().toISOString(), + runtimeMode: DEFAULT_RUNTIME_MODE, branch: null, worktreePath: null, envMode: "local", @@ -488,6 +499,7 @@ export const useComposerDraftStore = create()( const nextDraftThread: DraftThreadState = { projectId, createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), + runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, branch: options?.branch === undefined ? (existingThread?.branch ?? null) @@ -502,6 +514,7 @@ export const useComposerDraftStore = create()( existingThread && existingThread.projectId === nextDraftThread.projectId && existingThread.createdAt === nextDraftThread.createdAt && + existingThread.runtimeMode === nextDraftThread.runtimeMode && existingThread.branch === nextDraftThread.branch && existingThread.worktreePath === nextDraftThread.worktreePath && existingThread.envMode === nextDraftThread.envMode; @@ -556,6 +569,7 @@ export const useComposerDraftStore = create()( options.createdAt === undefined ? existing.createdAt : options.createdAt || existing.createdAt, + runtimeMode: options.runtimeMode ?? existing.runtimeMode, branch: options.branch === undefined ? existing.branch : (options.branch ?? null), worktreePath: nextWorktreePath, envMode: @@ -565,6 +579,7 @@ export const useComposerDraftStore = create()( const isUnchanged = nextDraftThread.projectId === existing.projectId && nextDraftThread.createdAt === existing.createdAt && + nextDraftThread.runtimeMode === existing.runtimeMode && nextDraftThread.branch === existing.branch && nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode; diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 5e88fe42ba7911a8f22b8f3b6df7bf618682b042..18ccd9669f1d30224e8a44f754bad0330c37ef0e 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -8,7 +8,6 @@ import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { ensureNativeApi } from "../nativeApi"; -import { useStore } from "../store"; import { preferredTerminalEditor } from "../terminal-links"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; @@ -33,19 +32,6 @@ const THEME_OPTIONS = [ }, ] as const; -const RUNTIME_MODE_OPTIONS = [ - { - value: "full-access", - label: "Full access", - description: "Allow commands to run without confirmation prompts.", - }, - { - value: "approval-required", - label: "Supervised", - description: "Require approval prompts before command execution.", - }, -] as const; - const MODEL_PROVIDER_SETTINGS: Array<{ provider: ProviderKind; title: string; @@ -120,8 +106,6 @@ function patchCustomModels(provider: ProviderKind, models: string[]) { function SettingsRouteView() { const { theme, setTheme, resolvedTheme } = useTheme(); - const runtimeMode = useStore((store) => store.runtimeMode); - const setRuntimeMode = useStore((store) => store.setRuntimeMode); const { settings, defaults, updateSettings } = useAppSettings(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); @@ -469,47 +453,6 @@ function SettingsRouteView() { -
-
-

Runtime Mode

-

- Select the default execution policy for this client. -

-
- -
- {RUNTIME_MODE_OPTIONS.map((option) => { - const selected = runtimeMode === option.value; - return ( - - ); - })} -
-
-

Responses

diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 4c9fe09b3465d2ae87d24c81312bce778a9ffa83..03d84c5fcdc6869320fd8b3b1e3b6a0f7c23227d 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -2,7 +2,7 @@ import { ProjectId, ThreadId, TurnId, type OrchestrationReadModel } from "@t3too import { describe, expect, it } from "vitest"; import { markThreadUnread, syncServerReadModel, type AppState } from "./store"; -import type { Thread } from "./types"; +import { DEFAULT_RUNTIME_MODE, type Thread } from "./types"; function makeThread(overrides: Partial = {}): Thread { return { @@ -11,6 +11,7 @@ function makeThread(overrides: Partial = {}): Thread { projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", model: "gpt-5-codex", + runtimeMode: DEFAULT_RUNTIME_MODE, session: null, messages: [], turnDiffSummaries: [], @@ -38,7 +39,6 @@ function makeState(thread: Thread): AppState { ], threads: [thread], threadsHydrated: true, - runtimeMode: "full-access", }; } @@ -48,6 +48,7 @@ function makeReadModelThread(overrides: Partial(); @@ -56,23 +52,14 @@ function readPersistedState(): AppState { try { const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); if (!raw) return initialState; - const parsed = JSON.parse(raw) as { - runtimeMode?: RuntimeMode; - expandedProjectCwds?: string[]; - }; + const parsed = JSON.parse(raw) as { expandedProjectCwds?: string[] }; persistedExpandedProjectCwds.clear(); for (const cwd of parsed.expandedProjectCwds ?? []) { if (typeof cwd === "string" && cwd.length > 0) { persistedExpandedProjectCwds.add(cwd); } } - return { - ...initialState, - runtimeMode: - parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access" - ? parsed.runtimeMode - : DEFAULT_RUNTIME_MODE, - }; + return { ...initialState }; } catch { return initialState; } @@ -84,7 +71,6 @@ function persistState(state: AppState): void { window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ - runtimeMode: state.runtimeMode, expandedProjectCwds: state.projects .filter((project) => project.expanded) .map((project) => project.cwd), @@ -265,12 +251,13 @@ export function syncServerReadModel( projectId: thread.projectId, title: thread.title, model: resolveModelSlugForProvider( - inferProviderForThreadModel({ - model: thread.model, - sessionProviderName: thread.session?.providerName ?? null, - }), - thread.model, - ), + inferProviderForThreadModel({ + model: thread.model, + sessionProviderName: thread.session?.providerName ?? null, + }), + thread.model, + ), + runtimeMode: thread.runtimeMode, session: thread.session ? { sessionId: @@ -415,11 +402,6 @@ export function setThreadBranch( return threads === state.threads ? state : { ...state, threads }; } -export function setRuntimeMode(state: AppState, mode: RuntimeMode): AppState { - if (state.runtimeMode === mode) return state; - return { ...state, runtimeMode: mode }; -} - // ── Zustand store ──────────────────────────────────────────────────── interface AppStore extends AppState { @@ -434,7 +416,6 @@ interface AppStore extends AppState { branch: string | null, worktreePath: string | null, ) => void; - setRuntimeMode: (mode: RuntimeMode) => void; } export const useStore = create((set) => ({ @@ -453,11 +434,9 @@ export const useStore = create((set) => ({ set((state) => setError(state, threadId, error)), setThreadBranch: (threadId, branch, worktreePath) => set((state) => setThreadBranch(state, threadId, branch, worktreePath)), - setRuntimeMode: (mode) => - set((state) => setRuntimeMode(state, mode)), })); -// Persist on every state change (only runtimeMode + expandedProjectCwds) +// Persist on every state change useStore.subscribe((state) => persistState(state)); export function StoreProvider({ children }: { children: ReactNode }) { diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 214b27cb4f8deed191a7ec62e70d7bf7f5742b43..85362f0a028332daaee38e2fcdc17ed2e5e56248 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -79,6 +79,7 @@ export interface Thread { projectId: ProjectId; title: string; model: string; + runtimeMode: RuntimeMode; session: ThreadSession | null; messages: ChatMessage[]; error: string | null; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index a73b7cd4eb524834b1f009cda98660346b0fe2f8..1da37295d30e81d48214e6793a15302033531d62 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -1,7 +1,7 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import type { Thread } from "./types"; +import { DEFAULT_RUNTIME_MODE, type Thread } from "./types"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; function makeThread(overrides: Partial = {}): Thread { @@ -11,6 +11,7 @@ function makeThread(overrides: Partial = {}): Thread { projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", model: "gpt-5.3-codex", + runtimeMode: DEFAULT_RUNTIME_MODE, session: null, messages: [], turnDiffSummaries: [], diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 9a552a78f61690626f439ff44b5d6a8feaaf3a22..e6dd616ea3b24ba67a744dd4fab73ce8592a15bd 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -223,6 +223,7 @@ export const OrchestrationThread = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, model: TrimmedNonEmptyString, + runtimeMode: RuntimeMode, branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), latestTurn: Schema.NullOr(OrchestrationLatestTurn), @@ -277,6 +278,7 @@ export const ThreadCreateCommand = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, model: TrimmedNonEmptyString, + runtimeMode: RuntimeMode, branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), createdAt: IsoDateTime, @@ -298,6 +300,14 @@ export const ThreadMetaUpdateCommand = Schema.Struct({ worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), }); +export const ThreadRuntimeModeSetCommand = Schema.Struct({ + type: Schema.Literal("thread.runtime-mode.set"), + commandId: CommandId, + threadId: ThreadId, + runtimeMode: RuntimeMode, + createdAt: IsoDateTime, +}); + export const ThreadTurnStartCommand = Schema.Struct({ type: Schema.Literal("thread.turn.start"), commandId: CommandId, @@ -373,6 +383,7 @@ export const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadCreateCommand, ThreadDeleteCommand, ThreadMetaUpdateCommand, + ThreadRuntimeModeSetCommand, ThreadTurnStartCommand, ThreadTurnInterruptCommand, ThreadApprovalRespondCommand, @@ -389,6 +400,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadCreateCommand, ThreadDeleteCommand, ThreadMetaUpdateCommand, + ThreadRuntimeModeSetCommand, ClientThreadTurnStartCommand, ThreadTurnInterruptCommand, ThreadApprovalRespondCommand, @@ -477,6 +489,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.created", "thread.deleted", "thread.meta-updated", + "thread.runtime-mode-set", "thread.message-sent", "thread.turn-start-requested", "thread.turn-interrupt-requested", @@ -523,6 +536,7 @@ export const ThreadCreatedPayload = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, model: TrimmedNonEmptyString, + runtimeMode: RuntimeMode, branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), createdAt: IsoDateTime, @@ -543,6 +557,12 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadRuntimeModeSetPayload = Schema.Struct({ + threadId: ThreadId, + runtimeMode: RuntimeMode, + updatedAt: IsoDateTime, +}); + export const ThreadMessageSentPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, @@ -684,6 +704,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.meta-updated"), payload: ThreadMetaUpdatedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.runtime-mode-set"), + payload: ThreadRuntimeModeSetPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.message-sent"), @@ -768,6 +793,11 @@ export const OrchestrationPersistedEvent = Schema.Union([ eventType: Schema.Literal("thread.meta-updated"), payload: ThreadMetaUpdatedPayload, }), + Schema.Struct({ + ...PersistedEventBaseFields, + eventType: Schema.Literal("thread.runtime-mode-set"), + payload: ThreadRuntimeModeSetPayload, + }), Schema.Struct({ ...PersistedEventBaseFields, eventType: Schema.Literal("thread.message-sent"), diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index fadde48cd9f15ee85c4c30c3a1d550c58973999e..54e9f043168dcbcbc3abbff0846a5e2e9e314159 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -11,6 +11,7 @@ describe("ProviderSessionStartInput", () => { provider: "codex", cwd: "/tmp/workspace", model: "gpt-5.3-codex", + runtimeMode: "full-access", providerOptions: { codex: { binaryPath: "/usr/local/bin/codex", @@ -18,11 +19,19 @@ describe("ProviderSessionStartInput", () => { }, }, }); - expect(parsed.runtimeMode).toBeUndefined(); + expect(parsed.runtimeMode).toBe("full-access"); expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex"); expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex"); }); + it("rejects payloads without runtime mode", () => { + expect(() => + decodeProviderSessionStartInput({ + provider: "codex", + }), + ).toThrow(); + }); + it("accepts claude runtime knobs", () => { const parsed = decodeProviderSessionStartInput({ provider: "claudeCode", diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index d7c83192fc98f0fc087b32b42e16040e0af9e009..fcbbc79338b8b958ec782bf0d83977aae27e3c08 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -37,6 +37,7 @@ export const ProviderSession = Schema.Struct({ sessionId: ProviderSessionId, provider: ProviderKind, status: ProviderSessionStatus, + runtimeMode: RuntimeMode, cwd: Schema.optional(TrimmedNonEmptyStringSchema), model: Schema.optional(TrimmedNonEmptyStringSchema), threadId: Schema.optional(ProviderThreadId), @@ -79,7 +80,7 @@ export const ProviderSessionStartInput = Schema.Struct({ model: Schema.optional(TrimmedNonEmptyStringSchema), resumeCursor: Schema.optional(Schema.Unknown), providerOptions: Schema.optional(ProviderStartOptions), - runtimeMode: Schema.optional(RuntimeMode), + runtimeMode: RuntimeMode, }); export type ProviderSessionStartInput = typeof ProviderSessionStartInput.Type;