authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 13:49:59-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-27 14:10:34-08:00
log3f81f9dcb40dd18ba8a95735ce017dc8b69373c2
treeeaa4b2058cc8b0d9529c55ff9fe0a46ac7e5aca2
parentb073fea5fb51b793373aae48647e39e1a2bc1890

Replace thread latestTurnId with structured latestTurn metadata

- 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 tests

17 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
93async function waitForThread(93async 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", () => {
453453
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);
777777
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 @@
1import {1import {
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);
62const 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});
58const ProjectionStateDbRowSchema = ProjectionState;71const ProjectionStateDbRowSchema = ProjectionState;
5972
60const REQUIRED_SNAPSHOT_PROJECTORS = [73const 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 });
228241
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>();
317359
318 let updatedAt: string | null = null;360 let updatedAt: string | null = null;
319361
...@@ -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 }
374416
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 );
208208
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 });
213213
...@@ -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 });
509509
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">>;
26const MAX_THREAD_MESSAGES = 2_000;26const MAX_THREAD_MESSAGES = 2_000;
27const MAX_THREAD_CHECKPOINTS = 500;27const MAX_THREAD_CHECKPOINTS = 500;
2828
29function 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
29function updateThread(35function 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);
424462
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 };
427475
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);
385386
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 ]);
404405
...@@ -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]);
489484
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;
494489
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;
518501
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;
523506
...@@ -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}
6464
65function hasUnseenCompletion(thread: Thread): boolean {65function 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;
7070
apps/web/src/session-logic.test.ts+26-1
...@@ -1,7 +1,11 @@...@@ -1,7 +1,11 @@
1import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts";1import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import { derivePendingApprovals, deriveWorkLogEntries } from "./session-logic";4import {
5 derivePendingApprovals,
6 deriveWorkLogEntries,
7 hasToolActivityForTurn,
8} from "./session-logic";
59
6function makeActivity(overrides: {10function 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
134describe("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}
149149
150export 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
150export function deriveTimelineEntries(158export 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 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";1import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";2import { describe, expect, it } from "vitest";
33
4import { reducer, type AppState } from "./store";4import { 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;
430417
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 @@
1import type {1import 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});
192export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type;192export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type;
193193
194export const OrchestrationLatestTurnState = Schema.Literals([
195 "running",
196 "interrupted",
197 "completed",
198 "error",
199]);
200export type OrchestrationLatestTurnState = typeof OrchestrationLatestTurnState.Type;
201
202export 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});
210export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type;
211
194export const OrchestrationThread = Schema.Struct({212export 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),