History

plumbing

Julius Marminge committed 8 months ago

Commit 032c0e51 parent 319ac4523 files · +1168 −522Browse files

Unified · Split

23 files
  1. apps/server/integration/OrchestrationEngineHarness.integration.ts +8 −60
  2. apps/server/integration/orchestrationEngine.integration.test.ts +92 −7
  3. apps/server/src/index.ts +1 −0
  4. apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +1 −77
  5. apps/server/src/orchestration/Layers/CheckpointReactor.ts +0 −73
  6. apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +131 −0
  7. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +189 −33
  8. apps/server/src/orchestration/projector.test.ts +209 −0
  9. apps/server/src/orchestration/projector.ts +74 −0
  10. apps/server/src/persistence/Errors.ts +0 −2
  11. apps/server/src/persistence/Layers/CheckpointDiffBlobs.ts +0 −79
  12. apps/server/src/persistence/Services/CheckpointDiffBlobs.ts +0 −36
  13. apps/server/src/wsServer.test.ts +134 −17
  14. apps/server/src/wsServer.ts +172 −51
  15. apps/web/src/components/Sidebar.tsx +36 −23
  16. apps/web/src/lib/providerReactQuery.test.ts +30 −0
  17. apps/web/src/lib/providerReactQuery.ts +6 −0
  18. apps/web/src/routes/__root.tsx +8 −63
  19. apps/web/src/wsNativeApi.test.ts +53 −0
  20. apps/web/src/wsNativeApi.ts +3 −1
  21. packages/contracts/src/ipc.ts +5 −0
  22. packages/contracts/src/orchestration.ts +14 −0
  23. packages/contracts/src/ws.ts +2 −0
apps/server/integration/OrchestrationEngineHarness.integration.ts+8−60
@@ -6,21 +6,20 @@ import { execFileSync } from "node:child_process"; · 5 unmodified lines
66 import { NodeServices } from "@effect/platform-node";
77 import {
88 ApprovalRequestId,
9+ CheckpointRef,
910 type OrchestrationEvent,
1011 type OrchestrationThread,
11− type ThreadId,
1212 } from "@t3tools/contracts";
1313 import { Effect, Exit, Layer, ManagedRuntime, Option, Schedule, Scope, Stream } from "effect";
1414
1515 import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts";
16−import { CheckpointDiffBlobRepositoryLive } from "../src/persistence/Layers/CheckpointDiffBlobs.ts";
16+import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";
1717 import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
1818 import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
1919 import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts";
2020 import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts";
2121 import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts";
2222 import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts";
23−import { CheckpointDiffBlobRepository } from "../src/persistence/Services/CheckpointDiffBlobs.ts";
2423 import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts";
2524 import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts";
2625 import { ProviderUnsupportedError } from "../src/provider/Errors.ts";
@@ -64,9 +63,9 @@ function initializeGitWorkspace(cwd: string) { · 37 unmodified lines
6463 runGit(cwd, ["commit", "-m", "Initial"]);
6564 }
6665
67−export function checkpointRefForTurn(threadId: string, turnCount: number): string {
66+export function checkpointRefForTurn(threadId: string, turnCount: number): CheckpointRef {
6867 const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url");
69− return `refs/t3/checkpoints/${encodedThreadId}/turn/${turnCount}`;
68+ return CheckpointRef.makeUnsafe(`refs/t3/checkpoints/${encodedThreadId}/turn/${turnCount}`);
7069 }
7170
7271 export function gitRefExists(cwd: string, ref: string): boolean {
@@ -127,7 +126,7 @@ export interface OrchestrationIntegrationHarness { · 54 unmodified lines
127126 readonly engine: OrchestrationEngineShape;
128127 readonly snapshotQuery: ProjectionSnapshotQuery["Service"];
129128 readonly providerService: ProviderService["Service"];
130− readonly diffBlobRepository: CheckpointDiffBlobRepository["Service"];
129+ readonly checkpointStore: CheckpointStore["Service"];
131130 readonly checkpointRepository: ProjectionCheckpointRepository["Service"];
132131 readonly pendingApprovalRepository: ProjectionPendingApprovalRepository["Service"];
133132 readonly waitForThread: (
@@ -155,23 +154,6 @@ export interface OrchestrationIntegrationHarness { · 21 unmodified lines
155154 },
156155 never
157156 >;
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− >;
175157 readonly dispose: Effect.Effect<void, never>;
176158 }
177159
@@ -214,7 +196,6 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { · 36 unmodified lines
214196 const runtimeServicesLayer = Layer.mergeAll(
215197 orchestrationLayer,
216198 OrchestrationProjectionSnapshotQueryLive,
217− CheckpointDiffBlobRepositoryLive,
218199 ProjectionCheckpointRepositoryLive,
219200 ProjectionPendingApprovalRepositoryLive,
220201 checkpointStoreLayer,
@@ -252,8 +233,8 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { · 31 unmodified lines
252233 const providerService = yield* Effect.promise(() =>
253234 runtime.runPromise(Effect.service(ProviderService)),
254235 );
255− const diffBlobRepository = yield* Effect.promise(() =>
256− runtime.runPromise(Effect.service(CheckpointDiffBlobRepository)),
236+ const checkpointStore = yield* Effect.promise(() =>
237+ runtime.runPromise(Effect.service(CheckpointStore)),
257238 );
258239 const checkpointRepository = yield* Effect.promise(() =>
259240 runtime.runPromise(Effect.service(ProjectionCheckpointRepository)),
@@ -335,38 +316,6 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { · 75 unmodified lines
335316 never
336317 >;
337318
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−
370319 let disposed = false;
371320 const dispose = Effect.gen(function* () {
372321 if (disposed) {
@@ -390,13 +339,12 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { · 17 unmodified lines
390339 engine,
391340 snapshotQuery,
392341 providerService,
393− diffBlobRepository,
342+ checkpointStore,
394343 checkpointRepository,
395344 pendingApprovalRepository,
396345 waitForThread,
397346 waitForDomainEvent,
398347 waitForPendingApproval,
399− waitForDiffBlob,
400348 dispose,
401349 } satisfies OrchestrationIntegrationHarness;
402350 });
apps/server/integration/orchestrationEngine.integration.test.ts+92−7
@@ -202,7 +202,7 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + · 201 unmodified lines
202202 ),
203203 );
204204
205−it.live("runs multi-turn file edits and persists checkpoint diffs + blobs", () =>
205+it.live("runs multi-turn file edits and persists checkpoint diffs", () =>
206206 withHarness((harness) =>
207207 Effect.gen(function* () {
208208 yield* seedProjectAndThread(harness);
@@ -327,12 +327,21 @@ it.live("runs multi-turn file edits and persists checkpoint diffs + blobs", () = · 118 unmodified lines
327327 [1, 2],
328328 );
329329
330− const diffBlob = yield* harness.waitForDiffBlob({
331− threadId: THREAD_ID,
332− fromTurnCount: 1,
333− toTurnCount: 2,
330+ const incrementalDiff = yield* harness.checkpointStore.diffCheckpoints({
331+ cwd: harness.workspaceDir,
332+ fromCheckpointRef: checkpointRefForTurn(THREAD_ID, 1),
333+ toCheckpointRef: checkpointRefForTurn(THREAD_ID, 2),
334+ fallbackFromToHead: false,
335+ });
336+ assert.equal(incrementalDiff.includes("README.md"), true);
337+
338+ const fullDiff = yield* harness.checkpointStore.diffCheckpoints({
339+ cwd: harness.workspaceDir,
340+ fromCheckpointRef: checkpointRefForTurn(THREAD_ID, 0),
341+ toCheckpointRef: checkpointRefForTurn(THREAD_ID, 2),
342+ fallbackFromToHead: false,
334343 });
335− assert.equal(diffBlob.diff.includes("README.md"), true);
344+ assert.equal(fullDiff.includes("README.md"), true);
336345
337346 assert.equal(
338347 gitShowFileAtRef(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 1), "README.md"),
@@ -511,6 +520,31 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git · 172 unmodified lines
511520 threadId: FIXTURE_THREAD_ID,
512521 turnId: FIXTURE_TURN_ID,
513522 },
523+ {
524+ type: "tool.started",
525+ ...runtimeBase("evt-revert-1-tool-started", "2026-02-24T10:05:00.025Z"),
526+ threadId: FIXTURE_THREAD_ID,
527+ turnId: FIXTURE_TURN_ID,
528+ toolKind: "command",
529+ title: "Edit file",
530+ detail: "README.md",
531+ },
532+ {
533+ type: "tool.completed",
534+ ...runtimeBase("evt-revert-1-tool-completed", "2026-02-24T10:05:00.035Z"),
535+ threadId: FIXTURE_THREAD_ID,
536+ turnId: FIXTURE_TURN_ID,
537+ toolKind: "command",
538+ title: "Edit file",
539+ detail: "README.md",
540+ },
541+ {
542+ type: "message.delta",
543+ ...runtimeBase("evt-revert-1a", "2026-02-24T10:05:00.050Z"),
544+ threadId: FIXTURE_THREAD_ID,
545+ turnId: FIXTURE_TURN_ID,
546+ delta: "Updated README to v2.\n",
547+ },
514548 {
515549 type: "turn.completed",
516550 ...runtimeBase("evt-revert-2", "2026-02-24T10:05:00.100Z"),
@@ -549,6 +583,31 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git · 32 unmodified lines
549583 threadId: FIXTURE_THREAD_ID,
550584 turnId: FIXTURE_TURN_ID,
551585 },
586+ {
587+ type: "tool.started",
588+ ...runtimeBase("evt-revert-3-tool-started", "2026-02-24T10:05:01.025Z"),
589+ threadId: FIXTURE_THREAD_ID,
590+ turnId: FIXTURE_TURN_ID,
591+ toolKind: "command",
592+ title: "Edit file",
593+ detail: "README.md",
594+ },
595+ {
596+ type: "tool.completed",
597+ ...runtimeBase("evt-revert-3-tool-completed", "2026-02-24T10:05:01.035Z"),
598+ threadId: FIXTURE_THREAD_ID,
599+ turnId: FIXTURE_TURN_ID,
600+ toolKind: "command",
601+ title: "Edit file",
602+ detail: "README.md",
603+ },
604+ {
605+ type: "message.delta",
606+ ...runtimeBase("evt-revert-3a", "2026-02-24T10:05:01.050Z"),
607+ threadId: FIXTURE_THREAD_ID,
608+ turnId: FIXTURE_TURN_ID,
609+ delta: "Updated README to v3.\n",
610+ },
552611 {
553612 type: "turn.completed",
554613 ...runtimeBase("evt-revert-4", "2026-02-24T10:05:01.100Z"),
@@ -571,7 +630,10 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git · 16 unmodified lines
571630
572631 yield* harness.waitForThread(
573632 THREAD_ID,
574− (entry) => entry.latestTurnId === "turn-2" && entry.checkpoints.length === 2,
633+ (entry) =>
634+ entry.latestTurnId === "turn-2" &&
635+ entry.checkpoints.length === 2 &&
636+ entry.activities.some((activity) => activity.turnId === "turn-2"),
575637 8000,
576638 );
577639
@@ -590,6 +652,29 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git · 12 unmodified lines
590652 entry.checkpoints.length === 1 && entry.checkpoints[0]?.checkpointTurnCount === 1,
591653 );
592654 assert.equal(revertedThread.checkpoints[0]?.checkpointTurnCount, 1);
655+ assert.deepEqual(
656+ revertedThread.messages.map((message) => ({ role: message.role, text: message.text })),
657+ [
658+ { role: "user", text: "First edit" },
659+ { role: "assistant", text: "Updated README to v2.\n" },
660+ ],
661+ );
662+ assert.equal(
663+ revertedThread.activities.some((activity) => activity.turnId === "turn-2"),
664+ false,
665+ );
666+ assert.equal(
667+ revertedThread.activities.some(
668+ (activity) => activity.turnId === "turn-1" && activity.kind === "tool.started",
669+ ),
670+ true,
671+ );
672+ assert.equal(
673+ revertedThread.activities.some(
674+ (activity) => activity.turnId === "turn-1" && activity.kind === "tool.completed",
675+ ),
676+ true,
677+ );
593678 assert.equal(fs.readFileSync(path.join(harness.workspaceDir, "README.md"), "utf8"), "v2\n");
594679 assert.equal(gitRefExists(harness.workspaceDir, checkpointRefForTurn(THREAD_ID, 2)), false);
595680 assert.deepEqual(harness.adapterHarness.getRollbackCalls(sessionId), [1]);
apps/server/src/index.ts+1−0
@@ -118,6 +118,7 @@ async function main() { · 117 unmodified lines
118118 port,
119119 host: mode === "desktop" ? "127.0.0.1" : undefined,
120120 cwd,
121+ autoBootstrapProjectFromCwd: mode === "web",
121122 stateDir,
122123 staticDir,
123124 devUrl,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+1−77
@@ -24,11 +24,9 @@ import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts · 23 unmodified lines
2424 import { CheckpointReactorLive } from "./CheckpointReactor.ts";
2525 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2626 import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
27−import { CheckpointDiffBlobRepositoryLive } from "../../persistence/Layers/CheckpointDiffBlobs.ts";
2827 import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
2928 import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
3029 import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
31−import { CheckpointDiffBlobRepository } from "../../persistence/Services/CheckpointDiffBlobs.ts";
3230 import {
3331 OrchestrationEngineService,
3432 type OrchestrationEngineShape,
@@ -145,26 +143,6 @@ async function waitForEvent( · 110 unmodified lines
145143 return poll();
146144 }
147145
148−async function waitForDiffBlob(
149− repository: CheckpointDiffBlobRepository["Service"],
150− input: { threadId: ThreadId; fromTurnCount: number; toTurnCount: number },
151− timeoutMs = 2000,
152−) {
153− const deadline = Date.now() + timeoutMs;
154− const poll = async () => {
155− const blob = await Effect.runPromise(repository.get(input));
156− if (blob._tag === "Some") {
157− return blob.value;
158− }
159− if (Date.now() >= deadline) {
160− throw new Error("Timed out waiting for checkpoint diff blob persistence.");
161− }
162− await new Promise((resolve) => setTimeout(resolve, 10));
163− return poll();
164− };
165− return poll();
166−}
167−
168146 function runGit(cwd: string, args: ReadonlyArray<string>) {
169147 return execFileSync("git", args, {
170148 cwd,
@@ -214,7 +192,7 @@ async function waitForGitRefExists(cwd: string, ref: string, timeoutMs = 2000) { · 43 unmodified lines
214192
215193 describe("CheckpointReactor", () => {
216194 let runtime: ManagedRuntime.ManagedRuntime<
217− OrchestrationEngineService | CheckpointReactor | CheckpointDiffBlobRepository | CheckpointStore,
195+ OrchestrationEngineService | CheckpointReactor | CheckpointStore,
218196 unknown
219197 > | null = null;
220198 let scope: Scope.Closeable | null = null;
@@ -250,23 +228,16 @@ describe("CheckpointReactor", () => { · 29 unmodified lines
250228 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
251229 Layer.provide(SqlitePersistenceMemory),
252230 );
253− const diffBlobLayer = CheckpointDiffBlobRepositoryLive.pipe(
254− Layer.provide(SqlitePersistenceMemory),
255− );
256231 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer));
257232 const layer = CheckpointReactorLive.pipe(
258233 Layer.provideMerge(orchestrationLayer),
259234 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
260− Layer.provideMerge(diffBlobLayer),
261235 Layer.provideMerge(checkpointStoreLayer),
262236 );
263237
264238 runtime = ManagedRuntime.make(layer);
265239 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
266240 const reactor = await runtime.runPromise(Effect.service(CheckpointReactor));
267− const diffBlobRepository = await runtime.runPromise(
268− Effect.service(CheckpointDiffBlobRepository),
269− );
270241 const checkpointStore = await runtime.runPromise(Effect.service(CheckpointStore));
271242 scope = await Effect.runPromise(Scope.make("sequential"));
272243 await Effect.runPromise(reactor.start.pipe(Scope.provide(scope)));
@@ -325,7 +296,6 @@ describe("CheckpointReactor", () => { · 52 unmodified lines
325296 engine,
326297 provider,
327298 cwd,
328− diffBlobRepository,
329299 };
330300 }
331301
@@ -561,50 +531,4 @@ describe("CheckpointReactor", () => { · 229 unmodified lines
561531 expect(harness.provider.rollbackConversation).not.toHaveBeenCalled();
562532 });
563533
564− it("persists full checkpoint diff blobs on thread.turn-diff-completed", async () => {
565− const harness = await createHarness();
566− const createdAt = new Date().toISOString();
567−
568− await Effect.runPromise(
569− harness.engine.dispatch({
570− type: "thread.session.set",
571− commandId: asCommandId("cmd-session-set-diff"),
572− threadId: asThreadId("thread-1"),
573− session: {
574− threadId: asThreadId("thread-1"),
575− status: "ready",
576− providerName: "codex",
577− providerSessionId: asSessionId("sess-1"),
578− providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"),
579− activeTurnId: null,
580− lastError: null,
581− updatedAt: createdAt,
582− },
583− createdAt,
584− }),
585− );
586−
587− await Effect.runPromise(
588− harness.engine.dispatch({
589− type: "thread.turn.diff.complete",
590− commandId: asCommandId("cmd-diff-persist"),
591− threadId: asThreadId("thread-1"),
592− turnId: asTurnId("turn-1"),
593− completedAt: createdAt,
594− checkpointRef: checkpointRefForTurn(asThreadId("thread-1"), 1),
595− status: "ready",
596− files: [],
597− checkpointTurnCount: 1,
598− createdAt,
599− }),
600− );
601−
602− const persistedBlob = await waitForDiffBlob(harness.diffBlobRepository, {
603− threadId: asThreadId("thread-1"),
604− fromTurnCount: 0,
605− toTurnCount: 1,
606− });
607− expect(persistedBlob.diff).toContain("diff --git");
608− expect(persistedBlob.diff).toContain("README.md");
609− });
610534 });
apps/server/src/orchestration/Layers/CheckpointReactor.ts+0−73
@@ -13,7 +13,6 @@ import { Effect, Layer, Option, Queue, Stream } from "effect"; · 12 unmodified lines
1313
1414 import { parseTurnDiffFilesFromUnifiedDiff } from "../../checkpointing/Diffs.ts";
1515 import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
16−import { CheckpointDiffBlobRepository } from "../../persistence/Services/CheckpointDiffBlobs.ts";
1716 import { ProviderService } from "../../provider/Services/ProviderService.ts";
1817 import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
1918 import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
@@ -67,7 +66,6 @@ const make = Effect.gen(function* () { · 47 unmodified lines
6766 const orchestrationEngine = yield* OrchestrationEngineService;
6867 const providerService = yield* ProviderService;
6968 const checkpointStore = yield* CheckpointStore;
70− const checkpointDiffBlobRepository = yield* CheckpointDiffBlobRepository;
7169
7270 const appendRevertFailureActivity = (input: {
7371 readonly threadId: ThreadId;
@@ -160,62 +158,6 @@ const make = Effect.gen(function* () { · 86 unmodified lines
160158 return findSessionWithCwd(matchedSession);
161159 });
162160
163− const persistDiffBlobFromTurnCompletion = Effect.fnUntraced(function* (
164− event: Extract<OrchestrationEvent, { type: "thread.turn-diff-completed" }>,
165− ) {
166− const toTurnCount = event.payload.checkpointTurnCount;
167− const fromTurnCount = Math.max(0, toTurnCount - 1);
168−
169− const readModel = yield* orchestrationEngine.getReadModel();
170− const thread = readModel.threads.find((entry) => entry.id === event.payload.threadId);
171− if (!thread) {
172− return;
173− }
174−
175− const sessionRuntime = yield* resolveSessionRuntimeForThread(event.payload.threadId);
176− if (Option.isNone(sessionRuntime)) {
177− yield* Effect.logWarning(
178− "checkpoint diff blob skipped: no active provider session cwd for thread",
179− {
180− threadId: event.payload.threadId,
181− fromTurnCount,
182− toTurnCount,
183− },
184− );
185− return;
186− }
187−
188− const fromCheckpointRef =
189− fromTurnCount === 0
190− ? checkpointRefForThreadTurn(event.payload.threadId, 0)
191− : thread.checkpoints.find((checkpoint) => checkpoint.checkpointTurnCount === fromTurnCount)
192− ?.checkpointRef;
193−
194− if (!fromCheckpointRef) {
195− yield* Effect.logWarning("checkpoint diff blob skipped: missing from checkpoint ref", {
196− threadId: event.payload.threadId,
197− fromTurnCount,
198− toTurnCount,
199− });
200− return;
201− }
202−
203− const diff = yield* checkpointStore.diffCheckpoints({
204− cwd: sessionRuntime.value.cwd,
205− fromCheckpointRef,
206− toCheckpointRef: event.payload.checkpointRef,
207− fallbackFromToHead: false,
208− });
209−
210− yield* checkpointDiffBlobRepository.upsert({
211− threadId: event.payload.threadId,
212− fromTurnCount,
213− toTurnCount,
214− diff,
215− createdAt: event.payload.completedAt,
216− });
217− });
218−
219161 const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* (
220162 event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>,
221163 ) {
@@ -565,20 +507,6 @@ const make = Effect.gen(function* () { · 343 unmodified lines
565507 return;
566508 }
567509
568− if (event.type === "thread.turn-diff-completed") {
569− yield* persistDiffBlobFromTurnCompletion(event).pipe(
570− Effect.catch((error) =>
571− Effect.logWarning("checkpoint diff blob persistence failed", {
572− threadId: event.payload.threadId,
573− turnId: event.payload.turnId,
574− checkpointTurnCount: event.payload.checkpointTurnCount,
575− detail: error.message,
576− }),
577− ),
578− );
579− return;
580− }
581−
582510 if (event.type === "thread.checkpoint-revert-requested") {
583511 yield* handleRevertRequested(event).pipe(
584512 Effect.catch((error) =>
@@ -714,7 +642,6 @@ const make = Effect.gen(function* () { · 129 unmodified lines
714642 if (
715643 event.type !== "thread.turn-start-requested" &&
716644 event.type !== "thread.message-sent" &&
717− event.type !== "thread.turn-diff-completed" &&
718645 event.type !== "thread.checkpoint-revert-requested"
719646 ) {
720647 return Effect.void;
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+131−0
@@ -269,6 +269,137 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { · 268 unmodified lines
269269 }
270270 }),
271271 );
272+
273+ it.effect("keeps accumulated assistant text when completion payload text is empty", () =>
274+ Effect.gen(function* () {
275+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
276+ const eventStore = yield* OrchestrationEventStore;
277+ const sql = yield* SqlClient.SqlClient;
278+ const now = new Date().toISOString();
279+
280+ yield* eventStore.append({
281+ type: "project.created",
282+ eventId: EventId.makeUnsafe("evt-empty-1"),
283+ aggregateKind: "project",
284+ aggregateId: ProjectId.makeUnsafe("project-empty"),
285+ occurredAt: now,
286+ commandId: CommandId.makeUnsafe("cmd-empty-1"),
287+ causationEventId: null,
288+ correlationId: CorrelationId.makeUnsafe("cmd-empty-1"),
289+ metadata: {},
290+ payload: {
291+ projectId: ProjectId.makeUnsafe("project-empty"),
292+ title: "Project Empty",
293+ workspaceRoot: "/tmp/project-empty",
294+ defaultModel: null,
295+ scripts: [],
296+ createdAt: now,
297+ updatedAt: now,
298+ },
299+ });
300+
301+ yield* eventStore.append({
302+ type: "thread.created",
303+ eventId: EventId.makeUnsafe("evt-empty-2"),
304+ aggregateKind: "thread",
305+ aggregateId: ThreadId.makeUnsafe("thread-empty"),
306+ occurredAt: now,
307+ commandId: CommandId.makeUnsafe("cmd-empty-2"),
308+ causationEventId: null,
309+ correlationId: CorrelationId.makeUnsafe("cmd-empty-2"),
310+ metadata: {},
311+ payload: {
312+ threadId: ThreadId.makeUnsafe("thread-empty"),
313+ projectId: ProjectId.makeUnsafe("project-empty"),
314+ title: "Thread Empty",
315+ model: "gpt-5-codex",
316+ branch: null,
317+ worktreePath: null,
318+ createdAt: now,
319+ updatedAt: now,
320+ },
321+ });
322+
323+ yield* eventStore.append({
324+ type: "thread.message-sent",
325+ eventId: EventId.makeUnsafe("evt-empty-3"),
326+ aggregateKind: "thread",
327+ aggregateId: ThreadId.makeUnsafe("thread-empty"),
328+ occurredAt: now,
329+ commandId: CommandId.makeUnsafe("cmd-empty-3"),
330+ causationEventId: null,
331+ correlationId: CorrelationId.makeUnsafe("cmd-empty-3"),
332+ metadata: {},
333+ payload: {
334+ threadId: ThreadId.makeUnsafe("thread-empty"),
335+ messageId: MessageId.makeUnsafe("assistant-empty"),
336+ role: "assistant",
337+ text: "Hello",
338+ turnId: null,
339+ streaming: true,
340+ createdAt: now,
341+ updatedAt: now,
342+ },
343+ });
344+
345+ yield* eventStore.append({
346+ type: "thread.message-sent",
347+ eventId: EventId.makeUnsafe("evt-empty-4"),
348+ aggregateKind: "thread",
349+ aggregateId: ThreadId.makeUnsafe("thread-empty"),
350+ occurredAt: now,
351+ commandId: CommandId.makeUnsafe("cmd-empty-4"),
352+ causationEventId: null,
353+ correlationId: CorrelationId.makeUnsafe("cmd-empty-4"),
354+ metadata: {},
355+ payload: {
356+ threadId: ThreadId.makeUnsafe("thread-empty"),
357+ messageId: MessageId.makeUnsafe("assistant-empty"),
358+ role: "assistant",
359+ text: " world",
360+ turnId: null,
361+ streaming: true,
362+ createdAt: now,
363+ updatedAt: now,
364+ },
365+ });
366+
367+ yield* eventStore.append({
368+ type: "thread.message-sent",
369+ eventId: EventId.makeUnsafe("evt-empty-5"),
370+ aggregateKind: "thread",
371+ aggregateId: ThreadId.makeUnsafe("thread-empty"),
372+ occurredAt: now,
373+ commandId: CommandId.makeUnsafe("cmd-empty-5"),
374+ causationEventId: null,
375+ correlationId: CorrelationId.makeUnsafe("cmd-empty-5"),
376+ metadata: {},
377+ payload: {
378+ threadId: ThreadId.makeUnsafe("thread-empty"),
379+ messageId: MessageId.makeUnsafe("assistant-empty"),
380+ role: "assistant",
381+ text: "",
382+ turnId: null,
383+ streaming: false,
384+ createdAt: now,
385+ updatedAt: now,
386+ },
387+ });
388+
389+ yield* projectionPipeline.bootstrap;
390+
391+ const messageRows = yield* sql<{ readonly text: string; readonly isStreaming: unknown }>`
392+ SELECT
393+ text,
394+ is_streaming AS "isStreaming"
395+ FROM projection_thread_messages
396+ WHERE message_id = 'assistant-empty'
397+ `;
398+ assert.equal(messageRows.length, 1);
399+ assert.equal(messageRows[0]?.text, "Hello world");
400+ assert.isFalse(Boolean(messageRows[0]?.isStreaming));
401+ }),
402+ );
272403 });
273404
274405 const engineLayer = it.layer(
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+189−33
@@ -12,7 +12,11 @@ import { ProjectionPendingApprovalRepository } from "../../persistence/Services/ · 11 unmodified lines
1212 import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts";
1313 import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts";
1414 import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts";
15−import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts";
15+import { type ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";
16+import {
17+ type ProjectionThreadMessage,
18+ ProjectionThreadMessageRepository,
19+} from "../../persistence/Services/ProjectionThreadMessages.ts";
1620 import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts";
1721 import { type ProjectionThreadTurn } from "../../persistence/Services/ProjectionThreadTurns.ts";
1822 import { ProjectionThreadTurnRepository } from "../../persistence/Services/ProjectionThreadTurns.ts";
@@ -62,6 +66,91 @@ function nextTurnCount(turns: ReadonlyArray<ProjectionThreadTurn>): number { · 43 unmodified lines
6266 return turns.reduce((maxTurnCount, turn) => Math.max(maxTurnCount, turn.turnCount), 0) + 1;
6367 }
6468
69+function retainProjectionMessagesAfterRevert(
70+ messages: ReadonlyArray<ProjectionThreadMessage>,
71+ turns: ReadonlyArray<ProjectionThreadTurn>,
72+ turnCount: number,
73+): ReadonlyArray<ProjectionThreadMessage> {
74+ const retainedMessageIds = new Set<string>();
75+ const retainedTurnIds = new Set<string>();
76+ const keptTurns = turns.filter((turn) => turn.turnCount <= turnCount);
77+ for (const turn of keptTurns) {
78+ retainedTurnIds.add(turn.turnId);
79+ if (turn.userMessageId !== null) {
80+ retainedMessageIds.add(turn.userMessageId);
81+ }
82+ if (turn.assistantMessageId !== null) {
83+ retainedMessageIds.add(turn.assistantMessageId);
84+ }
85+ }
86+
87+ for (const message of messages) {
88+ if (message.role === "system") {
89+ retainedMessageIds.add(message.messageId);
90+ continue;
91+ }
92+ if (message.turnId !== null && retainedTurnIds.has(message.turnId)) {
93+ retainedMessageIds.add(message.messageId);
94+ }
95+ }
96+
97+ const retainedUserCount = messages.filter(
98+ (message) => message.role === "user" && retainedMessageIds.has(message.messageId),
99+ ).length;
100+ const missingUserCount = Math.max(0, turnCount - retainedUserCount);
101+ if (missingUserCount > 0) {
102+ const fallbackUserMessages = messages
103+ .filter((message) => message.role === "user" && !retainedMessageIds.has(message.messageId))
104+ .toSorted(
105+ (left, right) =>
106+ left.createdAt.localeCompare(right.createdAt) ||
107+ left.messageId.localeCompare(right.messageId),
108+ )
109+ .slice(0, missingUserCount);
110+ for (const message of fallbackUserMessages) {
111+ retainedMessageIds.add(message.messageId);
112+ }
113+ }
114+
115+ const retainedAssistantCount = messages.filter(
116+ (message) => message.role === "assistant" && retainedMessageIds.has(message.messageId),
117+ ).length;
118+ const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount);
119+ if (missingAssistantCount > 0) {
120+ const fallbackAssistantMessages = messages
121+ .filter(
122+ (message) =>
123+ message.role === "assistant" && !retainedMessageIds.has(message.messageId),
124+ )
125+ .toSorted(
126+ (left, right) =>
127+ left.createdAt.localeCompare(right.createdAt) ||
128+ left.messageId.localeCompare(right.messageId),
129+ )
130+ .slice(0, missingAssistantCount);
131+ for (const message of fallbackAssistantMessages) {
132+ retainedMessageIds.add(message.messageId);
133+ }
134+ }
135+
136+ return messages.filter((message) => retainedMessageIds.has(message.messageId));
137+}
138+
139+function retainProjectionActivitiesAfterRevert(
140+ activities: ReadonlyArray<ProjectionThreadActivity>,
141+ turns: ReadonlyArray<ProjectionThreadTurn>,
142+ turnCount: number,
143+): ReadonlyArray<ProjectionThreadActivity> {
144+ const retainedTurnIds = new Set(
145+ turns
146+ .filter((turn) => turn.turnCount <= turnCount)
147+ .map((turn) => turn.turnId),
148+ );
149+ return activities.filter(
150+ (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId),
151+ );
152+}
153+
65154 const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
66155 const sql = yield* SqlClient.SqlClient;
67156 const eventStore = yield* OrchestrationEventStore;
@@ -260,44 +349,111 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { · 192 unmodified lines
260349
261350 const applyThreadMessagesProjection: ProjectorDefinition["apply"] = (event) =>
262351 Effect.gen(function* () {
263− if (event.type !== "thread.message-sent") {
264− return;
352+ switch (event.type) {
353+ case "thread.message-sent": {
354+ const existingRows = yield* projectionThreadMessageRepository.listByThreadId({
355+ threadId: event.payload.threadId,
356+ });
357+ const existingMessage = existingRows.find((row) => row.messageId === event.payload.messageId);
358+ const nextText =
359+ existingMessage && event.payload.streaming
360+ ? `${existingMessage.text}${event.payload.text}`
361+ : existingMessage && event.payload.text.length === 0
362+ ? existingMessage.text
363+ : event.payload.text;
364+ yield* projectionThreadMessageRepository.upsert({
365+ messageId: event.payload.messageId,
366+ threadId: event.payload.threadId,
367+ turnId: event.payload.turnId,
368+ role: event.payload.role,
369+ text: nextText,
370+ isStreaming: event.payload.streaming,
371+ createdAt: existingMessage?.createdAt ?? event.payload.createdAt,
372+ updatedAt: event.payload.updatedAt,
373+ });
374+ return;
375+ }
376+
377+ case "thread.reverted": {
378+ const existingRows = yield* projectionThreadMessageRepository.listByThreadId({
379+ threadId: event.payload.threadId,
380+ });
381+ if (existingRows.length === 0) {
382+ return;
383+ }
384+
385+ const existingTurns = yield* projectionThreadTurnRepository.listByThreadId({
386+ threadId: event.payload.threadId,
387+ });
388+ const keptRows = retainProjectionMessagesAfterRevert(
389+ existingRows,
390+ existingTurns,
391+ event.payload.turnCount,
392+ );
393+ if (keptRows.length === existingRows.length) {
394+ return;
395+ }
396+
397+ yield* projectionThreadMessageRepository.deleteByThreadId({
398+ threadId: event.payload.threadId,
399+ });
400+ yield* Effect.forEach(keptRows, projectionThreadMessageRepository.upsert, {
401+ concurrency: 1,
402+ }).pipe(Effect.asVoid);
403+ return;
404+ }
405+
406+ default:
407+ return;
265408 }
266− const existingRows = yield* projectionThreadMessageRepository.listByThreadId({
267− threadId: event.payload.threadId,
268− });
269− const existingMessage = existingRows.find((row) => row.messageId === event.payload.messageId);
270− const nextText =
271− existingMessage && event.payload.streaming
272− ? `${existingMessage.text}${event.payload.text}`
273− : event.payload.text;
274− yield* projectionThreadMessageRepository.upsert({
275− messageId: event.payload.messageId,
276− threadId: event.payload.threadId,
277− turnId: event.payload.turnId,
278− role: event.payload.role,
279− text: nextText,
280− isStreaming: event.payload.streaming,
281− createdAt: existingMessage?.createdAt ?? event.payload.createdAt,
282− updatedAt: event.payload.updatedAt,
283− });
284409 });
285410
286411 const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = (event) =>
287412 Effect.gen(function* () {
288− if (event.type !== "thread.activity-appended") {
289− return;
413+ switch (event.type) {
414+ case "thread.activity-appended":
415+ yield* projectionThreadActivityRepository.upsert({
416+ activityId: event.payload.activity.id,
417+ threadId: event.payload.threadId,
418+ turnId: event.payload.activity.turnId,
419+ tone: event.payload.activity.tone,
420+ kind: event.payload.activity.kind,
421+ summary: event.payload.activity.summary,
422+ payload: event.payload.activity.payload,
423+ createdAt: event.payload.activity.createdAt,
424+ });
425+ return;
426+
427+ case "thread.reverted": {
428+ const existingRows = yield* projectionThreadActivityRepository.listByThreadId({
429+ threadId: event.payload.threadId,
430+ });
431+ if (existingRows.length === 0) {
432+ return;
433+ }
434+ const existingTurns = yield* projectionThreadTurnRepository.listByThreadId({
435+ threadId: event.payload.threadId,
436+ });
437+ const keptRows = retainProjectionActivitiesAfterRevert(
438+ existingRows,
439+ existingTurns,
440+ event.payload.turnCount,
441+ );
442+ if (keptRows.length === existingRows.length) {
443+ return;
444+ }
445+ yield* projectionThreadActivityRepository.deleteByThreadId({
446+ threadId: event.payload.threadId,
447+ });
448+ yield* Effect.forEach(keptRows, projectionThreadActivityRepository.upsert, {
449+ concurrency: 1,
450+ }).pipe(Effect.asVoid);
451+ return;
452+ }
453+
454+ default:
455+ return;
290456 }
291− yield* projectionThreadActivityRepository.upsert({
292− activityId: event.payload.activity.id,
293− threadId: event.payload.threadId,
294− turnId: event.payload.activity.turnId,
295− tone: event.payload.activity.tone,
296− kind: event.payload.activity.kind,
297− summary: event.payload.activity.summary,
298− payload: event.payload.activity.payload,
299− createdAt: event.payload.activity.createdAt,
300− });
301457 });
302458
303459 const applyThreadSessionsProjection: ProjectorDefinition["apply"] = (event) =>
apps/server/src/orchestration/projector.test.ts+209−0
@@ -282,4 +282,213 @@ describe("orchestration projector", () => { · 281 unmodified lines
282282 expect(message?.streaming).toBe(false);
283283 expect(message?.updatedAt).toBe(completeAt);
284284 });
285+
286+ it("prunes reverted turn messages from in-memory thread snapshot", async () => {
287+ const createdAt = "2026-02-23T10:00:00.000Z";
288+ const model = createEmptyReadModel(createdAt);
289+
290+ const afterCreate = await Effect.runPromise(
291+ projectEvent(
292+ model,
293+ makeEvent({
294+ sequence: 1,
295+ type: "thread.created",
296+ aggregateType: "thread",
297+ aggregateId: "thread-1",
298+ occurredAt: createdAt,
299+ commandId: "cmd-create",
300+ payload: {
301+ threadId: "thread-1",
302+ projectId: "project-1",
303+ title: "demo",
304+ model: "gpt-5.3-codex",
305+ branch: null,
306+ worktreePath: null,
307+ createdAt,
308+ updatedAt: createdAt,
309+ },
310+ }),
311+ ),
312+ );
313+
314+ const events: ReadonlyArray<OrchestrationEvent> = [
315+ makeEvent({
316+ sequence: 2,
317+ type: "thread.message-sent",
318+ aggregateType: "thread",
319+ aggregateId: "thread-1",
320+ occurredAt: "2026-02-23T10:00:01.000Z",
321+ commandId: "cmd-user-1",
322+ payload: {
323+ threadId: "thread-1",
324+ messageId: "user-msg-1",
325+ role: "user",
326+ text: "First edit",
327+ turnId: null,
328+ streaming: false,
329+ createdAt: "2026-02-23T10:00:01.000Z",
330+ updatedAt: "2026-02-23T10:00:01.000Z",
331+ },
332+ }),
333+ makeEvent({
334+ sequence: 3,
335+ type: "thread.message-sent",
336+ aggregateType: "thread",
337+ aggregateId: "thread-1",
338+ occurredAt: "2026-02-23T10:00:02.000Z",
339+ commandId: "cmd-assistant-1",
340+ payload: {
341+ threadId: "thread-1",
342+ messageId: "assistant-msg-1",
343+ role: "assistant",
344+ text: "Updated README to v2.\n",
345+ turnId: "turn-1",
346+ streaming: false,
347+ createdAt: "2026-02-23T10:00:02.000Z",
348+ updatedAt: "2026-02-23T10:00:02.000Z",
349+ },
350+ }),
351+ makeEvent({
352+ sequence: 4,
353+ type: "thread.turn-diff-completed",
354+ aggregateType: "thread",
355+ aggregateId: "thread-1",
356+ occurredAt: "2026-02-23T10:00:02.500Z",
357+ commandId: "cmd-turn-1-complete",
358+ payload: {
359+ threadId: "thread-1",
360+ turnId: "turn-1",
361+ checkpointTurnCount: 1,
362+ checkpointRef: "refs/t3/checkpoints/thread-1/turn/1",
363+ status: "ready",
364+ files: [],
365+ assistantMessageId: "assistant-msg-1",
366+ completedAt: "2026-02-23T10:00:02.500Z",
367+ },
368+ }),
369+ makeEvent({
370+ sequence: 5,
371+ type: "thread.activity-appended",
372+ aggregateType: "thread",
373+ aggregateId: "thread-1",
374+ occurredAt: "2026-02-23T10:00:02.750Z",
375+ commandId: "cmd-activity-1",
376+ payload: {
377+ threadId: "thread-1",
378+ activity: {
379+ id: "activity-1",
380+ tone: "tool",
381+ kind: "tool.started",
382+ summary: "Edit file started",
383+ payload: { toolKind: "command" },
384+ turnId: "turn-1",
385+ createdAt: "2026-02-23T10:00:02.750Z",
386+ },
387+ },
388+ }),
389+ makeEvent({
390+ sequence: 6,
391+ type: "thread.message-sent",
392+ aggregateType: "thread",
393+ aggregateId: "thread-1",
394+ occurredAt: "2026-02-23T10:00:03.000Z",
395+ commandId: "cmd-user-2",
396+ payload: {
397+ threadId: "thread-1",
398+ messageId: "user-msg-2",
399+ role: "user",
400+ text: "Second edit",
401+ turnId: null,
402+ streaming: false,
403+ createdAt: "2026-02-23T10:00:03.000Z",
404+ updatedAt: "2026-02-23T10:00:03.000Z",
405+ },
406+ }),
407+ makeEvent({
408+ sequence: 7,
409+ type: "thread.message-sent",
410+ aggregateType: "thread",
411+ aggregateId: "thread-1",
412+ occurredAt: "2026-02-23T10:00:04.000Z",
413+ commandId: "cmd-assistant-2",
414+ payload: {
415+ threadId: "thread-1",
416+ messageId: "assistant-msg-2",
417+ role: "assistant",
418+ text: "Updated README to v3.\n",
419+ turnId: "turn-2",
420+ streaming: false,
421+ createdAt: "2026-02-23T10:00:04.000Z",
422+ updatedAt: "2026-02-23T10:00:04.000Z",
423+ },
424+ }),
425+ makeEvent({
426+ sequence: 8,
427+ type: "thread.turn-diff-completed",
428+ aggregateType: "thread",
429+ aggregateId: "thread-1",
430+ occurredAt: "2026-02-23T10:00:04.500Z",
431+ commandId: "cmd-turn-2-complete",
432+ payload: {
433+ threadId: "thread-1",
434+ turnId: "turn-2",
435+ checkpointTurnCount: 2,
436+ checkpointRef: "refs/t3/checkpoints/thread-1/turn/2",
437+ status: "ready",
438+ files: [],
439+ assistantMessageId: "assistant-msg-2",
440+ completedAt: "2026-02-23T10:00:04.500Z",
441+ },
442+ }),
443+ makeEvent({
444+ sequence: 9,
445+ type: "thread.activity-appended",
446+ aggregateType: "thread",
447+ aggregateId: "thread-1",
448+ occurredAt: "2026-02-23T10:00:04.750Z",
449+ commandId: "cmd-activity-2",
450+ payload: {
451+ threadId: "thread-1",
452+ activity: {
453+ id: "activity-2",
454+ tone: "tool",
455+ kind: "tool.completed",
456+ summary: "Edit file complete",
457+ payload: { toolKind: "command" },
458+ turnId: "turn-2",
459+ createdAt: "2026-02-23T10:00:04.750Z",
460+ },
461+ },
462+ }),
463+ makeEvent({
464+ sequence: 10,
465+ type: "thread.reverted",
466+ aggregateType: "thread",
467+ aggregateId: "thread-1",
468+ occurredAt: "2026-02-23T10:00:05.000Z",
469+ commandId: "cmd-revert",
470+ payload: {
471+ threadId: "thread-1",
472+ turnCount: 1,
473+ },
474+ }),
475+ ];
476+
477+ const afterRevert = await events.reduce<Promise<ReturnType<typeof createEmptyReadModel>>>(
478+ (statePromise, event) =>
479+ statePromise.then((state) => Effect.runPromise(projectEvent(state, event))),
480+ Promise.resolve(afterCreate),
481+ );
482+
483+ const thread = afterRevert.threads[0];
484+ expect(thread?.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([
485+ { role: "user", text: "First edit" },
486+ { role: "assistant", text: "Updated README to v2.\n" },
487+ ]);
488+ expect(thread?.activities.map((activity) => ({ id: activity.id, turnId: activity.turnId }))).toEqual([
489+ { id: "activity-1", turnId: "turn-1" },
490+ ]);
491+ expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]);
492+ expect(thread?.latestTurnId).toBe("turn-1");
493+ });
285494 });
apps/server/src/orchestration/projector.ts+74−0
@@ -47,6 +47,68 @@ function decodeForEvent<A>( · 46 unmodified lines
4747 ) as Effect.Effect<A, OrchestrationProjectorDecodeError>;
4848 }
4949
50+function retainThreadMessagesAfterRevert(
51+ messages: ReadonlyArray<OrchestrationMessage>,
52+ retainedTurnIds: ReadonlySet<string>,
53+ turnCount: number,
54+): ReadonlyArray<OrchestrationMessage> {
55+ const retainedMessageIds = new Set<string>();
56+ for (const message of messages) {
57+ if (message.role === "system") {
58+ retainedMessageIds.add(message.id);
59+ continue;
60+ }
61+ if (message.turnId !== null && retainedTurnIds.has(message.turnId)) {
62+ retainedMessageIds.add(message.id);
63+ }
64+ }
65+
66+ const retainedUserCount = messages.filter(
67+ (message) => message.role === "user" && retainedMessageIds.has(message.id),
68+ ).length;
69+ const missingUserCount = Math.max(0, turnCount - retainedUserCount);
70+ if (missingUserCount > 0) {
71+ const fallbackUserMessages = messages
72+ .filter((message) => message.role === "user" && !retainedMessageIds.has(message.id))
73+ .toSorted(
74+ (left, right) =>
75+ left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
76+ )
77+ .slice(0, missingUserCount);
78+ for (const message of fallbackUserMessages) {
79+ retainedMessageIds.add(message.id);
80+ }
81+ }
82+
83+ const retainedAssistantCount = messages.filter(
84+ (message) => message.role === "assistant" && retainedMessageIds.has(message.id),
85+ ).length;
86+ const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount);
87+ if (missingAssistantCount > 0) {
88+ const fallbackAssistantMessages = messages
89+ .filter((message) => message.role === "assistant" && !retainedMessageIds.has(message.id))
90+ .toSorted(
91+ (left, right) =>
92+ left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
93+ )
94+ .slice(0, missingAssistantCount);
95+ for (const message of fallbackAssistantMessages) {
96+ retainedMessageIds.add(message.id);
97+ }
98+ }
99+
100+ return messages.filter((message) => retainedMessageIds.has(message.id));
101+}
102+
103+function retainThreadActivitiesAfterRevert(
104+ activities: ReadonlyArray<OrchestrationThread["activities"][number]>,
105+ retainedTurnIds: ReadonlySet<string>,
106+): ReadonlyArray<OrchestrationThread["activities"][number]> {
107+ return activities.filter(
108+ (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId),
109+ );
110+}
111+
50112 export function createEmptyReadModel(nowIso: string): OrchestrationReadModel {
51113 return {
52114 snapshotSequence: 0,
@@ -347,6 +409,16 @@ export function projectEvent( · 294 unmodified lines
347409 const checkpoints = thread.checkpoints
348410 .filter((entry) => entry.checkpointTurnCount <= payload.turnCount)
349411 .toSorted((left, right) => left.checkpointTurnCount - right.checkpointTurnCount);
412+ const retainedTurnIds = new Set(checkpoints.map((checkpoint) => checkpoint.turnId));
413+ const messages = retainThreadMessagesAfterRevert(
414+ thread.messages,
415+ retainedTurnIds,
416+ payload.turnCount,
417+ );
418+ const activities = retainThreadActivitiesAfterRevert(
419+ thread.activities,
420+ retainedTurnIds,
421+ );
350422
351423 const latestTurnId =
352424 checkpoints.length > 0 ? checkpoints[checkpoints.length - 1]?.turnId ?? null : null;
@@ -355,6 +427,8 @@ export function projectEvent( · 2 unmodified lines
355427 ...nextBase,
356428 threads: updateThread(nextBase.threads, payload.threadId, {
357429 checkpoints,
430+ messages,
431+ activities,
358432 latestTurnId,
359433 updatedAt: event.occurredAt,
360434 }),
apps/server/src/persistence/Errors.ts+0−2
@@ -205,8 +205,6 @@ export type OrchestrationCommandReceiptRepositoryError = · 204 unmodified lines
205205 | PersistenceSqlError
206206 | PersistenceDecodeError;
207207
208−export type CheckpointDiffBlobRepositoryError = PersistenceSqlError | PersistenceDecodeError;
209−
210208 export type ProviderSessionRuntimeRepositoryError =
211209 | PersistenceSqlError
212210 | PersistenceDecodeError
apps/server/src/persistence/Layers/CheckpointDiffBlobs.ts+0−79
@@ -1,79 +0,0 @@
1−import * as SqlClient from "effect/unstable/sql/SqlClient";
2−import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3−import { Effect, Layer } from "effect";
4−
5−import { toPersistenceSqlError } from "../Errors.ts";
6−
7−import {
8− CheckpointDiffBlobRepository,
9− CheckpointDiffBlob,
10− GetCheckpointDiffBlobInput,
11− type CheckpointDiffBlobRepositoryShape,
12−} from "../Services/CheckpointDiffBlobs.ts";
13−
14−const makeCheckpointDiffBlobRepository = Effect.gen(function* () {
15− const sql = yield* SqlClient.SqlClient;
16−
17− const upsertDiffBlobRow = SqlSchema.void({
18− Request: CheckpointDiffBlob,
19− execute: (blob) =>
20− sql`
21− INSERT INTO checkpoint_diff_blobs (
22− thread_id,
23− from_turn_count,
24− to_turn_count,
25− diff,
26− created_at
27− )
28− VALUES (
29− ${blob.threadId},
30− ${blob.fromTurnCount},
31− ${blob.toTurnCount},
32− ${blob.diff},
33− ${blob.createdAt}
34− )
35− ON CONFLICT (thread_id, from_turn_count, to_turn_count)
36− DO UPDATE SET
37− diff = excluded.diff,
38− created_at = excluded.created_at
39− `,
40− });
41−
42− const getDiffBlobRow = SqlSchema.findOneOption({
43− Request: GetCheckpointDiffBlobInput,
44− Result: CheckpointDiffBlob,
45− execute: (request) =>
46− sql`
47− SELECT
48− thread_id AS "threadId",
49− from_turn_count AS "fromTurnCount",
50− to_turn_count AS "toTurnCount",
51− diff,
52− created_at AS "createdAt"
53− FROM checkpoint_diff_blobs
54− WHERE thread_id = ${request.threadId}
55− AND from_turn_count = ${request.fromTurnCount}
56− AND to_turn_count = ${request.toTurnCount}
57− `,
58− });
59−
60− const upsert: CheckpointDiffBlobRepositoryShape["upsert"] = (blob) =>
61− upsertDiffBlobRow(blob).pipe(
62− Effect.mapError(toPersistenceSqlError("CheckpointDiffBlobRepository.upsert:query")),
63− );
64−
65− const get: CheckpointDiffBlobRepositoryShape["get"] = (input) =>
66− getDiffBlobRow(input).pipe(
67− Effect.mapError(toPersistenceSqlError("CheckpointDiffBlobRepository.get:query")),
68− );
69−
70− return {
71− upsert,
72− get,
73− } satisfies CheckpointDiffBlobRepositoryShape;
74−});
75−
76−export const CheckpointDiffBlobRepositoryLive = Layer.effect(
77− CheckpointDiffBlobRepository,
78− makeCheckpointDiffBlobRepository,
79−);
apps/server/src/persistence/Services/CheckpointDiffBlobs.ts+0−36
@@ -1,36 +0,0 @@
1−import { IsoDateTime, NonNegativeInt, ThreadId } from "@t3tools/contracts";
2−import { Option, Schema, ServiceMap } from "effect";
3−import type { Effect } from "effect";
4−
5−import type { CheckpointDiffBlobRepositoryError } from "../Errors.ts";
6−
7−export const CheckpointDiffBlob = Schema.Struct({
8− threadId: ThreadId,
9− fromTurnCount: NonNegativeInt,
10− toTurnCount: NonNegativeInt,
11− diff: Schema.String,
12− createdAt: IsoDateTime,
13−});
14−export type CheckpointDiffBlob = typeof CheckpointDiffBlob.Type;
15−
16−export const GetCheckpointDiffBlobInput = Schema.Struct({
17− threadId: ThreadId,
18− fromTurnCount: NonNegativeInt,
19− toTurnCount: NonNegativeInt,
20−});
21−export type GetCheckpointDiffBlobInput = typeof GetCheckpointDiffBlobInput.Type;
22−
23−export interface CheckpointDiffBlobRepositoryShape {
24− readonly upsert: (
25− blob: CheckpointDiffBlob,
26− ) => Effect.Effect<void, CheckpointDiffBlobRepositoryError>;
27−
28− readonly get: (
29− input: GetCheckpointDiffBlobInput,
30− ) => Effect.Effect<Option.Option<CheckpointDiffBlob>, CheckpointDiffBlobRepositoryError>;
31−}
32−
33−export class CheckpointDiffBlobRepository extends ServiceMap.Service<
34− CheckpointDiffBlobRepository,
35− CheckpointDiffBlobRepositoryShape
36−>()("persistence/CheckpointDiffBlobRepository") {}
apps/server/src/wsServer.test.ts+134−17
@@ -273,6 +273,7 @@ describe("WebSocket Server", () => { · 272 unmodified lines
273273 options: {
274274 persistenceLayer?: Layer.Layer<SqlClient.SqlClient, any>;
275275 cwd?: string;
276+ autoBootstrapProjectFromCwd?: boolean;
276277 devUrl?: string;
277278 authToken?: string;
278279 stateDir?: string;
@@ -290,6 +291,9 @@ describe("WebSocket Server", () => { · 11 unmodified lines
290291 cwd: options.cwd ?? "/test/project",
291292 stateDir,
292293 persistenceLayer: options.persistenceLayer ?? SqlitePersistenceMemory,
294+ ...(options.autoBootstrapProjectFromCwd !== undefined
295+ ? { autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd }
296+ : {}),
293297 ...(options.devUrl ? { devUrl: options.devUrl } : {}),
294298 ...(options.authToken ? { authToken: options.authToken } : {}),
295299 ...(options.providerLayer ? { providerLayer: options.providerLayer } : {}),
@@ -333,6 +337,37 @@ describe("WebSocket Server", () => { · 37 unmodified lines
333337 });
334338 });
335339
340+ it("bootstraps the cwd project on startup when enabled", async () => {
341+ server = createTestServer({
342+ cwd: "/test/bootstrap-workspace",
343+ autoBootstrapProjectFromCwd: true,
344+ });
345+ await server.start();
346+ const addr = server.httpServer.address();
347+ const port = typeof addr === "object" && addr !== null ? addr.port : 0;
348+ expect(port).toBeGreaterThan(0);
349+
350+ const ws = await connectWs(port);
351+ connections.push(ws);
352+ await waitForMessage(ws); // welcome
353+
354+ const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot);
355+ expect(snapshotResponse.error).toBeUndefined();
356+ const snapshot = snapshotResponse.result as {
357+ projects: Array<{ workspaceRoot: string; title: string; defaultModel: string | null }>;
358+ };
359+
360+ expect(snapshot.projects).toEqual(
361+ expect.arrayContaining([
362+ expect.objectContaining({
363+ workspaceRoot: "/test/bootstrap-workspace",
364+ title: "bootstrap-workspace",
365+ defaultModel: "gpt-5-codex",
366+ }),
367+ ]),
368+ );
369+ });
370+
336371 it("logs outbound websocket push events in dev mode", async () => {
337372 const logSpy = vi.spyOn(console, "log").mockImplementation(() => {
338373 // Keep test output clean while verifying websocket logs.
@@ -670,6 +705,86 @@ describe("WebSocket Server", () => { · 331 unmodified lines
670705 expect(response.error!.message).toContain("Invalid request format");
671706 });
672707
708+ it("returns error when requesting turn diff for unknown thread", async () => {
709+ server = createTestServer({ cwd: "/test" });
710+ await server.start();
711+ const addr = server.httpServer.address();
712+ const port = typeof addr === "object" && addr !== null ? addr.port : 0;
713+
714+ const ws = await connectWs(port);
715+ connections.push(ws);
716+ await waitForMessage(ws);
717+
718+ const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
719+ threadId: "thread-missing",
720+ fromTurnCount: 1,
721+ toTurnCount: 2,
722+ });
723+ expect(response.result).toBeUndefined();
724+ expect(response.error?.message).toContain("Thread 'thread-missing' not found.");
725+ });
726+
727+ it("returns error when requesting full thread diff for unknown thread", async () => {
728+ server = createTestServer({ cwd: "/test" });
729+ await server.start();
730+ const addr = server.httpServer.address();
731+ const port = typeof addr === "object" && addr !== null ? addr.port : 0;
732+
733+ const ws = await connectWs(port);
734+ connections.push(ws);
735+ await waitForMessage(ws);
736+
737+ const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getFullThreadDiff, {
738+ threadId: "thread-missing",
739+ toTurnCount: 2,
740+ });
741+ expect(response.result).toBeUndefined();
742+ expect(response.error?.message).toContain("Thread 'thread-missing' not found.");
743+ });
744+
745+ it("returns retryable error when requested turn exceeds current checkpoint turn count", async () => {
746+ server = createTestServer({ cwd: "/test" });
747+ await server.start();
748+ const addr = server.httpServer.address();
749+ const port = typeof addr === "object" && addr !== null ? addr.port : 0;
750+
751+ const ws = await connectWs(port);
752+ connections.push(ws);
753+ await waitForMessage(ws);
754+
755+ const createdAt = new Date().toISOString();
756+ const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
757+ type: "project.create",
758+ commandId: "cmd-diff-project-create",
759+ projectId: "project-diff",
760+ title: "Diff Project",
761+ workspaceRoot: "/tmp/ws-diff-project",
762+ defaultModel: "gpt-5-codex",
763+ createdAt,
764+ });
765+ expect(createProjectResponse.error).toBeUndefined();
766+ const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, {
767+ type: "thread.create",
768+ commandId: "cmd-diff-thread-create",
769+ threadId: "thread-diff",
770+ projectId: "project-diff",
771+ title: "Diff Thread",
772+ model: "gpt-5-codex",
773+ branch: null,
774+ worktreePath: null,
775+ createdAt,
776+ });
777+ expect(createThreadResponse.error).toBeUndefined();
778+
779+ const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, {
780+ threadId: "thread-diff",
781+ fromTurnCount: 0,
782+ toTurnCount: 1,
783+ });
784+ expect(response.result).toBeUndefined();
785+ expect(response.error?.message).toContain("exceeds current turn count");
786+ });
787+
673788 it("keeps orchestration domain push behavior for provider runtime events", async () => {
674789 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
675790 const sessionId = "sess-test";
@@ -1011,21 +1126,23 @@ describe("WebSocket Server", () => { · 335 unmodified lines
10111126 expect(pullResponse.error?.message).not.toContain("Unknown method");
10121127 });
10131128
1014− it("returns error for git.status", async () => {
1129+ it("supports git.status over websocket", async () => {
1130+ const statusResult = {
1131+ branch: "feature/test",
1132+ hasWorkingTreeChanges: true,
1133+ workingTree: {
1134+ files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }],
1135+ insertions: 7,
1136+ deletions: 2,
1137+ },
1138+ hasUpstream: false,
1139+ aheadCount: 0,
1140+ behindCount: 0,
1141+ openPr: null,
1142+ };
1143+
10151144 const gitManager = {
1016− status: vi.fn().mockResolvedValue({
1017− branch: "feature/test",
1018− hasWorkingTreeChanges: true,
1019− workingTree: {
1020− files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }],
1021− insertions: 7,
1022− deletions: 2,
1023− },
1024− hasUpstream: false,
1025− aheadCount: 0,
1026− behindCount: 0,
1027− openPr: null,
1028− }),
1145+ status: vi.fn().mockResolvedValue(statusResult),
10291146 runStackedAction: vi.fn(),
10301147 };
10311148
@@ -1041,9 +1158,9 @@ describe("WebSocket Server", () => { · 9 unmodified lines
10411158 const response = await sendRequest(ws, WS_METHODS.gitStatus, {
10421159 cwd: "/test",
10431160 });
1044− expect(response.result).toBeUndefined();
1045− expect(response.error?.message).toContain("Unknown method");
1046− expect(gitManager.status).not.toHaveBeenCalled();
1161+ expect(response.error).toBeUndefined();
1162+ expect(response.result).toEqual(statusResult);
1163+ expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" });
10471164 });
10481165
10491166 it("returns errors from git.runStackedAction", async () => {
apps/server/src/wsServer.ts+172−51
@@ -6,6 +6,7 @@ import path from "node:path"; · 5 unmodified lines
66 import type { Duplex } from "node:stream";
77
88 import {
9+ CheckpointRef,
910 EDITORS,
1011 ORCHESTRATION_WS_CHANNELS,
1112 ORCHESTRATION_WS_METHODS,
@@ -17,7 +18,7 @@ import { · 5 unmodified lines
1718 WsResponse,
1819 } from "@t3tools/contracts";
1920 import { NodeServices } from "@effect/platform-node";
20−import { Effect, Exit, Layer, ManagedRuntime, Option, Schema, Scope, Stream } from "effect";
21+import { Effect, Exit, Layer, ManagedRuntime, Schema, Scope, Stream } from "effect";
2122 import { WebSocketServer, type WebSocket } from "ws";
2223
2324 import { createLogger } from "./logger";
@@ -52,7 +53,6 @@ import { · 28 unmodified lines
5253 import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
5354 import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite";
5455 import assert from "node:assert";
55−import { CheckpointDiffBlobRepositoryLive } from "./persistence/Layers/CheckpointDiffBlobs";
5656 import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore";
5757 import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts";
5858 import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
@@ -62,10 +62,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD · 3 unmodified lines
6262 import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
6363 import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
6464 import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime";
65−import {
66− CheckpointDiffBlobRepository,
67− type CheckpointDiffBlobRepositoryShape,
68−} from "./persistence/Services/CheckpointDiffBlobs";
65+import { CheckpointStore, type CheckpointStoreShape } from "./checkpointing/Services/CheckpointStore";
6966 import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
7067 import * as SqlClient from "effect/unstable/sql/SqlClient";
7168 import * as SqlError from "effect/unstable/sql/SqlError";
@@ -91,6 +88,7 @@ export interface ServerOptions { · 19 unmodified lines
9188 port: number;
9289 host?: string | undefined;
9390 cwd: string;
91+ autoBootstrapProjectFromCwd?: boolean | undefined;
9492 stateDir?: string | undefined;
9593 persistenceLayer?:
9694 | Layer.Layer<SqlClient.SqlClient, SqlError.SqlError | Migrator.MigrationError>
@@ -171,11 +169,18 @@ function stripRequestTag<T extends { _tag: string }>( · 74 unmodified lines
171169 return rest;
172170 }
173171
172+const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints";
173+
174+function checkpointRefForThreadTurn(threadId: string, turnCount: number): CheckpointRef {
175+ const encodedThreadId = Buffer.from(threadId, "utf8").toString("base64url");
176+ return CheckpointRef.makeUnsafe(`${CHECKPOINT_REFS_PREFIX}/${encodedThreadId}/turn/${turnCount}`);
177+}
178+
174179 type EffectRuntime = ManagedRuntime.ManagedRuntime<
175180 | OrchestrationEngineService
176181 | ProjectionSnapshotQuery
177182 | ProviderService
178− | CheckpointDiffBlobRepository
183+ | CheckpointStore
179184 | OrchestrationReactor,
180185 unknown
181186 >;
@@ -185,6 +190,7 @@ export function createServer(options: ServerOptions) { · 3 unmodified lines
185190 port,
186191 host,
187192 cwd,
193+ autoBootstrapProjectFromCwd = false,
188194 stateDir,
189195 persistenceLayer: customPersistenceLayer,
190196 staticDir,
@@ -201,7 +207,7 @@ export function createServer(options: ServerOptions) { · 10 unmodified lines
201207 let orchestrationEngine: OrchestrationEngineShape | null = null;
202208 let projectionSnapshotQuery: ProjectionSnapshotQueryShape | null = null;
203209 let providerService: ProviderServiceShape | null = null;
204− let checkpointDiffBlobRepository: CheckpointDiffBlobRepositoryShape | null = null;
210+ let checkpointStore: CheckpointStoreShape | null = null;
205211 const clients = new Set<WebSocket>();
206212 const logger = createLogger("ws");
207213 const logWebSocketEvents =
@@ -235,11 +241,40 @@ export function createServer(options: ServerOptions) { · 27 unmodified lines
235241 return projectionSnapshotQuery;
236242 };
237243
238− const getCheckpointDiffBlobRepository = () => {
239− assert(checkpointDiffBlobRepository, "Checkpoint diff blob repository is not started");
240− return checkpointDiffBlobRepository;
244+ const getCheckpointStore = () => {
245+ assert(checkpointStore, "Checkpoint store is not started");
246+ return checkpointStore;
241247 };
242248
249+ async function bootstrapProjectForCwd() {
250+ if (!autoBootstrapProjectFromCwd) {
251+ return;
252+ }
253+
254+ const runtime = getEffectRuntime();
255+ const snapshotQuery = getProjectionSnapshotQuery();
256+ const engine = getOrchestrationEngine();
257+ const snapshot = await runtime.runPromise(snapshotQuery.getSnapshot());
258+ const existing = snapshot.projects.find((project) => project.workspaceRoot === cwd);
259+ if (existing) {
260+ return;
261+ }
262+
263+ const createdAt = new Date().toISOString();
264+ const projectName = path.basename(cwd) || "project";
265+ await runtime.runPromise(
266+ engine.dispatchUnknownCommand({
267+ type: "project.create",
268+ commandId: crypto.randomUUID(),
269+ projectId: crypto.randomUUID(),
270+ title: projectName,
271+ workspaceRoot: cwd,
272+ defaultModel: "gpt-5-codex",
273+ createdAt,
274+ }),
275+ );
276+ }
277+
243278 const attachSubscriptionss = Effect.gen(function* () {
244279 const orchestrationEngine = yield* OrchestrationEngineService;
245280 const orchestrationReactor = yield* OrchestrationReactor;
@@ -439,7 +474,114 @@ export function createServer(options: ServerOptions) { · 193 unmodified lines
439474 const orchestrationEngine = getOrchestrationEngine();
440475 const projectionReadModelQuery = getProjectionSnapshotQuery();
441476 const effectRuntime = getEffectRuntime();
442− const diffBlobRepository = getCheckpointDiffBlobRepository();
477+ const liveCheckpointStore = getCheckpointStore();
478+
479+ const computeTurnDiff = async (input: {
480+ readonly threadId: string;
481+ readonly fromTurnCount: number;
482+ readonly toTurnCount: number;
483+ }) => {
484+ if (input.fromTurnCount > input.toTurnCount) {
485+ throw new Error(
486+ `Invalid turn diff range for thread '${input.threadId}': from ${input.fromTurnCount} exceeds to ${input.toTurnCount}.`,
487+ );
488+ }
489+
490+ if (input.fromTurnCount === input.toTurnCount) {
491+ return {
492+ threadId: input.threadId,
493+ fromTurnCount: input.fromTurnCount,
494+ toTurnCount: input.toTurnCount,
495+ diff: "",
496+ };
497+ }
498+
499+ const snapshot = await effectRuntime.runPromise(projectionReadModelQuery.getSnapshot());
500+ const thread = snapshot.threads.find((entry) => entry.id === input.threadId);
501+ if (!thread) {
502+ throw new Error(`Thread '${input.threadId}' not found.`);
503+ }
504+
505+ const maxTurnCount = thread.checkpoints.reduce(
506+ (max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount),
507+ 0,
508+ );
509+ if (input.toTurnCount > maxTurnCount) {
510+ throw new Error(
511+ `Turn diff range exceeds current turn count for thread '${input.threadId}': requested ${input.toTurnCount}, current ${maxTurnCount}.`,
512+ );
513+ }
514+
515+ const workspaceCwd =
516+ thread.worktreePath?.trim() ||
517+ snapshot.projects.find((project) => project.id === thread.projectId)?.workspaceRoot?.trim();
518+ if (!workspaceCwd) {
519+ throw new Error(
520+ `Invariant violation: workspace path missing for thread '${input.threadId}' when computing turn diff.`,
521+ );
522+ }
523+
524+ const fromCheckpointRef =
525+ input.fromTurnCount === 0
526+ ? checkpointRefForThreadTurn(input.threadId, 0)
527+ : thread.checkpoints.find(
528+ (checkpoint) => checkpoint.checkpointTurnCount === input.fromTurnCount,
529+ )?.checkpointRef;
530+ if (!fromCheckpointRef) {
531+ throw new Error(
532+ `Checkpoint is unavailable for turn ${input.fromTurnCount} in thread ${input.threadId}.`,
533+ );
534+ }
535+
536+ const toCheckpointRef = thread.checkpoints.find(
537+ (checkpoint) => checkpoint.checkpointTurnCount === input.toTurnCount,
538+ )?.checkpointRef;
539+ if (!toCheckpointRef) {
540+ throw new Error(
541+ `Checkpoint is unavailable for turn ${input.toTurnCount} in thread ${input.threadId}.`,
542+ );
543+ }
544+
545+ const [fromExists, toExists] = await Promise.all([
546+ effectRuntime.runPromise(
547+ liveCheckpointStore.hasCheckpointRef({
548+ cwd: workspaceCwd,
549+ checkpointRef: fromCheckpointRef,
550+ }),
551+ ),
552+ effectRuntime.runPromise(
553+ liveCheckpointStore.hasCheckpointRef({
554+ cwd: workspaceCwd,
555+ checkpointRef: toCheckpointRef,
556+ }),
557+ ),
558+ ]);
559+ if (!fromExists) {
560+ throw new Error(
561+ `Filesystem checkpoint is unavailable for turn ${input.fromTurnCount} in thread ${input.threadId}.`,
562+ );
563+ }
564+ if (!toExists) {
565+ throw new Error(
566+ `Filesystem checkpoint is unavailable for turn ${input.toTurnCount} in thread ${input.threadId}.`,
567+ );
568+ }
569+
570+ const diff = await effectRuntime.runPromise(
571+ liveCheckpointStore.diffCheckpoints({
572+ cwd: workspaceCwd,
573+ fromCheckpointRef,
574+ toCheckpointRef,
575+ fallbackFromToHead: false,
576+ }),
577+ );
578+ return {
579+ threadId: input.threadId,
580+ fromTurnCount: input.fromTurnCount,
581+ toTurnCount: input.toTurnCount,
582+ diff,
583+ };
584+ };
443585
444586 switch (request.body._tag) {
445587 case ORCHESTRATION_WS_METHODS.getSnapshot: {
@@ -456,19 +598,16 @@ export function createServer(options: ServerOptions) { · 10 unmodified lines
456598
457599 case ORCHESTRATION_WS_METHODS.getTurnDiff: {
458600 const body = stripRequestTag(request.body);
459− const blob = await effectRuntime.runPromise(diffBlobRepository.get(body));
460− if (Option.isNone(blob)) {
461− throw new Error(
462− `Turn diff not found for thread '${body.threadId}' from ${body.fromTurnCount} to ${body.toTurnCount}.`,
463− );
464− }
601+ return computeTurnDiff(body);
602+ }
465603
466− return {
604+ case ORCHESTRATION_WS_METHODS.getFullThreadDiff: {
605+ const body = stripRequestTag(request.body);
606+ return computeTurnDiff({
467607 threadId: body.threadId,
468− fromTurnCount: body.fromTurnCount,
608+ fromTurnCount: 0,
469609 toTurnCount: body.toTurnCount,
470− diff: blob.value?.diff,
471− };
610+ });
472611 }
473612
474613 case ORCHESTRATION_WS_METHODS.replayEvents:
@@ -526,28 +665,8 @@ export function createServer(options: ServerOptions) { · 51 unmodified lines
526665 return undefined;
527666 }
528667
529− // TODO: Bring back
530− // case WS_METHODS.gitStatus: {
531− // const params = request.params as { cwd: string };
532− // const status = await gitManager.status(request.params as never);
533− // const projects = await effectRuntime.runPromise(projectRepository.list());
534− // const project = projects.find((entry) => entry.cwd === params.cwd);
535− // if (project) {
536− // await effectRuntime.runPromise(
537− // orchestrationEngine.dispatch({
538− // type: "git.readModel.upsert",
539− // commandId: crypto.randomUUID(),
540− // projectId: project.id,
541− // branch: status.branch,
542− // hasWorkingTreeChanges: status.hasWorkingTreeChanges,
543− // aheadCount: status.aheadCount,
544− // behindCount: status.behindCount,
545− // createdAt: new Date().toISOString(),
546− // }),
547− // );
548− // }
549− // return status;
550− // }
668+ case WS_METHODS.gitStatus:
669+ return gitManager.status(stripRequestTag(request.body));
551670
552671 case WS_METHODS.gitPull:
553672 return pullGitBranch(stripRequestTag(request.body));
@@ -606,8 +725,10 @@ export function createServer(options: ServerOptions) { · 52 unmodified lines
606725 keybindings: keybindingsConfig,
607726 };
608727
609− default:
610− throw new Error(`Unknown method: ${request.body._tag}`);
728+ default: {
729+ const _exhaustiveCheck: never = request.body;
730+ throw new Error(`Unknown method: ${String(_exhaustiveCheck)}`);
731+ }
611732 }
612733 }
613734
@@ -651,7 +772,6 @@ export function createServer(options: ServerOptions) { · 37 unmodified lines
651772 const runtimeServicesLayer = Layer.mergeAll(
652773 orchestrationLayer,
653774 OrchestrationProjectionSnapshotQueryLive,
654− CheckpointDiffBlobRepositoryLive,
655775 checkpointStoreLayer,
656776 providerLayer,
657777 );
@@ -679,17 +799,17 @@ export function createServer(options: ServerOptions) { · 21 unmodified lines
679799 const [
680800 nextOrchestrationEngine,
681801 nextProjectionSnapshotQuery,
682− nextCheckpointDiffBlobRepository,
802+ nextCheckpointStore,
683803 nextProviderService,
684804 ] = await Promise.all([
685805 runtime.runPromise(Effect.service(OrchestrationEngineService)),
686806 runtime.runPromise(Effect.service(ProjectionSnapshotQuery)),
687− runtime.runPromise(Effect.service(CheckpointDiffBlobRepository)),
807+ runtime.runPromise(Effect.service(CheckpointStore)),
688808 runtime.runPromise(Effect.service(ProviderService)),
689809 ]);
690810 orchestrationEngine = nextOrchestrationEngine;
691811 projectionSnapshotQuery = nextProjectionSnapshotQuery;
692− checkpointDiffBlobRepository = nextCheckpointDiffBlobRepository;
812+ checkpointStore = nextCheckpointStore;
693813 providerService = nextProviderService;
694814 await runtime.runPromise(attachSubscriptionss);
695815 } catch (error) {
@@ -715,7 +835,7 @@ export function createServer(options: ServerOptions) { · 19 unmodified lines
715835 effectRuntime = null;
716836 orchestrationEngine = null;
717837 projectionSnapshotQuery = null;
718− checkpointDiffBlobRepository = null;
838+ checkpointStore = null;
719839 providerService = null;
720840
721841 if (!runtime) {
@@ -731,6 +851,7 @@ export function createServer(options: ServerOptions) { · 9 unmodified lines
731851
732852 async function start() {
733853 effectRuntime = await createEffectRuntime();
854+ await bootstrapProjectForCwd();
734855
735856 return new Promise<void>((resolve, reject) => {
736857 const onError = (error: Error) => {
apps/web/src/components/Sidebar.tsx+36−23
@@ -132,28 +132,39 @@ export default function Sidebar() { · 131 unmodified lines
132132 branch?: string | null;
133133 worktreePath?: string | null;
134134 },
135− ) => {
136− if (!api) return;
135+ ): Promise<void> => {
136+ if (!api) return Promise.resolve();
137137 const threadId = newThreadId();
138138 const createdAt = new Date().toISOString();
139139 const model = state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL;
140− void api.orchestration.dispatchCommand({
141− type: "thread.create",
142− commandId: newCommandId(),
143− threadId,
144− projectId: asProjectId(projectId),
145− title: "New thread",
146− model,
147− branch: options?.branch ?? null,
148− worktreePath: options?.worktreePath ?? null,
149− createdAt,
150− });
151− void navigate({
152− to: "/$threadId",
153− params: { threadId },
154− });
140+ return (async () => {
141+ await api.orchestration.dispatchCommand({
142+ type: "thread.create",
143+ commandId: newCommandId(),
144+ threadId,
145+ projectId: asProjectId(projectId),
146+ title: "New thread",
147+ model,
148+ branch: options?.branch ?? null,
149+ worktreePath: options?.worktreePath ?? null,
150+ createdAt,
151+ });
152+
153+ // Ensure route guards can see the new thread before navigating.
154+ try {
155+ const snapshot = await api.orchestration.getSnapshot();
156+ dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot });
157+ } catch {
158+ // Event stream can still hydrate the thread shortly after dispatch.
159+ }
160+
161+ await navigate({
162+ to: "/$threadId",
163+ params: { threadId },
164+ });
165+ })();
155166 },
156− [api, navigate, state.projects],
167+ [api, dispatch, navigate, state.projects],
157168 );
158169
159170 const focusMostRecentThreadForProject = useCallback(
@@ -201,7 +212,7 @@ export default function Sidebar() { · 41 unmodified lines
201212 defaultModel: DEFAULT_MODEL,
202213 createdAt,
203214 });
204− handleNewThread(projectId);
215+ await handleNewThread(projectId);
205216 } finally {
206217 setIsAddingProject(false);
207218 setNewCwd("");
@@ -399,7 +410,7 @@ export default function Sidebar() { · 191 unmodified lines
399410 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
400411 if (!projectId) return;
401412 event.preventDefault();
402− handleNewThread(projectId);
413+ void handleNewThread(projectId);
403414 return;
404415 }
405416
@@ -407,7 +418,7 @@ export default function Sidebar() { · 1 unmodified line
407418 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
408419 if (!projectId) return;
409420 event.preventDefault();
410− handleNewThread(projectId, {
421+ void handleNewThread(projectId, {
411422 branch: activeThread?.branch ?? null,
412423 worktreePath: activeThread?.worktreePath ?? null,
413424 });
@@ -446,7 +457,7 @@ export default function Sidebar() { · 32 unmodified lines
446457 return;
447458 }
448459 const firstProject = state.projects[0];
449− if (firstProject) handleNewThread(firstProject.id);
460+ if (firstProject) void handleNewThread(firstProject.id);
450461 }}
451462 >
452463 <span className="text-foreground">+</span>
@@ -561,7 +572,9 @@ export default function Sidebar() { · 108 unmodified lines
561572 <button
562573 type="button"
563574 className="flex w-full items-center gap-1 px-2 py-1 text-[10px] text-muted-foreground/60 transition-colors duration-150 hover:text-muted-foreground/80"
564− onClick={() => handleNewThread(project.id)}
575+ onClick={() => {
576+ void handleNewThread(project.id);
577+ }}
565578 >
566579 <span>+</span> New thread
567580 </button>
apps/web/src/lib/providerReactQuery.test.ts+30−0
@@ -28,9 +28,11 @@ describe("providerQueryKeys.checkpointDiff", () => { · 27 unmodified lines
2828 describe("checkpointDiffQueryOptions", () => {
2929 it("forwards checkpoint range to the provider API", async () => {
3030 const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });
31+ const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });
3132 const api = {
3233 orchestration: {
3334 getTurnDiff,
35+ getFullThreadDiff,
3436 },
3537 } as unknown as NativeApi;
3638
@@ -49,6 +51,34 @@ describe("checkpointDiffQueryOptions", () => { · 12 unmodified lines
4951 fromTurnCount: 3,
5052 toTurnCount: 4,
5153 });
54+ expect(getFullThreadDiff).not.toHaveBeenCalled();
55+ });
56+
57+ it("uses explicit full thread diff API when range starts from zero", async () => {
58+ const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" });
59+ const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" });
60+ const api = {
61+ orchestration: {
62+ getTurnDiff,
63+ getFullThreadDiff,
64+ },
65+ } as unknown as NativeApi;
66+
67+ const options = checkpointDiffQueryOptions(api, {
68+ threadId: "thread-id",
69+ fromTurnCount: 0,
70+ toTurnCount: 2,
71+ cacheScope: "thread:all",
72+ });
73+
74+ const queryClient = new QueryClient();
75+ await queryClient.fetchQuery(options);
76+
77+ expect(getFullThreadDiff).toHaveBeenCalledWith({
78+ threadId: "thread-id",
79+ toTurnCount: 2,
80+ });
81+ expect(getTurnDiff).not.toHaveBeenCalled();
5282 });
5383
5484 it("retries checkpoint-not-ready errors longer than generic failures", () => {
apps/web/src/lib/providerReactQuery.ts+6−0
@@ -64,6 +64,12 @@ export function checkpointDiffQueryOptions( · 63 unmodified lines
6464 if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") {
6565 throw new Error("Checkpoint diff range is invalid.");
6666 }
67+ if (fromTurnCount === 0) {
68+ return api.orchestration.getFullThreadDiff({
69+ threadId: asThreadId(input.threadId),
70+ toTurnCount,
71+ });
72+ }
6773 return api.orchestration.getTurnDiff({
6874 threadId: asThreadId(input.threadId),
6975 fromTurnCount,
apps/web/src/routes/__root.tsx+8−63
@@ -3,18 +3,16 @@ import { · 2 unmodified lines
33 createRootRouteWithContext,
44 type ErrorComponentProps,
55 } from "@tanstack/react-router";
6−import { useEffect, useRef } from "react";
7−import { QueryClient } from "@tanstack/react-query";
6+import { useEffect } from "react";
7+import { QueryClient, useQueryClient } from "@tanstack/react-query";
88
99 import { APP_DISPLAY_NAME } from "../branding";
1010 import { Button } from "../components/ui/button";
1111 import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast";
12−import { isElectron } from "../env";
1312 import { useNativeApi } from "../hooks/useNativeApi";
14−import { DEFAULT_MODEL } from "../model-logic";
1513 import { useStore } from "../store";
16−import { newCommandId, newProjectId, newThreadId } from "../lib/orchestrationIds";
1714 import { onServerWelcome } from "../wsNativeApi";
15+import { providerQueryKeys } from "../lib/providerReactQuery";
1816
1917 export const Route = createRootRouteWithContext<{
2018 queryClient: QueryClient;
@@ -42,7 +40,6 @@ function RootRouteView() { · 21 unmodified lines
4240 <ToastProvider>
4341 <AnchoredToastProvider>
4442 <EventRouter />
45− <AutoProjectBootstrap />
4643 <DesktopProjectBootstrap />
4744 <Outlet />
4845 </AnchoredToastProvider>
@@ -124,6 +121,7 @@ function errorDetails(error: unknown): string { · 75 unmodified lines
124121 function EventRouter() {
125122 const api = useNativeApi();
126123 const { dispatch } = useStore();
124+ const queryClient = useQueryClient();
127125
128126 useEffect(() => {
129127 if (!api) return;
@@ -166,6 +164,9 @@ function EventRouter() { · 36 unmodified lines
166164 return;
167165 }
168166 latestSequence = event.sequence;
167+ if (event.type === "thread.turn-diff-completed" || event.type === "thread.reverted") {
168+ void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all });
169+ }
169170 void syncSnapshot();
170171 });
171172 const unsubWelcome = onServerWelcome(() => {
@@ -176,63 +177,7 @@ function EventRouter() { · 4 unmodified lines
176177 unsubDomainEvent();
177178 unsubWelcome();
178179 };
179− }, [api, dispatch]);
180−
181− return null;
182−}
183−
184−function AutoProjectBootstrap() {
185− const api = useNativeApi();
186− const { state, dispatch } = useStore();
187− const bootstrappedRef = useRef(false);
188−
189− useEffect(() => {
190− if (!api) return;
191− // Browser mode auto-adds the current cwd project via server welcome.
192− // Desktop skips this because projects are already loaded from the repository.
193− if (isElectron) return;
194−
195− return onServerWelcome((payload) => {
196− if (bootstrappedRef.current) return;
197−
198− // Don't create duplicate projects for the same cwd
199− const existing = state.projects.find((project) => project.cwd === payload.cwd);
200− if (existing) {
201− bootstrappedRef.current = true;
202− dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });
203− return;
204− }
205−
206− bootstrappedRef.current = true;
207− const now = new Date().toISOString();
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({
222− type: "thread.create",
223− commandId: newCommandId(),
224− threadId,
225− projectId,
226− title: "New thread",
227− model: DEFAULT_MODEL,
228− branch: null,
229− worktreePath: null,
230− createdAt: now,
231− }),
232− )
233− .catch(() => undefined);
234− });
235− }, [api, state.projects, dispatch]);
180+ }, [api, dispatch, queryClient]);
236181
237182 return null;
238183 }
apps/web/src/wsNativeApi.test.ts+53−0
@@ -0,0 +1,53 @@
1+import { CommandId, ORCHESTRATION_WS_METHODS, ProjectId, ThreadId } from "@t3tools/contracts";
2+import { describe, expect, it, vi } from "vitest";
3+
4+const requestMock = vi.fn<(...args: Array<unknown>) => Promise<unknown>>();
5+const subscribeMock = vi.fn<(...args: Array<unknown>) => () => void>(() => () => undefined);
6+
7+vi.mock("./wsTransport", () => {
8+ return {
9+ WsTransport: class MockWsTransport {
10+ request = requestMock;
11+ subscribe = subscribeMock;
12+ },
13+ };
14+});
15+
16+describe("wsNativeApi", () => {
17+ it("wraps orchestration dispatch commands in the command envelope", async () => {
18+ requestMock.mockResolvedValue(undefined);
19+ const { createWsNativeApi } = await import("./wsNativeApi");
20+
21+ const api = createWsNativeApi();
22+ const command = {
23+ type: "project.create",
24+ commandId: CommandId.makeUnsafe("cmd-1"),
25+ projectId: ProjectId.makeUnsafe("project-1"),
26+ title: "Project",
27+ workspaceRoot: "/tmp/project",
28+ defaultModel: "gpt-5-codex",
29+ createdAt: "2026-02-24T00:00:00.000Z",
30+ } as const;
31+ await api.orchestration.dispatchCommand(command);
32+
33+ expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.dispatchCommand, {
34+ command,
35+ });
36+ });
37+
38+ it("forwards full-thread diff requests to the orchestration websocket method", async () => {
39+ requestMock.mockResolvedValue({ diff: "patch" });
40+ const { createWsNativeApi } = await import("./wsNativeApi");
41+
42+ const api = createWsNativeApi();
43+ await api.orchestration.getFullThreadDiff({
44+ threadId: ThreadId.makeUnsafe("thread-1"),
45+ toTurnCount: 1,
46+ });
47+
48+ expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.getFullThreadDiff, {
49+ threadId: "thread-1",
50+ toTurnCount: 1,
51+ });
52+ });
53+});
apps/web/src/wsNativeApi.ts+3−1
@@ -128,8 +128,10 @@ export function createWsNativeApi(): NativeApi { · 127 unmodified lines
128128 orchestration: {
129129 getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot),
130130 dispatchCommand: (command) =>
131− transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, command),
131+ transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, { command }),
132132 getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input),
133+ getFullThreadDiff: (input) =>
134+ transport.request(ORCHESTRATION_WS_METHODS.getFullThreadDiff, input),
133135 replayEvents: (fromSequenceExclusive) =>
134136 transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }),
135137 onDomainEvent: (callback) =>
packages/contracts/src/ipc.ts+5−0
@@ -28,6 +28,8 @@ import type { · 27 unmodified lines
2828 import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server";
2929 import type {
3030 ClientOrchestrationCommand,
31+ OrchestrationGetFullThreadDiffInput,
32+ OrchestrationGetFullThreadDiffResult,
3133 OrchestrationGetTurnDiffInput,
3234 OrchestrationGetTurnDiffResult,
3335 OrchestrationEvent,
@@ -95,6 +97,9 @@ export interface NativeApi { · 61 unmodified lines
9597 getSnapshot: () => Promise<OrchestrationReadModel>;
9698 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;
9799 getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>;
100+ getFullThreadDiff: (
101+ input: OrchestrationGetFullThreadDiffInput,
102+ ) => Promise<OrchestrationGetFullThreadDiffResult>;
98103 replayEvents: (fromSequenceExclusive: number) => Promise<OrchestrationEvent[]>;
99104 onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void;
100105 };
packages/contracts/src/orchestration.ts+14−0
@@ -4,6 +4,7 @@ export const ORCHESTRATION_WS_METHODS = { · 3 unmodified lines
44 getSnapshot: "orchestration.getSnapshot",
55 dispatchCommand: "orchestration.dispatchCommand",
66 getTurnDiff: "orchestration.getTurnDiff",
7+ getFullThreadDiff: "orchestration.getFullThreadDiff",
78 replayEvents: "orchestration.replayEvents",
89 } as const;
910
@@ -837,6 +838,15 @@ export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput · 827 unmodified lines
837838 export const OrchestrationGetTurnDiffResult = ThreadTurnDiff;
838839 export type OrchestrationGetTurnDiffResult = typeof OrchestrationGetTurnDiffResult.Type;
839840
841+export const OrchestrationGetFullThreadDiffInput = Schema.Struct({
842+ threadId: ThreadId,
843+ toTurnCount: NonNegativeInt,
844+});
845+export type OrchestrationGetFullThreadDiffInput = typeof OrchestrationGetFullThreadDiffInput.Type;
846+
847+export const OrchestrationGetFullThreadDiffResult = ThreadTurnDiff;
848+export type OrchestrationGetFullThreadDiffResult = typeof OrchestrationGetFullThreadDiffResult.Type;
849+
840850 export const OrchestrationReplayEventsInput = Schema.Struct({
841851 fromSequenceExclusive: NonNegativeInt,
842852 });
@@ -858,6 +868,10 @@ export const OrchestrationRpcSchemas = { · 15 unmodified lines
858868 input: OrchestrationGetTurnDiffInput,
859869 output: OrchestrationGetTurnDiffResult,
860870 },
871+ getFullThreadDiff: {
872+ input: OrchestrationGetFullThreadDiffInput,
873+ output: OrchestrationGetFullThreadDiffResult,
874+ },
861875 replayEvents: {
862876 input: OrchestrationReplayEventsInput,
863877 output: OrchestrationReplayEventsResult,
packages/contracts/src/ws.ts+2−0
@@ -2,6 +2,7 @@ import { Schema } from "effect"; · 1 unmodified line
22
33 import {
44 ClientOrchestrationCommand,
5+ OrchestrationGetFullThreadDiffInput,
56 ORCHESTRATION_WS_METHODS,
67 OrchestrationGetSnapshotInput,
78 OrchestrationGetTurnDiffInput,
@@ -80,6 +81,7 @@ export const WebSocketRequestBody = Schema.TaggedUnion({ · 72 unmodified lines
8081 [ORCHESTRATION_WS_METHODS.dispatchCommand]: { command: ClientOrchestrationCommand },
8182 [ORCHESTRATION_WS_METHODS.getSnapshot]: OrchestrationGetSnapshotInput.fields,
8283 [ORCHESTRATION_WS_METHODS.getTurnDiff]: OrchestrationGetTurnDiffInput.fields,
84+ [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: OrchestrationGetFullThreadDiffInput.fields,
8385 [ORCHESTRATION_WS_METHODS.replayEvents]: OrchestrationReplayEventsInput.fields,
8486
8587 // Project Search