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 @@
22 "name": "@t3tools/desktop",
33 "version": "0.0.0",
44 "private": true,
5 "productName": "T3 Code (Alpha)",
65 "main": "dist-electron/main.js",
76 "scripts": {
87 "dev": "bun run --parallel dev:bundle dev:electron",
......@@ -18,11 +17,13 @@
1817 "electron": "40.6.0"
1918 },
2019 "devDependencies": {
20 "@t3tools/contracts": "workspace:*",
2121 "@types/node": "^22.10.2",
2222 "electronmon": "^2.0.2",
2323 "tsdown": "^0.20.3",
2424 "typescript": "^5.7.3",
2525 "vitest": "^4.0.0",
2626 "wait-on": "^8.0.2"
27 }
27 },
28 "productName": "T3 Code (Alpha)"
2829}
apps/desktop/src/preload.ts+7-7
......@@ -1,4 +1,5 @@
11import { contextBridge, ipcRenderer } from "electron";
2import type { DesktopBridge } from "@t3tools/contracts";
23
34const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
45const CONFIRM_CHANNEL = "desktop:confirm";
......@@ -9,12 +10,11 @@ const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
910
1011contextBridge.exposeInMainWorld("desktopBridge", {
1112 getWsUrl: () => wsUrl,
12 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL) as Promise<string | null>,
13 confirm: (message: string) => ipcRenderer.invoke(CONFIRM_CHANNEL, message) as Promise<boolean>,
14 showContextMenu: (items: { id: string; label: string }[]) =>
15 ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>,
16 openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise<boolean>,
17 onMenuAction: (listener: (action: string) => void) => {
13 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
14 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
15 showContextMenu: (items) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items),
16 openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url),
17 onMenuAction: (listener) => {
1818 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
1919 if (typeof action !== "string") return;
2020 listener(action);
......@@ -25,4 +25,4 @@ contextBridge.exposeInMainWorld("desktopBridge", {
2525 ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener);
2626 };
2727 },
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 @@
11import { randomUUID } from "node:crypto";
22
3import type {
3import {
4 ApprovalRequestId,
5 EventId,
6 ProviderApprovalDecision,
47 ProviderRuntimeEvent,
58 ProviderSession,
9 ProviderSessionId,
10 ProviderThreadId,
11 ProviderTurnId,
612 ProviderTurnStartResult,
713} from "@t3tools/contracts";
814import { Effect, Queue, Stream } from "effect";
......@@ -40,7 +46,15 @@ export interface TestProviderAdapterHarness {
4046 sessionId: string,
4147 response: TestTurnResponse,
4248 ) => Effect.Effect<void, ProviderAdapterSessionNotFoundError>;
49 readonly queueTurnResponseForNextSession: (
50 response: TestTurnResponse,
51 ) => Effect.Effect<void, never>;
4352 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 }>;
4458}
4559
4660const PROVIDER = "codex" as const;
......@@ -64,6 +78,15 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
6478 const runtimeEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();
6579 let sessionCount = 0;
6680 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
6891 const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event);
6992
......@@ -78,8 +101,8 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
78101 }
79102
80103 sessionCount += 1;
81 const sessionId = `test-session-${sessionCount}`;
82 const threadId = `test-thread-${sessionCount}`;
104 const sessionId = ProviderSessionId.makeUnsafe(`test-session-${sessionCount}`);
105 const threadId = ProviderThreadId.makeUnsafe(`test-thread-${sessionCount}`);
83106 const createdAt = nowIso();
84107
85108 const session: ProviderSession = {
......@@ -99,7 +122,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
99122 turns: [],
100123 },
101124 turnCount: 0,
102 queuedResponses: [],
125 queuedResponses: queuedResponsesForNextSession.splice(0),
103126 rollbackCalls: [],
104127 });
105128
......@@ -115,7 +138,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
115138
116139 state.turnCount += 1;
117140 const turnCount = state.turnCount;
118 const turnId = `turn-${turnCount}`;
141 const turnId = ProviderTurnId.makeUnsafe(`turn-${turnCount}`);
119142
120143 const response = state.queuedResponses.shift();
121144 if (!response) {
......@@ -182,7 +205,7 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
182205 if (deferredTurnCompletedEvents.length === 0) {
183206 yield* emit({
184207 type: "turn.completed",
185 eventId: randomUUID(),
208 eventId: EventId.makeUnsafe(randomUUID()),
186209 provider: PROVIDER,
187210 sessionId: input.sessionId,
188211 createdAt: nowIso(),
......@@ -209,9 +232,20 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
209232
210233 const respondToRequest: ProviderAdapterShape<ProviderAdapterError>["respondToRequest"] = (
211234 sessionId,
212 _requestId,
213 _decision,
214 ) => (sessions.has(sessionId) ? Effect.void : missingSessionEffect(sessionId));
235 requestId,
236 decision,
237 ) =>
238 sessions.has(sessionId)
239 ? Effect.sync(() => {
240 const existing = approvalResponsesBySession.get(sessionId) ?? [];
241 existing.push({
242 sessionId,
243 requestId,
244 decision,
245 });
246 approvalResponsesBySession.set(sessionId, existing);
247 })
248 : missingSessionEffect(sessionId);
215249
216250 const stopSession: ProviderAdapterShape<ProviderAdapterError>["stopSession"] = (sessionId) =>
217251 Effect.sync(() => {
......@@ -295,6 +329,13 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
295329 ),
296330 );
297331
332 const queueTurnResponseForNextSession = (
333 response: TestTurnResponse,
334 ): Effect.Effect<void, never> =>
335 Effect.sync(() => {
336 queuedResponsesForNextSession.push(response);
337 });
338
298339 const getRollbackCalls = (sessionId: string): ReadonlyArray<number> => {
299340 const state = sessions.get(sessionId);
300341 if (!state) {
......@@ -303,9 +344,25 @@ export const makeTestProviderAdapterHarness = Effect.gen(function* () {
303344 return [...state.rollbackCalls];
304345 };
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
306361 return {
307362 adapter,
308363 queueTurnResponse,
364 queueTurnResponseForNextSession,
309365 getRollbackCalls,
366 getApprovalResponses,
310367 } satisfies TestProviderAdapterHarness;
311368});
apps/server/integration/fixtures/providerRuntime.ts+15-7
......@@ -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
310const PROVIDER = "codex" as const;
4const SESSION_ID = "fixture-session";
5const THREAD_ID = "fixture-thread";
6const TURN_ID = "fixture-turn";
11const SESSION_ID = ProviderSessionId.makeUnsafe("fixture-session");
12const THREAD_ID = ProviderThreadId.makeUnsafe("fixture-thread");
13const TURN_ID = ProviderTurnId.makeUnsafe("fixture-turn");
14const REQUEST_ID = ApprovalRequestId.makeUnsafe("req-1");
715
816function baseEvent(eventId: string, createdAt: string): Pick<
917 ProviderRuntimeEvent,
1018 "eventId" | "provider" | "sessionId" | "createdAt"
1119> {
1220 return {
13 eventId,
21 eventId: EventId.makeUnsafe(eventId),
1422 provider: PROVIDER,
1523 sessionId: SESSION_ID,
1624 createdAt,
......@@ -100,7 +108,7 @@ export const codexTurnApprovalFixture = [
100108 ...baseEvent("evt-22", "2026-02-23T00:02:00.100Z"),
101109 threadId: THREAD_ID,
102110 turnId: TURN_ID,
103 requestId: "req-1",
111 requestId: REQUEST_ID,
104112 requestKind: "command",
105113 detail: "Please approve command",
106114 },
......@@ -109,7 +117,7 @@ export const codexTurnApprovalFixture = [
109117 ...baseEvent("evt-23", "2026-02-23T00:02:00.200Z"),
110118 threadId: THREAD_ID,
111119 turnId: TURN_ID,
112 requestId: "req-1",
120 requestId: REQUEST_ID,
113121 requestKind: "command",
114122 decision: "accept",
115123 },
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 @@
1import type { ProviderRuntimeEvent } from "@t3tools/contracts";
1import type { ProviderRuntimeEvent, ProviderSessionId } from "@t3tools/contracts";
22import { NodeServices } from "@effect/platform-node";
33import { it, assert } from "@effect/vitest";
44import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect";
......@@ -92,7 +92,7 @@ const collectEventsDuring = <A, E, R>(
9292const runTurn = (input: {
9393 readonly provider: ProviderServiceShape;
9494 readonly harness: TestProviderAdapterHarness;
95 readonly sessionId: string;
95 readonly sessionId: ProviderSessionId;
9696 readonly userText: string;
9797 readonly response: TestTurnResponse;
9898}) =>
apps/server/src/checkpointing/Layers/CheckpointStore.ts+21-29
......@@ -132,14 +132,12 @@ const makeCheckpointStore = Effect.gen(function* () {
132132 });
133133 const treeOid = writeTreeResult.stdout.trim();
134134 if (treeOid.length === 0) {
135 return yield* Effect.fail(
136 new GitCommandError({
137 operation,
138 command: "git write-tree",
139 cwd: normalizedCwd,
140 detail: "git write-tree returned an empty tree oid.",
141 }),
142 );
135 return yield* new GitCommandError({
136 operation,
137 command: "git write-tree",
138 cwd: normalizedCwd,
139 detail: "git write-tree returned an empty tree oid.",
140 });
143141 }
144142
145143 const message = `t3 checkpoint ref=${checkpointRef}`;
......@@ -151,14 +149,12 @@ const makeCheckpointStore = Effect.gen(function* () {
151149 });
152150 const commitOid = commitTreeResult.stdout.trim();
153151 if (commitOid.length === 0) {
154 return yield* Effect.fail(
155 new GitCommandError({
156 operation,
157 command: "git commit-tree",
158 cwd: normalizedCwd,
159 detail: "git commit-tree returned an empty commit oid.",
160 }),
161 );
152 return yield* new GitCommandError({
153 operation,
154 command: "git commit-tree",
155 cwd: normalizedCwd,
156 detail: "git commit-tree returned an empty commit oid.",
157 });
162158 }
163159
164160 yield* git.execute({
......@@ -183,11 +179,9 @@ const makeCheckpointStore = Effect.gen(function* () {
183179 });
184180
185181 const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) =>
186 Effect.gen(function* () {
187 return yield* resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe(
188 Effect.map((commit) => commit !== null),
189 );
190 });
182 resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe(
183 Effect.map((commit) => commit !== null),
184 );
191185
192186 const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = (input) =>
193187 Effect.gen(function* () {
......@@ -241,14 +235,12 @@ const makeCheckpointStore = Effect.gen(function* () {
241235 }
242236
243237 if (!fromCommitOid || !toCommitOid) {
244 return yield* Effect.fail(
245 new GitCommandError({
246 operation,
247 command: "git diff",
248 cwd: input.cwd,
249 detail: "Checkpoint ref is unavailable for diff operation.",
250 }),
251 );
238 return yield* new GitCommandError({
239 operation,
240 command: "git diff",
241 cwd: input.cwd,
242 detail: "Checkpoint ref is unavailable for diff operation.",
243 });
252244 }
253245
254246 const result = yield* git.execute({
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+4-6
......@@ -103,12 +103,10 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
103103 const existingRuntime = Option.getOrUndefined(existing);
104104 const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId;
105105 if (!resolvedThreadId) {
106 return yield* Effect.fail(
107 new ProviderValidationError({
108 operation: "ProviderSessionDirectory.upsert",
109 issue: "threadId must be a non-empty string.",
110 }),
111 );
106 return yield* new ProviderValidationError({
107 operation: "ProviderSessionDirectory.upsert",
108 issue: "threadId must be a non-empty string.",
109 });
112110 }
113111
114112 const now = new Date().toISOString();
apps/server/src/wsServer.test.ts+50-28
......@@ -189,7 +189,13 @@ function waitForMessage(ws: WebSocket): Promise<unknown> {
189189
190190async function sendRequest(ws: WebSocket, method: string, params?: unknown): Promise<WsResponse> {
191191 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 });
193199 ws.send(message);
194200
195201 // Wait for response with matching id
......@@ -198,6 +204,9 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro
198204 if (parsed.id === id) {
199205 return parsed as WsResponse;
200206 }
207 if (parsed.id === "unknown") {
208 return parsed as WsResponse;
209 }
201210 }
202211}
203212
......@@ -658,18 +667,27 @@ describe("WebSocket Server", () => {
658667
659668 const response = await sendRequest(ws, "nonexistent.method");
660669 expect(response.error).toBeDefined();
661 expect(response.error!.message).toContain("Unknown method");
670 expect(response.error!.message).toContain("Invalid request format");
662671 });
663672
664673 it("keeps orchestration domain push behavior for provider runtime events", async () => {
665674 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
675 const sessionId = "sess-test";
666676 const emitRuntimeEvent = (event: ProviderRuntimeEvent) => {
667677 Effect.runSync(PubSub.publish(runtimeEventPubSub, event));
668678 };
669679 const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
670680 const providerService: ProviderServiceShape = {
671 startSession: () => unsupported(),
672 sendTurn: () => unsupported(),
681 startSession: () =>
682 Effect.succeed({
683 sessionId,
684 provider: "codex",
685 status: "ready",
686 threadId: "provider-thread-1",
687 createdAt: new Date().toISOString(),
688 updatedAt: new Date().toISOString(),
689 }),
690 sendTurn: () => Effect.succeed({ threadId: "provider-thread-1", turnId: "provider-turn-1" }),
673691 interruptTurn: () => unsupported(),
674692 respondToRequest: () => unsupported(),
675693 stopSession: () => unsupported(),
......@@ -716,29 +734,30 @@ describe("WebSocket Server", () => {
716734 });
717735 expect(createThreadResponse.error).toBeUndefined();
718736
719 const setSessionResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
720 type: "thread.session",
721 commandId: "cmd-ws-runtime-session-set",
737 const startTurnResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
738 type: "thread.turn.start",
739 commandId: "cmd-ws-runtime-turn-start",
722740 threadId: "thread-1",
723 session: {
724 sessionId: "sess-test",
725 provider: "codex",
726 status: "ready",
727 threadId: "thread-1",
728 activeTurnId: null,
729 createdAt,
730 updatedAt: createdAt,
731 lastError: null,
741 message: {
742 messageId: "msg-ws-runtime-1",
743 role: "user",
744 text: "hello",
745 attachments: [],
732746 },
733747 createdAt,
734748 });
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
737756 emitRuntimeEvent({
738757 type: "message.delta",
739758 eventId: "evt-ws-runtime-message-delta",
740759 provider: "codex",
741 sessionId: "sess-test",
760 sessionId,
742761 createdAt: new Date().toISOString(),
743762 turnId: "turn-1",
744763 itemId: "item-1",
......@@ -746,13 +765,16 @@ describe("WebSocket Server", () => {
746765 });
747766
748767 const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => {
749 const event = push.data as { type?: string; payload?: { id?: string; text?: string } };
750 return event.type === "message.sent" && event.payload?.id === "assistant:item-1";
768 const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } };
769 return event.type === "thread.message-sent" && event.payload?.messageId === "assistant:item-1";
751770 });
752771
753 const domainEvent = domainPush.data as { type: string; payload: { id: string; text: string } };
754 expect(domainEvent.type).toBe("message.sent");
755 expect(domainEvent.payload.id).toBe("assistant:item-1");
772 const domainEvent = domainPush.data as {
773 type: string;
774 payload: { messageId: string; text: string };
775 };
776 expect(domainEvent.type).toBe("thread.message-sent");
777 expect(domainEvent.payload.messageId).toBe("assistant:item-1");
756778 expect(domainEvent.payload.text).toBe("hello from runtime");
757779 });
758780
......@@ -895,7 +917,7 @@ describe("WebSocket Server", () => {
895917 expect(response!.error!.message).toContain("Invalid request format");
896918 });
897919
898 it("returns unknown method for removed projects CRUD methods", async () => {
920 it("returns errors for removed projects CRUD methods", async () => {
899921 server = createTestServer({ cwd: "/test" });
900922 await server.start();
901923 const addr = server.httpServer.address();
......@@ -907,19 +929,19 @@ describe("WebSocket Server", () => {
907929
908930 const listResponse = await sendRequest(ws, WS_METHODS.projectsList);
909931 expect(listResponse.result).toBeUndefined();
910 expect(listResponse.error?.message).toContain("Unknown method");
932 expect(listResponse.error?.message).toContain("Invalid request format");
911933
912934 const addResponse = await sendRequest(ws, WS_METHODS.projectsAdd, {
913935 cwd: "/tmp/project-a",
914936 });
915937 expect(addResponse.result).toBeUndefined();
916 expect(addResponse.error?.message).toContain("Unknown method");
938 expect(addResponse.error?.message).toContain("Invalid request format");
917939
918940 const removeResponse = await sendRequest(ws, WS_METHODS.projectsRemove, {
919941 id: "project-a",
920942 });
921943 expect(removeResponse.result).toBeUndefined();
922 expect(removeResponse.error?.message).toContain("Unknown method");
944 expect(removeResponse.error?.message).toContain("Invalid request format");
923945 });
924946
925947 it("supports projects.searchEntries", async () => {
......@@ -989,7 +1011,7 @@ describe("WebSocket Server", () => {
9891011 expect(pullResponse.error?.message).not.toContain("Unknown method");
9901012 });
9911013
992 it("returns unknown method for git.status", async () => {
1014 it("returns error for git.status", async () => {
9931015 const gitManager = {
9941016 status: vi.fn().mockResolvedValue({
9951017 branch: "feature/test",
apps/server/src/wsServer.ts+72-22
......@@ -132,6 +132,45 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {
132132 return undefined;
133133}
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
135174type EffectRuntime = ManagedRuntime.ManagedRuntime<
136175 | OrchestrationEngineService
137176 | ProjectionSnapshotQuery
......@@ -360,9 +399,19 @@ export function createServer(options: ServerOptions) {
360399 });
361400
362401 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
363412 let request: WebSocketRequest;
364413 try {
365 request = Schema.decodeUnknownSync(Schema.fromJsonString(WebSocketRequest))(raw);
414 request = Schema.decodeUnknownSync(Schema.fromJsonString(WebSocketRequest))(messageText);
366415 } catch {
367416 const errorResponse: WsResponse = {
368417 id: "unknown",
......@@ -406,17 +455,18 @@ export function createServer(options: ServerOptions) {
406455 }
407456
408457 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));
410460 if (Option.isNone(blob)) {
411461 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}.`,
413463 );
414464 }
415465
416466 return {
417 threadId: request.body.threadId,
418 fromTurnCount: request.body.fromTurnCount,
419 toTurnCount: request.body.toTurnCount,
467 threadId: body.threadId,
468 fromTurnCount: body.fromTurnCount,
469 toTurnCount: body.toTurnCount,
420470 diff: blob.value?.diff,
421471 };
422472 }
......@@ -434,7 +484,7 @@ export function createServer(options: ServerOptions) {
434484 );
435485
436486 case WS_METHODS.projectsSearchEntries:
437 return searchWorkspaceEntries(request.body);
487 return searchWorkspaceEntries(stripRequestTag(request.body));
438488
439489 case WS_METHODS.shellOpenInEditor: {
440490 const body = request.body;
......@@ -500,48 +550,48 @@ export function createServer(options: ServerOptions) {
500550 // }
501551
502552 case WS_METHODS.gitPull:
503 return pullGitBranch(request.body);
553 return pullGitBranch(stripRequestTag(request.body));
504554
505555 case WS_METHODS.gitRunStackedAction:
506 return gitManager.runStackedAction(request.body);
556 return gitManager.runStackedAction(stripRequestTag(request.body));
507557 case WS_METHODS.gitListBranches:
508 return listGitBranches(request.body);
558 return listGitBranches(stripRequestTag(request.body));
509559
510560 case WS_METHODS.gitCreateWorktree:
511 return createGitWorktree(request.body);
561 return createGitWorktree(stripRequestTag(request.body));
512562
513563 case WS_METHODS.gitRemoveWorktree:
514 return removeGitWorktree(request.body);
564 return removeGitWorktree(stripRequestTag(request.body));
515565
516566 case WS_METHODS.gitCreateBranch:
517 return createGitBranch(request.body);
567 return createGitBranch(stripRequestTag(request.body));
518568
519569 case WS_METHODS.gitCheckout:
520 return checkoutGitBranch(request.body);
570 return checkoutGitBranch(stripRequestTag(request.body));
521571
522572 case WS_METHODS.gitInit:
523 return initGitRepo(request.body);
573 return initGitRepo(stripRequestTag(request.body));
524574
525575 case WS_METHODS.terminalOpen:
526 return terminalManager.open(request.body);
576 return terminalManager.open(stripRequestTag(request.body));
527577
528578 case WS_METHODS.terminalWrite:
529 await terminalManager.write(request.body);
579 await terminalManager.write(stripRequestTag(request.body));
530580 return undefined;
531581
532582 case WS_METHODS.terminalResize:
533 await terminalManager.resize(request.body);
583 await terminalManager.resize(stripRequestTag(request.body));
534584 return undefined;
535585
536586 case WS_METHODS.terminalClear:
537 await terminalManager.clear(request.body);
587 await terminalManager.clear(stripRequestTag(request.body));
538588 return undefined;
539589
540590 case WS_METHODS.terminalRestart:
541 return terminalManager.restart(request.body);
591 return terminalManager.restart(stripRequestTag(request.body));
542592
543593 case WS_METHODS.terminalClose:
544 await terminalManager.close(request.body);
594 await terminalManager.close(stripRequestTag(request.body));
545595 return undefined;
546596
547597 case WS_METHODS.serverGetConfig:
......@@ -551,7 +601,7 @@ export function createServer(options: ServerOptions) {
551601 };
552602
553603 case WS_METHODS.serverUpsertKeybinding:
554 keybindingsConfig = upsertKeybindingRule(logger, request.body);
604 keybindingsConfig = upsertKeybindingRule(logger, stripRequestTag(request.body));
555605 return {
556606 keybindings: keybindingsConfig,
557607 };
apps/server/tsconfig.json+2-2
......@@ -12,6 +12,6 @@
1212 }
1313 ]
1414 },
15 "include": ["src", "tsdown.config.ts", "scripts"],
16 "exclude": ["src/**/*.test.ts", "integration"]
15 "include": ["src", "tsdown.config.ts", "scripts", "integration"],
16 "exclude": ["src/**/*.test.ts"]
1717}
apps/server/tsdown.config.ts+1-1
......@@ -6,7 +6,7 @@ export default defineConfig({
66 outDir: "dist",
77 sourcemap: true,
88 clean: true,
9 noExternal: ["@t3tools/contracts", "zod"],
9 noExternal: ["@t3tools/contracts"],
1010 inlineOnly: false,
1111 banner: {
1212 js: "#!/usr/bin/env node\n",
apps/web/package.json+2-2
......@@ -21,6 +21,7 @@
2121 "@xterm/addon-fit": "^0.11.0",
2222 "@xterm/xterm": "^6.0.0",
2323 "class-variance-authority": "^0.7.1",
24 "effect": "4.0.0-beta.10",
2425 "highlight.js": "^11.11.1",
2526 "lucide-react": "^0.564.0",
2627 "react": "^19.0.0",
......@@ -28,8 +29,7 @@
2829 "react-markdown": "^10.1.0",
2930 "rehype-highlight": "^7.0.2",
3031 "remark-gfm": "^4.0.1",
31 "tailwind-merge": "^3.4.0",
32 "zod": "^3.24.1"
32 "tailwind-merge": "^3.4.0"
3333 },
3434 "devDependencies": {
3535 "@tailwindcss/vite": "^4.0.0",
apps/web/src/appSettings.ts+18-12
......@@ -1,17 +1,20 @@
11import { useCallback, useSyncExternalStore } from "react";
2import { z } from "zod";
2import { Option, Schema } from "effect";
33
44const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
55
6const appSettingsSchema = z.object({
7 codexBinaryPath: z.string().trim().max(4096).default(""),
8 codexHomePath: z.string().trim().max(4096).default(""),
9 confirmThreadDelete: z.boolean().default(true),
6const AppSettingsSchema = Schema.Struct({
7 codexBinaryPath: Schema.NonEmptyString.check(Schema.isMaxLength(4096)).pipe(
8 Schema.withConstructorDefault(() => Option.some("")),
9 ),
10 codexHomePath: Schema.NonEmptyString.check(Schema.isMaxLength(4096)).pipe(
11 Schema.withConstructorDefault(() => Option.some("")),
12 ),
13 confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))),
1014});
15export type AppSettings = typeof AppSettingsSchema.Type;
1116
12export type AppSettings = z.infer<typeof appSettingsSchema>;
13
14const DEFAULT_APP_SETTINGS: AppSettings = appSettingsSchema.parse({});
17const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
1518
1619let listeners: Array<() => void> = [];
1720let cachedRawSettings: string | null | undefined;
......@@ -29,8 +32,7 @@ function parsePersistedSettings(value: string | null): AppSettings {
2932 }
3033
3134 try {
32 const parsed = JSON.parse(value) as unknown;
33 return appSettingsSchema.parse(parsed);
35 return Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value);
3436 } catch {
3537 return DEFAULT_APP_SETTINGS;
3638 }
......@@ -84,10 +86,14 @@ function subscribe(listener: () => void): () => void {
8486}
8587
8688export 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
8995 const updateSettings = useCallback((patch: Partial<AppSettings>) => {
90 const next = appSettingsSchema.parse({
96 const next = Schema.decodeSync(AppSettingsSchema)({
9197 ...getAppSettingsSnapshot(),
9298 ...patch,
9399 });
apps/web/src/components/BranchToolbar.logic.ts+1-1
......@@ -5,7 +5,7 @@ interface DeriveSyncedLocalBranchInput {
55 activeWorktreePath: string | null;
66 envMode: "local" | "worktree";
77 activeThreadBranch: string | null;
8 queryBranches: GitBranch[] | undefined;
8 queryBranches: ReadonlyArray<GitBranch> | undefined;
99}
1010
1111export function deriveSyncedLocalBranch({
apps/web/src/components/BranchToolbar.tsx+14-8
......@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
33import { useEffect, useState } from "react";
44
55import { useNativeApi } from "../hooks/useNativeApi";
6import { asThreadId, newCommandId } from "../lib/orchestrationIds";
67import {
78 gitBranchesQueryOptions,
89 gitCheckoutMutationOptions,
......@@ -95,11 +96,10 @@ export default function BranchToolbar({
9596 if (api) {
9697 void api.orchestration.dispatchCommand({
9798 type: "thread.meta.update",
98 commandId: crypto.randomUUID(),
99 threadId: activeThreadId,
99 commandId: newCommandId(),
100 threadId: asThreadId(activeThreadId),
100101 branch: syncedBranch,
101102 worktreePath: null,
102 createdAt: new Date().toISOString(),
103103 });
104104 }
105105 dispatch({
......@@ -127,17 +127,23 @@ export default function BranchToolbar({
127127 // If the effective cwd is about to change, stop the running session so the
128128 // next message creates a new one with the correct cwd.
129129 const sessionId = activeThread?.session?.sessionId;
130 if (sessionId && worktreePath !== activeWorktreePath) {
131 void api?.providers.stopSession({ sessionId }).catch(() => undefined);
130 if (sessionId && worktreePath !== activeWorktreePath && api) {
131 void api.orchestration
132 .dispatchCommand({
133 type: "thread.session.stop",
134 commandId: newCommandId(),
135 threadId: asThreadId(activeThreadId),
136 createdAt: new Date().toISOString(),
137 })
138 .catch(() => undefined);
132139 }
133140 if (api) {
134141 void api.orchestration.dispatchCommand({
135142 type: "thread.meta.update",
136 commandId: crypto.randomUUID(),
137 threadId: activeThreadId,
143 commandId: newCommandId(),
144 threadId: asThreadId(activeThreadId),
138145 branch,
139146 worktreePath,
140 createdAt: new Date().toISOString(),
141147 });
142148 }
143149 dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath });
apps/web/src/components/ChatView.tsx+76-288
......@@ -5,12 +5,10 @@ import {
55 type ProjectEntry,
66 type ProjectScript,
77 ModelSlug,
8 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
98 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
109 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
1110 type ResolvedKeybindingsConfig,
1211 type ProviderApprovalDecision,
13 type ProviderSendTurnAttachmentInput,
1412} from "@t3tools/contracts";
1513import {
1614 type ClipboardEvent,
......@@ -36,8 +34,6 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer
3634
3735import { isElectron } from "../env";
3836import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
39import { useAppSettings } from "../appSettings";
40import { buildBootstrapInput } from "../historyBootstrap";
4137import {
4238 type ComposerTriggerKind,
4339 detectComposerTrigger,
......@@ -116,6 +112,13 @@ import {
116112 setupProjectScript,
117113} from "~/projectScripts";
118114import { Toggle } from "./ui/toggle";
115import {
116 asApprovalRequestId,
117 asProjectId,
118 asThreadId,
119 newCommandId,
120 newMessageId,
121} from "~/lib/orchestrationIds";
119122
120123function formatMessageMeta(createdAt: string, duration: string | null): string {
121124 if (!duration) return formatTimestamp(createdAt);
......@@ -165,14 +168,6 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
165168 return "text-muted-foreground/40";
166169}
167170
168type SessionContinuityState = "resumed" | "new" | "fallback_new";
169
170interface EnsuredSessionInfo {
171 sessionId: string;
172 resolvedThreadId: string | null;
173 continuityState: SessionContinuityState;
174}
175
176171interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl"> {
177172 previewUrl: string;
178173 file: File;
......@@ -351,7 +346,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
351346 const rawSearch = useSearch({ strict: false });
352347 const api = useNativeApi();
353348 const { resolvedTheme } = useTheme();
354 const { settings: appSettings } = useAppSettings();
355349 const queryClient = useQueryClient();
356350 const createWorktreeMutation = useMutation(
357351 gitCreateWorktreeMutationOptions({ api, queryClient }),
......@@ -383,8 +377,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
383377 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
384378 const dragDepthRef = useRef(0);
385379 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
386 const checkpointHydrationSessionRequestRef = useRef(new Set<string>());
387 const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>());
388380
389381 const activeThread = state.threads.find((t) => t.id === threadId);
390382 const diffSearch = useMemo(
......@@ -393,9 +385,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
393385 );
394386 const diffOpen = diffSearch.diff === "1";
395387 const activeThreadId = activeThread?.id ?? null;
396 const activeSessionId = activeThread?.session?.sessionId ?? null;
397 const activeThreadRuntimeId =
398 activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;
399388 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
400389 const selectedModel = resolveModelSlug(
401390 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
......@@ -521,11 +510,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
521510 completionSummary,
522511 timelineEntries,
523512 ]);
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();
529513 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
530514 const composerTrigger = useMemo(
531515 () => detectComposerTrigger(prompt, composerCursor),
......@@ -820,17 +804,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
820804 scripts: input.nextScripts,
821805 });
822806
823 if (!isElectron || !api) return;
824
825 let scriptsPersisted = false;
807 if (!api) return;
826808 try {
827 const updated = await api.projects.updateScripts({
828 id: input.projectId,
809 await api.orchestration.dispatchCommand({
810 type: "project.meta.update",
811 commandId: newCommandId(),
812 projectId: asProjectId(input.projectId),
829813 scripts: input.nextScripts,
830814 });
831 scriptsPersisted = true;
832815
833 if (input.keybinding) {
816 if (isElectron && input.keybinding) {
834817 const keybindingUpdate = await api.server.upsertKeybinding({
835818 key: input.keybinding,
836819 command: input.keybindingCommand,
......@@ -843,24 +826,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
843826 : {
844827 cwd: input.projectCwd,
845828 keybindings: keybindingUpdate.keybindings,
846 },
829 },
847830 );
848831 }
849
850 dispatch({
851 type: "SET_PROJECT_SCRIPTS",
852 projectId: updated.project.id,
853 scripts: updated.project.scripts,
854 });
855832 } catch (error) {
856 if (scriptsPersisted) {
857 await api.projects
858 .updateScripts({
859 id: input.projectId,
860 scripts: input.previousScripts,
861 })
862 .catch(() => undefined);
863 }
864833 dispatch({
865834 type: "SET_PROJECT_SCRIPTS",
866835 projectId: input.projectId,
......@@ -946,17 +915,25 @@ export default function ChatView({ threadId }: ChatViewProps) {
946915 scheduleComposerFocus();
947916 if (!api) return;
948917
949 const sessionIds = state.threads
950 .map((t) => t.session)
951 .filter((s): s is NonNullable<typeof s> => s !== null && s.status !== "closed")
952 .map((s) => s.sessionId);
918 const runningThreadIds = state.threads
919 .filter((thread) => thread.session !== null && thread.session.status !== "closed")
920 .map((thread) => thread.id);
953921
954 if (sessionIds.length === 0) return;
922 if (runningThreadIds.length === 0) return;
955923
956924 setIsSwitchingRuntimeMode(true);
957925 try {
958926 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 ),
960937 );
961938 } finally {
962939 setIsSwitchingRuntimeMode(false);
......@@ -1321,167 +1298,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
13211298 focusComposer();
13221299 };
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
14851301 const onRevertToTurnCount = useCallback(
14861302 async (turnCount: number) => {
14871303 if (!api || !activeThread || isRevertingCheckpoint) {
......@@ -1505,13 +1321,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
15051321 setIsRevertingCheckpoint(true);
15061322 setThreadError(activeThread.id, null);
15071323 try {
1508 const sessionInfo = await ensureSession();
1509 if (!sessionInfo) {
1510 return;
1511 }
1512 await api.providers.revertToCheckpoint({
1513 sessionId: sessionInfo.sessionId,
1324 await api.orchestration.dispatchCommand({
1325 type: "thread.checkpoint.revert",
1326 commandId: newCommandId(),
1327 threadId: asThreadId(activeThread.id),
15141328 turnCount,
1329 createdAt: new Date().toISOString(),
15151330 });
15161331 } catch (err) {
15171332 setThreadError(
......@@ -1525,8 +1340,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
15251340 [
15261341 activeThread,
15271342 api,
1528 dispatch,
1529 ensureSession,
15301343 isConnecting,
15311344 isRevertingCheckpoint,
15321345 isSending,
......@@ -1544,7 +1357,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
15441357 const composerImagesSnapshot = [...composerImages];
15451358
15461359 // On first message: lock in branch + create worktree if needed.
1547 let sessionCwd: string | undefined;
15481360 if (
15491361 activeThread.messages.length === 0 &&
15501362 activeThread.branch &&
......@@ -1558,17 +1370,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
15581370 branch: activeThread.branch,
15591371 newBranch,
15601372 });
1561 sessionCwd = result.worktree.path;
1562 if (api) {
1563 await api.orchestration.dispatchCommand({
1564 type: "thread.meta.update",
1565 commandId: crypto.randomUUID(),
1566 threadId: activeThread.id,
1567 branch: result.worktree.branch,
1568 worktreePath: result.worktree.path,
1569 createdAt: new Date().toISOString(),
1570 });
1571 }
1373 await api.orchestration.dispatchCommand({
1374 type: "thread.meta.update",
1375 commandId: newCommandId(),
1376 threadId: asThreadId(activeThread.id),
1377 branch: result.worktree.branch,
1378 worktreePath: result.worktree.path,
1379 });
15721380 const setupScript = setupProjectScript(activeProject.scripts);
15731381 if (setupScript) {
15741382 void runProjectScript(setupScript, {
......@@ -1598,51 +1406,31 @@ export default function ChatView({ threadId }: ChatViewProps) {
15981406 if (api) {
15991407 await api.orchestration.dispatchCommand({
16001408 type: "thread.meta.update",
1601 commandId: crypto.randomUUID(),
1602 threadId: activeThread.id,
1409 commandId: newCommandId(),
1410 threadId: asThreadId(activeThread.id),
16031411 title,
1604 createdAt: new Date().toISOString(),
16051412 });
16061413 }
16071414 }
16081415
16091416 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;
16321417 promptRef.current = "";
16331418 setPrompt("");
16341419 setComposerImages([]);
16351420 setComposerCursor(0);
16361421 setComposerHighlightedItemId(null);
16371422
1638 const sessionInfo = await ensureSession(sessionCwd);
1639 if (!sessionInfo) return;
1640
16411423 setIsSending(true);
16421424 try {
16431425 const turnAttachments = await Promise.all(
16441426 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 }> => ({
16461434 type: "image",
16471435 name: image.name,
16481436 mimeType: image.mimeType,
......@@ -1651,23 +1439,19 @@ export default function ChatView({ threadId }: ChatViewProps) {
16511439 }),
16521440 ),
16531441 );
1654 const shouldBootstrap =
1655 previousMessages.length > 0 &&
1656 (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new");
1657 const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT;
1658 const input = shouldBootstrap
1659 ? buildBootstrapInput(
1660 previousMessages,
1661 latestPromptForBootstrap,
1662 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
1663 ).text
1664 : trimmed || undefined;
1665 await api.providers.sendTurn({
1666 sessionId: sessionInfo.sessionId,
1667 ...(input ? { input } : {}),
1668 ...(turnAttachments.length > 0 ? { attachments: turnAttachments } : {}),
1442 await api.orchestration.dispatchCommand({
1443 type: "thread.turn.start",
1444 commandId: newCommandId(),
1445 threadId: asThreadId(activeThread.id),
1446 message: {
1447 messageId: newMessageId(),
1448 role: "user",
1449 text: trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT,
1450 attachments: turnAttachments,
1451 },
16691452 model: selectedModel || undefined,
16701453 effort: selectedEffort || undefined,
1454 createdAt: new Date().toISOString(),
16711455 });
16721456 } catch (err) {
16731457 setThreadError(
......@@ -1680,25 +1464,30 @@ export default function ChatView({ threadId }: ChatViewProps) {
16801464 };
16811465
16821466 const onInterrupt = async () => {
1683 if (!api || !activeThread?.session) return;
1684 await api.providers.interruptTurn({
1685 sessionId: activeThread.session.sessionId,
1686 turnId: activeThread.session.activeTurnId,
1467 if (!api || !activeThread) return;
1468 await api.orchestration.dispatchCommand({
1469 type: "thread.turn.interrupt",
1470 commandId: newCommandId(),
1471 threadId: asThreadId(activeThread.id),
1472 createdAt: new Date().toISOString(),
16871473 });
16881474 };
16891475
16901476 const onRespondToApproval = useCallback(
16911477 async (requestId: string, decision: ProviderApprovalDecision) => {
1692 if (!api || !activeSessionId || !activeThreadId) return;
1478 if (!api || !activeThreadId) return;
16931479
16941480 setRespondingRequestIds((existing) =>
16951481 existing.includes(requestId) ? existing : [...existing, requestId],
16961482 );
16971483 try {
1698 await api.providers.respondToRequest({
1699 sessionId: activeSessionId,
1700 requestId,
1484 await api.orchestration.dispatchCommand({
1485 type: "thread.approval.respond",
1486 commandId: newCommandId(),
1487 threadId: asThreadId(activeThreadId),
1488 requestId: asApprovalRequestId(requestId),
17011489 decision,
1490 createdAt: new Date().toISOString(),
17021491 });
17031492 } catch (err) {
17041493 dispatch({
......@@ -1710,7 +1499,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
17101499 setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId));
17111500 }
17121501 },
1713 [activeSessionId, activeThreadId, api, dispatch],
1502 [activeThreadId, api, dispatch],
17141503 );
17151504
17161505 const onModelSelect = useCallback(
......@@ -1719,10 +1508,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
17191508 if (api) {
17201509 void api.orchestration.dispatchCommand({
17211510 type: "thread.meta.update",
1722 commandId: crypto.randomUUID(),
1723 threadId: activeThread.id,
1511 commandId: newCommandId(),
1512 threadId: asThreadId(activeThread.id),
17241513 model: resolveModelSlug(model),
1725 createdAt: new Date().toISOString(),
17261514 });
17271515 }
17281516 scheduleComposerFocus();
apps/web/src/components/DiffPanel.tsx+1-5
......@@ -99,9 +99,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
9999 );
100100 const activeThreadId = routeThreadId;
101101 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;
105102 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
106103 useTurnDiffSummaries(activeThread);
107104 const orderedTurnDiffSummaries = useMemo(
......@@ -173,8 +170,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
173170 }, [orderedTurnDiffSummaries, selectedTurn]);
174171 const activeCheckpointDiffQuery = useQuery(
175172 checkpointDiffQueryOptions(api, {
176 sessionId: activeSessionId,
177 threadRuntimeId: activeThreadRuntimeId,
173 threadId: activeThreadId,
178174 fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null,
179175 toTurnCount: activeCheckpointRange?.toTurnCount ?? null,
180176 cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope,
apps/web/src/components/Sidebar.tsx+35-31
......@@ -7,6 +7,7 @@ import { useAppSettings } from "../appSettings";
77import { isElectron } from "../env";
88import { APP_STAGE_LABEL } from "../branding";
99import { DEFAULT_MODEL } from "../model-logic";
10import { asProjectId, asThreadId, newCommandId, newProjectId, newThreadId } from "../lib/orchestrationIds";
1011import { useStore } from "../store";
1112import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
1213import { type Thread } from "../types";
......@@ -133,14 +134,14 @@ export default function Sidebar() {
133134 },
134135 ) => {
135136 if (!api) return;
136 const threadId = crypto.randomUUID();
137 const threadId = newThreadId();
137138 const createdAt = new Date().toISOString();
138139 const model = state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL;
139140 void api.orchestration.dispatchCommand({
140141 type: "thread.create",
141 commandId: crypto.randomUUID(),
142 commandId: newCommandId(),
142143 threadId,
143 projectId,
144 projectId: asProjectId(projectId),
144145 title: "New thread",
145146 model,
146147 branch: options?.branch ?? null,
......@@ -152,7 +153,7 @@ export default function Sidebar() {
152153 params: { threadId },
153154 });
154155 },
155 [api, dispatch, navigate, state.projects],
156 [api, navigate, state.projects],
156157 );
157158
158159 const focusMostRecentThreadForProject = useCallback(
......@@ -188,16 +189,19 @@ export default function Sidebar() {
188189 return;
189190 }
190191
191 const result = await api.projects.add({ cwd });
192 const projects = await api.projects.list();
193 dispatch({ type: "SYNC_PROJECTS", projects });
194
195 const hasThread = state.threads.some((thread) => thread.projectId === result.project.id);
196 if (hasThread) {
197 focusMostRecentThreadForProject(result.project.id);
198 return;
199 }
200 handleNewThread(result.project.id);
192 const projectId = newProjectId();
193 const createdAt = new Date().toISOString();
194 const title = cwd.split(/[/\\]/).filter(Boolean).at(-1) ?? cwd;
195 await api.orchestration.dispatchCommand({
196 type: "project.create",
197 commandId: newCommandId(),
198 projectId,
199 title,
200 workspaceRoot: cwd,
201 defaultModel: DEFAULT_MODEL,
202 createdAt,
203 });
204 handleNewThread(projectId);
201205 } finally {
202206 setIsAddingProject(false);
203207 setNewCwd("");
......@@ -206,7 +210,6 @@ export default function Sidebar() {
206210 },
207211 [
208212 api,
209 dispatch,
210213 focusMostRecentThreadForProject,
211214 handleNewThread,
212215 isAddingProject,
......@@ -267,15 +270,15 @@ export default function Sidebar() {
267270 ].join("\n"),
268271 ));
269272
270 // Stop active session if running
271 if (thread.session?.sessionId) {
272 try {
273 await api.providers.stopSession({
274 sessionId: thread.session.sessionId,
275 });
276 } catch {
277 // Session may already be stopped
278 }
273 if (thread.session && thread.session.status !== "closed") {
274 await api.orchestration
275 .dispatchCommand({
276 type: "thread.session.stop",
277 commandId: newCommandId(),
278 threadId: asThreadId(threadId),
279 createdAt: new Date().toISOString(),
280 })
281 .catch(() => undefined);
279282 }
280283
281284 try {
......@@ -291,9 +294,8 @@ export default function Sidebar() {
291294 const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null;
292295 await api.orchestration.dispatchCommand({
293296 type: "thread.delete",
294 commandId: crypto.randomUUID(),
295 threadId,
296 createdAt: new Date().toISOString(),
297 commandId: newCommandId(),
298 threadId: asThreadId(threadId),
297299 });
298300 if (shouldNavigateToFallback) {
299301 if (fallbackThreadId) {
......@@ -369,9 +371,11 @@ export default function Sidebar() {
369371 if (!confirmed) return;
370372
371373 try {
372 await api.projects.remove({ id: projectId });
373 const projects = await api.projects.list();
374 dispatch({ type: "SYNC_PROJECTS", projects });
374 await api.orchestration.dispatchCommand({
375 type: "project.delete",
376 commandId: newCommandId(),
377 projectId: asProjectId(projectId),
378 });
375379 } catch (error) {
376380 const message =
377381 error instanceof Error ? error.message : "Unknown error deleting project.";
......@@ -383,7 +387,7 @@ export default function Sidebar() {
383387 });
384388 }
385389 },
386 [api, dispatch, state.projects, state.threads],
390 [api, state.projects, state.threads],
387391 );
388392
389393 useEffect(() => {
apps/web/src/lib/gitReactQuery.ts+4-2
......@@ -152,15 +152,17 @@ export function gitCreateWorktreeMutationOptions(input: {
152152 cwd,
153153 branch,
154154 newBranch,
155 path,
155156 }: {
156157 cwd: string;
157158 branch: string;
158159 newBranch: string;
160 path?: string | null;
159161 }) => {
160162 if (!input.api) {
161163 throw new Error("Git worktree creation is unavailable.");
162164 }
163 return input.api.git.createWorktree({ cwd, branch, newBranch });
165 return input.api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null });
164166 },
165167 onSettled: async () => {
166168 await invalidateGitQueries(input.queryClient);
......@@ -177,7 +179,7 @@ export function gitRemoveWorktreeMutationOptions(input: {
177179 if (!input.api) {
178180 throw new Error("Git worktree removal is unavailable.");
179181 }
180 return input.api.git.removeWorktree({ cwd, path, force });
182 return input.api.git.removeWorktree({ cwd, path, force: force ?? null });
181183 },
182184 onSettled: async () => {
183185 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
66describe("providerQueryKeys.checkpointDiff", () => {
77 it("includes cacheScope so reused turn counts do not collide", () => {
88 const baseInput = {
9 sessionId: "session-id",
10 threadRuntimeId: "thread-id",
9 threadId: "thread-id",
1110 fromTurnCount: 1,
1211 toTurnCount: 2,
1312 } as const;
......@@ -28,16 +27,15 @@ describe("providerQueryKeys.checkpointDiff", () => {
2827
2928describe("checkpointDiffQueryOptions", () => {
3029 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" });
3231 const api = {
33 providers: {
34 getCheckpointDiff,
32 orchestration: {
33 getTurnDiff,
3534 },
3635 } as unknown as NativeApi;
3736
3837 const options = checkpointDiffQueryOptions(api, {
39 sessionId: "session-id",
40 threadRuntimeId: "thread-id",
38 threadId: "thread-id",
4139 fromTurnCount: 3,
4240 toTurnCount: 4,
4341 cacheScope: "turn:abc",
......@@ -46,8 +44,8 @@ describe("checkpointDiffQueryOptions", () => {
4644 const queryClient = new QueryClient();
4745 await queryClient.fetchQuery(options);
4846
49 expect(getCheckpointDiff).toHaveBeenCalledWith({
50 sessionId: "session-id",
47 expect(getTurnDiff).toHaveBeenCalledWith({
48 threadId: "thread-id",
5149 fromTurnCount: 3,
5250 toTurnCount: 4,
5351 });
......@@ -55,8 +53,7 @@ describe("checkpointDiffQueryOptions", () => {
5553
5654 it("retries checkpoint-not-ready errors longer than generic failures", () => {
5755 const options = checkpointDiffQueryOptions(undefined, {
58 sessionId: "session-id",
59 threadRuntimeId: "thread-id",
56 threadId: "thread-id",
6057 fromTurnCount: 1,
6158 toTurnCount: 2,
6259 cacheScope: "turn:abc",
......@@ -86,8 +83,7 @@ describe("checkpointDiffQueryOptions", () => {
8683
8784 it("backs off longer for checkpoint-not-ready errors", () => {
8885 const options = checkpointDiffQueryOptions(undefined, {
89 sessionId: "session-id",
90 threadRuntimeId: "thread-id",
86 threadId: "thread-id",
9187 fromTurnCount: 1,
9288 toTurnCount: 2,
9389 cacheScope: "turn:abc",
apps/web/src/lib/providerReactQuery.ts+7-8
......@@ -1,9 +1,9 @@
11import type { NativeApi } from "@t3tools/contracts";
22import { queryOptions } from "@tanstack/react-query";
3import { asThreadId } from "./orchestrationIds";
34
45export interface CheckpointDiffQueryInput {
5 sessionId: string | null;
6 threadRuntimeId: string | null;
6 threadId: string | null;
77 fromTurnCount: number | null;
88 toTurnCount: number | null;
99 cacheScope?: string | null;
......@@ -15,8 +15,7 @@ export const providerQueryKeys = {
1515 [
1616 "providers",
1717 "checkpointDiff",
18 input.sessionId,
19 input.threadRuntimeId,
18 input.threadId,
2019 input.fromTurnCount,
2120 input.toTurnCount,
2221 input.cacheScope ?? null,
......@@ -58,20 +57,20 @@ export function checkpointDiffQueryOptions(
5857 return queryOptions({
5958 queryKey: providerQueryKeys.checkpointDiff(input),
6059 queryFn: async () => {
61 if (!api || !input.sessionId || !hasValidRange) {
60 if (!api || !input.threadId || !hasValidRange) {
6261 throw new Error("Checkpoint diff is unavailable.");
6362 }
6463 const { fromTurnCount, toTurnCount } = input;
6564 if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") {
6665 throw new Error("Checkpoint diff range is invalid.");
6766 }
68 return api.providers.getCheckpointDiff({
69 sessionId: input.sessionId,
67 return api.orchestration.getTurnDiff({
68 threadId: asThreadId(input.threadId),
7069 fromTurnCount,
7170 toTurnCount,
7271 });
7372 },
74 enabled: !!api && !!input.sessionId && hasValidRange,
73 enabled: !!api && !!input.threadId && hasValidRange,
7574 staleTime: Infinity,
7675 retry: (failureCount, error) => {
7776 if (isCheckpointTemporarilyUnavailable(error)) {
apps/web/src/routes/__root.tsx+23-25
......@@ -13,6 +13,7 @@ import { isElectron } from "../env";
1313import { useNativeApi } from "../hooks/useNativeApi";
1414import { DEFAULT_MODEL } from "../model-logic";
1515import { useStore } from "../store";
16import { newCommandId, newProjectId, newThreadId } from "../lib/orchestrationIds";
1617import { onServerWelcome } from "../wsNativeApi";
1718
1819export const Route = createRootRouteWithContext<{
......@@ -134,7 +135,7 @@ function EventRouter() {
134135 const flushSnapshotSync = async (): Promise<void> => {
135136 const snapshot = await api.orchestration.getSnapshot();
136137 if (disposed) return;
137 latestSequence = Math.max(latestSequence, snapshot.sequence);
138 latestSequence = Math.max(latestSequence, snapshot.snapshotSequence);
138139 dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot });
139140 if (pending) {
140141 pending = false;
......@@ -158,12 +159,7 @@ function EventRouter() {
158159 }
159160 };
160161
161 void Promise.all([api.projects.list(), syncSnapshot()])
162 .then(([projects]) => {
163 if (disposed) return;
164 dispatch({ type: "SYNC_PROJECTS", projects });
165 })
166 .catch(() => undefined);
162 void syncSnapshot().catch(() => undefined);
167163
168164 const unsubDomainEvent = api.orchestration.onDomainEvent((event) => {
169165 if (event.sequence <= latestSequence) {
......@@ -209,32 +205,34 @@ function AutoProjectBootstrap() {
209205
210206 bootstrappedRef.current = true;
211207 const now = new Date().toISOString();
212 void api.projects
213 .add({ cwd: payload.cwd })
214 .then(async (result) => {
215 const projects = await api.projects.list();
216 dispatch({ type: "SYNC_PROJECTS", projects });
217
218 const hasThread = state.threads.some((thread) => thread.projectId === result.project.id);
219 if (hasThread) {
220 return;
221 }
222
223 return api.orchestration.dispatchCommand({
208 const projectId = newProjectId();
209 const threadId = newThreadId();
210 void api.orchestration
211 .dispatchCommand({
212 type: "project.create",
213 commandId: newCommandId(),
214 projectId,
215 title: payload.projectName,
216 workspaceRoot: payload.cwd,
217 defaultModel: DEFAULT_MODEL,
218 createdAt: now,
219 })
220 .then(() =>
221 api.orchestration.dispatchCommand({
224222 type: "thread.create",
225 commandId: crypto.randomUUID(),
226 threadId: crypto.randomUUID(),
227 projectId: result.project.id,
223 commandId: newCommandId(),
224 threadId,
225 projectId,
228226 title: "New thread",
229227 model: DEFAULT_MODEL,
230228 branch: null,
231229 worktreePath: null,
232230 createdAt: now,
233 });
234 })
231 }),
232 )
235233 .catch(() => undefined);
236234 });
237 }, [api, state.projects, state.threads, dispatch]);
235 }, [api, state.projects, dispatch]);
238236
239237 return null;
240238}
apps/web/src/session-logic.test.ts+46-28
......@@ -1,4 +1,4 @@
1import type { OrchestrationThreadActivity } from "@t3tools/contracts";
1import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
44import {
......@@ -7,18 +7,24 @@ import {
77 deriveWorkLogEntries,
88} from "./session-logic";
99
10function makeActivity(
11 overrides: Partial<OrchestrationThreadActivity>,
12): OrchestrationThreadActivity {
10function makeActivity(overrides: {
11 id?: string;
12 createdAt?: string;
13 kind?: string;
14 summary?: string;
15 tone?: OrchestrationThreadActivity["tone"];
16 payload?: Record<string, unknown>;
17 turnId?: string;
18}): OrchestrationThreadActivity {
19 const payload = overrides.payload ?? {};
1320 return {
14 id: overrides.id ?? crypto.randomUUID(),
21 id: EventId.makeUnsafe(overrides.id ?? crypto.randomUUID()),
1522 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",
1725 tone: overrides.tone ?? "tool",
18 ...(overrides.detail !== undefined ? { detail: overrides.detail } : {}),
19 ...(overrides.turnId !== undefined ? { turnId: overrides.turnId } : {}),
20 ...(overrides.requestId !== undefined ? { requestId: overrides.requestId } : {}),
21 ...(overrides.requestKind !== undefined ? { requestKind: overrides.requestKind } : {}),
26 payload,
27 turnId: overrides.turnId ? TurnId.makeUnsafe(overrides.turnId) : null,
2228 };
2329}
2430
......@@ -28,26 +34,30 @@ describe("derivePendingApprovals", () => {
2834 makeActivity({
2935 id: "approval-open",
3036 createdAt: "2026-02-23T00:00:01.000Z",
31 label: "Command approval requested",
32 detail: "bun run lint",
33 tone: "tool",
34 requestId: "req-1",
35 requestKind: "command",
37 kind: "approval.requested",
38 summary: "Command approval requested",
39 tone: "approval",
40 payload: {
41 requestId: "req-1",
42 requestKind: "command",
43 detail: "bun run lint",
44 },
3645 }),
3746 makeActivity({
3847 id: "approval-close",
3948 createdAt: "2026-02-23T00:00:02.000Z",
40 label: "Approval resolved",
49 kind: "approval.resolved",
50 summary: "Approval resolved",
4151 tone: "info",
42 requestId: "req-2",
52 payload: { requestId: "req-2" },
4353 }),
4454 makeActivity({
4555 id: "approval-closed-request",
4656 createdAt: "2026-02-23T00:00:01.500Z",
47 label: "File-change approval requested",
48 tone: "tool",
49 requestId: "req-2",
50 requestKind: "file-change",
57 kind: "approval.requested",
58 summary: "File-change approval requested",
59 tone: "approval",
60 payload: { requestId: "req-2", requestKind: "file-change" },
5161 }),
5262 ];
5363
......@@ -68,12 +78,14 @@ describe("deriveWorkLogEntries", () => {
6878 makeActivity({
6979 id: "tool-complete",
7080 createdAt: "2026-02-23T00:00:03.000Z",
71 label: "Tool call complete",
81 summary: "Tool call complete",
82 kind: "tool.completed",
7283 }),
7384 makeActivity({
7485 id: "tool-start",
7586 createdAt: "2026-02-23T00:00:02.000Z",
76 label: "Tool call",
87 summary: "Tool call",
88 kind: "tool.started",
7789 }),
7890 ];
7991
......@@ -83,9 +95,14 @@ describe("deriveWorkLogEntries", () => {
8395
8496 it("filters by turn id when provided", () => {
8597 const activities: OrchestrationThreadActivity[] = [
86 makeActivity({ id: "turn-1", turnId: "turn-1", label: "Tool call" }),
87 makeActivity({ id: "turn-2", turnId: "turn-2", label: "Tool call complete" }),
88 makeActivity({ id: "no-turn", label: "Checkpoint captured", tone: "info" }),
98 makeActivity({ id: "turn-1", turnId: "turn-1", summary: "Tool call", kind: "tool.started" }),
99 makeActivity({
100 id: "turn-2",
101 turnId: "turn-2",
102 summary: "Tool call complete",
103 kind: "tool.completed",
104 }),
105 makeActivity({ id: "no-turn", summary: "Checkpoint captured", tone: "info" }),
89106 ];
90107
91108 const entries = deriveWorkLogEntries(activities, "turn-2");
......@@ -97,14 +114,15 @@ describe("deriveWorkLogEntries", () => {
97114 makeActivity({
98115 id: "checkpoint",
99116 createdAt: "2026-02-23T00:00:01.000Z",
100 label: "Checkpoint captured",
117 summary: "Checkpoint captured",
101118 tone: "info",
102119 }),
103120 makeActivity({
104121 id: "tool-complete",
105122 createdAt: "2026-02-23T00:00:02.000Z",
106 label: "Command run complete",
123 summary: "Command run complete",
107124 tone: "tool",
125 kind: "tool.completed",
108126 }),
109127 ];
110128
apps/web/src/session-logic.ts+35-15
......@@ -1,10 +1,15 @@
11import type {
22 OrchestrationThreadActivity,
33 ProviderKind,
4 ProviderSession,
54} 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
914export const PROVIDER_OPTIONS: Array<{
1015 value: ProviderKind;
......@@ -83,18 +88,29 @@ export function derivePendingApprovals(
8388 );
8489
8590 for (const activity of ordered) {
86 if (activity.requestId && activity.requestKind) {
87 openByRequestId.set(activity.requestId, {
88 requestId: activity.requestId,
89 requestKind: activity.requestKind,
91 const payload =
92 activity.payload && typeof activity.payload === "object"
93 ? (activity.payload as Record<string, unknown>)
94 : null;
95 const requestId = payload && typeof payload.requestId === "string" ? payload.requestId : null;
96 const requestKind =
97 payload && (payload.requestKind === "command" || payload.requestKind === "file-change")
98 ? payload.requestKind
99 : null;
100 const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined;
101
102 if (activity.kind === "approval.requested" && requestId && requestKind) {
103 openByRequestId.set(requestId, {
104 requestId,
105 requestKind,
90106 createdAt: activity.createdAt,
91 ...(activity.detail ? { detail: activity.detail } : {}),
107 ...(detail ? { detail } : {}),
92108 });
93109 continue;
94110 }
95111
96 if (activity.requestId && activity.label === "Approval resolved") {
97 openByRequestId.delete(activity.requestId);
112 if (activity.kind === "approval.resolved" && requestId) {
113 openByRequestId.delete(requestId);
98114 }
99115 }
100116
......@@ -112,16 +128,20 @@ export function deriveWorkLogEntries(
112128 );
113129 return ordered
114130 .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true))
115 .filter((activity) => activity.label !== "Checkpoint captured")
131 .filter((activity) => activity.summary !== "Checkpoint captured")
116132 .map((activity) => {
133 const payload =
134 activity.payload && typeof activity.payload === "object"
135 ? (activity.payload as Record<string, unknown>)
136 : null;
117137 const entry: WorkLogEntry = {
118138 id: activity.id,
119139 createdAt: activity.createdAt,
120 label: activity.label,
121 tone: activity.tone,
140 label: activity.summary,
141 tone: activity.tone === "approval" ? "info" : activity.tone,
122142 };
123 if (activity.detail) {
124 entry.detail = activity.detail;
143 if (payload && typeof payload.detail === "string" && payload.detail.length > 0) {
144 entry.detail = payload.detail;
125145 }
126146 return entry;
127147 });
......@@ -207,7 +227,7 @@ export function deriveTurnDiffSummaries(_events: ReadonlyArray<OrchestrationThre
207227 return [];
208228}
209229
210export function derivePhase(session: ProviderSession | null): SessionPhase {
230export function derivePhase(session: ThreadSession | null): SessionPhase {
211231 if (!session || session.status === "closed") return "disconnected";
212232 if (session.status === "connecting") return "connecting";
213233 if (session.status === "running") return "running";
apps/web/src/store.ts+99-33
......@@ -10,8 +10,7 @@ import {
1010
1111import {
1212 type OrchestrationReadModel,
13 type ProjectListResult,
14 normalizeProjectScripts,
13 type OrchestrationSessionStatus,
1514} from "@t3tools/contracts";
1615import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
1716import {
......@@ -19,6 +18,7 @@ import {
1918 DEFAULT_THREAD_TERMINAL_ID,
2019 DEFAULT_RUNTIME_MODE,
2120 MAX_THREAD_TERMINAL_COUNT,
21 type ChatMessage,
2222 type ProjectScript,
2323 type Project,
2424 type RuntimeMode,
......@@ -30,7 +30,6 @@ import {
3030
3131type Action =
3232 | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel }
33 | { type: "SYNC_PROJECTS"; projects: ProjectListResult }
3433 | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] }
3534 | { type: "SET_THREADS_HYDRATED"; hydrated: boolean }
3635 | { type: "TOGGLE_PROJECT"; projectId: string }
......@@ -138,29 +137,56 @@ function updateThread(
138137 return threads.map((t) => (t.id === threadId ? updater(t) : t));
139138}
140139
141function mapProjects(
142 incoming: ProjectListResult,
140function cloneProjectScripts(scripts: ReadonlyArray<ProjectScript>): ProjectScript[] {
141 return scripts.map((script) => ({ ...script }));
142}
143
144function mapProjectsFromReadModel(
145 incoming: OrchestrationReadModel["projects"],
143146 previous: Project[],
144147): Project[] {
145148 return incoming.map((project) => {
146149 const existing =
147150 previous.find((entry) => entry.id === project.id) ??
148 previous.find((entry) => entry.cwd === project.cwd);
151 previous.find((entry) => entry.cwd === project.workspaceRoot);
149152 return {
150153 id: project.id,
151 name: project.name,
152 cwd: project.cwd,
153 model: existing?.model ?? DEFAULT_MODEL,
154 name: project.title,
155 cwd: project.workspaceRoot,
156 model: existing?.model ?? resolveModelSlug(project.defaultModel ?? DEFAULT_MODEL),
154157 expanded:
155158 existing?.expanded ??
156159 (persistedExpandedProjectCwds.size > 0
157 ? persistedExpandedProjectCwds.has(project.cwd)
160 ? persistedExpandedProjectCwds.has(project.workspaceRoot)
158161 : true),
159 scripts: normalizeProjectScripts(project.scripts),
162 scripts: cloneProjectScripts(project.scripts),
160163 };
161164 });
162165}
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
165191function normalizeTerminalIds(terminalIds: string[]): string[] {
166192 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);
......@@ -354,12 +380,32 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread {
354380export function reducer(state: AppState, action: Action): AppState {
355381 switch (action.type) {
356382 case "SYNC_SERVER_READ_MODEL": {
383 const projects = mapProjectsFromReadModel(
384 action.readModel.projects.filter((project) => project.deletedAt === null),
385 state.projects,
386 );
357387 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) => {
359391 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
360406 return normalizeThreadTerminals({
361407 id: thread.id,
362 codexThreadId: thread.session?.threadId ?? null,
408 codexThreadId: thread.session?.providerThreadId ?? null,
363409 projectId: thread.projectId,
364410 title: thread.title,
365411 model: resolveModelSlug(thread.model),
......@@ -374,52 +420,72 @@ export function reducer(state: AppState, action: Action): AppState {
374420 activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`,
375421 session: thread.session
376422 ? {
377 sessionId: thread.session.sessionId,
378 provider: thread.session.provider,
379 status: thread.session.status,
380 threadId: thread.session.threadId,
423 sessionId: thread.session.providerSessionId ?? `thread:${thread.id}`,
424 provider: toLegacyProvider(thread.session.providerName),
425 status: toLegacySessionStatus(thread.session.status),
426 orchestrationStatus: thread.session.status,
427 threadId: thread.session.providerThreadId,
381428 activeTurnId: thread.session.activeTurnId ?? undefined,
382 createdAt: thread.session.createdAt,
429 createdAt: thread.session.updatedAt,
383430 updatedAt: thread.session.updatedAt,
384431 ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}),
385432 }
386433 : null,
387 messages: thread.messages.map((message) => ({ ...message })),
388 error: thread.error,
434 messages: thread.messages.map((message) => {
435 const attachments = message.attachments?.map((attachment, index) => ({
436 type: "image" as const,
437 id: `${message.id}:${index}`,
438 name: attachment.name,
439 mimeType: attachment.mimeType,
440 sizeBytes: attachment.sizeBytes,
441 previewUrl: attachment.dataUrl,
442 }));
443 const normalizedMessage: ChatMessage = {
444 id: message.id,
445 role: message.role,
446 text: message.text,
447 createdAt: message.createdAt,
448 streaming: message.streaming,
449 ...(message.streaming ? {} : { completedAt: message.updatedAt }),
450 ...(attachments && attachments.length > 0 ? { attachments } : {}),
451 };
452 return normalizedMessage;
453 }),
454 error: thread.session?.lastError ?? null,
389455 createdAt: thread.createdAt,
390456 latestTurnId: thread.latestTurnId ?? undefined,
391 latestTurnStartedAt: thread.latestTurnStartedAt ?? undefined,
392 latestTurnCompletedAt: thread.latestTurnCompletedAt ?? undefined,
393 latestTurnDurationMs: thread.latestTurnDurationMs ?? undefined,
457 latestTurnStartedAt,
458 latestTurnCompletedAt,
459 latestTurnDurationMs,
394460 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,
395461 branch: thread.branch,
396462 worktreePath: thread.worktreePath,
397 turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({
398 ...summary,
399 files: summary.files.map((file) => ({ ...file })),
463 turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({
464 turnId: checkpoint.turnId,
465 completedAt: checkpoint.completedAt,
466 status: checkpoint.status,
467 assistantMessageId: checkpoint.assistantMessageId ?? undefined,
468 checkpointTurnCount: checkpoint.checkpointTurnCount,
469 checkpointRef: checkpoint.checkpointRef,
470 files: checkpoint.files.map((file) => ({ ...file })),
400471 })),
401472 activities: thread.activities.map((activity) => ({ ...activity })),
402473 });
403474 });
404475 return {
405476 ...state,
477 projects,
406478 threads,
407479 threadsHydrated: true,
408480 };
409481 }
410482
411 case "SYNC_PROJECTS":
412 return {
413 ...state,
414 projects: mapProjects(action.projects, state.projects),
415 };
416
417483 case "SET_PROJECT_SCRIPTS":
418484 return {
419485 ...state,
420486 projects: state.projects.map((project) =>
421487 project.id === action.projectId
422 ? { ...project, scripts: normalizeProjectScripts(action.scripts) }
488 ? { ...project, scripts: cloneProjectScripts(action.scripts) }
423489 : project,
424490 ),
425491 };
apps/web/src/types.ts+16-3
......@@ -1,7 +1,7 @@
11import type {
2 OrchestrationSessionStatus,
23 OrchestrationThreadActivity,
34 ProjectScript as ContractProjectScript,
4 ProviderSession,
55} from "@t3tools/contracts";
66
77export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
......@@ -30,7 +30,7 @@ export type ChatAttachment = ChatImageAttachment;
3030
3131export interface ChatMessage {
3232 id: string;
33 role: "user" | "assistant";
33 role: "user" | "assistant" | "system";
3434 text: string;
3535 attachments?: ChatAttachment[];
3636 createdAt: string;
......@@ -50,6 +50,7 @@ export interface TurnDiffSummary {
5050 completedAt: string;
5151 status?: string | undefined;
5252 files: TurnDiffFileChange[];
53 checkpointRef?: string | undefined;
5354 assistantMessageId?: string | undefined;
5455 checkpointTurnCount?: number | undefined;
5556}
......@@ -76,7 +77,7 @@ export interface Thread {
7677 activeTerminalId: string;
7778 terminalGroups: ThreadTerminalGroup[];
7879 activeTerminalGroupId: string;
79 session: ProviderSession | null;
80 session: ThreadSession | null;
8081 messages: ChatMessage[];
8182 error: string | null;
8283 createdAt: string;
......@@ -90,3 +91,15 @@ export interface Thread {
9091 turnDiffSummaries: TurnDiffSummary[];
9192 activities: OrchestrationThreadActivity[];
9293}
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 @@
11/// <reference types="vite/client" />
22
3import type { NativeApi } 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}
3import type { NativeApi, DesktopBridge } from "@t3tools/contracts";
134
145declare global {
156 interface Window {
apps/web/src/wsNativeApi.ts+1-28
......@@ -56,12 +56,6 @@ export function createWsNativeApi(): NativeApi {
5656 });
5757
5858 const api: NativeApi = {
59 todos: {
60 list: async () => [],
61 add: async () => [],
62 toggle: async () => [],
63 remove: async () => [],
64 },
6559 dialogs: {
6660 pickFolder: async () => {
6761 if (!window.desktopBridge) return null;
......@@ -84,30 +78,8 @@ export function createWsNativeApi(): NativeApi {
8478 onEvent: (callback) =>
8579 transport.subscribe(WS_CHANNELS.terminalEvent, callback as (data: unknown) => void),
8680 },
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 },
10581 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),
10982 searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input),
110 updateScripts: (input) => transport.request(WS_METHODS.projectsUpdateScripts, input),
11183 },
11284 shell: {
11385 openInEditor: (cwd, editor) =>
......@@ -157,6 +129,7 @@ export function createWsNativeApi(): NativeApi {
157129 getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot),
158130 dispatchCommand: (command) =>
159131 transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, command),
132 getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input),
160133 replayEvents: (fromSequenceExclusive) =>
161134 transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }),
162135 onDomainEvent: (callback) =>
apps/web/src/wsTransport.ts+26-10
......@@ -1,4 +1,4 @@
1import type { WsPush, WsRequest, WsResponse } from "@t3tools/contracts";
1import type { WsPush } from "@t3tools/contracts";
22
33type PushListener = (data: unknown) => void;
44
......@@ -11,6 +11,14 @@ interface PendingRequest {
1111const REQUEST_TIMEOUT_MS = 60_000;
1212const 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
1422export class WsTransport {
1523 private ws: WebSocket | null = null;
1624 private nextId = 1;
......@@ -41,7 +49,11 @@ export class WsTransport {
4149 throw new Error("Request method is required");
4250 }
4351 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
4658 return new Promise<T>((resolve, reject) => {
4759 const timeout = setTimeout(() => {
......@@ -144,22 +156,26 @@ export class WsTransport {
144156
145157 // Response to a request
146158 if (typeof message.id === "string") {
147 const response = message as unknown as WsResponse;
148 const pending = this.pending.get(response.id);
159 const responseId = message.id;
160 const pending = this.pending.get(responseId);
149161 if (!pending) return;
150162
151163 clearTimeout(pending.timeout);
152 this.pending.delete(response.id);
153
154 if (response.error) {
155 pending.reject(new Error(response.error.message));
164 this.pending.delete(responseId);
165
166 if (
167 message.error &&
168 typeof message.error === "object" &&
169 typeof (message.error as { message?: unknown }).message === "string"
170 ) {
171 pending.reject(new Error((message.error as { message: string }).message));
156172 } else {
157 pending.resolve(response.result);
173 pending.resolve(message.result);
158174 }
159175 }
160176 }
161177
162 private send(message: WsRequest) {
178 private send(message: WsRequestEnvelope) {
163179 if (this.ws?.readyState === WebSocket.OPEN) {
164180 this.ws.send(JSON.stringify(message));
165181 return;
bun.lock+2-2
......@@ -19,6 +19,7 @@
1919 "electron": "40.6.0",
2020 },
2121 "devDependencies": {
22 "@t3tools/contracts": "workspace:*",
2223 "@types/node": "^22.10.2",
2324 "electronmon": "^2.0.2",
2425 "tsdown": "^0.20.3",
......@@ -68,6 +69,7 @@
6869 "@xterm/addon-fit": "^0.11.0",
6970 "@xterm/xterm": "^6.0.0",
7071 "class-variance-authority": "^0.7.1",
72 "effect": "4.0.0-beta.10",
7173 "highlight.js": "^11.11.1",
7274 "lucide-react": "^0.564.0",
7375 "react": "^19.0.0",
......@@ -76,7 +78,6 @@
7678 "rehype-highlight": "^7.0.2",
7779 "remark-gfm": "^4.0.1",
7880 "tailwind-merge": "^3.4.0",
79 "zod": "^3.24.1",
8081 },
8182 "devDependencies": {
8283 "@tailwindcss/vite": "^4.0.0",
......@@ -95,7 +96,6 @@
9596 "version": "0.0.0",
9697 "dependencies": {
9798 "effect": "4.0.0-beta.10",
98 "zod": "^3.24.1",
9999 },
100100 "devDependencies": {
101101 "@effect/language-service": "0.75.1",
packages/contracts/src/git.ts+1
......@@ -5,6 +5,7 @@ const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty());
55// Domain Types
66
77export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]);
8export type GitStackedAction = typeof GitStackedAction.Type;
89export const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]);
910export const GitPushStepStatus = Schema.Literals([
1011 "pushed",
packages/contracts/src/ipc.ts+12
......@@ -35,6 +35,18 @@ import type {
3535} from "./orchestration";
3636import { 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
3850export interface NativeApi {
3951 dialogs: {
4052 pickFolder: () => Promise<string | null>;
packages/contracts/src/orchestration.ts+1
......@@ -105,6 +105,7 @@ export const ProjectScriptIcon = Schema.Literals([
105105 "build",
106106 "debug",
107107]);
108export type ProjectScriptIcon = typeof ProjectScriptIcon.Type;
108109
109110export const ProjectScript = Schema.Struct({
110111 id: Schema.String,
packages/contracts/src/server.ts+3-8
......@@ -5,17 +5,12 @@ export const ServerConfig = Schema.Struct({
55 cwd: Schema.NonEmptyString,
66 keybindings: ResolvedKeybindingsConfig,
77});
8export type ServerConfig = Schema.Codec.Encoded<typeof ServerConfig>;
8export type ServerConfig = typeof ServerConfig.Type;
99
1010export const ServerUpsertKeybindingInput = KeybindingRule;
11export type ServerUpsertKeybindingInput = Schema.Codec.Encoded<
12 typeof ServerUpsertKeybindingInput
13>;
11export type ServerUpsertKeybindingInput = typeof ServerUpsertKeybindingInput.Type;
1412
1513export const ServerUpsertKeybindingResult = Schema.Struct({
1614 keybindings: ResolvedKeybindingsConfig,
1715});
18export type ServerUpsertKeybindingResult = Schema.Codec.Encoded<
19 typeof ServerUpsertKeybindingResult
20>;
21
16export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type;