| @@ -0,0 +1,518 @@ |
| | 1 | import { EventEmitter } from "node:events"; |
| | 2 | import { randomUUID } from "node:crypto"; |
| | 3 | |
| | 4 | import type { |
| | 5 | ProviderCheckpoint, |
| | 6 | ProviderEvent, |
| | 7 | ProviderGetCheckpointDiffInput, |
| | 8 | ProviderGetCheckpointDiffResult, |
| | 9 | ProviderInterruptTurnInput, |
| | 10 | ProviderKind, |
| | 11 | ProviderListCheckpointsInput, |
| | 12 | ProviderListCheckpointsResult, |
| | 13 | ProviderRevertToCheckpointInput, |
| | 14 | ProviderRevertToCheckpointResult, |
| | 15 | ProviderRespondToRequestInput, |
| | 16 | ProviderSendTurnInput, |
| | 17 | ProviderSession, |
| | 18 | ProviderSessionStartInput, |
| | 19 | ProviderStopSessionInput, |
| | 20 | ProviderTurnStartResult, |
| | 21 | } from "@t3tools/contracts"; |
| | 22 | import { |
| | 23 | providerGetCheckpointDiffInputSchema, |
| | 24 | providerInterruptTurnInputSchema, |
| | 25 | providerListCheckpointsInputSchema, |
| | 26 | providerRevertToCheckpointInputSchema, |
| | 27 | providerRespondToRequestInputSchema, |
| | 28 | providerSendTurnInputSchema, |
| | 29 | providerSessionStartInputSchema, |
| | 30 | providerStopSessionInputSchema, |
| | 31 | } from "@t3tools/contracts"; |
| | 32 | |
| | 33 | import { FilesystemCheckpointStore } from "../../filesystemCheckpointStore"; |
| | 34 | import type { ProviderAdapter, ProviderThreadTurnSnapshot } from "./providerAdapter"; |
| | 35 | |
| | 36 | export interface ProviderOrchestratorEvents { |
| | 37 | event: [event: ProviderEvent]; |
| | 38 | } |
| | 39 | |
| | 40 | export class ProviderOrchestrator extends EventEmitter<ProviderOrchestratorEvents> { |
| | 41 | private readonly adapters: Map<ProviderKind, ProviderAdapter>; |
| | 42 | private readonly filesystemCheckpointStore = new FilesystemCheckpointStore(); |
| | 43 | private readonly sessionToProvider = new Map<string, ProviderKind>(); |
| | 44 | private readonly sessionCheckpointCwds = new Map<string, string>(); |
| | 45 | private readonly filesystemLocks = new Map<string, Promise<void>>(); |
| | 46 | private readonly unsubscribers: Array<() => void>; |
| | 47 | |
| | 48 | constructor(adapters: ProviderAdapter[]) { |
| | 49 | super(); |
| | 50 | this.adapters = new Map(adapters.map((adapter) => [adapter.kind, adapter] as const)); |
| | 51 | this.unsubscribers = adapters.map((adapter) => |
| | 52 | adapter.onEvent((event) => { |
| | 53 | this.sessionToProvider.set(event.sessionId, event.provider); |
| | 54 | this.emit("event", event); |
| | 55 | this.maybeCaptureFilesystemCheckpoint(event); |
| | 56 | }), |
| | 57 | ); |
| | 58 | } |
| | 59 | |
| | 60 | async startSession(raw: ProviderSessionStartInput): Promise<ProviderSession> { |
| | 61 | const input = providerSessionStartInputSchema.parse(raw); |
| | 62 | const provider = input.provider ?? "codex"; |
| | 63 | const adapter = this.requireAdapter(provider); |
| | 64 | const session = await adapter.startSession(input); |
| | 65 | this.sessionToProvider.set(session.sessionId, session.provider); |
| | 66 | await this.initializeFilesystemCheckpointing(adapter, session, input.cwd).catch((error) => { |
| | 67 | const message = |
| | 68 | error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints."; |
| | 69 | this.emitFilesystemCheckpointError(session.sessionId, message, session.threadId); |
| | 70 | }); |
| | 71 | return session; |
| | 72 | } |
| | 73 | |
| | 74 | async sendTurn(raw: ProviderSendTurnInput): Promise<ProviderTurnStartResult> { |
| | 75 | const input = providerSendTurnInputSchema.parse(raw); |
| | 76 | return this.resolveAdapterForSession(input.sessionId).sendTurn(input); |
| | 77 | } |
| | 78 | |
| | 79 | async interruptTurn(raw: ProviderInterruptTurnInput): Promise<void> { |
| | 80 | const input = providerInterruptTurnInputSchema.parse(raw); |
| | 81 | return this.resolveAdapterForSession(input.sessionId).interruptTurn(input); |
| | 82 | } |
| | 83 | |
| | 84 | async respondToRequest(raw: ProviderRespondToRequestInput): Promise<void> { |
| | 85 | const input = providerRespondToRequestInputSchema.parse(raw); |
| | 86 | return this.resolveAdapterForSession(input.sessionId).respondToRequest(input); |
| | 87 | } |
| | 88 | |
| | 89 | stopSession(raw: ProviderStopSessionInput): void { |
| | 90 | const input = providerStopSessionInputSchema.parse(raw); |
| | 91 | this.resolveAdapterForSession(input.sessionId).stopSession(input); |
| | 92 | this.sessionToProvider.delete(input.sessionId); |
| | 93 | this.sessionCheckpointCwds.delete(input.sessionId); |
| | 94 | this.filesystemLocks.delete(input.sessionId); |
| | 95 | } |
| | 96 | |
| | 97 | listSessions(): ProviderSession[] { |
| | 98 | const sessions = [...this.adapters.values()].flatMap((adapter) => adapter.listSessions()); |
| | 99 | for (const session of sessions) { |
| | 100 | this.sessionToProvider.set(session.sessionId, session.provider); |
| | 101 | } |
| | 102 | return sessions; |
| | 103 | } |
| | 104 | |
| | 105 | async listCheckpoints(raw: ProviderListCheckpointsInput): Promise<ProviderListCheckpointsResult> { |
| | 106 | const input = providerListCheckpointsInputSchema.parse(raw); |
| | 107 | const adapter = this.resolveAdapterForSession(input.sessionId); |
| | 108 | const snapshot = await adapter.readThread(input.sessionId); |
| | 109 | return { |
| | 110 | threadId: snapshot.threadId, |
| | 111 | checkpoints: buildCheckpoints(snapshot.turns), |
| | 112 | }; |
| | 113 | } |
| | 114 | |
| | 115 | async getCheckpointDiff( |
| | 116 | raw: ProviderGetCheckpointDiffInput, |
| | 117 | ): Promise<ProviderGetCheckpointDiffResult> { |
| | 118 | const input = providerGetCheckpointDiffInputSchema.parse(raw); |
| | 119 | const adapter = this.resolveAdapterForSession(input.sessionId); |
| | 120 | const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId, adapter); |
| | 121 | if (!checkpointCwd) { |
| | 122 | throw new Error("Filesystem checkpoints are unavailable for this session."); |
| | 123 | } |
| | 124 | |
| | 125 | return this.withFilesystemLock(input.sessionId, async () => { |
| | 126 | const snapshot = await adapter.readThread(input.sessionId); |
| | 127 | if (input.toTurnCount > snapshot.turns.length) { |
| | 128 | throw new Error( |
| | 129 | `Checkpoint turn count ${input.toTurnCount} exceeds current turn count ${snapshot.turns.length}.`, |
| | 130 | ); |
| | 131 | } |
| | 132 | |
| | 133 | const diff = await this.filesystemCheckpointStore.diffCheckpoints({ |
| | 134 | cwd: checkpointCwd, |
| | 135 | threadId: snapshot.threadId, |
| | 136 | fromTurnCount: input.fromTurnCount, |
| | 137 | toTurnCount: input.toTurnCount, |
| | 138 | }); |
| | 139 | return { |
| | 140 | threadId: snapshot.threadId, |
| | 141 | fromTurnCount: input.fromTurnCount, |
| | 142 | toTurnCount: input.toTurnCount, |
| | 143 | diff, |
| | 144 | }; |
| | 145 | }); |
| | 146 | } |
| | 147 | |
| | 148 | async revertToCheckpoint( |
| | 149 | raw: ProviderRevertToCheckpointInput, |
| | 150 | ): Promise<ProviderRevertToCheckpointResult> { |
| | 151 | const input = providerRevertToCheckpointInputSchema.parse(raw); |
| | 152 | const adapter = this.resolveAdapterForSession(input.sessionId); |
| | 153 | const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId, adapter); |
| | 154 | if (!checkpointCwd) { |
| | 155 | throw new Error("Filesystem checkpoints are unavailable for this session."); |
| | 156 | } |
| | 157 | |
| | 158 | return this.withFilesystemLock(input.sessionId, async () => { |
| | 159 | const beforeSnapshot = await adapter.readThread(input.sessionId); |
| | 160 | const currentTurnCount = beforeSnapshot.turns.length; |
| | 161 | if (input.turnCount > currentTurnCount) { |
| | 162 | throw new Error( |
| | 163 | `Checkpoint turn count ${input.turnCount} exceeds current turn count ${currentTurnCount}.`, |
| | 164 | ); |
| | 165 | } |
| | 166 | |
| | 167 | if (input.turnCount > 0) { |
| | 168 | const hasCheckpoint = await this.filesystemCheckpointStore.hasCheckpoint({ |
| | 169 | cwd: checkpointCwd, |
| | 170 | threadId: beforeSnapshot.threadId, |
| | 171 | turnCount: input.turnCount, |
| | 172 | }); |
| | 173 | if (!hasCheckpoint) { |
| | 174 | throw new Error( |
| | 175 | `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`, |
| | 176 | ); |
| | 177 | } |
| | 178 | } |
| | 179 | |
| | 180 | const restored = await this.filesystemCheckpointStore.restoreCheckpoint({ |
| | 181 | cwd: checkpointCwd, |
| | 182 | threadId: beforeSnapshot.threadId, |
| | 183 | turnCount: input.turnCount, |
| | 184 | }); |
| | 185 | if (!restored) { |
| | 186 | throw new Error( |
| | 187 | `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`, |
| | 188 | ); |
| | 189 | } |
| | 190 | |
| | 191 | const rollbackTurns = currentTurnCount - input.turnCount; |
| | 192 | const afterSnapshot = |
| | 193 | rollbackTurns > 0 |
| | 194 | ? await adapter.rollbackThread(input.sessionId, rollbackTurns) |
| | 195 | : beforeSnapshot; |
| | 196 | |
| | 197 | await this.filesystemCheckpointStore.pruneAfterTurn({ |
| | 198 | cwd: checkpointCwd, |
| | 199 | threadId: afterSnapshot.threadId, |
| | 200 | maxTurnCount: afterSnapshot.turns.length, |
| | 201 | }); |
| | 202 | |
| | 203 | const checkpoints = buildCheckpoints(afterSnapshot.turns); |
| | 204 | const currentCheckpoint = |
| | 205 | checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? |
| | 206 | checkpoints[checkpoints.length - 1] ?? |
| | 207 | checkpoints[0]; |
| | 208 | const rolledBackTurns = Math.max(0, currentTurnCount - afterSnapshot.turns.length); |
| | 209 | |
| | 210 | return { |
| | 211 | threadId: afterSnapshot.threadId, |
| | 212 | turnCount: currentCheckpoint?.turnCount ?? 0, |
| | 213 | messageCount: currentCheckpoint?.messageCount ?? 0, |
| | 214 | rolledBackTurns, |
| | 215 | checkpoints, |
| | 216 | }; |
| | 217 | }); |
| | 218 | } |
| | 219 | |
| | 220 | stopAll(): void { |
| | 221 | for (const adapter of this.adapters.values()) { |
| | 222 | adapter.stopAll(); |
| | 223 | } |
| | 224 | this.sessionToProvider.clear(); |
| | 225 | this.sessionCheckpointCwds.clear(); |
| | 226 | this.filesystemLocks.clear(); |
| | 227 | } |
| | 228 | |
| | 229 | dispose(): void { |
| | 230 | for (const unsubscribe of this.unsubscribers) { |
| | 231 | unsubscribe(); |
| | 232 | } |
| | 233 | for (const adapter of this.adapters.values()) { |
| | 234 | adapter.dispose(); |
| | 235 | } |
| | 236 | this.unsubscribers.length = 0; |
| | 237 | this.sessionToProvider.clear(); |
| | 238 | this.sessionCheckpointCwds.clear(); |
| | 239 | this.filesystemLocks.clear(); |
| | 240 | } |
| | 241 | |
| | 242 | private async initializeFilesystemCheckpointing( |
| | 243 | adapter: ProviderAdapter, |
| | 244 | session: ProviderSession, |
| | 245 | preferredCwd?: string, |
| | 246 | ): Promise<void> { |
| | 247 | const cwd = preferredCwd ?? session.cwd ?? process.cwd(); |
| | 248 | await this.withFilesystemLock(session.sessionId, async () => { |
| | 249 | const supportsGit = await this.filesystemCheckpointStore.isGitRepository(cwd); |
| | 250 | if (!supportsGit) { |
| | 251 | this.sessionCheckpointCwds.delete(session.sessionId); |
| | 252 | return; |
| | 253 | } |
| | 254 | |
| | 255 | const snapshot = await adapter.readThread(session.sessionId); |
| | 256 | await this.filesystemCheckpointStore.ensureRootCheckpoint({ |
| | 257 | cwd, |
| | 258 | threadId: snapshot.threadId, |
| | 259 | }); |
| | 260 | await this.filesystemCheckpointStore.captureCheckpoint({ |
| | 261 | cwd, |
| | 262 | threadId: snapshot.threadId, |
| | 263 | turnCount: snapshot.turns.length, |
| | 264 | }); |
| | 265 | this.sessionCheckpointCwds.set(session.sessionId, cwd); |
| | 266 | }); |
| | 267 | } |
| | 268 | |
| | 269 | private async getOrInitializeFilesystemCheckpointCwd( |
| | 270 | sessionId: string, |
| | 271 | adapter: ProviderAdapter, |
| | 272 | ): Promise<string | null> { |
| | 273 | const existingCwd = this.sessionCheckpointCwds.get(sessionId); |
| | 274 | if (existingCwd) return existingCwd; |
| | 275 | |
| | 276 | const session = adapter.listSessions().find((entry) => entry.sessionId === sessionId); |
| | 277 | const candidateCwds = session?.cwd ? [session.cwd] : [process.cwd()]; |
| | 278 | |
| | 279 | await this.withFilesystemLock(sessionId, async () => { |
| | 280 | const currentCwd = this.sessionCheckpointCwds.get(sessionId); |
| | 281 | if (currentCwd) return; |
| | 282 | |
| | 283 | const cwdSupport = await Promise.all( |
| | 284 | candidateCwds.map(async (cwd) => ({ |
| | 285 | cwd, |
| | 286 | supportsGit: await this.filesystemCheckpointStore.isGitRepository(cwd), |
| | 287 | })), |
| | 288 | ); |
| | 289 | const supportedCwd = cwdSupport.find((entry) => entry.supportsGit)?.cwd; |
| | 290 | if (!supportedCwd) { |
| | 291 | this.sessionCheckpointCwds.delete(sessionId); |
| | 292 | return; |
| | 293 | } |
| | 294 | |
| | 295 | const snapshot = await adapter.readThread(sessionId); |
| | 296 | await this.filesystemCheckpointStore.ensureRootCheckpoint({ |
| | 297 | cwd: supportedCwd, |
| | 298 | threadId: snapshot.threadId, |
| | 299 | }); |
| | 300 | await this.filesystemCheckpointStore.captureCheckpoint({ |
| | 301 | cwd: supportedCwd, |
| | 302 | threadId: snapshot.threadId, |
| | 303 | turnCount: snapshot.turns.length, |
| | 304 | }); |
| | 305 | this.sessionCheckpointCwds.set(sessionId, supportedCwd); |
| | 306 | }); |
| | 307 | |
| | 308 | return this.sessionCheckpointCwds.get(sessionId) ?? null; |
| | 309 | } |
| | 310 | |
| | 311 | private maybeCaptureFilesystemCheckpoint(event: ProviderEvent): void { |
| | 312 | if (event.kind !== "notification" || event.method !== "turn/completed") { |
| | 313 | return; |
| | 314 | } |
| | 315 | const adapter = this.resolveAdapterForSession(event.sessionId); |
| | 316 | const checkpointCwd = this.sessionCheckpointCwds.get(event.sessionId); |
| | 317 | if (!checkpointCwd) { |
| | 318 | void this.getOrInitializeFilesystemCheckpointCwd(event.sessionId, adapter) |
| | 319 | .then(async (initializedCwd) => { |
| | 320 | if (!initializedCwd) return; |
| | 321 | const snapshot = await adapter.readThread(event.sessionId); |
| | 322 | this.emitCheckpointCaptured(event.sessionId, snapshot.threadId, snapshot.turns.length); |
| | 323 | }) |
| | 324 | .catch((error) => { |
| | 325 | const message = |
| | 326 | error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints."; |
| | 327 | this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId); |
| | 328 | }); |
| | 329 | return; |
| | 330 | } |
| | 331 | |
| | 332 | void this.withFilesystemLock(event.sessionId, async () => { |
| | 333 | const snapshot = await adapter.readThread(event.sessionId); |
| | 334 | await this.filesystemCheckpointStore.captureCheckpoint({ |
| | 335 | cwd: checkpointCwd, |
| | 336 | threadId: snapshot.threadId, |
| | 337 | turnCount: snapshot.turns.length, |
| | 338 | }); |
| | 339 | this.emitCheckpointCaptured(event.sessionId, snapshot.threadId, snapshot.turns.length); |
| | 340 | }).catch((error) => { |
| | 341 | const message = error instanceof Error ? error.message : "Failed to capture checkpoint."; |
| | 342 | this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId); |
| | 343 | }); |
| | 344 | } |
| | 345 | |
| | 346 | private emitCheckpointCaptured(sessionId: string, threadId: string, turnCount: number): void { |
| | 347 | this.emit("event", { |
| | 348 | id: randomUUID(), |
| | 349 | kind: "notification", |
| | 350 | provider: this.sessionToProvider.get(sessionId) ?? "codex", |
| | 351 | sessionId, |
| | 352 | createdAt: new Date().toISOString(), |
| | 353 | method: "checkpoint/captured", |
| | 354 | threadId, |
| | 355 | payload: { |
| | 356 | threadId, |
| | 357 | turnCount, |
| | 358 | }, |
| | 359 | }); |
| | 360 | } |
| | 361 | |
| | 362 | private emitFilesystemCheckpointError( |
| | 363 | sessionId: string, |
| | 364 | message: string, |
| | 365 | threadId?: string, |
| | 366 | ): void { |
| | 367 | this.emit("event", { |
| | 368 | id: randomUUID(), |
| | 369 | kind: "error", |
| | 370 | provider: this.sessionToProvider.get(sessionId) ?? "codex", |
| | 371 | sessionId, |
| | 372 | createdAt: new Date().toISOString(), |
| | 373 | method: "checkpoint/filesystemError", |
| | 374 | message, |
| | 375 | ...(threadId ? { threadId } : {}), |
| | 376 | }); |
| | 377 | } |
| | 378 | |
| | 379 | private withFilesystemLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T> { |
| | 380 | const previous = this.filesystemLocks.get(sessionId) ?? Promise.resolve(); |
| | 381 | const next = previous.catch(() => undefined).then(fn); |
| | 382 | const completion = next.then( |
| | 383 | () => undefined, |
| | 384 | () => undefined, |
| | 385 | ); |
| | 386 | this.filesystemLocks.set(sessionId, completion); |
| | 387 | return next.finally(() => { |
| | 388 | const tracked = this.filesystemLocks.get(sessionId); |
| | 389 | if (tracked === completion) { |
| | 390 | this.filesystemLocks.delete(sessionId); |
| | 391 | } |
| | 392 | }); |
| | 393 | } |
| | 394 | |
| | 395 | private resolveAdapterForSession(sessionId: string): ProviderAdapter { |
| | 396 | const mappedProvider = this.sessionToProvider.get(sessionId); |
| | 397 | if (mappedProvider) { |
| | 398 | return this.requireAdapter(mappedProvider); |
| | 399 | } |
| | 400 | for (const adapter of this.adapters.values()) { |
| | 401 | if (adapter.hasSession(sessionId)) { |
| | 402 | this.sessionToProvider.set(sessionId, adapter.kind); |
| | 403 | return adapter; |
| | 404 | } |
| | 405 | } |
| | 406 | throw new Error(`Unknown provider session: ${sessionId}`); |
| | 407 | } |
| | 408 | |
| | 409 | private requireAdapter(provider: ProviderKind): ProviderAdapter { |
| | 410 | const adapter = this.adapters.get(provider); |
| | 411 | if (!adapter) { |
| | 412 | throw new Error(`Provider '${provider}' is not implemented yet.`); |
| | 413 | } |
| | 414 | return adapter; |
| | 415 | } |
| | 416 | } |
| | 417 | |
| | 418 | function asObject(value: unknown): Record<string, unknown> | undefined { |
| | 419 | if (!value || typeof value !== "object") return undefined; |
| | 420 | return value as Record<string, unknown>; |
| | 421 | } |
| | 422 | |
| | 423 | function asString(value: unknown): string | undefined { |
| | 424 | return typeof value === "string" ? value : undefined; |
| | 425 | } |
| | 426 | |
| | 427 | function asArray(value: unknown): unknown[] { |
| | 428 | return Array.isArray(value) ? value : []; |
| | 429 | } |
| | 430 | |
| | 431 | function trimToPreview(value: string): string { |
| | 432 | const normalized = value.replace(/\s+/g, " ").trim(); |
| | 433 | if (normalized.length <= 120) return normalized; |
| | 434 | return `${normalized.slice(0, 117)}...`; |
| | 435 | } |
| | 436 | |
| | 437 | function summarizeUserMessageContent(content: unknown[]): string | undefined { |
| | 438 | const segments: string[] = []; |
| | 439 | for (const part of content) { |
| | 440 | const record = asObject(part); |
| | 441 | const type = asString(record?.type); |
| | 442 | if (!type) continue; |
| | 443 | if (type === "text") { |
| | 444 | const text = asString(record?.text); |
| | 445 | if (text && text.trim().length > 0) { |
| | 446 | segments.push(text.trim()); |
| | 447 | } |
| | 448 | continue; |
| | 449 | } |
| | 450 | if (type === "image") { |
| | 451 | segments.push("[Image attachment]"); |
| | 452 | continue; |
| | 453 | } |
| | 454 | if (type === "localImage") { |
| | 455 | segments.push("[Local image attachment]"); |
| | 456 | } |
| | 457 | } |
| | 458 | if (segments.length === 0) return undefined; |
| | 459 | return trimToPreview(segments.join(" ")); |
| | 460 | } |
| | 461 | |
| | 462 | function summarizeTurn(turn: ProviderThreadTurnSnapshot): { messageCountDelta: number; preview?: string } { |
| | 463 | let messageCountDelta = 0; |
| | 464 | let preview: string | undefined; |
| | 465 | for (const item of turn.items) { |
| | 466 | const record = asObject(item); |
| | 467 | const type = asString(record?.type); |
| | 468 | if (!type) continue; |
| | 469 | if (type === "userMessage") { |
| | 470 | messageCountDelta += 1; |
| | 471 | if (!preview) { |
| | 472 | preview = summarizeUserMessageContent(asArray(record?.content)); |
| | 473 | } |
| | 474 | continue; |
| | 475 | } |
| | 476 | if (type === "agentMessage") { |
| | 477 | messageCountDelta += 1; |
| | 478 | if (!preview) { |
| | 479 | const text = asString(record?.text); |
| | 480 | if (text && text.trim().length > 0) { |
| | 481 | preview = trimToPreview(text); |
| | 482 | } |
| | 483 | } |
| | 484 | } |
| | 485 | } |
| | 486 | return { |
| | 487 | messageCountDelta, |
| | 488 | ...(preview ? { preview } : {}), |
| | 489 | }; |
| | 490 | } |
| | 491 | |
| | 492 | function buildCheckpoints(turns: ProviderThreadTurnSnapshot[]): ProviderCheckpoint[] { |
| | 493 | const checkpoints: ProviderCheckpoint[] = []; |
| | 494 | let messageCount = 0; |
| | 495 | const isEmpty = turns.length === 0; |
| | 496 | checkpoints.push({ |
| | 497 | id: "root", |
| | 498 | turnCount: 0, |
| | 499 | messageCount: 0, |
| | 500 | label: "Start of conversation", |
| | 501 | isCurrent: isEmpty, |
| | 502 | }); |
| | 503 | for (let index = 0; index < turns.length; index += 1) { |
| | 504 | const turn = turns[index]; |
| | 505 | if (!turn) continue; |
| | 506 | const turnSummary = summarizeTurn(turn); |
| | 507 | messageCount += turnSummary.messageCountDelta; |
| | 508 | checkpoints.push({ |
| | 509 | id: turn.id, |
| | 510 | turnCount: index + 1, |
| | 511 | messageCount, |
| | 512 | label: `Turn ${index + 1}`, |
| | 513 | ...(turnSummary.preview ? { preview: turnSummary.preview } : {}), |
| | 514 | isCurrent: index === turns.length - 1, |
| | 515 | }); |
| | 516 | } |
| | 517 | return checkpoints; |
| | 518 | } |