authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-23 01:23:39-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log20668e49a934ce0bb02fe1eb8b57846fe36959ad
tree9f8932cefb5043a2957e383b80604ec8bd1afe9c
parentfcc94b3b2ffdf3d96fe09fc224b11f0540d2443c

wip


20 files changed, 710 insertions(+), 331 deletions(-)

apps/server/package.json+1
...@@ -18,6 +18,7 @@...@@ -18,6 +18,7 @@
18 },18 },
19 "dependencies": {19 "dependencies": {
20 "@effect/platform-node": "4.0.0-beta.10",20 "@effect/platform-node": "4.0.0-beta.10",
21 "@pierre/diffs": "^1.1.0-beta.16",
21 "effect": "4.0.0-beta.10",22 "effect": "4.0.0-beta.10",
22 "node-pty": "^1.1.0",23 "node-pty": "^1.1.0",
23 "open": "^10.1.0",24 "open": "^10.1.0",
apps/server/src/checkpointing/Diffs.test.ts created+68
...@@ -0,0 +1,68 @@
1import { describe, expect, it } from "vitest";
2
3import { parseTurnDiffFilesFromUnifiedDiff } from "./Diffs.ts";
4
5describe("parseTurnDiffFilesFromUnifiedDiff", () => {
6 it("returns empty list for empty diff", () => {
7 expect(parseTurnDiffFilesFromUnifiedDiff("")).toEqual([]);
8 });
9
10 it("parses per-file additions and deletions", () => {
11 const diff = [
12 "diff --git a/a.txt b/a.txt",
13 "index 1111111..2222222 100644",
14 "--- a/a.txt",
15 "+++ b/a.txt",
16 "@@ -1,2 +1,3 @@",
17 " one",
18 "-two",
19 "+two updated",
20 "+three",
21 "diff --git a/src/b.ts b/src/b.ts",
22 "index 3333333..4444444 100644",
23 "--- a/src/b.ts",
24 "+++ b/src/b.ts",
25 "@@ -3,2 +3,0 @@",
26 "-old",
27 "-stale",
28 "",
29 ].join("\n");
30
31 expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([
32 { path: "a.txt", additions: 2, deletions: 1 },
33 { path: "src/b.ts", additions: 0, deletions: 2 },
34 ]);
35 });
36
37 it("parses rename-only diffs with zero line changes", () => {
38 const diff = [
39 "diff --git a/src/old.ts b/src/new.ts",
40 "similarity index 100%",
41 "rename from src/old.ts",
42 "rename to src/new.ts",
43 "",
44 ].join("\n");
45
46 expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([
47 { path: "src/new.ts", additions: 0, deletions: 0 },
48 ]);
49 });
50
51 it("normalizes CRLF input before parsing", () => {
52 const diff = [
53 "diff --git a/a.txt b/a.txt",
54 "index 1111111..2222222 100644",
55 "--- a/a.txt",
56 "+++ b/a.txt",
57 "@@ -1 +1,2 @@",
58 "-one",
59 "+one updated",
60 "+two",
61 "",
62 ].join("\r\n");
63
64 expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([
65 { path: "a.txt", additions: 2, deletions: 1 },
66 ]);
67 });
68});
apps/server/src/checkpointing/Diffs.ts created+25
...@@ -0,0 +1,25 @@
1import { parsePatchFiles } from "@pierre/diffs";
2
3export interface TurnDiffFileSummary {
4 readonly path: string;
5 readonly additions: number;
6 readonly deletions: number;
7}
8
9export function parseTurnDiffFilesFromUnifiedDiff(diff: string): ReadonlyArray<TurnDiffFileSummary> {
10 const normalized = diff.replace(/\r\n/g, "\n").trim();
11 if (normalized.length === 0) {
12 return [];
13 }
14
15 const parsedPatches = parsePatchFiles(normalized);
16 const files = parsedPatches.flatMap((patch) =>
17 patch.files.map((file) => ({
18 path: file.name,
19 additions: file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0),
20 deletions: file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0),
21 })),
22 );
23
24 return files.toSorted((left, right) => left.path.localeCompare(right.path));
25}
apps/server/src/orchestration/Layer.test.ts+41-28
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { OrchestrationEvent } from "@t3tools/contracts";1import type { OrchestrationEvent } from "@t3tools/contracts";
2import { Effect, Layer, ManagedRuntime } from "effect";2import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect";
3import { describe, expect, it } from "vitest";3import { describe, expect, it } from "vitest";
44
5import {5import {
...@@ -65,33 +65,6 @@ describe("OrchestrationEngine", () => {...@@ -65,33 +65,6 @@ describe("OrchestrationEngine", () => {
65 await system.dispose();65 await system.dispose();
66 });66 });
6767
68 it("fans out read-model updates to subscribers", async () => {
69 const system = await createOrchestrationSystem();
70 const engine = system.engine;
71 const updates: number[] = [];
72 const unsubscribe = await system.run(
73 engine.subscribeToReadModel((snapshot) => {
74 updates.push(snapshot.sequence);
75 }),
76 );
77 await system.run(
78 engine.dispatch({
79 type: "thread.create",
80 commandId: "cmd-thread",
81 threadId: "thread-2",
82 projectId: "project-2",
83 title: "fanout",
84 model: "gpt-5-codex",
85 branch: null,
86 worktreePath: null,
87 createdAt: new Date().toISOString(),
88 }),
89 );
90 unsubscribe();
91 expect(updates.length).toBeGreaterThan(0);
92 await system.dispose();
93 });
94
95 it("replays append-only events from sequence", async () => {68 it("replays append-only events from sequence", async () => {
96 const system = await createOrchestrationSystem();69 const system = await createOrchestrationSystem();
97 const engine = system.engine;70 const engine = system.engine;
...@@ -123,6 +96,46 @@ describe("OrchestrationEngine", () => {...@@ -123,6 +96,46 @@ describe("OrchestrationEngine", () => {
123 await system.dispose();96 await system.dispose();
124 });97 });
12598
99 it("streams persisted domain events in order", async () => {
100 const system = await createOrchestrationSystem();
101 const engine = system.engine;
102 const eventTypes: string[] = [];
103 await system.run(
104 Effect.gen(function* () {
105 const eventQueue = yield* Queue.unbounded<OrchestrationEvent>();
106 yield* Effect.forkScoped(
107 Stream.take(engine.streamDomainEvents, 2).pipe(
108 Stream.runForEach((event) => Queue.offer(eventQueue, event).pipe(Effect.asVoid)),
109 ),
110 );
111 yield* Effect.sleep("10 millis");
112 const createdAt = new Date().toISOString();
113 yield* engine.dispatch({
114 type: "thread.create",
115 commandId: "cmd-stream-domain-a",
116 threadId: "thread-stream-domain",
117 projectId: "project-stream-domain",
118 title: "domain-stream",
119 model: "gpt-5-codex",
120 branch: null,
121 worktreePath: null,
122 createdAt,
123 });
124 yield* engine.dispatch({
125 type: "thread.meta.update",
126 commandId: "cmd-stream-domain-b",
127 threadId: "thread-stream-domain",
128 title: "domain-stream-updated",
129 createdAt,
130 });
131 eventTypes.push((yield* Queue.take(eventQueue)).type);
132 eventTypes.push((yield* Queue.take(eventQueue)).type);
133 }).pipe(Effect.scoped),
134 );
135 expect(eventTypes).toEqual(["thread.created", "thread.meta-updated"]);
136 await system.dispose();
137 });
138
126 it("stores completed turn summaries even when no files changed", async () => {139 it("stores completed turn summaries even when no files changed", async () => {
127 const system = await createOrchestrationSystem();140 const system = await createOrchestrationSystem();
128 const engine = system.engine;141 const engine = system.engine;
apps/server/src/orchestration/Layer.ts+5-48
...@@ -4,12 +4,11 @@ import type {...@@ -4,12 +4,11 @@ import type {
4 OrchestrationReadModel,4 OrchestrationReadModel,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { OrchestrationCommandSchema } from "@t3tools/contracts";6import { OrchestrationCommandSchema } from "@t3tools/contracts";
7import { Deferred, Effect, Layer, PubSub, Queue, Schema } from "effect";7import { Deferred, Effect, Layer, PubSub, Queue, Schema, Stream } from "effect";
88
9import { createLogger } from "../logger.ts";9import { createLogger } from "../logger.ts";
10import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents.ts";10import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents.ts";
11import {11import {
12 toListenerCallbackError,
13 toOrchestrationCommandDecodeError,12 toOrchestrationCommandDecodeError,
14 toOrchestrationJsonParseError,13 toOrchestrationJsonParseError,
15 type OrchestrationDispatchError,14 type OrchestrationDispatchError,
...@@ -182,42 +181,14 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -182,42 +181,14 @@ const makeOrchestrationEngine = Effect.gen(function* () {
182 let readModel = createEmptyReadModel(new Date().toISOString());181 let readModel = createEmptyReadModel(new Date().toISOString());
183182
184 const commandQueue = yield* Queue.unbounded<CommandEnvelope>();183 const commandQueue = yield* Queue.unbounded<CommandEnvelope>();
185 const readModelPubSub = yield* PubSub.unbounded<OrchestrationReadModel>();
186 const eventPubSub = yield* PubSub.unbounded<OrchestrationEvent>();184 const eventPubSub = yield* PubSub.unbounded<OrchestrationEvent>();
187185
188 const readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>();
189 const domainEventListeners = new Set<(event: OrchestrationEvent) => void>();
190
191 const notifyReadModelListeners = (snapshot: OrchestrationReadModel) =>
192 Effect.forEach(readModelListeners, (listener) =>
193 Effect.try({
194 try: () => listener(snapshot),
195 catch: toListenerCallbackError("read-model"),
196 }),
197 ).pipe(Effect.asVoid);
198
199 const notifyDomainEventListeners = (event: OrchestrationEvent) =>
200 Effect.forEach(domainEventListeners, (listener) =>
201 Effect.try({
202 try: () => listener(event),
203 catch: toListenerCallbackError("domain-event"),
204 }),
205 ).pipe(Effect.asVoid);
206
207 const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> =>186 const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> =>
208 Effect.gen(function* () {187 Effect.gen(function* () {
209 const eventBase = mapCommandToEvent(envelope.command);188 const eventBase = mapCommandToEvent(envelope.command);
210 const savedEvent = yield* eventStore.append(eventBase);189 const savedEvent = yield* eventStore.append(eventBase);
211 readModel = yield* reduceEvent(readModel, savedEvent);190 readModel = yield* reduceEvent(readModel, savedEvent);
212191 yield* PubSub.publish(eventPubSub, savedEvent);
213 const snapshot = readModel;
214 yield* Effect.all([
215 PubSub.publish(eventPubSub, savedEvent),
216 PubSub.publish(readModelPubSub, snapshot),
217 ]);
218
219 yield* notifyDomainEventListeners(savedEvent);
220 yield* notifyReadModelListeners(snapshot);
221192
222 yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence });193 yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence });
223 }).pipe(Effect.catch((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid)));194 }).pipe(Effect.catch((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid)));
...@@ -269,29 +240,15 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -269,29 +240,15 @@ const makeOrchestrationEngine = Effect.gen(function* () {
269 return yield* dispatch(decoded);240 return yield* dispatch(decoded);
270 });241 });
271242
272 const subscribeToReadModel: OrchestrationEngineShape["subscribeToReadModel"] = (callback) =>243 const streamDomainEvents: OrchestrationEngineShape["streamDomainEvents"] =
273 Effect.sync(() => {244 Stream.fromPubSub(eventPubSub);
274 readModelListeners.add(callback);
275 return () => {
276 readModelListeners.delete(callback);
277 };
278 });
279
280 const subscribeToDomainEvents: OrchestrationEngineShape["subscribeToDomainEvents"] = (callback) =>
281 Effect.sync(() => {
282 domainEventListeners.add(callback);
283 return () => {
284 domainEventListeners.delete(callback);
285 };
286 });
287245
288 return {246 return {
289 getSnapshot,247 getSnapshot,
290 replayEvents,248 replayEvents,
291 dispatchUnknown,249 dispatchUnknown,
292 dispatch,250 dispatch,
293 subscribeToReadModel,251 streamDomainEvents,
294 subscribeToDomainEvents,
295 } satisfies OrchestrationEngineShape;252 } satisfies OrchestrationEngineShape;
296});253});
297254
apps/server/src/orchestration/Service.ts+3-18
...@@ -16,7 +16,7 @@ import type {...@@ -16,7 +16,7 @@ import type {
16 OrchestrationReadModel,16 OrchestrationReadModel,
17} from "@t3tools/contracts";17} from "@t3tools/contracts";
18import { ServiceMap } from "effect";18import { ServiceMap } from "effect";
19import type { Effect } from "effect";19import type { Effect, Stream } from "effect";
2020
21import type { OrchestrationDispatchError, OrchestrationEngineError } from "./Errors.ts";21import type { OrchestrationDispatchError, OrchestrationEngineError } from "./Errors.ts";
22import type { OrchestrationEventRepositoryError } from "../persistence/Errors.ts";22import type { OrchestrationEventRepositoryError } from "../persistence/Errors.ts";
...@@ -62,24 +62,9 @@ export interface OrchestrationEngineShape {...@@ -62,24 +62,9 @@ export interface OrchestrationEngineShape {
62 ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never>;62 ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never>;
6363
64 /**64 /**
65 * Subscribe to read model updates.65 * Stream persisted domain events in dispatch order.
66 *
67 * @param callback - Handler invoked on every read model update.
68 * @returns Effect containing an unsubscribe function.
69 */
70 readonly subscribeToReadModel: (
71 callback: (snapshot: OrchestrationReadModel) => void,
72 ) => Effect.Effect<() => void, never, never>;
73
74 /**
75 * Subscribe to domain event fan-out.
76 *
77 * @param callback - Handler invoked for every persisted domain event.
78 * @returns Effect containing an unsubscribe function.
79 */66 */
80 readonly subscribeToDomainEvents: (67 readonly streamDomainEvents: Stream.Stream<OrchestrationEvent>;
81 callback: (event: OrchestrationEvent) => void,
82 ) => Effect.Effect<() => void, never, never>;
83}68}
8469
85/**70/**
apps/server/src/orchestration/reducer.test.ts+160
...@@ -95,4 +95,164 @@ describe("orchestration reducer", () => {...@@ -95,4 +95,164 @@ describe("orchestration reducer", () => {
95 expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z");95 expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z");
96 expect(next.threads).toEqual([]);96 expect(next.threads).toEqual([]);
97 });97 });
98
99 it("tracks latest turn timing from session lifecycle events", async () => {
100 const createdAt = "2026-02-23T08:00:00.000Z";
101 const startedAt = "2026-02-23T08:00:05.000Z";
102 const completedAt = "2026-02-23T08:00:11.250Z";
103 const model = createEmptyReadModel(createdAt);
104
105 const createdEvent: OrchestrationEvent = {
106 sequence: 1,
107 eventId: "thread-created",
108 type: "thread.created",
109 aggregateType: "thread",
110 aggregateId: "thread-1",
111 occurredAt: createdAt,
112 commandId: "cmd-create",
113 payload: {
114 id: "thread-1",
115 projectId: "project-1",
116 title: "demo",
117 model: "gpt-5.3-codex",
118 branch: null,
119 worktreePath: null,
120 createdAt,
121 updatedAt: createdAt,
122 },
123 };
124
125 const runningSessionEvent: OrchestrationEvent = {
126 sequence: 2,
127 eventId: "session-running",
128 type: "thread.session-set",
129 aggregateType: "thread",
130 aggregateId: "thread-1",
131 occurredAt: startedAt,
132 commandId: "cmd-running",
133 payload: {
134 threadId: "thread-1",
135 session: {
136 sessionId: "session-1",
137 status: "running",
138 provider: "codex",
139 threadId: "thread-1",
140 activeTurnId: "turn-1",
141 createdAt,
142 updatedAt: startedAt,
143 lastError: null,
144 },
145 },
146 };
147
148 const readySessionEvent: OrchestrationEvent = {
149 sequence: 3,
150 eventId: "session-ready",
151 type: "thread.session-set",
152 aggregateType: "thread",
153 aggregateId: "thread-1",
154 occurredAt: completedAt,
155 commandId: "cmd-ready",
156 payload: {
157 threadId: "thread-1",
158 session: {
159 sessionId: "session-1",
160 status: "ready",
161 provider: "codex",
162 threadId: "thread-1",
163 activeTurnId: null,
164 createdAt,
165 updatedAt: completedAt,
166 lastError: null,
167 },
168 },
169 };
170
171 const afterCreate = await Effect.runPromise(reduceEvent(model, createdEvent));
172 const afterRunning = await Effect.runPromise(reduceEvent(afterCreate, runningSessionEvent));
173 const afterReady = await Effect.runPromise(reduceEvent(afterRunning, readySessionEvent));
174 const thread = afterReady.threads[0];
175
176 expect(thread?.latestTurnId).toBe("turn-1");
177 expect(thread?.latestTurnStartedAt).toBe(startedAt);
178 expect(thread?.latestTurnCompletedAt).toBe(completedAt);
179 expect(thread?.latestTurnDurationMs).toBe(6250);
180 });
181
182 it("marks assistant messages completed using non-streaming updates", async () => {
183 const createdAt = "2026-02-23T09:00:00.000Z";
184 const deltaAt = "2026-02-23T09:00:01.000Z";
185 const completeAt = "2026-02-23T09:00:03.500Z";
186 const model = createEmptyReadModel(createdAt);
187
188 const createdEvent: OrchestrationEvent = {
189 sequence: 1,
190 eventId: "thread-created",
191 type: "thread.created",
192 aggregateType: "thread",
193 aggregateId: "thread-1",
194 occurredAt: createdAt,
195 commandId: "cmd-create",
196 payload: {
197 id: "thread-1",
198 projectId: "project-1",
199 title: "demo",
200 model: "gpt-5.3-codex",
201 branch: null,
202 worktreePath: null,
203 createdAt,
204 updatedAt: createdAt,
205 },
206 };
207
208 const deltaEvent: OrchestrationEvent = {
209 sequence: 2,
210 eventId: "assistant-delta",
211 type: "message.sent",
212 aggregateType: "thread",
213 aggregateId: "thread-1",
214 occurredAt: deltaAt,
215 commandId: "cmd-delta",
216 payload: {
217 id: "assistant:msg-1",
218 role: "assistant",
219 text: "hello",
220 threadId: "thread-1",
221 createdAt: deltaAt,
222 streaming: true,
223 },
224 };
225
226 const completionEvent: OrchestrationEvent = {
227 sequence: 3,
228 eventId: "assistant-complete",
229 type: "message.sent",
230 aggregateType: "thread",
231 aggregateId: "thread-1",
232 occurredAt: completeAt,
233 commandId: "cmd-complete",
234 payload: {
235 id: "assistant:msg-1",
236 role: "assistant",
237 text: "",
238 threadId: "thread-1",
239 createdAt: completeAt,
240 streaming: false,
241 },
242 };
243
244 const afterCreate = await Effect.runPromise(reduceEvent(model, createdEvent));
245 const afterDelta = await Effect.runPromise(reduceEvent(afterCreate, deltaEvent));
246 const afterComplete = await Effect.runPromise(reduceEvent(afterDelta, completionEvent));
247 const message = afterComplete.threads[0]?.messages[0];
248
249 expect(message).toEqual({
250 id: "assistant:msg-1",
251 role: "assistant",
252 text: "hello",
253 createdAt: deltaAt,
254 completedAt: completeAt,
255 streaming: false,
256 });
257 });
98});258});
apps/server/src/orchestration/reducer.ts+54
...@@ -204,6 +204,7 @@ export function reduceEvent(...@@ -204,6 +204,7 @@ export function reduceEvent(
204 role: payload.role,204 role: payload.role,
205 text: payload.text,205 text: payload.text,
206 createdAt: payload.createdAt,206 createdAt: payload.createdAt,
207 ...(payload.streaming ? {} : { completedAt: event.occurredAt }),
207 streaming: payload.streaming,208 streaming: payload.streaming,
208 },209 },
209 event.type,210 event.type,
...@@ -222,6 +223,9 @@ export function reduceEvent(...@@ -222,6 +223,9 @@ export function reduceEvent(
222 : entry.text,223 : entry.text,
223 streaming: message.streaming,224 streaming: message.streaming,
224 createdAt: entry.createdAt,225 createdAt: entry.createdAt,
226 ...(message.streaming
227 ? { completedAt: undefined }
228 : { completedAt: message.completedAt ?? event.occurredAt }),
225 }229 }
226 : entry,230 : entry,
227 )231 )
...@@ -260,12 +264,51 @@ export function reduceEvent(...@@ -260,12 +264,51 @@ export function reduceEvent(
260 "session",264 "session",
261 );265 );
262266
267 const sessionUpdatedAt = session.updatedAt;
268 const turnTimingPatch: ThreadPatch =
269 session.status === "running" && session.activeTurnId
270 ? (() => {
271 const isSameTurn = thread.latestTurnId === session.activeTurnId;
272 return {
273 latestTurnId: session.activeTurnId,
274 latestTurnStartedAt:
275 isSameTurn && thread.latestTurnStartedAt
276 ? thread.latestTurnStartedAt
277 : sessionUpdatedAt,
278 ...(isSameTurn
279 ? {}
280 : {
281 latestTurnCompletedAt: null,
282 latestTurnDurationMs: null,
283 }),
284 };
285 })()
286 : session.activeTurnId === null && thread.latestTurnId && thread.latestTurnStartedAt
287 ? (() => {
288 const latestTurnCompletedAt = thread.latestTurnCompletedAt ?? sessionUpdatedAt;
289 const startedAtMs = Date.parse(thread.latestTurnStartedAt);
290 const completedAtMs = Date.parse(latestTurnCompletedAt);
291 const latestTurnDurationMs =
292 !Number.isNaN(startedAtMs) &&
293 !Number.isNaN(completedAtMs) &&
294 completedAtMs >= startedAtMs
295 ? completedAtMs - startedAtMs
296 : thread.latestTurnDurationMs;
297
298 return {
299 latestTurnCompletedAt,
300 latestTurnDurationMs,
301 };
302 })()
303 : {};
304
263 return {305 return {
264 ...nextBase,306 ...nextBase,
265 threads: updateThread(nextBase.threads, payload.threadId, {307 threads: updateThread(nextBase.threads, payload.threadId, {
266 session,308 session,
267 updatedAt: event.occurredAt,309 updatedAt: event.occurredAt,
268 error: session.lastError,310 error: session.lastError,
311 ...turnTimingPatch,
269 }),312 }),
270 };313 };
271 });314 });
...@@ -303,12 +346,23 @@ export function reduceEvent(...@@ -303,12 +346,23 @@ export function reduceEvent(
303 ...thread.turnDiffSummaries.filter((summary) => summary.turnId !== payload.turnId),346 ...thread.turnDiffSummaries.filter((summary) => summary.turnId !== payload.turnId),
304 nextSummary,347 nextSummary,
305 ].toSorted((left, right) => left.completedAt.localeCompare(right.completedAt));348 ].toSorted((left, right) => left.completedAt.localeCompare(right.completedAt));
349 const startedAtMs = thread.latestTurnStartedAt
350 ? Date.parse(thread.latestTurnStartedAt)
351 : Number.NaN;
352 const completedAtMs = Date.parse(nextSummary.completedAt);
353 const latestTurnDurationMs =
354 !Number.isNaN(startedAtMs) &&
355 !Number.isNaN(completedAtMs) &&
356 completedAtMs >= startedAtMs
357 ? completedAtMs - startedAtMs
358 : thread.latestTurnDurationMs;
306359
307 return {360 return {
308 ...nextBase,361 ...nextBase,
309 threads: updateThread(nextBase.threads, payload.threadId, {362 threads: updateThread(nextBase.threads, payload.threadId, {
310 latestTurnId: payload.turnId,363 latestTurnId: payload.turnId,
311 latestTurnCompletedAt: nextSummary.completedAt,364 latestTurnCompletedAt: nextSummary.completedAt,
365 latestTurnDurationMs,
312 turnDiffSummaries,366 turnDiffSummaries,
313 updatedAt: event.occurredAt,367 updatedAt: event.occurredAt,
314 }),368 }),
apps/server/src/provider/Layers/CodexAdapter.test.ts+39
...@@ -170,6 +170,45 @@ const lifecycleManager = new FakeCodexManager();...@@ -170,6 +170,45 @@ const lifecycleManager = new FakeCodexManager();
170const lifecycleLayer = it.layer(makeCodexAdapterLive({ manager: lifecycleManager }));170const lifecycleLayer = it.layer(makeCodexAdapterLive({ manager: lifecycleManager }));
171171
172lifecycleLayer("CodexAdapterLive lifecycle", (it) => {172lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
173 it.effect("maps completed agent message items to canonical message.completed events", () =>
174 Effect.gen(function* () {
175 const adapter = yield* CodexAdapter;
176 const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);
177
178 const event: ProviderEvent = {
179 id: "evt-msg-complete",
180 kind: "notification",
181 provider: "codex",
182 sessionId: "sess-1",
183 createdAt: new Date().toISOString(),
184 method: "item/completed",
185 threadId: "thread-1",
186 turnId: "turn-1",
187 itemId: "msg_1",
188 payload: {
189 item: {
190 type: "agentMessage",
191 id: "msg_1",
192 },
193 },
194 };
195
196 lifecycleManager.emit("event", event);
197 const firstEvent = yield* Fiber.join(firstEventFiber);
198
199 assert.equal(firstEvent._tag, "Some");
200 if (firstEvent._tag !== "Some") {
201 return;
202 }
203 assert.equal(firstEvent.value.type, "message.completed");
204 if (firstEvent.value.type !== "message.completed") {
205 return;
206 }
207 assert.equal(firstEvent.value.itemId, "msg_1");
208 assert.equal(firstEvent.value.turnId, "turn-1");
209 }),
210 );
211
173 it.effect("emits canonical events on stream and supports consumer interruption", () =>212 it.effect("emits canonical events on stream and supports consumer interruption", () =>
174 Effect.gen(function* () {213 Effect.gen(function* () {
175 const adapter = yield* CodexAdapter;214 const adapter = yield* CodexAdapter;
apps/server/src/provider/Layers/CodexAdapter.ts+38
...@@ -161,6 +161,39 @@ function itemDetail(...@@ -161,6 +161,39 @@ function itemDetail(
161 return undefined;161 return undefined;
162}162}
163163
164function mapMessageCompletedEvent(event: ProviderEvent): ProviderRuntimeEvent | undefined {
165 if (event.method !== "item/completed") {
166 return undefined;
167 }
168
169 const payload = asObject(event.payload);
170 const item = asObject(payload?.item);
171 if (!item) {
172 return undefined;
173 }
174
175 const normalizedType = normalizeItemType(item.type ?? item.kind);
176 if (!normalizedType.includes("agent message")) {
177 return undefined;
178 }
179
180 const itemId = event.itemId ?? asString(item.id);
181 if (!itemId) {
182 return undefined;
183 }
184
185 return {
186 type: "message.completed",
187 eventId: event.id,
188 provider: event.provider,
189 sessionId: event.sessionId,
190 createdAt: event.createdAt,
191 itemId,
192 ...(event.threadId ? { threadId: event.threadId } : {}),
193 ...(event.turnId ? { turnId: event.turnId } : {}),
194 };
195}
196
164function mapToolEvent(event: ProviderEvent): ProviderRuntimeEvent | undefined {197function mapToolEvent(event: ProviderEvent): ProviderRuntimeEvent | undefined {
165 if (event.method !== "item/started" && event.method !== "item/completed") {198 if (event.method !== "item/started" && event.method !== "item/completed") {
166 return undefined;199 return undefined;
...@@ -267,6 +300,11 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime...@@ -267,6 +300,11 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
267 ];300 ];
268 }301 }
269302
303 const messageCompleted = mapMessageCompletedEvent(event);
304 if (messageCompleted) {
305 return [messageCompleted];
306 }
307
270 const tool = mapToolEvent(event);308 const tool = mapToolEvent(event);
271 if (tool) {309 if (tool) {
272 return [tool];310 return [tool];
apps/server/src/wsServer.ts+83-28
...@@ -50,11 +50,9 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD...@@ -50,11 +50,9 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD
50import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";50import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService";
51import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";51import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
52import { CheckpointServiceLive } from "./checkpointing/Layers/CheckpointService";52import { CheckpointServiceLive } from "./checkpointing/Layers/CheckpointService";
53import { parseTurnDiffFilesFromUnifiedDiff, type TurnDiffFileSummary } from "./checkpointing/Diffs";
53import { CheckpointRepositoryLive } from "./persistence/Layers/Checkpoints";54import { CheckpointRepositoryLive } from "./persistence/Layers/Checkpoints";
54import {55import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger";
55 makeEventNdjsonLogger,
56 type EventNdjsonLogger,
57} from "./provider/Layers/EventNdjsonLogger";
58import * as SqlClient from "effect/unstable/sql/SqlClient";56import * as SqlClient from "effect/unstable/sql/SqlClient";
59import * as SqlError from "effect/unstable/sql/SqlError";57import * as SqlError from "effect/unstable/sql/SqlError";
60import * as Migrator from "effect/unstable/sql/Migrator";58import * as Migrator from "effect/unstable/sql/Migrator";
...@@ -275,28 +273,39 @@ export function createServer(options: ServerOptions) {...@@ -275,28 +273,39 @@ export function createServer(options: ServerOptions) {
275 >,273 >,
276 orchestrationEngine: OrchestrationEngineShape,274 orchestrationEngine: OrchestrationEngineShape,
277 ) {275 ) {
278 unsubscribeDomainEvents = await runtime.runPromise(276 const subscriptionScope = await runtime.runPromise(Scope.make("sequential"));
279 orchestrationEngine.subscribeToDomainEvents((event) => {277 await runtime.runPromise(
280 orchestrationDomainEventLogger?.write({278 Effect.gen(function* () {
281 observedAt: new Date().toISOString(),279 yield* Effect.forkScoped(
282 event,280 Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) =>
283 });281 Effect.sync(() => {
284 const push: WsPush = {282 orchestrationDomainEventLogger?.write({
285 type: "push",283 observedAt: new Date().toISOString(),
286 channel: ORCHESTRATION_WS_CHANNELS.domainEvent,284 event,
287 data: event,285 });
288 };286 const push: WsPush = {
289 const message = JSON.stringify(push);287 type: "push",
290 let recipients = 0;288 channel: ORCHESTRATION_WS_CHANNELS.domainEvent,
291 for (const client of clients) {289 data: event,
292 if (client.readyState === client.OPEN) {290 };
293 client.send(message);291 const message = JSON.stringify(push);
294 recipients += 1;292 let recipients = 0;
295 }293 for (const client of clients) {
296 }294 if (client.readyState === client.OPEN) {
297 logOutgoingPush(push, recipients);295 client.send(message);
298 }),296 recipients += 1;
297 }
298 }
299 logOutgoingPush(push, recipients);
300 }),
301 ),
302 );
303 }).pipe(Scope.provide(subscriptionScope)),
299 );304 );
305
306 unsubscribeDomainEvents = () => {
307 runtime.runFork(Scope.close(subscriptionScope, Exit.void));
308 };
300 }309 }
301310
302 async function attachProviderSubscriptions(311 async function attachProviderSubscriptions(
...@@ -350,7 +359,9 @@ export function createServer(options: ServerOptions) {...@@ -350,7 +359,9 @@ export function createServer(options: ServerOptions) {
350 const processEvent = (event: ProviderRuntimeEvent) =>359 const processEvent = (event: ProviderRuntimeEvent) =>
351 Effect.gen(function* () {360 Effect.gen(function* () {
352 const snapshot = yield* orchestrationEngine.getSnapshot();361 const snapshot = yield* orchestrationEngine.getSnapshot();
353 const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId);362 const thread = snapshot.threads.find(
363 (entry) => entry.session?.sessionId === event.sessionId,
364 );
354 if (!thread) return;365 if (!thread) return;
355366
356 const now = event.createdAt;367 const now = event.createdAt;
...@@ -393,9 +404,29 @@ export function createServer(options: ServerOptions) {...@@ -393,9 +404,29 @@ export function createServer(options: ServerOptions) {
393 });404 });
394 }405 }
395406
407 if (event.type === "message.completed") {
408 const assistantMessageId = `assistant:${event.itemId}`;
409 if (event.turnId) {
410 rememberAssistantMessageId(event.sessionId, event.turnId, assistantMessageId);
411 }
412 yield* orchestrationEngine.dispatch({
413 type: "message.send",
414 commandId: crypto.randomUUID(),
415 threadId: thread.id,
416 messageId: assistantMessageId,
417 role: "assistant",
418 text: "",
419 streaming: false,
420 createdAt: now,
421 });
422 }
423
396 if (event.type === "turn.completed") {424 if (event.type === "turn.completed") {
397 if (event.turnId) {425 if (event.turnId) {
398 const assistantMessageIds = getAssistantMessageIdsForTurn(event.sessionId, event.turnId);426 const assistantMessageIds = getAssistantMessageIdsForTurn(
427 event.sessionId,
428 event.turnId,
429 );
399 yield* Effect.forEach(assistantMessageIds, (assistantMessageId) =>430 yield* Effect.forEach(assistantMessageIds, (assistantMessageId) =>
400 orchestrationEngine.dispatch({431 orchestrationEngine.dispatch({
401 type: "message.send",432 type: "message.send",
...@@ -417,6 +448,30 @@ export function createServer(options: ServerOptions) {...@@ -417,6 +448,30 @@ export function createServer(options: ServerOptions) {
417 }448 }
418449
419 if (event.type === "checkpoint.captured" && event.turnId) {450 if (event.type === "checkpoint.captured" && event.turnId) {
451 const files: ReadonlyArray<TurnDiffFileSummary> = yield* providerService
452 .getCheckpointDiff({
453 sessionId: event.sessionId,
454 fromTurnCount: Math.max(0, event.turnCount - 1),
455 toTurnCount: event.turnCount,
456 })
457 .pipe(
458 Effect.map((result) => parseTurnDiffFilesFromUnifiedDiff(result.diff)),
459 Effect.catch((error) =>
460 Effect.sync(() => {
461 logger.warn("failed to derive checkpoint file summary", {
462 sessionId: event.sessionId,
463 turnId: event.turnId,
464 turnCount: event.turnCount,
465 error:
466 error instanceof Error
467 ? error.message
468 : typeof error === "string"
469 ? error
470 : "unknown error",
471 });
472 }).pipe(Effect.as([] as ReadonlyArray<TurnDiffFileSummary>)),
473 ),
474 );
420 const assistantMessageId =475 const assistantMessageId =
421 getLatestAssistantMessageIdForTurn(event.sessionId, event.turnId) ??476 getLatestAssistantMessageIdForTurn(event.sessionId, event.turnId) ??
422 `assistant:${event.turnId}`;477 `assistant:${event.turnId}`;
...@@ -427,7 +482,7 @@ export function createServer(options: ServerOptions) {...@@ -427,7 +482,7 @@ export function createServer(options: ServerOptions) {
427 turnId: event.turnId,482 turnId: event.turnId,
428 completedAt: now,483 completedAt: now,
429 ...(event.status ? { status: event.status } : {}),484 ...(event.status ? { status: event.status } : {}),
430 files: [],485 files,
431 assistantMessageId,486 assistantMessageId,
432 checkpointTurnCount: event.turnCount,487 checkpointTurnCount: event.turnCount,
433 createdAt: now,488 createdAt: now,
apps/web/src/components/ChatView.tsx+50-189
...@@ -26,12 +26,11 @@ import {...@@ -26,12 +26,11 @@ import {
26 useRef,26 useRef,
27 useState,27 useState,
28} from "react";28} from "react";
29import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";29import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
30import { useDebouncedValue } from "@tanstack/react-pacer";30import { useDebouncedValue } from "@tanstack/react-pacer";
31import { useNavigate, useSearch } from "@tanstack/react-router";31import { useNavigate, useSearch } from "@tanstack/react-router";
32import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";32import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";
33import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";33import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
34import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
35import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";34import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
36import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";35import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
3736
...@@ -55,7 +54,6 @@ import {...@@ -55,7 +54,6 @@ import {
55import {54import {
56 derivePendingApprovals,55 derivePendingApprovals,
57 derivePhase,56 derivePhase,
58 deriveTurnDiffFilesFromUnifiedDiff,
59 deriveTimelineEntries,57 deriveTimelineEntries,
60 type PendingApproval,58 type PendingApproval,
61 deriveWorkLogEntries,59 deriveWorkLogEntries,
...@@ -70,7 +68,6 @@ import {...@@ -70,7 +68,6 @@ import {
70 DEFAULT_THREAD_TERMINAL_ID,68 DEFAULT_THREAD_TERMINAL_ID,
71 MAX_THREAD_TERMINAL_COUNT,69 MAX_THREAD_TERMINAL_COUNT,
72 type ChatImageAttachment,70 type ChatImageAttachment,
73 type TurnDiffFileChange,
74 type TurnDiffSummary,71 type TurnDiffSummary,
75} from "../types";72} from "../types";
76import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";73import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons";
...@@ -186,33 +183,6 @@ interface ExpandedImagePreview {...@@ -186,33 +183,6 @@ interface ExpandedImagePreview {
186 name: string;183 name: string;
187}184}
188185
189type TurnCheckpointFilesState =
190 | {
191 status: "pending" | "loading";
192 files: TurnDiffFileChange[];
193 errorMessage?: undefined;
194 }
195 | {
196 status: "error";
197 files: TurnDiffFileChange[];
198 errorMessage: string;
199 }
200 | {
201 status: "ready";
202 files: TurnDiffFileChange[];
203 errorMessage?: undefined;
204 };
205
206function checkpointErrorMessage(error: unknown): string {
207 if (error instanceof Error && error.message.trim().length > 0) {
208 return error.message;
209 }
210 if (typeof error === "string" && error.trim().length > 0) {
211 return error;
212 }
213 return "Checkpoint diff is unavailable.";
214}
215
216type ComposerCommandItem =186type ComposerCommandItem =
217 | {187 | {
218 id: string;188 id: string;
...@@ -432,17 +402,21 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -432,17 +402,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
432 );402 );
433 const phase = derivePhase(activeThread?.session ?? null);403 const phase = derivePhase(activeThread?.session ?? null);
434 const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint;404 const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint;
435 const activeTurnId = activeThread?.session?.activeTurnId ?? undefined;
436 const nowIso = new Date(nowTick).toISOString();405 const nowIso = new Date(nowTick).toISOString();
437 const threadActivities = activeThread?.activities ?? [];406 const threadActivities = activeThread?.activities ?? [];
438 const workLogEntries = useMemo(407 const workLogEntries = useMemo(
439 () => deriveWorkLogEntries(threadActivities, undefined),408 () => deriveWorkLogEntries(threadActivities, undefined),
440 [threadActivities],409 [threadActivities],
441 );410 );
442 const latestTurnWorkEntries = useMemo(411 const latestTurnHasToolActivity = useMemo(() => {
443 () => deriveWorkLogEntries(threadActivities, activeTurnId ?? activeThread?.latestTurnId),412 const latestTurnId = activeThread?.latestTurnId;
444 [activeThread?.latestTurnId, activeTurnId, threadActivities],413 if (!latestTurnId) {
445 );414 return false;
415 }
416 return threadActivities.some(
417 (activity) => activity.turnId === latestTurnId && activity.tone === "tool",
418 );
419 }, [activeThread?.latestTurnId, threadActivities]);
446 const pendingApprovals = useMemo(420 const pendingApprovals = useMemo(
447 () => derivePendingApprovals(threadActivities),421 () => derivePendingApprovals(threadActivities),
448 [threadActivities],422 [threadActivities],
...@@ -453,14 +427,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -453,14 +427,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
453 );427 );
454 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =428 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
455 useTurnDiffSummaries(activeThread);429 useTurnDiffSummaries(activeThread);
456 const assistantCompletionByItemId = useMemo(() => {
457 const completionByMessageId = new Map<string, string>();
458 for (const summary of turnDiffSummaries) {
459 if (!summary.assistantMessageId) continue;
460 completionByMessageId.set(summary.assistantMessageId, summary.completedAt);
461 }
462 return completionByMessageId;
463 }, [turnDiffSummaries]);
464 const turnDiffSummaryByAssistantMessageId = useMemo(() => {430 const turnDiffSummaryByAssistantMessageId = useMemo(() => {
465 const byMessageId = new Map<string, TurnDiffSummary>();431 const byMessageId = new Map<string, TurnDiffSummary>();
466 for (const summary of turnDiffSummaries) {432 for (const summary of turnDiffSummaries) {
...@@ -469,69 +435,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -469,69 +435,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
469 }435 }
470 return byMessageId;436 return byMessageId;
471 }, [turnDiffSummaries]);437 }, [turnDiffSummaries]);
472 const checkpointDiffInputsByTurn = useMemo(
473 () =>
474 turnDiffSummaries
475 .filter((summary) => summary.assistantMessageId)
476 .map((summary) => {
477 const checkpointTurnCount =
478 summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId];
479 return {
480 turnId: summary.turnId,
481 checkpointTurnCount:
482 typeof checkpointTurnCount === "number" ? checkpointTurnCount : null,
483 };
484 }),
485 [inferredCheckpointTurnCountByTurnId, turnDiffSummaries],
486 );
487 const checkpointDiffQueriesByTurn = useQueries({
488 queries: checkpointDiffInputsByTurn.map((input) =>
489 checkpointDiffQueryOptions(api, {
490 sessionId: activeSessionId,
491 threadRuntimeId: activeThreadRuntimeId,
492 fromTurnCount:
493 typeof input.checkpointTurnCount === "number"
494 ? Math.max(0, input.checkpointTurnCount - 1)
495 : null,
496 toTurnCount: input.checkpointTurnCount,
497 cacheScope: `turn:${input.turnId}`,
498 }),
499 ),
500 });
501 const turnCheckpointFilesByTurnId = useMemo(() => {
502 const byTurnId = new Map<string, TurnCheckpointFilesState>();
503 for (let index = 0; index < checkpointDiffInputsByTurn.length; index += 1) {
504 const input = checkpointDiffInputsByTurn[index];
505 const query = checkpointDiffQueriesByTurn[index];
506 if (!input || !query) continue;
507
508 if (!activeSessionId || typeof input.checkpointTurnCount !== "number") {
509 byTurnId.set(input.turnId, { status: "pending", files: [] });
510 continue;
511 }
512
513 const diff = query.data?.diff;
514 if (typeof diff === "string") {
515 byTurnId.set(input.turnId, {
516 status: "ready",
517 files: deriveTurnDiffFilesFromUnifiedDiff(diff),
518 });
519 continue;
520 }
521
522 if (query.isError) {
523 byTurnId.set(input.turnId, {
524 status: "error",
525 files: [],
526 errorMessage: checkpointErrorMessage(query.error),
527 });
528 continue;
529 }
530
531 byTurnId.set(input.turnId, { status: "loading", files: [] });
532 }
533 return byTurnId;
534 }, [activeSessionId, checkpointDiffInputsByTurn, checkpointDiffQueriesByTurn]);
535 const revertTurnCountByUserMessageId = useMemo(() => {438 const revertTurnCountByUserMessageId = useMemo(() => {
536 const byUserMessageId = new Map<string, number>();439 const byUserMessageId = new Map<string, number>();
537 for (let index = 0; index < timelineEntries.length; index += 1) {440 for (let index = 0; index < timelineEntries.length; index += 1) {
...@@ -568,9 +471,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -568,9 +471,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
568 const completionSummary = useMemo(() => {471 const completionSummary = useMemo(() => {
569 if (!activeThread?.latestTurnStartedAt) return null;472 if (!activeThread?.latestTurnStartedAt) return null;
570 if (!activeThread.latestTurnCompletedAt) return null;473 if (!activeThread.latestTurnCompletedAt) return null;
571 if (!latestTurnWorkEntries.some((entry) => entry.tone === "tool")) {474 if (!latestTurnHasToolActivity) return null;
572 return null;
573 }
574475
575 if (476 if (
576 typeof activeThread.latestTurnDurationMs === "number" &&477 typeof activeThread.latestTurnDurationMs === "number" &&
...@@ -589,7 +490,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -589,7 +490,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
589 activeThread?.latestTurnStartedAt,490 activeThread?.latestTurnStartedAt,
590 activeThread?.latestTurnCompletedAt,491 activeThread?.latestTurnCompletedAt,
591 activeThread?.latestTurnDurationMs,492 activeThread?.latestTurnDurationMs,
592 latestTurnWorkEntries,493 latestTurnHasToolActivity,
593 ]);494 ]);
594 const completionDividerBeforeEntryId = useMemo(() => {495 const completionDividerBeforeEntryId = useMemo(() => {
595 if (!activeThread?.latestTurnStartedAt) return null;496 if (!activeThread?.latestTurnStartedAt) return null;
...@@ -2039,9 +1940,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2039,9 +1940,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2039 timelineEntries={timelineEntries}1940 timelineEntries={timelineEntries}
2040 completionDividerBeforeEntryId={completionDividerBeforeEntryId}1941 completionDividerBeforeEntryId={completionDividerBeforeEntryId}
2041 completionSummary={completionSummary}1942 completionSummary={completionSummary}
2042 assistantCompletionByItemId={assistantCompletionByItemId}
2043 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}1943 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
2044 turnCheckpointFilesByTurnId={turnCheckpointFilesByTurnId}
2045 nowIso={nowIso}1944 nowIso={nowIso}
2046 expandedWorkGroups={expandedWorkGroups}1945 expandedWorkGroups={expandedWorkGroups}
2047 onToggleWorkGroup={onToggleWorkGroup}1946 onToggleWorkGroup={onToggleWorkGroup}
...@@ -2505,9 +2404,7 @@ interface MessagesTimelineProps {...@@ -2505,9 +2404,7 @@ interface MessagesTimelineProps {
2505 timelineEntries: ReturnType<typeof deriveTimelineEntries>;2404 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
2506 completionDividerBeforeEntryId: string | null;2405 completionDividerBeforeEntryId: string | null;
2507 completionSummary: string | null;2406 completionSummary: string | null;
2508 assistantCompletionByItemId: Map<string, string>;
2509 turnDiffSummaryByAssistantMessageId: Map<string, TurnDiffSummary>;2407 turnDiffSummaryByAssistantMessageId: Map<string, TurnDiffSummary>;
2510 turnCheckpointFilesByTurnId: Map<string, TurnCheckpointFilesState>;
2511 nowIso: string;2408 nowIso: string;
2512 expandedWorkGroups: Record<string, boolean>;2409 expandedWorkGroups: Record<string, boolean>;
2513 onToggleWorkGroup: (groupId: string) => void;2410 onToggleWorkGroup: (groupId: string) => void;
...@@ -2542,9 +2439,7 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2542,9 +2439,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
2542 timelineEntries,2439 timelineEntries,
2543 completionDividerBeforeEntryId,2440 completionDividerBeforeEntryId,
2544 completionSummary,2441 completionSummary,
2545 assistantCompletionByItemId,
2546 turnDiffSummaryByAssistantMessageId,2442 turnDiffSummaryByAssistantMessageId,
2547 turnCheckpointFilesByTurnId,
2548 nowIso,2443 nowIso,
2549 expandedWorkGroups,2444 expandedWorkGroups,
2550 onToggleWorkGroup,2445 onToggleWorkGroup,
...@@ -2793,10 +2688,8 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2793,10 +2688,8 @@ const MessagesTimeline = memo(function MessagesTimeline({
2793 row.message.id,2688 row.message.id,
2794 );2689 );
2795 if (!turnSummary) return null;2690 if (!turnSummary) return null;
2796 const checkpointFilesState = turnCheckpointFilesByTurnId.get(2691 const checkpointFiles = turnSummary.files;
2797 turnSummary.turnId,2692 if (checkpointFiles.length === 0) return null;
2798 );
2799 const checkpointFiles = checkpointFilesState?.files ?? [];
2800 const summaryStat = checkpointFiles.reduce(2693 const summaryStat = checkpointFiles.reduce(
2801 (acc, file) => {2694 (acc, file) => {
2802 if (2695 if (
...@@ -2812,10 +2705,7 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2812,10 +2705,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
2812 },2705 },
2813 { additions: 0, deletions: 0 },2706 { additions: 0, deletions: 0 },
2814 );2707 );
2815 const changedFileCountLabel =2708 const changedFileCountLabel = String(checkpointFiles.length);
2816 checkpointFilesState?.status === "ready"
2817 ? String(checkpointFiles.length)
2818 : "...";
2819 return (2709 return (
2820 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">2710 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
2821 <div className="mb-1.5 flex items-center justify-between gap-2">2711 <div className="mb-1.5 flex items-center justify-between gap-2">
...@@ -2843,66 +2733,40 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2843,66 +2733,40 @@ const MessagesTimeline = memo(function MessagesTimeline({
2843 View diff2733 View diff
2844 </Button>2734 </Button>
2845 </div>2735 </div>
2846 {!checkpointFilesState ||2736 <div className="flex flex-wrap gap-1.5">
2847 checkpointFilesState.status === "pending" ? (2737 {checkpointFiles.map((file) => (
2848 <p className="text-[11px] text-muted-foreground/70">2738 <button
2849 Waiting for checkpoint metadata...2739 key={`${turnSummary.turnId}:${file.path}`}
2850 </p>2740 type="button"
2851 ) : checkpointFilesState?.status === "loading" ? (2741 className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90"
2852 <p className="text-[11px] text-muted-foreground/70">2742 onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}
2853 Loading checkpoint diff...2743 >
2854 </p>2744 {(() => {
2855 ) : checkpointFilesState?.status === "error" ? (2745 const stat =
2856 <p2746 typeof file.additions === "number" &&
2857 className="truncate text-[11px] text-muted-foreground/70"2747 typeof file.deletions === "number"
2858 title={checkpointFilesState.errorMessage}2748 ? {
2859 >2749 additions: file.additions,
2860 {checkpointFilesState.errorMessage}2750 deletions: file.deletions,
2861 </p>2751 }
2862 ) : checkpointFiles.length > 0 ? (2752 : null;
2863 <div className="flex flex-wrap gap-1.5">2753 if (!stat) {
2864 {checkpointFiles.map((file) => (2754 return file.path;
2865 <button2755 }
2866 key={`${turnSummary.turnId}:${file.path}`}2756 return (
2867 type="button"2757 <>
2868 className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90"2758 <span>{file.path}</span>
2869 onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}2759 <span className="ml-1 text-muted-foreground/70">(</span>
2870 >2760 <span className="text-success">+{stat.additions}</span>
2871 {(() => {2761 <span className="mx-0.5 text-muted-foreground/70">/</span>
2872 const stat =2762 <span className="text-destructive">-{stat.deletions}</span>
2873 typeof file.additions === "number" &&2763 <span className="text-muted-foreground/70">)</span>
2874 typeof file.deletions === "number"2764 </>
2875 ? {2765 );
2876 additions: file.additions,2766 })()}
2877 deletions: file.deletions,2767 </button>
2878 }2768 ))}
2879 : null;2769 </div>
2880 if (!stat) {
2881 return file.path;
2882 }
2883 return (
2884 <>
2885 <span>{file.path}</span>
2886 <span className="ml-1 text-muted-foreground/70">(</span>
2887 <span className="text-success">+{stat.additions}</span>
2888 <span className="mx-0.5 text-muted-foreground/70">
2889 /
2890 </span>
2891 <span className="text-destructive">
2892 -{stat.deletions}
2893 </span>
2894 <span className="text-muted-foreground/70">)</span>
2895 </>
2896 );
2897 })()}
2898 </button>
2899 ))}
2900 </div>
2901 ) : (
2902 <p className="text-[11px] text-muted-foreground/70">
2903 No changed files.
2904 </p>
2905 )}
2906 </div>2770 </div>
2907 );2771 );
2908 })()}2772 })()}
...@@ -2911,10 +2775,7 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2911,10 +2775,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
2911 row.message.createdAt,2775 row.message.createdAt,
2912 row.message.streaming2776 row.message.streaming
2913 ? formatElapsed(row.message.createdAt, nowIso)2777 ? formatElapsed(row.message.createdAt, nowIso)
2914 : formatElapsed(2778 : formatElapsed(row.message.createdAt, row.message.completedAt),
2915 row.message.createdAt,
2916 assistantCompletionByItemId.get(row.message.id),
2917 ),
2918 )}2779 )}
2919 </p>2780 </p>
2920 </div>2781 </div>
apps/web/src/components/DiffPanel.tsx+33-12
...@@ -104,14 +104,28 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -104,14 +104,28 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
104 const activeSessionId = activeThread?.session?.sessionId ?? null;104 const activeSessionId = activeThread?.session?.sessionId ?? null;
105 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =105 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
106 useTurnDiffSummaries(activeThread);106 useTurnDiffSummaries(activeThread);
107 const orderedTurnDiffSummaries = useMemo(
108 () =>
109 [...turnDiffSummaries].toSorted((left, right) => {
110 const leftTurnCount =
111 left.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[left.turnId] ?? 0;
112 const rightTurnCount =
113 right.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[right.turnId] ?? 0;
114 if (leftTurnCount !== rightTurnCount) {
115 return rightTurnCount - leftTurnCount;
116 }
117 return right.completedAt.localeCompare(left.completedAt);
118 }),
119 [inferredCheckpointTurnCountByTurnId, turnDiffSummaries],
120 );
107121
108 const selectedTurnId = diffSearch.diffTurnId ?? null;122 const selectedTurnId = diffSearch.diffTurnId ?? null;
109 const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null;123 const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null;
110 const selectedTurn =124 const selectedTurn =
111 selectedTurnId === null125 selectedTurnId === null
112 ? undefined126 ? undefined
113 : (turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ??127 : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ??
114 turnDiffSummaries[0]);128 orderedTurnDiffSummaries[0]);
115 const selectedCheckpointTurnCount =129 const selectedCheckpointTurnCount =
116 selectedTurn &&130 selectedTurn &&
117 (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]);131 (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]);
...@@ -126,7 +140,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -126,7 +140,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
126 [selectedCheckpointTurnCount],140 [selectedCheckpointTurnCount],
127 );141 );
128 const conversationCheckpointTurnCount = useMemo(() => {142 const conversationCheckpointTurnCount = useMemo(() => {
129 const turnCounts = turnDiffSummaries143 const turnCounts = orderedTurnDiffSummaries
130 .map(144 .map(
131 (summary) =>145 (summary) =>
132 summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId],146 summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId],
...@@ -137,7 +151,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -137,7 +151,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
137 }151 }
138 const latest = Math.max(...turnCounts);152 const latest = Math.max(...turnCounts);
139 return latest > 0 ? latest : undefined;153 return latest > 0 ? latest : undefined;
140 }, [inferredCheckpointTurnCountByTurnId, turnDiffSummaries]);154 }, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries]);
141 const conversationCheckpointRange = useMemo(155 const conversationCheckpointRange = useMemo(
142 () =>156 () =>
143 !selectedTurn && typeof conversationCheckpointTurnCount === "number"157 !selectedTurn && typeof conversationCheckpointTurnCount === "number"
...@@ -152,11 +166,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -152,11 +166,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
152 ? selectedCheckpointRange166 ? selectedCheckpointRange
153 : conversationCheckpointRange;167 : conversationCheckpointRange;
154 const conversationCacheScope = useMemo(() => {168 const conversationCacheScope = useMemo(() => {
155 if (selectedTurn || turnDiffSummaries.length === 0) {169 if (selectedTurn || orderedTurnDiffSummaries.length === 0) {
156 return null;170 return null;
157 }171 }
158 return `conversation:${turnDiffSummaries.map((summary) => summary.turnId).join(",")}`;172 return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`;
159 }, [selectedTurn, turnDiffSummaries]);173 }, [orderedTurnDiffSummaries, selectedTurn]);
160 const activeCheckpointDiffQuery = useQuery(174 const activeCheckpointDiffQuery = useQuery(
161 checkpointDiffQueryOptions(api, {175 checkpointDiffQueryOptions(api, {
162 sessionId: activeSessionId,176 sessionId: activeSessionId,
...@@ -181,6 +195,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -181,6 +195,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
181 : null;195 : null;
182196
183 const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff;197 const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff;
198 const hasResolvedPatch = typeof selectedPatch === "string";
199 const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0;
184 const renderablePatch = useMemo(() => getRenderablePatch(selectedPatch), [selectedPatch]);200 const renderablePatch = useMemo(() => getRenderablePatch(selectedPatch), [selectedPatch]);
185 const renderableFiles = useMemo(() => {201 const renderableFiles = useMemo(() => {
186 if (!renderablePatch || renderablePatch.kind !== "files") {202 if (!renderablePatch || renderablePatch.kind !== "files") {
...@@ -275,7 +291,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -275,7 +291,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
275291
276 useEffect(() => {292 useEffect(() => {
277 updateTurnStripScrollState();293 updateTurnStripScrollState();
278 }, [turnDiffSummaries, selectedTurnId, updateTurnStripScrollState]);294 }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]);
279295
280 useEffect(() => {296 useEffect(() => {
281 const element = turnStripRef.current;297 const element = turnStripRef.current;
...@@ -358,7 +374,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -358,7 +374,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
358 <div className="text-[10px] leading-tight font-medium">All turns</div>374 <div className="text-[10px] leading-tight font-medium">All turns</div>
359 </div>375 </div>
360 </button>376 </button>
361 {turnDiffSummaries.map((summary, index) => (377 {orderedTurnDiffSummaries.map((summary) => (
362 <button378 <button
363 key={summary.turnId}379 key={summary.turnId}
364 type="button"380 type="button"
...@@ -377,7 +393,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -377,7 +393,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
377 >393 >
378 <div className="flex items-center gap-1">394 <div className="flex items-center gap-1">
379 <span className="text-[10px] leading-tight font-medium">395 <span className="text-[10px] leading-tight font-medium">
380 Turn {turnDiffSummaries.length - index}396 Turn{" "}
397 {summary.checkpointTurnCount ??
398 inferredCheckpointTurnCountByTurnId[summary.turnId] ??
399 "?"}
381 </span>400 </span>
382 <span className="text-[9px] leading-tight opacity-70">401 <span className="text-[9px] leading-tight opacity-70">
383 {formatTurnChipTimestamp(summary.completedAt)}402 {formatTurnChipTimestamp(summary.completedAt)}
...@@ -413,7 +432,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -413,7 +432,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
413 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">432 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
414 Select a thread to inspect turn diffs.433 Select a thread to inspect turn diffs.
415 </div>434 </div>
416 ) : turnDiffSummaries.length === 0 ? (435 ) : orderedTurnDiffSummaries.length === 0 ? (
417 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">436 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
418 No completed turns yet.437 No completed turns yet.
419 </div>438 </div>
...@@ -430,7 +449,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -430,7 +449,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
430 <p>449 <p>
431 {isLoadingCheckpointDiff450 {isLoadingCheckpointDiff
432 ? "Loading checkpoint diff..."451 ? "Loading checkpoint diff..."
433 : "No patch available for this selection."}452 : hasNoNetChanges
453 ? "No net changes in this selection."
454 : "No patch available for this selection."}
434 </p>455 </p>
435 </div>456 </div>
436 ) : renderablePatch.kind === "files" ? (457 ) : renderablePatch.kind === "files" ? (
apps/web/src/hooks/useTurnDiffSummaries.ts+1-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { useMemo } from "react";1import { useMemo } from "react";
2import { inferCheckpointTurnCountByTurnId } from "../session-logic";2import { inferCheckpointTurnCountByTurnId } from "../session-logic";
3import type { Thread, TurnDiffSummary } from "../types";3import type { Thread } from "../types";
44
5export function useTurnDiffSummaries(activeThread: Thread | undefined) {5export function useTurnDiffSummaries(activeThread: Thread | undefined) {
6 const turnDiffSummaries = useMemo(() => {6 const turnDiffSummaries = useMemo(() => {
apps/web/src/session-logic.test.ts+53-1
...@@ -1,7 +1,11 @@...@@ -1,7 +1,11 @@
1import type { OrchestrationThreadActivity } from "@t3tools/contracts";1import type { OrchestrationThreadActivity } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import { derivePendingApprovals, deriveWorkLogEntries } from "./session-logic";4import {
5 derivePendingApprovals,
6 deriveTurnDiffFilesFromUnifiedDiff,
7 deriveWorkLogEntries,
8} from "./session-logic";
59
6function makeActivity(10function makeActivity(
7 overrides: Partial<OrchestrationThreadActivity>,11 overrides: Partial<OrchestrationThreadActivity>,
...@@ -87,4 +91,52 @@ describe("deriveWorkLogEntries", () => {...@@ -87,4 +91,52 @@ describe("deriveWorkLogEntries", () => {
87 const entries = deriveWorkLogEntries(activities, "turn-2");91 const entries = deriveWorkLogEntries(activities, "turn-2");
88 expect(entries.map((entry) => entry.id)).toEqual(["turn-2"]);92 expect(entries.map((entry) => entry.id)).toEqual(["turn-2"]);
89 });93 });
94
95 it("omits checkpoint captured info entries", () => {
96 const activities: OrchestrationThreadActivity[] = [
97 makeActivity({
98 id: "checkpoint",
99 createdAt: "2026-02-23T00:00:01.000Z",
100 label: "Checkpoint captured",
101 tone: "info",
102 }),
103 makeActivity({
104 id: "tool-complete",
105 createdAt: "2026-02-23T00:00:02.000Z",
106 label: "Command run complete",
107 tone: "tool",
108 }),
109 ];
110
111 const entries = deriveWorkLogEntries(activities, undefined);
112 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
113 });
114});
115
116describe("deriveTurnDiffFilesFromUnifiedDiff", () => {
117 it("extracts per-file +/- counts from unified diff", () => {
118 const diff = [
119 "diff --git a/a.txt b/a.txt",
120 "index 111..222 100644",
121 "--- a/a.txt",
122 "+++ b/a.txt",
123 "@@ -1 +1,2 @@",
124 "-old",
125 "+new",
126 "+extra",
127 "diff --git a/b.txt b/b.txt",
128 "index 333..444 100644",
129 "--- a/b.txt",
130 "+++ b/b.txt",
131 "@@ -2,2 +2 @@",
132 "-x",
133 "-y",
134 "+z",
135 ].join("\n");
136
137 expect(deriveTurnDiffFilesFromUnifiedDiff(diff)).toEqual([
138 { path: "a.txt", additions: 2, deletions: 1 },
139 { path: "b.txt", additions: 1, deletions: 2 },
140 ]);
141 });
90});142});
apps/web/src/session-logic.ts+38-6
...@@ -112,6 +112,7 @@ export function deriveWorkLogEntries(...@@ -112,6 +112,7 @@ export function deriveWorkLogEntries(
112 );112 );
113 return ordered113 return ordered
114 .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true))114 .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true))
115 .filter((activity) => activity.label !== "Checkpoint captured")
115 .map((activity) => {116 .map((activity) => {
116 const entry: WorkLogEntry = {117 const entry: WorkLogEntry = {
117 id: activity.id,118 id: activity.id,
...@@ -146,16 +147,47 @@ export function deriveTimelineEntries(...@@ -146,16 +147,47 @@ export function deriveTimelineEntries(
146}147}
147148
148export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] {149export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] {
150 const normalized = diff.replace(/\r\n/g, "\n");
151 const headerMatches = [...normalized.matchAll(/^diff --git .+$/gm)];
152 if (headerMatches.length === 0) {
153 return [];
154 }
155
149 const files: TurnDiffFileChange[] = [];156 const files: TurnDiffFileChange[] = [];
150 const lines = diff.split("\n");157 for (let index = 0; index < headerMatches.length; index += 1) {
151 for (const line of lines) {158 const header = headerMatches[index];
152 if (!line.startsWith("diff --git ")) continue;159 if (!header) continue;
153 const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/);160 const start = header.index ?? 0;
161 const nextStart = headerMatches[index + 1]?.index ?? normalized.length;
162 const segment = normalized.slice(start, nextStart);
163 const headerLine = header[0];
164 if (!headerLine) continue;
165
166 const match = headerLine.match(/^diff --git a\/(.+?) b\/(.+)$/);
154 const filePath = match?.[2] ?? match?.[1];167 const filePath = match?.[2] ?? match?.[1];
155 if (!filePath) continue;168 if (!filePath) continue;
156 files.push({ path: filePath });169
170 let additions = 0;
171 let deletions = 0;
172 for (const line of segment.split("\n")) {
173 if (line.startsWith("+++ ") || line.startsWith("--- ")) continue;
174 if (line.startsWith("+")) {
175 additions += 1;
176 continue;
177 }
178 if (line.startsWith("-")) {
179 deletions += 1;
180 }
181 }
182
183 files.push({
184 path: filePath,
185 additions,
186 deletions,
187 });
157 }188 }
158 return files;189
190 return files.toSorted((left, right) => left.path.localeCompare(right.path));
159}191}
160192
161export function inferCheckpointTurnCountByTurnId(193export function inferCheckpointTurnCountByTurnId(
apps/web/src/types.ts+1
...@@ -34,6 +34,7 @@ export interface ChatMessage {...@@ -34,6 +34,7 @@ export interface ChatMessage {
34 text: string;34 text: string;
35 attachments?: ChatAttachment[];35 attachments?: ChatAttachment[];
36 createdAt: string;36 createdAt: string;
37 completedAt?: string | undefined;
37 streaming: boolean;38 streaming: boolean;
38}39}
3940
bun.lock+1
...@@ -35,6 +35,7 @@...@@ -35,6 +35,7 @@
35 },35 },
36 "dependencies": {36 "dependencies": {
37 "@effect/platform-node": "4.0.0-beta.10",37 "@effect/platform-node": "4.0.0-beta.10",
38 "@pierre/diffs": "^1.1.0-beta.16",
38 "effect": "4.0.0-beta.10",39 "effect": "4.0.0-beta.10",
39 "node-pty": "^1.1.0",40 "node-pty": "^1.1.0",
40 "open": "^10.1.0",41 "open": "^10.1.0",
packages/contracts/src/orchestration.ts+1
...@@ -18,6 +18,7 @@ export const OrchestrationMessageSchema = Schema.Struct({...@@ -18,6 +18,7 @@ export const OrchestrationMessageSchema = Schema.Struct({
18 role: Schema.Literals(["user", "assistant"]),18 role: Schema.Literals(["user", "assistant"]),
19 text: Schema.String,19 text: Schema.String,
20 createdAt: IsoDateTimeSchema,20 createdAt: IsoDateTimeSchema,
21 completedAt: Schema.optional(IsoDateTimeSchema),
21 streaming: Schema.Boolean,22 streaming: Schema.Boolean,
22});23});
2324
packages/contracts/src/providerRuntime.ts+15
...@@ -85,6 +85,17 @@ export const ProviderRuntimeMessageDeltaEventSchema = Schema.Struct({...@@ -85,6 +85,17 @@ export const ProviderRuntimeMessageDeltaEventSchema = Schema.Struct({
85 delta: Schema.String,85 delta: Schema.String,
86});86});
8787
88export const ProviderRuntimeMessageCompletedEventSchema = Schema.Struct({
89 type: Schema.Literal("message.completed"),
90 eventId: IdSchema,
91 provider: ProviderRuntimeProviderSchema,
92 sessionId: IdSchema,
93 createdAt: IsoDateTimeSchema,
94 itemId: IdSchema,
95 threadId: Schema.optional(Schema.String),
96 turnId: Schema.optional(Schema.String),
97});
98
88export const ProviderRuntimeToolStartedEventSchema = Schema.Struct({99export const ProviderRuntimeToolStartedEventSchema = Schema.Struct({
89 type: Schema.Literal("tool.started"),100 type: Schema.Literal("tool.started"),
90 eventId: IdSchema,101 eventId: IdSchema,
...@@ -172,6 +183,7 @@ export const ProviderRuntimeEventSchema = Schema.Union([...@@ -172,6 +183,7 @@ export const ProviderRuntimeEventSchema = Schema.Union([
172 ProviderRuntimeTurnStartedEventSchema,183 ProviderRuntimeTurnStartedEventSchema,
173 ProviderRuntimeTurnCompletedEventSchema,184 ProviderRuntimeTurnCompletedEventSchema,
174 ProviderRuntimeMessageDeltaEventSchema,185 ProviderRuntimeMessageDeltaEventSchema,
186 ProviderRuntimeMessageCompletedEventSchema,
175 ProviderRuntimeToolStartedEventSchema,187 ProviderRuntimeToolStartedEventSchema,
176 ProviderRuntimeToolCompletedEventSchema,188 ProviderRuntimeToolCompletedEventSchema,
177 ProviderRuntimeApprovalRequestedEventSchema,189 ProviderRuntimeApprovalRequestedEventSchema,
...@@ -198,6 +210,9 @@ export type ProviderRuntimeTurnCompletedEvent = Schema.Schema.Type<...@@ -198,6 +210,9 @@ export type ProviderRuntimeTurnCompletedEvent = Schema.Schema.Type<
198export type ProviderRuntimeMessageDeltaEvent = Schema.Schema.Type<210export type ProviderRuntimeMessageDeltaEvent = Schema.Schema.Type<
199 typeof ProviderRuntimeMessageDeltaEventSchema211 typeof ProviderRuntimeMessageDeltaEventSchema
200>;212>;
213export type ProviderRuntimeMessageCompletedEvent = Schema.Schema.Type<
214 typeof ProviderRuntimeMessageCompletedEventSchema
215>;
201export type ProviderRuntimeToolStartedEvent = Schema.Schema.Type<216export type ProviderRuntimeToolStartedEvent = Schema.Schema.Type<
202 typeof ProviderRuntimeToolStartedEventSchema217 typeof ProviderRuntimeToolStartedEventSchema
203>;218>;