| author | |
| committer | |
| log | 20668e49a934ce0bb02fe1eb8b57846fe36959ad |
| tree | 9f8932cefb5043a2957e383b80604ec8bd1afe9c |
| parent | fcc94b3b2ffdf3d96fe09fc224b11f0540d2443c |
20 files changed, 710 insertions(+), 331 deletions(-)
apps/server/package.json+1| ... | ... | @@ -18,6 +18,7 @@ |
| 18 | 18 | }, |
| 19 | 19 | "dependencies": { |
| 20 | 20 | "@effect/platform-node": "4.0.0-beta.10", |
| 21 | "@pierre/diffs": "^1.1.0-beta.16", | |
| 21 | 22 | "effect": "4.0.0-beta.10", |
| 22 | 23 | "node-pty": "^1.1.0", |
| 23 | 24 | "open": "^10.1.0", |
apps/server/src/checkpointing/Diffs.test.ts created+68| ... | ... | @@ -0,0 +1,68 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { parseTurnDiffFilesFromUnifiedDiff } from "./Diffs.ts"; | |
| 4 | ||
| 5 | describe("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 @@ |
| 1 | import { parsePatchFiles } from "@pierre/diffs"; | |
| 2 | ||
| 3 | export interface TurnDiffFileSummary { | |
| 4 | readonly path: string; | |
| 5 | readonly additions: number; | |
| 6 | readonly deletions: number; | |
| 7 | } | |
| 8 | ||
| 9 | export 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 | 1 | import type { OrchestrationEvent } from "@t3tools/contracts"; |
| 2 | import { Effect, Layer, ManagedRuntime } from "effect"; | |
| 2 | import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"; | |
| 3 | 3 | import { describe, expect, it } from "vitest"; |
| 4 | 4 | |
| 5 | 5 | import { |
| ... | ... | @@ -65,33 +65,6 @@ describe("OrchestrationEngine", () => { |
| 65 | 65 | await system.dispose(); |
| 66 | 66 | }); |
| 67 | 67 | |
| 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 | 68 | it("replays append-only events from sequence", async () => { |
| 96 | 69 | const system = await createOrchestrationSystem(); |
| 97 | 70 | const engine = system.engine; |
| ... | ... | @@ -123,6 +96,46 @@ describe("OrchestrationEngine", () => { |
| 123 | 96 | await system.dispose(); |
| 124 | 97 | }); |
| 125 | 98 | |
| 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 | 139 | it("stores completed turn summaries even when no files changed", async () => { |
| 127 | 140 | const system = await createOrchestrationSystem(); |
| 128 | 141 | const engine = system.engine; |
apps/server/src/orchestration/Layer.ts+5-48| ... | ... | @@ -4,12 +4,11 @@ import type { |
| 4 | 4 | OrchestrationReadModel, |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | import { OrchestrationCommandSchema } from "@t3tools/contracts"; |
| 7 | import { Deferred, Effect, Layer, PubSub, Queue, Schema } from "effect"; | |
| 7 | import { Deferred, Effect, Layer, PubSub, Queue, Schema, Stream } from "effect"; | |
| 8 | 8 | |
| 9 | 9 | import { createLogger } from "../logger.ts"; |
| 10 | 10 | import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents.ts"; |
| 11 | 11 | import { |
| 12 | toListenerCallbackError, | |
| 13 | 12 | toOrchestrationCommandDecodeError, |
| 14 | 13 | toOrchestrationJsonParseError, |
| 15 | 14 | type OrchestrationDispatchError, |
| ... | ... | @@ -182,42 +181,14 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 182 | 181 | let readModel = createEmptyReadModel(new Date().toISOString()); |
| 183 | 182 | |
| 184 | 183 | const commandQueue = yield* Queue.unbounded<CommandEnvelope>(); |
| 185 | const readModelPubSub = yield* PubSub.unbounded<OrchestrationReadModel>(); | |
| 186 | 184 | const eventPubSub = yield* PubSub.unbounded<OrchestrationEvent>(); |
| 187 | 185 | |
| 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 | 186 | const processEnvelope = (envelope: CommandEnvelope): Effect.Effect<void> => |
| 208 | 187 | Effect.gen(function* () { |
| 209 | 188 | const eventBase = mapCommandToEvent(envelope.command); |
| 210 | 189 | const savedEvent = yield* eventStore.append(eventBase); |
| 211 | 190 | readModel = yield* reduceEvent(readModel, savedEvent); |
| 212 | ||
| 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); | |
| 191 | yield* PubSub.publish(eventPubSub, savedEvent); | |
| 221 | 192 | |
| 222 | 193 | yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); |
| 223 | 194 | }).pipe(Effect.catch((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid))); |
| ... | ... | @@ -269,29 +240,15 @@ const makeOrchestrationEngine = Effect.gen(function* () { |
| 269 | 240 | return yield* dispatch(decoded); |
| 270 | 241 | }); |
| 271 | 242 | |
| 272 | const subscribeToReadModel: OrchestrationEngineShape["subscribeToReadModel"] = (callback) => | |
| 273 | Effect.sync(() => { | |
| 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 | }); | |
| 243 | const streamDomainEvents: OrchestrationEngineShape["streamDomainEvents"] = | |
| 244 | Stream.fromPubSub(eventPubSub); | |
| 287 | 245 | |
| 288 | 246 | return { |
| 289 | 247 | getSnapshot, |
| 290 | 248 | replayEvents, |
| 291 | 249 | dispatchUnknown, |
| 292 | 250 | dispatch, |
| 293 | subscribeToReadModel, | |
| 294 | subscribeToDomainEvents, | |
| 251 | streamDomainEvents, | |
| 295 | 252 | } satisfies OrchestrationEngineShape; |
| 296 | 253 | }); |
| 297 | 254 |
apps/server/src/orchestration/Service.ts+3-18| ... | ... | @@ -16,7 +16,7 @@ import type { |
| 16 | 16 | OrchestrationReadModel, |
| 17 | 17 | } from "@t3tools/contracts"; |
| 18 | 18 | import { ServiceMap } from "effect"; |
| 19 | import type { Effect } from "effect"; | |
| 19 | import type { Effect, Stream } from "effect"; | |
| 20 | 20 | |
| 21 | 21 | import type { OrchestrationDispatchError, OrchestrationEngineError } from "./Errors.ts"; |
| 22 | 22 | import type { OrchestrationEventRepositoryError } from "../persistence/Errors.ts"; |
| ... | ... | @@ -62,24 +62,9 @@ export interface OrchestrationEngineShape { |
| 62 | 62 | ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never>; |
| 63 | 63 | |
| 64 | 64 | /** |
| 65 | * Subscribe to read model updates. | |
| 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. | |
| 65 | * Stream persisted domain events in dispatch order. | |
| 79 | 66 | */ |
| 80 | readonly subscribeToDomainEvents: ( | |
| 81 | callback: (event: OrchestrationEvent) => void, | |
| 82 | ) => Effect.Effect<() => void, never, never>; | |
| 67 | readonly streamDomainEvents: Stream.Stream<OrchestrationEvent>; | |
| 83 | 68 | } |
| 84 | 69 | |
| 85 | 70 | /** |
apps/server/src/orchestration/reducer.test.ts+160| ... | ... | @@ -95,4 +95,164 @@ describe("orchestration reducer", () => { |
| 95 | 95 | expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z"); |
| 96 | 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 | 204 | role: payload.role, |
| 205 | 205 | text: payload.text, |
| 206 | 206 | createdAt: payload.createdAt, |
| 207 | ...(payload.streaming ? {} : { completedAt: event.occurredAt }), | |
| 207 | 208 | streaming: payload.streaming, |
| 208 | 209 | }, |
| 209 | 210 | event.type, |
| ... | ... | @@ -222,6 +223,9 @@ export function reduceEvent( |
| 222 | 223 | : entry.text, |
| 223 | 224 | streaming: message.streaming, |
| 224 | 225 | createdAt: entry.createdAt, |
| 226 | ...(message.streaming | |
| 227 | ? { completedAt: undefined } | |
| 228 | : { completedAt: message.completedAt ?? event.occurredAt }), | |
| 225 | 229 | } |
| 226 | 230 | : entry, |
| 227 | 231 | ) |
| ... | ... | @@ -260,12 +264,51 @@ export function reduceEvent( |
| 260 | 264 | "session", |
| 261 | 265 | ); |
| 262 | 266 | |
| 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 | 305 | return { |
| 264 | 306 | ...nextBase, |
| 265 | 307 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 266 | 308 | session, |
| 267 | 309 | updatedAt: event.occurredAt, |
| 268 | 310 | error: session.lastError, |
| 311 | ...turnTimingPatch, | |
| 269 | 312 | }), |
| 270 | 313 | }; |
| 271 | 314 | }); |
| ... | ... | @@ -303,12 +346,23 @@ export function reduceEvent( |
| 303 | 346 | ...thread.turnDiffSummaries.filter((summary) => summary.turnId !== payload.turnId), |
| 304 | 347 | nextSummary, |
| 305 | 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; | |
| 306 | 359 | |
| 307 | 360 | return { |
| 308 | 361 | ...nextBase, |
| 309 | 362 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 310 | 363 | latestTurnId: payload.turnId, |
| 311 | 364 | latestTurnCompletedAt: nextSummary.completedAt, |
| 365 | latestTurnDurationMs, | |
| 312 | 366 | turnDiffSummaries, |
| 313 | 367 | updatedAt: event.occurredAt, |
| 314 | 368 | }), |
apps/server/src/provider/Layers/CodexAdapter.test.ts+39| ... | ... | @@ -170,6 +170,45 @@ const lifecycleManager = new FakeCodexManager(); |
| 170 | 170 | const lifecycleLayer = it.layer(makeCodexAdapterLive({ manager: lifecycleManager })); |
| 171 | 171 | |
| 172 | 172 | lifecycleLayer("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 | 212 | it.effect("emits canonical events on stream and supports consumer interruption", () => |
| 174 | 213 | Effect.gen(function* () { |
| 175 | 214 | const adapter = yield* CodexAdapter; |
apps/server/src/provider/Layers/CodexAdapter.ts+38| ... | ... | @@ -161,6 +161,39 @@ function itemDetail( |
| 161 | 161 | return undefined; |
| 162 | 162 | } |
| 163 | 163 | |
| 164 | function 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 | ||
| 164 | 197 | function mapToolEvent(event: ProviderEvent): ProviderRuntimeEvent | undefined { |
| 165 | 198 | if (event.method !== "item/started" && event.method !== "item/completed") { |
| 166 | 199 | return undefined; |
| ... | ... | @@ -267,6 +300,11 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 267 | 300 | ]; |
| 268 | 301 | } |
| 269 | 302 | |
| 303 | const messageCompleted = mapMessageCompletedEvent(event); | |
| 304 | if (messageCompleted) { | |
| 305 | return [messageCompleted]; | |
| 306 | } | |
| 307 | ||
| 270 | 308 | const tool = mapToolEvent(event); |
| 271 | 309 | if (tool) { |
| 272 | 310 | return [tool]; |
apps/server/src/wsServer.ts+83-28| ... | ... | @@ -50,11 +50,9 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD |
| 50 | 50 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| 51 | 51 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; |
| 52 | 52 | import { CheckpointServiceLive } from "./checkpointing/Layers/CheckpointService"; |
| 53 | import { parseTurnDiffFilesFromUnifiedDiff, type TurnDiffFileSummary } from "./checkpointing/Diffs"; | |
| 53 | 54 | import { CheckpointRepositoryLive } from "./persistence/Layers/Checkpoints"; |
| 54 | import { | |
| 55 | makeEventNdjsonLogger, | |
| 56 | type EventNdjsonLogger, | |
| 57 | } from "./provider/Layers/EventNdjsonLogger"; | |
| 55 | import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; | |
| 58 | 56 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 59 | 57 | import * as SqlError from "effect/unstable/sql/SqlError"; |
| 60 | 58 | import * as Migrator from "effect/unstable/sql/Migrator"; |
| ... | ... | @@ -275,28 +273,39 @@ export function createServer(options: ServerOptions) { |
| 275 | 273 | >, |
| 276 | 274 | orchestrationEngine: OrchestrationEngineShape, |
| 277 | 275 | ) { |
| 278 | unsubscribeDomainEvents = await runtime.runPromise( | |
| 279 | orchestrationEngine.subscribeToDomainEvents((event) => { | |
| 280 | orchestrationDomainEventLogger?.write({ | |
| 281 | observedAt: new Date().toISOString(), | |
| 282 | event, | |
| 283 | }); | |
| 284 | const push: WsPush = { | |
| 285 | type: "push", | |
| 286 | channel: ORCHESTRATION_WS_CHANNELS.domainEvent, | |
| 287 | data: event, | |
| 288 | }; | |
| 289 | const message = JSON.stringify(push); | |
| 290 | let recipients = 0; | |
| 291 | for (const client of clients) { | |
| 292 | if (client.readyState === client.OPEN) { | |
| 293 | client.send(message); | |
| 294 | recipients += 1; | |
| 295 | } | |
| 296 | } | |
| 297 | logOutgoingPush(push, recipients); | |
| 298 | }), | |
| 276 | const subscriptionScope = await runtime.runPromise(Scope.make("sequential")); | |
| 277 | await runtime.runPromise( | |
| 278 | Effect.gen(function* () { | |
| 279 | yield* Effect.forkScoped( | |
| 280 | Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => | |
| 281 | Effect.sync(() => { | |
| 282 | orchestrationDomainEventLogger?.write({ | |
| 283 | observedAt: new Date().toISOString(), | |
| 284 | event, | |
| 285 | }); | |
| 286 | const push: WsPush = { | |
| 287 | type: "push", | |
| 288 | channel: ORCHESTRATION_WS_CHANNELS.domainEvent, | |
| 289 | data: event, | |
| 290 | }; | |
| 291 | const message = JSON.stringify(push); | |
| 292 | let recipients = 0; | |
| 293 | for (const client of clients) { | |
| 294 | if (client.readyState === client.OPEN) { | |
| 295 | client.send(message); | |
| 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 | } |
| 301 | 310 | |
| 302 | 311 | async function attachProviderSubscriptions( |
| ... | ... | @@ -350,7 +359,9 @@ export function createServer(options: ServerOptions) { |
| 350 | 359 | const processEvent = (event: ProviderRuntimeEvent) => |
| 351 | 360 | Effect.gen(function* () { |
| 352 | 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 | 365 | if (!thread) return; |
| 355 | 366 | |
| 356 | 367 | const now = event.createdAt; |
| ... | ... | @@ -393,9 +404,29 @@ export function createServer(options: ServerOptions) { |
| 393 | 404 | }); |
| 394 | 405 | } |
| 395 | 406 | |
| 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 | 424 | if (event.type === "turn.completed") { |
| 397 | 425 | if (event.turnId) { |
| 398 | const assistantMessageIds = getAssistantMessageIdsForTurn(event.sessionId, event.turnId); | |
| 426 | const assistantMessageIds = getAssistantMessageIdsForTurn( | |
| 427 | event.sessionId, | |
| 428 | event.turnId, | |
| 429 | ); | |
| 399 | 430 | yield* Effect.forEach(assistantMessageIds, (assistantMessageId) => |
| 400 | 431 | orchestrationEngine.dispatch({ |
| 401 | 432 | type: "message.send", |
| ... | ... | @@ -417,6 +448,30 @@ export function createServer(options: ServerOptions) { |
| 417 | 448 | } |
| 418 | 449 | |
| 419 | 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 | 475 | const assistantMessageId = |
| 421 | 476 | getLatestAssistantMessageIdForTurn(event.sessionId, event.turnId) ?? |
| 422 | 477 | `assistant:${event.turnId}`; |
| ... | ... | @@ -427,7 +482,7 @@ export function createServer(options: ServerOptions) { |
| 427 | 482 | turnId: event.turnId, |
| 428 | 483 | completedAt: now, |
| 429 | 484 | ...(event.status ? { status: event.status } : {}), |
| 430 | files: [], | |
| 485 | files, | |
| 431 | 486 | assistantMessageId, |
| 432 | 487 | checkpointTurnCount: event.turnCount, |
| 433 | 488 | createdAt: now, |
apps/web/src/components/ChatView.tsx+50-189| ... | ... | @@ -26,12 +26,11 @@ import { |
| 26 | 26 | useRef, |
| 27 | 27 | useState, |
| 28 | 28 | } from "react"; |
| 29 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 29 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 30 | 30 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 31 | 31 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 32 | 32 | import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; |
| 33 | 33 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; |
| 34 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | |
| 35 | 34 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 36 | 35 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; |
| 37 | 36 | |
| ... | ... | @@ -55,7 +54,6 @@ import { |
| 55 | 54 | import { |
| 56 | 55 | derivePendingApprovals, |
| 57 | 56 | derivePhase, |
| 58 | deriveTurnDiffFilesFromUnifiedDiff, | |
| 59 | 57 | deriveTimelineEntries, |
| 60 | 58 | type PendingApproval, |
| 61 | 59 | deriveWorkLogEntries, |
| ... | ... | @@ -70,7 +68,6 @@ import { |
| 70 | 68 | DEFAULT_THREAD_TERMINAL_ID, |
| 71 | 69 | MAX_THREAD_TERMINAL_COUNT, |
| 72 | 70 | type ChatImageAttachment, |
| 73 | type TurnDiffFileChange, | |
| 74 | 71 | type TurnDiffSummary, |
| 75 | 72 | } from "../types"; |
| 76 | 73 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; |
| ... | ... | @@ -186,33 +183,6 @@ interface ExpandedImagePreview { |
| 186 | 183 | name: string; |
| 187 | 184 | } |
| 188 | 185 | |
| 189 | type 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 | ||
| 206 | function 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 | ||
| 216 | 186 | type ComposerCommandItem = |
| 217 | 187 | | { |
| 218 | 188 | id: string; |
| ... | ... | @@ -432,17 +402,21 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 432 | 402 | ); |
| 433 | 403 | const phase = derivePhase(activeThread?.session ?? null); |
| 434 | 404 | const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint; |
| 435 | const activeTurnId = activeThread?.session?.activeTurnId ?? undefined; | |
| 436 | 405 | const nowIso = new Date(nowTick).toISOString(); |
| 437 | 406 | const threadActivities = activeThread?.activities ?? []; |
| 438 | 407 | const workLogEntries = useMemo( |
| 439 | 408 | () => deriveWorkLogEntries(threadActivities, undefined), |
| 440 | 409 | [threadActivities], |
| 441 | 410 | ); |
| 442 | const latestTurnWorkEntries = useMemo( | |
| 443 | () => deriveWorkLogEntries(threadActivities, activeTurnId ?? activeThread?.latestTurnId), | |
| 444 | [activeThread?.latestTurnId, activeTurnId, threadActivities], | |
| 445 | ); | |
| 411 | const latestTurnHasToolActivity = useMemo(() => { | |
| 412 | const latestTurnId = activeThread?.latestTurnId; | |
| 413 | if (!latestTurnId) { | |
| 414 | return false; | |
| 415 | } | |
| 416 | return threadActivities.some( | |
| 417 | (activity) => activity.turnId === latestTurnId && activity.tone === "tool", | |
| 418 | ); | |
| 419 | }, [activeThread?.latestTurnId, threadActivities]); | |
| 446 | 420 | const pendingApprovals = useMemo( |
| 447 | 421 | () => derivePendingApprovals(threadActivities), |
| 448 | 422 | [threadActivities], |
| ... | ... | @@ -453,14 +427,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 453 | 427 | ); |
| 454 | 428 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 455 | 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 | 430 | const turnDiffSummaryByAssistantMessageId = useMemo(() => { |
| 465 | 431 | const byMessageId = new Map<string, TurnDiffSummary>(); |
| 466 | 432 | for (const summary of turnDiffSummaries) { |
| ... | ... | @@ -469,69 +435,6 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 469 | 435 | } |
| 470 | 436 | return byMessageId; |
| 471 | 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 | 438 | const revertTurnCountByUserMessageId = useMemo(() => { |
| 536 | 439 | const byUserMessageId = new Map<string, number>(); |
| 537 | 440 | for (let index = 0; index < timelineEntries.length; index += 1) { |
| ... | ... | @@ -568,9 +471,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 568 | 471 | const completionSummary = useMemo(() => { |
| 569 | 472 | if (!activeThread?.latestTurnStartedAt) return null; |
| 570 | 473 | if (!activeThread.latestTurnCompletedAt) return null; |
| 571 | if (!latestTurnWorkEntries.some((entry) => entry.tone === "tool")) { | |
| 572 | return null; | |
| 573 | } | |
| 474 | if (!latestTurnHasToolActivity) return null; | |
| 574 | 475 | |
| 575 | 476 | if ( |
| 576 | 477 | typeof activeThread.latestTurnDurationMs === "number" && |
| ... | ... | @@ -589,7 +490,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 589 | 490 | activeThread?.latestTurnStartedAt, |
| 590 | 491 | activeThread?.latestTurnCompletedAt, |
| 591 | 492 | activeThread?.latestTurnDurationMs, |
| 592 | latestTurnWorkEntries, | |
| 493 | latestTurnHasToolActivity, | |
| 593 | 494 | ]); |
| 594 | 495 | const completionDividerBeforeEntryId = useMemo(() => { |
| 595 | 496 | if (!activeThread?.latestTurnStartedAt) return null; |
| ... | ... | @@ -2039,9 +1940,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2039 | 1940 | timelineEntries={timelineEntries} |
| 2040 | 1941 | completionDividerBeforeEntryId={completionDividerBeforeEntryId} |
| 2041 | 1942 | completionSummary={completionSummary} |
| 2042 | assistantCompletionByItemId={assistantCompletionByItemId} | |
| 2043 | 1943 | turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} |
| 2044 | turnCheckpointFilesByTurnId={turnCheckpointFilesByTurnId} | |
| 2045 | 1944 | nowIso={nowIso} |
| 2046 | 1945 | expandedWorkGroups={expandedWorkGroups} |
| 2047 | 1946 | onToggleWorkGroup={onToggleWorkGroup} |
| ... | ... | @@ -2505,9 +2404,7 @@ interface MessagesTimelineProps { |
| 2505 | 2404 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; |
| 2506 | 2405 | completionDividerBeforeEntryId: string | null; |
| 2507 | 2406 | completionSummary: string | null; |
| 2508 | assistantCompletionByItemId: Map<string, string>; | |
| 2509 | 2407 | turnDiffSummaryByAssistantMessageId: Map<string, TurnDiffSummary>; |
| 2510 | turnCheckpointFilesByTurnId: Map<string, TurnCheckpointFilesState>; | |
| 2511 | 2408 | nowIso: string; |
| 2512 | 2409 | expandedWorkGroups: Record<string, boolean>; |
| 2513 | 2410 | onToggleWorkGroup: (groupId: string) => void; |
| ... | ... | @@ -2542,9 +2439,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2542 | 2439 | timelineEntries, |
| 2543 | 2440 | completionDividerBeforeEntryId, |
| 2544 | 2441 | completionSummary, |
| 2545 | assistantCompletionByItemId, | |
| 2546 | 2442 | turnDiffSummaryByAssistantMessageId, |
| 2547 | turnCheckpointFilesByTurnId, | |
| 2548 | 2443 | nowIso, |
| 2549 | 2444 | expandedWorkGroups, |
| 2550 | 2445 | onToggleWorkGroup, |
| ... | ... | @@ -2793,10 +2688,8 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2793 | 2688 | row.message.id, |
| 2794 | 2689 | ); |
| 2795 | 2690 | if (!turnSummary) return null; |
| 2796 | const checkpointFilesState = turnCheckpointFilesByTurnId.get( | |
| 2797 | turnSummary.turnId, | |
| 2798 | ); | |
| 2799 | const checkpointFiles = checkpointFilesState?.files ?? []; | |
| 2691 | const checkpointFiles = turnSummary.files; | |
| 2692 | if (checkpointFiles.length === 0) return null; | |
| 2800 | 2693 | const summaryStat = checkpointFiles.reduce( |
| 2801 | 2694 | (acc, file) => { |
| 2802 | 2695 | if ( |
| ... | ... | @@ -2812,10 +2705,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2812 | 2705 | }, |
| 2813 | 2706 | { additions: 0, deletions: 0 }, |
| 2814 | 2707 | ); |
| 2815 | const changedFileCountLabel = | |
| 2816 | checkpointFilesState?.status === "ready" | |
| 2817 | ? String(checkpointFiles.length) | |
| 2818 | : "..."; | |
| 2708 | const changedFileCountLabel = String(checkpointFiles.length); | |
| 2819 | 2709 | return ( |
| 2820 | 2710 | <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> |
| 2821 | 2711 | <div className="mb-1.5 flex items-center justify-between gap-2"> |
| ... | ... | @@ -2843,66 +2733,40 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2843 | 2733 | View diff |
| 2844 | 2734 | </Button> |
| 2845 | 2735 | </div> |
| 2846 | {!checkpointFilesState || | |
| 2847 | checkpointFilesState.status === "pending" ? ( | |
| 2848 | <p className="text-[11px] text-muted-foreground/70"> | |
| 2849 | Waiting for checkpoint metadata... | |
| 2850 | </p> | |
| 2851 | ) : checkpointFilesState?.status === "loading" ? ( | |
| 2852 | <p className="text-[11px] text-muted-foreground/70"> | |
| 2853 | Loading checkpoint diff... | |
| 2854 | </p> | |
| 2855 | ) : checkpointFilesState?.status === "error" ? ( | |
| 2856 | <p | |
| 2857 | className="truncate text-[11px] text-muted-foreground/70" | |
| 2858 | title={checkpointFilesState.errorMessage} | |
| 2859 | > | |
| 2860 | {checkpointFilesState.errorMessage} | |
| 2861 | </p> | |
| 2862 | ) : checkpointFiles.length > 0 ? ( | |
| 2863 | <div className="flex flex-wrap gap-1.5"> | |
| 2864 | {checkpointFiles.map((file) => ( | |
| 2865 | <button | |
| 2866 | key={`${turnSummary.turnId}:${file.path}`} | |
| 2867 | type="button" | |
| 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" | |
| 2869 | onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)} | |
| 2870 | > | |
| 2871 | {(() => { | |
| 2872 | const stat = | |
| 2873 | typeof file.additions === "number" && | |
| 2874 | typeof file.deletions === "number" | |
| 2875 | ? { | |
| 2876 | additions: file.additions, | |
| 2877 | deletions: file.deletions, | |
| 2878 | } | |
| 2879 | : null; | |
| 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 | )} | |
| 2736 | <div className="flex flex-wrap gap-1.5"> | |
| 2737 | {checkpointFiles.map((file) => ( | |
| 2738 | <button | |
| 2739 | key={`${turnSummary.turnId}:${file.path}`} | |
| 2740 | type="button" | |
| 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" | |
| 2742 | onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)} | |
| 2743 | > | |
| 2744 | {(() => { | |
| 2745 | const stat = | |
| 2746 | typeof file.additions === "number" && | |
| 2747 | typeof file.deletions === "number" | |
| 2748 | ? { | |
| 2749 | additions: file.additions, | |
| 2750 | deletions: file.deletions, | |
| 2751 | } | |
| 2752 | : null; | |
| 2753 | if (!stat) { | |
| 2754 | return file.path; | |
| 2755 | } | |
| 2756 | return ( | |
| 2757 | <> | |
| 2758 | <span>{file.path}</span> | |
| 2759 | <span className="ml-1 text-muted-foreground/70">(</span> | |
| 2760 | <span className="text-success">+{stat.additions}</span> | |
| 2761 | <span className="mx-0.5 text-muted-foreground/70">/</span> | |
| 2762 | <span className="text-destructive">-{stat.deletions}</span> | |
| 2763 | <span className="text-muted-foreground/70">)</span> | |
| 2764 | </> | |
| 2765 | ); | |
| 2766 | })()} | |
| 2767 | </button> | |
| 2768 | ))} | |
| 2769 | </div> | |
| 2906 | 2770 | </div> |
| 2907 | 2771 | ); |
| 2908 | 2772 | })()} |
| ... | ... | @@ -2911,10 +2775,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2911 | 2775 | row.message.createdAt, |
| 2912 | 2776 | row.message.streaming |
| 2913 | 2777 | ? formatElapsed(row.message.createdAt, nowIso) |
| 2914 | : formatElapsed( | |
| 2915 | row.message.createdAt, | |
| 2916 | assistantCompletionByItemId.get(row.message.id), | |
| 2917 | ), | |
| 2778 | : formatElapsed(row.message.createdAt, row.message.completedAt), | |
| 2918 | 2779 | )} |
| 2919 | 2780 | </p> |
| 2920 | 2781 | </div> |
apps/web/src/components/DiffPanel.tsx+33-12| ... | ... | @@ -104,14 +104,28 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 104 | 104 | const activeSessionId = activeThread?.session?.sessionId ?? null; |
| 105 | 105 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 106 | 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 | ); | |
| 107 | 121 | |
| 108 | 122 | const selectedTurnId = diffSearch.diffTurnId ?? null; |
| 109 | 123 | const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; |
| 110 | 124 | const selectedTurn = |
| 111 | 125 | selectedTurnId === null |
| 112 | 126 | ? undefined |
| 113 | : (turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? | |
| 114 | turnDiffSummaries[0]); | |
| 127 | : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? | |
| 128 | orderedTurnDiffSummaries[0]); | |
| 115 | 129 | const selectedCheckpointTurnCount = |
| 116 | 130 | selectedTurn && |
| 117 | 131 | (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); |
| ... | ... | @@ -126,7 +140,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 126 | 140 | [selectedCheckpointTurnCount], |
| 127 | 141 | ); |
| 128 | 142 | const conversationCheckpointTurnCount = useMemo(() => { |
| 129 | const turnCounts = turnDiffSummaries | |
| 143 | const turnCounts = orderedTurnDiffSummaries | |
| 130 | 144 | .map( |
| 131 | 145 | (summary) => |
| 132 | 146 | summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId], |
| ... | ... | @@ -137,7 +151,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 137 | 151 | } |
| 138 | 152 | const latest = Math.max(...turnCounts); |
| 139 | 153 | return latest > 0 ? latest : undefined; |
| 140 | }, [inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); | |
| 154 | }, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries]); | |
| 141 | 155 | const conversationCheckpointRange = useMemo( |
| 142 | 156 | () => |
| 143 | 157 | !selectedTurn && typeof conversationCheckpointTurnCount === "number" |
| ... | ... | @@ -152,11 +166,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 152 | 166 | ? selectedCheckpointRange |
| 153 | 167 | : conversationCheckpointRange; |
| 154 | 168 | const conversationCacheScope = useMemo(() => { |
| 155 | if (selectedTurn || turnDiffSummaries.length === 0) { | |
| 169 | if (selectedTurn || orderedTurnDiffSummaries.length === 0) { | |
| 156 | 170 | return null; |
| 157 | 171 | } |
| 158 | return `conversation:${turnDiffSummaries.map((summary) => summary.turnId).join(",")}`; | |
| 159 | }, [selectedTurn, turnDiffSummaries]); | |
| 172 | return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`; | |
| 173 | }, [orderedTurnDiffSummaries, selectedTurn]); | |
| 160 | 174 | const activeCheckpointDiffQuery = useQuery( |
| 161 | 175 | checkpointDiffQueryOptions(api, { |
| 162 | 176 | sessionId: activeSessionId, |
| ... | ... | @@ -181,6 +195,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 181 | 195 | : null; |
| 182 | 196 | |
| 183 | 197 | const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; |
| 198 | const hasResolvedPatch = typeof selectedPatch === "string"; | |
| 199 | const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; | |
| 184 | 200 | const renderablePatch = useMemo(() => getRenderablePatch(selectedPatch), [selectedPatch]); |
| 185 | 201 | const renderableFiles = useMemo(() => { |
| 186 | 202 | if (!renderablePatch || renderablePatch.kind !== "files") { |
| ... | ... | @@ -275,7 +291,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 275 | 291 | |
| 276 | 292 | useEffect(() => { |
| 277 | 293 | updateTurnStripScrollState(); |
| 278 | }, [turnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); | |
| 294 | }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); | |
| 279 | 295 | |
| 280 | 296 | useEffect(() => { |
| 281 | 297 | const element = turnStripRef.current; |
| ... | ... | @@ -358,7 +374,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 358 | 374 | <div className="text-[10px] leading-tight font-medium">All turns</div> |
| 359 | 375 | </div> |
| 360 | 376 | </button> |
| 361 | {turnDiffSummaries.map((summary, index) => ( | |
| 377 | {orderedTurnDiffSummaries.map((summary) => ( | |
| 362 | 378 | <button |
| 363 | 379 | key={summary.turnId} |
| 364 | 380 | type="button" |
| ... | ... | @@ -377,7 +393,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 377 | 393 | > |
| 378 | 394 | <div className="flex items-center gap-1"> |
| 379 | 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 | 400 | </span> |
| 382 | 401 | <span className="text-[9px] leading-tight opacity-70"> |
| 383 | 402 | {formatTurnChipTimestamp(summary.completedAt)} |
| ... | ... | @@ -413,7 +432,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 413 | 432 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> |
| 414 | 433 | Select a thread to inspect turn diffs. |
| 415 | 434 | </div> |
| 416 | ) : turnDiffSummaries.length === 0 ? ( | |
| 435 | ) : orderedTurnDiffSummaries.length === 0 ? ( | |
| 417 | 436 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> |
| 418 | 437 | No completed turns yet. |
| 419 | 438 | </div> |
| ... | ... | @@ -430,7 +449,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 430 | 449 | <p> |
| 431 | 450 | {isLoadingCheckpointDiff |
| 432 | 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 | 455 | </p> |
| 435 | 456 | </div> |
| 436 | 457 | ) : renderablePatch.kind === "files" ? ( |
apps/web/src/hooks/useTurnDiffSummaries.ts+1-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { useMemo } from "react"; |
| 2 | 2 | import { inferCheckpointTurnCountByTurnId } from "../session-logic"; |
| 3 | import type { Thread, TurnDiffSummary } from "../types"; | |
| 3 | import type { Thread } from "../types"; | |
| 4 | 4 | |
| 5 | 5 | export function useTurnDiffSummaries(activeThread: Thread | undefined) { |
| 6 | 6 | const turnDiffSummaries = useMemo(() => { |
apps/web/src/session-logic.test.ts+53-1| ... | ... | @@ -1,7 +1,11 @@ |
| 1 | 1 | import type { OrchestrationThreadActivity } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | import { derivePendingApprovals, deriveWorkLogEntries } from "./session-logic"; | |
| 4 | import { | |
| 5 | derivePendingApprovals, | |
| 6 | deriveTurnDiffFilesFromUnifiedDiff, | |
| 7 | deriveWorkLogEntries, | |
| 8 | } from "./session-logic"; | |
| 5 | 9 | |
| 6 | 10 | function makeActivity( |
| 7 | 11 | overrides: Partial<OrchestrationThreadActivity>, |
| ... | ... | @@ -87,4 +91,52 @@ describe("deriveWorkLogEntries", () => { |
| 87 | 91 | const entries = deriveWorkLogEntries(activities, "turn-2"); |
| 88 | 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 | ||
| 116 | describe("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 | 112 | ); |
| 113 | 113 | return ordered |
| 114 | 114 | .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) |
| 115 | .filter((activity) => activity.label !== "Checkpoint captured") | |
| 115 | 116 | .map((activity) => { |
| 116 | 117 | const entry: WorkLogEntry = { |
| 117 | 118 | id: activity.id, |
| ... | ... | @@ -146,16 +147,47 @@ export function deriveTimelineEntries( |
| 146 | 147 | } |
| 147 | 148 | |
| 148 | 149 | export 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 | 156 | const files: TurnDiffFileChange[] = []; |
| 150 | const lines = diff.split("\n"); | |
| 151 | for (const line of lines) { | |
| 152 | if (!line.startsWith("diff --git ")) continue; | |
| 153 | const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/); | |
| 157 | for (let index = 0; index < headerMatches.length; index += 1) { | |
| 158 | const header = headerMatches[index]; | |
| 159 | if (!header) continue; | |
| 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 | 167 | const filePath = match?.[2] ?? match?.[1]; |
| 155 | 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 | } |
| 160 | 192 | |
| 161 | 193 | export function inferCheckpointTurnCountByTurnId( |
apps/web/src/types.ts+1| ... | ... | @@ -34,6 +34,7 @@ export interface ChatMessage { |
| 34 | 34 | text: string; |
| 35 | 35 | attachments?: ChatAttachment[]; |
| 36 | 36 | createdAt: string; |
| 37 | completedAt?: string | undefined; | |
| 37 | 38 | streaming: boolean; |
| 38 | 39 | } |
| 39 | 40 |
bun.lock+1| ... | ... | @@ -35,6 +35,7 @@ |
| 35 | 35 | }, |
| 36 | 36 | "dependencies": { |
| 37 | 37 | "@effect/platform-node": "4.0.0-beta.10", |
| 38 | "@pierre/diffs": "^1.1.0-beta.16", | |
| 38 | 39 | "effect": "4.0.0-beta.10", |
| 39 | 40 | "node-pty": "^1.1.0", |
| 40 | 41 | "open": "^10.1.0", |
packages/contracts/src/orchestration.ts+1| ... | ... | @@ -18,6 +18,7 @@ export const OrchestrationMessageSchema = Schema.Struct({ |
| 18 | 18 | role: Schema.Literals(["user", "assistant"]), |
| 19 | 19 | text: Schema.String, |
| 20 | 20 | createdAt: IsoDateTimeSchema, |
| 21 | completedAt: Schema.optional(IsoDateTimeSchema), | |
| 21 | 22 | streaming: Schema.Boolean, |
| 22 | 23 | }); |
| 23 | 24 |
packages/contracts/src/providerRuntime.ts+15| ... | ... | @@ -85,6 +85,17 @@ export const ProviderRuntimeMessageDeltaEventSchema = Schema.Struct({ |
| 85 | 85 | delta: Schema.String, |
| 86 | 86 | }); |
| 87 | 87 | |
| 88 | export 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 | ||
| 88 | 99 | export const ProviderRuntimeToolStartedEventSchema = Schema.Struct({ |
| 89 | 100 | type: Schema.Literal("tool.started"), |
| 90 | 101 | eventId: IdSchema, |
| ... | ... | @@ -172,6 +183,7 @@ export const ProviderRuntimeEventSchema = Schema.Union([ |
| 172 | 183 | ProviderRuntimeTurnStartedEventSchema, |
| 173 | 184 | ProviderRuntimeTurnCompletedEventSchema, |
| 174 | 185 | ProviderRuntimeMessageDeltaEventSchema, |
| 186 | ProviderRuntimeMessageCompletedEventSchema, | |
| 175 | 187 | ProviderRuntimeToolStartedEventSchema, |
| 176 | 188 | ProviderRuntimeToolCompletedEventSchema, |
| 177 | 189 | ProviderRuntimeApprovalRequestedEventSchema, |
| ... | ... | @@ -198,6 +210,9 @@ export type ProviderRuntimeTurnCompletedEvent = Schema.Schema.Type< |
| 198 | 210 | export type ProviderRuntimeMessageDeltaEvent = Schema.Schema.Type< |
| 199 | 211 | typeof ProviderRuntimeMessageDeltaEventSchema |
| 200 | 212 | >; |
| 213 | export type ProviderRuntimeMessageCompletedEvent = Schema.Schema.Type< | |
| 214 | typeof ProviderRuntimeMessageCompletedEventSchema | |
| 215 | >; | |
| 201 | 216 | export type ProviderRuntimeToolStartedEvent = Schema.Schema.Type< |
| 202 | 217 | typeof ProviderRuntimeToolStartedEventSchema |
| 203 | 218 | >; |