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", () =>
319319 const secondTurnThread = yield* harness.waitForThread(
320320 THREAD_ID,
321321 (entry) =>
322 entry.latestTurnId === "turn-2" &&
322 entry.latestTurn?.turnId === "turn-2" &&
323323 entry.checkpoints.length === 2 &&
324324 entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 2),
325325 );
......@@ -653,7 +653,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git
653653 yield* harness.waitForThread(
654654 THREAD_ID,
655655 (entry) =>
656 entry.latestTurnId === "turn-2" &&
656 entry.latestTurn?.turnId === "turn-2" &&
657657 entry.checkpoints.length === 2 &&
658658 entry.activities.some((activity) => activity.turnId === "turn-2"),
659659 8000,
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+8-1
......@@ -45,7 +45,14 @@ function makeSnapshot(input: {
4545 model: "gpt-5-codex",
4646 branch: null,
4747 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 },
4956 createdAt: "2026-01-01T00:00:00.000Z",
5057 updatedAt: "2026-01-01T00:00:00.000Z",
5158 deletedAt: null,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+5-5
......@@ -93,7 +93,7 @@ function createProviderServiceHarness(cwd: string, hasSession = true, sessionCwd
9393async function waitForThread(
9494 engine: OrchestrationEngineShape,
9595 predicate: (thread: {
96 latestTurnId: string | null;
96 latestTurn: { turnId: string } | null;
9797 checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>;
9898 activities: ReadonlyArray<{ kind: string }>;
9999 }) => boolean,
......@@ -101,7 +101,7 @@ async function waitForThread(
101101) {
102102 const deadline = Date.now() + timeoutMs;
103103 const poll = async (): Promise<{
104 latestTurnId: string | null;
104 latestTurn: { turnId: string } | null;
105105 checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>;
106106 activities: ReadonlyArray<{ kind: string }>;
107107 }> => {
......@@ -358,7 +358,7 @@ describe("CheckpointReactor", () => {
358358 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");
359359 const thread = await waitForThread(
360360 harness.engine,
361 (entry) => entry.latestTurnId === "turn-1" && entry.checkpoints.length === 1,
361 (entry) => entry.latestTurn?.turnId === "turn-1" && entry.checkpoints.length === 1,
362362 );
363363 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
364364 expect(
......@@ -453,7 +453,7 @@ describe("CheckpointReactor", () => {
453453
454454 const thread = await waitForThread(
455455 harness.engine,
456 (entry) => entry.latestTurnId === "turn-main" && entry.checkpoints.length === 1,
456 (entry) => entry.latestTurn?.turnId === "turn-main" && entry.checkpoints.length === 1,
457457 );
458458 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
459459 });
......@@ -775,7 +775,7 @@ describe("CheckpointReactor", () => {
775775 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
776776 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");
779779 expect(thread.checkpoints).toHaveLength(1);
780780 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
781781 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+8-1
......@@ -243,7 +243,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
243243 model: "gpt-5-codex",
244244 branch: null,
245245 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 },
247254 createdAt: "2026-02-24T00:00:02.000Z",
248255 updatedAt: "2026-02-24T00:00:03.000Z",
249256 deletedAt: null,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+71-1
......@@ -1,8 +1,12 @@
11import {
2 IsoDateTime,
3 MessageId,
24 OrchestrationCheckpointFile,
35 OrchestrationReadModel,
46 ProjectScript,
7 TurnId,
58 type OrchestrationCheckpointSummary,
9 type OrchestrationLatestTurn,
610 type OrchestrationMessage,
711 type OrchestrationProject,
812 type OrchestrationSession,
......@@ -55,6 +59,15 @@ const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields(
5559 files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)),
5660 }),
5761);
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});
5871const ProjectionStateDbRowSchema = ProjectionState;
5972
6073const REQUIRED_SNAPSHOT_PROJECTORS = [
......@@ -226,6 +239,25 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
226239 `,
227240 });
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
229261 const listProjectionStateRows = SqlSchema.findAll({
230262 Request: Schema.Void,
231263 Result: ProjectionStateDbRowSchema,
......@@ -250,6 +282,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
250282 activityRows,
251283 sessionRows,
252284 checkpointRows,
285 latestTurnRows,
253286 stateRows,
254287 ] = yield* Effect.all([
255288 listProjectRows(undefined).pipe(
......@@ -300,6 +333,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
300333 ),
301334 ),
302335 ),
336 listLatestTurnRows(undefined).pipe(
337 Effect.mapError(
338 toPersistenceSqlOrDecodeError(
339 "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query",
340 "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows",
341 ),
342 ),
343 ),
303344 listProjectionStateRows(undefined).pipe(
304345 Effect.mapError(
305346 toPersistenceSqlOrDecodeError(
......@@ -314,6 +355,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
314355 const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>();
315356 const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>();
316357 const sessionsByThread = new Map<string, OrchestrationSession>();
358 const latestTurnByThread = new Map<string, OrchestrationLatestTurn>();
317359
318360 let updatedAt: string | null = null;
319361
......@@ -372,6 +414,34 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
372414 checkpointsByThread.set(row.threadId, threadCheckpoints);
373415 }
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
375445 for (const row of sessionRows) {
376446 updatedAt = maxIso(updatedAt, row.updatedAt);
377447 sessionsByThread.set(row.threadId, {
......@@ -406,7 +476,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
406476 model: row.model,
407477 branch: row.branch,
408478 worktreePath: row.worktreePath,
409 latestTurnId: row.latestTurnId,
479 latestTurn: latestTurnByThread.get(row.threadId) ?? null,
410480 createdAt: row.createdAt,
411481 updatedAt: row.updatedAt,
412482 deletedAt: row.deletedAt,
apps/server/src/orchestration/commandInvariants.test.ts+2-2
......@@ -54,7 +54,7 @@ const readModel: OrchestrationReadModel = {
5454 worktreePath: null,
5555 createdAt: now,
5656 updatedAt: now,
57 latestTurnId: null,
57 latestTurn: null,
5858 messages: [],
5959 session: null,
6060 activities: [],
......@@ -70,7 +70,7 @@ const readModel: OrchestrationReadModel = {
7070 worktreePath: null,
7171 createdAt: now,
7272 updatedAt: now,
73 latestTurnId: null,
73 latestTurn: null,
7474 messages: [],
7575 session: null,
7676 activities: [],
apps/server/src/orchestration/projector.test.ts+3-3
......@@ -75,7 +75,7 @@ describe("orchestration projector", () => {
7575 model: "gpt-5-codex",
7676 branch: null,
7777 worktreePath: null,
78 latestTurnId: null,
78 latestTurn: null,
7979 createdAt: now,
8080 updatedAt: now,
8181 deletedAt: null,
......@@ -207,7 +207,7 @@ describe("orchestration projector", () => {
207207 );
208208
209209 const thread = afterRunning.threads[0];
210 expect(thread?.latestTurnId).toBe("turn-1");
210 expect(thread?.latestTurn?.turnId).toBe("turn-1");
211211 expect(thread?.session?.status).toBe("running");
212212 });
213213
......@@ -504,7 +504,7 @@ describe("orchestration projector", () => {
504504 thread?.activities.map((activity) => ({ id: activity.id, turnId: activity.turnId })),
505505 ).toEqual([{ id: "activity-1", turnId: "turn-1" }]);
506506 expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]);
507 expect(thread?.latestTurnId).toBe("turn-1");
507 expect(thread?.latestTurn?.turnId).toBe("turn-1");
508508 });
509509
510510 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">>;
2626const MAX_THREAD_MESSAGES = 2_000;
2727const 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
2935function updateThread(
3036 threads: ReadonlyArray<OrchestrationThread>,
3137 threadId: ThreadId,
......@@ -220,7 +226,7 @@ export function projectEvent(
220226 model: payload.model,
221227 branch: payload.branch,
222228 worktreePath: payload.worktreePath,
223 latestTurnId: null,
229 latestTurn: null,
224230 createdAt: payload.createdAt,
225231 updatedAt: payload.updatedAt,
226232 deletedAt: null,
......@@ -351,7 +357,26 @@ export function projectEvent(
351357 ...nextBase,
352358 threads: updateThread(nextBase.threads, payload.threadId, {
353359 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,
355380 updatedAt: event.occurredAt,
356381 }),
357382 };
......@@ -396,7 +421,20 @@ export function projectEvent(
396421 ...nextBase,
397422 threads: updateThread(nextBase.threads, payload.threadId, {
398423 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 },
400438 updatedAt: event.occurredAt,
401439 }),
402440 };
......@@ -422,8 +460,18 @@ export function projectEvent(
422460 ).slice(-MAX_THREAD_MESSAGES);
423461 const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds);
424462
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 };
427475
428476 return {
429477 ...nextBase,
......@@ -431,7 +479,7 @@ export function projectEvent(
431479 checkpoints,
432480 messages,
433481 activities,
434 latestTurnId,
482 latestTurn,
435483 updatedAt: event.occurredAt,
436484 }),
437485 };
apps/web/src/components/ChatView.tsx+20-37
......@@ -43,7 +43,7 @@ import {
4343 deriveTimelineEntries,
4444 type PendingApproval,
4545 deriveWorkLogEntries,
46 formatDuration,
46 hasToolActivityForTurn,
4747 formatElapsed,
4848 formatTimestamp,
4949} from "../session-logic";
......@@ -381,12 +381,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
381381 );
382382 const diffOpen = diffSearch.diff === "1";
383383 const activeThreadId = activeThread?.id ?? null;
384 const activeLatestTurn = activeThread?.latestTurn ?? null;
384385 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
385386
386387 useEffect(() => {
387388 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);
390391 if (Number.isNaN(turnCompletedAt)) return;
391392 const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN;
392393 if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return;
......@@ -398,7 +399,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
398399 }, [
399400 activeThread?.id,
400401 activeThread?.lastVisitedAt,
401 activeThread?.latestTurnCompletedAt,
402 activeLatestTurn?.completedAt,
402403 dispatch,
403404 ]);
404405
......@@ -412,18 +413,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
412413 const nowIso = new Date(nowTick).toISOString();
413414 const threadActivities = activeThread?.activities ?? [];
414415 const workLogEntries = useMemo(
415 () => deriveWorkLogEntries(threadActivities, undefined),
416 [threadActivities],
416 () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined),
417 [activeLatestTurn?.turnId, threadActivities],
417418 );
418419 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]);
427422 const pendingApprovals = useMemo(
428423 () => derivePendingApprovals(threadActivities),
429424 [threadActivities],
......@@ -488,36 +483,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
488483 }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
489484
490485 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;
493488 if (!latestTurnHasToolActivity) return null;
494489
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);
507491 return elapsed ? `Worked for ${elapsed}` : null;
508492 }, [
509 activeThread?.latestTurnStartedAt,
510 activeThread?.latestTurnCompletedAt,
511 activeThread?.latestTurnDurationMs,
493 activeLatestTurn?.completedAt,
494 activeLatestTurn?.startedAt,
512495 latestTurnHasToolActivity,
513496 ]);
514497 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;
517500 if (!completionSummary) return null;
518501
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);
521504 if (Number.isNaN(turnStartedAt)) return null;
522505 if (Number.isNaN(turnCompletedAt)) return null;
523506
......@@ -535,8 +518,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
535518 }
536519 return inRangeMatch ?? fallbackMatch;
537520 }, [
538 activeThread?.latestTurnCompletedAt,
539 activeThread?.latestTurnStartedAt,
521 activeLatestTurn?.completedAt,
522 activeLatestTurn?.startedAt,
540523 completionSummary,
541524 timelineEntries,
542525 ]);
apps/web/src/components/Sidebar.tsx+2-2
......@@ -63,8 +63,8 @@ interface TerminalStatusIndicator {
6363}
6464
6565function 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);
6868 if (Number.isNaN(completedAt)) return false;
6969 if (!thread.lastVisitedAt) return true;
7070
apps/web/src/session-logic.test.ts+26-1
......@@ -1,7 +1,11 @@
11import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
4import { derivePendingApprovals, deriveWorkLogEntries } from "./session-logic";
4import {
5 derivePendingApprovals,
6 deriveWorkLogEntries,
7 hasToolActivityForTurn,
8} from "./session-logic";
59
610function makeActivity(overrides: {
711 id?: string;
......@@ -126,3 +130,24 @@ describe("deriveWorkLogEntries", () => {
126130 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
127131 });
128132});
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(
147147 });
148148}
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
150158export function deriveTimelineEntries(
151159 messages: ChatMessage[],
152160 workEntries: WorkLogEntry[],
apps/web/src/store.test.ts+11-3
......@@ -1,4 +1,4 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";
1import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
44import { reducer, type AppState } from "./store";
......@@ -29,6 +29,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
2929 activities: [],
3030 error: null,
3131 createdAt: "2026-02-13T00:00:00.000Z",
32 latestTurn: null,
3233 branch: null,
3334 worktreePath: null,
3435 ...overrides,
......@@ -58,7 +59,14 @@ describe("store reducer", () => {
5859 const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";
5960 const initialState = makeState(
6061 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 },
6270 lastVisitedAt: "2026-02-25T12:35:00.000Z",
6371 }),
6472 );
......@@ -79,7 +87,7 @@ describe("store reducer", () => {
7987 it("does not change a thread without a completed turn", () => {
8088 const initialState = makeState(
8189 makeThread({
82 latestTurnCompletedAt: undefined,
90 latestTurn: null,
8391 lastVisitedAt: "2026-02-25T12:35:00.000Z",
8492 }),
8593 );
apps/web/src/store.ts+3-19
......@@ -414,19 +414,6 @@ export function reducer(state: AppState, action: Action): AppState {
414414 .filter((thread) => thread.deletedAt === null)
415415 .map((thread) => {
416416 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
431418 return normalizeThreadTerminals({
432419 id: thread.id,
......@@ -484,10 +471,7 @@ export function reducer(state: AppState, action: Action): AppState {
484471 }),
485472 error: thread.session?.lastError ?? null,
486473 createdAt: thread.createdAt,
487 latestTurnId: thread.latestTurnId ?? undefined,
488 latestTurnStartedAt,
489 latestTurnCompletedAt,
490 latestTurnDurationMs,
474 latestTurn: thread.latestTurn,
491475 lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt,
492476 branch: thread.branch,
493477 worktreePath: thread.worktreePath,
......@@ -537,10 +521,10 @@ export function reducer(state: AppState, action: Action): AppState {
537521 return {
538522 ...state,
539523 threads: updateThread(state.threads, action.threadId, (thread) => {
540 if (!thread.latestTurnCompletedAt) {
524 if (!thread.latestTurn?.completedAt) {
541525 return thread;
542526 }
543 const latestTurnCompletedAtMs = Date.parse(thread.latestTurnCompletedAt);
527 const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt);
544528 if (Number.isNaN(latestTurnCompletedAtMs)) {
545529 return thread;
546530 }
apps/web/src/types.ts+2-4
......@@ -1,4 +1,5 @@
11import type {
2 OrchestrationLatestTurn,
23 OrchestrationSessionStatus,
34 OrchestrationThreadActivity,
45 ProjectScript as ContractProjectScript,
......@@ -89,10 +90,7 @@ export interface Thread {
8990 messages: ChatMessage[];
9091 error: string | null;
9192 createdAt: string;
92 latestTurnId?: TurnId | undefined;
93 latestTurnStartedAt?: string | undefined;
94 latestTurnCompletedAt?: string | undefined;
95 latestTurnDurationMs?: number | undefined;
93 latestTurn: OrchestrationLatestTurn | null;
9694 lastVisitedAt?: string | undefined;
9795 branch: string | null;
9896 worktreePath: string | null;
apps/web/src/worktreeCleanup.test.ts+1
......@@ -29,6 +29,7 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
2929 activities: [],
3030 error: null,
3131 createdAt: "2026-02-13T00:00:00.000Z",
32 latestTurn: null,
3233 branch: null,
3334 worktreePath: null,
3435 ...overrides,
packages/contracts/src/orchestration.ts+19-1
......@@ -191,6 +191,24 @@ export const OrchestrationThreadActivity = Schema.Struct({
191191});
192192export 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
194212export const OrchestrationThread = Schema.Struct({
195213 id: ThreadId,
196214 projectId: ProjectId,
......@@ -198,7 +216,7 @@ export const OrchestrationThread = Schema.Struct({
198216 model: TrimmedNonEmptyString,
199217 branch: Schema.NullOr(TrimmedNonEmptyString),
200218 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
201 latestTurnId: Schema.NullOr(TurnId),
219 latestTurn: Schema.NullOr(OrchestrationLatestTurn),
202220 createdAt: IsoDateTime,
203221 updatedAt: IsoDateTime,
204222 deletedAt: Schema.NullOr(IsoDateTime),