| author | |
| committer | |
| log | 3f81f9dcb40dd18ba8a95735ce017dc8b69373c2 |
| tree | eaa4b2058cc8b0d9529c55ff9fe0a46ac7e5aca2 |
| parent | b073fea5fb51b793373aae48647e39e1a2bc1890 |
- add `OrchestrationLatestTurn` schema and store full turn state/timestamps/message linkage
- update server projector/snapshot queries to populate `latestTurn` from session + projection_turns
- migrate web store/components to consume `latestTurn` and adjust related tests17 files changed, 245 insertions(+), 88 deletions(-)
apps/server/integration/orchestrationEngine.integration.test.ts+2-2| ... | ... | @@ -319,7 +319,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => |
| 319 | 319 | const secondTurnThread = yield* harness.waitForThread( |
| 320 | 320 | THREAD_ID, |
| 321 | 321 | (entry) => |
| 322 | entry.latestTurnId === "turn-2" && | |
| 322 | entry.latestTurn?.turnId === "turn-2" && | |
| 323 | 323 | entry.checkpoints.length === 2 && |
| 324 | 324 | entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 2), |
| 325 | 325 | ); |
| ... | ... | @@ -653,7 +653,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git |
| 653 | 653 | yield* harness.waitForThread( |
| 654 | 654 | THREAD_ID, |
| 655 | 655 | (entry) => |
| 656 | entry.latestTurnId === "turn-2" && | |
| 656 | entry.latestTurn?.turnId === "turn-2" && | |
| 657 | 657 | entry.checkpoints.length === 2 && |
| 658 | 658 | entry.activities.some((activity) => activity.turnId === "turn-2"), |
| 659 | 659 | 8000, |
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+8-1| ... | ... | @@ -45,7 +45,14 @@ function makeSnapshot(input: { |
| 45 | 45 | model: "gpt-5-codex", |
| 46 | 46 | branch: null, |
| 47 | 47 | worktreePath: input.worktreePath, |
| 48 | latestTurnId: TurnId.makeUnsafe("turn-1"), | |
| 48 | latestTurn: { | |
| 49 | turnId: TurnId.makeUnsafe("turn-1"), | |
| 50 | state: "completed", | |
| 51 | requestedAt: "2026-01-01T00:00:00.000Z", | |
| 52 | startedAt: "2026-01-01T00:00:00.000Z", | |
| 53 | completedAt: "2026-01-01T00:00:00.000Z", | |
| 54 | assistantMessageId: null, | |
| 55 | }, | |
| 49 | 56 | createdAt: "2026-01-01T00:00:00.000Z", |
| 50 | 57 | updatedAt: "2026-01-01T00:00:00.000Z", |
| 51 | 58 | deletedAt: null, |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+5-5| ... | ... | @@ -93,7 +93,7 @@ function createProviderServiceHarness(cwd: string, hasSession = true, sessionCwd |
| 93 | 93 | async function waitForThread( |
| 94 | 94 | engine: OrchestrationEngineShape, |
| 95 | 95 | predicate: (thread: { |
| 96 | latestTurnId: string | null; | |
| 96 | latestTurn: { turnId: string } | null; | |
| 97 | 97 | checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; |
| 98 | 98 | activities: ReadonlyArray<{ kind: string }>; |
| 99 | 99 | }) => boolean, |
| ... | ... | @@ -101,7 +101,7 @@ async function waitForThread( |
| 101 | 101 | ) { |
| 102 | 102 | const deadline = Date.now() + timeoutMs; |
| 103 | 103 | const poll = async (): Promise<{ |
| 104 | latestTurnId: string | null; | |
| 104 | latestTurn: { turnId: string } | null; | |
| 105 | 105 | checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; |
| 106 | 106 | activities: ReadonlyArray<{ kind: string }>; |
| 107 | 107 | }> => { |
| ... | ... | @@ -358,7 +358,7 @@ describe("CheckpointReactor", () => { |
| 358 | 358 | await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); |
| 359 | 359 | const thread = await waitForThread( |
| 360 | 360 | harness.engine, |
| 361 | (entry) => entry.latestTurnId === "turn-1" && entry.checkpoints.length === 1, | |
| 361 | (entry) => entry.latestTurn?.turnId === "turn-1" && entry.checkpoints.length === 1, | |
| 362 | 362 | ); |
| 363 | 363 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); |
| 364 | 364 | expect( |
| ... | ... | @@ -453,7 +453,7 @@ describe("CheckpointReactor", () => { |
| 453 | 453 | |
| 454 | 454 | const thread = await waitForThread( |
| 455 | 455 | harness.engine, |
| 456 | (entry) => entry.latestTurnId === "turn-main" && entry.checkpoints.length === 1, | |
| 456 | (entry) => entry.latestTurn?.turnId === "turn-main" && entry.checkpoints.length === 1, | |
| 457 | 457 | ); |
| 458 | 458 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); |
| 459 | 459 | }); |
| ... | ... | @@ -775,7 +775,7 @@ describe("CheckpointReactor", () => { |
| 775 | 775 | await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); |
| 776 | 776 | const thread = await waitForThread(harness.engine, (entry) => entry.checkpoints.length === 1); |
| 777 | 777 | |
| 778 | expect(thread.latestTurnId).toBe("turn-1"); | |
| 778 | expect(thread.latestTurn?.turnId).toBe("turn-1"); | |
| 779 | 779 | expect(thread.checkpoints).toHaveLength(1); |
| 780 | 780 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); |
| 781 | 781 | expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+8-1| ... | ... | @@ -243,7 +243,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 243 | 243 | model: "gpt-5-codex", |
| 244 | 244 | branch: null, |
| 245 | 245 | worktreePath: null, |
| 246 | latestTurnId: asTurnId("turn-1"), | |
| 246 | latestTurn: { | |
| 247 | turnId: asTurnId("turn-1"), | |
| 248 | state: "completed", | |
| 249 | requestedAt: "2026-02-24T00:00:08.000Z", | |
| 250 | startedAt: "2026-02-24T00:00:08.000Z", | |
| 251 | completedAt: "2026-02-24T00:00:08.000Z", | |
| 252 | assistantMessageId: asMessageId("message-1"), | |
| 253 | }, | |
| 247 | 254 | createdAt: "2026-02-24T00:00:02.000Z", |
| 248 | 255 | updatedAt: "2026-02-24T00:00:03.000Z", |
| 249 | 256 | deletedAt: null, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+71-1| ... | ... | @@ -1,8 +1,12 @@ |
| 1 | 1 | import { |
| 2 | IsoDateTime, | |
| 3 | MessageId, | |
| 2 | 4 | OrchestrationCheckpointFile, |
| 3 | 5 | OrchestrationReadModel, |
| 4 | 6 | ProjectScript, |
| 7 | TurnId, | |
| 5 | 8 | type OrchestrationCheckpointSummary, |
| 9 | type OrchestrationLatestTurn, | |
| 6 | 10 | type OrchestrationMessage, |
| 7 | 11 | type OrchestrationProject, |
| 8 | 12 | type OrchestrationSession, |
| ... | ... | @@ -55,6 +59,15 @@ const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( |
| 55 | 59 | files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), |
| 56 | 60 | }), |
| 57 | 61 | ); |
| 62 | const ProjectionLatestTurnDbRowSchema = Schema.Struct({ | |
| 63 | threadId: ProjectionThread.fields.threadId, | |
| 64 | turnId: TurnId, | |
| 65 | state: Schema.String, | |
| 66 | requestedAt: IsoDateTime, | |
| 67 | startedAt: Schema.NullOr(IsoDateTime), | |
| 68 | completedAt: Schema.NullOr(IsoDateTime), | |
| 69 | assistantMessageId: Schema.NullOr(MessageId), | |
| 70 | }); | |
| 58 | 71 | const ProjectionStateDbRowSchema = ProjectionState; |
| 59 | 72 | |
| 60 | 73 | const REQUIRED_SNAPSHOT_PROJECTORS = [ |
| ... | ... | @@ -226,6 +239,25 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 226 | 239 | `, |
| 227 | 240 | }); |
| 228 | 241 | |
| 242 | const listLatestTurnRows = SqlSchema.findAll({ | |
| 243 | Request: Schema.Void, | |
| 244 | Result: ProjectionLatestTurnDbRowSchema, | |
| 245 | execute: () => | |
| 246 | sql` | |
| 247 | SELECT | |
| 248 | thread_id AS "threadId", | |
| 249 | turn_id AS "turnId", | |
| 250 | state, | |
| 251 | requested_at AS "requestedAt", | |
| 252 | started_at AS "startedAt", | |
| 253 | completed_at AS "completedAt", | |
| 254 | assistant_message_id AS "assistantMessageId" | |
| 255 | FROM projection_turns | |
| 256 | WHERE turn_id IS NOT NULL | |
| 257 | ORDER BY thread_id ASC, requested_at DESC, turn_id DESC | |
| 258 | `, | |
| 259 | }); | |
| 260 | ||
| 229 | 261 | const listProjectionStateRows = SqlSchema.findAll({ |
| 230 | 262 | Request: Schema.Void, |
| 231 | 263 | Result: ProjectionStateDbRowSchema, |
| ... | ... | @@ -250,6 +282,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 250 | 282 | activityRows, |
| 251 | 283 | sessionRows, |
| 252 | 284 | checkpointRows, |
| 285 | latestTurnRows, | |
| 253 | 286 | stateRows, |
| 254 | 287 | ] = yield* Effect.all([ |
| 255 | 288 | listProjectRows(undefined).pipe( |
| ... | ... | @@ -300,6 +333,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 300 | 333 | ), |
| 301 | 334 | ), |
| 302 | 335 | ), |
| 336 | listLatestTurnRows(undefined).pipe( | |
| 337 | Effect.mapError( | |
| 338 | toPersistenceSqlOrDecodeError( | |
| 339 | "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query", | |
| 340 | "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows", | |
| 341 | ), | |
| 342 | ), | |
| 343 | ), | |
| 303 | 344 | listProjectionStateRows(undefined).pipe( |
| 304 | 345 | Effect.mapError( |
| 305 | 346 | toPersistenceSqlOrDecodeError( |
| ... | ... | @@ -314,6 +355,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 314 | 355 | const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>(); |
| 315 | 356 | const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>(); |
| 316 | 357 | const sessionsByThread = new Map<string, OrchestrationSession>(); |
| 358 | const latestTurnByThread = new Map<string, OrchestrationLatestTurn>(); | |
| 317 | 359 | |
| 318 | 360 | let updatedAt: string | null = null; |
| 319 | 361 | |
| ... | ... | @@ -372,6 +414,34 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 372 | 414 | checkpointsByThread.set(row.threadId, threadCheckpoints); |
| 373 | 415 | } |
| 374 | 416 | |
| 417 | for (const row of latestTurnRows) { | |
| 418 | updatedAt = maxIso(updatedAt, row.requestedAt); | |
| 419 | if (row.startedAt !== null) { | |
| 420 | updatedAt = maxIso(updatedAt, row.startedAt); | |
| 421 | } | |
| 422 | if (row.completedAt !== null) { | |
| 423 | updatedAt = maxIso(updatedAt, row.completedAt); | |
| 424 | } | |
| 425 | if (latestTurnByThread.has(row.threadId)) { | |
| 426 | continue; | |
| 427 | } | |
| 428 | latestTurnByThread.set(row.threadId, { | |
| 429 | turnId: row.turnId, | |
| 430 | state: | |
| 431 | row.state === "error" | |
| 432 | ? "error" | |
| 433 | : row.state === "interrupted" | |
| 434 | ? "interrupted" | |
| 435 | : row.state === "completed" | |
| 436 | ? "completed" | |
| 437 | : "running", | |
| 438 | requestedAt: row.requestedAt, | |
| 439 | startedAt: row.startedAt, | |
| 440 | completedAt: row.completedAt, | |
| 441 | assistantMessageId: row.assistantMessageId, | |
| 442 | }); | |
| 443 | } | |
| 444 | ||
| 375 | 445 | for (const row of sessionRows) { |
| 376 | 446 | updatedAt = maxIso(updatedAt, row.updatedAt); |
| 377 | 447 | sessionsByThread.set(row.threadId, { |
| ... | ... | @@ -406,7 +476,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 406 | 476 | model: row.model, |
| 407 | 477 | branch: row.branch, |
| 408 | 478 | worktreePath: row.worktreePath, |
| 409 | latestTurnId: row.latestTurnId, | |
| 479 | latestTurn: latestTurnByThread.get(row.threadId) ?? null, | |
| 410 | 480 | createdAt: row.createdAt, |
| 411 | 481 | updatedAt: row.updatedAt, |
| 412 | 482 | deletedAt: row.deletedAt, |
apps/server/src/orchestration/commandInvariants.test.ts+2-2| ... | ... | @@ -54,7 +54,7 @@ const readModel: OrchestrationReadModel = { |
| 54 | 54 | worktreePath: null, |
| 55 | 55 | createdAt: now, |
| 56 | 56 | updatedAt: now, |
| 57 | latestTurnId: null, | |
| 57 | latestTurn: null, | |
| 58 | 58 | messages: [], |
| 59 | 59 | session: null, |
| 60 | 60 | activities: [], |
| ... | ... | @@ -70,7 +70,7 @@ const readModel: OrchestrationReadModel = { |
| 70 | 70 | worktreePath: null, |
| 71 | 71 | createdAt: now, |
| 72 | 72 | updatedAt: now, |
| 73 | latestTurnId: null, | |
| 73 | latestTurn: null, | |
| 74 | 74 | messages: [], |
| 75 | 75 | session: null, |
| 76 | 76 | activities: [], |
apps/server/src/orchestration/projector.test.ts+3-3| ... | ... | @@ -75,7 +75,7 @@ describe("orchestration projector", () => { |
| 75 | 75 | model: "gpt-5-codex", |
| 76 | 76 | branch: null, |
| 77 | 77 | worktreePath: null, |
| 78 | latestTurnId: null, | |
| 78 | latestTurn: null, | |
| 79 | 79 | createdAt: now, |
| 80 | 80 | updatedAt: now, |
| 81 | 81 | deletedAt: null, |
| ... | ... | @@ -207,7 +207,7 @@ describe("orchestration projector", () => { |
| 207 | 207 | ); |
| 208 | 208 | |
| 209 | 209 | const thread = afterRunning.threads[0]; |
| 210 | expect(thread?.latestTurnId).toBe("turn-1"); | |
| 210 | expect(thread?.latestTurn?.turnId).toBe("turn-1"); | |
| 211 | 211 | expect(thread?.session?.status).toBe("running"); |
| 212 | 212 | }); |
| 213 | 213 | |
| ... | ... | @@ -504,7 +504,7 @@ describe("orchestration projector", () => { |
| 504 | 504 | thread?.activities.map((activity) => ({ id: activity.id, turnId: activity.turnId })), |
| 505 | 505 | ).toEqual([{ id: "activity-1", turnId: "turn-1" }]); |
| 506 | 506 | expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]); |
| 507 | expect(thread?.latestTurnId).toBe("turn-1"); | |
| 507 | expect(thread?.latestTurn?.turnId).toBe("turn-1"); | |
| 508 | 508 | }); |
| 509 | 509 | |
| 510 | 510 | it("does not fallback-retain messages tied to removed turn IDs", async () => { |
apps/server/src/orchestration/projector.ts+54-6| ... | ... | @@ -26,6 +26,12 @@ type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>; |
| 26 | 26 | const MAX_THREAD_MESSAGES = 2_000; |
| 27 | 27 | const MAX_THREAD_CHECKPOINTS = 500; |
| 28 | 28 | |
| 29 | function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { | |
| 30 | if (status === "error") return "error" as const; | |
| 31 | if (status === "missing") return "interrupted" as const; | |
| 32 | return "completed" as const; | |
| 33 | } | |
| 34 | ||
| 29 | 35 | function updateThread( |
| 30 | 36 | threads: ReadonlyArray<OrchestrationThread>, |
| 31 | 37 | threadId: ThreadId, |
| ... | ... | @@ -220,7 +226,7 @@ export function projectEvent( |
| 220 | 226 | model: payload.model, |
| 221 | 227 | branch: payload.branch, |
| 222 | 228 | worktreePath: payload.worktreePath, |
| 223 | latestTurnId: null, | |
| 229 | latestTurn: null, | |
| 224 | 230 | createdAt: payload.createdAt, |
| 225 | 231 | updatedAt: payload.updatedAt, |
| 226 | 232 | deletedAt: null, |
| ... | ... | @@ -351,7 +357,26 @@ export function projectEvent( |
| 351 | 357 | ...nextBase, |
| 352 | 358 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 353 | 359 | session, |
| 354 | latestTurnId: session.activeTurnId, | |
| 360 | latestTurn: | |
| 361 | session.status === "running" && session.activeTurnId !== null | |
| 362 | ? { | |
| 363 | turnId: session.activeTurnId, | |
| 364 | state: "running", | |
| 365 | requestedAt: | |
| 366 | thread.latestTurn?.turnId === session.activeTurnId | |
| 367 | ? thread.latestTurn.requestedAt | |
| 368 | : session.updatedAt, | |
| 369 | startedAt: | |
| 370 | thread.latestTurn?.turnId === session.activeTurnId | |
| 371 | ? (thread.latestTurn.startedAt ?? session.updatedAt) | |
| 372 | : session.updatedAt, | |
| 373 | completedAt: null, | |
| 374 | assistantMessageId: | |
| 375 | thread.latestTurn?.turnId === session.activeTurnId | |
| 376 | ? thread.latestTurn.assistantMessageId | |
| 377 | : null, | |
| 378 | } | |
| 379 | : thread.latestTurn, | |
| 355 | 380 | updatedAt: event.occurredAt, |
| 356 | 381 | }), |
| 357 | 382 | }; |
| ... | ... | @@ -396,7 +421,20 @@ export function projectEvent( |
| 396 | 421 | ...nextBase, |
| 397 | 422 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 398 | 423 | checkpoints, |
| 399 | latestTurnId: payload.turnId, | |
| 424 | latestTurn: { | |
| 425 | turnId: payload.turnId, | |
| 426 | state: checkpointStatusToLatestTurnState(payload.status), | |
| 427 | requestedAt: | |
| 428 | thread.latestTurn?.turnId === payload.turnId | |
| 429 | ? thread.latestTurn.requestedAt | |
| 430 | : payload.completedAt, | |
| 431 | startedAt: | |
| 432 | thread.latestTurn?.turnId === payload.turnId | |
| 433 | ? (thread.latestTurn.startedAt ?? payload.completedAt) | |
| 434 | : payload.completedAt, | |
| 435 | completedAt: payload.completedAt, | |
| 436 | assistantMessageId: payload.assistantMessageId, | |
| 437 | }, | |
| 400 | 438 | updatedAt: event.occurredAt, |
| 401 | 439 | }), |
| 402 | 440 | }; |
| ... | ... | @@ -422,8 +460,18 @@ export function projectEvent( |
| 422 | 460 | ).slice(-MAX_THREAD_MESSAGES); |
| 423 | 461 | const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds); |
| 424 | 462 | |
| 425 | const latestTurnId = | |
| 426 | checkpoints.length > 0 ? (checkpoints[checkpoints.length - 1]?.turnId ?? null) : null; | |
| 463 | const latestCheckpoint = checkpoints.at(-1) ?? null; | |
| 464 | const latestTurn = | |
| 465 | latestCheckpoint === null | |
| 466 | ? null | |
| 467 | : { | |
| 468 | turnId: latestCheckpoint.turnId, | |
| 469 | state: checkpointStatusToLatestTurnState(latestCheckpoint.status), | |
| 470 | requestedAt: latestCheckpoint.completedAt, | |
| 471 | startedAt: latestCheckpoint.completedAt, | |
| 472 | completedAt: latestCheckpoint.completedAt, | |
| 473 | assistantMessageId: latestCheckpoint.assistantMessageId, | |
| 474 | }; | |
| 427 | 475 | |
| 428 | 476 | return { |
| 429 | 477 | ...nextBase, |
| ... | ... | @@ -431,7 +479,7 @@ export function projectEvent( |
| 431 | 479 | checkpoints, |
| 432 | 480 | messages, |
| 433 | 481 | activities, |
| 434 | latestTurnId, | |
| 482 | latestTurn, | |
| 435 | 483 | updatedAt: event.occurredAt, |
| 436 | 484 | }), |
| 437 | 485 | }; |
apps/web/src/components/ChatView.tsx+20-37| ... | ... | @@ -43,7 +43,7 @@ import { |
| 43 | 43 | deriveTimelineEntries, |
| 44 | 44 | type PendingApproval, |
| 45 | 45 | deriveWorkLogEntries, |
| 46 | formatDuration, | |
| 46 | hasToolActivityForTurn, | |
| 47 | 47 | formatElapsed, |
| 48 | 48 | formatTimestamp, |
| 49 | 49 | } from "../session-logic"; |
| ... | ... | @@ -381,12 +381,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 381 | 381 | ); |
| 382 | 382 | const diffOpen = diffSearch.diff === "1"; |
| 383 | 383 | const activeThreadId = activeThread?.id ?? null; |
| 384 | const activeLatestTurn = activeThread?.latestTurn ?? null; | |
| 384 | 385 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 385 | 386 | |
| 386 | 387 | useEffect(() => { |
| 387 | 388 | if (!activeThread?.id) return; |
| 388 | if (!activeThread.latestTurnCompletedAt) return; | |
| 389 | const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); | |
| 389 | if (!activeLatestTurn?.completedAt) return; | |
| 390 | const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); | |
| 390 | 391 | if (Number.isNaN(turnCompletedAt)) return; |
| 391 | 392 | const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; |
| 392 | 393 | if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; |
| ... | ... | @@ -398,7 +399,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 398 | 399 | }, [ |
| 399 | 400 | activeThread?.id, |
| 400 | 401 | activeThread?.lastVisitedAt, |
| 401 | activeThread?.latestTurnCompletedAt, | |
| 402 | activeLatestTurn?.completedAt, | |
| 402 | 403 | dispatch, |
| 403 | 404 | ]); |
| 404 | 405 | |
| ... | ... | @@ -412,18 +413,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 412 | 413 | const nowIso = new Date(nowTick).toISOString(); |
| 413 | 414 | const threadActivities = activeThread?.activities ?? []; |
| 414 | 415 | const workLogEntries = useMemo( |
| 415 | () => deriveWorkLogEntries(threadActivities, undefined), | |
| 416 | [threadActivities], | |
| 416 | () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), | |
| 417 | [activeLatestTurn?.turnId, threadActivities], | |
| 417 | 418 | ); |
| 418 | 419 | const latestTurnHasToolActivity = useMemo(() => { |
| 419 | const latestTurnId = activeThread?.latestTurnId; | |
| 420 | if (!latestTurnId) { | |
| 421 | return false; | |
| 422 | } | |
| 423 | return threadActivities.some( | |
| 424 | (activity) => activity.turnId === latestTurnId && activity.tone === "tool", | |
| 425 | ); | |
| 426 | }, [activeThread?.latestTurnId, threadActivities]); | |
| 420 | return hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId); | |
| 421 | }, [activeLatestTurn?.turnId, threadActivities]); | |
| 427 | 422 | const pendingApprovals = useMemo( |
| 428 | 423 | () => derivePendingApprovals(threadActivities), |
| 429 | 424 | [threadActivities], |
| ... | ... | @@ -488,36 +483,24 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 488 | 483 | }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); |
| 489 | 484 | |
| 490 | 485 | const completionSummary = useMemo(() => { |
| 491 | if (!activeThread?.latestTurnStartedAt) return null; | |
| 492 | if (!activeThread.latestTurnCompletedAt) return null; | |
| 486 | if (!activeLatestTurn?.startedAt) return null; | |
| 487 | if (!activeLatestTurn.completedAt) return null; | |
| 493 | 488 | if (!latestTurnHasToolActivity) return null; |
| 494 | 489 | |
| 495 | if ( | |
| 496 | typeof activeThread.latestTurnDurationMs === "number" && | |
| 497 | Number.isFinite(activeThread.latestTurnDurationMs) && | |
| 498 | activeThread.latestTurnDurationMs >= 0 | |
| 499 | ) { | |
| 500 | return `Worked for ${formatDuration(activeThread.latestTurnDurationMs)}`; | |
| 501 | } | |
| 502 | ||
| 503 | const elapsed = formatElapsed( | |
| 504 | activeThread.latestTurnStartedAt, | |
| 505 | activeThread.latestTurnCompletedAt, | |
| 506 | ); | |
| 490 | const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt); | |
| 507 | 491 | return elapsed ? `Worked for ${elapsed}` : null; |
| 508 | 492 | }, [ |
| 509 | activeThread?.latestTurnStartedAt, | |
| 510 | activeThread?.latestTurnCompletedAt, | |
| 511 | activeThread?.latestTurnDurationMs, | |
| 493 | activeLatestTurn?.completedAt, | |
| 494 | activeLatestTurn?.startedAt, | |
| 512 | 495 | latestTurnHasToolActivity, |
| 513 | 496 | ]); |
| 514 | 497 | const completionDividerBeforeEntryId = useMemo(() => { |
| 515 | if (!activeThread?.latestTurnStartedAt) return null; | |
| 516 | if (!activeThread.latestTurnCompletedAt) return null; | |
| 498 | if (!activeLatestTurn?.startedAt) return null; | |
| 499 | if (!activeLatestTurn.completedAt) return null; | |
| 517 | 500 | if (!completionSummary) return null; |
| 518 | 501 | |
| 519 | const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt); | |
| 520 | const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); | |
| 502 | const turnStartedAt = Date.parse(activeLatestTurn.startedAt); | |
| 503 | const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); | |
| 521 | 504 | if (Number.isNaN(turnStartedAt)) return null; |
| 522 | 505 | if (Number.isNaN(turnCompletedAt)) return null; |
| 523 | 506 | |
| ... | ... | @@ -535,8 +518,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 535 | 518 | } |
| 536 | 519 | return inRangeMatch ?? fallbackMatch; |
| 537 | 520 | }, [ |
| 538 | activeThread?.latestTurnCompletedAt, | |
| 539 | activeThread?.latestTurnStartedAt, | |
| 521 | activeLatestTurn?.completedAt, | |
| 522 | activeLatestTurn?.startedAt, | |
| 540 | 523 | completionSummary, |
| 541 | 524 | timelineEntries, |
| 542 | 525 | ]); |
apps/web/src/components/Sidebar.tsx+2-2| ... | ... | @@ -63,8 +63,8 @@ interface TerminalStatusIndicator { |
| 63 | 63 | } |
| 64 | 64 | |
| 65 | 65 | function hasUnseenCompletion(thread: Thread): boolean { |
| 66 | if (!thread.latestTurnCompletedAt) return false; | |
| 67 | const completedAt = Date.parse(thread.latestTurnCompletedAt); | |
| 66 | if (!thread.latestTurn?.completedAt) return false; | |
| 67 | const completedAt = Date.parse(thread.latestTurn.completedAt); | |
| 68 | 68 | if (Number.isNaN(completedAt)) return false; |
| 69 | 69 | if (!thread.lastVisitedAt) return true; |
| 70 | 70 |
apps/web/src/session-logic.test.ts+26-1| ... | ... | @@ -1,7 +1,11 @@ |
| 1 | 1 | import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | import { derivePendingApprovals, deriveWorkLogEntries } from "./session-logic"; | |
| 4 | import { | |
| 5 | derivePendingApprovals, | |
| 6 | deriveWorkLogEntries, | |
| 7 | hasToolActivityForTurn, | |
| 8 | } from "./session-logic"; | |
| 5 | 9 | |
| 6 | 10 | function makeActivity(overrides: { |
| 7 | 11 | id?: string; |
| ... | ... | @@ -126,3 +130,24 @@ describe("deriveWorkLogEntries", () => { |
| 126 | 130 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); |
| 127 | 131 | }); |
| 128 | 132 | }); |
| 133 | ||
| 134 | describe("hasToolActivityForTurn", () => { | |
| 135 | it("returns false when turn id is missing", () => { | |
| 136 | const activities: OrchestrationThreadActivity[] = [ | |
| 137 | makeActivity({ id: "tool-1", turnId: "turn-1", kind: "tool.completed", tone: "tool" }), | |
| 138 | ]; | |
| 139 | ||
| 140 | expect(hasToolActivityForTurn(activities, undefined)).toBe(false); | |
| 141 | expect(hasToolActivityForTurn(activities, null)).toBe(false); | |
| 142 | }); | |
| 143 | ||
| 144 | it("returns true only for matching tool activity in the target turn", () => { | |
| 145 | const activities: OrchestrationThreadActivity[] = [ | |
| 146 | makeActivity({ id: "tool-1", turnId: "turn-1", kind: "tool.completed", tone: "tool" }), | |
| 147 | makeActivity({ id: "info-1", turnId: "turn-2", kind: "turn.completed", tone: "info" }), | |
| 148 | ]; | |
| 149 | ||
| 150 | expect(hasToolActivityForTurn(activities, TurnId.makeUnsafe("turn-1"))).toBe(true); | |
| 151 | expect(hasToolActivityForTurn(activities, TurnId.makeUnsafe("turn-2"))).toBe(false); | |
| 152 | }); | |
| 153 | }); |
apps/web/src/session-logic.ts+8| ... | ... | @@ -147,6 +147,14 @@ export function deriveWorkLogEntries( |
| 147 | 147 | }); |
| 148 | 148 | } |
| 149 | 149 | |
| 150 | export function hasToolActivityForTurn( | |
| 151 | activities: ReadonlyArray<OrchestrationThreadActivity>, | |
| 152 | turnId: TurnId | null | undefined, | |
| 153 | ): boolean { | |
| 154 | if (!turnId) return false; | |
| 155 | return activities.some((activity) => activity.turnId === turnId && activity.tone === "tool"); | |
| 156 | } | |
| 157 | ||
| 150 | 158 | export function deriveTimelineEntries( |
| 151 | 159 | messages: ChatMessage[], |
| 152 | 160 | workEntries: WorkLogEntry[], |
apps/web/src/store.test.ts+11-3| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | |
| 1 | import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; | |
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | |
| 4 | 4 | import { reducer, type AppState } from "./store"; |
| ... | ... | @@ -29,6 +29,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 29 | 29 | activities: [], |
| 30 | 30 | error: null, |
| 31 | 31 | createdAt: "2026-02-13T00:00:00.000Z", |
| 32 | latestTurn: null, | |
| 32 | 33 | branch: null, |
| 33 | 34 | worktreePath: null, |
| 34 | 35 | ...overrides, |
| ... | ... | @@ -58,7 +59,14 @@ describe("store reducer", () => { |
| 58 | 59 | const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; |
| 59 | 60 | const initialState = makeState( |
| 60 | 61 | makeThread({ |
| 61 | latestTurnCompletedAt, | |
| 62 | latestTurn: { | |
| 63 | turnId: TurnId.makeUnsafe("turn-1"), | |
| 64 | state: "completed", | |
| 65 | requestedAt: "2026-02-25T12:28:00.000Z", | |
| 66 | startedAt: "2026-02-25T12:28:30.000Z", | |
| 67 | completedAt: latestTurnCompletedAt, | |
| 68 | assistantMessageId: null, | |
| 69 | }, | |
| 62 | 70 | lastVisitedAt: "2026-02-25T12:35:00.000Z", |
| 63 | 71 | }), |
| 64 | 72 | ); |
| ... | ... | @@ -79,7 +87,7 @@ describe("store reducer", () => { |
| 79 | 87 | it("does not change a thread without a completed turn", () => { |
| 80 | 88 | const initialState = makeState( |
| 81 | 89 | makeThread({ |
| 82 | latestTurnCompletedAt: undefined, | |
| 90 | latestTurn: null, | |
| 83 | 91 | lastVisitedAt: "2026-02-25T12:35:00.000Z", |
| 84 | 92 | }), |
| 85 | 93 | ); |
apps/web/src/store.ts+3-19| ... | ... | @@ -414,19 +414,6 @@ export function reducer(state: AppState, action: Action): AppState { |
| 414 | 414 | .filter((thread) => thread.deletedAt === null) |
| 415 | 415 | .map((thread) => { |
| 416 | 416 | const existing = existingThreadById.get(thread.id); |
| 417 | const latestTurnStartedAt = | |
| 418 | thread.latestTurnId === null | |
| 419 | ? undefined | |
| 420 | : thread.messages.find( | |
| 421 | (message) => message.turnId === thread.latestTurnId && message.role === "user", | |
| 422 | )?.createdAt; | |
| 423 | const latestTurnCompletedAt = thread.checkpoints.find( | |
| 424 | (checkpoint) => checkpoint.turnId === thread.latestTurnId, | |
| 425 | )?.completedAt; | |
| 426 | const latestTurnDurationMs = | |
| 427 | latestTurnStartedAt && latestTurnCompletedAt | |
| 428 | ? Math.max(0, Date.parse(latestTurnCompletedAt) - Date.parse(latestTurnStartedAt)) | |
| 429 | : undefined; | |
| 430 | 417 | |
| 431 | 418 | return normalizeThreadTerminals({ |
| 432 | 419 | id: thread.id, |
| ... | ... | @@ -484,10 +471,7 @@ export function reducer(state: AppState, action: Action): AppState { |
| 484 | 471 | }), |
| 485 | 472 | error: thread.session?.lastError ?? null, |
| 486 | 473 | createdAt: thread.createdAt, |
| 487 | latestTurnId: thread.latestTurnId ?? undefined, | |
| 488 | latestTurnStartedAt, | |
| 489 | latestTurnCompletedAt, | |
| 490 | latestTurnDurationMs, | |
| 474 | latestTurn: thread.latestTurn, | |
| 491 | 475 | lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, |
| 492 | 476 | branch: thread.branch, |
| 493 | 477 | worktreePath: thread.worktreePath, |
| ... | ... | @@ -537,10 +521,10 @@ export function reducer(state: AppState, action: Action): AppState { |
| 537 | 521 | return { |
| 538 | 522 | ...state, |
| 539 | 523 | threads: updateThread(state.threads, action.threadId, (thread) => { |
| 540 | if (!thread.latestTurnCompletedAt) { | |
| 524 | if (!thread.latestTurn?.completedAt) { | |
| 541 | 525 | return thread; |
| 542 | 526 | } |
| 543 | const latestTurnCompletedAtMs = Date.parse(thread.latestTurnCompletedAt); | |
| 527 | const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt); | |
| 544 | 528 | if (Number.isNaN(latestTurnCompletedAtMs)) { |
| 545 | 529 | return thread; |
| 546 | 530 | } |
apps/web/src/types.ts+2-4| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import type { |
| 2 | OrchestrationLatestTurn, | |
| 2 | 3 | OrchestrationSessionStatus, |
| 3 | 4 | OrchestrationThreadActivity, |
| 4 | 5 | ProjectScript as ContractProjectScript, |
| ... | ... | @@ -89,10 +90,7 @@ export interface Thread { |
| 89 | 90 | messages: ChatMessage[]; |
| 90 | 91 | error: string | null; |
| 91 | 92 | createdAt: string; |
| 92 | latestTurnId?: TurnId | undefined; | |
| 93 | latestTurnStartedAt?: string | undefined; | |
| 94 | latestTurnCompletedAt?: string | undefined; | |
| 95 | latestTurnDurationMs?: number | undefined; | |
| 93 | latestTurn: OrchestrationLatestTurn | null; | |
| 96 | 94 | lastVisitedAt?: string | undefined; |
| 97 | 95 | branch: string | null; |
| 98 | 96 | worktreePath: string | null; |
apps/web/src/worktreeCleanup.test.ts+1| ... | ... | @@ -29,6 +29,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 29 | 29 | activities: [], |
| 30 | 30 | error: null, |
| 31 | 31 | createdAt: "2026-02-13T00:00:00.000Z", |
| 32 | latestTurn: null, | |
| 32 | 33 | branch: null, |
| 33 | 34 | worktreePath: null, |
| 34 | 35 | ...overrides, |
packages/contracts/src/orchestration.ts+19-1| ... | ... | @@ -191,6 +191,24 @@ export const OrchestrationThreadActivity = Schema.Struct({ |
| 191 | 191 | }); |
| 192 | 192 | export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type; |
| 193 | 193 | |
| 194 | export const OrchestrationLatestTurnState = Schema.Literals([ | |
| 195 | "running", | |
| 196 | "interrupted", | |
| 197 | "completed", | |
| 198 | "error", | |
| 199 | ]); | |
| 200 | export type OrchestrationLatestTurnState = typeof OrchestrationLatestTurnState.Type; | |
| 201 | ||
| 202 | export const OrchestrationLatestTurn = Schema.Struct({ | |
| 203 | turnId: TurnId, | |
| 204 | state: OrchestrationLatestTurnState, | |
| 205 | requestedAt: IsoDateTime, | |
| 206 | startedAt: Schema.NullOr(IsoDateTime), | |
| 207 | completedAt: Schema.NullOr(IsoDateTime), | |
| 208 | assistantMessageId: Schema.NullOr(MessageId), | |
| 209 | }); | |
| 210 | export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; | |
| 211 | ||
| 194 | 212 | export const OrchestrationThread = Schema.Struct({ |
| 195 | 213 | id: ThreadId, |
| 196 | 214 | projectId: ProjectId, |
| ... | ... | @@ -198,7 +216,7 @@ export const OrchestrationThread = Schema.Struct({ |
| 198 | 216 | model: TrimmedNonEmptyString, |
| 199 | 217 | branch: Schema.NullOr(TrimmedNonEmptyString), |
| 200 | 218 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), |
| 201 | latestTurnId: Schema.NullOr(TurnId), | |
| 219 | latestTurn: Schema.NullOr(OrchestrationLatestTurn), | |
| 202 | 220 | createdAt: IsoDateTime, |
| 203 | 221 | updatedAt: IsoDateTime, |
| 204 | 222 | deletedAt: Schema.NullOr(IsoDateTime), |