| 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,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 @@ | ||
| 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,5 +1,5 @@ |
| 1 | import type { OrchestrationEvent } from "@t3tools/contracts"; | 1 | import type { OrchestrationEvent } from "@t3tools/contracts"; |
| 2 | import { Effect, Layer, ManagedRuntime } from "effect"; | 2 | import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"; |
| 3 | import { describe, expect, it } from "vitest"; | 3 | import { describe, expect, it } from "vitest"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| ... | @@ -65,33 +65,6 @@ describe("OrchestrationEngine", () => { | ... | @@ -65,33 +65,6 @@ describe("OrchestrationEngine", () => { |
| 65 | await system.dispose(); | 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 | 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 | }); |
| 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 | 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"; |
| 6 | import { OrchestrationCommandSchema } from "@t3tools/contracts"; | 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 | import { createLogger } from "../logger.ts"; | 9 | import { createLogger } from "../logger.ts"; |
| 10 | import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents.ts"; | 10 | import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents.ts"; |
| 11 | import { | 11 | import { |
| 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()); |
| 183 | 182 | ||
| 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>(); |
| 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 | 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); |
| 212 | 191 | 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); | ||
| 221 | 192 | ||
| 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 | }); |
| 271 | 242 | ||
| 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 | }); | ||
| 287 | 245 | ||
| 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 | }); |
| 297 | 254 |
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"; |
| 18 | import { ServiceMap } from "effect"; | 18 | import { ServiceMap } from "effect"; |
| 19 | import type { Effect } from "effect"; | 19 | import type { Effect, Stream } from "effect"; |
| 20 | 20 | ||
| 21 | import type { OrchestrationDispatchError, OrchestrationEngineError } from "./Errors.ts"; | 21 | import type { OrchestrationDispatchError, OrchestrationEngineError } from "./Errors.ts"; |
| 22 | import type { OrchestrationEventRepositoryError } from "../persistence/Errors.ts"; | 22 | import 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>; |
| 63 | 63 | ||
| 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 | } |
| 84 | 69 | ||
| 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 | ); |
| 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 | 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; | ||
| 306 | 359 | ||
| 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(); |
| 170 | const lifecycleLayer = it.layer(makeCodexAdapterLive({ manager: lifecycleManager })); | 170 | const lifecycleLayer = it.layer(makeCodexAdapterLive({ manager: lifecycleManager })); |
| 171 | 171 | ||
| 172 | lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | 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 | 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 | } |
| 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 | function mapToolEvent(event: ProviderEvent): ProviderRuntimeEvent | undefined { | 197 | function 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 | } |
| 269 | 302 | ||
| 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 |
| 50 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; | 50 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| 51 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; | 51 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; |
| 52 | import { CheckpointServiceLive } from "./checkpointing/Layers/CheckpointService"; | 52 | import { CheckpointServiceLive } from "./checkpointing/Layers/CheckpointService"; |
| 53 | import { parseTurnDiffFilesFromUnifiedDiff, type TurnDiffFileSummary } from "./checkpointing/Diffs"; | ||
| 53 | import { CheckpointRepositoryLive } from "./persistence/Layers/Checkpoints"; | 54 | import { CheckpointRepositoryLive } from "./persistence/Layers/Checkpoints"; |
| 54 | import { | 55 | import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; |
| 55 | makeEventNdjsonLogger, | ||
| 56 | type EventNdjsonLogger, | ||
| 57 | } from "./provider/Layers/EventNdjsonLogger"; | ||
| 58 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 56 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 59 | import * as SqlError from "effect/unstable/sql/SqlError"; | 57 | import * as SqlError from "effect/unstable/sql/SqlError"; |
| 60 | import * as Migrator from "effect/unstable/sql/Migrator"; | 58 | import * 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 | } |
| 301 | 310 | ||
| 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; |
| 355 | 366 | ||
| 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 | } |
| 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 | 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 | } |
| 418 | 449 | ||
| 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"; |
| 29 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; | 29 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 30 | import { useDebouncedValue } from "@tanstack/react-pacer"; | 30 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 31 | import { useNavigate, useSearch } from "@tanstack/react-router"; | 31 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 32 | import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; | 32 | import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; |
| 33 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; | 33 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; |
| 34 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | ||
| 35 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; | 34 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 36 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; | 35 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; |
| 37 | 36 | ||
| ... | @@ -55,7 +54,6 @@ import { | ... | @@ -55,7 +54,6 @@ import { |
| 55 | import { | 54 | import { |
| 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"; |
| 76 | import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; | 73 | import { 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 | } |
| 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 | type ComposerCommandItem = | 186 | type 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 | } | ||
| 574 | 475 | ||
| 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 diff | 2733 | 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 | <p | 2746 | 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 | <button | 2755 | } |
| 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.streaming | 2776 | 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 | ); | ||
| 107 | 121 | ||
| 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 === null | 125 | selectedTurnId === null |
| 112 | ? undefined | 126 | ? 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 = turnDiffSummaries | 143 | 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 | ? selectedCheckpointRange | 166 | ? 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; |
| 182 | 196 | ||
| 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) { |
| 275 | 291 | ||
| 276 | useEffect(() => { | 292 | useEffect(() => { |
| 277 | updateTurnStripScrollState(); | 293 | updateTurnStripScrollState(); |
| 278 | }, [turnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); | 294 | }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); |
| 279 | 295 | ||
| 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 | <button | 378 | <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 | {isLoadingCheckpointDiff | 450 | {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 @@ |
| 1 | import { useMemo } from "react"; | 1 | import { useMemo } from "react"; |
| 2 | import { inferCheckpointTurnCountByTurnId } from "../session-logic"; | 2 | import { inferCheckpointTurnCountByTurnId } from "../session-logic"; |
| 3 | import type { Thread, TurnDiffSummary } from "../types"; | 3 | import type { Thread } from "../types"; |
| 4 | 4 | ||
| 5 | export function useTurnDiffSummaries(activeThread: Thread | undefined) { | 5 | export 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 @@ |
| 1 | import type { OrchestrationThreadActivity } from "@t3tools/contracts"; | 1 | import type { OrchestrationThreadActivity } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 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 | function makeActivity( | 10 | function 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 | |||
| 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,6 +112,7 @@ export function deriveWorkLogEntries( |
| 112 | ); | 112 | ); |
| 113 | return ordered | 113 | 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 | } |
| 147 | 148 | ||
| 148 | export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] { | 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 | 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 | } |
| 160 | 192 | ||
| 161 | export function inferCheckpointTurnCountByTurnId( | 193 | export 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 | } |
| 39 | 40 |
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 | }); |
| 23 | 24 |
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 | }); |
| 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 | export const ProviderRuntimeToolStartedEventSchema = Schema.Struct({ | 99 | export 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< |
| 198 | export type ProviderRuntimeMessageDeltaEvent = Schema.Schema.Type< | 210 | export type ProviderRuntimeMessageDeltaEvent = Schema.Schema.Type< |
| 199 | typeof ProviderRuntimeMessageDeltaEventSchema | 211 | typeof ProviderRuntimeMessageDeltaEventSchema |
| 200 | >; | 212 | >; |
| 213 | export type ProviderRuntimeMessageCompletedEvent = Schema.Schema.Type< | ||
| 214 | typeof ProviderRuntimeMessageCompletedEventSchema | ||
| 215 | >; | ||
| 201 | export type ProviderRuntimeToolStartedEvent = Schema.Schema.Type< | 216 | export type ProviderRuntimeToolStartedEvent = Schema.Schema.Type< |
| 202 | typeof ProviderRuntimeToolStartedEventSchema | 217 | typeof ProviderRuntimeToolStartedEventSchema |
| 203 | >; | 218 | >; |