| 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,7 +319,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => |
| 319 | const secondTurnThread = yield* harness.waitForThread( | 319 | const secondTurnThread = yield* harness.waitForThread( |
| 320 | THREAD_ID, | 320 | THREAD_ID, |
| 321 | (entry) => | 321 | (entry) => |
| 322 | entry.latestTurnId === "turn-2" && | 322 | entry.latestTurn?.turnId === "turn-2" && |
| 323 | entry.checkpoints.length === 2 && | 323 | entry.checkpoints.length === 2 && |
| 324 | entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 2), | 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,7 +653,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git |
| 653 | yield* harness.waitForThread( | 653 | yield* harness.waitForThread( |
| 654 | THREAD_ID, | 654 | THREAD_ID, |
| 655 | (entry) => | 655 | (entry) => |
| 656 | entry.latestTurnId === "turn-2" && | 656 | entry.latestTurn?.turnId === "turn-2" && |
| 657 | entry.checkpoints.length === 2 && | 657 | entry.checkpoints.length === 2 && |
| 658 | entry.activities.some((activity) => activity.turnId === "turn-2"), | 658 | entry.activities.some((activity) => activity.turnId === "turn-2"), |
| 659 | 8000, | 659 | 8000, |
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+8-1| ... | @@ -45,7 +45,14 @@ function makeSnapshot(input: { | ... | @@ -45,7 +45,14 @@ function makeSnapshot(input: { |
| 45 | model: "gpt-5-codex", | 45 | model: "gpt-5-codex", |
| 46 | branch: null, | 46 | branch: null, |
| 47 | worktreePath: input.worktreePath, | 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 | createdAt: "2026-01-01T00:00:00.000Z", | 56 | createdAt: "2026-01-01T00:00:00.000Z", |
| 50 | updatedAt: "2026-01-01T00:00:00.000Z", | 57 | updatedAt: "2026-01-01T00:00:00.000Z", |
| 51 | deletedAt: null, | 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,7 +93,7 @@ function createProviderServiceHarness(cwd: string, hasSession = true, sessionCwd |
| 93 | async function waitForThread( | 93 | async function waitForThread( |
| 94 | engine: OrchestrationEngineShape, | 94 | engine: OrchestrationEngineShape, |
| 95 | predicate: (thread: { | 95 | predicate: (thread: { |
| 96 | latestTurnId: string | null; | 96 | latestTurn: { turnId: string } | null; |
| 97 | checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; | 97 | checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; |
| 98 | activities: ReadonlyArray<{ kind: string }>; | 98 | activities: ReadonlyArray<{ kind: string }>; |
| 99 | }) => boolean, | 99 | }) => boolean, |
| ... | @@ -101,7 +101,7 @@ async function waitForThread( | ... | @@ -101,7 +101,7 @@ async function waitForThread( |
| 101 | ) { | 101 | ) { |
| 102 | const deadline = Date.now() + timeoutMs; | 102 | const deadline = Date.now() + timeoutMs; |
| 103 | const poll = async (): Promise<{ | 103 | const poll = async (): Promise<{ |
| 104 | latestTurnId: string | null; | 104 | latestTurn: { turnId: string } | null; |
| 105 | checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; | 105 | checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; |
| 106 | activities: ReadonlyArray<{ kind: string }>; | 106 | activities: ReadonlyArray<{ kind: string }>; |
| 107 | }> => { | 107 | }> => { |
| ... | @@ -358,7 +358,7 @@ describe("CheckpointReactor", () => { | ... | @@ -358,7 +358,7 @@ describe("CheckpointReactor", () => { |
| 358 | await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); | 358 | await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); |
| 359 | const thread = await waitForThread( | 359 | const thread = await waitForThread( |
| 360 | harness.engine, | 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 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); | 363 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); |
| 364 | expect( | 364 | expect( |
| ... | @@ -453,7 +453,7 @@ describe("CheckpointReactor", () => { | ... | @@ -453,7 +453,7 @@ describe("CheckpointReactor", () => { |
| 453 | 453 | ||
| 454 | const thread = await waitForThread( | 454 | const thread = await waitForThread( |
| 455 | harness.engine, | 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 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); | 458 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); |
| 459 | }); | 459 | }); |
| ... | @@ -775,7 +775,7 @@ describe("CheckpointReactor", () => { | ... | @@ -775,7 +775,7 @@ describe("CheckpointReactor", () => { |
| 775 | await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); | 775 | await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); |
| 776 | const thread = await waitForThread(harness.engine, (entry) => entry.checkpoints.length === 1); | 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 | expect(thread.checkpoints).toHaveLength(1); | 779 | expect(thread.checkpoints).toHaveLength(1); |
| 780 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); | 780 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); |
| 781 | expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); | 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,7 +243,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 243 | model: "gpt-5-codex", | 243 | model: "gpt-5-codex", |
| 244 | branch: null, | 244 | branch: null, |
| 245 | worktreePath: null, | 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 | createdAt: "2026-02-24T00:00:02.000Z", | 254 | createdAt: "2026-02-24T00:00:02.000Z", |
| 248 | updatedAt: "2026-02-24T00:00:03.000Z", | 255 | updatedAt: "2026-02-24T00:00:03.000Z", |
| 249 | deletedAt: null, | 256 | deletedAt: null, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+71-1| ... | @@ -1,8 +1,12 @@ | ... | @@ -1,8 +1,12 @@ |
| 1 | import { | 1 | import { |
| 2 | IsoDateTime, | ||
| 3 | MessageId, | ||
| 2 | OrchestrationCheckpointFile, | 4 | OrchestrationCheckpointFile, |
| 3 | OrchestrationReadModel, | 5 | OrchestrationReadModel, |
| 4 | ProjectScript, | 6 | ProjectScript, |
| 7 | TurnId, | ||
| 5 | type OrchestrationCheckpointSummary, | 8 | type OrchestrationCheckpointSummary, |
| 9 | type OrchestrationLatestTurn, | ||
| 6 | type OrchestrationMessage, | 10 | type OrchestrationMessage, |
| 7 | type OrchestrationProject, | 11 | type OrchestrationProject, |
| 8 | type OrchestrationSession, | 12 | type OrchestrationSession, |
| ... | @@ -55,6 +59,15 @@ const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( | ... | @@ -55,6 +59,15 @@ const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( |
| 55 | files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), | 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 | const ProjectionStateDbRowSchema = ProjectionState; | 71 | const ProjectionStateDbRowSchema = ProjectionState; |
| 59 | 72 | ||
| 60 | const REQUIRED_SNAPSHOT_PROJECTORS = [ | 73 | const REQUIRED_SNAPSHOT_PROJECTORS = [ |
| ... | @@ -226,6 +239,25 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -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 | const listProjectionStateRows = SqlSchema.findAll({ | 261 | const listProjectionStateRows = SqlSchema.findAll({ |
| 230 | Request: Schema.Void, | 262 | Request: Schema.Void, |
| 231 | Result: ProjectionStateDbRowSchema, | 263 | Result: ProjectionStateDbRowSchema, |
| ... | @@ -250,6 +282,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -250,6 +282,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 250 | activityRows, | 282 | activityRows, |
| 251 | sessionRows, | 283 | sessionRows, |
| 252 | checkpointRows, | 284 | checkpointRows, |
| 285 | latestTurnRows, | ||
| 253 | stateRows, | 286 | stateRows, |
| 254 | ] = yield* Effect.all([ | 287 | ] = yield* Effect.all([ |
| 255 | listProjectRows(undefined).pipe( | 288 | listProjectRows(undefined).pipe( |
| ... | @@ -300,6 +333,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -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 | listProjectionStateRows(undefined).pipe( | 344 | listProjectionStateRows(undefined).pipe( |
| 304 | Effect.mapError( | 345 | Effect.mapError( |
| 305 | toPersistenceSqlOrDecodeError( | 346 | toPersistenceSqlOrDecodeError( |
| ... | @@ -314,6 +355,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -314,6 +355,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 314 | const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>(); | 355 | const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>(); |
| 315 | const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>(); | 356 | const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>(); |
| 316 | const sessionsByThread = new Map<string, OrchestrationSession>(); | 357 | const sessionsByThread = new Map<string, OrchestrationSession>(); |
| 358 | const latestTurnByThread = new Map<string, OrchestrationLatestTurn>(); | ||
| 317 | 359 | ||
| 318 | let updatedAt: string | null = null; | 360 | let updatedAt: string | null = null; |
| 319 | 361 | ||
| ... | @@ -372,6 +414,34 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -372,6 +414,34 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 372 | checkpointsByThread.set(row.threadId, threadCheckpoints); | 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 | for (const row of sessionRows) { | 445 | for (const row of sessionRows) { |
| 376 | updatedAt = maxIso(updatedAt, row.updatedAt); | 446 | updatedAt = maxIso(updatedAt, row.updatedAt); |
| 377 | sessionsByThread.set(row.threadId, { | 447 | sessionsByThread.set(row.threadId, { |
| ... | @@ -406,7 +476,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -406,7 +476,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 406 | model: row.model, | 476 | model: row.model, |
| 407 | branch: row.branch, | 477 | branch: row.branch, |
| 408 | worktreePath: row.worktreePath, | 478 | worktreePath: row.worktreePath, |
| 409 | latestTurnId: row.latestTurnId, | 479 | latestTurn: latestTurnByThread.get(row.threadId) ?? null, |
| 410 | createdAt: row.createdAt, | 480 | createdAt: row.createdAt, |
| 411 | updatedAt: row.updatedAt, | 481 | updatedAt: row.updatedAt, |
| 412 | deletedAt: row.deletedAt, | 482 | deletedAt: row.deletedAt, |
apps/server/src/orchestration/commandInvariants.test.ts+2-2| ... | @@ -54,7 +54,7 @@ const readModel: OrchestrationReadModel = { | ... | @@ -54,7 +54,7 @@ const readModel: OrchestrationReadModel = { |
| 54 | worktreePath: null, | 54 | worktreePath: null, |
| 55 | createdAt: now, | 55 | createdAt: now, |
| 56 | updatedAt: now, | 56 | updatedAt: now, |
| 57 | latestTurnId: null, | 57 | latestTurn: null, |
| 58 | messages: [], | 58 | messages: [], |
| 59 | session: null, | 59 | session: null, |
| 60 | activities: [], | 60 | activities: [], |
| ... | @@ -70,7 +70,7 @@ const readModel: OrchestrationReadModel = { | ... | @@ -70,7 +70,7 @@ const readModel: OrchestrationReadModel = { |
| 70 | worktreePath: null, | 70 | worktreePath: null, |
| 71 | createdAt: now, | 71 | createdAt: now, |
| 72 | updatedAt: now, | 72 | updatedAt: now, |
| 73 | latestTurnId: null, | 73 | latestTurn: null, |
| 74 | messages: [], | 74 | messages: [], |
| 75 | session: null, | 75 | session: null, |
| 76 | activities: [], | 76 | activities: [], |
apps/server/src/orchestration/projector.test.ts+3-3| ... | @@ -75,7 +75,7 @@ describe("orchestration projector", () => { | ... | @@ -75,7 +75,7 @@ describe("orchestration projector", () => { |
| 75 | model: "gpt-5-codex", | 75 | model: "gpt-5-codex", |
| 76 | branch: null, | 76 | branch: null, |
| 77 | worktreePath: null, | 77 | worktreePath: null, |
| 78 | latestTurnId: null, | 78 | latestTurn: null, |
| 79 | createdAt: now, | 79 | createdAt: now, |
| 80 | updatedAt: now, | 80 | updatedAt: now, |
| 81 | deletedAt: null, | 81 | deletedAt: null, |
| ... | @@ -207,7 +207,7 @@ describe("orchestration projector", () => { | ... | @@ -207,7 +207,7 @@ describe("orchestration projector", () => { |
| 207 | ); | 207 | ); |
| 208 | 208 | ||
| 209 | const thread = afterRunning.threads[0]; | 209 | const thread = afterRunning.threads[0]; |
| 210 | expect(thread?.latestTurnId).toBe("turn-1"); | 210 | expect(thread?.latestTurn?.turnId).toBe("turn-1"); |
| 211 | expect(thread?.session?.status).toBe("running"); | 211 | expect(thread?.session?.status).toBe("running"); |
| 212 | }); | 212 | }); |
| 213 | 213 | ||
| ... | @@ -504,7 +504,7 @@ describe("orchestration projector", () => { | ... | @@ -504,7 +504,7 @@ describe("orchestration projector", () => { |
| 504 | thread?.activities.map((activity) => ({ id: activity.id, turnId: activity.turnId })), | 504 | thread?.activities.map((activity) => ({ id: activity.id, turnId: activity.turnId })), |
| 505 | ).toEqual([{ id: "activity-1", turnId: "turn-1" }]); | 505 | ).toEqual([{ id: "activity-1", turnId: "turn-1" }]); |
| 506 | expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]); | 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 | it("does not fallback-retain messages tied to removed turn IDs", async () => { | 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,6 +26,12 @@ type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>; |
| 26 | const MAX_THREAD_MESSAGES = 2_000; | 26 | const MAX_THREAD_MESSAGES = 2_000; |
| 27 | const MAX_THREAD_CHECKPOINTS = 500; | 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 | function updateThread( | 35 | function updateThread( |
| 30 | threads: ReadonlyArray<OrchestrationThread>, | 36 | threads: ReadonlyArray<OrchestrationThread>, |
| 31 | threadId: ThreadId, | 37 | threadId: ThreadId, |
| ... | @@ -220,7 +226,7 @@ export function projectEvent( | ... | @@ -220,7 +226,7 @@ export function projectEvent( |
| 220 | model: payload.model, | 226 | model: payload.model, |
| 221 | branch: payload.branch, | 227 | branch: payload.branch, |
| 222 | worktreePath: payload.worktreePath, | 228 | worktreePath: payload.worktreePath, |
| 223 | latestTurnId: null, | 229 | latestTurn: null, |
| 224 | createdAt: payload.createdAt, | 230 | createdAt: payload.createdAt, |
| 225 | updatedAt: payload.updatedAt, | 231 | updatedAt: payload.updatedAt, |
| 226 | deletedAt: null, | 232 | deletedAt: null, |
| ... | @@ -351,7 +357,26 @@ export function projectEvent( | ... | @@ -351,7 +357,26 @@ export function projectEvent( |
| 351 | ...nextBase, | 357 | ...nextBase, |
| 352 | threads: updateThread(nextBase.threads, payload.threadId, { | 358 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 353 | session, | 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 | updatedAt: event.occurredAt, | 380 | updatedAt: event.occurredAt, |
| 356 | }), | 381 | }), |
| 357 | }; | 382 | }; |
| ... | @@ -396,7 +421,20 @@ export function projectEvent( | ... | @@ -396,7 +421,20 @@ export function projectEvent( |
| 396 | ...nextBase, | 421 | ...nextBase, |
| 397 | threads: updateThread(nextBase.threads, payload.threadId, { | 422 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 398 | checkpoints, | 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 | updatedAt: event.occurredAt, | 438 | updatedAt: event.occurredAt, |
| 401 | }), | 439 | }), |
| 402 | }; | 440 | }; |
| ... | @@ -422,8 +460,18 @@ export function projectEvent( | ... | @@ -422,8 +460,18 @@ export function projectEvent( |
| 422 | ).slice(-MAX_THREAD_MESSAGES); | 460 | ).slice(-MAX_THREAD_MESSAGES); |
| 423 | const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds); | 461 | const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds); |
| 424 | 462 | ||
| 425 | const latestTurnId = | 463 | const latestCheckpoint = checkpoints.at(-1) ?? null; |
| 426 | checkpoints.length > 0 ? (checkpoints[checkpoints.length - 1]?.turnId ?? null) : 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 | return { | 476 | return { |
| 429 | ...nextBase, | 477 | ...nextBase, |
| ... | @@ -431,7 +479,7 @@ export function projectEvent( | ... | @@ -431,7 +479,7 @@ export function projectEvent( |
| 431 | checkpoints, | 479 | checkpoints, |
| 432 | messages, | 480 | messages, |
| 433 | activities, | 481 | activities, |
| 434 | latestTurnId, | 482 | latestTurn, |
| 435 | updatedAt: event.occurredAt, | 483 | updatedAt: event.occurredAt, |
| 436 | }), | 484 | }), |
| 437 | }; | 485 | }; |
apps/web/src/components/ChatView.tsx+20-37| ... | @@ -43,7 +43,7 @@ import { | ... | @@ -43,7 +43,7 @@ import { |
| 43 | deriveTimelineEntries, | 43 | deriveTimelineEntries, |
| 44 | type PendingApproval, | 44 | type PendingApproval, |
| 45 | deriveWorkLogEntries, | 45 | deriveWorkLogEntries, |
| 46 | formatDuration, | 46 | hasToolActivityForTurn, |
| 47 | formatElapsed, | 47 | formatElapsed, |
| 48 | formatTimestamp, | 48 | formatTimestamp, |
| 49 | } from "../session-logic"; | 49 | } from "../session-logic"; |
| ... | @@ -381,12 +381,13 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -381,12 +381,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 381 | ); | 381 | ); |
| 382 | const diffOpen = diffSearch.diff === "1"; | 382 | const diffOpen = diffSearch.diff === "1"; |
| 383 | const activeThreadId = activeThread?.id ?? null; | 383 | const activeThreadId = activeThread?.id ?? null; |
| 384 | const activeLatestTurn = activeThread?.latestTurn ?? null; | ||
| 384 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); | 385 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 385 | 386 | ||
| 386 | useEffect(() => { | 387 | useEffect(() => { |
| 387 | if (!activeThread?.id) return; | 388 | if (!activeThread?.id) return; |
| 388 | if (!activeThread.latestTurnCompletedAt) return; | 389 | if (!activeLatestTurn?.completedAt) return; |
| 389 | const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); | 390 | const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); |
| 390 | if (Number.isNaN(turnCompletedAt)) return; | 391 | if (Number.isNaN(turnCompletedAt)) return; |
| 391 | const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; | 392 | const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; |
| 392 | if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; | 393 | if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; |
| ... | @@ -398,7 +399,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -398,7 +399,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 398 | }, [ | 399 | }, [ |
| 399 | activeThread?.id, | 400 | activeThread?.id, |
| 400 | activeThread?.lastVisitedAt, | 401 | activeThread?.lastVisitedAt, |
| 401 | activeThread?.latestTurnCompletedAt, | 402 | activeLatestTurn?.completedAt, |
| 402 | dispatch, | 403 | dispatch, |
| 403 | ]); | 404 | ]); |
| 404 | 405 | ||
| ... | @@ -412,18 +413,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -412,18 +413,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 412 | const nowIso = new Date(nowTick).toISOString(); | 413 | const nowIso = new Date(nowTick).toISOString(); |
| 413 | const threadActivities = activeThread?.activities ?? []; | 414 | const threadActivities = activeThread?.activities ?? []; |
| 414 | const workLogEntries = useMemo( | 415 | const workLogEntries = useMemo( |
| 415 | () => deriveWorkLogEntries(threadActivities, undefined), | 416 | () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), |
| 416 | [threadActivities], | 417 | [activeLatestTurn?.turnId, threadActivities], |
| 417 | ); | 418 | ); |
| 418 | const latestTurnHasToolActivity = useMemo(() => { | 419 | const latestTurnHasToolActivity = useMemo(() => { |
| 419 | const latestTurnId = activeThread?.latestTurnId; | 420 | return hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId); |
| 420 | if (!latestTurnId) { | 421 | }, [activeLatestTurn?.turnId, threadActivities]); |
| 421 | return false; | ||
| 422 | } | ||
| 423 | return threadActivities.some( | ||
| 424 | (activity) => activity.turnId === latestTurnId && activity.tone === "tool", | ||
| 425 | ); | ||
| 426 | }, [activeThread?.latestTurnId, threadActivities]); | ||
| 427 | const pendingApprovals = useMemo( | 422 | const pendingApprovals = useMemo( |
| 428 | () => derivePendingApprovals(threadActivities), | 423 | () => derivePendingApprovals(threadActivities), |
| 429 | [threadActivities], | 424 | [threadActivities], |
| ... | @@ -488,36 +483,24 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -488,36 +483,24 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 488 | }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); | 483 | }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); |
| 489 | 484 | ||
| 490 | const completionSummary = useMemo(() => { | 485 | const completionSummary = useMemo(() => { |
| 491 | if (!activeThread?.latestTurnStartedAt) return null; | 486 | if (!activeLatestTurn?.startedAt) return null; |
| 492 | if (!activeThread.latestTurnCompletedAt) return null; | 487 | if (!activeLatestTurn.completedAt) return null; |
| 493 | if (!latestTurnHasToolActivity) return null; | 488 | if (!latestTurnHasToolActivity) return null; |
| 494 | 489 | ||
| 495 | if ( | 490 | const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt); |
| 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 | ); | ||
| 507 | return elapsed ? `Worked for ${elapsed}` : null; | 491 | return elapsed ? `Worked for ${elapsed}` : null; |
| 508 | }, [ | 492 | }, [ |
| 509 | activeThread?.latestTurnStartedAt, | 493 | activeLatestTurn?.completedAt, |
| 510 | activeThread?.latestTurnCompletedAt, | 494 | activeLatestTurn?.startedAt, |
| 511 | activeThread?.latestTurnDurationMs, | ||
| 512 | latestTurnHasToolActivity, | 495 | latestTurnHasToolActivity, |
| 513 | ]); | 496 | ]); |
| 514 | const completionDividerBeforeEntryId = useMemo(() => { | 497 | const completionDividerBeforeEntryId = useMemo(() => { |
| 515 | if (!activeThread?.latestTurnStartedAt) return null; | 498 | if (!activeLatestTurn?.startedAt) return null; |
| 516 | if (!activeThread.latestTurnCompletedAt) return null; | 499 | if (!activeLatestTurn.completedAt) return null; |
| 517 | if (!completionSummary) return null; | 500 | if (!completionSummary) return null; |
| 518 | 501 | ||
| 519 | const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt); | 502 | const turnStartedAt = Date.parse(activeLatestTurn.startedAt); |
| 520 | const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); | 503 | const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); |
| 521 | if (Number.isNaN(turnStartedAt)) return null; | 504 | if (Number.isNaN(turnStartedAt)) return null; |
| 522 | if (Number.isNaN(turnCompletedAt)) return null; | 505 | if (Number.isNaN(turnCompletedAt)) return null; |
| 523 | 506 | ||
| ... | @@ -535,8 +518,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -535,8 +518,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 535 | } | 518 | } |
| 536 | return inRangeMatch ?? fallbackMatch; | 519 | return inRangeMatch ?? fallbackMatch; |
| 537 | }, [ | 520 | }, [ |
| 538 | activeThread?.latestTurnCompletedAt, | 521 | activeLatestTurn?.completedAt, |
| 539 | activeThread?.latestTurnStartedAt, | 522 | activeLatestTurn?.startedAt, |
| 540 | completionSummary, | 523 | completionSummary, |
| 541 | timelineEntries, | 524 | timelineEntries, |
| 542 | ]); | 525 | ]); |
apps/web/src/components/Sidebar.tsx+2-2| ... | @@ -63,8 +63,8 @@ interface TerminalStatusIndicator { | ... | @@ -63,8 +63,8 @@ interface TerminalStatusIndicator { |
| 63 | } | 63 | } |
| 64 | 64 | ||
| 65 | function hasUnseenCompletion(thread: Thread): boolean { | 65 | function hasUnseenCompletion(thread: Thread): boolean { |
| 66 | if (!thread.latestTurnCompletedAt) return false; | 66 | if (!thread.latestTurn?.completedAt) return false; |
| 67 | const completedAt = Date.parse(thread.latestTurnCompletedAt); | 67 | const completedAt = Date.parse(thread.latestTurn.completedAt); |
| 68 | if (Number.isNaN(completedAt)) return false; | 68 | if (Number.isNaN(completedAt)) return false; |
| 69 | if (!thread.lastVisitedAt) return true; | 69 | if (!thread.lastVisitedAt) return true; |
| 70 | 70 |
apps/web/src/session-logic.test.ts+26-1| ... | @@ -1,7 +1,11 @@ | ... | @@ -1,7 +1,11 @@ |
| 1 | import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; | 1 | import { EventId, TurnId, 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 | deriveWorkLogEntries, | ||
| 7 | hasToolActivityForTurn, | ||
| 8 | } from "./session-logic"; | ||
| 5 | 9 | ||
| 6 | function makeActivity(overrides: { | 10 | function makeActivity(overrides: { |
| 7 | id?: string; | 11 | id?: string; |
| ... | @@ -126,3 +130,24 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -126,3 +130,24 @@ describe("deriveWorkLogEntries", () => { |
| 126 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); | 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,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 | export function deriveTimelineEntries( | 158 | export function deriveTimelineEntries( |
| 151 | messages: ChatMessage[], | 159 | messages: ChatMessage[], |
| 152 | workEntries: WorkLogEntry[], | 160 | workEntries: WorkLogEntry[], |
apps/web/src/store.test.ts+11-3| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; | 1 | import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | ||
| 4 | import { reducer, type AppState } from "./store"; | 4 | import { reducer, type AppState } from "./store"; |
| ... | @@ -29,6 +29,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -29,6 +29,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 29 | activities: [], | 29 | activities: [], |
| 30 | error: null, | 30 | error: null, |
| 31 | createdAt: "2026-02-13T00:00:00.000Z", | 31 | createdAt: "2026-02-13T00:00:00.000Z", |
| 32 | latestTurn: null, | ||
| 32 | branch: null, | 33 | branch: null, |
| 33 | worktreePath: null, | 34 | worktreePath: null, |
| 34 | ...overrides, | 35 | ...overrides, |
| ... | @@ -58,7 +59,14 @@ describe("store reducer", () => { | ... | @@ -58,7 +59,14 @@ describe("store reducer", () => { |
| 58 | const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; | 59 | const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; |
| 59 | const initialState = makeState( | 60 | const initialState = makeState( |
| 60 | makeThread({ | 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 | lastVisitedAt: "2026-02-25T12:35:00.000Z", | 70 | lastVisitedAt: "2026-02-25T12:35:00.000Z", |
| 63 | }), | 71 | }), |
| 64 | ); | 72 | ); |
| ... | @@ -79,7 +87,7 @@ describe("store reducer", () => { | ... | @@ -79,7 +87,7 @@ describe("store reducer", () => { |
| 79 | it("does not change a thread without a completed turn", () => { | 87 | it("does not change a thread without a completed turn", () => { |
| 80 | const initialState = makeState( | 88 | const initialState = makeState( |
| 81 | makeThread({ | 89 | makeThread({ |
| 82 | latestTurnCompletedAt: undefined, | 90 | latestTurn: null, |
| 83 | lastVisitedAt: "2026-02-25T12:35:00.000Z", | 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,19 +414,6 @@ export function reducer(state: AppState, action: Action): AppState { |
| 414 | .filter((thread) => thread.deletedAt === null) | 414 | .filter((thread) => thread.deletedAt === null) |
| 415 | .map((thread) => { | 415 | .map((thread) => { |
| 416 | const existing = existingThreadById.get(thread.id); | 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 | return normalizeThreadTerminals({ | 418 | return normalizeThreadTerminals({ |
| 432 | id: thread.id, | 419 | id: thread.id, |
| ... | @@ -484,10 +471,7 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -484,10 +471,7 @@ export function reducer(state: AppState, action: Action): AppState { |
| 484 | }), | 471 | }), |
| 485 | error: thread.session?.lastError ?? null, | 472 | error: thread.session?.lastError ?? null, |
| 486 | createdAt: thread.createdAt, | 473 | createdAt: thread.createdAt, |
| 487 | latestTurnId: thread.latestTurnId ?? undefined, | 474 | latestTurn: thread.latestTurn, |
| 488 | latestTurnStartedAt, | ||
| 489 | latestTurnCompletedAt, | ||
| 490 | latestTurnDurationMs, | ||
| 491 | lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, | 475 | lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, |
| 492 | branch: thread.branch, | 476 | branch: thread.branch, |
| 493 | worktreePath: thread.worktreePath, | 477 | worktreePath: thread.worktreePath, |
| ... | @@ -537,10 +521,10 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -537,10 +521,10 @@ export function reducer(state: AppState, action: Action): AppState { |
| 537 | return { | 521 | return { |
| 538 | ...state, | 522 | ...state, |
| 539 | threads: updateThread(state.threads, action.threadId, (thread) => { | 523 | threads: updateThread(state.threads, action.threadId, (thread) => { |
| 540 | if (!thread.latestTurnCompletedAt) { | 524 | if (!thread.latestTurn?.completedAt) { |
| 541 | return thread; | 525 | return thread; |
| 542 | } | 526 | } |
| 543 | const latestTurnCompletedAtMs = Date.parse(thread.latestTurnCompletedAt); | 527 | const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt); |
| 544 | if (Number.isNaN(latestTurnCompletedAtMs)) { | 528 | if (Number.isNaN(latestTurnCompletedAtMs)) { |
| 545 | return thread; | 529 | return thread; |
| 546 | } | 530 | } |
apps/web/src/types.ts+2-4| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { | 1 | import type { |
| 2 | OrchestrationLatestTurn, | ||
| 2 | OrchestrationSessionStatus, | 3 | OrchestrationSessionStatus, |
| 3 | OrchestrationThreadActivity, | 4 | OrchestrationThreadActivity, |
| 4 | ProjectScript as ContractProjectScript, | 5 | ProjectScript as ContractProjectScript, |
| ... | @@ -89,10 +90,7 @@ export interface Thread { | ... | @@ -89,10 +90,7 @@ export interface Thread { |
| 89 | messages: ChatMessage[]; | 90 | messages: ChatMessage[]; |
| 90 | error: string | null; | 91 | error: string | null; |
| 91 | createdAt: string; | 92 | createdAt: string; |
| 92 | latestTurnId?: TurnId | undefined; | 93 | latestTurn: OrchestrationLatestTurn | null; |
| 93 | latestTurnStartedAt?: string | undefined; | ||
| 94 | latestTurnCompletedAt?: string | undefined; | ||
| 95 | latestTurnDurationMs?: number | undefined; | ||
| 96 | lastVisitedAt?: string | undefined; | 94 | lastVisitedAt?: string | undefined; |
| 97 | branch: string | null; | 95 | branch: string | null; |
| 98 | worktreePath: string | null; | 96 | worktreePath: string | null; |
apps/web/src/worktreeCleanup.test.ts+1| ... | @@ -29,6 +29,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -29,6 +29,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 29 | activities: [], | 29 | activities: [], |
| 30 | error: null, | 30 | error: null, |
| 31 | createdAt: "2026-02-13T00:00:00.000Z", | 31 | createdAt: "2026-02-13T00:00:00.000Z", |
| 32 | latestTurn: null, | ||
| 32 | branch: null, | 33 | branch: null, |
| 33 | worktreePath: null, | 34 | worktreePath: null, |
| 34 | ...overrides, | 35 | ...overrides, |
packages/contracts/src/orchestration.ts+19-1| ... | @@ -191,6 +191,24 @@ export const OrchestrationThreadActivity = Schema.Struct({ | ... | @@ -191,6 +191,24 @@ export const OrchestrationThreadActivity = Schema.Struct({ |
| 191 | }); | 191 | }); |
| 192 | export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type; | 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 | export const OrchestrationThread = Schema.Struct({ | 212 | export const OrchestrationThread = Schema.Struct({ |
| 195 | id: ThreadId, | 213 | id: ThreadId, |
| 196 | projectId: ProjectId, | 214 | projectId: ProjectId, |
| ... | @@ -198,7 +216,7 @@ export const OrchestrationThread = Schema.Struct({ | ... | @@ -198,7 +216,7 @@ export const OrchestrationThread = Schema.Struct({ |
| 198 | model: TrimmedNonEmptyString, | 216 | model: TrimmedNonEmptyString, |
| 199 | branch: Schema.NullOr(TrimmedNonEmptyString), | 217 | branch: Schema.NullOr(TrimmedNonEmptyString), |
| 200 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), | 218 | worktreePath: Schema.NullOr(TrimmedNonEmptyString), |
| 201 | latestTurnId: Schema.NullOr(TurnId), | 219 | latestTurn: Schema.NullOr(OrchestrationLatestTurn), |
| 202 | createdAt: IsoDateTime, | 220 | createdAt: IsoDateTime, |
| 203 | updatedAt: IsoDateTime, | 221 | updatedAt: IsoDateTime, |
| 204 | deletedAt: Schema.NullOr(IsoDateTime), | 222 | deletedAt: Schema.NullOr(IsoDateTime), |