| 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,7 +2,6 @@ |
| 2 | "name": "@t3tools/desktop", | 2 | "name": "@t3tools/desktop", |
| 3 | "version": "0.0.0", | 3 | "version": "0.0.0", |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "productName": "T3 Code (Alpha)", | ||
| 6 | "main": "dist-electron/main.js", | 5 | "main": "dist-electron/main.js", |
| 7 | "scripts": { | 6 | "scripts": { |
| 8 | "dev": "bun run --parallel dev:bundle dev:electron", | 7 | "dev": "bun run --parallel dev:bundle dev:electron", |
| ... | @@ -18,11 +17,13 @@ | ... | @@ -18,11 +17,13 @@ |
| 18 | "electron": "40.6.0" | 17 | "electron": "40.6.0" |
| 19 | }, | 18 | }, |
| 20 | "devDependencies": { | 19 | "devDependencies": { |
| 20 | "@t3tools/contracts": "workspace:*", | ||
| 21 | "@types/node": "^22.10.2", | 21 | "@types/node": "^22.10.2", |
| 22 | "electronmon": "^2.0.2", | 22 | "electronmon": "^2.0.2", |
| 23 | "tsdown": "^0.20.3", | 23 | "tsdown": "^0.20.3", |
| 24 | "typescript": "^5.7.3", | 24 | "typescript": "^5.7.3", |
| 25 | "vitest": "^4.0.0", | 25 | "vitest": "^4.0.0", |
| 26 | "wait-on": "^8.0.2" | 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,4 +1,5 @@ |
| 1 | import { contextBridge, ipcRenderer } from "electron"; | 1 | import { contextBridge, ipcRenderer } from "electron"; |
| 2 | import type { DesktopBridge } from "@t3tools/contracts"; | ||
| 2 | 3 | ||
| 3 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; | 4 | const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 4 | const CONFIRM_CHANNEL = "desktop:confirm"; | 5 | const CONFIRM_CHANNEL = "desktop:confirm"; |
| ... | @@ -9,12 +10,11 @@ const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; | ... | @@ -9,12 +10,11 @@ const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; |
| 9 | 10 | ||
| 10 | contextBridge.exposeInMainWorld("desktopBridge", { | 11 | contextBridge.exposeInMainWorld("desktopBridge", { |
| 11 | getWsUrl: () => wsUrl, | 12 | getWsUrl: () => wsUrl, |
| 12 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>, | 13 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), |
| 13 | confirm: (message: string) => ipcRenderer.invoke(CONFIRM_CHANNEL, message) as Promise<boolean>, | 14 | confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), |
| 14 | showContextMenu: (items: { id: string; label: string }[]) => | 15 | showContextMenu: (items) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items), |
| 15 | ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>, | 16 | openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), |
| 16 | openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise<boolean>, | 17 | onMenuAction: (listener) => { |
| 17 | onMenuAction: (listener: (action: string) => void) => { | ||
| 18 | const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { | 18 | const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { |
| 19 | if (typeof action !== "string") return; | 19 | if (typeof action !== "string") return; |
| 20 | listener(action); | 20 | listener(action); |
| ... | @@ -25,4 +25,4 @@ contextBridge.exposeInMainWorld("desktopBridge", { | ... | @@ -25,4 +25,4 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 25 | ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener); | 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,8 +1,14 @@ |
| 1 | import { randomUUID } from "node:crypto"; | 1 | import { randomUUID } from "node:crypto"; |
| 2 | 2 | ||
| 3 | import type { | 3 | import { |
| 4 | ApprovalRequestId, | ||
| 5 | EventId, | ||
| 6 | ProviderApprovalDecision, | ||
| 4 | ProviderRuntimeEvent, | 7 | ProviderRuntimeEvent, |
| 5 | ProviderSession, | 8 | ProviderSession, |
| 9 | ProviderSessionId, | ||
| 10 | ProviderThreadId, | ||
| 11 | ProviderTurnId, | ||
| 6 | ProviderTurnStartResult, | 12 | ProviderTurnStartResult, |
| 7 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 8 | import { Effect, Queue, Stream } from "effect"; | 14 | import { Effect, Queue, Stream } from "effect"; |
| ... | @@ -40,7 +46,15 @@ export interface TestProviderAdapterHarness { | ... | @@ -40,7 +46,15 @@ export interface TestProviderAdapterHarness { |
| 40 | sessionId: string, | 46 | sessionId: string, |
| 41 | response: TestTurnResponse, | 47 | response: TestTurnResponse, |
| 42 | ) => Effect.Effect<void, ProviderAdapterSessionNotFoundError>; | 48 | ) => Effect.Effect<void, ProviderAdapterSessionNotFoundError>; |
| 49 | readonly queueTurnResponseForNextSession: ( | ||
| 50 | response: TestTurnResponse, | ||
| 51 | ) => Effect.Effect<void, never>; | ||
| 43 | readonly getRollbackCalls: (sessionId: string) => ReadonlyArray<number>; | 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 | const PROVIDER = "codex" as const; | 60 | const PROVIDER = "codex" as const; |
| ... | @@ -64,6 +78,15 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { | ... | @@ -64,6 +78,15 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { |
| 64 | const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>(); | 78 | const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 65 | let sessionCount = 0; | 79 | let sessionCount = 0; |
| 66 | const sessions = new Map<string, SessionState>(); | 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 | const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event); | 91 | const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event); |
| 69 | 92 | ||
| ... | @@ -78,8 +101,8 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { | ... | @@ -78,8 +101,8 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { |
| 78 | } | 101 | } |
| 79 | 102 | ||
| 80 | sessionCount += 1; | 103 | sessionCount += 1; |
| 81 | const sessionId = `test-session-${sessionCount}`; | 104 | const sessionId = ProviderSessionId.makeUnsafe(`test-session-${sessionCount}`); |
| 82 | const threadId = `test-thread-${sessionCount}`; | 105 | const threadId = ProviderThreadId.makeUnsafe(`test-thread-${sessionCount}`); |
| 83 | const createdAt = nowIso(); | 106 | const createdAt = nowIso(); |
| 84 | 107 | ||
| 85 | const session: ProviderSession = { | 108 | const session: ProviderSession = { |
| ... | @@ -99,7 +122,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { | ... | @@ -99,7 +122,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { |
| 99 | turns: [], | 122 | turns: [], |
| 100 | }, | 123 | }, |
| 101 | turnCount: 0, | 124 | turnCount: 0, |
| 102 | queuedResponses: [], | 125 | queuedResponses: queuedResponsesForNextSession.splice(0), |
| 103 | rollbackCalls: [], | 126 | rollbackCalls: [], |
| 104 | }); | 127 | }); |
| 105 | 128 | ||
| ... | @@ -115,7 +138,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { | ... | @@ -115,7 +138,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { |
| 115 | 138 | ||
| 116 | state.turnCount += 1; | 139 | state.turnCount += 1; |
| 117 | const turnCount = state.turnCount; | 140 | const turnCount = state.turnCount; |
| 118 | const turnId = `turn-${turnCount}`; | 141 | const turnId = ProviderTurnId.makeUnsafe(`turn-${turnCount}`); |
| 119 | 142 | ||
| 120 | const response = state.queuedResponses.shift(); | 143 | const response = state.queuedResponses.shift(); |
| 121 | if (!response) { | 144 | if (!response) { |
| ... | @@ -182,7 +205,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { | ... | @@ -182,7 +205,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { |
| 182 | if (deferredTurnCompletedEvents.length === 0) { | 205 | if (deferredTurnCompletedEvents.length === 0) { |
| 183 | yield* emit({ | 206 | yield* emit({ |
| 184 | type: "turn.completed", | 207 | type: "turn.completed", |
| 185 | eventId: randomUUID(), | 208 | eventId: EventId.makeUnsafe(randomUUID()), |
| 186 | provider: PROVIDER, | 209 | provider: PROVIDER, |
| 187 | sessionId: input.sessionId, | 210 | sessionId: input.sessionId, |
| 188 | createdAt: nowIso(), | 211 | createdAt: nowIso(), |
| ... | @@ -209,9 +232,20 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { | ... | @@ -209,9 +232,20 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { |
| 209 | 232 | ||
| 210 | const respondToRequest: ProviderAdapterShape<ProviderAdapterError>["respondToRequest"] = ( | 233 | const respondToRequest: ProviderAdapterShape<ProviderAdapterError>["respondToRequest"] = ( |
| 211 | sessionId, | 234 | sessionId, |
| 212 | _requestId, | 235 | requestId, |
| 213 | _decision, | 236 | decision, |
| 214 | ) => (sessions.has(sessionId) ? Effect.void : missingSessionEffect(sessionId)); | 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 | const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (sessionId) => | 250 | const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (sessionId) => |
| 217 | Effect.sync(() => { | 251 | Effect.sync(() => { |
| ... | @@ -295,6 +329,13 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { | ... | @@ -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 | const getRollbackCalls = (sessionId: string): ReadonlyArray<number> => { | 339 | const getRollbackCalls = (sessionId: string): ReadonlyArray<number> => { |
| 299 | const state = sessions.get(sessionId); | 340 | const state = sessions.get(sessionId); |
| 300 | if (!state) { | 341 | if (!state) { |
| ... | @@ -303,9 +344,25 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { | ... | @@ -303,9 +344,25 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () { |
| 303 | return [...state.rollbackCalls]; | 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 | return { | 361 | return { |
| 307 | adapter, | 362 | adapter, |
| 308 | queueTurnResponse, | 363 | queueTurnResponse, |
| 364 | queueTurnResponseForNextSession, | ||
| 309 | getRollbackCalls, | 365 | getRollbackCalls, |
| 366 | getApprovalResponses, | ||
| 310 | } satisfies TestProviderAdapterHarness; | 367 | } satisfies TestProviderAdapterHarness; |
| 311 | }); | 368 | }); |
apps/server/integration/fixtures/providerRuntime.ts+15-7| ... | @@ -1,16 +1,24 @@ | ... | @@ -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 | const PROVIDER = "codex" as const; | 10 | const PROVIDER = "codex" as const; |
| 4 | const SESSION_ID = "fixture-session"; | 11 | const SESSION_ID = ProviderSessionId.makeUnsafe("fixture-session"); |
| 5 | const THREAD_ID = "fixture-thread"; | 12 | const THREAD_ID = ProviderThreadId.makeUnsafe("fixture-thread"); |
| 6 | const TURN_ID = "fixture-turn"; | 13 | const TURN_ID = ProviderTurnId.makeUnsafe("fixture-turn"); |
| 14 | const REQUEST_ID = ApprovalRequestId.makeUnsafe("req-1"); | ||
| 7 | 15 | ||
| 8 | function baseEvent(eventId: string, createdAt: string): Pick< | 16 | function baseEvent(eventId: string, createdAt: string): Pick< |
| 9 | ProviderRuntimeEvent, | 17 | ProviderRuntimeEvent, |
| 10 | "eventId" | "provider" | "sessionId" | "createdAt" | 18 | "eventId" | "provider" | "sessionId" | "createdAt" |
| 11 | > { | 19 | > { |
| 12 | return { | 20 | return { |
| 13 | eventId, | 21 | eventId: EventId.makeUnsafe(eventId), |
| 14 | provider: PROVIDER, | 22 | provider: PROVIDER, |
| 15 | sessionId: SESSION_ID, | 23 | sessionId: SESSION_ID, |
| 16 | createdAt, | 24 | createdAt, |
| ... | @@ -100,7 +108,7 @@ export const codexTurnApprovalFixture = [ | ... | @@ -100,7 +108,7 @@ export const codexTurnApprovalFixture = [ |
| 100 | ...baseEvent("evt-22", "2026-02-23T00:02:00.100Z"), | 108 | ...baseEvent("evt-22", "2026-02-23T00:02:00.100Z"), |
| 101 | threadId: THREAD_ID, | 109 | threadId: THREAD_ID, |
| 102 | turnId: TURN_ID, | 110 | turnId: TURN_ID, |
| 103 | requestId: "req-1", | 111 | requestId: REQUEST_ID, |
| 104 | requestKind: "command", | 112 | requestKind: "command", |
| 105 | detail: "Please approve command", | 113 | detail: "Please approve command", |
| 106 | }, | 114 | }, |
| ... | @@ -109,7 +117,7 @@ export const codexTurnApprovalFixture = [ | ... | @@ -109,7 +117,7 @@ export const codexTurnApprovalFixture = [ |
| 109 | ...baseEvent("evt-23", "2026-02-23T00:02:00.200Z"), | 117 | ...baseEvent("evt-23", "2026-02-23T00:02:00.200Z"), |
| 110 | threadId: THREAD_ID, | 118 | threadId: THREAD_ID, |
| 111 | turnId: TURN_ID, | 119 | turnId: TURN_ID, |
| 112 | requestId: "req-1", | 120 | requestId: REQUEST_ID, |
| 113 | requestKind: "command", | 121 | requestKind: "command", |
| 114 | decision: "accept", | 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,4 +1,4 @@ |
| 1 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; | 1 | import type { ProviderRuntimeEvent, ProviderSessionId } from "@t3tools/contracts"; |
| 2 | import { NodeServices } from "@effect/platform-node"; | 2 | import { NodeServices } from "@effect/platform-node"; |
| 3 | import { it, assert } from "@effect/vitest"; | 3 | import { it, assert } from "@effect/vitest"; |
| 4 | import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect"; | 4 | import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect"; |
| ... | @@ -92,7 +92,7 @@ const collectEventsDuring = <A, E, R>( | ... | @@ -92,7 +92,7 @@ const collectEventsDuring = <A, E, R>( |
| 92 | const runTurn = (input: { | 92 | const runTurn = (input: { |
| 93 | readonly provider: ProviderServiceShape; | 93 | readonly provider: ProviderServiceShape; |
| 94 | readonly harness: TestProviderAdapterHarness; | 94 | readonly harness: TestProviderAdapterHarness; |
| 95 | readonly sessionId: string; | 95 | readonly sessionId: ProviderSessionId; |
| 96 | readonly userText: string; | 96 | readonly userText: string; |
| 97 | readonly response: TestTurnResponse; | 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,14 +132,12 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 132 | }); | 132 | }); |
| 133 | const treeOid = writeTreeResult.stdout.trim(); | 133 | const treeOid = writeTreeResult.stdout.trim(); |
| 134 | if (treeOid.length === 0) { | 134 | if (treeOid.length === 0) { |
| 135 | return yield* Effect.fail( | 135 | return yield* new GitCommandError({ |
| 136 | new GitCommandError({ | 136 | operation, |
| 137 | operation, | 137 | command: "git write-tree", |
| 138 | command: "git write-tree", | 138 | cwd: normalizedCwd, |
| 139 | cwd: normalizedCwd, | 139 | detail: "git write-tree returned an empty tree oid.", |
| 140 | detail: "git write-tree returned an empty tree oid.", | 140 | }); |
| 141 | }), | ||
| 142 | ); | ||
| 143 | } | 141 | } |
| 144 | 142 | ||
| 145 | const message = `t3 checkpoint ref=${checkpointRef}`; | 143 | const message = `t3 checkpoint ref=${checkpointRef}`; |
| ... | @@ -151,14 +149,12 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -151,14 +149,12 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 151 | }); | 149 | }); |
| 152 | const commitOid = commitTreeResult.stdout.trim(); | 150 | const commitOid = commitTreeResult.stdout.trim(); |
| 153 | if (commitOid.length === 0) { | 151 | if (commitOid.length === 0) { |
| 154 | return yield* Effect.fail( | 152 | return yield* new GitCommandError({ |
| 155 | new GitCommandError({ | 153 | operation, |
| 156 | operation, | 154 | command: "git commit-tree", |
| 157 | command: "git commit-tree", | 155 | cwd: normalizedCwd, |
| 158 | cwd: normalizedCwd, | 156 | detail: "git commit-tree returned an empty commit oid.", |
| 159 | detail: "git commit-tree returned an empty commit oid.", | 157 | }); |
| 160 | }), | ||
| 161 | ); | ||
| 162 | } | 158 | } |
| 163 | 159 | ||
| 164 | yield* git.execute({ | 160 | yield* git.execute({ |
| ... | @@ -183,11 +179,9 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -183,11 +179,9 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 183 | }); | 179 | }); |
| 184 | 180 | ||
| 185 | const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) => | 181 | const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) => |
| 186 | Effect.gen(function* () { | 182 | resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe( |
| 187 | return yield* resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe( | 183 | Effect.map((commit) => commit !== null), |
| 188 | Effect.map((commit) => commit !== null), | 184 | ); |
| 189 | ); | ||
| 190 | }); | ||
| 191 | 185 | ||
| 192 | const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = (input) => | 186 | const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = (input) => |
| 193 | Effect.gen(function* () { | 187 | Effect.gen(function* () { |
| ... | @@ -241,14 +235,12 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -241,14 +235,12 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 241 | } | 235 | } |
| 242 | 236 | ||
| 243 | if (!fromCommitOid || !toCommitOid) { | 237 | if (!fromCommitOid || !toCommitOid) { |
| 244 | return yield* Effect.fail( | 238 | return yield* new GitCommandError({ |
| 245 | new GitCommandError({ | 239 | operation, |
| 246 | operation, | 240 | command: "git diff", |
| 247 | command: "git diff", | 241 | cwd: input.cwd, |
| 248 | cwd: input.cwd, | 242 | detail: "Checkpoint ref is unavailable for diff operation.", |
| 249 | detail: "Checkpoint ref is unavailable for diff operation.", | 243 | }); |
| 250 | }), | ||
| 251 | ); | ||
| 252 | } | 244 | } |
| 253 | 245 | ||
| 254 | const result = yield* git.execute({ | 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,12 +103,10 @@ const makeProviderSessionDirectory = Effect.gen(function* () { |
| 103 | const existingRuntime = Option.getOrUndefined(existing); | 103 | const existingRuntime = Option.getOrUndefined(existing); |
| 104 | const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; | 104 | const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; |
| 105 | if (!resolvedThreadId) { | 105 | if (!resolvedThreadId) { |
| 106 | return yield* Effect.fail( | 106 | return yield* new ProviderValidationError({ |
| 107 | new ProviderValidationError({ | 107 | operation: "ProviderSessionDirectory.upsert", |
| 108 | operation: "ProviderSessionDirectory.upsert", | 108 | issue: "threadId must be a non-empty string.", |
| 109 | issue: "threadId must be a non-empty string.", | 109 | }); |
| 110 | }), | ||
| 111 | ); | ||
| 112 | } | 110 | } |
| 113 | 111 | ||
| 114 | const now = new Date().toISOString(); | 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,7 +189,13 @@ function waitForMessage(ws: WebSocket): Promise<unknown> { |
| 189 | 189 | ||
| 190 | async function sendRequest(ws: WebSocket, method: string, params?: unknown): Promise<WsResponse> { | 190 | async function sendRequest(ws: WebSocket, method: string, params?: unknown): Promise<WsResponse> { |
| 191 | const id = crypto.randomUUID(); | 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 | ws.send(message); | 199 | ws.send(message); |
| 194 | 200 | ||
| 195 | // Wait for response with matching id | 201 | // Wait for response with matching id |
| ... | @@ -198,6 +204,9 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro | ... | @@ -198,6 +204,9 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro |
| 198 | if (parsed.id === id) { | 204 | if (parsed.id === id) { |
| 199 | return parsed as WsResponse; | 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,18 +667,27 @@ describe("WebSocket Server", () => { |
| 658 | 667 | ||
| 659 | const response = await sendRequest(ws, "nonexistent.method"); | 668 | const response = await sendRequest(ws, "nonexistent.method"); |
| 660 | expect(response.error).toBeDefined(); | 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 | it("keeps orchestration domain push behavior for provider runtime events", async () => { | 673 | it("keeps orchestration domain push behavior for provider runtime events", async () => { |
| 665 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | 674 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 675 | const sessionId = "sess-test"; | ||
| 666 | const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { | 676 | const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { |
| 667 | Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); | 677 | Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); |
| 668 | }; | 678 | }; |
| 669 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; | 679 | const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; |
| 670 | const providerService: ProviderServiceShape = { | 680 | const providerService: ProviderServiceShape = { |
| 671 | startSession: () => unsupported(), | 681 | startSession: () => |
| 672 | sendTurn: () => unsupported(), | 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 | interruptTurn: () => unsupported(), | 691 | interruptTurn: () => unsupported(), |
| 674 | respondToRequest: () => unsupported(), | 692 | respondToRequest: () => unsupported(), |
| 675 | stopSession: () => unsupported(), | 693 | stopSession: () => unsupported(), |
| ... | @@ -716,29 +734,30 @@ describe("WebSocket Server", () => { | ... | @@ -716,29 +734,30 @@ describe("WebSocket Server", () => { |
| 716 | }); | 734 | }); |
| 717 | expect(createThreadResponse.error).toBeUndefined(); | 735 | expect(createThreadResponse.error).toBeUndefined(); |
| 718 | 736 | ||
| 719 | const setSessionResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { | 737 | const startTurnResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { |
| 720 | type: "thread.session", | 738 | type: "thread.turn.start", |
| 721 | commandId: "cmd-ws-runtime-session-set", | 739 | commandId: "cmd-ws-runtime-turn-start", |
| 722 | threadId: "thread-1", | 740 | threadId: "thread-1", |
| 723 | session: { | 741 | message: { |
| 724 | sessionId: "sess-test", | 742 | messageId: "msg-ws-runtime-1", |
| 725 | provider: "codex", | 743 | role: "user", |
| 726 | status: "ready", | 744 | text: "hello", |
| 727 | threadId: "thread-1", | 745 | attachments: [], |
| 728 | activeTurnId: null, | ||
| 729 | createdAt, | ||
| 730 | updatedAt: createdAt, | ||
| 731 | lastError: null, | ||
| 732 | }, | 746 | }, |
| 733 | createdAt, | 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 | emitRuntimeEvent({ | 756 | emitRuntimeEvent({ |
| 738 | type: "message.delta", | 757 | type: "message.delta", |
| 739 | eventId: "evt-ws-runtime-message-delta", | 758 | eventId: "evt-ws-runtime-message-delta", |
| 740 | provider: "codex", | 759 | provider: "codex", |
| 741 | sessionId: "sess-test", | 760 | sessionId, |
| 742 | createdAt: new Date().toISOString(), | 761 | createdAt: new Date().toISOString(), |
| 743 | turnId: "turn-1", | 762 | turnId: "turn-1", |
| 744 | itemId: "item-1", | 763 | itemId: "item-1", |
| ... | @@ -746,13 +765,16 @@ describe("WebSocket Server", () => { | ... | @@ -746,13 +765,16 @@ describe("WebSocket Server", () => { |
| 746 | }); | 765 | }); |
| 747 | 766 | ||
| 748 | const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { | 767 | const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { |
| 749 | const event = push.data as { type?: string; payload?: { id?: string; text?: string } }; | 768 | const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } }; |
| 750 | return event.type === "message.sent" && event.payload?.id === "assistant:item-1"; | 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 } }; | 772 | const domainEvent = domainPush.data as { |
| 754 | expect(domainEvent.type).toBe("message.sent"); | 773 | type: string; |
| 755 | expect(domainEvent.payload.id).toBe("assistant:item-1"); | 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 | expect(domainEvent.payload.text).toBe("hello from runtime"); | 778 | expect(domainEvent.payload.text).toBe("hello from runtime"); |
| 757 | }); | 779 | }); |
| 758 | 780 | ||
| ... | @@ -895,7 +917,7 @@ describe("WebSocket Server", () => { | ... | @@ -895,7 +917,7 @@ describe("WebSocket Server", () => { |
| 895 | expect(response!.error!.message).toContain("Invalid request format"); | 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 | server = createTestServer({ cwd: "/test" }); | 921 | server = createTestServer({ cwd: "/test" }); |
| 900 | await server.start(); | 922 | await server.start(); |
| 901 | const addr = server.httpServer.address(); | 923 | const addr = server.httpServer.address(); |
| ... | @@ -907,19 +929,19 @@ describe("WebSocket Server", () => { | ... | @@ -907,19 +929,19 @@ describe("WebSocket Server", () => { |
| 907 | 929 | ||
| 908 | const listResponse = await sendRequest(ws, WS_METHODS.projectsList); | 930 | const listResponse = await sendRequest(ws, WS_METHODS.projectsList); |
| 909 | expect(listResponse.result).toBeUndefined(); | 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 | const addResponse = await sendRequest(ws, WS_METHODS.projectsAdd, { | 934 | const addResponse = await sendRequest(ws, WS_METHODS.projectsAdd, { |
| 913 | cwd: "/tmp/project-a", | 935 | cwd: "/tmp/project-a", |
| 914 | }); | 936 | }); |
| 915 | expect(addResponse.result).toBeUndefined(); | 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 | const removeResponse = await sendRequest(ws, WS_METHODS.projectsRemove, { | 940 | const removeResponse = await sendRequest(ws, WS_METHODS.projectsRemove, { |
| 919 | id: "project-a", | 941 | id: "project-a", |
| 920 | }); | 942 | }); |
| 921 | expect(removeResponse.result).toBeUndefined(); | 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 | it("supports projects.searchEntries", async () => { | 947 | it("supports projects.searchEntries", async () => { |
| ... | @@ -989,7 +1011,7 @@ describe("WebSocket Server", () => { | ... | @@ -989,7 +1011,7 @@ describe("WebSocket Server", () => { |
| 989 | expect(pullResponse.error?.message).not.toContain("Unknown method"); | 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 | const gitManager = { | 1015 | const gitManager = { |
| 994 | status: vi.fn().mockResolvedValue({ | 1016 | status: vi.fn().mockResolvedValue({ |
| 995 | branch: "feature/test", | 1017 | branch: "feature/test", |
apps/server/src/wsServer.ts+72-22| ... | @@ -132,6 +132,45 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { | ... | @@ -132,6 +132,45 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { |
| 132 | return undefined; | 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 | type EffectRuntime = ManagedRuntime.ManagedRuntime< | 174 | type EffectRuntime = ManagedRuntime.ManagedRuntime< |
| 136 | | OrchestrationEngineService | 175 | | OrchestrationEngineService |
| 137 | | ProjectionSnapshotQuery | 176 | | ProjectionSnapshotQuery |
| ... | @@ -360,9 +399,19 @@ export function createServer(options: ServerOptions) { | ... | @@ -360,9 +399,19 @@ export function createServer(options: ServerOptions) { |
| 360 | }); | 399 | }); |
| 361 | 400 | ||
| 362 | async function handleMessage(ws: WebSocket, raw: unknown) { | 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 | let request: WebSocketRequest; | 412 | let request: WebSocketRequest; |
| 364 | try { | 413 | try { |
| 365 | request = Schema.decodeUnknownSync(Schema.fromJsonString(WebSocketRequest))(raw); | 414 | request = Schema.decodeUnknownSync(Schema.fromJsonString(WebSocketRequest))(messageText); |
| 366 | } catch { | 415 | } catch { |
| 367 | const errorResponse: WsResponse = { | 416 | const errorResponse: WsResponse = { |
| 368 | id: "unknown", | 417 | id: "unknown", |
| ... | @@ -406,17 +455,18 @@ export function createServer(options: ServerOptions) { | ... | @@ -406,17 +455,18 @@ export function createServer(options: ServerOptions) { |
| 406 | } | 455 | } |
| 407 | 456 | ||
| 408 | case ORCHESTRATION_WS_METHODS.getTurnDiff: { | 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 | if (Option.isNone(blob)) { | 460 | if (Option.isNone(blob)) { |
| 411 | throw new Error( | 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 | return { | 466 | return { |
| 417 | threadId: request.body.threadId, | 467 | threadId: body.threadId, |
| 418 | fromTurnCount: request.body.fromTurnCount, | 468 | fromTurnCount: body.fromTurnCount, |
| 419 | toTurnCount: request.body.toTurnCount, | 469 | toTurnCount: body.toTurnCount, |
| 420 | diff: blob.value?.diff, | 470 | diff: blob.value?.diff, |
| 421 | }; | 471 | }; |
| 422 | } | 472 | } |
| ... | @@ -434,7 +484,7 @@ export function createServer(options: ServerOptions) { | ... | @@ -434,7 +484,7 @@ export function createServer(options: ServerOptions) { |
| 434 | ); | 484 | ); |
| 435 | 485 | ||
| 436 | case WS_METHODS.projectsSearchEntries: | 486 | case WS_METHODS.projectsSearchEntries: |
| 437 | return searchWorkspaceEntries(request.body); | 487 | return searchWorkspaceEntries(stripRequestTag(request.body)); |
| 438 | 488 | ||
| 439 | case WS_METHODS.shellOpenInEditor: { | 489 | case WS_METHODS.shellOpenInEditor: { |
| 440 | const body = request.body; | 490 | const body = request.body; |
| ... | @@ -500,48 +550,48 @@ export function createServer(options: ServerOptions) { | ... | @@ -500,48 +550,48 @@ export function createServer(options: ServerOptions) { |
| 500 | // } | 550 | // } |
| 501 | 551 | ||
| 502 | case WS_METHODS.gitPull: | 552 | case WS_METHODS.gitPull: |
| 503 | return pullGitBranch(request.body); | 553 | return pullGitBranch(stripRequestTag(request.body)); |
| 504 | 554 | ||
| 505 | case WS_METHODS.gitRunStackedAction: | 555 | case WS_METHODS.gitRunStackedAction: |
| 506 | return gitManager.runStackedAction(request.body); | 556 | return gitManager.runStackedAction(stripRequestTag(request.body)); |
| 507 | case WS_METHODS.gitListBranches: | 557 | case WS_METHODS.gitListBranches: |
| 508 | return listGitBranches(request.body); | 558 | return listGitBranches(stripRequestTag(request.body)); |
| 509 | 559 | ||
| 510 | case WS_METHODS.gitCreateWorktree: | 560 | case WS_METHODS.gitCreateWorktree: |
| 511 | return createGitWorktree(request.body); | 561 | return createGitWorktree(stripRequestTag(request.body)); |
| 512 | 562 | ||
| 513 | case WS_METHODS.gitRemoveWorktree: | 563 | case WS_METHODS.gitRemoveWorktree: |
| 514 | return removeGitWorktree(request.body); | 564 | return removeGitWorktree(stripRequestTag(request.body)); |
| 515 | 565 | ||
| 516 | case WS_METHODS.gitCreateBranch: | 566 | case WS_METHODS.gitCreateBranch: |
| 517 | return createGitBranch(request.body); | 567 | return createGitBranch(stripRequestTag(request.body)); |
| 518 | 568 | ||
| 519 | case WS_METHODS.gitCheckout: | 569 | case WS_METHODS.gitCheckout: |
| 520 | return checkoutGitBranch(request.body); | 570 | return checkoutGitBranch(stripRequestTag(request.body)); |
| 521 | 571 | ||
| 522 | case WS_METHODS.gitInit: | 572 | case WS_METHODS.gitInit: |
| 523 | return initGitRepo(request.body); | 573 | return initGitRepo(stripRequestTag(request.body)); |
| 524 | 574 | ||
| 525 | case WS_METHODS.terminalOpen: | 575 | case WS_METHODS.terminalOpen: |
| 526 | return terminalManager.open(request.body); | 576 | return terminalManager.open(stripRequestTag(request.body)); |
| 527 | 577 | ||
| 528 | case WS_METHODS.terminalWrite: | 578 | case WS_METHODS.terminalWrite: |
| 529 | await terminalManager.write(request.body); | 579 | await terminalManager.write(stripRequestTag(request.body)); |
| 530 | return undefined; | 580 | return undefined; |
| 531 | 581 | ||
| 532 | case WS_METHODS.terminalResize: | 582 | case WS_METHODS.terminalResize: |
| 533 | await terminalManager.resize(request.body); | 583 | await terminalManager.resize(stripRequestTag(request.body)); |
| 534 | return undefined; | 584 | return undefined; |
| 535 | 585 | ||
| 536 | case WS_METHODS.terminalClear: | 586 | case WS_METHODS.terminalClear: |
| 537 | await terminalManager.clear(request.body); | 587 | await terminalManager.clear(stripRequestTag(request.body)); |
| 538 | return undefined; | 588 | return undefined; |
| 539 | 589 | ||
| 540 | case WS_METHODS.terminalRestart: | 590 | case WS_METHODS.terminalRestart: |
| 541 | return terminalManager.restart(request.body); | 591 | return terminalManager.restart(stripRequestTag(request.body)); |
| 542 | 592 | ||
| 543 | case WS_METHODS.terminalClose: | 593 | case WS_METHODS.terminalClose: |
| 544 | await terminalManager.close(request.body); | 594 | await terminalManager.close(stripRequestTag(request.body)); |
| 545 | return undefined; | 595 | return undefined; |
| 546 | 596 | ||
| 547 | case WS_METHODS.serverGetConfig: | 597 | case WS_METHODS.serverGetConfig: |
| ... | @@ -551,7 +601,7 @@ export function createServer(options: ServerOptions) { | ... | @@ -551,7 +601,7 @@ export function createServer(options: ServerOptions) { |
| 551 | }; | 601 | }; |
| 552 | 602 | ||
| 553 | case WS_METHODS.serverUpsertKeybinding: | 603 | case WS_METHODS.serverUpsertKeybinding: |
| 554 | keybindingsConfig = upsertKeybindingRule(logger, request.body); | 604 | keybindingsConfig = upsertKeybindingRule(logger, stripRequestTag(request.body)); |
| 555 | return { | 605 | return { |
| 556 | keybindings: keybindingsConfig, | 606 | keybindings: keybindingsConfig, |
| 557 | }; | 607 | }; |
apps/server/tsconfig.json+2-2| ... | @@ -12,6 +12,6 @@ | ... | @@ -12,6 +12,6 @@ |
| 12 | } | 12 | } |
| 13 | ] | 13 | ] |
| 14 | }, | 14 | }, |
| 15 | "include": ["src", "tsdown.config.ts", "scripts"], | 15 | "include": ["src", "tsdown.config.ts", "scripts", "integration"], |
| 16 | "exclude": ["src/**/*.test.ts", "integration"] | 16 | "exclude": ["src/**/*.test.ts"] |
| 17 | } | 17 | } |
apps/server/tsdown.config.ts+1-1| ... | @@ -6,7 +6,7 @@ export default defineConfig({ | ... | @@ -6,7 +6,7 @@ export default defineConfig({ |
| 6 | outDir: "dist", | 6 | outDir: "dist", |
| 7 | sourcemap: true, | 7 | sourcemap: true, |
| 8 | clean: true, | 8 | clean: true, |
| 9 | noExternal: ["@t3tools/contracts", "zod"], | 9 | noExternal: ["@t3tools/contracts"], |
| 10 | inlineOnly: false, | 10 | inlineOnly: false, |
| 11 | banner: { | 11 | banner: { |
| 12 | js: "#!/usr/bin/env node\n", | 12 | js: "#!/usr/bin/env node\n", |
apps/web/package.json+2-2| ... | @@ -21,6 +21,7 @@ | ... | @@ -21,6 +21,7 @@ |
| 21 | "@xterm/addon-fit": "^0.11.0", | 21 | "@xterm/addon-fit": "^0.11.0", |
| 22 | "@xterm/xterm": "^6.0.0", | 22 | "@xterm/xterm": "^6.0.0", |
| 23 | "class-variance-authority": "^0.7.1", | 23 | "class-variance-authority": "^0.7.1", |
| 24 | "effect": "4.0.0-beta.10", | ||
| 24 | "highlight.js": "^11.11.1", | 25 | "highlight.js": "^11.11.1", |
| 25 | "lucide-react": "^0.564.0", | 26 | "lucide-react": "^0.564.0", |
| 26 | "react": "^19.0.0", | 27 | "react": "^19.0.0", |
| ... | @@ -28,8 +29,7 @@ | ... | @@ -28,8 +29,7 @@ |
| 28 | "react-markdown": "^10.1.0", | 29 | "react-markdown": "^10.1.0", |
| 29 | "rehype-highlight": "^7.0.2", | 30 | "rehype-highlight": "^7.0.2", |
| 30 | "remark-gfm": "^4.0.1", | 31 | "remark-gfm": "^4.0.1", |
| 31 | "tailwind-merge": "^3.4.0", | 32 | "tailwind-merge": "^3.4.0" |
| 32 | "zod": "^3.24.1" | ||
| 33 | }, | 33 | }, |
| 34 | "devDependencies": { | 34 | "devDependencies": { |
| 35 | "@tailwindcss/vite": "^4.0.0", | 35 | "@tailwindcss/vite": "^4.0.0", |
apps/web/src/appSettings.ts+18-12| ... | @@ -1,17 +1,20 @@ | ... | @@ -1,17 +1,20 @@ |
| 1 | import { useCallback, useSyncExternalStore } from "react"; | 1 | import { useCallback, useSyncExternalStore } from "react"; |
| 2 | import { z } from "zod"; | 2 | import { Option, Schema } from "effect"; |
| 3 | 3 | ||
| 4 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; | 4 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; |
| 5 | 5 | ||
| 6 | const appSettingsSchema = z.object({ | 6 | const AppSettingsSchema = Schema.Struct({ |
| 7 | codexBinaryPath: z.string().trim().max(4096).default(""), | 7 | codexBinaryPath: Schema.NonEmptyString.check(Schema.isMaxLength(4096)).pipe( |
| 8 | codexHomePath: z.string().trim().max(4096).default(""), | 8 | Schema.withConstructorDefault(() => Option.some("")), |
| 9 | confirmThreadDelete: z.boolean().default(true), | 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>; | 17 | const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); |
| 13 | |||
| 14 | const DEFAULT_APP_SETTINGS: AppSettings = appSettingsSchema.parse({}); | ||
| 15 | 18 | ||
| 16 | let listeners: Array<() => void> = []; | 19 | let listeners: Array<() => void> = []; |
| 17 | let cachedRawSettings: string | null | undefined; | 20 | let cachedRawSettings: string | null | undefined; |
| ... | @@ -29,8 +32,7 @@ function parsePersistedSettings(value: string | null): AppSettings { | ... | @@ -29,8 +32,7 @@ function parsePersistedSettings(value: string | null): AppSettings { |
| 29 | } | 32 | } |
| 30 | 33 | ||
| 31 | try { | 34 | try { |
| 32 | const parsed = JSON.parse(value) as unknown; | 35 | return Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value); |
| 33 | return appSettingsSchema.parse(parsed); | ||
| 34 | } catch { | 36 | } catch { |
| 35 | return DEFAULT_APP_SETTINGS; | 37 | return DEFAULT_APP_SETTINGS; |
| 36 | } | 38 | } |
| ... | @@ -84,10 +86,14 @@ function subscribe(listener: () => void): () => void { | ... | @@ -84,10 +86,14 @@ function subscribe(listener: () => void): () => void { |
| 84 | } | 86 | } |
| 85 | 87 | ||
| 86 | export function useAppSettings() { | 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 | const updateSettings = useCallback((patch: Partial<AppSettings>) => { | 95 | const updateSettings = useCallback((patch: Partial<AppSettings>) => { |
| 90 | const next = appSettingsSchema.parse({ | 96 | const next = Schema.decodeSync(AppSettingsSchema)({ |
| 91 | ...getAppSettingsSnapshot(), | 97 | ...getAppSettingsSnapshot(), |
| 92 | ...patch, | 98 | ...patch, |
| 93 | }); | 99 | }); |
apps/web/src/components/BranchToolbar.logic.ts+1-1| ... | @@ -5,7 +5,7 @@ interface DeriveSyncedLocalBranchInput { | ... | @@ -5,7 +5,7 @@ interface DeriveSyncedLocalBranchInput { |
| 5 | activeWorktreePath: string | null; | 5 | activeWorktreePath: string | null; |
| 6 | envMode: "local" | "worktree"; | 6 | envMode: "local" | "worktree"; |
| 7 | activeThreadBranch: string | null; | 7 | activeThreadBranch: string | null; |
| 8 | queryBranches: GitBranch[] | undefined; | 8 | queryBranches: ReadonlyArray<GitBranch> | undefined; |
| 9 | } | 9 | } |
| 10 | 10 | ||
| 11 | export function deriveSyncedLocalBranch({ | 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,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 | import { useEffect, useState } from "react"; | 3 | import { useEffect, useState } from "react"; |
| 4 | 4 | ||
| 5 | import { useNativeApi } from "../hooks/useNativeApi"; | 5 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 6 | import { asThreadId, newCommandId } from "../lib/orchestrationIds"; | ||
| 6 | import { | 7 | import { |
| 7 | gitBranchesQueryOptions, | 8 | gitBranchesQueryOptions, |
| 8 | gitCheckoutMutationOptions, | 9 | gitCheckoutMutationOptions, |
| ... | @@ -95,11 +96,10 @@ export default function BranchToolbar({ | ... | @@ -95,11 +96,10 @@ export default function BranchToolbar({ |
| 95 | if (api) { | 96 | if (api) { |
| 96 | void api.orchestration.dispatchCommand({ | 97 | void api.orchestration.dispatchCommand({ |
| 97 | type: "thread.meta.update", | 98 | type: "thread.meta.update", |
| 98 | commandId: crypto.randomUUID(), | 99 | commandId: newCommandId(), |
| 99 | threadId: activeThreadId, | 100 | threadId: asThreadId(activeThreadId), |
| 100 | branch: syncedBranch, | 101 | branch: syncedBranch, |
| 101 | worktreePath: null, | 102 | worktreePath: null, |
| 102 | createdAt: new Date().toISOString(), | ||
| 103 | }); | 103 | }); |
| 104 | } | 104 | } |
| 105 | dispatch({ | 105 | dispatch({ |
| ... | @@ -127,17 +127,23 @@ export default function BranchToolbar({ | ... | @@ -127,17 +127,23 @@ export default function BranchToolbar({ |
| 127 | // If the effective cwd is about to change, stop the running session so the | 127 | // If the effective cwd is about to change, stop the running session so the |
| 128 | // next message creates a new one with the correct cwd. | 128 | // next message creates a new one with the correct cwd. |
| 129 | const sessionId = activeThread?.session?.sessionId; | 129 | const sessionId = activeThread?.session?.sessionId; |
| 130 | if (sessionId && worktreePath !== activeWorktreePath) { | 130 | if (sessionId && worktreePath !== activeWorktreePath && api) { |
| 131 | void api?.providers.stopSession({ sessionId }).catch(() => undefined); | 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 | if (api) { | 140 | if (api) { |
| 134 | void api.orchestration.dispatchCommand({ | 141 | void api.orchestration.dispatchCommand({ |
| 135 | type: "thread.meta.update", | 142 | type: "thread.meta.update", |
| 136 | commandId: crypto.randomUUID(), | 143 | commandId: newCommandId(), |
| 137 | threadId: activeThreadId, | 144 | threadId: asThreadId(activeThreadId), |
| 138 | branch, | 145 | branch, |
| 139 | worktreePath, | 146 | worktreePath, |
| 140 | createdAt: new Date().toISOString(), | ||
| 141 | }); | 147 | }); |
| 142 | } | 148 | } |
| 143 | dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath }); | 149 | dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath }); |
apps/web/src/components/ChatView.tsx+76-288| ... | @@ -5,12 +5,10 @@ import { | ... | @@ -5,12 +5,10 @@ import { |
| 5 | type ProjectEntry, | 5 | type ProjectEntry, |
| 6 | type ProjectScript, | 6 | type ProjectScript, |
| 7 | ModelSlug, | 7 | ModelSlug, |
| 8 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | ||
| 9 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 8 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 10 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | 9 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, |
| 11 | type ResolvedKeybindingsConfig, | 10 | type ResolvedKeybindingsConfig, |
| 12 | type ProviderApprovalDecision, | 11 | type ProviderApprovalDecision, |
| 13 | type ProviderSendTurnAttachmentInput, | ||
| 14 | } from "@t3tools/contracts"; | 12 | } from "@t3tools/contracts"; |
| 15 | import { | 13 | import { |
| 16 | type ClipboardEvent, | 14 | type ClipboardEvent, |
| ... | @@ -36,8 +34,6 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer | ... | @@ -36,8 +34,6 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer |
| 36 | 34 | ||
| 37 | import { isElectron } from "../env"; | 35 | import { isElectron } from "../env"; |
| 38 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 36 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 39 | import { useAppSettings } from "../appSettings"; | ||
| 40 | import { buildBootstrapInput } from "../historyBootstrap"; | ||
| 41 | import { | 37 | import { |
| 42 | type ComposerTriggerKind, | 38 | type ComposerTriggerKind, |
| 43 | detectComposerTrigger, | 39 | detectComposerTrigger, |
| ... | @@ -116,6 +112,13 @@ import { | ... | @@ -116,6 +112,13 @@ import { |
| 116 | setupProjectScript, | 112 | setupProjectScript, |
| 117 | } from "~/projectScripts"; | 113 | } from "~/projectScripts"; |
| 118 | import { Toggle } from "./ui/toggle"; | 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 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 123 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 121 | if (!duration) return formatTimestamp(createdAt); | 124 | if (!duration) return formatTimestamp(createdAt); |
| ... | @@ -165,14 +168,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | ... | @@ -165,14 +168,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 165 | return "text-muted-foreground/40"; | 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 | interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { | 171 | interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> { |
| 177 | previewUrl: string; | 172 | previewUrl: string; |
| 178 | file: File; | 173 | file: File; |
| ... | @@ -351,7 +346,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -351,7 +346,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 351 | const rawSearch = useSearch({ strict: false }); | 346 | const rawSearch = useSearch({ strict: false }); |
| 352 | const api = useNativeApi(); | 347 | const api = useNativeApi(); |
| 353 | const { resolvedTheme } = useTheme(); | 348 | const { resolvedTheme } = useTheme(); |
| 354 | const { settings: appSettings } = useAppSettings(); | ||
| 355 | const queryClient = useQueryClient(); | 349 | const queryClient = useQueryClient(); |
| 356 | const createWorktreeMutation = useMutation( | 350 | const createWorktreeMutation = useMutation( |
| 357 | gitCreateWorktreeMutationOptions({ api, queryClient }), | 351 | gitCreateWorktreeMutationOptions({ api, queryClient }), |
| ... | @@ -383,8 +377,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -383,8 +377,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 383 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); | 377 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); |
| 384 | const dragDepthRef = useRef(0); | 378 | const dragDepthRef = useRef(0); |
| 385 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); | 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 | const activeThread = state.threads.find((t) => t.id === threadId); | 381 | const activeThread = state.threads.find((t) => t.id === threadId); |
| 390 | const diffSearch = useMemo( | 382 | const diffSearch = useMemo( |
| ... | @@ -393,9 +385,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -393,9 +385,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 393 | ); | 385 | ); |
| 394 | const diffOpen = diffSearch.diff === "1"; | 386 | const diffOpen = diffSearch.diff === "1"; |
| 395 | const activeThreadId = activeThread?.id ?? null; | 387 | const activeThreadId = activeThread?.id ?? null; |
| 396 | const activeSessionId = activeThread?.session?.sessionId ?? null; | ||
| 397 | const activeThreadRuntimeId = | ||
| 398 | activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; | ||
| 399 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); | 388 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 400 | const selectedModel = resolveModelSlug( | 389 | const selectedModel = resolveModelSlug( |
| 401 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, | 390 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| ... | @@ -521,11 +510,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -521,11 +510,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 521 | completionSummary, | 510 | completionSummary, |
| 522 | timelineEntries, | 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 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; | 513 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; |
| 530 | const composerTrigger = useMemo( | 514 | const composerTrigger = useMemo( |
| 531 | () => detectComposerTrigger(prompt, composerCursor), | 515 | () => detectComposerTrigger(prompt, composerCursor), |
| ... | @@ -820,17 +804,16 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -820,17 +804,16 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 820 | scripts: input.nextScripts, | 804 | scripts: input.nextScripts, |
| 821 | }); | 805 | }); |
| 822 | 806 | ||
| 823 | if (!isElectron || !api) return; | 807 | if (!api) return; |
| 824 | |||
| 825 | let scriptsPersisted = false; | ||
| 826 | try { | 808 | try { |
| 827 | const updated = await api.projects.updateScripts({ | 809 | await api.orchestration.dispatchCommand({ |
| 828 | id: input.projectId, | 810 | type: "project.meta.update", |
| 811 | commandId: newCommandId(), | ||
| 812 | projectId: asProjectId(input.projectId), | ||
| 829 | scripts: input.nextScripts, | 813 | scripts: input.nextScripts, |
| 830 | }); | 814 | }); |
| 831 | scriptsPersisted = true; | ||
| 832 | 815 | ||
| 833 | if (input.keybinding) { | 816 | if (isElectron && input.keybinding) { |
| 834 | const keybindingUpdate = await api.server.upsertKeybinding({ | 817 | const keybindingUpdate = await api.server.upsertKeybinding({ |
| 835 | key: input.keybinding, | 818 | key: input.keybinding, |
| 836 | command: input.keybindingCommand, | 819 | command: input.keybindingCommand, |
| ... | @@ -843,24 +826,10 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -843,24 +826,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 843 | : { | 826 | : { |
| 844 | cwd: input.projectCwd, | 827 | cwd: input.projectCwd, |
| 845 | keybindings: keybindingUpdate.keybindings, | 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 | } catch (error) { | 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 | dispatch({ | 833 | dispatch({ |
| 865 | type: "SET_PROJECT_SCRIPTS", | 834 | type: "SET_PROJECT_SCRIPTS", |
| 866 | projectId: input.projectId, | 835 | projectId: input.projectId, |
| ... | @@ -946,17 +915,25 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -946,17 +915,25 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 946 | scheduleComposerFocus(); | 915 | scheduleComposerFocus(); |
| 947 | if (!api) return; | 916 | if (!api) return; |
| 948 | 917 | ||
| 949 | const sessionIds = state.threads | 918 | const runningThreadIds = state.threads |
| 950 | .map((t) => t.session) | 919 | .filter((thread) => thread.session !== null && thread.session.status !== "closed") |
| 951 | .filter((s): s is NonNullable<typeof s> => s !== null && s.status !== "closed") | 920 | .map((thread) => thread.id); |
| 952 | .map((s) => s.sessionId); | ||
| 953 | 921 | ||
| 954 | if (sessionIds.length === 0) return; | 922 | if (runningThreadIds.length === 0) return; |
| 955 | 923 | ||
| 956 | setIsSwitchingRuntimeMode(true); | 924 | setIsSwitchingRuntimeMode(true); |
| 957 | try { | 925 | try { |
| 958 | await Promise.all( | 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 | } finally { | 938 | } finally { |
| 962 | setIsSwitchingRuntimeMode(false); | 939 | setIsSwitchingRuntimeMode(false); |
| ... | @@ -1321,167 +1298,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1321,167 +1298,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1321 | focusComposer(); | 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 | const onRevertToTurnCount = useCallback( | 1301 | const onRevertToTurnCount = useCallback( |
| 1486 | async (turnCount: number) => { | 1302 | async (turnCount: number) => { |
| 1487 | if (!api || !activeThread || isRevertingCheckpoint) { | 1303 | if (!api || !activeThread || isRevertingCheckpoint) { |
| ... | @@ -1505,13 +1321,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1505,13 +1321,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1505 | setIsRevertingCheckpoint(true); | 1321 | setIsRevertingCheckpoint(true); |
| 1506 | setThreadError(activeThread.id, null); | 1322 | setThreadError(activeThread.id, null); |
| 1507 | try { | 1323 | try { |
| 1508 | const sessionInfo = await ensureSession(); | 1324 | await api.orchestration.dispatchCommand({ |
| 1509 | if (!sessionInfo) { | 1325 | type: "thread.checkpoint.revert", |
| 1510 | return; | 1326 | commandId: newCommandId(), |
| 1511 | } | 1327 | threadId: asThreadId(activeThread.id), |
| 1512 | await api.providers.revertToCheckpoint({ | ||
| 1513 | sessionId: sessionInfo.sessionId, | ||
| 1514 | turnCount, | 1328 | turnCount, |
| 1329 | createdAt: new Date().toISOString(), | ||
| 1515 | }); | 1330 | }); |
| 1516 | } catch (err) { | 1331 | } catch (err) { |
| 1517 | setThreadError( | 1332 | setThreadError( |
| ... | @@ -1525,8 +1340,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1525,8 +1340,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1525 | [ | 1340 | [ |
| 1526 | activeThread, | 1341 | activeThread, |
| 1527 | api, | 1342 | api, |
| 1528 | dispatch, | ||
| 1529 | ensureSession, | ||
| 1530 | isConnecting, | 1343 | isConnecting, |
| 1531 | isRevertingCheckpoint, | 1344 | isRevertingCheckpoint, |
| 1532 | isSending, | 1345 | isSending, |
| ... | @@ -1544,7 +1357,6 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1544,7 +1357,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1544 | const composerImagesSnapshot = [...composerImages]; | 1357 | const composerImagesSnapshot = [...composerImages]; |
| 1545 | 1358 | ||
| 1546 | // On first message: lock in branch + create worktree if needed. | 1359 | // On first message: lock in branch + create worktree if needed. |
| 1547 | let sessionCwd: string | undefined; | ||
| 1548 | if ( | 1360 | if ( |
| 1549 | activeThread.messages.length === 0 && | 1361 | activeThread.messages.length === 0 && |
| 1550 | activeThread.branch && | 1362 | activeThread.branch && |
| ... | @@ -1558,17 +1370,13 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1558,17 +1370,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1558 | branch: activeThread.branch, | 1370 | branch: activeThread.branch, |
| 1559 | newBranch, | 1371 | newBranch, |
| 1560 | }); | 1372 | }); |
| 1561 | sessionCwd = result.worktree.path; | 1373 | await api.orchestration.dispatchCommand({ |
| 1562 | if (api) { | 1374 | type: "thread.meta.update", |
| 1563 | await api.orchestration.dispatchCommand({ | 1375 | commandId: newCommandId(), |
| 1564 | type: "thread.meta.update", | 1376 | threadId: asThreadId(activeThread.id), |
| 1565 | commandId: crypto.randomUUID(), | 1377 | branch: result.worktree.branch, |
| 1566 | threadId: activeThread.id, | 1378 | worktreePath: result.worktree.path, |
| 1567 | branch: result.worktree.branch, | 1379 | }); |
| 1568 | worktreePath: result.worktree.path, | ||
| 1569 | createdAt: new Date().toISOString(), | ||
| 1570 | }); | ||
| 1571 | } | ||
| 1572 | const setupScript = setupProjectScript(activeProject.scripts); | 1380 | const setupScript = setupProjectScript(activeProject.scripts); |
| 1573 | if (setupScript) { | 1381 | if (setupScript) { |
| 1574 | void runProjectScript(setupScript, { | 1382 | void runProjectScript(setupScript, { |
| ... | @@ -1598,51 +1406,31 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1598,51 +1406,31 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1598 | if (api) { | 1406 | if (api) { |
| 1599 | await api.orchestration.dispatchCommand({ | 1407 | await api.orchestration.dispatchCommand({ |
| 1600 | type: "thread.meta.update", | 1408 | type: "thread.meta.update", |
| 1601 | commandId: crypto.randomUUID(), | 1409 | commandId: newCommandId(), |
| 1602 | threadId: activeThread.id, | 1410 | threadId: asThreadId(activeThread.id), |
| 1603 | title, | 1411 | title, |
| 1604 | createdAt: new Date().toISOString(), | ||
| 1605 | }); | 1412 | }); |
| 1606 | } | 1413 | } |
| 1607 | } | 1414 | } |
| 1608 | 1415 | ||
| 1609 | setThreadError(activeThread.id, null); | 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 | promptRef.current = ""; | 1417 | promptRef.current = ""; |
| 1633 | setPrompt(""); | 1418 | setPrompt(""); |
| 1634 | setComposerImages([]); | 1419 | setComposerImages([]); |
| 1635 | setComposerCursor(0); | 1420 | setComposerCursor(0); |
| 1636 | setComposerHighlightedItemId(null); | 1421 | setComposerHighlightedItemId(null); |
| 1637 | 1422 | ||
| 1638 | const sessionInfo = await ensureSession(sessionCwd); | ||
| 1639 | if (!sessionInfo) return; | ||
| 1640 | |||
| 1641 | setIsSending(true); | 1423 | setIsSending(true); |
| 1642 | try { | 1424 | try { |
| 1643 | const turnAttachments = await Promise.all( | 1425 | const turnAttachments = await Promise.all( |
| 1644 | composerImagesSnapshot.map( | 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 | type: "image", | 1434 | type: "image", |
| 1647 | name: image.name, | 1435 | name: image.name, |
| 1648 | mimeType: image.mimeType, | 1436 | mimeType: image.mimeType, |
| ... | @@ -1651,23 +1439,19 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1651,23 +1439,19 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1651 | }), | 1439 | }), |
| 1652 | ), | 1440 | ), |
| 1653 | ); | 1441 | ); |
| 1654 | const shouldBootstrap = | 1442 | await api.orchestration.dispatchCommand({ |
| 1655 | previousMessages.length > 0 && | 1443 | type: "thread.turn.start", |
| 1656 | (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new"); | 1444 | commandId: newCommandId(), |
| 1657 | const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT; | 1445 | threadId: asThreadId(activeThread.id), |
| 1658 | const input = shouldBootstrap | 1446 | message: { |
| 1659 | ? buildBootstrapInput( | 1447 | messageId: newMessageId(), |
| 1660 | previousMessages, | 1448 | role: "user", |
| 1661 | latestPromptForBootstrap, | 1449 | text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT, |
| 1662 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | 1450 | attachments: turnAttachments, |
| 1663 | ).text | 1451 | }, |
| 1664 | : trimmed || undefined; | ||
| 1665 | await api.providers.sendTurn({ | ||
| 1666 | sessionId: sessionInfo.sessionId, | ||
| 1667 | ...(input ? { input } : {}), | ||
| 1668 | ...(turnAttachments.length > 0 ? { attachments: turnAttachments } : {}), | ||
| 1669 | model: selectedModel || undefined, | 1452 | model: selectedModel || undefined, |
| 1670 | effort: selectedEffort || undefined, | 1453 | effort: selectedEffort || undefined, |
| 1454 | createdAt: new Date().toISOString(), | ||
| 1671 | }); | 1455 | }); |
| 1672 | } catch (err) { | 1456 | } catch (err) { |
| 1673 | setThreadError( | 1457 | setThreadError( |
| ... | @@ -1680,25 +1464,30 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1680,25 +1464,30 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1680 | }; | 1464 | }; |
| 1681 | 1465 | ||
| 1682 | const onInterrupt = async () => { | 1466 | const onInterrupt = async () => { |
| 1683 | if (!api || !activeThread?.session) return; | 1467 | if (!api || !activeThread) return; |
| 1684 | await api.providers.interruptTurn({ | 1468 | await api.orchestration.dispatchCommand({ |
| 1685 | sessionId: activeThread.session.sessionId, | 1469 | type: "thread.turn.interrupt", |
| 1686 | turnId: activeThread.session.activeTurnId, | 1470 | commandId: newCommandId(), |
| 1471 | threadId: asThreadId(activeThread.id), | ||
| 1472 | createdAt: new Date().toISOString(), | ||
| 1687 | }); | 1473 | }); |
| 1688 | }; | 1474 | }; |
| 1689 | 1475 | ||
| 1690 | const onRespondToApproval = useCallback( | 1476 | const onRespondToApproval = useCallback( |
| 1691 | async (requestId: string, decision: ProviderApprovalDecision) => { | 1477 | async (requestId: string, decision: ProviderApprovalDecision) => { |
| 1692 | if (!api || !activeSessionId || !activeThreadId) return; | 1478 | if (!api || !activeThreadId) return; |
| 1693 | 1479 | ||
| 1694 | setRespondingRequestIds((existing) => | 1480 | setRespondingRequestIds((existing) => |
| 1695 | existing.includes(requestId) ? existing : [...existing, requestId], | 1481 | existing.includes(requestId) ? existing : [...existing, requestId], |
| 1696 | ); | 1482 | ); |
| 1697 | try { | 1483 | try { |
| 1698 | await api.providers.respondToRequest({ | 1484 | await api.orchestration.dispatchCommand({ |
| 1699 | sessionId: activeSessionId, | 1485 | type: "thread.approval.respond", |
| 1700 | requestId, | 1486 | commandId: newCommandId(), |
| 1487 | threadId: asThreadId(activeThreadId), | ||
| 1488 | requestId: asApprovalRequestId(requestId), | ||
| 1701 | decision, | 1489 | decision, |
| 1490 | createdAt: new Date().toISOString(), | ||
| 1702 | }); | 1491 | }); |
| 1703 | } catch (err) { | 1492 | } catch (err) { |
| 1704 | dispatch({ | 1493 | dispatch({ |
| ... | @@ -1710,7 +1499,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1710,7 +1499,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1710 | setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); | 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 | const onModelSelect = useCallback( | 1505 | const onModelSelect = useCallback( |
| ... | @@ -1719,10 +1508,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1719,10 +1508,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1719 | if (api) { | 1508 | if (api) { |
| 1720 | void api.orchestration.dispatchCommand({ | 1509 | void api.orchestration.dispatchCommand({ |
| 1721 | type: "thread.meta.update", | 1510 | type: "thread.meta.update", |
| 1722 | commandId: crypto.randomUUID(), | 1511 | commandId: newCommandId(), |
| 1723 | threadId: activeThread.id, | 1512 | threadId: asThreadId(activeThread.id), |
| 1724 | model: resolveModelSlug(model), | 1513 | model: resolveModelSlug(model), |
| 1725 | createdAt: new Date().toISOString(), | ||
| 1726 | }); | 1514 | }); |
| 1727 | } | 1515 | } |
| 1728 | scheduleComposerFocus(); | 1516 | scheduleComposerFocus(); |
apps/web/src/components/DiffPanel.tsx+1-5| ... | @@ -99,9 +99,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -99,9 +99,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 99 | ); | 99 | ); |
| 100 | const activeThreadId = routeThreadId; | 100 | const activeThreadId = routeThreadId; |
| 101 | const activeThread = state.threads.find((thread) => thread.id === activeThreadId); | 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 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | 102 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 106 | useTurnDiffSummaries(activeThread); | 103 | useTurnDiffSummaries(activeThread); |
| 107 | const orderedTurnDiffSummaries = useMemo( | 104 | const orderedTurnDiffSummaries = useMemo( |
| ... | @@ -173,8 +170,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -173,8 +170,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 173 | }, [orderedTurnDiffSummaries, selectedTurn]); | 170 | }, [orderedTurnDiffSummaries, selectedTurn]); |
| 174 | const activeCheckpointDiffQuery = useQuery( | 171 | const activeCheckpointDiffQuery = useQuery( |
| 175 | checkpointDiffQueryOptions(api, { | 172 | checkpointDiffQueryOptions(api, { |
| 176 | sessionId: activeSessionId, | 173 | threadId: activeThreadId, |
| 177 | threadRuntimeId: activeThreadRuntimeId, | ||
| 178 | fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, | 174 | fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, |
| 179 | toTurnCount: activeCheckpointRange?.toTurnCount ?? null, | 175 | toTurnCount: activeCheckpointRange?.toTurnCount ?? null, |
| 180 | cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, | 176 | cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, |
apps/web/src/components/Sidebar.tsx+35-31| ... | @@ -7,6 +7,7 @@ import { useAppSettings } from "../appSettings"; | ... | @@ -7,6 +7,7 @@ import { useAppSettings } from "../appSettings"; |
| 7 | import { isElectron } from "../env"; | 7 | import { isElectron } from "../env"; |
| 8 | import { APP_STAGE_LABEL } from "../branding"; | 8 | import { APP_STAGE_LABEL } from "../branding"; |
| 9 | import { DEFAULT_MODEL } from "../model-logic"; | 9 | import { DEFAULT_MODEL } from "../model-logic"; |
| 10 | import { asProjectId, asThreadId, newCommandId, newProjectId, newThreadId } from "../lib/orchestrationIds"; | ||
| 10 | import { useStore } from "../store"; | 11 | import { useStore } from "../store"; |
| 11 | import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; | 12 | import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; |
| 12 | import { type Thread } from "../types"; | 13 | import { type Thread } from "../types"; |
| ... | @@ -133,14 +134,14 @@ export default function Sidebar() { | ... | @@ -133,14 +134,14 @@ export default function Sidebar() { |
| 133 | }, | 134 | }, |
| 134 | ) => { | 135 | ) => { |
| 135 | if (!api) return; | 136 | if (!api) return; |
| 136 | const threadId = crypto.randomUUID(); | 137 | const threadId = newThreadId(); |
| 137 | const createdAt = new Date().toISOString(); | 138 | const createdAt = new Date().toISOString(); |
| 138 | const model = state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL; | 139 | const model = state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL; |
| 139 | void api.orchestration.dispatchCommand({ | 140 | void api.orchestration.dispatchCommand({ |
| 140 | type: "thread.create", | 141 | type: "thread.create", |
| 141 | commandId: crypto.randomUUID(), | 142 | commandId: newCommandId(), |
| 142 | threadId, | 143 | threadId, |
| 143 | projectId, | 144 | projectId: asProjectId(projectId), |
| 144 | title: "New thread", | 145 | title: "New thread", |
| 145 | model, | 146 | model, |
| 146 | branch: options?.branch ?? null, | 147 | branch: options?.branch ?? null, |
| ... | @@ -152,7 +153,7 @@ export default function Sidebar() { | ... | @@ -152,7 +153,7 @@ export default function Sidebar() { |
| 152 | params: { threadId }, | 153 | params: { threadId }, |
| 153 | }); | 154 | }); |
| 154 | }, | 155 | }, |
| 155 | [api, dispatch, navigate, state.projects], | 156 | [api, navigate, state.projects], |
| 156 | ); | 157 | ); |
| 157 | 158 | ||
| 158 | const focusMostRecentThreadForProject = useCallback( | 159 | const focusMostRecentThreadForProject = useCallback( |
| ... | @@ -188,16 +189,19 @@ export default function Sidebar() { | ... | @@ -188,16 +189,19 @@ export default function Sidebar() { |
| 188 | return; | 189 | return; |
| 189 | } | 190 | } |
| 190 | 191 | ||
| 191 | const result = await api.projects.add({ cwd }); | 192 | const projectId = newProjectId(); |
| 192 | const projects = await api.projects.list(); | 193 | const createdAt = new Date().toISOString(); |
| 193 | dispatch({ type: "SYNC_PROJECTS", projects }); | 194 | const title = cwd.split(/[/\\]/).filter(Boolean).at(-1) ?? cwd; |
| 194 | 195 | await api.orchestration.dispatchCommand({ | |
| 195 | const hasThread = state.threads.some((thread) => thread.projectId === result.project.id); | 196 | type: "project.create", |
| 196 | if (hasThread) { | 197 | commandId: newCommandId(), |
| 197 | focusMostRecentThreadForProject(result.project.id); | 198 | projectId, |
| 198 | return; | 199 | title, |
| 199 | } | 200 | workspaceRoot: cwd, |
| 200 | handleNewThread(result.project.id); | 201 | defaultModel: DEFAULT_MODEL, |
| 202 | createdAt, | ||
| 203 | }); | ||
| 204 | handleNewThread(projectId); | ||
| 201 | } finally { | 205 | } finally { |
| 202 | setIsAddingProject(false); | 206 | setIsAddingProject(false); |
| 203 | setNewCwd(""); | 207 | setNewCwd(""); |
| ... | @@ -206,7 +210,6 @@ export default function Sidebar() { | ... | @@ -206,7 +210,6 @@ export default function Sidebar() { |
| 206 | }, | 210 | }, |
| 207 | [ | 211 | [ |
| 208 | api, | 212 | api, |
| 209 | dispatch, | ||
| 210 | focusMostRecentThreadForProject, | 213 | focusMostRecentThreadForProject, |
| 211 | handleNewThread, | 214 | handleNewThread, |
| 212 | isAddingProject, | 215 | isAddingProject, |
| ... | @@ -267,15 +270,15 @@ export default function Sidebar() { | ... | @@ -267,15 +270,15 @@ export default function Sidebar() { |
| 267 | ].join("\n"), | 270 | ].join("\n"), |
| 268 | )); | 271 | )); |
| 269 | 272 | ||
| 270 | // Stop active session if running | 273 | if (thread.session && thread.session.status !== "closed") { |
| 271 | if (thread.session?.sessionId) { | 274 | await api.orchestration |
| 272 | try { | 275 | .dispatchCommand({ |
| 273 | await api.providers.stopSession({ | 276 | type: "thread.session.stop", |
| 274 | sessionId: thread.session.sessionId, | 277 | commandId: newCommandId(), |
| 275 | }); | 278 | threadId: asThreadId(threadId), |
| 276 | } catch { | 279 | createdAt: new Date().toISOString(), |
| 277 | // Session may already be stopped | 280 | }) |
| 278 | } | 281 | .catch(() => undefined); |
| 279 | } | 282 | } |
| 280 | 283 | ||
| 281 | try { | 284 | try { |
| ... | @@ -291,9 +294,8 @@ export default function Sidebar() { | ... | @@ -291,9 +294,8 @@ export default function Sidebar() { |
| 291 | const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null; | 294 | const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null; |
| 292 | await api.orchestration.dispatchCommand({ | 295 | await api.orchestration.dispatchCommand({ |
| 293 | type: "thread.delete", | 296 | type: "thread.delete", |
| 294 | commandId: crypto.randomUUID(), | 297 | commandId: newCommandId(), |
| 295 | threadId, | 298 | threadId: asThreadId(threadId), |
| 296 | createdAt: new Date().toISOString(), | ||
| 297 | }); | 299 | }); |
| 298 | if (shouldNavigateToFallback) { | 300 | if (shouldNavigateToFallback) { |
| 299 | if (fallbackThreadId) { | 301 | if (fallbackThreadId) { |
| ... | @@ -369,9 +371,11 @@ export default function Sidebar() { | ... | @@ -369,9 +371,11 @@ export default function Sidebar() { |
| 369 | if (!confirmed) return; | 371 | if (!confirmed) return; |
| 370 | 372 | ||
| 371 | try { | 373 | try { |
| 372 | await api.projects.remove({ id: projectId }); | 374 | await api.orchestration.dispatchCommand({ |
| 373 | const projects = await api.projects.list(); | 375 | type: "project.delete", |
| 374 | dispatch({ type: "SYNC_PROJECTS", projects }); | 376 | commandId: newCommandId(), |
| 377 | projectId: asProjectId(projectId), | ||
| 378 | }); | ||
| 375 | } catch (error) { | 379 | } catch (error) { |
| 376 | const message = | 380 | const message = |
| 377 | error instanceof Error ? error.message : "Unknown error deleting project."; | 381 | error instanceof Error ? error.message : "Unknown error deleting project."; |
| ... | @@ -383,7 +387,7 @@ export default function Sidebar() { | ... | @@ -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 | useEffect(() => { | 393 | useEffect(() => { |
apps/web/src/lib/gitReactQuery.ts+4-2| ... | @@ -152,15 +152,17 @@ export function gitCreateWorktreeMutationOptions(input: { | ... | @@ -152,15 +152,17 @@ export function gitCreateWorktreeMutationOptions(input: { |
| 152 | cwd, | 152 | cwd, |
| 153 | branch, | 153 | branch, |
| 154 | newBranch, | 154 | newBranch, |
| 155 | path, | ||
| 155 | }: { | 156 | }: { |
| 156 | cwd: string; | 157 | cwd: string; |
| 157 | branch: string; | 158 | branch: string; |
| 158 | newBranch: string; | 159 | newBranch: string; |
| 160 | path?: string | null; | ||
| 159 | }) => { | 161 | }) => { |
| 160 | if (!input.api) { | 162 | if (!input.api) { |
| 161 | throw new Error("Git worktree creation is unavailable."); | 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 | onSettled: async () => { | 167 | onSettled: async () => { |
| 166 | await invalidateGitQueries(input.queryClient); | 168 | await invalidateGitQueries(input.queryClient); |
| ... | @@ -177,7 +179,7 @@ export function gitRemoveWorktreeMutationOptions(input: { | ... | @@ -177,7 +179,7 @@ export function gitRemoveWorktreeMutationOptions(input: { |
| 177 | if (!input.api) { | 179 | if (!input.api) { |
| 178 | throw new Error("Git worktree removal is unavailable."); | 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 | onSettled: async () => { | 184 | onSettled: async () => { |
| 183 | await invalidateGitQueries(input.queryClient); | 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,8 +6,7 @@ import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQu |
| 6 | describe("providerQueryKeys.checkpointDiff", () => { | 6 | describe("providerQueryKeys.checkpointDiff", () => { |
| 7 | it("includes cacheScope so reused turn counts do not collide", () => { | 7 | it("includes cacheScope so reused turn counts do not collide", () => { |
| 8 | const baseInput = { | 8 | const baseInput = { |
| 9 | sessionId: "session-id", | 9 | threadId: "thread-id", |
| 10 | threadRuntimeId: "thread-id", | ||
| 11 | fromTurnCount: 1, | 10 | fromTurnCount: 1, |
| 12 | toTurnCount: 2, | 11 | toTurnCount: 2, |
| 13 | } as const; | 12 | } as const; |
| ... | @@ -28,16 +27,15 @@ describe("providerQueryKeys.checkpointDiff", () => { | ... | @@ -28,16 +27,15 @@ describe("providerQueryKeys.checkpointDiff", () => { |
| 28 | 27 | ||
| 29 | describe("checkpointDiffQueryOptions", () => { | 28 | describe("checkpointDiffQueryOptions", () => { |
| 30 | it("forwards checkpoint range to the provider API", async () => { | 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 | const api = { | 31 | const api = { |
| 33 | providers: { | 32 | orchestration: { |
| 34 | getCheckpointDiff, | 33 | getTurnDiff, |
| 35 | }, | 34 | }, |
| 36 | } as unknown as NativeApi; | 35 | } as unknown as NativeApi; |
| 37 | 36 | ||
| 38 | const options = checkpointDiffQueryOptions(api, { | 37 | const options = checkpointDiffQueryOptions(api, { |
| 39 | sessionId: "session-id", | 38 | threadId: "thread-id", |
| 40 | threadRuntimeId: "thread-id", | ||
| 41 | fromTurnCount: 3, | 39 | fromTurnCount: 3, |
| 42 | toTurnCount: 4, | 40 | toTurnCount: 4, |
| 43 | cacheScope: "turn:abc", | 41 | cacheScope: "turn:abc", |
| ... | @@ -46,8 +44,8 @@ describe("checkpointDiffQueryOptions", () => { | ... | @@ -46,8 +44,8 @@ describe("checkpointDiffQueryOptions", () => { |
| 46 | const queryClient = new QueryClient(); | 44 | const queryClient = new QueryClient(); |
| 47 | await queryClient.fetchQuery(options); | 45 | await queryClient.fetchQuery(options); |
| 48 | 46 | ||
| 49 | expect(getCheckpointDiff).toHaveBeenCalledWith({ | 47 | expect(getTurnDiff).toHaveBeenCalledWith({ |
| 50 | sessionId: "session-id", | 48 | threadId: "thread-id", |
| 51 | fromTurnCount: 3, | 49 | fromTurnCount: 3, |
| 52 | toTurnCount: 4, | 50 | toTurnCount: 4, |
| 53 | }); | 51 | }); |
| ... | @@ -55,8 +53,7 @@ describe("checkpointDiffQueryOptions", () => { | ... | @@ -55,8 +53,7 @@ describe("checkpointDiffQueryOptions", () => { |
| 55 | 53 | ||
| 56 | it("retries checkpoint-not-ready errors longer than generic failures", () => { | 54 | it("retries checkpoint-not-ready errors longer than generic failures", () => { |
| 57 | const options = checkpointDiffQueryOptions(undefined, { | 55 | const options = checkpointDiffQueryOptions(undefined, { |
| 58 | sessionId: "session-id", | 56 | threadId: "thread-id", |
| 59 | threadRuntimeId: "thread-id", | ||
| 60 | fromTurnCount: 1, | 57 | fromTurnCount: 1, |
| 61 | toTurnCount: 2, | 58 | toTurnCount: 2, |
| 62 | cacheScope: "turn:abc", | 59 | cacheScope: "turn:abc", |
| ... | @@ -86,8 +83,7 @@ describe("checkpointDiffQueryOptions", () => { | ... | @@ -86,8 +83,7 @@ describe("checkpointDiffQueryOptions", () => { |
| 86 | 83 | ||
| 87 | it("backs off longer for checkpoint-not-ready errors", () => { | 84 | it("backs off longer for checkpoint-not-ready errors", () => { |
| 88 | const options = checkpointDiffQueryOptions(undefined, { | 85 | const options = checkpointDiffQueryOptions(undefined, { |
| 89 | sessionId: "session-id", | 86 | threadId: "thread-id", |
| 90 | threadRuntimeId: "thread-id", | ||
| 91 | fromTurnCount: 1, | 87 | fromTurnCount: 1, |
| 92 | toTurnCount: 2, | 88 | toTurnCount: 2, |
| 93 | cacheScope: "turn:abc", | 89 | cacheScope: "turn:abc", |
apps/web/src/lib/providerReactQuery.ts+7-8| ... | @@ -1,9 +1,9 @@ | ... | @@ -1,9 +1,9 @@ |
| 1 | import type { NativeApi } from "@t3tools/contracts"; | 1 | import type { NativeApi } from "@t3tools/contracts"; |
| 2 | import { queryOptions } from "@tanstack/react-query"; | 2 | import { queryOptions } from "@tanstack/react-query"; |
| 3 | import { asThreadId } from "./orchestrationIds"; | ||
| 3 | 4 | ||
| 4 | export interface CheckpointDiffQueryInput { | 5 | export interface CheckpointDiffQueryInput { |
| 5 | sessionId: string | null; | 6 | threadId: string | null; |
| 6 | threadRuntimeId: string | null; | ||
| 7 | fromTurnCount: number | null; | 7 | fromTurnCount: number | null; |
| 8 | toTurnCount: number | null; | 8 | toTurnCount: number | null; |
| 9 | cacheScope?: string | null; | 9 | cacheScope?: string | null; |
| ... | @@ -15,8 +15,7 @@ export const providerQueryKeys = { | ... | @@ -15,8 +15,7 @@ export const providerQueryKeys = { |
| 15 | [ | 15 | [ |
| 16 | "providers", | 16 | "providers", |
| 17 | "checkpointDiff", | 17 | "checkpointDiff", |
| 18 | input.sessionId, | 18 | input.threadId, |
| 19 | input.threadRuntimeId, | ||
| 20 | input.fromTurnCount, | 19 | input.fromTurnCount, |
| 21 | input.toTurnCount, | 20 | input.toTurnCount, |
| 22 | input.cacheScope ?? null, | 21 | input.cacheScope ?? null, |
| ... | @@ -58,20 +57,20 @@ export function checkpointDiffQueryOptions( | ... | @@ -58,20 +57,20 @@ export function checkpointDiffQueryOptions( |
| 58 | return queryOptions({ | 57 | return queryOptions({ |
| 59 | queryKey: providerQueryKeys.checkpointDiff(input), | 58 | queryKey: providerQueryKeys.checkpointDiff(input), |
| 60 | queryFn: async () => { | 59 | queryFn: async () => { |
| 61 | if (!api || !input.sessionId || !hasValidRange) { | 60 | if (!api || !input.threadId || !hasValidRange) { |
| 62 | throw new Error("Checkpoint diff is unavailable."); | 61 | throw new Error("Checkpoint diff is unavailable."); |
| 63 | } | 62 | } |
| 64 | const { fromTurnCount, toTurnCount } = input; | 63 | const { fromTurnCount, toTurnCount } = input; |
| 65 | if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") { | 64 | if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") { |
| 66 | throw new Error("Checkpoint diff range is invalid."); | 65 | throw new Error("Checkpoint diff range is invalid."); |
| 67 | } | 66 | } |
| 68 | return api.providers.getCheckpointDiff({ | 67 | return api.orchestration.getTurnDiff({ |
| 69 | sessionId: input.sessionId, | 68 | threadId: asThreadId(input.threadId), |
| 70 | fromTurnCount, | 69 | fromTurnCount, |
| 71 | toTurnCount, | 70 | toTurnCount, |
| 72 | }); | 71 | }); |
| 73 | }, | 72 | }, |
| 74 | enabled: !!api && !!input.sessionId && hasValidRange, | 73 | enabled: !!api && !!input.threadId && hasValidRange, |
| 75 | staleTime: Infinity, | 74 | staleTime: Infinity, |
| 76 | retry: (failureCount, error) => { | 75 | retry: (failureCount, error) => { |
| 77 | if (isCheckpointTemporarilyUnavailable(error)) { | 76 | if (isCheckpointTemporarilyUnavailable(error)) { |
apps/web/src/routes/__root.tsx+23-25| ... | @@ -13,6 +13,7 @@ import { isElectron } from "../env"; | ... | @@ -13,6 +13,7 @@ import { isElectron } from "../env"; |
| 13 | import { useNativeApi } from "../hooks/useNativeApi"; | 13 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 14 | import { DEFAULT_MODEL } from "../model-logic"; | 14 | import { DEFAULT_MODEL } from "../model-logic"; |
| 15 | import { useStore } from "../store"; | 15 | import { useStore } from "../store"; |
| 16 | import { newCommandId, newProjectId, newThreadId } from "../lib/orchestrationIds"; | ||
| 16 | import { onServerWelcome } from "../wsNativeApi"; | 17 | import { onServerWelcome } from "../wsNativeApi"; |
| 17 | 18 | ||
| 18 | export const Route = createRootRouteWithContext<{ | 19 | export const Route = createRootRouteWithContext<{ |
| ... | @@ -134,7 +135,7 @@ function EventRouter() { | ... | @@ -134,7 +135,7 @@ function EventRouter() { |
| 134 | const flushSnapshotSync = async (): Promise<void> => { | 135 | const flushSnapshotSync = async (): Promise<void> => { |
| 135 | const snapshot = await api.orchestration.getSnapshot(); | 136 | const snapshot = await api.orchestration.getSnapshot(); |
| 136 | if (disposed) return; | 137 | if (disposed) return; |
| 137 | latestSequence = Math.max(latestSequence, snapshot.sequence); | 138 | latestSequence = Math.max(latestSequence, snapshot.snapshotSequence); |
| 138 | dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); | 139 | dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); |
| 139 | if (pending) { | 140 | if (pending) { |
| 140 | pending = false; | 141 | pending = false; |
| ... | @@ -158,12 +159,7 @@ function EventRouter() { | ... | @@ -158,12 +159,7 @@ function EventRouter() { |
| 158 | } | 159 | } |
| 159 | }; | 160 | }; |
| 160 | 161 | ||
| 161 | void Promise.all([api.projects.list(), syncSnapshot()]) | 162 | void syncSnapshot().catch(() => undefined); |
| 162 | .then(([projects]) => { | ||
| 163 | if (disposed) return; | ||
| 164 | dispatch({ type: "SYNC_PROJECTS", projects }); | ||
| 165 | }) | ||
| 166 | .catch(() => undefined); | ||
| 167 | 163 | ||
| 168 | const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { | 164 | const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { |
| 169 | if (event.sequence <= latestSequence) { | 165 | if (event.sequence <= latestSequence) { |
| ... | @@ -209,32 +205,34 @@ function AutoProjectBootstrap() { | ... | @@ -209,32 +205,34 @@ function AutoProjectBootstrap() { |
| 209 | 205 | ||
| 210 | bootstrappedRef.current = true; | 206 | bootstrappedRef.current = true; |
| 211 | const now = new Date().toISOString(); | 207 | const now = new Date().toISOString(); |
| 212 | void api.projects | 208 | const projectId = newProjectId(); |
| 213 | .add({ cwd: payload.cwd }) | 209 | const threadId = newThreadId(); |
| 214 | .then(async (result) => { | 210 | void api.orchestration |
| 215 | const projects = await api.projects.list(); | 211 | .dispatchCommand({ |
| 216 | dispatch({ type: "SYNC_PROJECTS", projects }); | 212 | type: "project.create", |
| 217 | 213 | commandId: newCommandId(), | |
| 218 | const hasThread = state.threads.some((thread) => thread.projectId === result.project.id); | 214 | projectId, |
| 219 | if (hasThread) { | 215 | title: payload.projectName, |
| 220 | return; | 216 | workspaceRoot: payload.cwd, |
| 221 | } | 217 | defaultModel: DEFAULT_MODEL, |
| 222 | 218 | createdAt: now, | |
| 223 | return api.orchestration.dispatchCommand({ | 219 | }) |
| 220 | .then(() => | ||
| 221 | api.orchestration.dispatchCommand({ | ||
| 224 | type: "thread.create", | 222 | type: "thread.create", |
| 225 | commandId: crypto.randomUUID(), | 223 | commandId: newCommandId(), |
| 226 | threadId: crypto.randomUUID(), | 224 | threadId, |
| 227 | projectId: result.project.id, | 225 | projectId, |
| 228 | title: "New thread", | 226 | title: "New thread", |
| 229 | model: DEFAULT_MODEL, | 227 | model: DEFAULT_MODEL, |
| 230 | branch: null, | 228 | branch: null, |
| 231 | worktreePath: null, | 229 | worktreePath: null, |
| 232 | createdAt: now, | 230 | createdAt: now, |
| 233 | }); | 231 | }), |
| 234 | }) | 232 | ) |
| 235 | .catch(() => undefined); | 233 | .catch(() => undefined); |
| 236 | }); | 234 | }); |
| 237 | }, [api, state.projects, state.threads, dispatch]); | 235 | }, [api, state.projects, dispatch]); |
| 238 | 236 | ||
| 239 | return null; | 237 | return null; |
| 240 | } | 238 | } |
apps/web/src/session-logic.test.ts+46-28| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { OrchestrationThreadActivity } from "@t3tools/contracts"; | 1 | import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| ... | @@ -7,18 +7,24 @@ import { | ... | @@ -7,18 +7,24 @@ import { |
| 7 | deriveWorkLogEntries, | 7 | deriveWorkLogEntries, |
| 8 | } from "./session-logic"; | 8 | } from "./session-logic"; |
| 9 | 9 | ||
| 10 | function makeActivity( | 10 | function makeActivity(overrides: { |
| 11 | overrides: Partial<OrchestrationThreadActivity>, | 11 | id?: string; |
| 12 | ): OrchestrationThreadActivity { | 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 | return { | 20 | return { |
| 14 | id: overrides.id ?? crypto.randomUUID(), | 21 | id: EventId.makeUnsafe(overrides.id ?? crypto.randomUUID()), |
| 15 | createdAt: overrides.createdAt ?? "2026-02-23T00:00:00.000Z", | 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 | tone: overrides.tone ?? "tool", | 25 | tone: overrides.tone ?? "tool", |
| 18 | ...(overrides.detail !== undefined ? { detail: overrides.detail } : {}), | 26 | payload, |
| 19 | ...(overrides.turnId !== undefined ? { turnId: overrides.turnId } : {}), | 27 | turnId: overrides.turnId ? TurnId.makeUnsafe(overrides.turnId) : null, |
| 20 | ...(overrides.requestId !== undefined ? { requestId: overrides.requestId } : {}), | ||
| 21 | ...(overrides.requestKind !== undefined ? { requestKind: overrides.requestKind } : {}), | ||
| 22 | }; | 28 | }; |
| 23 | } | 29 | } |
| 24 | 30 | ||
| ... | @@ -28,26 +34,30 @@ describe("derivePendingApprovals", () => { | ... | @@ -28,26 +34,30 @@ describe("derivePendingApprovals", () => { |
| 28 | makeActivity({ | 34 | makeActivity({ |
| 29 | id: "approval-open", | 35 | id: "approval-open", |
| 30 | createdAt: "2026-02-23T00:00:01.000Z", | 36 | createdAt: "2026-02-23T00:00:01.000Z", |
| 31 | label: "Command approval requested", | 37 | kind: "approval.requested", |
| 32 | detail: "bun run lint", | 38 | summary: "Command approval requested", |
| 33 | tone: "tool", | 39 | tone: "approval", |
| 34 | requestId: "req-1", | 40 | payload: { |
| 35 | requestKind: "command", | 41 | requestId: "req-1", |
| 42 | requestKind: "command", | ||
| 43 | detail: "bun run lint", | ||
| 44 | }, | ||
| 36 | }), | 45 | }), |
| 37 | makeActivity({ | 46 | makeActivity({ |
| 38 | id: "approval-close", | 47 | id: "approval-close", |
| 39 | createdAt: "2026-02-23T00:00:02.000Z", | 48 | createdAt: "2026-02-23T00:00:02.000Z", |
| 40 | label: "Approval resolved", | 49 | kind: "approval.resolved", |
| 50 | summary: "Approval resolved", | ||
| 41 | tone: "info", | 51 | tone: "info", |
| 42 | requestId: "req-2", | 52 | payload: { requestId: "req-2" }, |
| 43 | }), | 53 | }), |
| 44 | makeActivity({ | 54 | makeActivity({ |
| 45 | id: "approval-closed-request", | 55 | id: "approval-closed-request", |
| 46 | createdAt: "2026-02-23T00:00:01.500Z", | 56 | createdAt: "2026-02-23T00:00:01.500Z", |
| 47 | label: "File-change approval requested", | 57 | kind: "approval.requested", |
| 48 | tone: "tool", | 58 | summary: "File-change approval requested", |
| 49 | requestId: "req-2", | 59 | tone: "approval", |
| 50 | requestKind: "file-change", | 60 | payload: { requestId: "req-2", requestKind: "file-change" }, |
| 51 | }), | 61 | }), |
| 52 | ]; | 62 | ]; |
| 53 | 63 | ||
| ... | @@ -68,12 +78,14 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -68,12 +78,14 @@ describe("deriveWorkLogEntries", () => { |
| 68 | makeActivity({ | 78 | makeActivity({ |
| 69 | id: "tool-complete", | 79 | id: "tool-complete", |
| 70 | createdAt: "2026-02-23T00:00:03.000Z", | 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 | makeActivity({ | 84 | makeActivity({ |
| 74 | id: "tool-start", | 85 | id: "tool-start", |
| 75 | createdAt: "2026-02-23T00:00:02.000Z", | 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,9 +95,14 @@ describe("deriveWorkLogEntries", () => { |
| 83 | 95 | ||
| 84 | it("filters by turn id when provided", () => { | 96 | it("filters by turn id when provided", () => { |
| 85 | const activities: OrchestrationThreadActivity[] = [ | 97 | const activities: OrchestrationThreadActivity[] = [ |
| 86 | makeActivity({ id: "turn-1", turnId: "turn-1", label: "Tool call" }), | 98 | makeActivity({ id: "turn-1", turnId: "turn-1", summary: "Tool call", kind: "tool.started" }), |
| 87 | makeActivity({ id: "turn-2", turnId: "turn-2", label: "Tool call complete" }), | 99 | makeActivity({ |
| 88 | makeActivity({ id: "no-turn", label: "Checkpoint captured", tone: "info" }), | 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 | const entries = deriveWorkLogEntries(activities, "turn-2"); | 108 | const entries = deriveWorkLogEntries(activities, "turn-2"); |
| ... | @@ -97,14 +114,15 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -97,14 +114,15 @@ describe("deriveWorkLogEntries", () => { |
| 97 | makeActivity({ | 114 | makeActivity({ |
| 98 | id: "checkpoint", | 115 | id: "checkpoint", |
| 99 | createdAt: "2026-02-23T00:00:01.000Z", | 116 | createdAt: "2026-02-23T00:00:01.000Z", |
| 100 | label: "Checkpoint captured", | 117 | summary: "Checkpoint captured", |
| 101 | tone: "info", | 118 | tone: "info", |
| 102 | }), | 119 | }), |
| 103 | makeActivity({ | 120 | makeActivity({ |
| 104 | id: "tool-complete", | 121 | id: "tool-complete", |
| 105 | createdAt: "2026-02-23T00:00:02.000Z", | 122 | createdAt: "2026-02-23T00:00:02.000Z", |
| 106 | label: "Command run complete", | 123 | summary: "Command run complete", |
| 107 | tone: "tool", | 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,10 +1,15 @@ |
| 1 | import type { | 1 | import type { |
| 2 | OrchestrationThreadActivity, | 2 | OrchestrationThreadActivity, |
| 3 | ProviderKind, | 3 | ProviderKind, |
| 4 | ProviderSession, | ||
| 5 | } from "@t3tools/contracts"; | 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 | export const PROVIDER_OPTIONS: Array<{ | 14 | export const PROVIDER_OPTIONS: Array<{ |
| 10 | value: ProviderKind; | 15 | value: ProviderKind; |
| ... | @@ -83,18 +88,29 @@ export function derivePendingApprovals( | ... | @@ -83,18 +88,29 @@ export function derivePendingApprovals( |
| 83 | ); | 88 | ); |
| 84 | 89 | ||
| 85 | for (const activity of ordered) { | 90 | for (const activity of ordered) { |
| 86 | if (activity.requestId && activity.requestKind) { | 91 | const payload = |
| 87 | openByRequestId.set(activity.requestId, { | 92 | activity.payload && typeof activity.payload === "object" |
| 88 | requestId: activity.requestId, | 93 | ? (activity.payload as Record<string, unknown>) |
| 89 | requestKind: activity.requestKind, | 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 | createdAt: activity.createdAt, | 106 | createdAt: activity.createdAt, |
| 91 | ...(activity.detail ? { detail: activity.detail } : {}), | 107 | ...(detail ? { detail } : {}), |
| 92 | }); | 108 | }); |
| 93 | continue; | 109 | continue; |
| 94 | } | 110 | } |
| 95 | 111 | ||
| 96 | if (activity.requestId && activity.label === "Approval resolved") { | 112 | if (activity.kind === "approval.resolved" && requestId) { |
| 97 | openByRequestId.delete(activity.requestId); | 113 | openByRequestId.delete(requestId); |
| 98 | } | 114 | } |
| 99 | } | 115 | } |
| 100 | 116 | ||
| ... | @@ -112,16 +128,20 @@ export function deriveWorkLogEntries( | ... | @@ -112,16 +128,20 @@ export function deriveWorkLogEntries( |
| 112 | ); | 128 | ); |
| 113 | return ordered | 129 | return ordered |
| 114 | .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) | 130 | .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) |
| 115 | .filter((activity) => activity.label !== "Checkpoint captured") | 131 | .filter((activity) => activity.summary !== "Checkpoint captured") |
| 116 | .map((activity) => { | 132 | .map((activity) => { |
| 133 | const payload = | ||
| 134 | activity.payload && typeof activity.payload === "object" | ||
| 135 | ? (activity.payload as Record<string, unknown>) | ||
| 136 | : null; | ||
| 117 | const entry: WorkLogEntry = { | 137 | const entry: WorkLogEntry = { |
| 118 | id: activity.id, | 138 | id: activity.id, |
| 119 | createdAt: activity.createdAt, | 139 | createdAt: activity.createdAt, |
| 120 | label: activity.label, | 140 | label: activity.summary, |
| 121 | tone: activity.tone, | 141 | tone: activity.tone === "approval" ? "info" : activity.tone, |
| 122 | }; | 142 | }; |
| 123 | if (activity.detail) { | 143 | if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { |
| 124 | entry.detail = activity.detail; | 144 | entry.detail = payload.detail; |
| 125 | } | 145 | } |
| 126 | return entry; | 146 | return entry; |
| 127 | }); | 147 | }); |
| ... | @@ -207,7 +227,7 @@ export function deriveTurnDiffSummaries(_events: ReadonlyArray<OrchestrationThre | ... | @@ -207,7 +227,7 @@ export function deriveTurnDiffSummaries(_events: ReadonlyArray<OrchestrationThre |
| 207 | return []; | 227 | return []; |
| 208 | } | 228 | } |
| 209 | 229 | ||
| 210 | export function derivePhase(session: ProviderSession | null): SessionPhase { | 230 | export function derivePhase(session: ThreadSession | null): SessionPhase { |
| 211 | if (!session || session.status === "closed") return "disconnected"; | 231 | if (!session || session.status === "closed") return "disconnected"; |
| 212 | if (session.status === "connecting") return "connecting"; | 232 | if (session.status === "connecting") return "connecting"; |
| 213 | if (session.status === "running") return "running"; | 233 | if (session.status === "running") return "running"; |
apps/web/src/store.ts+99-33| ... | @@ -10,8 +10,7 @@ import { | ... | @@ -10,8 +10,7 @@ import { |
| 10 | 10 | ||
| 11 | import { | 11 | import { |
| 12 | type OrchestrationReadModel, | 12 | type OrchestrationReadModel, |
| 13 | type ProjectListResult, | 13 | type OrchestrationSessionStatus, |
| 14 | normalizeProjectScripts, | ||
| 15 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 16 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; | 15 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; |
| 17 | import { | 16 | import { |
| ... | @@ -19,6 +18,7 @@ import { | ... | @@ -19,6 +18,7 @@ import { |
| 19 | DEFAULT_THREAD_TERMINAL_ID, | 18 | DEFAULT_THREAD_TERMINAL_ID, |
| 20 | DEFAULT_RUNTIME_MODE, | 19 | DEFAULT_RUNTIME_MODE, |
| 21 | MAX_THREAD_TERMINAL_COUNT, | 20 | MAX_THREAD_TERMINAL_COUNT, |
| 21 | type ChatMessage, | ||
| 22 | type ProjectScript, | 22 | type ProjectScript, |
| 23 | type Project, | 23 | type Project, |
| 24 | type RuntimeMode, | 24 | type RuntimeMode, |
| ... | @@ -30,7 +30,6 @@ import { | ... | @@ -30,7 +30,6 @@ import { |
| 30 | 30 | ||
| 31 | type Action = | 31 | type Action = |
| 32 | | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } | 32 | | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } |
| 33 | | { type: "SYNC_PROJECTS"; projects: ProjectListResult } | ||
| 34 | | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } | 33 | | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } |
| 35 | | { type: "SET_THREADS_HYDRATED"; hydrated: boolean } | 34 | | { type: "SET_THREADS_HYDRATED"; hydrated: boolean } |
| 36 | | { type: "TOGGLE_PROJECT"; projectId: string } | 35 | | { type: "TOGGLE_PROJECT"; projectId: string } |
| ... | @@ -138,29 +137,56 @@ function updateThread( | ... | @@ -138,29 +137,56 @@ function updateThread( |
| 138 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); | 137 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); |
| 139 | } | 138 | } |
| 140 | 139 | ||
| 141 | function mapProjects( | 140 | function cloneProjectScripts(scripts: ReadonlyArray<ProjectScript>): ProjectScript[] { |
| 142 | incoming: ProjectListResult, | 141 | return scripts.map((script) => ({ ...script })); |
| 142 | } | ||
| 143 | |||
| 144 | function mapProjectsFromReadModel( | ||
| 145 | incoming: OrchestrationReadModel["projects"], | ||
| 143 | previous: Project[], | 146 | previous: Project[], |
| 144 | ): Project[] { | 147 | ): Project[] { |
| 145 | return incoming.map((project) => { | 148 | return incoming.map((project) => { |
| 146 | const existing = | 149 | const existing = |
| 147 | previous.find((entry) => entry.id === project.id) ?? | 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 | return { | 152 | return { |
| 150 | id: project.id, | 153 | id: project.id, |
| 151 | name: project.name, | 154 | name: project.title, |
| 152 | cwd: project.cwd, | 155 | cwd: project.workspaceRoot, |
| 153 | model: existing?.model ?? DEFAULT_MODEL, | 156 | model: existing?.model ?? resolveModelSlug(project.defaultModel ?? DEFAULT_MODEL), |
| 154 | expanded: | 157 | expanded: |
| 155 | existing?.expanded ?? | 158 | existing?.expanded ?? |
| 156 | (persistedExpandedProjectCwds.size > 0 | 159 | (persistedExpandedProjectCwds.size > 0 |
| 157 | ? persistedExpandedProjectCwds.has(project.cwd) | 160 | ? persistedExpandedProjectCwds.has(project.workspaceRoot) |
| 158 | : true), | 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 | function normalizeTerminalIds(terminalIds: string[]): string[] { | 191 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 166 | const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); | 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,12 +380,32 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { |
| 354 | export function reducer(state: AppState, action: Action): AppState { | 380 | export function reducer(state: AppState, action: Action): AppState { |
| 355 | switch (action.type) { | 381 | switch (action.type) { |
| 356 | case "SYNC_SERVER_READ_MODEL": { | 382 | case "SYNC_SERVER_READ_MODEL": { |
| 383 | const projects = mapProjectsFromReadModel( | ||
| 384 | action.readModel.projects.filter((project) => project.deletedAt === null), | ||
| 385 | state.projects, | ||
| 386 | ); | ||
| 357 | const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const)); | 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 | const existing = existingThreadById.get(thread.id); | 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 | return normalizeThreadTerminals({ | 406 | return normalizeThreadTerminals({ |
| 361 | id: thread.id, | 407 | id: thread.id, |
| 362 | codexThreadId: thread.session?.threadId ?? null, | 408 | codexThreadId: thread.session?.providerThreadId ?? null, |
| 363 | projectId: thread.projectId, | 409 | projectId: thread.projectId, |
| 364 | title: thread.title, | 410 | title: thread.title, |
| 365 | model: resolveModelSlug(thread.model), | 411 | model: resolveModelSlug(thread.model), |
| ... | @@ -374,52 +420,72 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -374,52 +420,72 @@ export function reducer(state: AppState, action: Action): AppState { |
| 374 | activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, | 420 | activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, |
| 375 | session: thread.session | 421 | session: thread.session |
| 376 | ? { | 422 | ? { |
| 377 | sessionId: thread.session.sessionId, | 423 | sessionId: thread.session.providerSessionId ?? `thread:${thread.id}`, |
| 378 | provider: thread.session.provider, | 424 | provider: toLegacyProvider(thread.session.providerName), |
| 379 | status: thread.session.status, | 425 | status: toLegacySessionStatus(thread.session.status), |
| 380 | threadId: thread.session.threadId, | 426 | orchestrationStatus: thread.session.status, |
| 427 | threadId: thread.session.providerThreadId, | ||
| 381 | activeTurnId: thread.session.activeTurnId ?? undefined, | 428 | activeTurnId: thread.session.activeTurnId ?? undefined, |
| 382 | createdAt: thread.session.createdAt, | 429 | createdAt: thread.session.updatedAt, |
| 383 | updatedAt: thread.session.updatedAt, | 430 | updatedAt: thread.session.updatedAt, |
| 384 | ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), | 431 | ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), |
| 385 | } | 432 | } |
| 386 | : null, | 433 | : null, |
| 387 | messages: thread.messages.map((message) => ({ ...message })), | 434 | messages: thread.messages.map((message) => { |
| 388 | error: thread.error, | 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 | createdAt: thread.createdAt, | 455 | createdAt: thread.createdAt, |
| 390 | latestTurnId: thread.latestTurnId ?? undefined, | 456 | latestTurnId: thread.latestTurnId ?? undefined, |
| 391 | latestTurnStartedAt: thread.latestTurnStartedAt ?? undefined, | 457 | latestTurnStartedAt, |
| 392 | latestTurnCompletedAt: thread.latestTurnCompletedAt ?? undefined, | 458 | latestTurnCompletedAt, |
| 393 | latestTurnDurationMs: thread.latestTurnDurationMs ?? undefined, | 459 | latestTurnDurationMs, |
| 394 | lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, | 460 | lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, |
| 395 | branch: thread.branch, | 461 | branch: thread.branch, |
| 396 | worktreePath: thread.worktreePath, | 462 | worktreePath: thread.worktreePath, |
| 397 | turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({ | 463 | turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({ |
| 398 | ...summary, | 464 | turnId: checkpoint.turnId, |
| 399 | files: summary.files.map((file) => ({ ...file })), | 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 | activities: thread.activities.map((activity) => ({ ...activity })), | 472 | activities: thread.activities.map((activity) => ({ ...activity })), |
| 402 | }); | 473 | }); |
| 403 | }); | 474 | }); |
| 404 | return { | 475 | return { |
| 405 | ...state, | 476 | ...state, |
| 477 | projects, | ||
| 406 | threads, | 478 | threads, |
| 407 | threadsHydrated: true, | 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 | case "SET_PROJECT_SCRIPTS": | 483 | case "SET_PROJECT_SCRIPTS": |
| 418 | return { | 484 | return { |
| 419 | ...state, | 485 | ...state, |
| 420 | projects: state.projects.map((project) => | 486 | projects: state.projects.map((project) => |
| 421 | project.id === action.projectId | 487 | project.id === action.projectId |
| 422 | ? { ...project, scripts: normalizeProjectScripts(action.scripts) } | 488 | ? { ...project, scripts: cloneProjectScripts(action.scripts) } |
| 423 | : project, | 489 | : project, |
| 424 | ), | 490 | ), |
| 425 | }; | 491 | }; |
apps/web/src/types.ts+16-3| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import type { | 1 | import type { |
| 2 | OrchestrationSessionStatus, | ||
| 2 | OrchestrationThreadActivity, | 3 | OrchestrationThreadActivity, |
| 3 | ProjectScript as ContractProjectScript, | 4 | ProjectScript as ContractProjectScript, |
| 4 | ProviderSession, | ||
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | ||
| 7 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; | 7 | export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| ... | @@ -30,7 +30,7 @@ export type ChatAttachment = ChatImageAttachment; | ... | @@ -30,7 +30,7 @@ export type ChatAttachment = ChatImageAttachment; |
| 30 | 30 | ||
| 31 | export interface ChatMessage { | 31 | export interface ChatMessage { |
| 32 | id: string; | 32 | id: string; |
| 33 | role: "user" | "assistant"; | 33 | role: "user" | "assistant" | "system"; |
| 34 | text: string; | 34 | text: string; |
| 35 | attachments?: ChatAttachment[]; | 35 | attachments?: ChatAttachment[]; |
| 36 | createdAt: string; | 36 | createdAt: string; |
| ... | @@ -50,6 +50,7 @@ export interface TurnDiffSummary { | ... | @@ -50,6 +50,7 @@ export interface TurnDiffSummary { |
| 50 | completedAt: string; | 50 | completedAt: string; |
| 51 | status?: string | undefined; | 51 | status?: string | undefined; |
| 52 | files: TurnDiffFileChange[]; | 52 | files: TurnDiffFileChange[]; |
| 53 | checkpointRef?: string | undefined; | ||
| 53 | assistantMessageId?: string | undefined; | 54 | assistantMessageId?: string | undefined; |
| 54 | checkpointTurnCount?: number | undefined; | 55 | checkpointTurnCount?: number | undefined; |
| 55 | } | 56 | } |
| ... | @@ -76,7 +77,7 @@ export interface Thread { | ... | @@ -76,7 +77,7 @@ export interface Thread { |
| 76 | activeTerminalId: string; | 77 | activeTerminalId: string; |
| 77 | terminalGroups: ThreadTerminalGroup[]; | 78 | terminalGroups: ThreadTerminalGroup[]; |
| 78 | activeTerminalGroupId: string; | 79 | activeTerminalGroupId: string; |
| 79 | session: ProviderSession | null; | 80 | session: ThreadSession | null; |
| 80 | messages: ChatMessage[]; | 81 | messages: ChatMessage[]; |
| 81 | error: string | null; | 82 | error: string | null; |
| 82 | createdAt: string; | 83 | createdAt: string; |
| ... | @@ -90,3 +91,15 @@ export interface Thread { | ... | @@ -90,3 +91,15 @@ export interface Thread { |
| 90 | turnDiffSummaries: TurnDiffSummary[]; | 91 | turnDiffSummaries: TurnDiffSummary[]; |
| 91 | activities: OrchestrationThreadActivity[]; | 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,15 +1,6 @@ |
| 1 | /// <reference types="vite/client" /> | 1 | /// <reference types="vite/client" /> |
| 2 | 2 | ||
| 3 | import type { NativeApi } from "@t3tools/contracts"; | 3 | import type { NativeApi, DesktopBridge } 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 | } | ||
| 13 | 4 | ||
| 14 | declare global { | 5 | declare global { |
| 15 | interface Window { | 6 | interface Window { |
apps/web/src/wsNativeApi.ts+1-28| ... | @@ -56,12 +56,6 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -56,12 +56,6 @@ export function createWsNativeApi(): NativeApi { |
| 56 | }); | 56 | }); |
| 57 | 57 | ||
| 58 | const api: NativeApi = { | 58 | const api: NativeApi = { |
| 59 | todos: { | ||
| 60 | list: async () => [], | ||
| 61 | add: async () => [], | ||
| 62 | toggle: async () => [], | ||
| 63 | remove: async () => [], | ||
| 64 | }, | ||
| 65 | dialogs: { | 59 | dialogs: { |
| 66 | pickFolder: async () => { | 60 | pickFolder: async () => { |
| 67 | if (!window.desktopBridge) return null; | 61 | if (!window.desktopBridge) return null; |
| ... | @@ -84,30 +78,8 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -84,30 +78,8 @@ export function createWsNativeApi(): NativeApi { |
| 84 | onEvent: (callback) => | 78 | onEvent: (callback) => |
| 85 | transport.subscribe(WS_CHANNELS.terminalEvent, callback as (data: unknown) => void), | 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 | projects: { | 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 | searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), | 82 | searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), |
| 110 | updateScripts: (input) => transport.request(WS_METHODS.projectsUpdateScripts, input), | ||
| 111 | }, | 83 | }, |
| 112 | shell: { | 84 | shell: { |
| 113 | openInEditor: (cwd, editor) => | 85 | openInEditor: (cwd, editor) => |
| ... | @@ -157,6 +129,7 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -157,6 +129,7 @@ export function createWsNativeApi(): NativeApi { |
| 157 | getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), | 129 | getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), |
| 158 | dispatchCommand: (command) => | 130 | dispatchCommand: (command) => |
| 159 | transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, command), | 131 | transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, command), |
| 132 | getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input), | ||
| 160 | replayEvents: (fromSequenceExclusive) => | 133 | replayEvents: (fromSequenceExclusive) => |
| 161 | transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }), | 134 | transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }), |
| 162 | onDomainEvent: (callback) => | 135 | onDomainEvent: (callback) => |
apps/web/src/wsTransport.ts+26-10| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { WsPush, WsRequest, WsResponse } from "@t3tools/contracts"; | 1 | import type { WsPush } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | type PushListener = (data: unknown) => void; | 3 | type PushListener = (data: unknown) => void; |
| 4 | 4 | ||
| ... | @@ -11,6 +11,14 @@ interface PendingRequest { | ... | @@ -11,6 +11,14 @@ interface PendingRequest { |
| 11 | const REQUEST_TIMEOUT_MS = 60_000; | 11 | const REQUEST_TIMEOUT_MS = 60_000; |
| 12 | const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000]; | 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 | export class WsTransport { | 22 | export class WsTransport { |
| 15 | private ws: WebSocket | null = null; | 23 | private ws: WebSocket | null = null; |
| 16 | private nextId = 1; | 24 | private nextId = 1; |
| ... | @@ -41,7 +49,11 @@ export class WsTransport { | ... | @@ -41,7 +49,11 @@ export class WsTransport { |
| 41 | throw new Error("Request method is required"); | 49 | throw new Error("Request method is required"); |
| 42 | } | 50 | } |
| 43 | const id = String(this.nextId++); | 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 | return new Promise<T>((resolve, reject) => { | 58 | return new Promise<T>((resolve, reject) => { |
| 47 | const timeout = setTimeout(() => { | 59 | const timeout = setTimeout(() => { |
| ... | @@ -144,22 +156,26 @@ export class WsTransport { | ... | @@ -144,22 +156,26 @@ export class WsTransport { |
| 144 | 156 | ||
| 145 | // Response to a request | 157 | // Response to a request |
| 146 | if (typeof message.id === "string") { | 158 | if (typeof message.id === "string") { |
| 147 | const response = message as unknown as WsResponse; | 159 | const responseId = message.id; |
| 148 | const pending = this.pending.get(response.id); | 160 | const pending = this.pending.get(responseId); |
| 149 | if (!pending) return; | 161 | if (!pending) return; |
| 150 | 162 | ||
| 151 | clearTimeout(pending.timeout); | 163 | clearTimeout(pending.timeout); |
| 152 | this.pending.delete(response.id); | 164 | this.pending.delete(responseId); |
| 153 | 165 | ||
| 154 | if (response.error) { | 166 | if ( |
| 155 | pending.reject(new Error(response.error.message)); | 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 | } else { | 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 | if (this.ws?.readyState === WebSocket.OPEN) { | 179 | if (this.ws?.readyState === WebSocket.OPEN) { |
| 164 | this.ws.send(JSON.stringify(message)); | 180 | this.ws.send(JSON.stringify(message)); |
| 165 | return; | 181 | return; |
bun.lock+2-2| ... | @@ -19,6 +19,7 @@ | ... | @@ -19,6 +19,7 @@ |
| 19 | "electron": "40.6.0", | 19 | "electron": "40.6.0", |
| 20 | }, | 20 | }, |
| 21 | "devDependencies": { | 21 | "devDependencies": { |
| 22 | "@t3tools/contracts": "workspace:*", | ||
| 22 | "@types/node": "^22.10.2", | 23 | "@types/node": "^22.10.2", |
| 23 | "electronmon": "^2.0.2", | 24 | "electronmon": "^2.0.2", |
| 24 | "tsdown": "^0.20.3", | 25 | "tsdown": "^0.20.3", |
| ... | @@ -68,6 +69,7 @@ | ... | @@ -68,6 +69,7 @@ |
| 68 | "@xterm/addon-fit": "^0.11.0", | 69 | "@xterm/addon-fit": "^0.11.0", |
| 69 | "@xterm/xterm": "^6.0.0", | 70 | "@xterm/xterm": "^6.0.0", |
| 70 | "class-variance-authority": "^0.7.1", | 71 | "class-variance-authority": "^0.7.1", |
| 72 | "effect": "4.0.0-beta.10", | ||
| 71 | "highlight.js": "^11.11.1", | 73 | "highlight.js": "^11.11.1", |
| 72 | "lucide-react": "^0.564.0", | 74 | "lucide-react": "^0.564.0", |
| 73 | "react": "^19.0.0", | 75 | "react": "^19.0.0", |
| ... | @@ -76,7 +78,6 @@ | ... | @@ -76,7 +78,6 @@ |
| 76 | "rehype-highlight": "^7.0.2", | 78 | "rehype-highlight": "^7.0.2", |
| 77 | "remark-gfm": "^4.0.1", | 79 | "remark-gfm": "^4.0.1", |
| 78 | "tailwind-merge": "^3.4.0", | 80 | "tailwind-merge": "^3.4.0", |
| 79 | "zod": "^3.24.1", | ||
| 80 | }, | 81 | }, |
| 81 | "devDependencies": { | 82 | "devDependencies": { |
| 82 | "@tailwindcss/vite": "^4.0.0", | 83 | "@tailwindcss/vite": "^4.0.0", |
| ... | @@ -95,7 +96,6 @@ | ... | @@ -95,7 +96,6 @@ |
| 95 | "version": "0.0.0", | 96 | "version": "0.0.0", |
| 96 | "dependencies": { | 97 | "dependencies": { |
| 97 | "effect": "4.0.0-beta.10", | 98 | "effect": "4.0.0-beta.10", |
| 98 | "zod": "^3.24.1", | ||
| 99 | }, | 99 | }, |
| 100 | "devDependencies": { | 100 | "devDependencies": { |
| 101 | "@effect/language-service": "0.75.1", | 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,6 +5,7 @@ const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty()); |
| 5 | // Domain Types | 5 | // Domain Types |
| 6 | 6 | ||
| 7 | export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]); | 7 | export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]); |
| 8 | export type GitStackedAction = typeof GitStackedAction.Type; | ||
| 8 | export const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]); | 9 | export const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]); |
| 9 | export const GitPushStepStatus = Schema.Literals([ | 10 | export const GitPushStepStatus = Schema.Literals([ |
| 10 | "pushed", | 11 | "pushed", |
packages/contracts/src/ipc.ts+12| ... | @@ -35,6 +35,18 @@ import type { | ... | @@ -35,6 +35,18 @@ import type { |
| 35 | } from "./orchestration"; | 35 | } from "./orchestration"; |
| 36 | import { EditorId } from "./editor"; | 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 | export interface NativeApi { | 50 | export interface NativeApi { |
| 39 | dialogs: { | 51 | dialogs: { |
| 40 | pickFolder: () => Promise<string | null>; | 52 | pickFolder: () => Promise<string | null>; |
packages/contracts/src/orchestration.ts+1| ... | @@ -105,6 +105,7 @@ export const ProjectScriptIcon = Schema.Literals([ | ... | @@ -105,6 +105,7 @@ export const ProjectScriptIcon = Schema.Literals([ |
| 105 | "build", | 105 | "build", |
| 106 | "debug", | 106 | "debug", |
| 107 | ]); | 107 | ]); |
| 108 | export type ProjectScriptIcon = typeof ProjectScriptIcon.Type; | ||
| 108 | 109 | ||
| 109 | export const ProjectScript = Schema.Struct({ | 110 | export const ProjectScript = Schema.Struct({ |
| 110 | id: Schema.String, | 111 | id: Schema.String, |
packages/contracts/src/server.ts+3-8| ... | @@ -5,17 +5,12 @@ export const ServerConfig = Schema.Struct({ | ... | @@ -5,17 +5,12 @@ export const ServerConfig = Schema.Struct({ |
| 5 | cwd: Schema.NonEmptyString, | 5 | cwd: Schema.NonEmptyString, |
| 6 | keybindings: ResolvedKeybindingsConfig, | 6 | keybindings: ResolvedKeybindingsConfig, |
| 7 | }); | 7 | }); |
| 8 | export type ServerConfig = Schema.Codec.Encoded<typeof ServerConfig>; | 8 | export type ServerConfig = typeof ServerConfig.Type; |
| 9 | 9 | ||
| 10 | export const ServerUpsertKeybindingInput = KeybindingRule; | 10 | export const ServerUpsertKeybindingInput = KeybindingRule; |
| 11 | export type ServerUpsertKeybindingInput = Schema.Codec.Encoded< | 11 | export type ServerUpsertKeybindingInput = typeof ServerUpsertKeybindingInput.Type; |
| 12 | typeof ServerUpsertKeybindingInput | ||
| 13 | >; | ||
| 14 | 12 | ||
| 15 | export const ServerUpsertKeybindingResult = Schema.Struct({ | 13 | export const ServerUpsertKeybindingResult = Schema.Struct({ |
| 16 | keybindings: ResolvedKeybindingsConfig, | 14 | keybindings: ResolvedKeybindingsConfig, |
| 17 | }); | 15 | }); |
| 18 | export type ServerUpsertKeybindingResult = Schema.Codec.Encoded< | 16 | export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type; |
| 19 | typeof ServerUpsertKeybindingResult | ||
| 20 | >; | ||
| 21 |