| author | |
| committer | |
| log | 36802d707df879e6f5d635f4ec3f89d50045ae20 |
| tree | 3fd60dbdfcc3be89d6bb69c98a7faff10d622d77 |
| parent | 25d20ab7928c6aed5f613f4c3319394635869250 |
37 files changed, 1734 insertions(+), 647 deletions(-)
apps/desktop/package.json+3-2| ... | ... | @@ -2,7 +2,6 @@ |
| 2 | 2 | "name": "@t3tools/desktop", |
| 3 | 3 | "version": "0.0.0", |
| 4 | 4 | "private": true, |
| 5 | "productName": "T3 Code (Alpha)", | |
| 6 | 5 | "main": "dist-electron/main.js", |
| 7 | 6 | "scripts": { |
| 8 | 7 | "dev": "bun run --parallel dev:bundle dev:electron", |
| ... | ... | @@ -18,11 +17,13 @@ |
| 18 | 17 | "electron": "40.6.0" |
| 19 | 18 | }, |
| 20 | 19 | "devDependencies": { |
| 20 | "@t3tools/contracts": "workspace:*", | |
| 21 | 21 | "@types/node": "^22.10.2", |
| 22 | 22 | "electronmon": "^2.0.2", |
| 23 | 23 | "tsdown": "^0.20.3", |
| 24 | 24 | "typescript": "^5.7.3", |
| 25 | 25 | "vitest": "^4.0.0", |
| 26 | 26 | "wait-on": "^8.0.2" |
| 27 | } | |
| 27 | }, | |
| 28 | "productName": "T3 Code (Alpha)" | |
| 28 | 29 | } |
apps/desktop/src/preload.ts+7-7| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { contextBridge, ipcRenderer } from "electron"; |
| 2 | import type { DesktopBridge } from "@t3tools/contracts"; | |
| 2 | 3 | |
| 3 | 4 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 4 | 5 | const CONFIRM_CHANNEL = "desktop:confirm"; |
| ... | ... | @@ -9,12 +10,11 @@ const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; |
| 9 | 10 | |
| 10 | 11 | contextBridge.exposeInMainWorld("desktopBridge", { |
| 11 | 12 | getWsUrl: () => wsUrl, |
| 12 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>, | |
| 13 | confirm: (message: string) => ipcRenderer.invoke(CONFIRM_CHANNEL, message) as Promise<boolean>, | |
| 14 | showContextMenu: (items: { id: string; label: string }[]) => | |
| 15 | ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>, | |
| 16 | openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise<boolean>, | |
| 17 | onMenuAction: (listener: (action: string) => void) => { | |
| 13 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), | |
| 14 | confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), | |
| 15 | showContextMenu: (items) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items), | |
| 16 | openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), | |
| 17 | onMenuAction: (listener) => { | |
| 18 | 18 | const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { |
| 19 | 19 | if (typeof action !== "string") return; |
| 20 | 20 | listener(action); |
| ... | ... | @@ -25,4 +25,4 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 25 | 25 | ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener); |
| 26 | 26 | }; |
| 27 | 27 | }, |
| 28 | }); | |
| 28 | } satisfies DesktopBridge); |
apps/server/integration/OrchestrationEngineHarness.integration.ts created+402| ... | ... | @@ -0,0 +1,402 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import os from "node:os"; | |
| 3 | import path from "node:path"; | |
| 4 | import { execFileSync } from "node:child_process"; | |
| 5 | ||
| 6 | import { NodeServices } from "@effect/platform-node"; | |
| 7 | import { | |
| 8 | ApprovalRequestId, | |
| 9 | type OrchestrationEvent, | |
| 10 | type OrchestrationThread, | |
| 11 | type ThreadId, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import { Effect, Exit, Layer, ManagedRuntime, Option, Schedule, Scope, Stream } from "effect"; | |
| 14 | ||
| 15 | import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; | |
| 16 | import { CheckpointDiffBlobRepositoryLive } from "../src/persistence/Layers/CheckpointDiffBlobs.ts"; | |
| 17 | import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; | |
| 18 | import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; | |
| 19 | import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; | |
| 20 | import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts"; | |
| 21 | import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; | |
| 22 | import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts"; | |
| 23 | import { CheckpointDiffBlobRepository } from "../src/persistence/Services/CheckpointDiffBlobs.ts"; | |
| 24 | import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts"; | |
| 25 | import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts"; | |
| 26 | import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; | |
| 27 | import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; | |
| 28 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; | |
| 29 | import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; | |
| 30 | import { ProviderService } from "../src/provider/Services/ProviderService.ts"; | |
| 31 | import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; | |
| 32 | import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; | |
| 33 | import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; | |
| 34 | import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; | |
| 35 | import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; | |
| 36 | import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; | |
| 37 | import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts"; | |
| 38 | import { | |
| 39 | OrchestrationEngineService, | |
| 40 | type OrchestrationEngineShape, | |
| 41 | } from "../src/orchestration/Services/OrchestrationEngine.ts"; | |
| 42 | import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; | |
| 43 | import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 44 | ||
| 45 | import { | |
| 46 | makeTestProviderAdapterHarness, | |
| 47 | type TestProviderAdapterHarness, | |
| 48 | } from "./TestProviderAdapter.integration.ts"; | |
| 49 | ||
| 50 | function runGit(cwd: string, args: ReadonlyArray<string>) { | |
| 51 | return execFileSync("git", args, { | |
| 52 | cwd, | |
| 53 | stdio: ["ignore", "pipe", "pipe"], | |
| 54 | encoding: "utf8", | |
| 55 | }); | |
| 56 | } | |
| 57 | ||
| 58 | function initializeGitWorkspace(cwd: string) { | |
| 59 | runGit(cwd, ["init", "--initial-branch=main"]); | |
| 60 | runGit(cwd, ["config", "user.email", "test@example.com"]); | |
| 61 | runGit(cwd, ["config", "user.name", "Test User"]); | |
| 62 | fs.writeFileSync(path.join(cwd, "README.md"), "v1\n", "utf8"); | |
| 63 | runGit(cwd, ["add", "."]); | |
| 64 | runGit(cwd, ["commit", "-m", "Initial"]); | |
| 65 | } | |
| 66 | ||
| 67 | export function checkpointRefForTurn(threadId: string, turnCount: number): string { | |
| 68 | const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url"); | |
| 69 | return `refs/t3/checkpoints/${encodedThreadId}/turn/${turnCount}`; | |
| 70 | } | |
| 71 | ||
| 72 | export function gitRefExists(cwd: string, ref: string): boolean { | |
| 73 | try { | |
| 74 | runGit(cwd, ["show-ref", "--verify", "--quiet", ref]); | |
| 75 | return true; | |
| 76 | } catch { | |
| 77 | return false; | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | export function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string { | |
| 82 | return runGit(cwd, ["show", `${ref}:${filePath}`]); | |
| 83 | } | |
| 84 | ||
| 85 | function waitFor<A>( | |
| 86 | read: Effect.Effect<A, unknown>, | |
| 87 | predicate: (value: A) => boolean, | |
| 88 | description: string, | |
| 89 | timeoutMs?: number, | |
| 90 | ): Effect.Effect<A, never>; | |
| 91 | function waitFor<A, B extends A>( | |
| 92 | read: Effect.Effect<A, unknown>, | |
| 93 | predicate: (value: A) => value is B, | |
| 94 | description: string, | |
| 95 | timeoutMs?: number, | |
| 96 | ): Effect.Effect<B, never>; | |
| 97 | function waitFor<A>( | |
| 98 | read: Effect.Effect<A, unknown>, | |
| 99 | predicate: (value: A) => boolean, | |
| 100 | description: string, | |
| 101 | timeoutMs = 3000, | |
| 102 | ): Effect.Effect<A, never> { | |
| 103 | const RETRY_SIGNAL = "wait_for_retry"; | |
| 104 | const retryIntervalMs = 10; | |
| 105 | const maxRetries = Math.max(0, Math.floor(timeoutMs / retryIntervalMs)); | |
| 106 | const retrySchedule = Schedule.spaced(`${retryIntervalMs} millis`); | |
| 107 | ||
| 108 | return read.pipe( | |
| 109 | Effect.filterOrFail(predicate, () => RETRY_SIGNAL), | |
| 110 | Effect.retry({ | |
| 111 | schedule: retrySchedule, | |
| 112 | times: maxRetries, | |
| 113 | while: (error) => error === RETRY_SIGNAL, | |
| 114 | }), | |
| 115 | Effect.mapError((error) => | |
| 116 | error === RETRY_SIGNAL ? new Error(`Timed out waiting for ${description}.`) : error, | |
| 117 | ), | |
| 118 | Effect.orDie, | |
| 119 | ); | |
| 120 | } | |
| 121 | ||
| 122 | export interface OrchestrationIntegrationHarness { | |
| 123 | readonly rootDir: string; | |
| 124 | readonly workspaceDir: string; | |
| 125 | readonly dbPath: string; | |
| 126 | readonly adapterHarness: TestProviderAdapterHarness; | |
| 127 | readonly engine: OrchestrationEngineShape; | |
| 128 | readonly snapshotQuery: ProjectionSnapshotQuery["Service"]; | |
| 129 | readonly providerService: ProviderService["Service"]; | |
| 130 | readonly diffBlobRepository: CheckpointDiffBlobRepository["Service"]; | |
| 131 | readonly checkpointRepository: ProjectionCheckpointRepository["Service"]; | |
| 132 | readonly pendingApprovalRepository: ProjectionPendingApprovalRepository["Service"]; | |
| 133 | readonly waitForThread: ( | |
| 134 | threadId: string, | |
| 135 | predicate: (thread: OrchestrationThread) => boolean, | |
| 136 | timeoutMs?: number, | |
| 137 | ) => Effect.Effect<OrchestrationThread, never>; | |
| 138 | readonly waitForDomainEvent: ( | |
| 139 | predicate: (event: OrchestrationEvent) => boolean, | |
| 140 | timeoutMs?: number, | |
| 141 | ) => Effect.Effect<ReadonlyArray<OrchestrationEvent>, never>; | |
| 142 | readonly waitForPendingApproval: ( | |
| 143 | requestId: string, | |
| 144 | predicate: (row: { | |
| 145 | readonly status: "pending" | "resolved"; | |
| 146 | readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; | |
| 147 | readonly resolvedAt: string | null; | |
| 148 | }) => boolean, | |
| 149 | timeoutMs?: number, | |
| 150 | ) => Effect.Effect< | |
| 151 | { | |
| 152 | readonly status: "pending" | "resolved"; | |
| 153 | readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; | |
| 154 | readonly resolvedAt: string | null; | |
| 155 | }, | |
| 156 | never | |
| 157 | >; | |
| 158 | readonly waitForDiffBlob: ( | |
| 159 | input: { | |
| 160 | readonly threadId: ThreadId; | |
| 161 | readonly fromTurnCount: number; | |
| 162 | readonly toTurnCount: number; | |
| 163 | }, | |
| 164 | timeoutMs?: number, | |
| 165 | ) => Effect.Effect< | |
| 166 | { | |
| 167 | readonly threadId: ThreadId; | |
| 168 | readonly fromTurnCount: number; | |
| 169 | readonly toTurnCount: number; | |
| 170 | readonly diff: string; | |
| 171 | readonly createdAt: string; | |
| 172 | }, | |
| 173 | never | |
| 174 | >; | |
| 175 | readonly dispose: Effect.Effect<void, never>; | |
| 176 | } | |
| 177 | ||
| 178 | export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { | |
| 179 | const sleep = (ms: number) => | |
| 180 | Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms))); | |
| 181 | const adapterHarness = yield* makeTestProviderAdapterHarness; | |
| 182 | ||
| 183 | const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-")); | |
| 184 | const workspaceDir = path.join(rootDir, "workspace"); | |
| 185 | const stateDir = path.join(rootDir, "state"); | |
| 186 | const dbPath = path.join(stateDir, "orchestration.sqlite"); | |
| 187 | fs.mkdirSync(workspaceDir, { recursive: true }); | |
| 188 | fs.mkdirSync(stateDir, { recursive: true }); | |
| 189 | initializeGitWorkspace(workspaceDir); | |
| 190 | ||
| 191 | const registry: typeof ProviderAdapterRegistry.Service = { | |
| 192 | getByProvider: (provider) => | |
| 193 | provider === "codex" | |
| 194 | ? Effect.succeed(adapterHarness.adapter) | |
| 195 | : Effect.fail(new ProviderUnsupportedError({ provider })), | |
| 196 | listProviders: () => Effect.succeed(["codex"]), | |
| 197 | }; | |
| 198 | ||
| 199 | const persistenceLayer = makeSqlitePersistenceLive(dbPath); | |
| 200 | const orchestrationLayer = OrchestrationEngineLive.pipe( | |
| 201 | Layer.provide(OrchestrationProjectionPipelineLive), | |
| 202 | Layer.provide(OrchestrationEventStoreLive), | |
| 203 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | |
| 204 | ); | |
| 205 | const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( | |
| 206 | Layer.provide(ProviderSessionRuntimeRepositoryLive), | |
| 207 | ); | |
| 208 | const providerLayer = makeProviderServiceLive().pipe( | |
| 209 | Layer.provide(providerSessionDirectoryLayer), | |
| 210 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), | |
| 211 | ); | |
| 212 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); | |
| 213 | ||
| 214 | const runtimeServicesLayer = Layer.mergeAll( | |
| 215 | orchestrationLayer, | |
| 216 | OrchestrationProjectionSnapshotQueryLive, | |
| 217 | CheckpointDiffBlobRepositoryLive, | |
| 218 | ProjectionCheckpointRepositoryLive, | |
| 219 | ProjectionPendingApprovalRepositoryLive, | |
| 220 | checkpointStoreLayer, | |
| 221 | providerLayer, | |
| 222 | ); | |
| 223 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( | |
| 224 | Layer.provideMerge(runtimeServicesLayer), | |
| 225 | ); | |
| 226 | const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( | |
| 227 | Layer.provideMerge(runtimeServicesLayer), | |
| 228 | ); | |
| 229 | const checkpointReactorLayer = CheckpointReactorLive.pipe( | |
| 230 | Layer.provideMerge(runtimeServicesLayer), | |
| 231 | ); | |
| 232 | const orchestrationReactorLayer = OrchestrationReactorLive.pipe( | |
| 233 | Layer.provideMerge(runtimeIngestionLayer), | |
| 234 | Layer.provideMerge(providerCommandReactorLayer), | |
| 235 | Layer.provideMerge(checkpointReactorLayer), | |
| 236 | ); | |
| 237 | const layer = orchestrationReactorLayer.pipe( | |
| 238 | Layer.provide(persistenceLayer), | |
| 239 | Layer.provideMerge(NodeServices.layer), | |
| 240 | ); | |
| 241 | ||
| 242 | const runtime = ManagedRuntime.make(layer); | |
| 243 | const engine = yield* Effect.promise(() => | |
| 244 | runtime.runPromise(Effect.service(OrchestrationEngineService)), | |
| 245 | ); | |
| 246 | const reactor = yield* Effect.promise(() => | |
| 247 | runtime.runPromise(Effect.service(OrchestrationReactor)), | |
| 248 | ); | |
| 249 | const snapshotQuery = yield* Effect.promise(() => | |
| 250 | runtime.runPromise(Effect.service(ProjectionSnapshotQuery)), | |
| 251 | ); | |
| 252 | const providerService = yield* Effect.promise(() => | |
| 253 | runtime.runPromise(Effect.service(ProviderService)), | |
| 254 | ); | |
| 255 | const diffBlobRepository = yield* Effect.promise(() => | |
| 256 | runtime.runPromise(Effect.service(CheckpointDiffBlobRepository)), | |
| 257 | ); | |
| 258 | const checkpointRepository = yield* Effect.promise(() => | |
| 259 | runtime.runPromise(Effect.service(ProjectionCheckpointRepository)), | |
| 260 | ); | |
| 261 | const pendingApprovalRepository = yield* Effect.promise(() => | |
| 262 | runtime.runPromise(Effect.service(ProjectionPendingApprovalRepository)), | |
| 263 | ); | |
| 264 | ||
| 265 | const scope = yield* Effect.promise(() => Effect.runPromise(Scope.make("sequential"))); | |
| 266 | yield* Effect.promise(() => runtime.runPromise(reactor.start.pipe(Scope.provide(scope)))); | |
| 267 | yield* sleep(10); | |
| 268 | ||
| 269 | const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = ( | |
| 270 | threadId, | |
| 271 | predicate, | |
| 272 | timeoutMs, | |
| 273 | ) => | |
| 274 | waitFor( | |
| 275 | snapshotQuery | |
| 276 | .getSnapshot() | |
| 277 | .pipe( | |
| 278 | Effect.map( | |
| 279 | (snapshot) => snapshot.threads.find((thread) => thread.id === threadId) ?? null, | |
| 280 | ), | |
| 281 | ), | |
| 282 | (thread): thread is OrchestrationThread => thread !== null && predicate(thread), | |
| 283 | `projected thread '${threadId}'`, | |
| 284 | timeoutMs, | |
| 285 | ) as Effect.Effect<OrchestrationThread, never>; | |
| 286 | ||
| 287 | const waitForDomainEvent: OrchestrationIntegrationHarness["waitForDomainEvent"] = ( | |
| 288 | predicate, | |
| 289 | timeoutMs, | |
| 290 | ) => | |
| 291 | waitFor( | |
| 292 | Stream.runCollect(engine.readEvents(0)).pipe( | |
| 293 | Effect.map((chunk): ReadonlyArray<OrchestrationEvent> => Array.from(chunk)), | |
| 294 | ), | |
| 295 | (events) => events.some(predicate), | |
| 296 | "domain event", | |
| 297 | timeoutMs, | |
| 298 | ); | |
| 299 | ||
| 300 | const waitForPendingApproval: OrchestrationIntegrationHarness["waitForPendingApproval"] = ( | |
| 301 | requestId, | |
| 302 | predicate, | |
| 303 | timeoutMs, | |
| 304 | ) => | |
| 305 | waitFor( | |
| 306 | pendingApprovalRepository | |
| 307 | .getByRequestId({ requestId: ApprovalRequestId.makeUnsafe(requestId) }) | |
| 308 | .pipe( | |
| 309 | Effect.map((row) => | |
| 310 | Option.match(row, { | |
| 311 | onNone: () => null, | |
| 312 | onSome: (value) => ({ | |
| 313 | status: value.status, | |
| 314 | decision: value.decision, | |
| 315 | resolvedAt: value.resolvedAt, | |
| 316 | }), | |
| 317 | }), | |
| 318 | ), | |
| 319 | ), | |
| 320 | ( | |
| 321 | row, | |
| 322 | ): row is { | |
| 323 | readonly status: "pending" | "resolved"; | |
| 324 | readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; | |
| 325 | readonly resolvedAt: string | null; | |
| 326 | } => row !== null && predicate(row), | |
| 327 | `pending approval '${requestId}'`, | |
| 328 | timeoutMs, | |
| 329 | ) as Effect.Effect< | |
| 330 | { | |
| 331 | readonly status: "pending" | "resolved"; | |
| 332 | readonly decision: "accept" | "acceptForSession" | "decline" | "cancel" | null; | |
| 333 | readonly resolvedAt: string | null; | |
| 334 | }, | |
| 335 | never | |
| 336 | >; | |
| 337 | ||
| 338 | const waitForDiffBlob: OrchestrationIntegrationHarness["waitForDiffBlob"] = (input, timeoutMs) => | |
| 339 | waitFor( | |
| 340 | diffBlobRepository.get(input).pipe( | |
| 341 | Effect.map((blob) => | |
| 342 | Option.match(blob, { | |
| 343 | onNone: () => null, | |
| 344 | onSome: (value) => value, | |
| 345 | }), | |
| 346 | ), | |
| 347 | ), | |
| 348 | ( | |
| 349 | blob, | |
| 350 | ): blob is { | |
| 351 | readonly threadId: ThreadId; | |
| 352 | readonly fromTurnCount: number; | |
| 353 | readonly toTurnCount: number; | |
| 354 | readonly diff: string; | |
| 355 | readonly createdAt: string; | |
| 356 | } => blob !== null, | |
| 357 | `checkpoint diff blob ${input.threadId}:${input.fromTurnCount}->${input.toTurnCount}`, | |
| 358 | timeoutMs, | |
| 359 | ) as Effect.Effect< | |
| 360 | { | |
| 361 | readonly threadId: ThreadId; | |
| 362 | readonly fromTurnCount: number; | |
| 363 | readonly toTurnCount: number; | |
| 364 | readonly diff: string; | |
| 365 | readonly createdAt: string; | |
| 366 | }, | |
| 367 | never | |
| 368 | >; | |
| 369 | ||
| 370 | let disposed = false; | |
| 371 | const dispose = Effect.gen(function* () { | |
| 372 | if (disposed) { | |
| 373 | return; | |
| 374 | } | |
| 375 | disposed = true; | |
| 376 | ||
| 377 | yield* Effect.promise(() => runtime.runPromise(providerService.stopAll())); | |
| 378 | yield* Effect.promise(() => Effect.runPromise(Scope.close(scope, Exit.void))); | |
| 379 | yield* Effect.promise(() => runtime.dispose()); | |
| 380 | yield* Effect.sync(() => { | |
| 381 | fs.rmSync(rootDir, { recursive: true, force: true }); | |
| 382 | }); | |
| 383 | }); | |
| 384 | ||
| 385 | return { | |
| 386 | rootDir, | |
| 387 | workspaceDir, | |
| 388 | dbPath, | |
| 389 | adapterHarness, | |
| 390 | engine, | |
| 391 | snapshotQuery, | |
| 392 | providerService, | |
| 393 | diffBlobRepository, | |
| 394 | checkpointRepository, | |
| 395 | pendingApprovalRepository, | |
| 396 | waitForThread, | |
| 397 | waitForDomainEvent, | |
| 398 | waitForPendingApproval, | |
| 399 | waitForDiffBlob, | |
| 400 | dispose, | |
| 401 | } satisfies OrchestrationIntegrationHarness; | |
| 402 | }); |
apps/server/integration/TestProviderAdapter.integration.ts+66-9| ... | ... | @@ -1,8 +1,14 @@ |
| 1 | 1 | import { randomUUID } from "node:crypto"; |
| 2 | 2 | |
| 3 | import type { | |
| 3 | import { | |
| 4 | ApprovalRequestId, | |
| 5 | EventId, | |
| 6 | ProviderApprovalDecision, | |
| 4 | 7 | ProviderRuntimeEvent, |
| 5 | 8 | ProviderSession, |
| 9 | ProviderSessionId, | |
| 10 | ProviderThreadId, | |
| 11 | ProviderTurnId, | |
| 6 | 12 | ProviderTurnStartResult, |
| 7 | 13 | } from "@t3tools/contracts"; |
| 8 | 14 | import { Effect, Queue, Stream } from "effect"; |
| ... | ... | @@ -40,7 +46,15 @@ export interface TestProviderAdapterHarness { |
| 40 | 46 | sessionId: string, |
| 41 | 47 | response: TestTurnResponse, |
| 42 | 48 | ) => Effect.Effect<void, ProviderAdapterSessionNotFoundError>; |
| 49 | readonly queueTurnResponseForNextSession: ( | |
| 50 | response: TestTurnResponse, | |
| 51 | ) => Effect.Effect<void, never>; | |
| 43 | 52 | readonly getRollbackCalls: (sessionId: string) => ReadonlyArray<number>; |
| 53 | readonly getApprovalResponses: (sessionId: string) => ReadonlyArray<{ | |
| 54 | readonly sessionId: ProviderSessionId; | |
| 55 | readonly requestId: ApprovalRequestId; | |
| 56 | readonly decision: ProviderApprovalDecision; | |
| 57 | }>; | |
| 44 | 58 | } |
| 45 | 59 | |
| 46 | 60 | const PROVIDER = "codex" as const; |
| ... | ... | @@ -64,6 +78,15 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { |
| 64 | 78 | const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 65 | 79 | let sessionCount = 0; |
| 66 | 80 | const sessions = new Map<string, SessionState>(); |
| 81 | const queuedResponsesForNextSession: TestTurnResponse[] = []; | |
| 82 | const approvalResponsesBySession = new Map< | |
| 83 | string, | |
| 84 | Array<{ | |
| 85 | readonly sessionId: ProviderSessionId; | |
| 86 | readonly requestId: ApprovalRequestId; | |
| 87 | readonly decision: ProviderApprovalDecision; | |
| 88 | }> | |
| 89 | >(); | |
| 67 | 90 | |
| 68 | 91 | const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event); |
| 69 | 92 | |
| ... | ... | @@ -78,8 +101,8 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { |
| 78 | 101 | } |
| 79 | 102 | |
| 80 | 103 | sessionCount += 1; |
| 81 | const sessionId = `test-session-${sessionCount}`; | |
| 82 | const threadId = `test-thread-${sessionCount}`; | |
| 104 | const sessionId = ProviderSessionId.makeUnsafe(`test-session-${sessionCount}`); | |
| 105 | const threadId = ProviderThreadId.makeUnsafe(`test-thread-${sessionCount}`); | |
| 83 | 106 | const createdAt = nowIso(); |
| 84 | 107 | |
| 85 | 108 | const session: ProviderSession = { |
| ... | ... | @@ -99,7 +122,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { |
| 99 | 122 | turns: [], |
| 100 | 123 | }, |
| 101 | 124 | turnCount: 0, |
| 102 | queuedResponses: [], | |
| 125 | queuedResponses: queuedResponsesForNextSession.splice(0), | |
| 103 | 126 | rollbackCalls: [], |
| 104 | 127 | }); |
| 105 | 128 | |
| ... | ... | @@ -115,7 +138,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { |
| 115 | 138 | |
| 116 | 139 | state.turnCount += 1; |
| 117 | 140 | const turnCount = state.turnCount; |
| 118 | const turnId = `turn-${turnCount}`; | |
| 141 | const turnId = ProviderTurnId.makeUnsafe(`turn-${turnCount}`); | |
| 119 | 142 | |
| 120 | 143 | const response = state.queuedResponses.shift(); |
| 121 | 144 | if (!response) { |
| ... | ... | @@ -182,7 +205,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { |
| 182 | 205 | if (deferredTurnCompletedEvents.length === 0) { |
| 183 | 206 | yield* emit({ |
| 184 | 207 | type: "turn.completed", |
| 185 | eventId: randomUUID(), | |
| 208 | eventId: EventId.makeUnsafe(randomUUID()), | |
| 186 | 209 | provider: PROVIDER, |
| 187 | 210 | sessionId: input.sessionId, |
| 188 | 211 | createdAt: nowIso(), |
| ... | ... | @@ -209,9 +232,20 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { |
| 209 | 232 | |
| 210 | 233 | const respondToRequest: ProviderAdapterShape<ProviderAdapterError>["respondToRequest"] = ( |
| 211 | 234 | sessionId, |
| 212 | _requestId, | |
| 213 | _decision, | |
| 214 | ) => (sessions.has(sessionId) ? Effect.void : missingSessionEffect(sessionId)); | |
| 235 | requestId, | |
| 236 | decision, | |
| 237 | ) => | |
| 238 | sessions.has(sessionId) | |
| 239 | ? Effect.sync(() => { | |
| 240 | const existing = approvalResponsesBySession.get(sessionId) ?? []; | |
| 241 | existing.push({ | |
| 242 | sessionId, | |
| 243 | requestId, | |
| 244 | decision, | |
| 245 | }); | |
| 246 | approvalResponsesBySession.set(sessionId, existing); | |
| 247 | }) | |
| 248 | : missingSessionEffect(sessionId); | |
| 215 | 249 | |
| 216 | 250 | const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (sessionId) => |
| 217 | 251 | Effect.sync(() => { |
| ... | ... | @@ -295,6 +329,13 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { |
| 295 | 329 | ), |
| 296 | 330 | ); |
| 297 | 331 | |
| 332 | const queueTurnResponseForNextSession = ( | |
| 333 | response: TestTurnResponse, | |
| 334 | ): Effect.Effect<void, never> => | |
| 335 | Effect.sync(() => { | |
| 336 | queuedResponsesForNextSession.push(response); | |
| 337 | }); | |
| 338 | ||
| 298 | 339 | const getRollbackCalls = (sessionId: string): ReadonlyArray<number> => { |
| 299 | 340 | const state = sessions.get(sessionId); |
| 300 | 341 | if (!state) { |
| ... | ... | @@ -303,9 +344,25 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { |
| 303 | 344 | return [...state.rollbackCalls]; |
| 304 | 345 | }; |
| 305 | 346 | |
| 347 | const getApprovalResponses = ( | |
| 348 | sessionId: string, | |
| 349 | ): ReadonlyArray<{ | |
| 350 | readonly sessionId: ProviderSessionId; | |
| 351 | readonly requestId: ApprovalRequestId; | |
| 352 | readonly decision: ProviderApprovalDecision; | |
| 353 | }> => { | |
| 354 | const responses = approvalResponsesBySession.get(sessionId); | |
| 355 | if (!responses) { | |
| 356 | return []; | |
| 357 | } | |
| 358 | return [...responses]; | |
| 359 | }; | |
| 360 | ||
| 306 | 361 | return { |
| 307 | 362 | adapter, |
| 308 | 363 | queueTurnResponse, |
| 364 | queueTurnResponseForNextSession, | |
| 309 | 365 | getRollbackCalls, |
| 366 | getApprovalResponses, | |
| 310 | 367 | } satisfies TestProviderAdapterHarness; |
| 311 | 368 | }); |
apps/server/integration/fixtures/providerRuntime.ts+15-7| ... | ... | @@ -1,16 +1,24 @@ |
| 1 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | ApprovalRequestId, | |
| 3 | EventId, | |
| 4 | ProviderSessionId, | |
| 5 | ProviderThreadId, | |
| 6 | ProviderTurnId, | |
| 7 | type ProviderRuntimeEvent, | |
| 8 | } from "@t3tools/contracts"; | |
| 2 | 9 | |
| 3 | 10 | const PROVIDER = "codex" as const; |
| 4 | const SESSION_ID = "fixture-session"; | |
| 5 | const THREAD_ID = "fixture-thread"; | |
| 6 | const TURN_ID = "fixture-turn"; | |
| 11 | const SESSION_ID = ProviderSessionId.makeUnsafe("fixture-session"); | |
| 12 | const THREAD_ID = ProviderThreadId.makeUnsafe("fixture-thread"); | |
| 13 | const TURN_ID = ProviderTurnId.makeUnsafe("fixture-turn"); | |
| 14 | const REQUEST_ID = ApprovalRequestId.makeUnsafe("req-1"); | |
| 7 | 15 | |
| 8 | 16 | function baseEvent(eventId: string, createdAt: string): Pick< |
| 9 | 17 | ProviderRuntimeEvent, |
| 10 | 18 | "eventId" | "provider" | "sessionId" | "createdAt" |
| 11 | 19 | > { |
| 12 | 20 | return { |
| 13 | eventId, | |
| 21 | eventId: EventId.makeUnsafe(eventId), | |
| 14 | 22 | provider: PROVIDER, |
| 15 | 23 | sessionId: SESSION_ID, |
| 16 | 24 | createdAt, |
| ... | ... | @@ -100,7 +108,7 @@ export const codexTurnApprovalFixture = [ |
| 100 | 108 | ...baseEvent("evt-22", "2026-02-23T00:02:00.100Z"), |
| 101 | 109 | threadId: THREAD_ID, |
| 102 | 110 | turnId: TURN_ID, |
| 103 | requestId: "req-1", | |
| 111 | requestId: REQUEST_ID, | |
| 104 | 112 | requestKind: "command", |
| 105 | 113 | detail: "Please approve command", |
| 106 | 114 | }, |
| ... | ... | @@ -109,7 +117,7 @@ export const codexTurnApprovalFixture = [ |
| 109 | 117 | ...baseEvent("evt-23", "2026-02-23T00:02:00.200Z"), |
| 110 | 118 | threadId: THREAD_ID, |
| 111 | 119 | turnId: TURN_ID, |
| 112 | requestId: "req-1", | |
| 120 | requestId: REQUEST_ID, | |
| 113 | 121 | requestKind: "command", |
| 114 | 122 | decision: "accept", |
| 115 | 123 | }, |
apps/server/integration/orchestrationEngine.integration.test.ts created+640| ... | ... | @@ -0,0 +1,640 @@ |
| 1 | import fs from "node:fs"; | |
| 2 | import path from "node:path"; | |
| 3 | ||
| 4 | import { | |
| 5 | ApprovalRequestId, | |
| 6 | CommandId, | |
| 7 | EventId, | |
| 8 | MessageId, | |
| 9 | ProjectId, | |
| 10 | ProviderSessionId, | |
| 11 | ProviderThreadId, | |
| 12 | ProviderTurnId, | |
| 13 | ThreadId, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { assert, it } from "@effect/vitest"; | |
| 16 | import { Effect, Option } from "effect"; | |
| 17 | ||
| 18 | import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts"; | |
| 19 | import { | |
| 20 | checkpointRefForTurn, | |
| 21 | gitRefExists, | |
| 22 | gitShowFileAtRef, | |
| 23 | makeOrchestrationIntegrationHarness, | |
| 24 | type OrchestrationIntegrationHarness, | |
| 25 | } from "./OrchestrationEngineHarness.integration.ts"; | |
| 26 | ||
| 27 | const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); | |
| 28 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | |
| 29 | const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 30 | const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 31 | const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | |
| 32 | const asProviderSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); | |
| 33 | const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); | |
| 34 | const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); | |
| 35 | const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); | |
| 36 | ||
| 37 | const PROJECT_ID = asProjectId("project-1"); | |
| 38 | const THREAD_ID = asThreadId("thread-1"); | |
| 39 | const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session"); | |
| 40 | const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread"); | |
| 41 | const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn"); | |
| 42 | const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); | |
| 43 | ||
| 44 | function nowIso() { | |
| 45 | return new Date().toISOString(); | |
| 46 | } | |
| 47 | ||
| 48 | const sleep = (ms: number) => Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms))); | |
| 49 | ||
| 50 | function waitForSync<A>( | |
| 51 | read: () => A, | |
| 52 | predicate: (value: A) => boolean, | |
| 53 | description: string, | |
| 54 | timeoutMs = 3000, | |
| 55 | ): Effect.Effect<A, never> { | |
| 56 | return Effect.gen(function* () { | |
| 57 | const deadline = Date.now() + timeoutMs; | |
| 58 | ||
| 59 | while (true) { | |
| 60 | const value = read(); | |
| 61 | if (predicate(value)) { | |
| 62 | return value; | |
| 63 | } | |
| 64 | if (Date.now() >= deadline) { | |
| 65 | return yield* Effect.die(new Error(`Timed out waiting for ${description}.`)); | |
| 66 | } | |
| 67 | yield* sleep(10); | |
| 68 | } | |
| 69 | }); | |
| 70 | } | |
| 71 | ||
| 72 | function runtimeBase(eventId: string, createdAt: string) { | |
| 73 | return { | |
| 74 | eventId: asEventId(eventId), | |
| 75 | provider: "codex" as const, | |
| 76 | sessionId: FIXTURE_SESSION_ID, | |
| 77 | createdAt, | |
| 78 | }; | |
| 79 | } | |
| 80 | ||
| 81 | function withHarness<A, E>( | |
| 82 | use: (harness: OrchestrationIntegrationHarness) => Effect.Effect<A, E>, | |
| 83 | ) { | |
| 84 | return Effect.acquireUseRelease( | |
| 85 | makeOrchestrationIntegrationHarness, | |
| 86 | use, | |
| 87 | (harness) => harness.dispose, | |
| 88 | ); | |
| 89 | } | |
| 90 | ||
| 91 | const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => | |
| 92 | Effect.gen(function* () { | |
| 93 | const createdAt = nowIso(); | |
| 94 | ||
| 95 | yield* harness.engine.dispatch({ | |
| 96 | type: "project.create", | |
| 97 | commandId: asCommandId("cmd-project-create"), | |
| 98 | projectId: PROJECT_ID, | |
| 99 | title: "Integration Project", | |
| 100 | workspaceRoot: harness.workspaceDir, | |
| 101 | defaultModel: "gpt-5-codex", | |
| 102 | createdAt, | |
| 103 | }); | |
| 104 | ||
| 105 | yield* harness.engine.dispatch({ | |
| 106 | type: "thread.create", | |
| 107 | commandId: asCommandId("cmd-thread-create"), | |
| 108 | threadId: THREAD_ID, | |
| 109 | projectId: PROJECT_ID, | |
| 110 | title: "Integration Thread", | |
| 111 | model: "gpt-5-codex", | |
| 112 | branch: null, | |
| 113 | worktreePath: harness.workspaceDir, | |
| 114 | createdAt, | |
| 115 | }); | |
| 116 | }); | |
| 117 | ||
| 118 | const startTurn = (input: { | |
| 119 | readonly harness: OrchestrationIntegrationHarness; | |
| 120 | readonly commandId: string; | |
| 121 | readonly messageId: string; | |
| 122 | readonly text: string; | |
| 123 | }) => | |
| 124 | input.harness.engine.dispatch({ | |
| 125 | type: "thread.turn.start", | |
| 126 | commandId: asCommandId(input.commandId), | |
| 127 | threadId: THREAD_ID, | |
| 128 | message: { | |
| 129 | messageId: asMessageId(input.messageId), | |
| 130 | role: "user", | |
| 131 | text: input.text, | |
| 132 | attachments: [], | |
| 133 | }, | |
| 134 | createdAt: nowIso(), | |
| 135 | }); | |
| 136 | ||
| 137 | it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + git", () => | |
| 138 | withHarness((harness) => | |
| 139 | Effect.gen(function* () { | |
| 140 | yield* seedProjectAndThread(harness); | |
| 141 | ||
| 142 | const turnResponse: TestTurnResponse = { | |
| 143 | events: [ | |
| 144 | { | |
| 145 | type: "turn.started", | |
| 146 | ...runtimeBase("evt-single-1", "2026-02-24T10:00:00.000Z"), | |
| 147 | threadId: FIXTURE_THREAD_ID, | |
| 148 | turnId: FIXTURE_TURN_ID, | |
| 149 | }, | |
| 150 | { | |
| 151 | type: "message.delta", | |
| 152 | ...runtimeBase("evt-single-2", "2026-02-24T10:00:00.100Z"), | |
| 153 | threadId: FIXTURE_THREAD_ID, | |
| 154 | turnId: FIXTURE_TURN_ID, | |
| 155 | delta: "Single turn response.\n", | |
| 156 | }, | |
| 157 | { | |
| 158 | type: "turn.completed", | |
| 159 | ...runtimeBase("evt-single-3", "2026-02-24T10:00:00.200Z"), | |
| 160 | threadId: FIXTURE_THREAD_ID, | |
| 161 | turnId: FIXTURE_TURN_ID, | |
| 162 | status: "completed", | |
| 163 | }, | |
| 164 | ], | |
| 165 | }; | |
| 166 | ||
| 167 | yield* harness.adapterHarness.queueTurnResponseForNextSession(turnResponse); | |
| 168 | yield* startTurn({ | |
| 169 | harness, | |
| 170 | commandId: "cmd-turn-start-single", | |
| 171 | messageId: "msg-user-single", | |
| 172 | text: "Say hello", | |
| 173 | }); | |
| 174 | ||
| 175 | const thread = yield* harness.waitForThread( | |
| 176 | THREAD_ID, | |
| 177 | (entry) => | |
| 178 | entry.session?.status === "ready" && | |
| 179 | entry.messages.some((message) => message.role === "assistant" && message.streaming === false) && | |
| 180 | entry.checkpoints.length === 1, | |
| 181 | ); | |
| 182 | assert.equal(thread.checkpoints[0]?.status, "ready"); | |
| 183 | assert.equal(thread.checkpoints[0]?.checkpointTurnCount, 1); | |
| 184 | ||
| 185 | const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ | |
| 186 | threadId: THREAD_ID, | |
| 187 | }); | |
| 188 | assert.equal(checkpointRows.length, 1); | |
| 189 | assert.equal(checkpointRows[0]?.checkpointTurnCount, 1); | |
| 190 | assert.equal(checkpointRows[0]?.status, "ready"); | |
| 191 | assert.deepEqual(checkpointRows[0]?.files, []); | |
| 192 | ||
| 193 | yield* harness.waitForDomainEvent((event) => event.type === "thread.turn-diff-completed"); | |
| 194 | ||
| 195 | const ref0 = checkpointRefForTurn(THREAD_ID, 0); | |
| 196 | const ref1 = checkpointRefForTurn(THREAD_ID, 1); | |
| 197 | assert.equal(gitRefExists(harness.workspaceDir, ref0), true); | |
| 198 | assert.equal(gitRefExists(harness.workspaceDir, ref1), true); | |
| 199 | assert.equal(gitShowFileAtRef(harness.workspaceDir, ref0, "README.md"), "v1\n"); | |
| 200 | assert.equal(gitShowFileAtRef(harness.workspaceDir, ref1, "README.md"), "v1\n"); | |
| 201 | }), | |
| 202 | ), | |
| 203 | ); | |
| 204 | ||
| 205 | it.live("runs multi-turn file edits and persists checkpoint diffs + blobs", () => | |
| 206 | withHarness((harness) => | |
| 207 | Effect.gen(function* () { | |
| 208 | yield* seedProjectAndThread(harness); | |
| 209 | ||
| 210 | yield* harness.adapterHarness.queueTurnResponseForNextSession({ | |
| 211 | events: [ | |
| 212 | { | |
| 213 | type: "turn.started", | |
| 214 | ...runtimeBase("evt-multi-1", "2026-02-24T10:01:00.000Z"), | |
| 215 | threadId: FIXTURE_THREAD_ID, | |
| 216 | turnId: FIXTURE_TURN_ID, | |
| 217 | }, | |
| 218 | { | |
| 219 | type: "tool.started", | |
| 220 | ...runtimeBase("evt-multi-2", "2026-02-24T10:01:00.100Z"), | |
| 221 | threadId: FIXTURE_THREAD_ID, | |
| 222 | turnId: FIXTURE_TURN_ID, | |
| 223 | toolKind: "command", | |
| 224 | title: "Edit file", | |
| 225 | detail: "README.md", | |
| 226 | }, | |
| 227 | { | |
| 228 | type: "tool.completed", | |
| 229 | ...runtimeBase("evt-multi-3", "2026-02-24T10:01:00.200Z"), | |
| 230 | threadId: FIXTURE_THREAD_ID, | |
| 231 | turnId: FIXTURE_TURN_ID, | |
| 232 | toolKind: "command", | |
| 233 | title: "Edit file", | |
| 234 | detail: "README.md", | |
| 235 | }, | |
| 236 | { | |
| 237 | type: "message.delta", | |
| 238 | ...runtimeBase("evt-multi-4", "2026-02-24T10:01:00.300Z"), | |
| 239 | threadId: FIXTURE_THREAD_ID, | |
| 240 | turnId: FIXTURE_TURN_ID, | |
| 241 | delta: "Updated README to v2.\n", | |
| 242 | }, | |
| 243 | { | |
| 244 | type: "turn.completed", | |
| 245 | ...runtimeBase("evt-multi-5", "2026-02-24T10:01:00.400Z"), | |
| 246 | threadId: FIXTURE_THREAD_ID, | |
| 247 | turnId: FIXTURE_TURN_ID, | |
| 248 | status: "completed", | |
| 249 | }, | |
| 250 | ], | |
| 251 | mutateWorkspace: ({ cwd }) => | |
| 252 | Effect.sync(() => { | |
| 253 | fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); | |
| 254 | }), | |
| 255 | }); | |
| 256 | ||
| 257 | yield* startTurn({ | |
| 258 | harness, | |
| 259 | commandId: "cmd-turn-start-multi-1", | |
| 260 | messageId: "msg-user-multi-1", | |
| 261 | text: "Make first edit", | |
| 262 | }); | |
| 263 | ||
| 264 | const firstTurnThread = yield* harness.waitForThread( | |
| 265 | THREAD_ID, | |
| 266 | (entry) => entry.checkpoints.length === 1 && entry.session?.providerSessionId !== null, | |
| 267 | ); | |
| 268 | const sessionId = firstTurnThread.session?.providerSessionId; | |
| 269 | assert.equal(sessionId !== null, true); | |
| 270 | if (!sessionId) { | |
| 271 | return; | |
| 272 | } | |
| 273 | ||
| 274 | yield* harness.adapterHarness.queueTurnResponse(sessionId, { | |
| 275 | events: [ | |
| 276 | { | |
| 277 | type: "turn.started", | |
| 278 | ...runtimeBase("evt-multi-6", "2026-02-24T10:02:00.000Z"), | |
| 279 | threadId: FIXTURE_THREAD_ID, | |
| 280 | turnId: FIXTURE_TURN_ID, | |
| 281 | }, | |
| 282 | { | |
| 283 | type: "message.delta", | |
| 284 | ...runtimeBase("evt-multi-7", "2026-02-24T10:02:00.100Z"), | |
| 285 | threadId: FIXTURE_THREAD_ID, | |
| 286 | turnId: FIXTURE_TURN_ID, | |
| 287 | delta: "Updated README to v3.\n", | |
| 288 | }, | |
| 289 | { | |
| 290 | type: "turn.completed", | |
| 291 | ...runtimeBase("evt-multi-8", "2026-02-24T10:02:00.200Z"), | |
| 292 | threadId: FIXTURE_THREAD_ID, | |
| 293 | turnId: FIXTURE_TURN_ID, | |
| 294 | status: "completed", | |
| 295 | }, | |
| 296 | ], | |
| 297 | mutateWorkspace: ({ cwd }) => | |
| 298 | Effect.sync(() => { | |
| 299 | fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); | |
| 300 | }), | |
| 301 | }); | |
| 302 | ||
| 303 | yield* startTurn({ | |
| 304 | harness, | |
| 305 | commandId: "cmd-turn-start-multi-2", | |
| 306 | messageId: "msg-user-multi-2", | |
| 307 | text: "Make second edit", | |
| 308 | }); | |
| 309 | ||
| 310 | const secondTurnThread = yield* harness.waitForThread( | |
| 311 | THREAD_ID, | |
| 312 | (entry) => | |
| 313 | entry.latestTurnId === "turn-2" && | |
| 314 | entry.checkpoints.length === 2 && | |
| 315 | entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 2), | |
| 316 | ); | |
| 317 | const secondCheckpoint = secondTurnThread.checkpoints.find( | |
| 318 | (checkpoint) => checkpoint.checkpointTurnCount === 2, | |
| 319 | ); | |
| 320 | assert.equal(secondCheckpoint?.files.some((file) => file.path === "README.md"), true); | |
| 321 | ||
| 322 | const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ | |
| 323 | threadId: THREAD_ID, | |
| 324 | }); | |
| 325 | assert.deepEqual( | |
| 326 | checkpointRows.map((row) => row.checkpointTurnCount), | |
| 327 | [1, 2], | |
| 328 | ); | |
| 329 | ||
| 330 | const diffBlob = yield* harness.waitForDiffBlob({ | |
| 331 | threadId: THREAD_ID, | |
| 332 | fromTurnCount: 1, | |
| 333 | toTurnCount: 2, | |
| 334 | }); | |
| 335 | assert.equal(diffBlob.diff.includes("README.md"), true); | |
| 336 | ||
| 337 | assert.equal( | |
| 338 | gitShowFileAtRef(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 1), "README.md"), | |
| 339 | "v2\n", | |
| 340 | ); | |
| 341 | assert.equal( | |
| 342 | gitShowFileAtRef(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 2), "README.md"), | |
| 343 | "v3\n", | |
| 344 | ); | |
| 345 | }), | |
| 346 | ), | |
| 347 | ); | |
| 348 | ||
| 349 | it.live("tracks approval requests and resolves pending approvals on user response", () => | |
| 350 | withHarness((harness) => | |
| 351 | Effect.gen(function* () { | |
| 352 | yield* seedProjectAndThread(harness); | |
| 353 | ||
| 354 | yield* harness.adapterHarness.queueTurnResponseForNextSession({ | |
| 355 | events: [ | |
| 356 | { | |
| 357 | type: "turn.started", | |
| 358 | ...runtimeBase("evt-approval-1", "2026-02-24T10:03:00.000Z"), | |
| 359 | threadId: FIXTURE_THREAD_ID, | |
| 360 | turnId: FIXTURE_TURN_ID, | |
| 361 | }, | |
| 362 | { | |
| 363 | type: "approval.requested", | |
| 364 | ...runtimeBase("evt-approval-2", "2026-02-24T10:03:00.100Z"), | |
| 365 | threadId: FIXTURE_THREAD_ID, | |
| 366 | turnId: FIXTURE_TURN_ID, | |
| 367 | requestId: APPROVAL_REQUEST_ID, | |
| 368 | requestKind: "command", | |
| 369 | detail: "Approve command execution", | |
| 370 | }, | |
| 371 | { | |
| 372 | type: "turn.completed", | |
| 373 | ...runtimeBase("evt-approval-3", "2026-02-24T10:03:00.200Z"), | |
| 374 | threadId: FIXTURE_THREAD_ID, | |
| 375 | turnId: FIXTURE_TURN_ID, | |
| 376 | status: "completed", | |
| 377 | }, | |
| 378 | ], | |
| 379 | }); | |
| 380 | ||
| 381 | yield* startTurn({ | |
| 382 | harness, | |
| 383 | commandId: "cmd-turn-start-approval", | |
| 384 | messageId: "msg-user-approval", | |
| 385 | text: "Run command needing approval", | |
| 386 | }); | |
| 387 | ||
| 388 | const thread = yield* harness.waitForThread( | |
| 389 | THREAD_ID, | |
| 390 | (entry) => entry.activities.some((activity) => activity.kind === "approval.requested"), | |
| 391 | ); | |
| 392 | assert.equal( | |
| 393 | thread.activities.some((activity) => activity.kind === "approval.requested"), | |
| 394 | true, | |
| 395 | ); | |
| 396 | ||
| 397 | const pendingRow = yield* harness.waitForPendingApproval( | |
| 398 | "req-approval-1", | |
| 399 | (row) => row.status === "pending" && row.decision === null, | |
| 400 | ); | |
| 401 | assert.equal(pendingRow.status, "pending"); | |
| 402 | ||
| 403 | yield* harness.engine.dispatch({ | |
| 404 | type: "thread.approval.respond", | |
| 405 | commandId: asCommandId("cmd-approval-respond"), | |
| 406 | threadId: THREAD_ID, | |
| 407 | requestId: APPROVAL_REQUEST_ID, | |
| 408 | decision: "accept", | |
| 409 | createdAt: nowIso(), | |
| 410 | }); | |
| 411 | ||
| 412 | const resolvedRow = yield* harness.waitForPendingApproval( | |
| 413 | "req-approval-1", | |
| 414 | (row) => row.status === "resolved" && row.decision === "accept", | |
| 415 | ); | |
| 416 | assert.equal(resolvedRow.status, "resolved"); | |
| 417 | assert.equal(resolvedRow.decision, "accept"); | |
| 418 | ||
| 419 | const providerSessionId = thread.session?.providerSessionId ?? "test-session-1"; | |
| 420 | const approvalResponses = yield* waitForSync( | |
| 421 | () => harness.adapterHarness.getApprovalResponses(providerSessionId), | |
| 422 | (responses) => responses.length === 1, | |
| 423 | "provider approval response", | |
| 424 | ); | |
| 425 | assert.equal(approvalResponses.length, 1); | |
| 426 | assert.equal(approvalResponses[0]?.requestId, "req-approval-1"); | |
| 427 | assert.equal(approvalResponses[0]?.decision, "accept"); | |
| 428 | }), | |
| 429 | ), | |
| 430 | ); | |
| 431 | ||
| 432 | it.live("records failed turn runtime state and checkpoint status as error", () => | |
| 433 | withHarness((harness) => | |
| 434 | Effect.gen(function* () { | |
| 435 | yield* seedProjectAndThread(harness); | |
| 436 | ||
| 437 | yield* harness.adapterHarness.queueTurnResponseForNextSession({ | |
| 438 | events: [ | |
| 439 | { | |
| 440 | type: "turn.started", | |
| 441 | ...runtimeBase("evt-failure-1", "2026-02-24T10:04:00.000Z"), | |
| 442 | threadId: FIXTURE_THREAD_ID, | |
| 443 | turnId: FIXTURE_TURN_ID, | |
| 444 | }, | |
| 445 | { | |
| 446 | type: "message.delta", | |
| 447 | ...runtimeBase("evt-failure-2", "2026-02-24T10:04:00.100Z"), | |
| 448 | threadId: FIXTURE_THREAD_ID, | |
| 449 | turnId: FIXTURE_TURN_ID, | |
| 450 | delta: "Partial output before failure.\n", | |
| 451 | }, | |
| 452 | { | |
| 453 | type: "runtime.error", | |
| 454 | ...runtimeBase("evt-failure-3", "2026-02-24T10:04:00.200Z"), | |
| 455 | threadId: FIXTURE_THREAD_ID, | |
| 456 | turnId: FIXTURE_TURN_ID, | |
| 457 | message: "Sandbox command failed.", | |
| 458 | }, | |
| 459 | { | |
| 460 | type: "turn.completed", | |
| 461 | ...runtimeBase("evt-failure-4", "2026-02-24T10:04:00.300Z"), | |
| 462 | threadId: FIXTURE_THREAD_ID, | |
| 463 | turnId: FIXTURE_TURN_ID, | |
| 464 | status: "failed", | |
| 465 | errorMessage: "Sandbox command failed.", | |
| 466 | }, | |
| 467 | ], | |
| 468 | }); | |
| 469 | ||
| 470 | yield* startTurn({ | |
| 471 | harness, | |
| 472 | commandId: "cmd-turn-start-failure", | |
| 473 | messageId: "msg-user-failure", | |
| 474 | text: "Run risky command", | |
| 475 | }); | |
| 476 | ||
| 477 | const thread = yield* harness.waitForThread( | |
| 478 | THREAD_ID, | |
| 479 | (entry) => | |
| 480 | entry.session?.status === "error" && | |
| 481 | entry.session?.lastError === "Sandbox command failed." && | |
| 482 | entry.activities.some((activity) => activity.kind === "runtime.error") && | |
| 483 | entry.checkpoints.length === 1, | |
| 484 | ); | |
| 485 | assert.equal(thread.session?.status, "error"); | |
| 486 | assert.equal(thread.checkpoints[0]?.status, "error"); | |
| 487 | ||
| 488 | const checkpointRow = yield* harness.checkpointRepository.getByThreadAndTurnCount({ | |
| 489 | threadId: THREAD_ID, | |
| 490 | checkpointTurnCount: 1, | |
| 491 | }); | |
| 492 | assert.equal(Option.isSome(checkpointRow), true); | |
| 493 | if (Option.isSome(checkpointRow)) { | |
| 494 | assert.equal(checkpointRow.value.status, "error"); | |
| 495 | } | |
| 496 | assert.equal(gitRefExists(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 1)), true); | |
| 497 | }), | |
| 498 | ), | |
| 499 | ); | |
| 500 | ||
| 501 | it.live("reverts to an earlier checkpoint and trims checkpoint projections + git refs", () => | |
| 502 | withHarness((harness) => | |
| 503 | Effect.gen(function* () { | |
| 504 | yield* seedProjectAndThread(harness); | |
| 505 | ||
| 506 | yield* harness.adapterHarness.queueTurnResponseForNextSession({ | |
| 507 | events: [ | |
| 508 | { | |
| 509 | type: "turn.started", | |
| 510 | ...runtimeBase("evt-revert-1", "2026-02-24T10:05:00.000Z"), | |
| 511 | threadId: FIXTURE_THREAD_ID, | |
| 512 | turnId: FIXTURE_TURN_ID, | |
| 513 | }, | |
| 514 | { | |
| 515 | type: "turn.completed", | |
| 516 | ...runtimeBase("evt-revert-2", "2026-02-24T10:05:00.100Z"), | |
| 517 | threadId: FIXTURE_THREAD_ID, | |
| 518 | turnId: FIXTURE_TURN_ID, | |
| 519 | status: "completed", | |
| 520 | }, | |
| 521 | ], | |
| 522 | mutateWorkspace: ({ cwd }) => | |
| 523 | Effect.sync(() => { | |
| 524 | fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); | |
| 525 | }), | |
| 526 | }); | |
| 527 | yield* startTurn({ | |
| 528 | harness, | |
| 529 | commandId: "cmd-turn-start-revert-1", | |
| 530 | messageId: "msg-user-revert-1", | |
| 531 | text: "First edit", | |
| 532 | }); | |
| 533 | ||
| 534 | const firstTurnThread = yield* harness.waitForThread( | |
| 535 | THREAD_ID, | |
| 536 | (entry) => entry.session?.providerSessionId !== null && entry.checkpoints.length === 1, | |
| 537 | ); | |
| 538 | const sessionId = firstTurnThread.session?.providerSessionId; | |
| 539 | assert.equal(sessionId !== null, true); | |
| 540 | if (!sessionId) { | |
| 541 | return; | |
| 542 | } | |
| 543 | ||
| 544 | yield* harness.adapterHarness.queueTurnResponse(sessionId, { | |
| 545 | events: [ | |
| 546 | { | |
| 547 | type: "turn.started", | |
| 548 | ...runtimeBase("evt-revert-3", "2026-02-24T10:05:01.000Z"), | |
| 549 | threadId: FIXTURE_THREAD_ID, | |
| 550 | turnId: FIXTURE_TURN_ID, | |
| 551 | }, | |
| 552 | { | |
| 553 | type: "turn.completed", | |
| 554 | ...runtimeBase("evt-revert-4", "2026-02-24T10:05:01.100Z"), | |
| 555 | threadId: FIXTURE_THREAD_ID, | |
| 556 | turnId: FIXTURE_TURN_ID, | |
| 557 | status: "completed", | |
| 558 | }, | |
| 559 | ], | |
| 560 | mutateWorkspace: ({ cwd }) => | |
| 561 | Effect.sync(() => { | |
| 562 | fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); | |
| 563 | }), | |
| 564 | }); | |
| 565 | yield* startTurn({ | |
| 566 | harness, | |
| 567 | commandId: "cmd-turn-start-revert-2", | |
| 568 | messageId: "msg-user-revert-2", | |
| 569 | text: "Second edit", | |
| 570 | }); | |
| 571 | ||
| 572 | yield* harness.waitForThread( | |
| 573 | THREAD_ID, | |
| 574 | (entry) => entry.latestTurnId === "turn-2" && entry.checkpoints.length === 2, | |
| 575 | 8000, | |
| 576 | ); | |
| 577 | ||
| 578 | yield* harness.engine.dispatch({ | |
| 579 | type: "thread.checkpoint.revert", | |
| 580 | commandId: asCommandId("cmd-checkpoint-revert"), | |
| 581 | threadId: THREAD_ID, | |
| 582 | turnCount: 1, | |
| 583 | createdAt: nowIso(), | |
| 584 | }); | |
| 585 | ||
| 586 | yield* harness.waitForDomainEvent((event) => event.type === "thread.reverted"); | |
| 587 | const revertedThread = yield* harness.waitForThread( | |
| 588 | THREAD_ID, | |
| 589 | (entry) => | |
| 590 | entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1, | |
| 591 | ); | |
| 592 | assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1); | |
| 593 | assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n"); | |
| 594 | assert.equal(gitRefExists(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 2)), false); | |
| 595 | assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]); | |
| 596 | ||
| 597 | const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ | |
| 598 | threadId: THREAD_ID, | |
| 599 | }); | |
| 600 | assert.equal(checkpointRows.length, 1); | |
| 601 | }), | |
| 602 | ), | |
| 603 | ); | |
| 604 | ||
| 605 | it.live("appends checkpoint.revert.failed activity when revert is requested without an active session", () => | |
| 606 | withHarness((harness) => | |
| 607 | Effect.gen(function* () { | |
| 608 | yield* seedProjectAndThread(harness); | |
| 609 | ||
| 610 | yield* harness.engine.dispatch({ | |
| 611 | type: "thread.checkpoint.revert", | |
| 612 | commandId: asCommandId("cmd-checkpoint-revert-no-session"), | |
| 613 | threadId: THREAD_ID, | |
| 614 | turnCount: 0, | |
| 615 | createdAt: nowIso(), | |
| 616 | }); | |
| 617 | ||
| 618 | const thread = yield* harness.waitForThread( | |
| 619 | THREAD_ID, | |
| 620 | (entry) => | |
| 621 | entry.activities.some( | |
| 622 | (activity) => | |
| 623 | activity.kind === "checkpoint.revert.failed" && | |
| 624 | typeof activity.payload === "object" && | |
| 625 | activity.payload !== null, | |
| 626 | ), | |
| 627 | ); | |
| 628 | const failureActivity = thread.activities.find( | |
| 629 | (activity) => activity.kind === "checkpoint.revert.failed", | |
| 630 | ); | |
| 631 | assert.equal(failureActivity !== undefined, true); | |
| 632 | assert.equal( | |
| 633 | String((failureActivity?.payload as { readonly detail?: string } | undefined)?.detail).includes( | |
| 634 | "No active provider session", | |
| 635 | ), | |
| 636 | true, | |
| 637 | ); | |
| 638 | }), | |
| 639 | ), | |
| 640 | ); |
apps/server/integration/providerService.integration.test.ts+2-2| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; | |
| 1 | import type { ProviderRuntimeEvent, ProviderSessionId } from "@t3tools/contracts"; | |
| 2 | 2 | import { NodeServices } from "@effect/platform-node"; |
| 3 | 3 | import { it, assert } from "@effect/vitest"; |
| 4 | 4 | import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect"; |
| ... | ... | @@ -92,7 +92,7 @@ const collectEventsDuring = <A, E, R>( |
| 92 | 92 | const runTurn = (input: { |
| 93 | 93 | readonly provider: ProviderServiceShape; |
| 94 | 94 | readonly harness: TestProviderAdapterHarness; |
| 95 | readonly sessionId: string; | |
| 95 | readonly sessionId: ProviderSessionId; | |
| 96 | 96 | readonly userText: string; |
| 97 | 97 | readonly response: TestTurnResponse; |
| 98 | 98 | }) => |
apps/server/src/checkpointing/Layers/CheckpointStore.ts+21-29| ... | ... | @@ -132,14 +132,12 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 132 | 132 | }); |
| 133 | 133 | const treeOid = writeTreeResult.stdout.trim(); |
| 134 | 134 | if (treeOid.length === 0) { |
| 135 | return yield* Effect.fail( | |
| 136 | new GitCommandError({ | |
| 137 | operation, | |
| 138 | command: "git write-tree", | |
| 139 | cwd: normalizedCwd, | |
| 140 | detail: "git write-tree returned an empty tree oid.", | |
| 141 | }), | |
| 142 | ); | |
| 135 | return yield* new GitCommandError({ | |
| 136 | operation, | |
| 137 | command: "git write-tree", | |
| 138 | cwd: normalizedCwd, | |
| 139 | detail: "git write-tree returned an empty tree oid.", | |
| 140 | }); | |
| 143 | 141 | } |
| 144 | 142 | |
| 145 | 143 | const message = `t3 checkpoint ref=${checkpointRef}`; |
| ... | ... | @@ -151,14 +149,12 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 151 | 149 | }); |
| 152 | 150 | const commitOid = commitTreeResult.stdout.trim(); |
| 153 | 151 | if (commitOid.length === 0) { |
| 154 | return yield* Effect.fail( | |
| 155 | new GitCommandError({ | |
| 156 | operation, | |
| 157 | command: "git commit-tree", | |
| 158 | cwd: normalizedCwd, | |
| 159 | detail: "git commit-tree returned an empty commit oid.", | |
| 160 | }), | |
| 161 | ); | |
| 152 | return yield* new GitCommandError({ | |
| 153 | operation, | |
| 154 | command: "git commit-tree", | |
| 155 | cwd: normalizedCwd, | |
| 156 | detail: "git commit-tree returned an empty commit oid.", | |
| 157 | }); | |
| 162 | 158 | } |
| 163 | 159 | |
| 164 | 160 | yield* git.execute({ |
| ... | ... | @@ -183,11 +179,9 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 183 | 179 | }); |
| 184 | 180 | |
| 185 | 181 | const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) => |
| 186 | Effect.gen(function* () { | |
| 187 | return yield* resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe( | |
| 188 | Effect.map((commit) => commit !== null), | |
| 189 | ); | |
| 190 | }); | |
| 182 | resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe( | |
| 183 | Effect.map((commit) => commit !== null), | |
| 184 | ); | |
| 191 | 185 | |
| 192 | 186 | const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = (input) => |
| 193 | 187 | Effect.gen(function* () { |
| ... | ... | @@ -241,14 +235,12 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 241 | 235 | } |
| 242 | 236 | |
| 243 | 237 | if (!fromCommitOid || !toCommitOid) { |
| 244 | return yield* Effect.fail( | |
| 245 | new GitCommandError({ | |
| 246 | operation, | |
| 247 | command: "git diff", | |
| 248 | cwd: input.cwd, | |
| 249 | detail: "Checkpoint ref is unavailable for diff operation.", | |
| 250 | }), | |
| 251 | ); | |
| 238 | return yield* new GitCommandError({ | |
| 239 | operation, | |
| 240 | command: "git diff", | |
| 241 | cwd: input.cwd, | |
| 242 | detail: "Checkpoint ref is unavailable for diff operation.", | |
| 243 | }); | |
| 252 | 244 | } |
| 253 | 245 | |
| 254 | 246 | const result = yield* git.execute({ |
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+4-6| ... | ... | @@ -103,12 +103,10 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 103 | 103 | const existingRuntime = Option.getOrUndefined(existing); |
| 104 | 104 | const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; |
| 105 | 105 | if (!resolvedThreadId) { |
| 106 | return yield* Effect.fail( | |
| 107 | new ProviderValidationError({ | |
| 108 | operation: "ProviderSessionDirectory.upsert", | |
| 109 | issue: "threadId must be a non-empty string.", | |
| 110 | }), | |
| 111 | ); | |
| 106 | return yield* new ProviderValidationError({ | |
| 107 | operation: "ProviderSessionDirectory.upsert", | |
| 108 | issue: "threadId must be a non-empty string.", | |
| 109 | }); | |
| 112 | 110 | } |
| 113 | 111 | |
| 114 | 112 | const now = new Date().toISOString(); |
apps/server/src/wsServer.test.ts+50-28| ... | ... | @@ -189,7 +189,13 @@ function waitForMessage(ws: WebSocket): Promise<unknown> { |
| 189 | 189 | |
| 190 | 190 | async function sendRequest(ws: WebSocket, method: string, params?: unknown): Promise<WsResponse> { |
| 191 | 191 | const id = crypto.randomUUID(); |
| 192 | const message = JSON.stringify({ id, method, ...(params !== undefined ? { params } : {}) }); | |
| 192 | const body = | |
| 193 | method === ORCHESTRATION_WS_METHODS.dispatchCommand | |
| 194 | ? { _tag: method, command: params } | |
| 195 | : params && typeof params === "object" && !Array.isArray(params) | |
| 196 | ? { _tag: method, ...(params as Record<string, unknown>) } | |
| 197 | : { _tag: method }; | |
| 198 | const message = JSON.stringify({ id, body }); | |
| 193 | 199 | ws.send(message); |
| 194 | 200 | |
| 195 | 201 | // Wait for response with matching id |
| ... | ... | @@ -198,6 +204,9 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro |
| 198 | 204 | if (parsed.id === id) { |
| 199 | 205 | return parsed as WsResponse; |
| 200 | 206 | } |
| 207 | if (parsed.id === "unknown") { | |
| 208 | return parsed as WsResponse; | |
| 209 | } | |
| 201 | 210 | } |
| 202 | 211 | } |
| 203 | 212 | |
| ... | ... | @@ -658,18 +667,27 @@ describe("WebSocket Server", () => { |
| 658 | 667 | |
| 659 | 668 | const response = await sendRequest(ws, "nonexistent.method"); |
| 660 | 669 | expect(response.error).toBeDefined(); |
| 661 | expect(response.error!.message).toContain("Unknown method"); | |
| 670 | expect(response.error!.message).toContain("Invalid request format"); | |
| 662 | 671 | }); |
| 663 | 672 | |
| 664 | 673 | it("keeps orchestration domain push behavior for provider runtime events", async () => { |
| 665 | 674 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 675 | const sessionId = "sess-test"; | |
| 666 | 676 | const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { |
| 667 | 677 | Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); |
| 668 | 678 | }; |
| 669 | 679 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; |
| 670 | 680 | const providerService: ProviderServiceShape = { |
| 671 | startSession: () => unsupported(), | |
| 672 | sendTurn: () => unsupported(), | |
| 681 | startSession: () => | |
| 682 | Effect.succeed({ | |
| 683 | sessionId, | |
| 684 | provider: "codex", | |
| 685 | status: "ready", | |
| 686 | threadId: "provider-thread-1", | |
| 687 | createdAt: new Date().toISOString(), | |
| 688 | updatedAt: new Date().toISOString(), | |
| 689 | }), | |
| 690 | sendTurn: () => Effect.succeed({ threadId: "provider-thread-1", turnId: "provider-turn-1" }), | |
| 673 | 691 | interruptTurn: () => unsupported(), |
| 674 | 692 | respondToRequest: () => unsupported(), |
| 675 | 693 | stopSession: () => unsupported(), |
| ... | ... | @@ -716,29 +734,30 @@ describe("WebSocket Server", () => { |
| 716 | 734 | }); |
| 717 | 735 | expect(createThreadResponse.error).toBeUndefined(); |
| 718 | 736 | |
| 719 | const setSessionResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | |
| 720 | type: "thread.session", | |
| 721 | commandId: "cmd-ws-runtime-session-set", | |
| 737 | const startTurnResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | |
| 738 | type: "thread.turn.start", | |
| 739 | commandId: "cmd-ws-runtime-turn-start", | |
| 722 | 740 | threadId: "thread-1", |
| 723 | session: { | |
| 724 | sessionId: "sess-test", | |
| 725 | provider: "codex", | |
| 726 | status: "ready", | |
| 727 | threadId: "thread-1", | |
| 728 | activeTurnId: null, | |
| 729 | createdAt, | |
| 730 | updatedAt: createdAt, | |
| 731 | lastError: null, | |
| 741 | message: { | |
| 742 | messageId: "msg-ws-runtime-1", | |
| 743 | role: "user", | |
| 744 | text: "hello", | |
| 745 | attachments: [], | |
| 732 | 746 | }, |
| 733 | 747 | createdAt, |
| 734 | 748 | }); |
| 735 | expect(setSessionResponse.error).toBeUndefined(); | |
| 749 | expect(startTurnResponse.error).toBeUndefined(); | |
| 750 | ||
| 751 | await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { | |
| 752 | const event = push.data as { type?: string }; | |
| 753 | return event.type === "thread.session-set"; | |
| 754 | }); | |
| 736 | 755 | |
| 737 | 756 | emitRuntimeEvent({ |
| 738 | 757 | type: "message.delta", |
| 739 | 758 | eventId: "evt-ws-runtime-message-delta", |
| 740 | 759 | provider: "codex", |
| 741 | sessionId: "sess-test", | |
| 760 | sessionId, | |
| 742 | 761 | createdAt: new Date().toISOString(), |
| 743 | 762 | turnId: "turn-1", |
| 744 | 763 | itemId: "item-1", |
| ... | ... | @@ -746,13 +765,16 @@ describe("WebSocket Server", () => { |
| 746 | 765 | }); |
| 747 | 766 | |
| 748 | 767 | const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { |
| 749 | const event = push.data as { type?: string; payload?: { id?: string; text?: string } }; | |
| 750 | return event.type === "message.sent" && event.payload?.id === "assistant:item-1"; | |
| 768 | const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } }; | |
| 769 | return event.type === "thread.message-sent" && event.payload?.messageId === "assistant:item-1"; | |
| 751 | 770 | }); |
| 752 | 771 | |
| 753 | const domainEvent = domainPush.data as { type: string; payload: { id: string; text: string } }; | |
| 754 | expect(domainEvent.type).toBe("message.sent"); | |
| 755 | expect(domainEvent.payload.id).toBe("assistant:item-1"); | |
| 772 | const domainEvent = domainPush.data as { | |
| 773 | type: string; | |
| 774 | payload: { messageId: string; text: string }; | |
| 775 | }; | |
| 776 | expect(domainEvent.type).toBe("thread.message-sent"); | |
| 777 | expect(domainEvent.payload.messageId).toBe("assistant:item-1"); | |
| 756 | 778 | expect(domainEvent.payload.text).toBe("hello from runtime"); |
| 757 | 779 | }); |
| 758 | 780 | |
| ... | ... | @@ -895,7 +917,7 @@ describe("WebSocket Server", () => { |
| 895 | 917 | expect(response!.error!.message).toContain("Invalid request format"); |
| 896 | 918 | }); |
| 897 | 919 | |
| 898 | it("returns unknown method for removed projects CRUD methods", async () => { | |
| 920 | it("returns errors for removed projects CRUD methods", async () => { | |
| 899 | 921 | server = createTestServer({ cwd: "/test" }); |
| 900 | 922 | await server.start(); |
| 901 | 923 | const addr = server.httpServer.address(); |
| ... | ... | @@ -907,19 +929,19 @@ describe("WebSocket Server", () => { |
| 907 | 929 | |
| 908 | 930 | const listResponse = await sendRequest(ws, WS_METHODS.projectsList); |
| 909 | 931 | expect(listResponse.result).toBeUndefined(); |
| 910 | expect(listResponse.error?.message).toContain("Unknown method"); | |
| 932 | expect(listResponse.error?.message).toContain("Invalid request format"); | |
| 911 | 933 | |
| 912 | 934 | const addResponse = await sendRequest(ws, WS_METHODS.projectsAdd, { |
| 913 | 935 | cwd: "/tmp/project-a", |
| 914 | 936 | }); |
| 915 | 937 | expect(addResponse.result).toBeUndefined(); |
| 916 | expect(addResponse.error?.message).toContain("Unknown method"); | |
| 938 | expect(addResponse.error?.message).toContain("Invalid request format"); | |
| 917 | 939 | |
| 918 | 940 | const removeResponse = await sendRequest(ws, WS_METHODS.projectsRemove, { |
| 919 | 941 | id: "project-a", |
| 920 | 942 | }); |
| 921 | 943 | expect(removeResponse.result).toBeUndefined(); |
| 922 | expect(removeResponse.error?.message).toContain("Unknown method"); | |
| 944 | expect(removeResponse.error?.message).toContain("Invalid request format"); | |
| 923 | 945 | }); |
| 924 | 946 | |
| 925 | 947 | it("supports projects.searchEntries", async () => { |
| ... | ... | @@ -989,7 +1011,7 @@ describe("WebSocket Server", () => { |
| 989 | 1011 | expect(pullResponse.error?.message).not.toContain("Unknown method"); |
| 990 | 1012 | }); |
| 991 | 1013 | |
| 992 | it("returns unknown method for git.status", async () => { | |
| 1014 | it("returns error for git.status", async () => { | |
| 993 | 1015 | const gitManager = { |
| 994 | 1016 | status: vi.fn().mockResolvedValue({ |
| 995 | 1017 | branch: "feature/test", |
apps/server/src/wsServer.ts+72-22| ... | ... | @@ -132,6 +132,45 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { |
| 132 | 132 | return undefined; |
| 133 | 133 | } |
| 134 | 134 | |
| 135 | function websocketRawToString(raw: unknown): string | null { | |
| 136 | if (typeof raw === "string") { | |
| 137 | return raw; | |
| 138 | } | |
| 139 | if (raw instanceof Uint8Array) { | |
| 140 | return Buffer.from(raw).toString("utf8"); | |
| 141 | } | |
| 142 | if (raw instanceof ArrayBuffer) { | |
| 143 | return Buffer.from(new Uint8Array(raw)).toString("utf8"); | |
| 144 | } | |
| 145 | if (Array.isArray(raw)) { | |
| 146 | const chunks: string[] = []; | |
| 147 | for (const chunk of raw) { | |
| 148 | if (typeof chunk === "string") { | |
| 149 | chunks.push(chunk); | |
| 150 | continue; | |
| 151 | } | |
| 152 | if (chunk instanceof Uint8Array) { | |
| 153 | chunks.push(Buffer.from(chunk).toString("utf8")); | |
| 154 | continue; | |
| 155 | } | |
| 156 | if (chunk instanceof ArrayBuffer) { | |
| 157 | chunks.push(Buffer.from(new Uint8Array(chunk)).toString("utf8")); | |
| 158 | continue; | |
| 159 | } | |
| 160 | return null; | |
| 161 | } | |
| 162 | return chunks.join(""); | |
| 163 | } | |
| 164 | return null; | |
| 165 | } | |
| 166 | ||
| 167 | function stripRequestTag<T extends { _tag: string }>( | |
| 168 | body: T, | |
| 169 | ): Omit<T, "_tag"> { | |
| 170 | const { _tag: _ignoredTag, ...rest } = body; | |
| 171 | return rest; | |
| 172 | } | |
| 173 | ||
| 135 | 174 | type EffectRuntime = ManagedRuntime.ManagedRuntime< |
| 136 | 175 | | OrchestrationEngineService |
| 137 | 176 | | ProjectionSnapshotQuery |
| ... | ... | @@ -360,9 +399,19 @@ export function createServer(options: ServerOptions) { |
| 360 | 399 | }); |
| 361 | 400 | |
| 362 | 401 | async function handleMessage(ws: WebSocket, raw: unknown) { |
| 402 | const messageText = websocketRawToString(raw); | |
| 403 | if (messageText === null) { | |
| 404 | const errorResponse: WsResponse = { | |
| 405 | id: "unknown", | |
| 406 | error: { message: "Invalid request format" }, | |
| 407 | }; | |
| 408 | ws.send(JSON.stringify(errorResponse)); | |
| 409 | return; | |
| 410 | } | |
| 411 | ||
| 363 | 412 | let request: WebSocketRequest; |
| 364 | 413 | try { |
| 365 | request = Schema.decodeUnknownSync(Schema.fromJsonString(WebSocketRequest))(raw); | |
| 414 | request = Schema.decodeUnknownSync(Schema.fromJsonString(WebSocketRequest))(messageText); | |
| 366 | 415 | } catch { |
| 367 | 416 | const errorResponse: WsResponse = { |
| 368 | 417 | id: "unknown", |
| ... | ... | @@ -406,17 +455,18 @@ export function createServer(options: ServerOptions) { |
| 406 | 455 | } |
| 407 | 456 | |
| 408 | 457 | case ORCHESTRATION_WS_METHODS.getTurnDiff: { |
| 409 | const blob = await effectRuntime.runPromise(diffBlobRepository.get(request.body)); | |
| 458 | const body = stripRequestTag(request.body); | |
| 459 | const blob = await effectRuntime.runPromise(diffBlobRepository.get(body)); | |
| 410 | 460 | if (Option.isNone(blob)) { |
| 411 | 461 | throw new Error( |
| 412 | `Turn diff not found for thread '${request.body.threadId}' from ${request.body.fromTurnCount} to ${request.body.toTurnCount}.`, | |
| 462 | `Turn diff not found for thread '${body.threadId}' from ${body.fromTurnCount} to ${body.toTurnCount}.`, | |
| 413 | 463 | ); |
| 414 | 464 | } |
| 415 | 465 | |
| 416 | 466 | return { |
| 417 | threadId: request.body.threadId, | |
| 418 | fromTurnCount: request.body.fromTurnCount, | |
| 419 | toTurnCount: request.body.toTurnCount, | |
| 467 | threadId: body.threadId, | |
| 468 | fromTurnCount: body.fromTurnCount, | |
| 469 | toTurnCount: body.toTurnCount, | |
| 420 | 470 | diff: blob.value?.diff, |
| 421 | 471 | }; |
| 422 | 472 | } |
| ... | ... | @@ -434,7 +484,7 @@ export function createServer(options: ServerOptions) { |
| 434 | 484 | ); |
| 435 | 485 | |
| 436 | 486 | case WS_METHODS.projectsSearchEntries: |
| 437 | return searchWorkspaceEntries(request.body); | |
| 487 | return searchWorkspaceEntries(stripRequestTag(request.body)); | |
| 438 | 488 | |
| 439 | 489 | case WS_METHODS.shellOpenInEditor: { |
| 440 | 490 | const body = request.body; |
| ... | ... | @@ -500,48 +550,48 @@ export function createServer(options: ServerOptions) { |
| 500 | 550 | // } |
| 501 | 551 | |
| 502 | 552 | case WS_METHODS.gitPull: |
| 503 | return pullGitBranch(request.body); | |
| 553 | return pullGitBranch(stripRequestTag(request.body)); | |
| 504 | 554 | |
| 505 | 555 | case WS_METHODS.gitRunStackedAction: |
| 506 | return gitManager.runStackedAction(request.body); | |
| 556 | return gitManager.runStackedAction(stripRequestTag(request.body)); | |
| 507 | 557 | case WS_METHODS.gitListBranches: |
| 508 | return listGitBranches(request.body); | |
| 558 | return listGitBranches(stripRequestTag(request.body)); | |
| 509 | 559 | |
| 510 | 560 | case WS_METHODS.gitCreateWorktree: |
| 511 | return createGitWorktree(request.body); | |
| 561 | return createGitWorktree(stripRequestTag(request.body)); | |
| 512 | 562 | |
| 513 | 563 | case WS_METHODS.gitRemoveWorktree: |
| 514 | return removeGitWorktree(request.body); | |
| 564 | return removeGitWorktree(stripRequestTag(request.body)); | |
| 515 | 565 | |
| 516 | 566 | case WS_METHODS.gitCreateBranch: |
| 517 | return createGitBranch(request.body); | |
| 567 | return createGitBranch(stripRequestTag(request.body)); | |
| 518 | 568 | |
| 519 | 569 | case WS_METHODS.gitCheckout: |
| 520 | return checkoutGitBranch(request.body); | |
| 570 | return checkoutGitBranch(stripRequestTag(request.body)); | |
| 521 | 571 | |
| 522 | 572 | case WS_METHODS.gitInit: |
| 523 | return initGitRepo(request.body); | |
| 573 | return initGitRepo(stripRequestTag(request.body)); | |
| 524 | 574 | |
| 525 | 575 | case WS_METHODS.terminalOpen: |
| 526 | return terminalManager.open(request.body); | |
| 576 | return terminalManager.open(stripRequestTag(request.body)); | |
| 527 | 577 | |
| 528 | 578 | case WS_METHODS.terminalWrite: |
| 529 | await terminalManager.write(request.body); | |
| 579 | await terminalManager.write(stripRequestTag(request.body)); | |
| 530 | 580 | return undefined; |
| 531 | 581 | |
| 532 | 582 | case WS_METHODS.terminalResize: |
| 533 | await terminalManager.resize(request.body); | |
| 583 | await terminalManager.resize(stripRequestTag(request.body)); | |
| 534 | 584 | return undefined; |
| 535 | 585 | |
| 536 | 586 | case WS_METHODS.terminalClear: |
| 537 | await terminalManager.clear(request.body); | |
| 587 | await terminalManager.clear(stripRequestTag(request.body)); | |
| 538 | 588 | return undefined; |
| 539 | 589 | |
| 540 | 590 | case WS_METHODS.terminalRestart: |
| 541 | return terminalManager.restart(request.body); | |
| 591 | return terminalManager.restart(stripRequestTag(request.body)); | |
| 542 | 592 | |
| 543 | 593 | case WS_METHODS.terminalClose: |
| 544 | await terminalManager.close(request.body); | |
| 594 | await terminalManager.close(stripRequestTag(request.body)); | |
| 545 | 595 | return undefined; |
| 546 | 596 | |
| 547 | 597 | case WS_METHODS.serverGetConfig: |
| ... | ... | @@ -551,7 +601,7 @@ export function createServer(options: ServerOptions) { |
| 551 | 601 | }; |
| 552 | 602 | |
| 553 | 603 | case WS_METHODS.serverUpsertKeybinding: |
| 554 | keybindingsConfig = upsertKeybindingRule(logger, request.body); | |
| 604 | keybindingsConfig = upsertKeybindingRule(logger, stripRequestTag(request.body)); | |
| 555 | 605 | return { |
| 556 | 606 | keybindings: keybindingsConfig, |
| 557 | 607 | }; |
apps/server/tsconfig.json+2-2| ... | ... | @@ -12,6 +12,6 @@ |
| 12 | 12 | } |
| 13 | 13 | ] |
| 14 | 14 | }, |
| 15 | "include": ["src", "tsdown.config.ts", "scripts"], | |
| 16 | "exclude": ["src/**/*.test.ts", "integration"] | |
| 15 | "include": ["src", "tsdown.config.ts", "scripts", "integration"], | |
| 16 | "exclude": ["src/**/*.test.ts"] | |
| 17 | 17 | } |
apps/server/tsdown.config.ts+1-1| ... | ... | @@ -6,7 +6,7 @@ export default defineConfig({ |
| 6 | 6 | outDir: "dist", |
| 7 | 7 | sourcemap: true, |
| 8 | 8 | clean: true, |
| 9 | noExternal: ["@t3tools/contracts", "zod"], | |
| 9 | noExternal: ["@t3tools/contracts"], | |
| 10 | 10 | inlineOnly: false, |
| 11 | 11 | banner: { |
| 12 | 12 | js: "#!/usr/bin/env node\n", |
apps/web/package.json+2-2| ... | ... | @@ -21,6 +21,7 @@ |
| 21 | 21 | "@xterm/addon-fit": "^0.11.0", |
| 22 | 22 | "@xterm/xterm": "^6.0.0", |
| 23 | 23 | "class-variance-authority": "^0.7.1", |
| 24 | "effect": "4.0.0-beta.10", | |
| 24 | 25 | "highlight.js": "^11.11.1", |
| 25 | 26 | "lucide-react": "^0.564.0", |
| 26 | 27 | "react": "^19.0.0", |
| ... | ... | @@ -28,8 +29,7 @@ |
| 28 | 29 | "react-markdown": "^10.1.0", |
| 29 | 30 | "rehype-highlight": "^7.0.2", |
| 30 | 31 | "remark-gfm": "^4.0.1", |
| 31 | "tailwind-merge": "^3.4.0", | |
| 32 | "zod": "^3.24.1" | |
| 32 | "tailwind-merge": "^3.4.0" | |
| 33 | 33 | }, |
| 34 | 34 | "devDependencies": { |
| 35 | 35 | "@tailwindcss/vite": "^4.0.0", |
apps/web/src/appSettings.ts+18-12| ... | ... | @@ -1,17 +1,20 @@ |
| 1 | 1 | import { useCallback, useSyncExternalStore } from "react"; |
| 2 | import { z } from "zod"; | |
| 2 | import { Option, Schema } from "effect"; | |
| 3 | 3 | |
| 4 | 4 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; |
| 5 | 5 | |
| 6 | const appSettingsSchema = z.object({ | |
| 7 | codexBinaryPath: z.string().trim().max(4096).default(""), | |
| 8 | codexHomePath: z.string().trim().max(4096).default(""), | |
| 9 | confirmThreadDelete: z.boolean().default(true), | |
| 6 | const AppSettingsSchema = Schema.Struct({ | |
| 7 | codexBinaryPath: Schema.NonEmptyString.check(Schema.isMaxLength(4096)).pipe( | |
| 8 | Schema.withConstructorDefault(() => Option.some("")), | |
| 9 | ), | |
| 10 | codexHomePath: Schema.NonEmptyString.check(Schema.isMaxLength(4096)).pipe( | |
| 11 | Schema.withConstructorDefault(() => Option.some("")), | |
| 12 | ), | |
| 13 | confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), | |
| 10 | 14 | }); |
| 15 | export type AppSettings = typeof AppSettingsSchema.Type; | |
| 11 | 16 | |
| 12 | export type AppSettings = z.infer<typeof appSettingsSchema>; | |
| 13 | ||
| 14 | const DEFAULT_APP_SETTINGS: AppSettings = appSettingsSchema.parse({}); | |
| 17 | const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); | |
| 15 | 18 | |
| 16 | 19 | let listeners: Array<() => void> = []; |
| 17 | 20 | let cachedRawSettings: string | null | undefined; |
| ... | ... | @@ -29,8 +32,7 @@ function parsePersistedSettings(value: string | null): AppSettings { |
| 29 | 32 | } |
| 30 | 33 | |
| 31 | 34 | try { |
| 32 | const parsed = JSON.parse(value) as unknown; | |
| 33 | return appSettingsSchema.parse(parsed); | |
| 35 | return Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value); | |
| 34 | 36 | } catch { |
| 35 | 37 | return DEFAULT_APP_SETTINGS; |
| 36 | 38 | } |
| ... | ... | @@ -84,10 +86,14 @@ function subscribe(listener: () => void): () => void { |
| 84 | 86 | } |
| 85 | 87 | |
| 86 | 88 | export function useAppSettings() { |
| 87 | const settings = useSyncExternalStore(subscribe, getAppSettingsSnapshot, () => DEFAULT_APP_SETTINGS); | |
| 89 | const settings = useSyncExternalStore( | |
| 90 | subscribe, | |
| 91 | getAppSettingsSnapshot, | |
| 92 | () => DEFAULT_APP_SETTINGS, | |
| 93 | ); | |
| 88 | 94 | |
| 89 | 95 | const updateSettings = useCallback((patch: Partial<AppSettings>) => { |
| 90 | const next = appSettingsSchema.parse({ | |
| 96 | const next = Schema.decodeSync(AppSettingsSchema)({ | |
| 91 | 97 | ...getAppSettingsSnapshot(), |
| 92 | 98 | ...patch, |
| 93 | 99 | }); |
apps/web/src/components/BranchToolbar.logic.ts+1-1| ... | ... | @@ -5,7 +5,7 @@ interface DeriveSyncedLocalBranchInput { |
| 5 | 5 | activeWorktreePath: string | null; |
| 6 | 6 | envMode: "local" | "worktree"; |
| 7 | 7 | activeThreadBranch: string | null; |
| 8 | queryBranches: GitBranch[] | undefined; | |
| 8 | queryBranches: ReadonlyArray<GitBranch> | undefined; | |
| 9 | 9 | } |
| 10 | 10 | |
| 11 | 11 | export function deriveSyncedLocalBranch({ |
apps/web/src/components/BranchToolbar.tsx+14-8| ... | ... | @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | 3 | import { useEffect, useState } from "react"; |
| 4 | 4 | |
| 5 | 5 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 6 | import { asThreadId, newCommandId } from "../lib/orchestrationIds"; | |
| 6 | 7 | import { |
| 7 | 8 | gitBranchesQueryOptions, |
| 8 | 9 | gitCheckoutMutationOptions, |
| ... | ... | @@ -95,11 +96,10 @@ export default function BranchToolbar({ |
| 95 | 96 | if (api) { |
| 96 | 97 | void api.orchestration.dispatchCommand({ |
| 97 | 98 | type: "thread.meta.update", |
| 98 | commandId: crypto.randomUUID(), | |
| 99 | threadId: activeThreadId, | |
| 99 | commandId: newCommandId(), | |
| 100 | threadId: asThreadId(activeThreadId), | |
| 100 | 101 | branch: syncedBranch, |
| 101 | 102 | worktreePath: null, |
| 102 | createdAt: new Date().toISOString(), | |
| 103 | 103 | }); |
| 104 | 104 | } |
| 105 | 105 | dispatch({ |
| ... | ... | @@ -127,17 +127,23 @@ export default function BranchToolbar({ |
| 127 | 127 | // If the effective cwd is about to change, stop the running session so the |
| 128 | 128 | // next message creates a new one with the correct cwd. |
| 129 | 129 | const sessionId = activeThread?.session?.sessionId; |
| 130 | if (sessionId && worktreePath !== activeWorktreePath) { | |
| 131 | void api?.providers.stopSession({ sessionId }).catch(() => undefined); | |
| 130 | if (sessionId && worktreePath !== activeWorktreePath && api) { | |
| 131 | void api.orchestration | |
| 132 | .dispatchCommand({ | |
| 133 | type: "thread.session.stop", | |
| 134 | commandId: newCommandId(), | |
| 135 | threadId: asThreadId(activeThreadId), | |
| 136 | createdAt: new Date().toISOString(), | |
| 137 | }) | |
| 138 | .catch(() => undefined); | |
| 132 | 139 | } |
| 133 | 140 | if (api) { |
| 134 | 141 | void api.orchestration.dispatchCommand({ |
| 135 | 142 | type: "thread.meta.update", |
| 136 | commandId: crypto.randomUUID(), | |
| 137 | threadId: activeThreadId, | |
| 143 | commandId: newCommandId(), | |
| 144 | threadId: asThreadId(activeThreadId), | |
| 138 | 145 | branch, |
| 139 | 146 | worktreePath, |
| 140 | createdAt: new Date().toISOString(), | |
| 141 | 147 | }); |
| 142 | 148 | } |
| 143 | 149 | dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath }); |
apps/web/src/components/ChatView.tsx+76-288| ... | ... | @@ -5,12 +5,10 @@ import { |
| 5 | 5 | type ProjectEntry, |
| 6 | 6 | type ProjectScript, |
| 7 | 7 | ModelSlug, |
| 8 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | |
| 9 | 8 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 10 | 9 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 11 | 10 | type ResolvedKeybindingsConfig, |
| 12 | 11 | type ProviderApprovalDecision, |
| 13 | type ProviderSendTurnAttachmentInput, | |
| 14 | 12 | } from "@t3tools/contracts"; |
| 15 | 13 | import { |
| 16 | 14 | type ClipboardEvent, |
| ... | ... | @@ -36,8 +34,6 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer |
| 36 | 34 | |
| 37 | 35 | import { isElectron } from "../env"; |
| 38 | 36 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 39 | import { useAppSettings } from "../appSettings"; | |
| 40 | import { buildBootstrapInput } from "../historyBootstrap"; | |
| 41 | 37 | import { |
| 42 | 38 | type ComposerTriggerKind, |
| 43 | 39 | detectComposerTrigger, |
| ... | ... | @@ -116,6 +112,13 @@ import { |
| 116 | 112 | setupProjectScript, |
| 117 | 113 | } from "~/projectScripts"; |
| 118 | 114 | import { Toggle } from "./ui/toggle"; |
| 115 | import { | |
| 116 | asApprovalRequestId, | |
| 117 | asProjectId, | |
| 118 | asThreadId, | |
| 119 | newCommandId, | |
| 120 | newMessageId, | |
| 121 | } from "~/lib/orchestrationIds"; | |
| 119 | 122 | |
| 120 | 123 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 121 | 124 | if (!duration) return formatTimestamp(createdAt); |
| ... | ... | @@ -165,14 +168,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 165 | 168 | return "text-muted-foreground/40"; |
| 166 | 169 | } |
| 167 | 170 | |
| 168 | type SessionContinuityState = "resumed" | "new" | "fallback_new"; | |
| 169 | ||
| 170 | interface EnsuredSessionInfo { | |
| 171 | sessionId: string; | |
| 172 | resolvedThreadId: string | null; | |
| 173 | continuityState: SessionContinuityState; | |
| 174 | } | |
| 175 | ||
| 176 | 171 | interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { |
| 177 | 172 | previewUrl: string; |
| 178 | 173 | file: File; |
| ... | ... | @@ -351,7 +346,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 351 | 346 | const rawSearch = useSearch({ strict: false }); |
| 352 | 347 | const api = useNativeApi(); |
| 353 | 348 | const { resolvedTheme } = useTheme(); |
| 354 | const { settings: appSettings } = useAppSettings(); | |
| 355 | 349 | const queryClient = useQueryClient(); |
| 356 | 350 | const createWorktreeMutation = useMutation( |
| 357 | 351 | gitCreateWorktreeMutationOptions({ api, queryClient }), |
| ... | ... | @@ -383,8 +377,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 383 | 377 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); |
| 384 | 378 | const dragDepthRef = useRef(0); |
| 385 | 379 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| 386 | const checkpointHydrationSessionRequestRef = useRef(new Set<string>()); | |
| 387 | const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>()); | |
| 388 | 380 | |
| 389 | 381 | const activeThread = state.threads.find((t) => t.id === threadId); |
| 390 | 382 | const diffSearch = useMemo( |
| ... | ... | @@ -393,9 +385,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 393 | 385 | ); |
| 394 | 386 | const diffOpen = diffSearch.diff === "1"; |
| 395 | 387 | const activeThreadId = activeThread?.id ?? null; |
| 396 | const activeSessionId = activeThread?.session?.sessionId ?? null; | |
| 397 | const activeThreadRuntimeId = | |
| 398 | activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; | |
| 399 | 388 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 400 | 389 | const selectedModel = resolveModelSlug( |
| 401 | 390 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| ... | ... | @@ -521,11 +510,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 521 | 510 | completionSummary, |
| 522 | 511 | timelineEntries, |
| 523 | 512 | ]); |
| 524 | const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; | |
| 525 | const runtimeSandboxMode = | |
| 526 | state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; | |
| 527 | const codexBinaryPath = appSettings.codexBinaryPath.trim(); | |
| 528 | const codexHomePath = appSettings.codexHomePath.trim(); | |
| 529 | 513 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; |
| 530 | 514 | const composerTrigger = useMemo( |
| 531 | 515 | () => detectComposerTrigger(prompt, composerCursor), |
| ... | ... | @@ -820,17 +804,16 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 820 | 804 | scripts: input.nextScripts, |
| 821 | 805 | }); |
| 822 | 806 | |
| 823 | if (!isElectron || !api) return; | |
| 824 | ||
| 825 | let scriptsPersisted = false; | |
| 807 | if (!api) return; | |
| 826 | 808 | try { |
| 827 | const updated = await api.projects.updateScripts({ | |
| 828 | id: input.projectId, | |
| 809 | await api.orchestration.dispatchCommand({ | |
| 810 | type: "project.meta.update", | |
| 811 | commandId: newCommandId(), | |
| 812 | projectId: asProjectId(input.projectId), | |
| 829 | 813 | scripts: input.nextScripts, |
| 830 | 814 | }); |
| 831 | scriptsPersisted = true; | |
| 832 | 815 | |
| 833 | if (input.keybinding) { | |
| 816 | if (isElectron && input.keybinding) { | |
| 834 | 817 | const keybindingUpdate = await api.server.upsertKeybinding({ |
| 835 | 818 | key: input.keybinding, |
| 836 | 819 | command: input.keybindingCommand, |
| ... | ... | @@ -843,24 +826,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 843 | 826 | : { |
| 844 | 827 | cwd: input.projectCwd, |
| 845 | 828 | keybindings: keybindingUpdate.keybindings, |
| 846 | }, | |
| 829 | }, | |
| 847 | 830 | ); |
| 848 | 831 | } |
| 849 | ||
| 850 | dispatch({ | |
| 851 | type: "SET_PROJECT_SCRIPTS", | |
| 852 | projectId: updated.project.id, | |
| 853 | scripts: updated.project.scripts, | |
| 854 | }); | |
| 855 | 832 | } catch (error) { |
| 856 | if (scriptsPersisted) { | |
| 857 | await api.projects | |
| 858 | .updateScripts({ | |
| 859 | id: input.projectId, | |
| 860 | scripts: input.previousScripts, | |
| 861 | }) | |
| 862 | .catch(() => undefined); | |
| 863 | } | |
| 864 | 833 | dispatch({ |
| 865 | 834 | type: "SET_PROJECT_SCRIPTS", |
| 866 | 835 | projectId: input.projectId, |
| ... | ... | @@ -946,17 +915,25 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 946 | 915 | scheduleComposerFocus(); |
| 947 | 916 | if (!api) return; |
| 948 | 917 | |
| 949 | const sessionIds = state.threads | |
| 950 | .map((t) => t.session) | |
| 951 | .filter((s): s is NonNullable<typeof s> => s !== null && s.status !== "closed") | |
| 952 | .map((s) => s.sessionId); | |
| 918 | const runningThreadIds = state.threads | |
| 919 | .filter((thread) => thread.session !== null && thread.session.status !== "closed") | |
| 920 | .map((thread) => thread.id); | |
| 953 | 921 | |
| 954 | if (sessionIds.length === 0) return; | |
| 922 | if (runningThreadIds.length === 0) return; | |
| 955 | 923 | |
| 956 | 924 | setIsSwitchingRuntimeMode(true); |
| 957 | 925 | try { |
| 958 | 926 | await Promise.all( |
| 959 | sessionIds.map((id) => api.providers.stopSession({ sessionId: id }).catch(() => undefined)), | |
| 927 | runningThreadIds.map((threadId) => | |
| 928 | api.orchestration | |
| 929 | .dispatchCommand({ | |
| 930 | type: "thread.session.stop", | |
| 931 | commandId: newCommandId(), | |
| 932 | threadId: asThreadId(threadId), | |
| 933 | createdAt: new Date().toISOString(), | |
| 934 | }) | |
| 935 | .catch(() => undefined), | |
| 936 | ), | |
| 960 | 937 | ); |
| 961 | 938 | } finally { |
| 962 | 939 | setIsSwitchingRuntimeMode(false); |
| ... | ... | @@ -1321,167 +1298,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1321 | 1298 | focusComposer(); |
| 1322 | 1299 | }; |
| 1323 | 1300 | |
| 1324 | const syncCheckpointTurnCounts = useCallback( | |
| 1325 | async (input: { | |
| 1326 | threadId: string; | |
| 1327 | sessionId: string; | |
| 1328 | threadRuntimeId: string | null; | |
| 1329 | }): Promise<void> => { | |
| 1330 | if (!api || turnDiffSummaries.length === 0) { | |
| 1331 | return; | |
| 1332 | } | |
| 1333 | ||
| 1334 | const turnIds = turnDiffSummaries.map((summary) => summary.turnId); | |
| 1335 | if (turnIds.length === 0) { | |
| 1336 | return; | |
| 1337 | } | |
| 1338 | ||
| 1339 | const syncKey = `${input.threadId}:${input.sessionId}:${input.threadRuntimeId ?? "none"}`; | |
| 1340 | const turnFingerprint = turnIds.join(","); | |
| 1341 | if (checkpointTurnCountSyncFingerprintRef.current.get(syncKey) === turnFingerprint) { | |
| 1342 | return; | |
| 1343 | } | |
| 1344 | checkpointTurnCountSyncFingerprintRef.current.set(syncKey, turnFingerprint); | |
| 1345 | ||
| 1346 | try { | |
| 1347 | await api.providers.listCheckpoints({ | |
| 1348 | sessionId: input.sessionId, | |
| 1349 | }); | |
| 1350 | } catch { | |
| 1351 | checkpointTurnCountSyncFingerprintRef.current.delete(syncKey); | |
| 1352 | } | |
| 1353 | }, | |
| 1354 | [api, turnDiffSummaries], | |
| 1355 | ); | |
| 1356 | ||
| 1357 | const ensureSession = useCallback( | |
| 1358 | async (cwdOverride?: string): Promise<EnsuredSessionInfo | null> => { | |
| 1359 | if (!api || !activeThread || !activeProject) return null; | |
| 1360 | if (activeThread.session && activeThread.session.status !== "closed") { | |
| 1361 | const sessionThreadId = activeThread.session.threadId ?? null; | |
| 1362 | const continuityState: SessionContinuityState = | |
| 1363 | activeThread.codexThreadId === null | |
| 1364 | ? "new" | |
| 1365 | : sessionThreadId === activeThread.codexThreadId | |
| 1366 | ? "resumed" | |
| 1367 | : "fallback_new"; | |
| 1368 | await syncCheckpointTurnCounts({ | |
| 1369 | threadId: activeThread.id, | |
| 1370 | sessionId: activeThread.session.sessionId, | |
| 1371 | threadRuntimeId: sessionThreadId, | |
| 1372 | }); | |
| 1373 | return { | |
| 1374 | sessionId: activeThread.session.sessionId, | |
| 1375 | resolvedThreadId: sessionThreadId, | |
| 1376 | continuityState, | |
| 1377 | } satisfies EnsuredSessionInfo; | |
| 1378 | } | |
| 1379 | ||
| 1380 | const priorCodexThreadId = activeThread.codexThreadId; | |
| 1381 | setIsConnecting(true); | |
| 1382 | try { | |
| 1383 | const session = await api.providers.startSession({ | |
| 1384 | provider: "codex", | |
| 1385 | cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, | |
| 1386 | model: selectedModel || undefined, | |
| 1387 | resumeThreadId: priorCodexThreadId ?? undefined, | |
| 1388 | ...(codexBinaryPath.length > 0 ? { codexBinaryPath } : {}), | |
| 1389 | ...(codexHomePath.length > 0 ? { codexHomePath } : {}), | |
| 1390 | approvalPolicy: runtimeApprovalPolicy, | |
| 1391 | sandboxMode: runtimeSandboxMode, | |
| 1392 | }); | |
| 1393 | await api.orchestration.dispatchCommand({ | |
| 1394 | type: "thread.session", | |
| 1395 | commandId: crypto.randomUUID(), | |
| 1396 | threadId: activeThread.id, | |
| 1397 | session: { | |
| 1398 | sessionId: session.sessionId, | |
| 1399 | provider: session.provider, | |
| 1400 | status: session.status, | |
| 1401 | threadId: session.threadId ?? activeThread.id, | |
| 1402 | activeTurnId: session.activeTurnId ?? null, | |
| 1403 | createdAt: session.createdAt, | |
| 1404 | updatedAt: session.updatedAt, | |
| 1405 | lastError: session.lastError ?? null, | |
| 1406 | }, | |
| 1407 | createdAt: new Date().toISOString(), | |
| 1408 | }); | |
| 1409 | const resolvedThreadId = session.threadId ?? null; | |
| 1410 | const continuityState: SessionContinuityState = | |
| 1411 | priorCodexThreadId === null | |
| 1412 | ? "new" | |
| 1413 | : resolvedThreadId === priorCodexThreadId | |
| 1414 | ? "resumed" | |
| 1415 | : "fallback_new"; | |
| 1416 | await syncCheckpointTurnCounts({ | |
| 1417 | threadId: activeThread.id, | |
| 1418 | sessionId: session.sessionId, | |
| 1419 | threadRuntimeId: resolvedThreadId, | |
| 1420 | }); | |
| 1421 | return { | |
| 1422 | sessionId: session.sessionId, | |
| 1423 | resolvedThreadId, | |
| 1424 | continuityState, | |
| 1425 | }; | |
| 1426 | } catch (err) { | |
| 1427 | dispatch({ | |
| 1428 | type: "SET_ERROR", | |
| 1429 | threadId: activeThread.id, | |
| 1430 | error: err instanceof Error ? err.message : "Failed to connect.", | |
| 1431 | }); | |
| 1432 | return null; | |
| 1433 | } finally { | |
| 1434 | setIsConnecting(false); | |
| 1435 | } | |
| 1436 | }, | |
| 1437 | [ | |
| 1438 | activeProject, | |
| 1439 | activeThread, | |
| 1440 | api, | |
| 1441 | codexBinaryPath, | |
| 1442 | codexHomePath, | |
| 1443 | runtimeApprovalPolicy, | |
| 1444 | runtimeSandboxMode, | |
| 1445 | selectedModel, | |
| 1446 | syncCheckpointTurnCounts, | |
| 1447 | ], | |
| 1448 | ); | |
| 1449 | ||
| 1450 | useEffect(() => { | |
| 1451 | if (!api || !activeThreadId || activeSessionId) return; | |
| 1452 | if (phase === "running" || isSending || isConnecting || isRevertingCheckpoint) return; | |
| 1453 | const hasThreadIdentity = Boolean(activeThreadRuntimeId); | |
| 1454 | if (!hasThreadIdentity) return; | |
| 1455 | if (turnDiffSummaries.length === 0) return; | |
| 1456 | ||
| 1457 | const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`; | |
| 1458 | if (checkpointHydrationSessionRequestRef.current.has(requestKey)) { | |
| 1459 | return; | |
| 1460 | } | |
| 1461 | checkpointHydrationSessionRequestRef.current.add(requestKey); | |
| 1462 | setThreadError(activeThreadId, null); | |
| 1463 | void ensureSession().then( | |
| 1464 | () => { | |
| 1465 | checkpointHydrationSessionRequestRef.current.delete(requestKey); | |
| 1466 | }, | |
| 1467 | () => { | |
| 1468 | // Keep the key on failure so this effect does not re-enter in a tight loop. | |
| 1469 | }, | |
| 1470 | ); | |
| 1471 | }, [ | |
| 1472 | activeThreadId, | |
| 1473 | activeThreadRuntimeId, | |
| 1474 | activeSessionId, | |
| 1475 | api, | |
| 1476 | ensureSession, | |
| 1477 | isConnecting, | |
| 1478 | isRevertingCheckpoint, | |
| 1479 | isSending, | |
| 1480 | phase, | |
| 1481 | setThreadError, | |
| 1482 | turnDiffSummaries, | |
| 1483 | ]); | |
| 1484 | ||
| 1485 | 1301 | const onRevertToTurnCount = useCallback( |
| 1486 | 1302 | async (turnCount: number) => { |
| 1487 | 1303 | if (!api || !activeThread || isRevertingCheckpoint) { |
| ... | ... | @@ -1505,13 +1321,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1505 | 1321 | setIsRevertingCheckpoint(true); |
| 1506 | 1322 | setThreadError(activeThread.id, null); |
| 1507 | 1323 | try { |
| 1508 | const sessionInfo = await ensureSession(); | |
| 1509 | if (!sessionInfo) { | |
| 1510 | return; | |
| 1511 | } | |
| 1512 | await api.providers.revertToCheckpoint({ | |
| 1513 | sessionId: sessionInfo.sessionId, | |
| 1324 | await api.orchestration.dispatchCommand({ | |
| 1325 | type: "thread.checkpoint.revert", | |
| 1326 | commandId: newCommandId(), | |
| 1327 | threadId: asThreadId(activeThread.id), | |
| 1514 | 1328 | turnCount, |
| 1329 | createdAt: new Date().toISOString(), | |
| 1515 | 1330 | }); |
| 1516 | 1331 | } catch (err) { |
| 1517 | 1332 | setThreadError( |
| ... | ... | @@ -1525,8 +1340,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1525 | 1340 | [ |
| 1526 | 1341 | activeThread, |
| 1527 | 1342 | api, |
| 1528 | dispatch, | |
| 1529 | ensureSession, | |
| 1530 | 1343 | isConnecting, |
| 1531 | 1344 | isRevertingCheckpoint, |
| 1532 | 1345 | isSending, |
| ... | ... | @@ -1544,7 +1357,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1544 | 1357 | const composerImagesSnapshot = [...composerImages]; |
| 1545 | 1358 | |
| 1546 | 1359 | // On first message: lock in branch + create worktree if needed. |
| 1547 | let sessionCwd: string | undefined; | |
| 1548 | 1360 | if ( |
| 1549 | 1361 | activeThread.messages.length === 0 && |
| 1550 | 1362 | activeThread.branch && |
| ... | ... | @@ -1558,17 +1370,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1558 | 1370 | branch: activeThread.branch, |
| 1559 | 1371 | newBranch, |
| 1560 | 1372 | }); |
| 1561 | sessionCwd = result.worktree.path; | |
| 1562 | if (api) { | |
| 1563 | await api.orchestration.dispatchCommand({ | |
| 1564 | type: "thread.meta.update", | |
| 1565 | commandId: crypto.randomUUID(), | |
| 1566 | threadId: activeThread.id, | |
| 1567 | branch: result.worktree.branch, | |
| 1568 | worktreePath: result.worktree.path, | |
| 1569 | createdAt: new Date().toISOString(), | |
| 1570 | }); | |
| 1571 | } | |
| 1373 | await api.orchestration.dispatchCommand({ | |
| 1374 | type: "thread.meta.update", | |
| 1375 | commandId: newCommandId(), | |
| 1376 | threadId: asThreadId(activeThread.id), | |
| 1377 | branch: result.worktree.branch, | |
| 1378 | worktreePath: result.worktree.path, | |
| 1379 | }); | |
| 1572 | 1380 | const setupScript = setupProjectScript(activeProject.scripts); |
| 1573 | 1381 | if (setupScript) { |
| 1574 | 1382 | void runProjectScript(setupScript, { |
| ... | ... | @@ -1598,51 +1406,31 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1598 | 1406 | if (api) { |
| 1599 | 1407 | await api.orchestration.dispatchCommand({ |
| 1600 | 1408 | type: "thread.meta.update", |
| 1601 | commandId: crypto.randomUUID(), | |
| 1602 | threadId: activeThread.id, | |
| 1409 | commandId: newCommandId(), | |
| 1410 | threadId: asThreadId(activeThread.id), | |
| 1603 | 1411 | title, |
| 1604 | createdAt: new Date().toISOString(), | |
| 1605 | 1412 | }); |
| 1606 | 1413 | } |
| 1607 | 1414 | } |
| 1608 | 1415 | |
| 1609 | 1416 | setThreadError(activeThread.id, null); |
| 1610 | const messageAttachments: ChatImageAttachment[] = composerImagesSnapshot.map((image) => ({ | |
| 1611 | type: "image", | |
| 1612 | id: image.id, | |
| 1613 | name: image.name, | |
| 1614 | mimeType: image.mimeType, | |
| 1615 | sizeBytes: image.sizeBytes, | |
| 1616 | previewUrl: image.previewUrl, | |
| 1617 | })); | |
| 1618 | const userMessageId = crypto.randomUUID(); | |
| 1619 | if (api) { | |
| 1620 | await api.orchestration.dispatchCommand({ | |
| 1621 | type: "message.send", | |
| 1622 | commandId: crypto.randomUUID(), | |
| 1623 | threadId: activeThread.id, | |
| 1624 | messageId: userMessageId, | |
| 1625 | role: "user", | |
| 1626 | text: trimmed, | |
| 1627 | streaming: false, | |
| 1628 | createdAt: new Date().toISOString(), | |
| 1629 | }); | |
| 1630 | } | |
| 1631 | const previousMessages = activeThread.messages; | |
| 1632 | 1417 | promptRef.current = ""; |
| 1633 | 1418 | setPrompt(""); |
| 1634 | 1419 | setComposerImages([]); |
| 1635 | 1420 | setComposerCursor(0); |
| 1636 | 1421 | setComposerHighlightedItemId(null); |
| 1637 | 1422 | |
| 1638 | const sessionInfo = await ensureSession(sessionCwd); | |
| 1639 | if (!sessionInfo) return; | |
| 1640 | ||
| 1641 | 1423 | setIsSending(true); |
| 1642 | 1424 | try { |
| 1643 | 1425 | const turnAttachments = await Promise.all( |
| 1644 | 1426 | composerImagesSnapshot.map( |
| 1645 | async (image): Promise<ProviderSendTurnAttachmentInput> => ({ | |
| 1427 | async (image): Promise<{ | |
| 1428 | type: "image"; | |
| 1429 | name: string; | |
| 1430 | mimeType: string; | |
| 1431 | sizeBytes: number; | |
| 1432 | dataUrl: string; | |
| 1433 | }> => ({ | |
| 1646 | 1434 | type: "image", |
| 1647 | 1435 | name: image.name, |
| 1648 | 1436 | mimeType: image.mimeType, |
| ... | ... | @@ -1651,23 +1439,19 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1651 | 1439 | }), |
| 1652 | 1440 | ), |
| 1653 | 1441 | ); |
| 1654 | const shouldBootstrap = | |
| 1655 | previousMessages.length > 0 && | |
| 1656 | (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new"); | |
| 1657 | const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT; | |
| 1658 | const input = shouldBootstrap | |
| 1659 | ? buildBootstrapInput( | |
| 1660 | previousMessages, | |
| 1661 | latestPromptForBootstrap, | |
| 1662 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | |
| 1663 | ).text | |
| 1664 | : trimmed || undefined; | |
| 1665 | await api.providers.sendTurn({ | |
| 1666 | sessionId: sessionInfo.sessionId, | |
| 1667 | ...(input ? { input } : {}), | |
| 1668 | ...(turnAttachments.length > 0 ? { attachments: turnAttachments } : {}), | |
| 1442 | await api.orchestration.dispatchCommand({ | |
| 1443 | type: "thread.turn.start", | |
| 1444 | commandId: newCommandId(), | |
| 1445 | threadId: asThreadId(activeThread.id), | |
| 1446 | message: { | |
| 1447 | messageId: newMessageId(), | |
| 1448 | role: "user", | |
| 1449 | text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, | |
| 1450 | attachments: turnAttachments, | |
| 1451 | }, | |
| 1669 | 1452 | model: selectedModel || undefined, |
| 1670 | 1453 | effort: selectedEffort || undefined, |
| 1454 | createdAt: new Date().toISOString(), | |
| 1671 | 1455 | }); |
| 1672 | 1456 | } catch (err) { |
| 1673 | 1457 | setThreadError( |
| ... | ... | @@ -1680,25 +1464,30 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1680 | 1464 | }; |
| 1681 | 1465 | |
| 1682 | 1466 | const onInterrupt = async () => { |
| 1683 | if (!api || !activeThread?.session) return; | |
| 1684 | await api.providers.interruptTurn({ | |
| 1685 | sessionId: activeThread.session.sessionId, | |
| 1686 | turnId: activeThread.session.activeTurnId, | |
| 1467 | if (!api || !activeThread) return; | |
| 1468 | await api.orchestration.dispatchCommand({ | |
| 1469 | type: "thread.turn.interrupt", | |
| 1470 | commandId: newCommandId(), | |
| 1471 | threadId: asThreadId(activeThread.id), | |
| 1472 | createdAt: new Date().toISOString(), | |
| 1687 | 1473 | }); |
| 1688 | 1474 | }; |
| 1689 | 1475 | |
| 1690 | 1476 | const onRespondToApproval = useCallback( |
| 1691 | 1477 | async (requestId: string, decision: ProviderApprovalDecision) => { |
| 1692 | if (!api || !activeSessionId || !activeThreadId) return; | |
| 1478 | if (!api || !activeThreadId) return; | |
| 1693 | 1479 | |
| 1694 | 1480 | setRespondingRequestIds((existing) => |
| 1695 | 1481 | existing.includes(requestId) ? existing : [...existing, requestId], |
| 1696 | 1482 | ); |
| 1697 | 1483 | try { |
| 1698 | await api.providers.respondToRequest({ | |
| 1699 | sessionId: activeSessionId, | |
| 1700 | requestId, | |
| 1484 | await api.orchestration.dispatchCommand({ | |
| 1485 | type: "thread.approval.respond", | |
| 1486 | commandId: newCommandId(), | |
| 1487 | threadId: asThreadId(activeThreadId), | |
| 1488 | requestId: asApprovalRequestId(requestId), | |
| 1701 | 1489 | decision, |
| 1490 | createdAt: new Date().toISOString(), | |
| 1702 | 1491 | }); |
| 1703 | 1492 | } catch (err) { |
| 1704 | 1493 | dispatch({ |
| ... | ... | @@ -1710,7 +1499,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1710 | 1499 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); |
| 1711 | 1500 | } |
| 1712 | 1501 | }, |
| 1713 | [activeSessionId, activeThreadId, api, dispatch], | |
| 1502 | [activeThreadId, api, dispatch], | |
| 1714 | 1503 | ); |
| 1715 | 1504 | |
| 1716 | 1505 | const onModelSelect = useCallback( |
| ... | ... | @@ -1719,10 +1508,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1719 | 1508 | if (api) { |
| 1720 | 1509 | void api.orchestration.dispatchCommand({ |
| 1721 | 1510 | type: "thread.meta.update", |
| 1722 | commandId: crypto.randomUUID(), | |
| 1723 | threadId: activeThread.id, | |
| 1511 | commandId: newCommandId(), | |
| 1512 | threadId: asThreadId(activeThread.id), | |
| 1724 | 1513 | model: resolveModelSlug(model), |
| 1725 | createdAt: new Date().toISOString(), | |
| 1726 | 1514 | }); |
| 1727 | 1515 | } |
| 1728 | 1516 | scheduleComposerFocus(); |
apps/web/src/components/DiffPanel.tsx+1-5| ... | ... | @@ -99,9 +99,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 99 | 99 | ); |
| 100 | 100 | const activeThreadId = routeThreadId; |
| 101 | 101 | const activeThread = state.threads.find((thread) => thread.id === activeThreadId); |
| 102 | const activeThreadRuntimeId = | |
| 103 | activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; | |
| 104 | const activeSessionId = activeThread?.session?.sessionId ?? null; | |
| 105 | 102 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 106 | 103 | useTurnDiffSummaries(activeThread); |
| 107 | 104 | const orderedTurnDiffSummaries = useMemo( |
| ... | ... | @@ -173,8 +170,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 173 | 170 | }, [orderedTurnDiffSummaries, selectedTurn]); |
| 174 | 171 | const activeCheckpointDiffQuery = useQuery( |
| 175 | 172 | checkpointDiffQueryOptions(api, { |
| 176 | sessionId: activeSessionId, | |
| 177 | threadRuntimeId: activeThreadRuntimeId, | |
| 173 | threadId: activeThreadId, | |
| 178 | 174 | fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, |
| 179 | 175 | toTurnCount: activeCheckpointRange?.toTurnCount ?? null, |
| 180 | 176 | cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, |
apps/web/src/components/Sidebar.tsx+35-31| ... | ... | @@ -7,6 +7,7 @@ import { useAppSettings } from "../appSettings"; |
| 7 | 7 | import { isElectron } from "../env"; |
| 8 | 8 | import { APP_STAGE_LABEL } from "../branding"; |
| 9 | 9 | import { DEFAULT_MODEL } from "../model-logic"; |
| 10 | import { asProjectId, asThreadId, newCommandId, newProjectId, newThreadId } from "../lib/orchestrationIds"; | |
| 10 | 11 | import { useStore } from "../store"; |
| 11 | 12 | import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; |
| 12 | 13 | import { type Thread } from "../types"; |
| ... | ... | @@ -133,14 +134,14 @@ export default function Sidebar() { |
| 133 | 134 | }, |
| 134 | 135 | ) => { |
| 135 | 136 | if (!api) return; |
| 136 | const threadId = crypto.randomUUID(); | |
| 137 | const threadId = newThreadId(); | |
| 137 | 138 | const createdAt = new Date().toISOString(); |
| 138 | 139 | const model = state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL; |
| 139 | 140 | void api.orchestration.dispatchCommand({ |
| 140 | 141 | type: "thread.create", |
| 141 | commandId: crypto.randomUUID(), | |
| 142 | commandId: newCommandId(), | |
| 142 | 143 | threadId, |
| 143 | projectId, | |
| 144 | projectId: asProjectId(projectId), | |
| 144 | 145 | title: "New thread", |
| 145 | 146 | model, |
| 146 | 147 | branch: options?.branch ?? null, |
| ... | ... | @@ -152,7 +153,7 @@ export default function Sidebar() { |
| 152 | 153 | params: { threadId }, |
| 153 | 154 | }); |
| 154 | 155 | }, |
| 155 | [api, dispatch, navigate, state.projects], | |
| 156 | [api, navigate, state.projects], | |
| 156 | 157 | ); |
| 157 | 158 | |
| 158 | 159 | const focusMostRecentThreadForProject = useCallback( |
| ... | ... | @@ -188,16 +189,19 @@ export default function Sidebar() { |
| 188 | 189 | return; |
| 189 | 190 | } |
| 190 | 191 | |
| 191 | const result = await api.projects.add({ cwd }); | |
| 192 | const projects = await api.projects.list(); | |
| 193 | dispatch({ type: "SYNC_PROJECTS", projects }); | |
| 194 | ||
| 195 | const hasThread = state.threads.some((thread) => thread.projectId === result.project.id); | |
| 196 | if (hasThread) { | |
| 197 | focusMostRecentThreadForProject(result.project.id); | |
| 198 | return; | |
| 199 | } | |
| 200 | handleNewThread(result.project.id); | |
| 192 | const projectId = newProjectId(); | |
| 193 | const createdAt = new Date().toISOString(); | |
| 194 | const title = cwd.split(/[/\\]/).filter(Boolean).at(-1) ?? cwd; | |
| 195 | await api.orchestration.dispatchCommand({ | |
| 196 | type: "project.create", | |
| 197 | commandId: newCommandId(), | |
| 198 | projectId, | |
| 199 | title, | |
| 200 | workspaceRoot: cwd, | |
| 201 | defaultModel: DEFAULT_MODEL, | |
| 202 | createdAt, | |
| 203 | }); | |
| 204 | handleNewThread(projectId); | |
| 201 | 205 | } finally { |
| 202 | 206 | setIsAddingProject(false); |
| 203 | 207 | setNewCwd(""); |
| ... | ... | @@ -206,7 +210,6 @@ export default function Sidebar() { |
| 206 | 210 | }, |
| 207 | 211 | [ |
| 208 | 212 | api, |
| 209 | dispatch, | |
| 210 | 213 | focusMostRecentThreadForProject, |
| 211 | 214 | handleNewThread, |
| 212 | 215 | isAddingProject, |
| ... | ... | @@ -267,15 +270,15 @@ export default function Sidebar() { |
| 267 | 270 | ].join("\n"), |
| 268 | 271 | )); |
| 269 | 272 | |
| 270 | // Stop active session if running | |
| 271 | if (thread.session?.sessionId) { | |
| 272 | try { | |
| 273 | await api.providers.stopSession({ | |
| 274 | sessionId: thread.session.sessionId, | |
| 275 | }); | |
| 276 | } catch { | |
| 277 | // Session may already be stopped | |
| 278 | } | |
| 273 | if (thread.session && thread.session.status !== "closed") { | |
| 274 | await api.orchestration | |
| 275 | .dispatchCommand({ | |
| 276 | type: "thread.session.stop", | |
| 277 | commandId: newCommandId(), | |
| 278 | threadId: asThreadId(threadId), | |
| 279 | createdAt: new Date().toISOString(), | |
| 280 | }) | |
| 281 | .catch(() => undefined); | |
| 279 | 282 | } |
| 280 | 283 | |
| 281 | 284 | try { |
| ... | ... | @@ -291,9 +294,8 @@ export default function Sidebar() { |
| 291 | 294 | const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null; |
| 292 | 295 | await api.orchestration.dispatchCommand({ |
| 293 | 296 | type: "thread.delete", |
| 294 | commandId: crypto.randomUUID(), | |
| 295 | threadId, | |
| 296 | createdAt: new Date().toISOString(), | |
| 297 | commandId: newCommandId(), | |
| 298 | threadId: asThreadId(threadId), | |
| 297 | 299 | }); |
| 298 | 300 | if (shouldNavigateToFallback) { |
| 299 | 301 | if (fallbackThreadId) { |
| ... | ... | @@ -369,9 +371,11 @@ export default function Sidebar() { |
| 369 | 371 | if (!confirmed) return; |
| 370 | 372 | |
| 371 | 373 | try { |
| 372 | await api.projects.remove({ id: projectId }); | |
| 373 | const projects = await api.projects.list(); | |
| 374 | dispatch({ type: "SYNC_PROJECTS", projects }); | |
| 374 | await api.orchestration.dispatchCommand({ | |
| 375 | type: "project.delete", | |
| 376 | commandId: newCommandId(), | |
| 377 | projectId: asProjectId(projectId), | |
| 378 | }); | |
| 375 | 379 | } catch (error) { |
| 376 | 380 | const message = |
| 377 | 381 | error instanceof Error ? error.message : "Unknown error deleting project."; |
| ... | ... | @@ -383,7 +387,7 @@ export default function Sidebar() { |
| 383 | 387 | }); |
| 384 | 388 | } |
| 385 | 389 | }, |
| 386 | [api, dispatch, state.projects, state.threads], | |
| 390 | [api, state.projects, state.threads], | |
| 387 | 391 | ); |
| 388 | 392 | |
| 389 | 393 | useEffect(() => { |
apps/web/src/lib/gitReactQuery.ts+4-2| ... | ... | @@ -152,15 +152,17 @@ export function gitCreateWorktreeMutationOptions(input: { |
| 152 | 152 | cwd, |
| 153 | 153 | branch, |
| 154 | 154 | newBranch, |
| 155 | path, | |
| 155 | 156 | }: { |
| 156 | 157 | cwd: string; |
| 157 | 158 | branch: string; |
| 158 | 159 | newBranch: string; |
| 160 | path?: string | null; | |
| 159 | 161 | }) => { |
| 160 | 162 | if (!input.api) { |
| 161 | 163 | throw new Error("Git worktree creation is unavailable."); |
| 162 | 164 | } |
| 163 | return input.api.git.createWorktree({ cwd, branch, newBranch }); | |
| 165 | return input.api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null }); | |
| 164 | 166 | }, |
| 165 | 167 | onSettled: async () => { |
| 166 | 168 | await invalidateGitQueries(input.queryClient); |
| ... | ... | @@ -177,7 +179,7 @@ export function gitRemoveWorktreeMutationOptions(input: { |
| 177 | 179 | if (!input.api) { |
| 178 | 180 | throw new Error("Git worktree removal is unavailable."); |
| 179 | 181 | } |
| 180 | return input.api.git.removeWorktree({ cwd, path, force }); | |
| 182 | return input.api.git.removeWorktree({ cwd, path, force: force ?? null }); | |
| 181 | 183 | }, |
| 182 | 184 | onSettled: async () => { |
| 183 | 185 | await invalidateGitQueries(input.queryClient); |
apps/web/src/lib/orchestrationIds.ts created+16| ... | ... | @@ -0,0 +1,16 @@ |
| 1 | import { ApprovalRequestId, CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export const newCommandId = (): CommandId => CommandId.makeUnsafe(crypto.randomUUID()); | |
| 4 | ||
| 5 | export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(crypto.randomUUID()); | |
| 6 | ||
| 7 | export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(crypto.randomUUID()); | |
| 8 | ||
| 9 | export const newMessageId = (): MessageId => MessageId.makeUnsafe(crypto.randomUUID()); | |
| 10 | ||
| 11 | export const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 12 | ||
| 13 | export const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 14 | ||
| 15 | export const asApprovalRequestId = (value: string): ApprovalRequestId => | |
| 16 | ApprovalRequestId.makeUnsafe(value); |
apps/web/src/lib/providerReactQuery.test.ts+9-13| ... | ... | @@ -6,8 +6,7 @@ import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQu |
| 6 | 6 | describe("providerQueryKeys.checkpointDiff", () => { |
| 7 | 7 | it("includes cacheScope so reused turn counts do not collide", () => { |
| 8 | 8 | const baseInput = { |
| 9 | sessionId: "session-id", | |
| 10 | threadRuntimeId: "thread-id", | |
| 9 | threadId: "thread-id", | |
| 11 | 10 | fromTurnCount: 1, |
| 12 | 11 | toTurnCount: 2, |
| 13 | 12 | } as const; |
| ... | ... | @@ -28,16 +27,15 @@ describe("providerQueryKeys.checkpointDiff", () => { |
| 28 | 27 | |
| 29 | 28 | describe("checkpointDiffQueryOptions", () => { |
| 30 | 29 | it("forwards checkpoint range to the provider API", async () => { |
| 31 | const getCheckpointDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | |
| 30 | const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); | |
| 32 | 31 | const api = { |
| 33 | providers: { | |
| 34 | getCheckpointDiff, | |
| 32 | orchestration: { | |
| 33 | getTurnDiff, | |
| 35 | 34 | }, |
| 36 | 35 | } as unknown as NativeApi; |
| 37 | 36 | |
| 38 | 37 | const options = checkpointDiffQueryOptions(api, { |
| 39 | sessionId: "session-id", | |
| 40 | threadRuntimeId: "thread-id", | |
| 38 | threadId: "thread-id", | |
| 41 | 39 | fromTurnCount: 3, |
| 42 | 40 | toTurnCount: 4, |
| 43 | 41 | cacheScope: "turn:abc", |
| ... | ... | @@ -46,8 +44,8 @@ describe("checkpointDiffQueryOptions", () => { |
| 46 | 44 | const queryClient = new QueryClient(); |
| 47 | 45 | await queryClient.fetchQuery(options); |
| 48 | 46 | |
| 49 | expect(getCheckpointDiff).toHaveBeenCalledWith({ | |
| 50 | sessionId: "session-id", | |
| 47 | expect(getTurnDiff).toHaveBeenCalledWith({ | |
| 48 | threadId: "thread-id", | |
| 51 | 49 | fromTurnCount: 3, |
| 52 | 50 | toTurnCount: 4, |
| 53 | 51 | }); |
| ... | ... | @@ -55,8 +53,7 @@ describe("checkpointDiffQueryOptions", () => { |
| 55 | 53 | |
| 56 | 54 | it("retries checkpoint-not-ready errors longer than generic failures", () => { |
| 57 | 55 | const options = checkpointDiffQueryOptions(undefined, { |
| 58 | sessionId: "session-id", | |
| 59 | threadRuntimeId: "thread-id", | |
| 56 | threadId: "thread-id", | |
| 60 | 57 | fromTurnCount: 1, |
| 61 | 58 | toTurnCount: 2, |
| 62 | 59 | cacheScope: "turn:abc", |
| ... | ... | @@ -86,8 +83,7 @@ describe("checkpointDiffQueryOptions", () => { |
| 86 | 83 | |
| 87 | 84 | it("backs off longer for checkpoint-not-ready errors", () => { |
| 88 | 85 | const options = checkpointDiffQueryOptions(undefined, { |
| 89 | sessionId: "session-id", | |
| 90 | threadRuntimeId: "thread-id", | |
| 86 | threadId: "thread-id", | |
| 91 | 87 | fromTurnCount: 1, |
| 92 | 88 | toTurnCount: 2, |
| 93 | 89 | cacheScope: "turn:abc", |
apps/web/src/lib/providerReactQuery.ts+7-8| ... | ... | @@ -1,9 +1,9 @@ |
| 1 | 1 | import type { NativeApi } from "@t3tools/contracts"; |
| 2 | 2 | import { queryOptions } from "@tanstack/react-query"; |
| 3 | import { asThreadId } from "./orchestrationIds"; | |
| 3 | 4 | |
| 4 | 5 | export interface CheckpointDiffQueryInput { |
| 5 | sessionId: string | null; | |
| 6 | threadRuntimeId: string | null; | |
| 6 | threadId: string | null; | |
| 7 | 7 | fromTurnCount: number | null; |
| 8 | 8 | toTurnCount: number | null; |
| 9 | 9 | cacheScope?: string | null; |
| ... | ... | @@ -15,8 +15,7 @@ export const providerQueryKeys = { |
| 15 | 15 | [ |
| 16 | 16 | "providers", |
| 17 | 17 | "checkpointDiff", |
| 18 | input.sessionId, | |
| 19 | input.threadRuntimeId, | |
| 18 | input.threadId, | |
| 20 | 19 | input.fromTurnCount, |
| 21 | 20 | input.toTurnCount, |
| 22 | 21 | input.cacheScope ?? null, |
| ... | ... | @@ -58,20 +57,20 @@ export function checkpointDiffQueryOptions( |
| 58 | 57 | return queryOptions({ |
| 59 | 58 | queryKey: providerQueryKeys.checkpointDiff(input), |
| 60 | 59 | queryFn: async () => { |
| 61 | if (!api || !input.sessionId || !hasValidRange) { | |
| 60 | if (!api || !input.threadId || !hasValidRange) { | |
| 62 | 61 | throw new Error("Checkpoint diff is unavailable."); |
| 63 | 62 | } |
| 64 | 63 | const { fromTurnCount, toTurnCount } = input; |
| 65 | 64 | if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") { |
| 66 | 65 | throw new Error("Checkpoint diff range is invalid."); |
| 67 | 66 | } |
| 68 | return api.providers.getCheckpointDiff({ | |
| 69 | sessionId: input.sessionId, | |
| 67 | return api.orchestration.getTurnDiff({ | |
| 68 | threadId: asThreadId(input.threadId), | |
| 70 | 69 | fromTurnCount, |
| 71 | 70 | toTurnCount, |
| 72 | 71 | }); |
| 73 | 72 | }, |
| 74 | enabled: !!api && !!input.sessionId && hasValidRange, | |
| 73 | enabled: !!api && !!input.threadId && hasValidRange, | |
| 75 | 74 | staleTime: Infinity, |
| 76 | 75 | retry: (failureCount, error) => { |
| 77 | 76 | if (isCheckpointTemporarilyUnavailable(error)) { |
apps/web/src/routes/__root.tsx+23-25| ... | ... | @@ -13,6 +13,7 @@ import { isElectron } from "../env"; |
| 13 | 13 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 14 | 14 | import { DEFAULT_MODEL } from "../model-logic"; |
| 15 | 15 | import { useStore } from "../store"; |
| 16 | import { newCommandId, newProjectId, newThreadId } from "../lib/orchestrationIds"; | |
| 16 | 17 | import { onServerWelcome } from "../wsNativeApi"; |
| 17 | 18 | |
| 18 | 19 | export const Route = createRootRouteWithContext<{ |
| ... | ... | @@ -134,7 +135,7 @@ function EventRouter() { |
| 134 | 135 | const flushSnapshotSync = async (): Promise<void> => { |
| 135 | 136 | const snapshot = await api.orchestration.getSnapshot(); |
| 136 | 137 | if (disposed) return; |
| 137 | latestSequence = Math.max(latestSequence, snapshot.sequence); | |
| 138 | latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); | |
| 138 | 139 | dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); |
| 139 | 140 | if (pending) { |
| 140 | 141 | pending = false; |
| ... | ... | @@ -158,12 +159,7 @@ function EventRouter() { |
| 158 | 159 | } |
| 159 | 160 | }; |
| 160 | 161 | |
| 161 | void Promise.all([api.projects.list(), syncSnapshot()]) | |
| 162 | .then(([projects]) => { | |
| 163 | if (disposed) return; | |
| 164 | dispatch({ type: "SYNC_PROJECTS", projects }); | |
| 165 | }) | |
| 166 | .catch(() => undefined); | |
| 162 | void syncSnapshot().catch(() => undefined); | |
| 167 | 163 | |
| 168 | 164 | const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { |
| 169 | 165 | if (event.sequence <= latestSequence) { |
| ... | ... | @@ -209,32 +205,34 @@ function AutoProjectBootstrap() { |
| 209 | 205 | |
| 210 | 206 | bootstrappedRef.current = true; |
| 211 | 207 | const now = new Date().toISOString(); |
| 212 | void api.projects | |
| 213 | .add({ cwd: payload.cwd }) | |
| 214 | .then(async (result) => { | |
| 215 | const projects = await api.projects.list(); | |
| 216 | dispatch({ type: "SYNC_PROJECTS", projects }); | |
| 217 | ||
| 218 | const hasThread = state.threads.some((thread) => thread.projectId === result.project.id); | |
| 219 | if (hasThread) { | |
| 220 | return; | |
| 221 | } | |
| 222 | ||
| 223 | return api.orchestration.dispatchCommand({ | |
| 208 | const projectId = newProjectId(); | |
| 209 | const threadId = newThreadId(); | |
| 210 | void api.orchestration | |
| 211 | .dispatchCommand({ | |
| 212 | type: "project.create", | |
| 213 | commandId: newCommandId(), | |
| 214 | projectId, | |
| 215 | title: payload.projectName, | |
| 216 | workspaceRoot: payload.cwd, | |
| 217 | defaultModel: DEFAULT_MODEL, | |
| 218 | createdAt: now, | |
| 219 | }) | |
| 220 | .then(() => | |
| 221 | api.orchestration.dispatchCommand({ | |
| 224 | 222 | type: "thread.create", |
| 225 | commandId: crypto.randomUUID(), | |
| 226 | threadId: crypto.randomUUID(), | |
| 227 | projectId: result.project.id, | |
| 223 | commandId: newCommandId(), | |
| 224 | threadId, | |
| 225 | projectId, | |
| 228 | 226 | title: "New thread", |
| 229 | 227 | model: DEFAULT_MODEL, |
| 230 | 228 | branch: null, |
| 231 | 229 | worktreePath: null, |
| 232 | 230 | createdAt: now, |
| 233 | }); | |
| 234 | }) | |
| 231 | }), | |
| 232 | ) | |
| 235 | 233 | .catch(() => undefined); |
| 236 | 234 | }); |
| 237 | }, [api, state.projects, state.threads, dispatch]); | |
| 235 | }, [api, state.projects, dispatch]); | |
| 238 | 236 | |
| 239 | 237 | return null; |
| 240 | 238 | } |
apps/web/src/session-logic.test.ts+46-28| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { OrchestrationThreadActivity } from "@t3tools/contracts"; | |
| 1 | import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; | |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | 4 | import { |
| ... | ... | @@ -7,18 +7,24 @@ import { |
| 7 | 7 | deriveWorkLogEntries, |
| 8 | 8 | } from "./session-logic"; |
| 9 | 9 | |
| 10 | function makeActivity( | |
| 11 | overrides: Partial<OrchestrationThreadActivity>, | |
| 12 | ): OrchestrationThreadActivity { | |
| 10 | function makeActivity(overrides: { | |
| 11 | id?: string; | |
| 12 | createdAt?: string; | |
| 13 | kind?: string; | |
| 14 | summary?: string; | |
| 15 | tone?: OrchestrationThreadActivity["tone"]; | |
| 16 | payload?: Record<string, unknown>; | |
| 17 | turnId?: string; | |
| 18 | }): OrchestrationThreadActivity { | |
| 19 | const payload = overrides.payload ?? {}; | |
| 13 | 20 | return { |
| 14 | id: overrides.id ?? crypto.randomUUID(), | |
| 21 | id: EventId.makeUnsafe(overrides.id ?? crypto.randomUUID()), | |
| 15 | 22 | createdAt: overrides.createdAt ?? "2026-02-23T00:00:00.000Z", |
| 16 | label: overrides.label ?? "Tool call", | |
| 23 | kind: overrides.kind ?? "tool.started", | |
| 24 | summary: overrides.summary ?? "Tool call", | |
| 17 | 25 | tone: overrides.tone ?? "tool", |
| 18 | ...(overrides.detail !== undefined ? { detail: overrides.detail } : {}), | |
| 19 | ...(overrides.turnId !== undefined ? { turnId: overrides.turnId } : {}), | |
| 20 | ...(overrides.requestId !== undefined ? { requestId: overrides.requestId } : {}), | |
| 21 | ...(overrides.requestKind !== undefined ? { requestKind: overrides.requestKind } : {}), | |
| 26 | payload, | |
| 27 | turnId: overrides.turnId ? TurnId.makeUnsafe(overrides.turnId) : null, | |
| 22 | 28 | }; |
| 23 | 29 | } |
| 24 | 30 | |
| ... | ... | @@ -28,26 +34,30 @@ describe("derivePendingApprovals", () => { |
| 28 | 34 | makeActivity({ |
| 29 | 35 | id: "approval-open", |
| 30 | 36 | createdAt: "2026-02-23T00:00:01.000Z", |
| 31 | label: "Command approval requested", | |
| 32 | detail: "bun run lint", | |
| 33 | tone: "tool", | |
| 34 | requestId: "req-1", | |
| 35 | requestKind: "command", | |
| 37 | kind: "approval.requested", | |
| 38 | summary: "Command approval requested", | |
| 39 | tone: "approval", | |
| 40 | payload: { | |
| 41 | requestId: "req-1", | |
| 42 | requestKind: "command", | |
| 43 | detail: "bun run lint", | |
| 44 | }, | |
| 36 | 45 | }), |
| 37 | 46 | makeActivity({ |
| 38 | 47 | id: "approval-close", |
| 39 | 48 | createdAt: "2026-02-23T00:00:02.000Z", |
| 40 | label: "Approval resolved", | |
| 49 | kind: "approval.resolved", | |
| 50 | summary: "Approval resolved", | |
| 41 | 51 | tone: "info", |
| 42 | requestId: "req-2", | |
| 52 | payload: { requestId: "req-2" }, | |
| 43 | 53 | }), |
| 44 | 54 | makeActivity({ |
| 45 | 55 | id: "approval-closed-request", |
| 46 | 56 | createdAt: "2026-02-23T00:00:01.500Z", |
| 47 | label: "File-change approval requested", | |
| 48 | tone: "tool", | |
| 49 | requestId: "req-2", | |
| 50 | requestKind: "file-change", | |
| 57 | kind: "approval.requested", | |
| 58 | summary: "File-change approval requested", | |
| 59 | tone: "approval", | |
| 60 | payload: { requestId: "req-2", requestKind: "file-change" }, | |
| 51 | 61 | }), |
| 52 | 62 | ]; |
| 53 | 63 | |
| ... | ... | @@ -68,12 +78,14 @@ describe("deriveWorkLogEntries", () => { |
| 68 | 78 | makeActivity({ |
| 69 | 79 | id: "tool-complete", |
| 70 | 80 | createdAt: "2026-02-23T00:00:03.000Z", |
| 71 | label: "Tool call complete", | |
| 81 | summary: "Tool call complete", | |
| 82 | kind: "tool.completed", | |
| 72 | 83 | }), |
| 73 | 84 | makeActivity({ |
| 74 | 85 | id: "tool-start", |
| 75 | 86 | createdAt: "2026-02-23T00:00:02.000Z", |
| 76 | label: "Tool call", | |
| 87 | summary: "Tool call", | |
| 88 | kind: "tool.started", | |
| 77 | 89 | }), |
| 78 | 90 | ]; |
| 79 | 91 | |
| ... | ... | @@ -83,9 +95,14 @@ describe("deriveWorkLogEntries", () => { |
| 83 | 95 | |
| 84 | 96 | it("filters by turn id when provided", () => { |
| 85 | 97 | const activities: OrchestrationThreadActivity[] = [ |
| 86 | makeActivity({ id: "turn-1", turnId: "turn-1", label: "Tool call" }), | |
| 87 | makeActivity({ id: "turn-2", turnId: "turn-2", label: "Tool call complete" }), | |
| 88 | makeActivity({ id: "no-turn", label: "Checkpoint captured", tone: "info" }), | |
| 98 | makeActivity({ id: "turn-1", turnId: "turn-1", summary: "Tool call", kind: "tool.started" }), | |
| 99 | makeActivity({ | |
| 100 | id: "turn-2", | |
| 101 | turnId: "turn-2", | |
| 102 | summary: "Tool call complete", | |
| 103 | kind: "tool.completed", | |
| 104 | }), | |
| 105 | makeActivity({ id: "no-turn", summary: "Checkpoint captured", tone: "info" }), | |
| 89 | 106 | ]; |
| 90 | 107 | |
| 91 | 108 | const entries = deriveWorkLogEntries(activities, "turn-2"); |
| ... | ... | @@ -97,14 +114,15 @@ describe("deriveWorkLogEntries", () => { |
| 97 | 114 | makeActivity({ |
| 98 | 115 | id: "checkpoint", |
| 99 | 116 | createdAt: "2026-02-23T00:00:01.000Z", |
| 100 | label: "Checkpoint captured", | |
| 117 | summary: "Checkpoint captured", | |
| 101 | 118 | tone: "info", |
| 102 | 119 | }), |
| 103 | 120 | makeActivity({ |
| 104 | 121 | id: "tool-complete", |
| 105 | 122 | createdAt: "2026-02-23T00:00:02.000Z", |
| 106 | label: "Command run complete", | |
| 123 | summary: "Command run complete", | |
| 107 | 124 | tone: "tool", |
| 125 | kind: "tool.completed", | |
| 108 | 126 | }), |
| 109 | 127 | ]; |
| 110 | 128 |
apps/web/src/session-logic.ts+35-15| ... | ... | @@ -1,10 +1,15 @@ |
| 1 | 1 | import type { |
| 2 | 2 | OrchestrationThreadActivity, |
| 3 | 3 | ProviderKind, |
| 4 | ProviderSession, | |
| 5 | 4 | } from "@t3tools/contracts"; |
| 6 | 5 | |
| 7 | import type { ChatMessage, SessionPhase, TurnDiffFileChange, TurnDiffSummary } from "./types"; | |
| 6 | import type { | |
| 7 | ChatMessage, | |
| 8 | SessionPhase, | |
| 9 | ThreadSession, | |
| 10 | TurnDiffFileChange, | |
| 11 | TurnDiffSummary, | |
| 12 | } from "./types"; | |
| 8 | 13 | |
| 9 | 14 | export const PROVIDER_OPTIONS: Array<{ |
| 10 | 15 | value: ProviderKind; |
| ... | ... | @@ -83,18 +88,29 @@ export function derivePendingApprovals( |
| 83 | 88 | ); |
| 84 | 89 | |
| 85 | 90 | for (const activity of ordered) { |
| 86 | if (activity.requestId && activity.requestKind) { | |
| 87 | openByRequestId.set(activity.requestId, { | |
| 88 | requestId: activity.requestId, | |
| 89 | requestKind: activity.requestKind, | |
| 91 | const payload = | |
| 92 | activity.payload && typeof activity.payload === "object" | |
| 93 | ? (activity.payload as Record<string, unknown>) | |
| 94 | : null; | |
| 95 | const requestId = payload && typeof payload.requestId === "string" ? payload.requestId : null; | |
| 96 | const requestKind = | |
| 97 | payload && (payload.requestKind === "command" || payload.requestKind === "file-change") | |
| 98 | ? payload.requestKind | |
| 99 | : null; | |
| 100 | const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined; | |
| 101 | ||
| 102 | if (activity.kind === "approval.requested" && requestId && requestKind) { | |
| 103 | openByRequestId.set(requestId, { | |
| 104 | requestId, | |
| 105 | requestKind, | |
| 90 | 106 | createdAt: activity.createdAt, |
| 91 | ...(activity.detail ? { detail: activity.detail } : {}), | |
| 107 | ...(detail ? { detail } : {}), | |
| 92 | 108 | }); |
| 93 | 109 | continue; |
| 94 | 110 | } |
| 95 | 111 | |
| 96 | if (activity.requestId && activity.label === "Approval resolved") { | |
| 97 | openByRequestId.delete(activity.requestId); | |
| 112 | if (activity.kind === "approval.resolved" && requestId) { | |
| 113 | openByRequestId.delete(requestId); | |
| 98 | 114 | } |
| 99 | 115 | } |
| 100 | 116 | |
| ... | ... | @@ -112,16 +128,20 @@ export function deriveWorkLogEntries( |
| 112 | 128 | ); |
| 113 | 129 | return ordered |
| 114 | 130 | .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) |
| 115 | .filter((activity) => activity.label !== "Checkpoint captured") | |
| 131 | .filter((activity) => activity.summary !== "Checkpoint captured") | |
| 116 | 132 | .map((activity) => { |
| 133 | const payload = | |
| 134 | activity.payload && typeof activity.payload === "object" | |
| 135 | ? (activity.payload as Record<string, unknown>) | |
| 136 | : null; | |
| 117 | 137 | const entry: WorkLogEntry = { |
| 118 | 138 | id: activity.id, |
| 119 | 139 | createdAt: activity.createdAt, |
| 120 | label: activity.label, | |
| 121 | tone: activity.tone, | |
| 140 | label: activity.summary, | |
| 141 | tone: activity.tone === "approval" ? "info" : activity.tone, | |
| 122 | 142 | }; |
| 123 | if (activity.detail) { | |
| 124 | entry.detail = activity.detail; | |
| 143 | if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { | |
| 144 | entry.detail = payload.detail; | |
| 125 | 145 | } |
| 126 | 146 | return entry; |
| 127 | 147 | }); |
| ... | ... | @@ -207,7 +227,7 @@ export function deriveTurnDiffSummaries(_events: ReadonlyArray<OrchestrationThre |
| 207 | 227 | return []; |
| 208 | 228 | } |
| 209 | 229 | |
| 210 | export function derivePhase(session: ProviderSession | null): SessionPhase { | |
| 230 | export function derivePhase(session: ThreadSession | null): SessionPhase { | |
| 211 | 231 | if (!session || session.status === "closed") return "disconnected"; |
| 212 | 232 | if (session.status === "connecting") return "connecting"; |
| 213 | 233 | if (session.status === "running") return "running"; |
apps/web/src/store.ts+99-33| ... | ... | @@ -10,8 +10,7 @@ import { |
| 10 | 10 | |
| 11 | 11 | import { |
| 12 | 12 | type OrchestrationReadModel, |
| 13 | type ProjectListResult, | |
| 14 | normalizeProjectScripts, | |
| 13 | type OrchestrationSessionStatus, | |
| 15 | 14 | } from "@t3tools/contracts"; |
| 16 | 15 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; |
| 17 | 16 | import { |
| ... | ... | @@ -19,6 +18,7 @@ import { |
| 19 | 18 | DEFAULT_THREAD_TERMINAL_ID, |
| 20 | 19 | DEFAULT_RUNTIME_MODE, |
| 21 | 20 | MAX_THREAD_TERMINAL_COUNT, |
| 21 | type ChatMessage, | |
| 22 | 22 | type ProjectScript, |
| 23 | 23 | type Project, |
| 24 | 24 | type RuntimeMode, |
| ... | ... | @@ -30,7 +30,6 @@ import { |
| 30 | 30 | |
| 31 | 31 | type Action = |
| 32 | 32 | | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } |
| 33 | | { type: "SYNC_PROJECTS"; projects: ProjectListResult } | |
| 34 | 33 | | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } |
| 35 | 34 | | { type: "SET_THREADS_HYDRATED"; hydrated: boolean } |
| 36 | 35 | | { type: "TOGGLE_PROJECT"; projectId: string } |
| ... | ... | @@ -138,29 +137,56 @@ function updateThread( |
| 138 | 137 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); |
| 139 | 138 | } |
| 140 | 139 | |
| 141 | function mapProjects( | |
| 142 | incoming: ProjectListResult, | |
| 140 | function cloneProjectScripts(scripts: ReadonlyArray<ProjectScript>): ProjectScript[] { | |
| 141 | return scripts.map((script) => ({ ...script })); | |
| 142 | } | |
| 143 | ||
| 144 | function mapProjectsFromReadModel( | |
| 145 | incoming: OrchestrationReadModel["projects"], | |
| 143 | 146 | previous: Project[], |
| 144 | 147 | ): Project[] { |
| 145 | 148 | return incoming.map((project) => { |
| 146 | 149 | const existing = |
| 147 | 150 | previous.find((entry) => entry.id === project.id) ?? |
| 148 | previous.find((entry) => entry.cwd === project.cwd); | |
| 151 | previous.find((entry) => entry.cwd === project.workspaceRoot); | |
| 149 | 152 | return { |
| 150 | 153 | id: project.id, |
| 151 | name: project.name, | |
| 152 | cwd: project.cwd, | |
| 153 | model: existing?.model ?? DEFAULT_MODEL, | |
| 154 | name: project.title, | |
| 155 | cwd: project.workspaceRoot, | |
| 156 | model: existing?.model ?? resolveModelSlug(project.defaultModel ?? DEFAULT_MODEL), | |
| 154 | 157 | expanded: |
| 155 | 158 | existing?.expanded ?? |
| 156 | 159 | (persistedExpandedProjectCwds.size > 0 |
| 157 | ? persistedExpandedProjectCwds.has(project.cwd) | |
| 160 | ? persistedExpandedProjectCwds.has(project.workspaceRoot) | |
| 158 | 161 | : true), |
| 159 | scripts: normalizeProjectScripts(project.scripts), | |
| 162 | scripts: cloneProjectScripts(project.scripts), | |
| 160 | 163 | }; |
| 161 | 164 | }); |
| 162 | 165 | } |
| 163 | 166 | |
| 167 | function toLegacySessionStatus( | |
| 168 | status: OrchestrationSessionStatus, | |
| 169 | ): "connecting" | "ready" | "running" | "error" | "closed" { | |
| 170 | switch (status) { | |
| 171 | case "starting": | |
| 172 | return "connecting"; | |
| 173 | case "running": | |
| 174 | return "running"; | |
| 175 | case "error": | |
| 176 | return "error"; | |
| 177 | case "ready": | |
| 178 | case "interrupted": | |
| 179 | return "ready"; | |
| 180 | case "idle": | |
| 181 | case "stopped": | |
| 182 | return "closed"; | |
| 183 | } | |
| 184 | } | |
| 185 | ||
| 186 | function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { | |
| 187 | return providerName === "claudeCode" ? "claudeCode" : "codex"; | |
| 188 | } | |
| 189 | ||
| 164 | 190 | |
| 165 | 191 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 166 | 192 | const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); |
| ... | ... | @@ -354,12 +380,32 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { |
| 354 | 380 | export function reducer(state: AppState, action: Action): AppState { |
| 355 | 381 | switch (action.type) { |
| 356 | 382 | case "SYNC_SERVER_READ_MODEL": { |
| 383 | const projects = mapProjectsFromReadModel( | |
| 384 | action.readModel.projects.filter((project) => project.deletedAt === null), | |
| 385 | state.projects, | |
| 386 | ); | |
| 357 | 387 | const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const)); |
| 358 | const threads = action.readModel.threads.map((thread) => { | |
| 388 | const threads = action.readModel.threads | |
| 389 | .filter((thread) => thread.deletedAt === null) | |
| 390 | .map((thread) => { | |
| 359 | 391 | const existing = existingThreadById.get(thread.id); |
| 392 | const latestTurnStartedAt = | |
| 393 | thread.latestTurnId === null | |
| 394 | ? undefined | |
| 395 | : thread.messages.find( | |
| 396 | (message) => message.turnId === thread.latestTurnId && message.role === "user", | |
| 397 | )?.createdAt; | |
| 398 | const latestTurnCompletedAt = thread.checkpoints.find( | |
| 399 | (checkpoint) => checkpoint.turnId === thread.latestTurnId, | |
| 400 | )?.completedAt; | |
| 401 | const latestTurnDurationMs = | |
| 402 | latestTurnStartedAt && latestTurnCompletedAt | |
| 403 | ? Math.max(0, Date.parse(latestTurnCompletedAt) - Date.parse(latestTurnStartedAt)) | |
| 404 | : undefined; | |
| 405 | ||
| 360 | 406 | return normalizeThreadTerminals({ |
| 361 | 407 | id: thread.id, |
| 362 | codexThreadId: thread.session?.threadId ?? null, | |
| 408 | codexThreadId: thread.session?.providerThreadId ?? null, | |
| 363 | 409 | projectId: thread.projectId, |
| 364 | 410 | title: thread.title, |
| 365 | 411 | model: resolveModelSlug(thread.model), |
| ... | ... | @@ -374,52 +420,72 @@ export function reducer(state: AppState, action: Action): AppState { |
| 374 | 420 | activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, |
| 375 | 421 | session: thread.session |
| 376 | 422 | ? { |
| 377 | sessionId: thread.session.sessionId, | |
| 378 | provider: thread.session.provider, | |
| 379 | status: thread.session.status, | |
| 380 | threadId: thread.session.threadId, | |
| 423 | sessionId: thread.session.providerSessionId ?? `thread:${thread.id}`, | |
| 424 | provider: toLegacyProvider(thread.session.providerName), | |
| 425 | status: toLegacySessionStatus(thread.session.status), | |
| 426 | orchestrationStatus: thread.session.status, | |
| 427 | threadId: thread.session.providerThreadId, | |
| 381 | 428 | activeTurnId: thread.session.activeTurnId ?? undefined, |
| 382 | createdAt: thread.session.createdAt, | |
| 429 | createdAt: thread.session.updatedAt, | |
| 383 | 430 | updatedAt: thread.session.updatedAt, |
| 384 | 431 | ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), |
| 385 | 432 | } |
| 386 | 433 | : null, |
| 387 | messages: thread.messages.map((message) => ({ ...message })), | |
| 388 | error: thread.error, | |
| 434 | messages: thread.messages.map((message) => { | |
| 435 | const attachments = message.attachments?.map((attachment, index) => ({ | |
| 436 | type: "image" as const, | |
| 437 | id: `${message.id}:${index}`, | |
| 438 | name: attachment.name, | |
| 439 | mimeType: attachment.mimeType, | |
| 440 | sizeBytes: attachment.sizeBytes, | |
| 441 | previewUrl: attachment.dataUrl, | |
| 442 | })); | |
| 443 | const normalizedMessage: ChatMessage = { | |
| 444 | id: message.id, | |
| 445 | role: message.role, | |
| 446 | text: message.text, | |
| 447 | createdAt: message.createdAt, | |
| 448 | streaming: message.streaming, | |
| 449 | ...(message.streaming ? {} : { completedAt: message.updatedAt }), | |
| 450 | ...(attachments && attachments.length > 0 ? { attachments } : {}), | |
| 451 | }; | |
| 452 | return normalizedMessage; | |
| 453 | }), | |
| 454 | error: thread.session?.lastError ?? null, | |
| 389 | 455 | createdAt: thread.createdAt, |
| 390 | 456 | latestTurnId: thread.latestTurnId ?? undefined, |
| 391 | latestTurnStartedAt: thread.latestTurnStartedAt ?? undefined, | |
| 392 | latestTurnCompletedAt: thread.latestTurnCompletedAt ?? undefined, | |
| 393 | latestTurnDurationMs: thread.latestTurnDurationMs ?? undefined, | |
| 457 | latestTurnStartedAt, | |
| 458 | latestTurnCompletedAt, | |
| 459 | latestTurnDurationMs, | |
| 394 | 460 | lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, |
| 395 | 461 | branch: thread.branch, |
| 396 | 462 | worktreePath: thread.worktreePath, |
| 397 | turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({ | |
| 398 | ...summary, | |
| 399 | files: summary.files.map((file) => ({ ...file })), | |
| 463 | turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({ | |
| 464 | turnId: checkpoint.turnId, | |
| 465 | completedAt: checkpoint.completedAt, | |
| 466 | status: checkpoint.status, | |
| 467 | assistantMessageId: checkpoint.assistantMessageId ?? undefined, | |
| 468 | checkpointTurnCount: checkpoint.checkpointTurnCount, | |
| 469 | checkpointRef: checkpoint.checkpointRef, | |
| 470 | files: checkpoint.files.map((file) => ({ ...file })), | |
| 400 | 471 | })), |
| 401 | 472 | activities: thread.activities.map((activity) => ({ ...activity })), |
| 402 | 473 | }); |
| 403 | 474 | }); |
| 404 | 475 | return { |
| 405 | 476 | ...state, |
| 477 | projects, | |
| 406 | 478 | threads, |
| 407 | 479 | threadsHydrated: true, |
| 408 | 480 | }; |
| 409 | 481 | } |
| 410 | 482 | |
| 411 | case "SYNC_PROJECTS": | |
| 412 | return { | |
| 413 | ...state, | |
| 414 | projects: mapProjects(action.projects, state.projects), | |
| 415 | }; | |
| 416 | ||
| 417 | 483 | case "SET_PROJECT_SCRIPTS": |
| 418 | 484 | return { |
| 419 | 485 | ...state, |
| 420 | 486 | projects: state.projects.map((project) => |
| 421 | 487 | project.id === action.projectId |
| 422 | ? { ...project, scripts: normalizeProjectScripts(action.scripts) } | |
| 488 | ? { ...project, scripts: cloneProjectScripts(action.scripts) } | |
| 423 | 489 | : project, |
| 424 | 490 | ), |
| 425 | 491 | }; |
apps/web/src/types.ts+16-3| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import type { |
| 2 | OrchestrationSessionStatus, | |
| 2 | 3 | OrchestrationThreadActivity, |
| 3 | 4 | ProjectScript as ContractProjectScript, |
| 4 | ProviderSession, | |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | |
| 7 | 7 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| ... | ... | @@ -30,7 +30,7 @@ export type ChatAttachment = ChatImageAttachment; |
| 30 | 30 | |
| 31 | 31 | export interface ChatMessage { |
| 32 | 32 | id: string; |
| 33 | role: "user" | "assistant"; | |
| 33 | role: "user" | "assistant" | "system"; | |
| 34 | 34 | text: string; |
| 35 | 35 | attachments?: ChatAttachment[]; |
| 36 | 36 | createdAt: string; |
| ... | ... | @@ -50,6 +50,7 @@ export interface TurnDiffSummary { |
| 50 | 50 | completedAt: string; |
| 51 | 51 | status?: string | undefined; |
| 52 | 52 | files: TurnDiffFileChange[]; |
| 53 | checkpointRef?: string | undefined; | |
| 53 | 54 | assistantMessageId?: string | undefined; |
| 54 | 55 | checkpointTurnCount?: number | undefined; |
| 55 | 56 | } |
| ... | ... | @@ -76,7 +77,7 @@ export interface Thread { |
| 76 | 77 | activeTerminalId: string; |
| 77 | 78 | terminalGroups: ThreadTerminalGroup[]; |
| 78 | 79 | activeTerminalGroupId: string; |
| 79 | session: ProviderSession | null; | |
| 80 | session: ThreadSession | null; | |
| 80 | 81 | messages: ChatMessage[]; |
| 81 | 82 | error: string | null; |
| 82 | 83 | createdAt: string; |
| ... | ... | @@ -90,3 +91,15 @@ export interface Thread { |
| 90 | 91 | turnDiffSummaries: TurnDiffSummary[]; |
| 91 | 92 | activities: OrchestrationThreadActivity[]; |
| 92 | 93 | } |
| 94 | ||
| 95 | export interface ThreadSession { | |
| 96 | sessionId: string; | |
| 97 | provider: "codex" | "claudeCode"; | |
| 98 | status: SessionPhase | "error" | "closed"; | |
| 99 | threadId: string | null; | |
| 100 | activeTurnId?: string | undefined; | |
| 101 | createdAt: string; | |
| 102 | updatedAt: string; | |
| 103 | lastError?: string; | |
| 104 | orchestrationStatus: OrchestrationSessionStatus; | |
| 105 | } |
apps/web/src/vite-env.d.ts+1-10| ... | ... | @@ -1,15 +1,6 @@ |
| 1 | 1 | /// <reference types="vite/client" /> |
| 2 | 2 | |
| 3 | import type { NativeApi } from "@t3tools/contracts"; | |
| 4 | ||
| 5 | interface DesktopBridge { | |
| 6 | getWsUrl: () => string | null; | |
| 7 | pickFolder: () => Promise<string | null>; | |
| 8 | confirm: (message: string) => Promise<boolean>; | |
| 9 | showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>; | |
| 10 | openExternal: (url: string) => Promise<boolean>; | |
| 11 | onMenuAction: (listener: (action: string) => void) => () => void; | |
| 12 | } | |
| 3 | import type { NativeApi, DesktopBridge } from "@t3tools/contracts"; | |
| 13 | 4 | |
| 14 | 5 | declare global { |
| 15 | 6 | interface Window { |
apps/web/src/wsNativeApi.ts+1-28| ... | ... | @@ -56,12 +56,6 @@ export function createWsNativeApi(): NativeApi { |
| 56 | 56 | }); |
| 57 | 57 | |
| 58 | 58 | const api: NativeApi = { |
| 59 | todos: { | |
| 60 | list: async () => [], | |
| 61 | add: async () => [], | |
| 62 | toggle: async () => [], | |
| 63 | remove: async () => [], | |
| 64 | }, | |
| 65 | 59 | dialogs: { |
| 66 | 60 | pickFolder: async () => { |
| 67 | 61 | if (!window.desktopBridge) return null; |
| ... | ... | @@ -84,30 +78,8 @@ export function createWsNativeApi(): NativeApi { |
| 84 | 78 | onEvent: (callback) => |
| 85 | 79 | transport.subscribe(WS_CHANNELS.terminalEvent, callback as (data: unknown) => void), |
| 86 | 80 | }, |
| 87 | agent: { | |
| 88 | spawn: async () => "", | |
| 89 | kill: async () => {}, | |
| 90 | write: async () => {}, | |
| 91 | onOutput: () => () => {}, | |
| 92 | onExit: () => () => {}, | |
| 93 | }, | |
| 94 | providers: { | |
| 95 | startSession: (input) => transport.request(WS_METHODS.providersStartSession, input), | |
| 96 | sendTurn: (input) => transport.request(WS_METHODS.providersSendTurn, input), | |
| 97 | interruptTurn: (input) => transport.request(WS_METHODS.providersInterruptTurn, input), | |
| 98 | respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input), | |
| 99 | stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input), | |
| 100 | listCheckpoints: (input) => transport.request(WS_METHODS.providersListCheckpoints, input), | |
| 101 | getCheckpointDiff: (input) => transport.request(WS_METHODS.providersGetCheckpointDiff, input), | |
| 102 | revertToCheckpoint: (input) => | |
| 103 | transport.request(WS_METHODS.providersRevertToCheckpoint, input), | |
| 104 | }, | |
| 105 | 81 | projects: { |
| 106 | list: () => transport.request(WS_METHODS.projectsList), | |
| 107 | add: (input) => transport.request(WS_METHODS.projectsAdd, input), | |
| 108 | remove: (input) => transport.request(WS_METHODS.projectsRemove, input), | |
| 109 | 82 | searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), |
| 110 | updateScripts: (input) => transport.request(WS_METHODS.projectsUpdateScripts, input), | |
| 111 | 83 | }, |
| 112 | 84 | shell: { |
| 113 | 85 | openInEditor: (cwd, editor) => |
| ... | ... | @@ -157,6 +129,7 @@ export function createWsNativeApi(): NativeApi { |
| 157 | 129 | getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), |
| 158 | 130 | dispatchCommand: (command) => |
| 159 | 131 | transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, command), |
| 132 | getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input), | |
| 160 | 133 | replayEvents: (fromSequenceExclusive) => |
| 161 | 134 | transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }), |
| 162 | 135 | onDomainEvent: (callback) => |
apps/web/src/wsTransport.ts+26-10| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { WsPush, WsRequest, WsResponse } from "@t3tools/contracts"; | |
| 1 | import type { WsPush } from "@t3tools/contracts"; | |
| 2 | 2 | |
| 3 | 3 | type PushListener = (data: unknown) => void; |
| 4 | 4 | |
| ... | ... | @@ -11,6 +11,14 @@ interface PendingRequest { |
| 11 | 11 | const REQUEST_TIMEOUT_MS = 60_000; |
| 12 | 12 | const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000]; |
| 13 | 13 | |
| 14 | interface WsRequestEnvelope { | |
| 15 | id: string; | |
| 16 | body: { | |
| 17 | _tag: string; | |
| 18 | [key: string]: unknown; | |
| 19 | }; | |
| 20 | } | |
| 21 | ||
| 14 | 22 | export class WsTransport { |
| 15 | 23 | private ws: WebSocket | null = null; |
| 16 | 24 | private nextId = 1; |
| ... | ... | @@ -41,7 +49,11 @@ export class WsTransport { |
| 41 | 49 | throw new Error("Request method is required"); |
| 42 | 50 | } |
| 43 | 51 | const id = String(this.nextId++); |
| 44 | const message: WsRequest = { id, method, ...(params !== undefined ? { params } : {}) }; | |
| 52 | const body = | |
| 53 | params !== undefined | |
| 54 | ? { _tag: method, ...(params as Record<string, unknown>) } | |
| 55 | : { _tag: method }; | |
| 56 | const message: WsRequestEnvelope = { id, body }; | |
| 45 | 57 | |
| 46 | 58 | return new Promise<T>((resolve, reject) => { |
| 47 | 59 | const timeout = setTimeout(() => { |
| ... | ... | @@ -144,22 +156,26 @@ export class WsTransport { |
| 144 | 156 | |
| 145 | 157 | // Response to a request |
| 146 | 158 | if (typeof message.id === "string") { |
| 147 | const response = message as unknown as WsResponse; | |
| 148 | const pending = this.pending.get(response.id); | |
| 159 | const responseId = message.id; | |
| 160 | const pending = this.pending.get(responseId); | |
| 149 | 161 | if (!pending) return; |
| 150 | 162 | |
| 151 | 163 | clearTimeout(pending.timeout); |
| 152 | this.pending.delete(response.id); | |
| 153 | ||
| 154 | if (response.error) { | |
| 155 | pending.reject(new Error(response.error.message)); | |
| 164 | this.pending.delete(responseId); | |
| 165 | ||
| 166 | if ( | |
| 167 | message.error && | |
| 168 | typeof message.error === "object" && | |
| 169 | typeof (message.error as { message?: unknown }).message === "string" | |
| 170 | ) { | |
| 171 | pending.reject(new Error((message.error as { message: string }).message)); | |
| 156 | 172 | } else { |
| 157 | pending.resolve(response.result); | |
| 173 | pending.resolve(message.result); | |
| 158 | 174 | } |
| 159 | 175 | } |
| 160 | 176 | } |
| 161 | 177 | |
| 162 | private send(message: WsRequest) { | |
| 178 | private send(message: WsRequestEnvelope) { | |
| 163 | 179 | if (this.ws?.readyState === WebSocket.OPEN) { |
| 164 | 180 | this.ws.send(JSON.stringify(message)); |
| 165 | 181 | return; |
bun.lock+2-2| ... | ... | @@ -19,6 +19,7 @@ |
| 19 | 19 | "electron": "40.6.0", |
| 20 | 20 | }, |
| 21 | 21 | "devDependencies": { |
| 22 | "@t3tools/contracts": "workspace:*", | |
| 22 | 23 | "@types/node": "^22.10.2", |
| 23 | 24 | "electronmon": "^2.0.2", |
| 24 | 25 | "tsdown": "^0.20.3", |
| ... | ... | @@ -68,6 +69,7 @@ |
| 68 | 69 | "@xterm/addon-fit": "^0.11.0", |
| 69 | 70 | "@xterm/xterm": "^6.0.0", |
| 70 | 71 | "class-variance-authority": "^0.7.1", |
| 72 | "effect": "4.0.0-beta.10", | |
| 71 | 73 | "highlight.js": "^11.11.1", |
| 72 | 74 | "lucide-react": "^0.564.0", |
| 73 | 75 | "react": "^19.0.0", |
| ... | ... | @@ -76,7 +78,6 @@ |
| 76 | 78 | "rehype-highlight": "^7.0.2", |
| 77 | 79 | "remark-gfm": "^4.0.1", |
| 78 | 80 | "tailwind-merge": "^3.4.0", |
| 79 | "zod": "^3.24.1", | |
| 80 | 81 | }, |
| 81 | 82 | "devDependencies": { |
| 82 | 83 | "@tailwindcss/vite": "^4.0.0", |
| ... | ... | @@ -95,7 +96,6 @@ |
| 95 | 96 | "version": "0.0.0", |
| 96 | 97 | "dependencies": { |
| 97 | 98 | "effect": "4.0.0-beta.10", |
| 98 | "zod": "^3.24.1", | |
| 99 | 99 | }, |
| 100 | 100 | "devDependencies": { |
| 101 | 101 | "@effect/language-service": "0.75.1", |
packages/contracts/src/git.ts+1| ... | ... | @@ -5,6 +5,7 @@ const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty()); |
| 5 | 5 | // Domain Types |
| 6 | 6 | |
| 7 | 7 | export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]); |
| 8 | export type GitStackedAction = typeof GitStackedAction.Type; | |
| 8 | 9 | export const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]); |
| 9 | 10 | export const GitPushStepStatus = Schema.Literals([ |
| 10 | 11 | "pushed", |
packages/contracts/src/ipc.ts+12| ... | ... | @@ -35,6 +35,18 @@ import type { |
| 35 | 35 | } from "./orchestration"; |
| 36 | 36 | import { EditorId } from "./editor"; |
| 37 | 37 | |
| 38 | export interface DesktopBridge { | |
| 39 | getWsUrl: () => string | null; | |
| 40 | pickFolder: () => Promise<string | null>; | |
| 41 | confirm: (message: string) => Promise<boolean>; | |
| 42 | showContextMenu: <T extends string>( | |
| 43 | items: readonly { id: T; label: string }[], | |
| 44 | position?: { x: number; y: number }, | |
| 45 | ) => Promise<T | null>; | |
| 46 | openExternal: (url: string) => Promise<boolean>; | |
| 47 | onMenuAction: (listener: (action: string) => void) => () => void; | |
| 48 | } | |
| 49 | ||
| 38 | 50 | export interface NativeApi { |
| 39 | 51 | dialogs: { |
| 40 | 52 | pickFolder: () => Promise<string | null>; |
packages/contracts/src/orchestration.ts+1| ... | ... | @@ -105,6 +105,7 @@ export const ProjectScriptIcon = Schema.Literals([ |
| 105 | 105 | "build", |
| 106 | 106 | "debug", |
| 107 | 107 | ]); |
| 108 | export type ProjectScriptIcon = typeof ProjectScriptIcon.Type; | |
| 108 | 109 | |
| 109 | 110 | export const ProjectScript = Schema.Struct({ |
| 110 | 111 | id: Schema.String, |
packages/contracts/src/server.ts+3-8| ... | ... | @@ -5,17 +5,12 @@ export const ServerConfig = Schema.Struct({ |
| 5 | 5 | cwd: Schema.NonEmptyString, |
| 6 | 6 | keybindings: ResolvedKeybindingsConfig, |
| 7 | 7 | }); |
| 8 | export type ServerConfig = Schema.Codec.Encoded<typeof ServerConfig>; | |
| 8 | export type ServerConfig = typeof ServerConfig.Type; | |
| 9 | 9 | |
| 10 | 10 | export const ServerUpsertKeybindingInput = KeybindingRule; |
| 11 | export type ServerUpsertKeybindingInput = Schema.Codec.Encoded< | |
| 12 | typeof ServerUpsertKeybindingInput | |
| 13 | >; | |
| 11 | export type ServerUpsertKeybindingInput = typeof ServerUpsertKeybindingInput.Type; | |
| 14 | 12 | |
| 15 | 13 | export const ServerUpsertKeybindingResult = Schema.Struct({ |
| 16 | 14 | keybindings: ResolvedKeybindingsConfig, |
| 17 | 15 | }); |
| 18 | export type ServerUpsertKeybindingResult = Schema.Codec.Encoded< | |
| 19 | typeof ServerUpsertKeybindingResult | |
| 20 | >; | |
| 21 | ||
| 16 | export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type; |