authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-24 09:52:38-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log36802d707df879e6f5d635f4ec3f89d50045ae20
tree3fd60dbdfcc3be89d6bb69c98a7faff10d622d77
parent25d20ab7928c6aed5f613f4c3319394635869250

frontend


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 @@
1import { contextBridge, ipcRenderer } from "electron";1import { contextBridge, ipcRenderer } from "electron";
2import type { DesktopBridge } from "@t3tools/contracts";
23
3const PICK_FOLDER_CHANNEL = "desktop:pick-folder";4const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
4const CONFIRM_CHANNEL = "desktop:confirm";5const 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;
910
10contextBridge.exposeInMainWorld("desktopBridge", {11contextBridge.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 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4import { execFileSync } from "node:child_process";
5
6import { NodeServices } from "@effect/platform-node";
7import {
8 ApprovalRequestId,
9 type OrchestrationEvent,
10 type OrchestrationThread,
11 type ThreadId,
12} from "@t3tools/contracts";
13import { Effect, Exit, Layer, ManagedRuntime, Option, Schedule, Scope, Stream } from "effect";
14
15import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts";
16import { CheckpointDiffBlobRepositoryLive } from "../src/persistence/Layers/CheckpointDiffBlobs.ts";
17import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
18import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
19import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts";
20import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts";
21import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts";
22import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts";
23import { CheckpointDiffBlobRepository } from "../src/persistence/Services/CheckpointDiffBlobs.ts";
24import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts";
25import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts";
26import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
27import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
28import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
29import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
30import { ProviderService } from "../src/provider/Services/ProviderService.ts";
31import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
32import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
33import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
34import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
35import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";
36import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";
37import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts";
38import {
39 OrchestrationEngineService,
40 type OrchestrationEngineShape,
41} from "../src/orchestration/Services/OrchestrationEngine.ts";
42import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts";
43import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts";
44
45import {
46 makeTestProviderAdapterHarness,
47 type TestProviderAdapterHarness,
48} from "./TestProviderAdapter.integration.ts";
49
50function runGit(cwd: string, args: ReadonlyArray<string>) {
51 return execFileSync("git", args, {
52 cwd,
53 stdio: ["ignore", "pipe", "pipe"],
54 encoding: "utf8",
55 });
56}
57
58function 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
67export 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
72export 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
81export function gitShowFileAtRef(cwd: string, ref: string, filePath: string): string {
82 return runGit(cwd, ["show", `${ref}:${filePath}`]);
83}
84
85function waitFor<A>(
86 read: Effect.Effect<A, unknown>,
87 predicate: (value: A) => boolean,
88 description: string,
89 timeoutMs?: number,
90): Effect.Effect<A, never>;
91function 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>;
97function 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
122export 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
178export 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 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
22
3import type {3import {
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";
8import { Effect, Queue, Stream } from "effect";14import { 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}
4559
46const PROVIDER = "codex" as const;60const 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 >();
6790
68 const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event);91 const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event);
6992
...@@ -78,8 +101,8 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {...@@ -78,8 +101,8 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
78 }101 }
79102
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();
84107
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 });
105128
...@@ -115,7 +138,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {...@@ -115,7 +138,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
115138
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}`);
119142
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* () {
209232
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);
215249
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 );
297331
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 };
305346
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 @@
1import type { ProviderRuntimeEvent } from "@t3tools/contracts";1import {
2 ApprovalRequestId,
3 EventId,
4 ProviderSessionId,
5 ProviderThreadId,
6 ProviderTurnId,
7 type ProviderRuntimeEvent,
8} from "@t3tools/contracts";
29
3const PROVIDER = "codex" as const;10const PROVIDER = "codex" as const;
4const SESSION_ID = "fixture-session";11const SESSION_ID = ProviderSessionId.makeUnsafe("fixture-session");
5const THREAD_ID = "fixture-thread";12const THREAD_ID = ProviderThreadId.makeUnsafe("fixture-thread");
6const TURN_ID = "fixture-turn";13const TURN_ID = ProviderTurnId.makeUnsafe("fixture-turn");
14const REQUEST_ID = ApprovalRequestId.makeUnsafe("req-1");
715
8function baseEvent(eventId: string, createdAt: string): Pick<16function 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 @@
1import fs from "node:fs";
2import path from "node:path";
3
4import {
5 ApprovalRequestId,
6 CommandId,
7 EventId,
8 MessageId,
9 ProjectId,
10 ProviderSessionId,
11 ProviderThreadId,
12 ProviderTurnId,
13 ThreadId,
14} from "@t3tools/contracts";
15import { assert, it } from "@effect/vitest";
16import { Effect, Option } from "effect";
17
18import type { TestTurnResponse } from "./TestProviderAdapter.integration.ts";
19import {
20 checkpointRefForTurn,
21 gitRefExists,
22 gitShowFileAtRef,
23 makeOrchestrationIntegrationHarness,
24 type OrchestrationIntegrationHarness,
25} from "./OrchestrationEngineHarness.integration.ts";
26
27const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value);
28const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
29const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
30const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
31const asEventId = (value: string): EventId => EventId.makeUnsafe(value);
32const asProviderSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
33const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value);
34const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value);
35const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value);
36
37const PROJECT_ID = asProjectId("project-1");
38const THREAD_ID = asThreadId("thread-1");
39const FIXTURE_SESSION_ID = asProviderSessionId("fixture-session");
40const FIXTURE_THREAD_ID = asProviderThreadId("fixture-thread");
41const FIXTURE_TURN_ID = asProviderTurnId("fixture-turn");
42const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1");
43
44function nowIso() {
45 return new Date().toISOString();
46}
47
48const sleep = (ms: number) => Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));
49
50function 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
72function 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
81function 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
91const 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
118const 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
137it.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
205it.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
349it.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
432it.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
501it.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
605it.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 @@
1import type { ProviderRuntimeEvent } from "@t3tools/contracts";1import type { ProviderRuntimeEvent, ProviderSessionId } from "@t3tools/contracts";
2import { NodeServices } from "@effect/platform-node";2import { NodeServices } from "@effect/platform-node";
3import { it, assert } from "@effect/vitest";3import { it, assert } from "@effect/vitest";
4import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect";4import { 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>(
92const runTurn = (input: {92const 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 }
144142
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 }
163159
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 });
184180
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 });
191185
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 }
242236
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 }
253245
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 }
113111
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> {
189189
190async function sendRequest(ws: WebSocket, method: string, params?: unknown): Promise<WsResponse> {190async 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);
194200
195 // Wait for response with matching id201 // 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}
203212
...@@ -658,18 +667,27 @@ describe("WebSocket Server", () => {...@@ -658,18 +667,27 @@ describe("WebSocket Server", () => {
658667
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 });
663672
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();
718736
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 });
736755
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 });
747766
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 });
752771
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 });
758780
...@@ -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 });
897919
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", () => {
907929
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");
911933
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");
917939
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 });
924946
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 });
9911013
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}
134134
135function 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
167function stripRequestTag<T extends { _tag: string }>(
168 body: T,
169): Omit<T, "_tag"> {
170 const { _tag: _ignoredTag, ...rest } = body;
171 return rest;
172}
173
135type EffectRuntime = ManagedRuntime.ManagedRuntime<174type EffectRuntime = ManagedRuntime.ManagedRuntime<
136 | OrchestrationEngineService175 | OrchestrationEngineService
137 | ProjectionSnapshotQuery176 | ProjectionSnapshotQuery
...@@ -360,9 +399,19 @@ export function createServer(options: ServerOptions) {...@@ -360,9 +399,19 @@ export function createServer(options: ServerOptions) {
360 });399 });
361400
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 }
407456
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 }
415465
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 );
435485
436 case WS_METHODS.projectsSearchEntries:486 case WS_METHODS.projectsSearchEntries:
437 return searchWorkspaceEntries(request.body);487 return searchWorkspaceEntries(stripRequestTag(request.body));
438488
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 // }
501551
502 case WS_METHODS.gitPull:552 case WS_METHODS.gitPull:
503 return pullGitBranch(request.body);553 return pullGitBranch(stripRequestTag(request.body));
504554
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));
509559
510 case WS_METHODS.gitCreateWorktree:560 case WS_METHODS.gitCreateWorktree:
511 return createGitWorktree(request.body);561 return createGitWorktree(stripRequestTag(request.body));
512562
513 case WS_METHODS.gitRemoveWorktree:563 case WS_METHODS.gitRemoveWorktree:
514 return removeGitWorktree(request.body);564 return removeGitWorktree(stripRequestTag(request.body));
515565
516 case WS_METHODS.gitCreateBranch:566 case WS_METHODS.gitCreateBranch:
517 return createGitBranch(request.body);567 return createGitBranch(stripRequestTag(request.body));
518568
519 case WS_METHODS.gitCheckout:569 case WS_METHODS.gitCheckout:
520 return checkoutGitBranch(request.body);570 return checkoutGitBranch(stripRequestTag(request.body));
521571
522 case WS_METHODS.gitInit:572 case WS_METHODS.gitInit:
523 return initGitRepo(request.body);573 return initGitRepo(stripRequestTag(request.body));
524574
525 case WS_METHODS.terminalOpen:575 case WS_METHODS.terminalOpen:
526 return terminalManager.open(request.body);576 return terminalManager.open(stripRequestTag(request.body));
527577
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;
531581
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;
535585
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;
539589
540 case WS_METHODS.terminalRestart:590 case WS_METHODS.terminalRestart:
541 return terminalManager.restart(request.body);591 return terminalManager.restart(stripRequestTag(request.body));
542592
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;
546596
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 };
552602
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 @@
1import { useCallback, useSyncExternalStore } from "react";1import { useCallback, useSyncExternalStore } from "react";
2import { z } from "zod";2import { Option, Schema } from "effect";
33
4const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";4const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
55
6const appSettingsSchema = z.object({6const 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});
15export type AppSettings = typeof AppSettingsSchema.Type;
1116
12export type AppSettings = z.infer<typeof appSettingsSchema>;17const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
13
14const DEFAULT_APP_SETTINGS: AppSettings = appSettingsSchema.parse({});
1518
16let listeners: Array<() => void> = [];19let listeners: Array<() => void> = [];
17let cachedRawSettings: string | null | undefined;20let 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 }
3033
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}
8587
86export function useAppSettings() {88export function useAppSettings() {
87 const settings = useSyncExternalStore(subscribe, getAppSettingsSnapshot, () => DEFAULT_APP_SETTINGS);89 const settings = useSyncExternalStore(
90 subscribe,
91 getAppSettingsSnapshot,
92 () => DEFAULT_APP_SETTINGS,
93 );
8894
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}
1010
11export function deriveSyncedLocalBranch({11export 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";
3import { useEffect, useState } from "react";3import { useEffect, useState } from "react";
44
5import { useNativeApi } from "../hooks/useNativeApi";5import { useNativeApi } from "../hooks/useNativeApi";
6import { asThreadId, newCommandId } from "../lib/orchestrationIds";
6import {7import {
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 the127 // 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";
15import {13import {
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
3634
37import { isElectron } from "../env";35import { isElectron } from "../env";
38import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";36import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
39import { useAppSettings } from "../appSettings";
40import { buildBootstrapInput } from "../historyBootstrap";
41import {37import {
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";
118import { Toggle } from "./ui/toggle";114import { Toggle } from "./ui/toggle";
115import {
116 asApprovalRequestId,
117 asProjectId,
118 asThreadId,
119 newCommandId,
120 newMessageId,
121} from "~/lib/orchestrationIds";
119122
120function formatMessageMeta(createdAt: string, duration: string | null): string {123function 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}
167170
168type SessionContinuityState = "resumed" | "new" | "fallback_new";
169
170interface EnsuredSessionInfo {
171 sessionId: string;
172 resolvedThreadId: string | null;
173 continuityState: SessionContinuityState;
174}
175
176interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> {171interface 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>());
388380
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 });
822806
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;
832815
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;
948917
949 const sessionIds = state.threads918 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);
953921
954 if (sessionIds.length === 0) return;922 if (runningThreadIds.length === 0) return;
955923
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 };
13231300
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];
15451358
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 }
16081415
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);
16371422
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 = shouldBootstrap1446 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 ).text1451 },
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 };
16811465
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 };
16891475
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;
16931479
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 );
17151504
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";
7import { isElectron } from "../env";7import { isElectron } from "../env";
8import { APP_STAGE_LABEL } from "../branding";8import { APP_STAGE_LABEL } from "../branding";
9import { DEFAULT_MODEL } from "../model-logic";9import { DEFAULT_MODEL } from "../model-logic";
10import { asProjectId, asThreadId, newCommandId, newProjectId, newThreadId } from "../lib/orchestrationIds";
10import { useStore } from "../store";11import { useStore } from "../store";
11import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";12import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
12import { type Thread } from "../types";13import { 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 );
157158
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 }
190191
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;
194195 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 ));
269272
270 // Stop active session if running273 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 stopped280 })
278 }281 .catch(() => undefined);
279 }282 }
280283
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;
370372
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 );
388392
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 @@
1import { ApprovalRequestId, CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
2
3export const newCommandId = (): CommandId => CommandId.makeUnsafe(crypto.randomUUID());
4
5export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(crypto.randomUUID());
6
7export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(crypto.randomUUID());
8
9export const newMessageId = (): MessageId => MessageId.makeUnsafe(crypto.randomUUID());
10
11export const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);
12
13export const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
14
15export 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
6describe("providerQueryKeys.checkpointDiff", () => {6describe("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", () => {
2827
29describe("checkpointDiffQueryOptions", () => {28describe("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;
3736
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);
4846
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", () => {
5553
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", () => {
8683
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 @@
1import type { NativeApi } from "@t3tools/contracts";1import type { NativeApi } from "@t3tools/contracts";
2import { queryOptions } from "@tanstack/react-query";2import { queryOptions } from "@tanstack/react-query";
3import { asThreadId } from "./orchestrationIds";
34
4export interface CheckpointDiffQueryInput {5export 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";
13import { useNativeApi } from "../hooks/useNativeApi";13import { useNativeApi } from "../hooks/useNativeApi";
14import { DEFAULT_MODEL } from "../model-logic";14import { DEFAULT_MODEL } from "../model-logic";
15import { useStore } from "../store";15import { useStore } from "../store";
16import { newCommandId, newProjectId, newThreadId } from "../lib/orchestrationIds";
16import { onServerWelcome } from "../wsNativeApi";17import { onServerWelcome } from "../wsNativeApi";
1718
18export const Route = createRootRouteWithContext<{19export 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 };
160161
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);
167163
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() {
209205
210 bootstrappedRef.current = true;206 bootstrappedRef.current = true;
211 const now = new Date().toISOString();207 const now = new Date().toISOString();
212 void api.projects208 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",
217213 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,
222218 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]);
238236
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 @@
1import type { OrchestrationThreadActivity } from "@t3tools/contracts";1import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import {4import {
...@@ -7,18 +7,24 @@ import {...@@ -7,18 +7,24 @@ import {
7 deriveWorkLogEntries,7 deriveWorkLogEntries,
8} from "./session-logic";8} from "./session-logic";
99
10function makeActivity(10function 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}
2430
...@@ -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 ];
5363
...@@ -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 ];
7991
...@@ -83,9 +95,14 @@ describe("deriveWorkLogEntries", () => {...@@ -83,9 +95,14 @@ describe("deriveWorkLogEntries", () => {
8395
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 ];
90107
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 ];
110128
apps/web/src/session-logic.ts+35-15
...@@ -1,10 +1,15 @@...@@ -1,10 +1,15 @@
1import type {1import type {
2 OrchestrationThreadActivity,2 OrchestrationThreadActivity,
3 ProviderKind,3 ProviderKind,
4 ProviderSession,
5} from "@t3tools/contracts";4} from "@t3tools/contracts";
65
7import type { ChatMessage, SessionPhase, TurnDiffFileChange, TurnDiffSummary } from "./types";6import type {
7 ChatMessage,
8 SessionPhase,
9 ThreadSession,
10 TurnDiffFileChange,
11 TurnDiffSummary,
12} from "./types";
813
9export const PROVIDER_OPTIONS: Array<{14export 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 );
8489
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 }
95111
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 }
100116
...@@ -112,16 +128,20 @@ export function deriveWorkLogEntries(...@@ -112,16 +128,20 @@ export function deriveWorkLogEntries(
112 );128 );
113 return ordered129 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}
209229
210export function derivePhase(session: ProviderSession | null): SessionPhase {230export 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 {
1010
11import {11import {
12 type OrchestrationReadModel,12 type OrchestrationReadModel,
13 type ProjectListResult,13 type OrchestrationSessionStatus,
14 normalizeProjectScripts,
15} from "@t3tools/contracts";14} from "@t3tools/contracts";
16import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";15import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
17import {16import {
...@@ -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 {
3030
31type Action =31type 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}
140139
141function mapProjects(140function cloneProjectScripts(scripts: ReadonlyArray<ProjectScript>): ProjectScript[] {
142 incoming: ProjectListResult,141 return scripts.map((script) => ({ ...script }));
142}
143
144function 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 > 0159 (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}
163166
167function 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
186function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" {
187 return providerName === "claudeCode" ? "claudeCode" : "codex";
188}
189
164190
165function normalizeTerminalIds(terminalIds: string[]): string[] {191function 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 {
354export function reducer(state: AppState, action: Action): AppState {380export 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.session421 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 }
410482
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.projectId487 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 @@
1import type {1import 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";
66
7export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";7export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
...@@ -30,7 +30,7 @@ export type ChatAttachment = ChatImageAttachment;...@@ -30,7 +30,7 @@ export type ChatAttachment = ChatImageAttachment;
3030
31export interface ChatMessage {31export 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
95export 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" />
22
3import type { NativeApi } from "@t3tools/contracts";3import type { NativeApi, DesktopBridge } from "@t3tools/contracts";
4
5interface 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}
134
14declare global {5declare 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 });
5757
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 @@
1import type { WsPush, WsRequest, WsResponse } from "@t3tools/contracts";1import type { WsPush } from "@t3tools/contracts";
22
3type PushListener = (data: unknown) => void;3type PushListener = (data: unknown) => void;
44
...@@ -11,6 +11,14 @@ interface PendingRequest {...@@ -11,6 +11,14 @@ interface PendingRequest {
11const REQUEST_TIMEOUT_MS = 60_000;11const REQUEST_TIMEOUT_MS = 60_000;
12const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];12const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
1313
14interface WsRequestEnvelope {
15 id: string;
16 body: {
17 _tag: string;
18 [key: string]: unknown;
19 };
20}
21
14export class WsTransport {22export 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 };
4557
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 {
144156
145 // Response to a request157 // 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;
150162
151 clearTimeout(pending.timeout);163 clearTimeout(pending.timeout);
152 this.pending.delete(response.id);164 this.pending.delete(responseId);
153165
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 }
161177
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 Types5// Domain Types
66
7export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]);7export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]);
8export type GitStackedAction = typeof GitStackedAction.Type;
8export const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]);9export const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]);
9export const GitPushStepStatus = Schema.Literals([10export 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";
36import { EditorId } from "./editor";36import { EditorId } from "./editor";
3737
38export 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
38export interface NativeApi {50export 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]);
108export type ProjectScriptIcon = typeof ProjectScriptIcon.Type;
108109
109export const ProjectScript = Schema.Struct({110export 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});
8export type ServerConfig = Schema.Codec.Encoded<typeof ServerConfig>;8export type ServerConfig = typeof ServerConfig.Type;
99
10export const ServerUpsertKeybindingInput = KeybindingRule;10export const ServerUpsertKeybindingInput = KeybindingRule;
11export type ServerUpsertKeybindingInput = Schema.Codec.Encoded<11export type ServerUpsertKeybindingInput = typeof ServerUpsertKeybindingInput.Type;
12 typeof ServerUpsertKeybindingInput
13>;
1412
15export const ServerUpsertKeybindingResult = Schema.Struct({13export const ServerUpsertKeybindingResult = Schema.Struct({
16 keybindings: ResolvedKeybindingsConfig,14 keybindings: ResolvedKeybindingsConfig,
17});15});
18export type ServerUpsertKeybindingResult = Schema.Codec.Encoded<16export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type;
19 typeof ServerUpsertKeybindingResult
20>;
21