authorclover caruso <git@paperclover.net> 2026-10-14 14:12:29-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
log59d067bf9b4ea9cc94d4aeafeeba665cc3b06600
treef1679a7463fb271d2e52d9dcb889dd0e641952ba
parent3bc314d088f34759f6ecac21eda3767529f5466c
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

fix: summarize compaction and queue follow-ups until it finishes

Clover wants context compaction to use the turn status line and never reject a follow-up. Show Compacting for its own elapsed duration, then retain context compacted in live and finished summaries without another full-width divider. Keep mid-turn compactions and failed or stopped attempts visible. Queue steer and restart requests while manual or automatic compaction is pending or running. Preserve attachments, context, model options, and command idempotency; attempts to promote the queued message to steering leave it queued until compaction finishes. Apply this at the shared orchestrator so every client and provider uses the same durable behavior. Use recorded completion times for historical summaries, avoid empty disclosures for compaction-only work, and announce stable status labels without reading every timer tick. Assisted-by: gpt-6.1-sol

9 files changed, 675 insertions(+), 43 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+1-1
......@@ -1668,7 +1668,7 @@ describe("retained v2 feed presentation", () => {
16681668 ? "Compacting context"
16691669 : status === "completed"
16701670 ? "Context compacted 899K → 19K tokens"
1671 : "Context compacted",
1671 : "Context compaction stopped",
16721672 },
16731673 ],
16741674 });
apps/server/src/orchestration-v2/Orchestrator.ts+52-10
......@@ -3832,6 +3832,40 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
38323832 });
38333833 });
38343834
3835 const isRunCompacting = (
3836 projection: Pick<OrchestrationV2ThreadProjection, "thread" | "runs" | "messages">,
3837 targetRunId: RunId,
3838 ) =>
3839 Effect.gen(function* () {
3840 const run = projection.runs.find((candidate) => candidate.id === targetRunId);
3841 if (run === undefined || !isBlockingRun(run)) return false;
3842
3843 const message = projection.messages.find((candidate) => candidate.id === run.userMessageId);
3844 if (
3845 message !== undefined &&
3846 message.attachments.length === 0 &&
3847 message.text.trim().toLowerCase() === "/compact"
3848 ) {
3849 return true;
3850 }
3851
3852 const records = yield* projectionStore
3853 .getThreadRecords(projection.thread.id, ["turnItems"], {
3854 turnItemTypes: ["compaction"],
3855 turnItemRunId: run.id,
3856 })
3857 .pipe(
3858 Effect.mapError(
3859 (cause) => new OrchestratorProjectionError({ threadId: projection.thread.id, cause }),
3860 ),
3861 );
3862 return records.turnItems.some(
3863 (item) =>
3864 item.nodeId === run.rootNodeId &&
3865 (item.status === "pending" || item.status === "running"),
3866 );
3867 });
3868
38353869 const dispatchSteerIntoRun = (input: {
38363870 readonly command: Extract<
38373871 OrchestrationV2Command,
......@@ -3897,14 +3931,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
38973931 const targetMessage = input.projection.messages.find(
38983932 (message) => message.id === targetRun.userMessageId,
38993933 );
3900 if (targetMessage !== undefined && isNativeMaintenanceCommand(targetMessage)) {
3934 if (
3935 targetMessage !== undefined &&
3936 targetMessage.attachments.length === 0 &&
3937 targetMessage.text.trim().toLowerCase() === "/logout"
3938 ) {
39013939 return yield* new OrchestratorDispatchError({
39023940 commandId: input.command.commandId,
39033941 commandType: input.command.type,
3904 cause:
3905 targetMessage.text.trim().toLowerCase() === "/compact"
3906 ? "Wait for context compaction to finish before steering the thread."
3907 : "Wait for sign-out to finish before steering the thread.",
3942 cause: "Wait for sign-out to finish before steering the thread.",
39083943 });
39093944 }
39103945 const rootNodeId = targetRun.rootNodeId;
......@@ -4925,6 +4960,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
49254960 payload: { ...sourcePlan, status: "completed" },
49264961 });
49274962
4963 if (
4964 (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") &&
4965 (yield* isRunCompacting(projection, dispatchMode.targetRunId))
4966 ) {
4967 dispatchMode = { type: "queue_after_active" };
4968 }
4969
49284970 if (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") {
49294971 yield* dispatchSteerIntoRun({
49304972 command,
......@@ -7695,6 +7737,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
76957737 });
76967738 }
76977739
7740 if (yield* isRunCompacting(projection, command.targetRunId)) return;
7741
76987742 const now = yield* DateTime.now;
76997743 const emitEvent = emit(events, command);
77007744 yield* emitEvent({
......@@ -11164,12 +11208,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
1116411208
1116511209 const plan = yield* dispatchOnce(command).pipe(
1116611210 Effect.flatMap((planned) =>
11167 // A settle that finds the provider already ended everything, or a
11168 // stop that finds nothing running, has nothing to record. That is
11169 // its expected outcome, not a failure.
11211 // These commands can succeed without changing the current state.
1117011212 planned.events.length > 0 ||
1117111213 command.type === "thread.background-work.settle" ||
1117211214 command.type === "thread.stop" ||
11215 command.type === "queued-message.promote-to-steer" ||
1117311216 command.type === "subagent.stop"
1117411217 ? Effect.succeed(planned)
1117511218 : Effect.fail(
......@@ -11221,8 +11264,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
1122111264 );
1122211265
1122311266 if (plan.events.length === 0) {
11224 // A settle that ended nothing still records its receipt: a replayed Stop
11225 // effect then finds it instead of settling work that appeared since.
11267 // A no-op consumes its command ID so a replay cannot change newer work.
1122611268 const resultSequence = yield* Effect.gen(function* () {
1122711269 const sequence = yield* eventSink.latestSequence({ threadId: commandThreadId(command) });
1122811270 yield* commandReceipts.insertIfAbsent({
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+198-6
......@@ -16,9 +16,11 @@ import {
1616 type ChatAttachment,
1717 CommandId,
1818 EnvironmentId,
19 EventId,
1920 DEFAULT_SERVER_SETTINGS,
2021 GitCommandError,
2122 MessageId,
23 NodeId,
2224 ProjectId,
2325 ProviderDriverKind,
2426 ProviderInstanceId,
......@@ -26,6 +28,7 @@ import {
2628 ScheduledTaskId,
2729 type ServerProvider,
2830 ThreadId,
31 TurnItemId,
2932} from "@t3tools/contracts";
3033import * as Cause from "effect/Cause";
3134import * as Deferred from "effect/Deferred";
......@@ -54,6 +57,7 @@ import * as TextGeneration from "../textGeneration/TextGeneration.ts";
5457import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
5558import * as CommandReceiptStore from "./CommandReceiptStore.ts";
5659import * as EffectOutbox from "./EffectOutbox.ts";
60import * as EventSink from "./EventSink.ts";
5761import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
5862import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
5963import * as ThreadLaunch from "./ThreadLaunchService.ts";
......@@ -248,6 +252,7 @@ function makeHarness(options: HarnessOptions = {}) {
248252 layerTitleRegeneration,
249253 layerOutbox,
250254 layerDatabase,
255 layerOrchestrator,
251256 layerExternalServices,
252257 ),
253258 createWorktree,
......@@ -737,12 +742,6 @@ it.effect("keeps native maintenance commands out of steering and restart message
737742 next: "Continue with the parser",
738743 mode: "steer_active",
739744 },
740 {
741 name: "steer-compaction",
742 first: "/compact",
743 next: "Continue with the parser",
744 mode: "steer_active",
745 },
746745 ] as const) {
747746 const launched = yield* launches.launch(
748747 launchInput({
......@@ -781,6 +780,199 @@ it.effect("keeps native maintenance commands out of steering and restart message
781780 }),
782781);
783782
783it.effect.each([
784 ...(["manual", "automatic", "pending"] as const).flatMap((compaction) =>
785 (["steer", "restart", "auto", "targeted-steer", "targeted-restart"] as const).map((intent) => ({
786 compaction,
787 intent,
788 })),
789 ),
790 { compaction: "completed", intent: "steer" },
791 { compaction: "child", intent: "steer" },
792] as const)(
793 "resolves $intent during $compaction compaction without losing the submission",
794 ({ compaction, intent }) =>
795 Effect.gen(function* () {
796 const harness = makeHarness();
797 yield* Effect.gen(function* () {
798 const launches = yield* ThreadLaunch.ThreadLaunchService;
799 const threads = yield* ThreadManagement.ThreadManagementService;
800 const sink = yield* EventSink.EventSinkV2;
801 const outbox = yield* EffectOutbox.EffectOutboxV2;
802 const launched = yield* launches.launch(
803 launchInput({
804 command: "compaction-queue:launch",
805 thread: "compaction-queue",
806 message: compaction === "manual" ? " /COMPACT " : "Fix the parser",
807 }),
808 );
809 const before = yield* threads.getThreadProjection(launched.threadId);
810 const active = before.runs[0]!;
811 const now = yield* DateTime.now;
812 yield* sink.write({
813 events: [
814 {
815 id: EventId.make("compaction-queue:running"),
816 type: "run.updated",
817 threadId: launched.threadId,
818 occurredAt: now,
819 payload: { ...active, status: "running", startedAt: now },
820 },
821 ...(compaction === "manual"
822 ? []
823 : [
824 {
825 id: EventId.make("compaction-queue:compacting"),
826 type: "turn-item.updated" as const,
827 threadId: launched.threadId,
828 occurredAt: now,
829 payload: {
830 id: TurnItemId.make("compaction-queue:item"),
831 type: "compaction" as const,
832 threadId: launched.threadId,
833 runId: active.id,
834 nodeId:
835 compaction === "child"
836 ? NodeId.make("compaction-child")
837 : active.rootNodeId,
838 providerThreadId: active.providerThreadId,
839 providerTurnId: null,
840 nativeItemRef: null,
841 parentItemId: null,
842 ordinal: 2,
843 status:
844 compaction === "completed"
845 ? ("completed" as const)
846 : compaction === "pending"
847 ? ("pending" as const)
848 : ("running" as const),
849 title: null,
850 driver: ProviderDriverKind.make("codex"),
851 startedAt: now,
852 completedAt: compaction === "completed" ? now : null,
853 updatedAt: now,
854 },
855 },
856 ]),
857 ],
858 });
859 const attachments: ChatAttachment[] = [
860 {
861 type: "image",
862 id: ChatAttachmentId.make("compaction-queue-image"),
863 name: "parser.png",
864 mimeType: "image/png",
865 sizeBytes: 42,
866 },
867 ];
868 const command = {
869 type: "message.dispatch" as const,
870 commandId: CommandId.make("compaction-queue:follow-up"),
871 threadId: launched.threadId,
872 messageId: MessageId.make("compaction-queue:follow-up"),
873 text: "Continue with the parser [image](t3-context://v1/file/parser)",
874 context: {
875 version: 1 as const,
876 records: [
877 {
878 version: 1 as const,
879 kind: "file" as const,
880 contextId: ComposerContextId.make("parser"),
881 label: "Parser screenshot",
882 attachmentId: attachments[0]!.id,
883 name: "parser.png",
884 mimeType: "image/png",
885 sizeBytes: 42,
886 },
887 ],
888 },
889 attachments,
890 modelSelection: { ...modelSelection, model: "gpt-5.2-codex" },
891 createdBy: "user" as const,
892 creationSource: "web" as const,
893 dispatchMode: {
894 type:
895 intent === "targeted-restart"
896 ? ("restart_active" as const)
897 : ("steer_active" as const),
898 targetRunId: active.id,
899 },
900 ...(intent === "targeted-steer" || intent === "targeted-restart"
901 ? {}
902 : { deliveryIntent: intent }),
903 };
904 if (compaction === "completed" || compaction === "child") {
905 const failure = yield* threads.dispatch(command).pipe(Effect.flip);
906 assert.include(String(failure.cause), "No running provider turn found");
907 assert.deepEqual(
908 (yield* threads.getThreadProjection(launched.threadId)).messages,
909 before.messages,
910 );
911 return;
912 }
913 const accepted = yield* threads.dispatch(command);
914 const retried = yield* threads.dispatch(command);
915 assert.equal(retried.sequence, accepted.sequence);
916 assert.deepEqual(retried.storedEvents, accepted.storedEvents);
917
918 const queued = yield* threads.getThreadProjection(launched.threadId);
919 assert.equal(queued.runs.length, 2);
920 assert.equal(queued.runs[0]?.status, "running");
921 const queuedRun = queued.runs[1]!;
922 assert.equal(queuedRun.status, "queued");
923 assert.deepEqual(queuedRun.modelSelection, command.modelSelection);
924 const message = queued.messages.find((candidate) => candidate.id === command.messageId)!;
925 assert.equal(message.text, command.text);
926 assert.deepEqual(message.attachments, command.attachments);
927 assert.deepEqual(message.context, command.context);
928 assert.equal(message.createdBy, command.createdBy);
929 assert.equal(message.creationSource, command.creationSource);
930 assert.equal(message.runId, queuedRun.id);
931 assert.isFalse(
932 (yield* outbox.listByCommandId(command.commandId)).some((effect) =>
933 effect.request.type.startsWith("provider-turn."),
934 ),
935 );
936
937 const promote = {
938 type: "queued-message.promote-to-steer" as const,
939 commandId: CommandId.make("compaction-queue:promote"),
940 threadId: launched.threadId,
941 queuedRunId: queuedRun.id,
942 targetRunId: active.id,
943 };
944 assert.deepEqual((yield* threads.dispatch(promote)).storedEvents, []);
945 assert.deepEqual((yield* threads.dispatch(promote)).storedEvents, []);
946 const afterPromotion = yield* threads.getThreadProjection(launched.threadId);
947 assert.deepEqual(afterPromotion.runs, queued.runs);
948 assert.deepEqual(afterPromotion.messages, queued.messages);
949
950 yield* sink.write({
951 events: [
952 {
953 id: EventId.make("compaction-queue:completed"),
954 type: "run.updated",
955 threadId: launched.threadId,
956 occurredAt: now,
957 payload: { ...active, status: "completed", completedAt: now },
958 },
959 ],
960 });
961 yield* threads.dispatch({
962 type: "queue.resume",
963 commandId: CommandId.make("compaction-queue:resume"),
964 threadId: launched.threadId,
965 });
966 const resumed = yield* threads.getThreadProjection(launched.threadId);
967 assert.equal(resumed.runs.find((run) => run.id === queuedRun.id)?.status, "starting");
968 assert.equal(
969 resumed.messages.filter((candidate) => candidate.id === command.messageId).length,
970 1,
971 );
972 }).pipe(Effect.provide(harness.layer));
973 }),
974);
975
784976it.effect("arms durable title generation after accepting the first message", () =>
785977 Effect.gen(function* () {
786978 const harness = makeHarness({
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+196-1
......@@ -1093,7 +1093,7 @@ describe("deriveMessagesTimelineRows", () => {
10931093 ]);
10941094 });
10951095
1096 it("gives live compaction the activity slot and restores Thinking when it completes", () => {
1096 it("keeps mid-turn compaction in the activity slot and restores Thinking when it completes", () => {
10971097 const fixture = makeStreamingTimelineFixture();
10981098 const last = fixture.visibleTurnItems.at(-1)!;
10991099 if (last.item.type !== "assistant_message") throw new Error("Expected assistant fixture");
......@@ -1156,6 +1156,201 @@ describe("deriveMessagesTimelineRows", () => {
11561156 expect(computeStableMessagesTimelineRows(settledRows, stable).result).toEqual(settledRows);
11571157 });
11581158
1159 it("uses the initial compaction's own clock in the turn header, then remembers completion", () => {
1160 const fixture = makeStreamingTimelineFixture();
1161 const source = fixture.visibleTurnItems.at(-1)!;
1162 if (source.item.type !== "assistant_message") throw new Error("Expected assistant fixture");
1163 const compaction = {
1164 ...source,
1165 item: {
1166 ...source.item,
1167 type: "compaction" as const,
1168 driver: null,
1169 status: "running" as const,
1170 startedAt: DateTime.makeUnsafe(fixture.time(9)),
1171 completedAt: null,
1172 },
1173 };
1174 const entries = (status: "running" | "completed") =>
1175 deriveTimelineEntriesFromVisibleTurnItems({
1176 visibleTurnItems: [
1177 ...fixture.visibleTurnItems.slice(0, 4),
1178 { ...compaction, item: { ...compaction.item, status } },
1179 ],
1180 optimisticMessages: [],
1181 });
1182 const input = {
1183 isWorking: true,
1184 runningRunId: fixture.runId,
1185 activeTurnStartedAt: fixture.time(5),
1186 turnDiffSummaries: [],
1187 supportsConversationRollback: false,
1188 };
1189 const runningRows = deriveMessagesTimelineRows({
1190 ...input,
1191 timelineEntries: entries("running"),
1192 });
1193 expect(runningRows.find((row) => row.kind === "working")).toMatchObject({
1194 createdAt: fixture.time(5),
1195 compaction: { createdAt: fixture.time(9), toolLifecycleStatus: "inProgress" },
1196 });
1197 expect(runningRows.some((row) => row.kind === "context-compaction")).toBe(false);
1198 expect(runningRows.some((row) => row.kind === "thinking")).toBe(false);
1199
1200 const completedRows = deriveMessagesTimelineRows({
1201 ...input,
1202 timelineEntries: entries("completed"),
1203 });
1204 expect(completedRows.find((row) => row.kind === "working")).toMatchObject({
1205 createdAt: fixture.time(5),
1206 compaction: { toolLifecycleStatus: "completed" },
1207 });
1208 expect(completedRows.some((row) => row.kind === "context-compaction")).toBe(false);
1209 expect(completedRows.at(-1)?.kind).toBe("thinking");
1210 const stable = computeStableMessagesTimelineRows(runningRows, { result: [], byId: new Map() });
1211 expect(computeStableMessagesTimelineRows(completedRows, stable).result).toEqual(completedRows);
1212 });
1213
1214 it.each([false, true])(
1215 "keeps initial compaction in the completed turn summary when expanded=%s",
1216 (expanded) => {
1217 const fixture = makeStreamingTimelineFixture("Done");
1218 const source = fixture.visibleTurnItems.at(-1)!;
1219 if (source.item.type !== "assistant_message") throw new Error("Expected assistant fixture");
1220 const compaction = {
1221 ...source,
1222 sourceItemId: TurnItemId.make("initial-compaction"),
1223 item: {
1224 ...source.item,
1225 id: TurnItemId.make("initial-compaction"),
1226 type: "compaction" as const,
1227 driver: null,
1228 status: "completed" as const,
1229 startedAt: DateTime.makeUnsafe(fixture.time(6)),
1230 completedAt: DateTime.makeUnsafe(fixture.time(7)),
1231 },
1232 };
1233 const rows = deriveMessagesTimelineRows({
1234 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
1235 visibleTurnItems: [
1236 ...fixture.visibleTurnItems.slice(0, 4),
1237 compaction,
1238 { ...source, item: { ...source.item, streaming: false, status: "completed" } },
1239 ],
1240 optimisticMessages: [],
1241 }),
1242 isWorking: false,
1243 latestRun: {
1244 runId: fixture.runId,
1245 status: "completed",
1246 startedAt: fixture.time(5),
1247 completedAt: fixture.time(27),
1248 },
1249 expandedRunIds: expanded ? new Set([fixture.runId]) : new Set(),
1250 turnDiffSummaries: [],
1251 supportsConversationRollback: false,
1252 });
1253 expect(
1254 rows.find((row) => row.kind === "turn-fold" && row.runId === fixture.runId),
1255 ).toMatchObject({
1256 label: "Worked for 22s (context compacted)",
1257 expanded,
1258 summaryOnly: true,
1259 });
1260 expect(rows.some((row) => row.kind === "context-compaction")).toBe(false);
1261 expect(rows.some((row) => row.kind === "message" && row.message.text === "Done")).toBe(true);
1262 },
1263 );
1264
1265 it.each(["latest run unavailable", "later run selected"] as const)(
1266 "uses recorded compaction completion for a historical summary when %s",
1267 (latest) => {
1268 const fixture = makeStreamingTimelineFixture();
1269 const source = fixture.visibleTurnItems.at(-1)!;
1270 if (source.item.type !== "assistant_message") throw new Error("Expected assistant fixture");
1271 const compaction = {
1272 ...source,
1273 item: {
1274 ...source.item,
1275 type: "compaction" as const,
1276 driver: null,
1277 runId: fixture.historyRunId,
1278 status: "completed" as const,
1279 startedAt: DateTime.makeUnsafe(fixture.time(1)),
1280 completedAt: DateTime.makeUnsafe(fixture.time(83)),
1281 updatedAt: DateTime.makeUnsafe(fixture.time(83)),
1282 },
1283 };
1284 const rows = deriveMessagesTimelineRows({
1285 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
1286 visibleTurnItems: [fixture.visibleTurnItems[0]!, compaction],
1287 optimisticMessages: [],
1288 }),
1289 isWorking: false,
1290 latestRun:
1291 latest === "later run selected"
1292 ? {
1293 runId: fixture.runId,
1294 status: "completed",
1295 startedAt: fixture.time(100),
1296 completedAt: fixture.time(105),
1297 }
1298 : null,
1299 turnDiffSummaries: [],
1300 supportsConversationRollback: false,
1301 });
1302 expect(rows.find((row) => row.kind === "turn-fold")).toMatchObject({
1303 label: "Worked for 1m 23s (context compacted)",
1304 summaryOnly: true,
1305 });
1306 },
1307 );
1308
1309 it.each(
1310 (["failed", "cancelled", "interrupted"] as const).flatMap((status) =>
1311 [true, false].map((isWorking) => ({ status, isWorking })),
1312 ),
1313 )(
1314 "retains a visible initial compaction result when $status and working=$isWorking",
1315 ({ status, isWorking }) => {
1316 const fixture = makeStreamingTimelineFixture("Done");
1317 const source = fixture.visibleTurnItems.at(-1)!;
1318 if (source.item.type !== "assistant_message") throw new Error("Expected assistant fixture");
1319 const rows = deriveMessagesTimelineRows({
1320 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
1321 visibleTurnItems: [
1322 ...fixture.visibleTurnItems.slice(0, 4),
1323 {
1324 ...source,
1325 sourceItemId: TurnItemId.make("failed-compaction"),
1326 item: {
1327 ...source.item,
1328 id: TurnItemId.make("failed-compaction"),
1329 type: "compaction",
1330 driver: null,
1331 status,
1332 },
1333 },
1334 fixture.visibleTurnItems[4]!,
1335 { ...source, item: { ...source.item, streaming: isWorking } },
1336 ],
1337 optimisticMessages: [],
1338 }),
1339 isWorking,
1340 runningRunId: isWorking ? fixture.runId : null,
1341 turnDiffSummaries: [],
1342 supportsConversationRollback: false,
1343 });
1344 expect(rows.find((row) => row.kind === "context-compaction")).toMatchObject({
1345 active: false,
1346 label: status === "failed" ? "Context compaction failed" : "Context compaction stopped",
1347 });
1348 if (isWorking) {
1349 expect(rows.find((row) => row.kind === "working")).not.toHaveProperty("compaction");
1350 }
1351 },
1352 );
1353
11591354 it("only enables assistant copy for the terminal assistant message in a turn", () => {
11601355 const rows = deriveMessagesTimelineRows({
11611356 timelineEntries: [
apps/web/src/components/chat/MessagesTimeline.logic.ts+91-7
......@@ -550,6 +550,7 @@ type MessagesTimelineRowContent =
550550 kind: "working";
551551 id: string;
552552 createdAt: string | null;
553 compaction?: WorkLogEntry;
553554 }
554555 | {
555556 kind: "thinking";
......@@ -581,6 +582,7 @@ type MessagesTimelineRowContent =
581582 runId: RunId;
582583 label: string;
583584 expanded: boolean;
585 summaryOnly?: true;
584586 }
585587 | {
586588 kind: "attempt-fold";
......@@ -948,6 +950,34 @@ function responseBoundaryRunId(entry: TimelineEntry): RunId | null {
948950 return null;
949951}
950952
953function initialCompactionEntryIds(entries: ReadonlyArray<TimelineEntry>): ReadonlySet<string> {
954 const initial = new Set<string>();
955 const startedRuns = new Set<RunId>();
956 let runlessKey: RunId | null = null;
957
958 for (const entry of entries) {
959 if (timelineEntryStartsResponse(entry)) {
960 runlessKey = RunId.make(`runless:${entry.id}`);
961 continue;
962 }
963 const runId = timelineEntryFoldRunId(entry, runlessKey);
964 if (runId === null || startedRuns.has(runId)) continue;
965 if (
966 entry.kind === "message" &&
967 entry.message.text.trim() === "" &&
968 (entry.message.attachments?.length ?? 0) === 0
969 ) {
970 continue;
971 }
972 if (entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction") {
973 initial.add(entry.id);
974 } else {
975 startedRuns.add(runId);
976 }
977 }
978 return initial;
979}
980
951981/**
952982 * A promptless provider restart replaces the native turn without adding a
953983 * user message. Keep every provider turn since the initiating prompt in one
......@@ -1030,6 +1060,7 @@ function deriveTurnFolds(input: {
10301060 /** Finished replies a steer superseded; they answer their prompt, so they stay out. */
10311061 completeSupersededReplyEntryIds: ReadonlySet<string>;
10321062}): ReadonlyMap<string, TurnFold> {
1063 const initialCompactions = initialCompactionEntryIds(input.timelineEntries);
10331064 const interruptedRunIds = new Set<RunId>();
10341065 const promptIndexByRunId = new Map<RunId, number>();
10351066 for (const [index, entry] of input.timelineEntries.entries()) {
......@@ -1143,6 +1174,13 @@ function deriveTurnFolds(input: {
11431174 }
11441175 const isCompaction =
11451176 entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction";
1177 if (
1178 isCompaction &&
1179 (entry.entry.toolLifecycleStatus === "failed" ||
1180 entry.entry.toolLifecycleStatus === "stopped")
1181 ) {
1182 continue;
1183 }
11461184 const isFoldableTrailingActivity =
11471185 entry.kind === "work" &&
11481186 entry.entry.toolLifecycleStatus !== "inProgress" &&
......@@ -1165,14 +1203,18 @@ function deriveTurnFolds(input: {
11651203 if (hiddenEntryIds.size === 0) {
11661204 continue;
11671205 }
1168 // A lone compaction row stays visible on its own; it only folds away as
1169 // part of a turn that already folds other work.
11701206 const hidesNonCompactionWork = group.entries.some(
11711207 (entry) =>
11721208 hiddenEntryIds.has(entry.id) &&
11731209 !(entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction"),
11741210 );
1175 if (!hidesNonCompactionWork) {
1211 const startsCompacted = group.entries.some(
1212 (entry) =>
1213 initialCompactions.has(entry.id) &&
1214 entry.kind === "work" &&
1215 entry.entry.toolLifecycleStatus === "completed",
1216 );
1217 if (!hidesNonCompactionWork && !startsCompacted) {
11761218 continue;
11771219 }
11781220
......@@ -1186,8 +1228,19 @@ function deriveTurnFolds(input: {
11861228 input.latestRun?.runId === runId && input.latestRun.status === "interrupted";
11871229 // A turn cut short by a steer leaves trailing work entries behind its
11881230 // terminal message — take whichever ended last.
1231 const lastProjectedItem =
1232 lastEntry.kind === "work"
1233 ? lastEntry.entry.projectedItem
1234 : lastEntry.kind === "event"
1235 ? lastEntry.projectedItem
1236 : undefined;
1237 const lastItemCompletedAt = lastProjectedItem?.item.completedAt;
11891238 const lastEntryEnd =
1190 lastEntry.kind === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt;
1239 lastEntry.kind === "message"
1240 ? lastEntry.message.updatedAt
1241 : lastItemCompletedAt
1242 ? DateTime.formatIso(lastItemCompletedAt)
1243 : lastEntry.createdAt;
11911244 const elapsedMs =
11921245 input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt
11931246 ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt)
......@@ -1210,7 +1263,7 @@ function deriveTurnFolds(input: {
12101263 anchorEntryId: group.anchorEntryId,
12111264 createdAt: group.startBoundary ?? firstEntry.createdAt,
12121265 hiddenEntryIds,
1213 label,
1266 label: startsCompacted ? `${label} (context compacted)` : label,
12141267 });
12151268 }
12161269 return foldsByAnchorEntryId;
......@@ -1483,6 +1536,14 @@ export function deriveMessagesTimelineRows(input: {
14831536 input.isWorking &&
14841537 entry.toolLifecycleStatus === "inProgress" &&
14851538 (entry.runId == null ? runlessWorkActive : entry.runId === unsettledRunId);
1539 const initialCompactions = initialCompactionEntryIds(timelineEntries);
1540 const activeInitialCompaction = timelineEntries.findLast(
1541 (entry) =>
1542 initialCompactions.has(entry.id) &&
1543 entry.kind === "work" &&
1544 runIdIsActiveResponse(entry.entry.runId) &&
1545 (entry.entry.toolLifecycleStatus === "completed" || workEntryIsInActiveRun(entry.entry)),
1546 );
14861547
14871548 // A steer continues the current turn. Keep its elapsed-time header below
14881549 // the initiating prompt (or automatic wake), rather than moving it down.
......@@ -1572,6 +1633,9 @@ export function deriveMessagesTimelineRows(input: {
15721633 kind: "working",
15731634 id: "working-indicator-row",
15741635 createdAt: input.activeTurnStartedAt ?? null,
1636 ...(activeInitialCompaction?.kind === "work"
1637 ? { compaction: activeInitialCompaction.entry }
1638 : {}),
15751639 });
15761640 };
15771641 let hasActivityRow = false;
......@@ -1622,6 +1686,9 @@ export function deriveMessagesTimelineRows(input: {
16221686 runId: turnFold.runId,
16231687 label: turnFold.label,
16241688 expanded: input.expandedRunIds?.has(turnFold.runId) ?? false,
1689 ...([...turnFold.hiddenEntryIds].every((id) => initialCompactions.has(id))
1690 ? { summaryOnly: true as const }
1691 : {}),
16251692 });
16261693 }
16271694
......@@ -1656,6 +1723,16 @@ export function deriveMessagesTimelineRows(input: {
16561723 ) {
16571724 const active = workEntryIsInActiveRun(timelineEntry.entry);
16581725 hasActiveCompaction ||= active;
1726 if (
1727 initialCompactions.has(timelineEntry.id) &&
1728 ((input.isWorking && runIdIsActiveResponse(timelineEntry.entry.runId)) ||
1729 [...foldsByAnchorEntryId.values()].some((fold) =>
1730 fold.hiddenEntryIds.has(timelineEntry.id),
1731 )) &&
1732 (active || timelineEntry.entry.toolLifecycleStatus === "completed")
1733 ) {
1734 continue;
1735 }
16591736 nextRows.push({
16601737 kind: "context-compaction",
16611738 id: timelineEntry.id,
......@@ -2248,7 +2325,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
22482325
22492326 switch (a.kind) {
22502327 case "working":
2251 return a.createdAt === (b as typeof a).createdAt;
2328 return (
2329 a.createdAt === (b as typeof a).createdAt && a.compaction === (b as typeof a).compaction
2330 );
22522331 case "thinking": {
22532332 const bt = b as typeof a;
22542333 return a.createdAt === bt.createdAt && a.groupId === bt.groupId && a.expanded === bt.expanded;
......@@ -2269,7 +2348,12 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
22692348
22702349 case "turn-fold": {
22712350 const bf = b as typeof a;
2272 return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded;
2351 return (
2352 a.createdAt === bf.createdAt &&
2353 a.label === bf.label &&
2354 a.expanded === bf.expanded &&
2355 a.summaryOnly === bf.summaryOnly
2356 );
22732357 }
22742358
22752359 case "attempt-fold": {
apps/web/src/components/chat/MessagesTimeline.test.tsx+61
......@@ -492,6 +492,67 @@ function buildSnapShotTimelineEntry(previewUrl?: string) {
492492}
493493
494494describe("MessagesTimeline", () => {
495 it("times initial compaction in one status and transitions to working without a divider", async () => {
496 vi.unstubAllGlobals();
497 vi.useFakeTimers();
498 vi.setSystemTime(new Date("2026-01-01T00:01:22.000Z"));
499 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
500 vi.stubGlobal("requestAnimationFrame", () => 0);
501 vi.stubGlobal("cancelAnimationFrame", () => {});
502 const container = document.createElement("div");
503 document.body.append(container);
504 const root = createRoot(container);
505 const runId = RunId.make("run-1");
506 const render = (completed: boolean) =>
507 root.render(
508 <MessagesTimeline
509 {...buildProps()}
510 isWorking
511 isCompacting
512 runningRunId={runId}
513 activeTurnStartedAt="2025-12-31T23:59:40.000Z"
514 timelineEntries={[
515 {
516 id: "initial-compaction",
517 kind: "work",
518 createdAt: "2026-01-01T00:00:00.000Z",
519 entry: {
520 id: "initial-compaction",
521 runId,
522 createdAt: "2026-01-01T00:00:00.000Z",
523 tone: "info",
524 label: completed ? "Context compacted" : "Compacting context",
525 sourceActivityKind: "context-compaction",
526 toolLifecycleStatus: completed ? "completed" : "inProgress",
527 },
528 },
529 ]}
530 />,
531 );
532
533 try {
534 await act(() => render(false));
535 const status = container.querySelector('[role="status"]');
536 expect(status?.getAttribute("aria-label")).toBe("Compacting context");
537 expect(status?.textContent).toBe("Compacting for 1m 22s");
538 expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);
539
540 await act(() => vi.advanceTimersByTime(1000));
541 expect(status?.textContent).toBe("Compacting for 1m 23s");
542 await act(() => render(true));
543 expect(container.querySelector('[role="status"]')).toBe(status);
544 expect(status?.getAttribute("aria-label")).toBe("Working (context compacted)");
545 expect(status?.textContent).toBe("Working for 1m 43s (context compacted)");
546 expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);
547 expect(container.textContent).not.toContain("Compacting");
548 } finally {
549 await act(() => root.unmount());
550 container.remove();
551 vi.useRealTimers();
552 vi.unstubAllGlobals();
553 }
554 });
555
495556 it("shows dynamic tool input without cached output when the row is expanded", async () => {
496557 activityTestState.expanded = true;
497558 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
apps/web/src/components/chat/MessagesTimeline.tsx+53-17
......@@ -1427,7 +1427,12 @@ const ConversationTimeline = memo(function ConversationTimeline({
14271427 ],
14281428 );
14291429 const compactionAwaitingRow =
1430 isCompacting && !rows.some((row) => row.kind === "context-compaction" && row.active);
1430 isCompacting &&
1431 !rows.some(
1432 (row) =>
1433 (row.kind === "context-compaction" && row.active) ||
1434 (row.kind === "working" && row.compaction !== undefined),
1435 );
14311436 const backgroundWorktreeSetup =
14321437 worktreeSetup !== null &&
14331438 worktreeSetup.phase === "running" &&
......@@ -2789,16 +2794,22 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-
27892794
27902795 return (
27912796 <div className="group/timeline-row relative flex items-center gap-1 border-b border-border/60 pb-2 pe-0.5 pt-1">
2792 <button
2793 type="button"
2794 aria-expanded={row.expanded}
2795 data-scroll-anchor-ignore
2796 onClick={() => ctx.onToggleTurnFold(row.runId)}
2797 className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
2798 >
2799 <span>{row.label}</span>
2800 <MorphIcon className="size-3.5" icon={row.expanded ? ChevronDown : ChevronRight} />
2801 </button>
2797 {row.summaryOnly ? (
2798 <span className="px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
2799 {row.label}
2800 </span>
2801 ) : (
2802 <button
2803 type="button"
2804 aria-expanded={row.expanded}
2805 data-scroll-anchor-ignore
2806 onClick={() => ctx.onToggleTurnFold(row.runId)}
2807 className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
2808 >
2809 <span>{row.label}</span>
2810 <MorphIcon className="size-3.5" icon={row.expanded ? ChevronDown : ChevronRight} />
2811 </button>
2812 )}
28022813 <TimelineRowTimestamp
28032814 createdAt={row.createdAt}
28042815 timestampFormat={ctx.timestampFormat}
......@@ -3795,14 +3806,23 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
37953806 use(TimelineRowActivityCtx);
37963807 // One span for every label so the setup-to-working handoff swaps text in
37973808 // place instead of remounting the row.
3798 const shimmer = isPreparingWorktree || isCompacting;
3809 const compacting = row.compaction?.toolLifecycleStatus === "inProgress";
3810 const compacted = row.compaction?.toolLifecycleStatus === "completed";
3811 const projectedCompaction = row.compaction?.projectedItem?.item;
3812 const compactionStartedAt = projectedCompaction
3813 ? projectedCompaction.startedAt === null
3814 ? undefined
3815 : DateTime.formatIso(projectedCompaction.startedAt)
3816 : row.compaction?.createdAt;
3817 const shimmer = isPreparingWorktree || (isCompacting && !compacting);
37993818 const label = isPreparingWorktree ? (
38003819 "Setting up worktree…"
3801 ) : isCompacting ? (
3802 <CompactingLabel />
3820 ) : compacting || isCompacting ? (
3821 <CompactingLabel createdAt={compacting ? compactionStartedAt : undefined} />
38033822 ) : row.createdAt ? (
38043823 <>
38053824 Working for <WorkingTimer createdAt={row.createdAt} />
3825 {compacted ? " (context compacted)" : null}
38063826 </>
38073827 ) : (
38083828 "Working..."
......@@ -3811,10 +3831,20 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
38113831 <div className="border-b border-border/60 pb-2 pt-1">
38123832 <div className="flex h-6 min-w-0 items-baseline gap-2 px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
38133833 <span
3834 role="status"
3835 aria-label={
3836 isPreparingWorktree
3837 ? "Setting up worktree"
3838 : compacting || isCompacting
3839 ? "Compacting context"
3840 : compacted
3841 ? "Working (context compacted)"
3842 : "Working"
3843 }
38143844 ref={shimmer ? observeVisibleAnimation : undefined}
38153845 className="relative shrink-0 overflow-hidden whitespace-nowrap"
38163846 >
3817 {label}
3847 <span aria-hidden="true">{label}</span>
38183848 {shimmer ? <ActivityShimmerOverlay>{label}</ActivityShimmerOverlay> : null}
38193849 </span>
38203850 {backgroundWorktreeSetup ? (
......@@ -3867,11 +3897,17 @@ function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnap
38673897 );
38683898}
38693899
3870function CompactingLabel() {
3900function CompactingLabel({ createdAt }: { createdAt?: string | undefined }) {
38713901 return (
38723902 <span className="inline-flex items-center gap-1.5">
38733903 <Minimize2Icon aria-hidden="true" className="size-3" />
3874 Compacting…
3904 {createdAt ? (
3905 <span>
3906 Compacting for <WorkingTimer createdAt={createdAt} />
3907 </span>
3908 ) : (
3909 "Compacting…"
3910 )}
38753911 </span>
38763912 );
38773913}
packages/client-runtime/src/work-log/presentation.test.ts+16
......@@ -6,6 +6,7 @@ import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation";
66
77import {
88 commandDetailRepeatsCommand,
9 contextCompactionLabel,
910 extractCommandOutputText,
1011 liveThoughtLine,
1112 resolveViewedImageAsset,
......@@ -22,6 +23,21 @@ import {
2223 workEntryIndicatesToolSuccess,
2324} from "./presentation.js";
2425
26describe("contextCompactionLabel", () => {
27 it.each(["pending", "running", "waiting"] as const)("reports %s as active", (status) => {
28 expect(contextCompactionLabel({ status })).toBe("Compacting context");
29 });
30
31 it.each(["failed", "cancelled", "interrupted"] as const)(
32 "does not report %s compaction as successful even with token counts",
33 (status) => {
34 expect(contextCompactionLabel({ status, beforeTokenCount: 1000, afterTokenCount: 100 })).toBe(
35 status === "failed" ? "Context compaction failed" : "Context compaction stopped",
36 );
37 },
38 );
39});
40
2541function commandItem(
2642 fields: Partial<Extract<OrchestrationV2TurnItem, { type: "command_execution" }>> = {},
2743): OrchestrationV2TurnItem {
packages/client-runtime/src/work-log/presentation.ts+7-1
......@@ -55,7 +55,13 @@ export function contextCompactionLabel(
5555 "status" | "beforeTokenCount" | "afterTokenCount"
5656 >,
5757): string {
58 if (item.status === "running") return "Compacting context";
58 if (item.status === "pending" || item.status === "running" || item.status === "waiting") {
59 return "Compacting context";
60 }
61 if (item.status === "failed") return "Context compaction failed";
62 if (item.status === "cancelled" || item.status === "interrupted") {
63 return "Context compaction stopped";
64 }
5965 if (item.beforeTokenCount !== undefined && item.afterTokenCount !== undefined) {
6066 return `Context compacted ${formatTokens(item.beforeTokenCount)} → ${formatTokens(item.afterTokenCount)} tokens`;
6167 }