diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 5572b6e3eed925847c0617c0b96202262eb8c6b5..e12b8d2bd1828b39dafcbfb743aced79138f87f6 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -319,7 +319,7 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => const secondTurnThread = yield* harness.waitForThread( THREAD_ID, (entry) => - entry.latestTurnId === "turn-2" && + entry.latestTurn?.turnId === "turn-2" && entry.checkpoints.length === 2 && entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 2), ); @@ -653,7 +653,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git yield* harness.waitForThread( THREAD_ID, (entry) => - entry.latestTurnId === "turn-2" && + entry.latestTurn?.turnId === "turn-2" && entry.checkpoints.length === 2 && entry.activities.some((activity) => activity.turnId === "turn-2"), 8000, diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index e01081ee3fc3464196bd1a4b547eeb6f68d51042..786253d5a47c2eda6349c105e637154af9f0a10c 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -45,7 +45,14 @@ function makeSnapshot(input: { model: "gpt-5-codex", branch: null, worktreePath: input.worktreePath, - latestTurnId: TurnId.makeUnsafe("turn-1"), + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "completed", + requestedAt: "2026-01-01T00:00:00.000Z", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:00.000Z", + assistantMessageId: null, + }, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", deletedAt: null, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index f0e494b39a72a7ad332e77afebff3046a8df2760..3da8faf731ee1fc0f8d4a2eb49d3b1481ae9516f 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -93,7 +93,7 @@ function createProviderServiceHarness(cwd: string, hasSession = true, sessionCwd async function waitForThread( engine: OrchestrationEngineShape, predicate: (thread: { - latestTurnId: string | null; + latestTurn: { turnId: string } | null; checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; activities: ReadonlyArray<{ kind: string }>; }) => boolean, @@ -101,7 +101,7 @@ async function waitForThread( ) { const deadline = Date.now() + timeoutMs; const poll = async (): Promise<{ - latestTurnId: string | null; + latestTurn: { turnId: string } | null; checkpoints: ReadonlyArray<{ checkpointTurnCount: number }>; activities: ReadonlyArray<{ kind: string }>; }> => { @@ -358,7 +358,7 @@ describe("CheckpointReactor", () => { await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); const thread = await waitForThread( harness.engine, - (entry) => entry.latestTurnId === "turn-1" && entry.checkpoints.length === 1, + (entry) => entry.latestTurn?.turnId === "turn-1" && entry.checkpoints.length === 1, ); expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); expect( @@ -453,7 +453,7 @@ describe("CheckpointReactor", () => { const thread = await waitForThread( harness.engine, - (entry) => entry.latestTurnId === "turn-main" && entry.checkpoints.length === 1, + (entry) => entry.latestTurn?.turnId === "turn-main" && entry.checkpoints.length === 1, ); expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); }); @@ -775,7 +775,7 @@ describe("CheckpointReactor", () => { await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); const thread = await waitForThread(harness.engine, (entry) => entry.checkpoints.length === 1); - expect(thread.latestTurnId).toBe("turn-1"); + expect(thread.latestTurn?.turnId).toBe("turn-1"); expect(thread.checkpoints).toHaveLength(1); expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 97dadf01cb5a77e2e3ea4cd3f90fabdfedb5a31f..ba99a796487d1bb2a1b7e6d3266db5019fb63b67 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -243,7 +243,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { model: "gpt-5-codex", branch: null, worktreePath: null, - latestTurnId: asTurnId("turn-1"), + latestTurn: { + turnId: asTurnId("turn-1"), + state: "completed", + requestedAt: "2026-02-24T00:00:08.000Z", + startedAt: "2026-02-24T00:00:08.000Z", + completedAt: "2026-02-24T00:00:08.000Z", + assistantMessageId: asMessageId("message-1"), + }, createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", deletedAt: null, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index fe2f96615cb1358d40db28288e80e60d56fc2d2c..3b0798e20eaf9897189a0173b08fdb48bf7d5035 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,8 +1,12 @@ import { + IsoDateTime, + MessageId, OrchestrationCheckpointFile, OrchestrationReadModel, ProjectScript, + TurnId, type OrchestrationCheckpointSummary, + type OrchestrationLatestTurn, type OrchestrationMessage, type OrchestrationProject, type OrchestrationSession, @@ -55,6 +59,15 @@ const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), }), ); +const ProjectionLatestTurnDbRowSchema = Schema.Struct({ + threadId: ProjectionThread.fields.threadId, + turnId: TurnId, + state: Schema.String, + requestedAt: IsoDateTime, + startedAt: Schema.NullOr(IsoDateTime), + completedAt: Schema.NullOr(IsoDateTime), + assistantMessageId: Schema.NullOr(MessageId), +}); const ProjectionStateDbRowSchema = ProjectionState; const REQUIRED_SNAPSHOT_PROJECTORS = [ @@ -226,6 +239,25 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listLatestTurnRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionLatestTurnDbRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId", + state, + requested_at AS "requestedAt", + started_at AS "startedAt", + completed_at AS "completedAt", + assistant_message_id AS "assistantMessageId" + FROM projection_turns + WHERE turn_id IS NOT NULL + ORDER BY thread_id ASC, requested_at DESC, turn_id DESC + `, + }); + const listProjectionStateRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionStateDbRowSchema, @@ -250,6 +282,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { activityRows, sessionRows, checkpointRows, + latestTurnRows, stateRows, ] = yield* Effect.all([ listProjectRows(undefined).pipe( @@ -300,6 +333,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listLatestTurnRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query", + "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows", + ), + ), + ), listProjectionStateRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -314,6 +355,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const activitiesByThread = new Map>(); const checkpointsByThread = new Map>(); const sessionsByThread = new Map(); + const latestTurnByThread = new Map(); let updatedAt: string | null = null; @@ -372,6 +414,34 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { checkpointsByThread.set(row.threadId, threadCheckpoints); } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + if (latestTurnByThread.has(row.threadId)) { + continue; + } + latestTurnByThread.set(row.threadId, { + turnId: row.turnId, + state: + row.state === "error" + ? "error" + : row.state === "interrupted" + ? "interrupted" + : row.state === "completed" + ? "completed" + : "running", + requestedAt: row.requestedAt, + startedAt: row.startedAt, + completedAt: row.completedAt, + assistantMessageId: row.assistantMessageId, + }); + } + for (const row of sessionRows) { updatedAt = maxIso(updatedAt, row.updatedAt); sessionsByThread.set(row.threadId, { @@ -406,7 +476,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { model: row.model, branch: row.branch, worktreePath: row.worktreePath, - latestTurnId: row.latestTurnId, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, deletedAt: row.deletedAt, diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9ad953d5b7fb341a831c67e709e6967e585ea475..af64eba008ff4c0ca2e10ef483c2273f3d44d084 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -54,7 +54,7 @@ const readModel: OrchestrationReadModel = { worktreePath: null, createdAt: now, updatedAt: now, - latestTurnId: null, + latestTurn: null, messages: [], session: null, activities: [], @@ -70,7 +70,7 @@ const readModel: OrchestrationReadModel = { worktreePath: null, createdAt: now, updatedAt: now, - latestTurnId: null, + latestTurn: null, messages: [], session: null, activities: [], diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 56c0c0bed92a2e6a3fe3c1d5f21c2b9c0c85684b..094290d90f3cf30f77bb256f3557cf3dad15118d 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -75,7 +75,7 @@ describe("orchestration projector", () => { model: "gpt-5-codex", branch: null, worktreePath: null, - latestTurnId: null, + latestTurn: null, createdAt: now, updatedAt: now, deletedAt: null, @@ -207,7 +207,7 @@ describe("orchestration projector", () => { ); const thread = afterRunning.threads[0]; - expect(thread?.latestTurnId).toBe("turn-1"); + expect(thread?.latestTurn?.turnId).toBe("turn-1"); expect(thread?.session?.status).toBe("running"); }); @@ -504,7 +504,7 @@ describe("orchestration projector", () => { thread?.activities.map((activity) => ({ id: activity.id, turnId: activity.turnId })), ).toEqual([{ id: "activity-1", turnId: "turn-1" }]); expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]); - expect(thread?.latestTurnId).toBe("turn-1"); + expect(thread?.latestTurn?.turnId).toBe("turn-1"); }); it("does not fallback-retain messages tied to removed turn IDs", async () => { diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 6588505d02b71a1cb2b97ee0052dfe1593943aee..f92f7172a4cb88d2073cb733b382f44d3f6d5480 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -26,6 +26,12 @@ type ThreadPatch = Partial>; const MAX_THREAD_MESSAGES = 2_000; const MAX_THREAD_CHECKPOINTS = 500; +function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { + if (status === "error") return "error" as const; + if (status === "missing") return "interrupted" as const; + return "completed" as const; +} + function updateThread( threads: ReadonlyArray, threadId: ThreadId, @@ -220,7 +226,7 @@ export function projectEvent( model: payload.model, branch: payload.branch, worktreePath: payload.worktreePath, - latestTurnId: null, + latestTurn: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, deletedAt: null, @@ -351,7 +357,26 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { session, - latestTurnId: session.activeTurnId, + latestTurn: + session.status === "running" && session.activeTurnId !== null + ? { + turnId: session.activeTurnId, + state: "running", + requestedAt: + thread.latestTurn?.turnId === session.activeTurnId + ? thread.latestTurn.requestedAt + : session.updatedAt, + startedAt: + thread.latestTurn?.turnId === session.activeTurnId + ? (thread.latestTurn.startedAt ?? session.updatedAt) + : session.updatedAt, + completedAt: null, + assistantMessageId: + thread.latestTurn?.turnId === session.activeTurnId + ? thread.latestTurn.assistantMessageId + : null, + } + : thread.latestTurn, updatedAt: event.occurredAt, }), }; @@ -396,7 +421,20 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { checkpoints, - latestTurnId: payload.turnId, + latestTurn: { + turnId: payload.turnId, + state: checkpointStatusToLatestTurnState(payload.status), + requestedAt: + thread.latestTurn?.turnId === payload.turnId + ? thread.latestTurn.requestedAt + : payload.completedAt, + startedAt: + thread.latestTurn?.turnId === payload.turnId + ? (thread.latestTurn.startedAt ?? payload.completedAt) + : payload.completedAt, + completedAt: payload.completedAt, + assistantMessageId: payload.assistantMessageId, + }, updatedAt: event.occurredAt, }), }; @@ -422,8 +460,18 @@ export function projectEvent( ).slice(-MAX_THREAD_MESSAGES); const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds); - const latestTurnId = - checkpoints.length > 0 ? (checkpoints[checkpoints.length - 1]?.turnId ?? null) : null; + const latestCheckpoint = checkpoints.at(-1) ?? null; + const latestTurn = + latestCheckpoint === null + ? null + : { + turnId: latestCheckpoint.turnId, + state: checkpointStatusToLatestTurnState(latestCheckpoint.status), + requestedAt: latestCheckpoint.completedAt, + startedAt: latestCheckpoint.completedAt, + completedAt: latestCheckpoint.completedAt, + assistantMessageId: latestCheckpoint.assistantMessageId, + }; return { ...nextBase, @@ -431,7 +479,7 @@ export function projectEvent( checkpoints, messages, activities, - latestTurnId, + latestTurn, updatedAt: event.occurredAt, }), }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1456a07dd0033df31655ee3a3d3fc29a24413f08..2e1404f058e32e5b3ef51840dd282f7d102ca355 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -43,7 +43,7 @@ import { deriveTimelineEntries, type PendingApproval, deriveWorkLogEntries, - formatDuration, + hasToolActivityForTurn, formatElapsed, formatTimestamp, } from "../session-logic"; @@ -381,12 +381,13 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const diffOpen = diffSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; + const activeLatestTurn = activeThread?.latestTurn ?? null; const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); useEffect(() => { if (!activeThread?.id) return; - if (!activeThread.latestTurnCompletedAt) return; - const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); + if (!activeLatestTurn?.completedAt) return; + const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); if (Number.isNaN(turnCompletedAt)) return; const lastVisitedAt = activeThread.lastVisitedAt ? Date.parse(activeThread.lastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; @@ -398,7 +399,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [ activeThread?.id, activeThread?.lastVisitedAt, - activeThread?.latestTurnCompletedAt, + activeLatestTurn?.completedAt, dispatch, ]); @@ -412,18 +413,12 @@ export default function ChatView({ threadId }: ChatViewProps) { const nowIso = new Date(nowTick).toISOString(); const threadActivities = activeThread?.activities ?? []; const workLogEntries = useMemo( - () => deriveWorkLogEntries(threadActivities, undefined), - [threadActivities], + () => deriveWorkLogEntries(threadActivities, activeLatestTurn?.turnId ?? undefined), + [activeLatestTurn?.turnId, threadActivities], ); const latestTurnHasToolActivity = useMemo(() => { - const latestTurnId = activeThread?.latestTurnId; - if (!latestTurnId) { - return false; - } - return threadActivities.some( - (activity) => activity.turnId === latestTurnId && activity.tone === "tool", - ); - }, [activeThread?.latestTurnId, threadActivities]); + return hasToolActivityForTurn(threadActivities, activeLatestTurn?.turnId); + }, [activeLatestTurn?.turnId, threadActivities]); const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), [threadActivities], @@ -488,36 +483,24 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); const completionSummary = useMemo(() => { - if (!activeThread?.latestTurnStartedAt) return null; - if (!activeThread.latestTurnCompletedAt) return null; + if (!activeLatestTurn?.startedAt) return null; + if (!activeLatestTurn.completedAt) return null; if (!latestTurnHasToolActivity) return null; - if ( - typeof activeThread.latestTurnDurationMs === "number" && - Number.isFinite(activeThread.latestTurnDurationMs) && - activeThread.latestTurnDurationMs >= 0 - ) { - return `Worked for ${formatDuration(activeThread.latestTurnDurationMs)}`; - } - - const elapsed = formatElapsed( - activeThread.latestTurnStartedAt, - activeThread.latestTurnCompletedAt, - ); + const elapsed = formatElapsed(activeLatestTurn.startedAt, activeLatestTurn.completedAt); return elapsed ? `Worked for ${elapsed}` : null; }, [ - activeThread?.latestTurnStartedAt, - activeThread?.latestTurnCompletedAt, - activeThread?.latestTurnDurationMs, + activeLatestTurn?.completedAt, + activeLatestTurn?.startedAt, latestTurnHasToolActivity, ]); const completionDividerBeforeEntryId = useMemo(() => { - if (!activeThread?.latestTurnStartedAt) return null; - if (!activeThread.latestTurnCompletedAt) return null; + if (!activeLatestTurn?.startedAt) return null; + if (!activeLatestTurn.completedAt) return null; if (!completionSummary) return null; - const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt); - const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); + const turnStartedAt = Date.parse(activeLatestTurn.startedAt); + const turnCompletedAt = Date.parse(activeLatestTurn.completedAt); if (Number.isNaN(turnStartedAt)) return null; if (Number.isNaN(turnCompletedAt)) return null; @@ -535,8 +518,8 @@ export default function ChatView({ threadId }: ChatViewProps) { } return inRangeMatch ?? fallbackMatch; }, [ - activeThread?.latestTurnCompletedAt, - activeThread?.latestTurnStartedAt, + activeLatestTurn?.completedAt, + activeLatestTurn?.startedAt, completionSummary, timelineEntries, ]); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0f05fd689ce526a2fa204791af1421cd4bdcbd36..b348836e17545ea397d0c1c397c6d54a773d7582 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -63,8 +63,8 @@ interface TerminalStatusIndicator { } function hasUnseenCompletion(thread: Thread): boolean { - if (!thread.latestTurnCompletedAt) return false; - const completedAt = Date.parse(thread.latestTurnCompletedAt); + if (!thread.latestTurn?.completedAt) return false; + const completedAt = Date.parse(thread.latestTurn.completedAt); if (Number.isNaN(completedAt)) return false; if (!thread.lastVisitedAt) return true; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 3377268d2a35783e49d5a38b4fd1a4165f0366e8..cafaea5f91dcbbb512ea11d069499678d9c77b9f 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1,7 +1,11 @@ import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { derivePendingApprovals, deriveWorkLogEntries } from "./session-logic"; +import { + derivePendingApprovals, + deriveWorkLogEntries, + hasToolActivityForTurn, +} from "./session-logic"; function makeActivity(overrides: { id?: string; @@ -126,3 +130,24 @@ describe("deriveWorkLogEntries", () => { expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); }); }); + +describe("hasToolActivityForTurn", () => { + it("returns false when turn id is missing", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ id: "tool-1", turnId: "turn-1", kind: "tool.completed", tone: "tool" }), + ]; + + expect(hasToolActivityForTurn(activities, undefined)).toBe(false); + expect(hasToolActivityForTurn(activities, null)).toBe(false); + }); + + it("returns true only for matching tool activity in the target turn", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ id: "tool-1", turnId: "turn-1", kind: "tool.completed", tone: "tool" }), + makeActivity({ id: "info-1", turnId: "turn-2", kind: "turn.completed", tone: "info" }), + ]; + + expect(hasToolActivityForTurn(activities, TurnId.makeUnsafe("turn-1"))).toBe(true); + expect(hasToolActivityForTurn(activities, TurnId.makeUnsafe("turn-2"))).toBe(false); + }); +}); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 114c2272e8ded1362ae5fb79f68c36ef8c20d310..4a4f6025145ddcfd3cd0059bf2b05c485dd30f62 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -147,6 +147,14 @@ export function deriveWorkLogEntries( }); } +export function hasToolActivityForTurn( + activities: ReadonlyArray, + turnId: TurnId | null | undefined, +): boolean { + if (!turnId) return false; + return activities.some((activity) => activity.turnId === turnId && activity.tone === "tool"); +} + export function deriveTimelineEntries( messages: ChatMessage[], workEntries: WorkLogEntry[], diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 4a3a73a3c00437ccfd1707c4227c466c282eacb6..13b86329599e0752fa28b541e51efb3399808f28 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,4 +1,4 @@ -import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { reducer, type AppState } from "./store"; @@ -29,6 +29,7 @@ function makeThread(overrides: Partial = {}): Thread { activities: [], error: null, createdAt: "2026-02-13T00:00:00.000Z", + latestTurn: null, branch: null, worktreePath: null, ...overrides, @@ -58,7 +59,14 @@ describe("store reducer", () => { const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z"; const initialState = makeState( makeThread({ - latestTurnCompletedAt, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-1"), + state: "completed", + requestedAt: "2026-02-25T12:28:00.000Z", + startedAt: "2026-02-25T12:28:30.000Z", + completedAt: latestTurnCompletedAt, + assistantMessageId: null, + }, lastVisitedAt: "2026-02-25T12:35:00.000Z", }), ); @@ -79,7 +87,7 @@ describe("store reducer", () => { it("does not change a thread without a completed turn", () => { const initialState = makeState( makeThread({ - latestTurnCompletedAt: undefined, + latestTurn: null, lastVisitedAt: "2026-02-25T12:35:00.000Z", }), ); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 8a9af566a4f17e86fe547d38eba1e9eb89094c25..bce86d0e66aba76bf8b7bab24bc605f084cedd0a 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -414,19 +414,6 @@ export function reducer(state: AppState, action: Action): AppState { .filter((thread) => thread.deletedAt === null) .map((thread) => { const existing = existingThreadById.get(thread.id); - const latestTurnStartedAt = - thread.latestTurnId === null - ? undefined - : thread.messages.find( - (message) => message.turnId === thread.latestTurnId && message.role === "user", - )?.createdAt; - const latestTurnCompletedAt = thread.checkpoints.find( - (checkpoint) => checkpoint.turnId === thread.latestTurnId, - )?.completedAt; - const latestTurnDurationMs = - latestTurnStartedAt && latestTurnCompletedAt - ? Math.max(0, Date.parse(latestTurnCompletedAt) - Date.parse(latestTurnStartedAt)) - : undefined; return normalizeThreadTerminals({ id: thread.id, @@ -484,10 +471,7 @@ export function reducer(state: AppState, action: Action): AppState { }), error: thread.session?.lastError ?? null, createdAt: thread.createdAt, - latestTurnId: thread.latestTurnId ?? undefined, - latestTurnStartedAt, - latestTurnCompletedAt, - latestTurnDurationMs, + latestTurn: thread.latestTurn, lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, branch: thread.branch, worktreePath: thread.worktreePath, @@ -537,10 +521,10 @@ export function reducer(state: AppState, action: Action): AppState { return { ...state, threads: updateThread(state.threads, action.threadId, (thread) => { - if (!thread.latestTurnCompletedAt) { + if (!thread.latestTurn?.completedAt) { return thread; } - const latestTurnCompletedAtMs = Date.parse(thread.latestTurnCompletedAt); + const latestTurnCompletedAtMs = Date.parse(thread.latestTurn.completedAt); if (Number.isNaN(latestTurnCompletedAtMs)) { return thread; } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 3eca157f091bc5359b0e88ed3f38dcc707dcef3d..0ae07e20c4164478adef5ae1266109b3427a6555 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,4 +1,5 @@ import type { + OrchestrationLatestTurn, OrchestrationSessionStatus, OrchestrationThreadActivity, ProjectScript as ContractProjectScript, @@ -89,10 +90,7 @@ export interface Thread { messages: ChatMessage[]; error: string | null; createdAt: string; - latestTurnId?: TurnId | undefined; - latestTurnStartedAt?: string | undefined; - latestTurnCompletedAt?: string | undefined; - latestTurnDurationMs?: number | undefined; + latestTurn: OrchestrationLatestTurn | null; lastVisitedAt?: string | undefined; branch: string | null; worktreePath: string | null; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index b6f9711bf7ee443bf53e556a5f6b22f845d442af..2d997f2f8dd34a64c98eec5e9b4fbbb8fbccdfac 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -29,6 +29,7 @@ function makeThread(overrides: Partial = {}): Thread { activities: [], error: null, createdAt: "2026-02-13T00:00:00.000Z", + latestTurn: null, branch: null, worktreePath: null, ...overrides, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 06fcbfec3ea10e0ab700ca77842151a9c4f08aea..1de117eb66b56d7c8c3f86f09393050e4ddee8fc 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -191,6 +191,24 @@ export const OrchestrationThreadActivity = Schema.Struct({ }); export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Type; +export const OrchestrationLatestTurnState = Schema.Literals([ + "running", + "interrupted", + "completed", + "error", +]); +export type OrchestrationLatestTurnState = typeof OrchestrationLatestTurnState.Type; + +export const OrchestrationLatestTurn = Schema.Struct({ + turnId: TurnId, + state: OrchestrationLatestTurnState, + requestedAt: IsoDateTime, + startedAt: Schema.NullOr(IsoDateTime), + completedAt: Schema.NullOr(IsoDateTime), + assistantMessageId: Schema.NullOr(MessageId), +}); +export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; + export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, @@ -198,7 +216,7 @@ export const OrchestrationThread = Schema.Struct({ model: TrimmedNonEmptyString, branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), - latestTurnId: Schema.NullOr(TurnId), + latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, deletedAt: Schema.NullOr(IsoDateTime),