From 59d067bf9b4ea9cc94d4aeafeeba665cc3b06600 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Wed, 14 Oct 2026 14:12:29 -0700 Subject: [PATCH] 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 --- apps/mobile/src/lib/threadActivity.test.ts | 2 +- .../src/orchestration-v2/Orchestrator.ts | 62 +++++- .../ThreadLaunchService.test.ts | 204 +++++++++++++++++- .../chat/MessagesTimeline.logic.test.ts | 197 ++++++++++++++++- .../components/chat/MessagesTimeline.logic.ts | 98 ++++++++- .../components/chat/MessagesTimeline.test.tsx | 61 ++++++ .../src/components/chat/MessagesTimeline.tsx | 70 ++++-- .../src/work-log/presentation.test.ts | 16 ++ .../src/work-log/presentation.ts | 8 +- 9 files changed, 675 insertions(+), 43 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 3d2684f128dcb0ea9b8a4d0cd796551202935b56..7a6447093ab5f047f93f6528fd7157bf0e0df2ed 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1668,7 +1668,7 @@ describe("retained v2 feed presentation", () => { ? "Compacting context" : status === "completed" ? "Context compacted 899K → 19K tokens" - : "Context compacted", + : "Context compaction stopped", }, ], }); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index c7b322f2c49203403feceb024ab9fd9304323951..7e3f84c531bf9c049fe98d8c4a57d55320e6db79 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3832,6 +3832,40 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); + const isRunCompacting = ( + projection: Pick, + targetRunId: RunId, + ) => + Effect.gen(function* () { + const run = projection.runs.find((candidate) => candidate.id === targetRunId); + if (run === undefined || !isBlockingRun(run)) return false; + + const message = projection.messages.find((candidate) => candidate.id === run.userMessageId); + if ( + message !== undefined && + message.attachments.length === 0 && + message.text.trim().toLowerCase() === "/compact" + ) { + return true; + } + + const records = yield* projectionStore + .getThreadRecords(projection.thread.id, ["turnItems"], { + turnItemTypes: ["compaction"], + turnItemRunId: run.id, + }) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: projection.thread.id, cause }), + ), + ); + return records.turnItems.some( + (item) => + item.nodeId === run.rootNodeId && + (item.status === "pending" || item.status === "running"), + ); + }); + const dispatchSteerIntoRun = (input: { readonly command: Extract< OrchestrationV2Command, @@ -3897,14 +3931,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const targetMessage = input.projection.messages.find( (message) => message.id === targetRun.userMessageId, ); - if (targetMessage !== undefined && isNativeMaintenanceCommand(targetMessage)) { + if ( + targetMessage !== undefined && + targetMessage.attachments.length === 0 && + targetMessage.text.trim().toLowerCase() === "/logout" + ) { return yield* new OrchestratorDispatchError({ commandId: input.command.commandId, commandType: input.command.type, - cause: - targetMessage.text.trim().toLowerCase() === "/compact" - ? "Wait for context compaction to finish before steering the thread." - : "Wait for sign-out to finish before steering the thread.", + cause: "Wait for sign-out to finish before steering the thread.", }); } const rootNodeId = targetRun.rootNodeId; @@ -4925,6 +4960,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio payload: { ...sourcePlan, status: "completed" }, }); + if ( + (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") && + (yield* isRunCompacting(projection, dispatchMode.targetRunId)) + ) { + dispatchMode = { type: "queue_after_active" }; + } + if (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") { yield* dispatchSteerIntoRun({ command, @@ -7695,6 +7737,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } + if (yield* isRunCompacting(projection, command.targetRunId)) return; + const now = yield* DateTime.now; const emitEvent = emit(events, command); yield* emitEvent({ @@ -11164,12 +11208,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const plan = yield* dispatchOnce(command).pipe( Effect.flatMap((planned) => - // A settle that finds the provider already ended everything, or a - // stop that finds nothing running, has nothing to record. That is - // its expected outcome, not a failure. + // These commands can succeed without changing the current state. planned.events.length > 0 || command.type === "thread.background-work.settle" || command.type === "thread.stop" || + command.type === "queued-message.promote-to-steer" || command.type === "subagent.stop" ? Effect.succeed(planned) : Effect.fail( @@ -11221,8 +11264,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); if (plan.events.length === 0) { - // A settle that ended nothing still records its receipt: a replayed Stop - // effect then finds it instead of settling work that appeared since. + // A no-op consumes its command ID so a replay cannot change newer work. const resultSequence = yield* Effect.gen(function* () { const sequence = yield* eventSink.latestSequence({ threadId: commandThreadId(command) }); yield* commandReceipts.insertIfAbsent({ diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 26e9b8c8d78d4aed6cf05d39a46bfad75de0d877..83bcea423b9f2e756adff89ca1a855e8d0ec9e5e 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -16,9 +16,11 @@ import { type ChatAttachment, CommandId, EnvironmentId, + EventId, DEFAULT_SERVER_SETTINGS, GitCommandError, MessageId, + NodeId, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -26,6 +28,7 @@ import { ScheduledTaskId, type ServerProvider, ThreadId, + TurnItemId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; @@ -54,6 +57,7 @@ import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; +import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ThreadLaunch from "./ThreadLaunchService.ts"; @@ -248,6 +252,7 @@ function makeHarness(options: HarnessOptions = {}) { layerTitleRegeneration, layerOutbox, layerDatabase, + layerOrchestrator, layerExternalServices, ), createWorktree, @@ -737,12 +742,6 @@ it.effect("keeps native maintenance commands out of steering and restart message next: "Continue with the parser", mode: "steer_active", }, - { - name: "steer-compaction", - first: "/compact", - next: "Continue with the parser", - mode: "steer_active", - }, ] as const) { const launched = yield* launches.launch( launchInput({ @@ -781,6 +780,199 @@ it.effect("keeps native maintenance commands out of steering and restart message }), ); +it.effect.each([ + ...(["manual", "automatic", "pending"] as const).flatMap((compaction) => + (["steer", "restart", "auto", "targeted-steer", "targeted-restart"] as const).map((intent) => ({ + compaction, + intent, + })), + ), + { compaction: "completed", intent: "steer" }, + { compaction: "child", intent: "steer" }, +] as const)( + "resolves $intent during $compaction compaction without losing the submission", + ({ compaction, intent }) => + Effect.gen(function* () { + const harness = makeHarness(); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const threads = yield* ThreadManagement.ThreadManagementService; + const sink = yield* EventSink.EventSinkV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const launched = yield* launches.launch( + launchInput({ + command: "compaction-queue:launch", + thread: "compaction-queue", + message: compaction === "manual" ? " /COMPACT " : "Fix the parser", + }), + ); + const before = yield* threads.getThreadProjection(launched.threadId); + const active = before.runs[0]!; + const now = yield* DateTime.now; + yield* sink.write({ + events: [ + { + id: EventId.make("compaction-queue:running"), + type: "run.updated", + threadId: launched.threadId, + occurredAt: now, + payload: { ...active, status: "running", startedAt: now }, + }, + ...(compaction === "manual" + ? [] + : [ + { + id: EventId.make("compaction-queue:compacting"), + type: "turn-item.updated" as const, + threadId: launched.threadId, + occurredAt: now, + payload: { + id: TurnItemId.make("compaction-queue:item"), + type: "compaction" as const, + threadId: launched.threadId, + runId: active.id, + nodeId: + compaction === "child" + ? NodeId.make("compaction-child") + : active.rootNodeId, + providerThreadId: active.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: + compaction === "completed" + ? ("completed" as const) + : compaction === "pending" + ? ("pending" as const) + : ("running" as const), + title: null, + driver: ProviderDriverKind.make("codex"), + startedAt: now, + completedAt: compaction === "completed" ? now : null, + updatedAt: now, + }, + }, + ]), + ], + }); + const attachments: ChatAttachment[] = [ + { + type: "image", + id: ChatAttachmentId.make("compaction-queue-image"), + name: "parser.png", + mimeType: "image/png", + sizeBytes: 42, + }, + ]; + const command = { + type: "message.dispatch" as const, + commandId: CommandId.make("compaction-queue:follow-up"), + threadId: launched.threadId, + messageId: MessageId.make("compaction-queue:follow-up"), + text: "Continue with the parser [image](t3-context://v1/file/parser)", + context: { + version: 1 as const, + records: [ + { + version: 1 as const, + kind: "file" as const, + contextId: ComposerContextId.make("parser"), + label: "Parser screenshot", + attachmentId: attachments[0]!.id, + name: "parser.png", + mimeType: "image/png", + sizeBytes: 42, + }, + ], + }, + attachments, + modelSelection: { ...modelSelection, model: "gpt-5.2-codex" }, + createdBy: "user" as const, + creationSource: "web" as const, + dispatchMode: { + type: + intent === "targeted-restart" + ? ("restart_active" as const) + : ("steer_active" as const), + targetRunId: active.id, + }, + ...(intent === "targeted-steer" || intent === "targeted-restart" + ? {} + : { deliveryIntent: intent }), + }; + if (compaction === "completed" || compaction === "child") { + const failure = yield* threads.dispatch(command).pipe(Effect.flip); + assert.include(String(failure.cause), "No running provider turn found"); + assert.deepEqual( + (yield* threads.getThreadProjection(launched.threadId)).messages, + before.messages, + ); + return; + } + const accepted = yield* threads.dispatch(command); + const retried = yield* threads.dispatch(command); + assert.equal(retried.sequence, accepted.sequence); + assert.deepEqual(retried.storedEvents, accepted.storedEvents); + + const queued = yield* threads.getThreadProjection(launched.threadId); + assert.equal(queued.runs.length, 2); + assert.equal(queued.runs[0]?.status, "running"); + const queuedRun = queued.runs[1]!; + assert.equal(queuedRun.status, "queued"); + assert.deepEqual(queuedRun.modelSelection, command.modelSelection); + const message = queued.messages.find((candidate) => candidate.id === command.messageId)!; + assert.equal(message.text, command.text); + assert.deepEqual(message.attachments, command.attachments); + assert.deepEqual(message.context, command.context); + assert.equal(message.createdBy, command.createdBy); + assert.equal(message.creationSource, command.creationSource); + assert.equal(message.runId, queuedRun.id); + assert.isFalse( + (yield* outbox.listByCommandId(command.commandId)).some((effect) => + effect.request.type.startsWith("provider-turn."), + ), + ); + + const promote = { + type: "queued-message.promote-to-steer" as const, + commandId: CommandId.make("compaction-queue:promote"), + threadId: launched.threadId, + queuedRunId: queuedRun.id, + targetRunId: active.id, + }; + assert.deepEqual((yield* threads.dispatch(promote)).storedEvents, []); + assert.deepEqual((yield* threads.dispatch(promote)).storedEvents, []); + const afterPromotion = yield* threads.getThreadProjection(launched.threadId); + assert.deepEqual(afterPromotion.runs, queued.runs); + assert.deepEqual(afterPromotion.messages, queued.messages); + + yield* sink.write({ + events: [ + { + id: EventId.make("compaction-queue:completed"), + type: "run.updated", + threadId: launched.threadId, + occurredAt: now, + payload: { ...active, status: "completed", completedAt: now }, + }, + ], + }); + yield* threads.dispatch({ + type: "queue.resume", + commandId: CommandId.make("compaction-queue:resume"), + threadId: launched.threadId, + }); + const resumed = yield* threads.getThreadProjection(launched.threadId); + assert.equal(resumed.runs.find((run) => run.id === queuedRun.id)?.status, "starting"); + assert.equal( + resumed.messages.filter((candidate) => candidate.id === command.messageId).length, + 1, + ); + }).pipe(Effect.provide(harness.layer)); + }), +); + it.effect("arms durable title generation after accepting the first message", () => Effect.gen(function* () { const harness = makeHarness({ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index caaa2d8241a1be8ae6788ac720ba45ea0a8bd4d6..8cddb185fe939c17f5979880e115c29cee12d597 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1093,7 +1093,7 @@ describe("deriveMessagesTimelineRows", () => { ]); }); - it("gives live compaction the activity slot and restores Thinking when it completes", () => { + it("keeps mid-turn compaction in the activity slot and restores Thinking when it completes", () => { const fixture = makeStreamingTimelineFixture(); const last = fixture.visibleTurnItems.at(-1)!; if (last.item.type !== "assistant_message") throw new Error("Expected assistant fixture"); @@ -1156,6 +1156,201 @@ describe("deriveMessagesTimelineRows", () => { expect(computeStableMessagesTimelineRows(settledRows, stable).result).toEqual(settledRows); }); + it("uses the initial compaction's own clock in the turn header, then remembers completion", () => { + const fixture = makeStreamingTimelineFixture(); + const source = fixture.visibleTurnItems.at(-1)!; + if (source.item.type !== "assistant_message") throw new Error("Expected assistant fixture"); + const compaction = { + ...source, + item: { + ...source.item, + type: "compaction" as const, + driver: null, + status: "running" as const, + startedAt: DateTime.makeUnsafe(fixture.time(9)), + completedAt: null, + }, + }; + const entries = (status: "running" | "completed") => + deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + ...fixture.visibleTurnItems.slice(0, 4), + { ...compaction, item: { ...compaction.item, status } }, + ], + optimisticMessages: [], + }); + const input = { + isWorking: true, + runningRunId: fixture.runId, + activeTurnStartedAt: fixture.time(5), + turnDiffSummaries: [], + supportsConversationRollback: false, + }; + const runningRows = deriveMessagesTimelineRows({ + ...input, + timelineEntries: entries("running"), + }); + expect(runningRows.find((row) => row.kind === "working")).toMatchObject({ + createdAt: fixture.time(5), + compaction: { createdAt: fixture.time(9), toolLifecycleStatus: "inProgress" }, + }); + expect(runningRows.some((row) => row.kind === "context-compaction")).toBe(false); + expect(runningRows.some((row) => row.kind === "thinking")).toBe(false); + + const completedRows = deriveMessagesTimelineRows({ + ...input, + timelineEntries: entries("completed"), + }); + expect(completedRows.find((row) => row.kind === "working")).toMatchObject({ + createdAt: fixture.time(5), + compaction: { toolLifecycleStatus: "completed" }, + }); + expect(completedRows.some((row) => row.kind === "context-compaction")).toBe(false); + expect(completedRows.at(-1)?.kind).toBe("thinking"); + const stable = computeStableMessagesTimelineRows(runningRows, { result: [], byId: new Map() }); + expect(computeStableMessagesTimelineRows(completedRows, stable).result).toEqual(completedRows); + }); + + it.each([false, true])( + "keeps initial compaction in the completed turn summary when expanded=%s", + (expanded) => { + const fixture = makeStreamingTimelineFixture("Done"); + const source = fixture.visibleTurnItems.at(-1)!; + if (source.item.type !== "assistant_message") throw new Error("Expected assistant fixture"); + const compaction = { + ...source, + sourceItemId: TurnItemId.make("initial-compaction"), + item: { + ...source.item, + id: TurnItemId.make("initial-compaction"), + type: "compaction" as const, + driver: null, + status: "completed" as const, + startedAt: DateTime.makeUnsafe(fixture.time(6)), + completedAt: DateTime.makeUnsafe(fixture.time(7)), + }, + }; + const rows = deriveMessagesTimelineRows({ + timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + ...fixture.visibleTurnItems.slice(0, 4), + compaction, + { ...source, item: { ...source.item, streaming: false, status: "completed" } }, + ], + optimisticMessages: [], + }), + isWorking: false, + latestRun: { + runId: fixture.runId, + status: "completed", + startedAt: fixture.time(5), + completedAt: fixture.time(27), + }, + expandedRunIds: expanded ? new Set([fixture.runId]) : new Set(), + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect( + rows.find((row) => row.kind === "turn-fold" && row.runId === fixture.runId), + ).toMatchObject({ + label: "Worked for 22s (context compacted)", + expanded, + summaryOnly: true, + }); + expect(rows.some((row) => row.kind === "context-compaction")).toBe(false); + expect(rows.some((row) => row.kind === "message" && row.message.text === "Done")).toBe(true); + }, + ); + + it.each(["latest run unavailable", "later run selected"] as const)( + "uses recorded compaction completion for a historical summary when %s", + (latest) => { + const fixture = makeStreamingTimelineFixture(); + const source = fixture.visibleTurnItems.at(-1)!; + if (source.item.type !== "assistant_message") throw new Error("Expected assistant fixture"); + const compaction = { + ...source, + item: { + ...source.item, + type: "compaction" as const, + driver: null, + runId: fixture.historyRunId, + status: "completed" as const, + startedAt: DateTime.makeUnsafe(fixture.time(1)), + completedAt: DateTime.makeUnsafe(fixture.time(83)), + updatedAt: DateTime.makeUnsafe(fixture.time(83)), + }, + }; + const rows = deriveMessagesTimelineRows({ + timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [fixture.visibleTurnItems[0]!, compaction], + optimisticMessages: [], + }), + isWorking: false, + latestRun: + latest === "later run selected" + ? { + runId: fixture.runId, + status: "completed", + startedAt: fixture.time(100), + completedAt: fixture.time(105), + } + : null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.find((row) => row.kind === "turn-fold")).toMatchObject({ + label: "Worked for 1m 23s (context compacted)", + summaryOnly: true, + }); + }, + ); + + it.each( + (["failed", "cancelled", "interrupted"] as const).flatMap((status) => + [true, false].map((isWorking) => ({ status, isWorking })), + ), + )( + "retains a visible initial compaction result when $status and working=$isWorking", + ({ status, isWorking }) => { + const fixture = makeStreamingTimelineFixture("Done"); + const source = fixture.visibleTurnItems.at(-1)!; + if (source.item.type !== "assistant_message") throw new Error("Expected assistant fixture"); + const rows = deriveMessagesTimelineRows({ + timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + ...fixture.visibleTurnItems.slice(0, 4), + { + ...source, + sourceItemId: TurnItemId.make("failed-compaction"), + item: { + ...source.item, + id: TurnItemId.make("failed-compaction"), + type: "compaction", + driver: null, + status, + }, + }, + fixture.visibleTurnItems[4]!, + { ...source, item: { ...source.item, streaming: isWorking } }, + ], + optimisticMessages: [], + }), + isWorking, + runningRunId: isWorking ? fixture.runId : null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.find((row) => row.kind === "context-compaction")).toMatchObject({ + active: false, + label: status === "failed" ? "Context compaction failed" : "Context compaction stopped", + }); + if (isWorking) { + expect(rows.find((row) => row.kind === "working")).not.toHaveProperty("compaction"); + } + }, + ); + it("only enables assistant copy for the terminal assistant message in a turn", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 25f67ffce1c99e50590bfa9705081948febf0586..ac58b8e422cd796be6e4a97adc4f7d8a4eae0405 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -550,6 +550,7 @@ type MessagesTimelineRowContent = kind: "working"; id: string; createdAt: string | null; + compaction?: WorkLogEntry; } | { kind: "thinking"; @@ -581,6 +582,7 @@ type MessagesTimelineRowContent = runId: RunId; label: string; expanded: boolean; + summaryOnly?: true; } | { kind: "attempt-fold"; @@ -948,6 +950,34 @@ function responseBoundaryRunId(entry: TimelineEntry): RunId | null { return null; } +function initialCompactionEntryIds(entries: ReadonlyArray): ReadonlySet { + const initial = new Set(); + const startedRuns = new Set(); + let runlessKey: RunId | null = null; + + for (const entry of entries) { + if (timelineEntryStartsResponse(entry)) { + runlessKey = RunId.make(`runless:${entry.id}`); + continue; + } + const runId = timelineEntryFoldRunId(entry, runlessKey); + if (runId === null || startedRuns.has(runId)) continue; + if ( + entry.kind === "message" && + entry.message.text.trim() === "" && + (entry.message.attachments?.length ?? 0) === 0 + ) { + continue; + } + if (entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction") { + initial.add(entry.id); + } else { + startedRuns.add(runId); + } + } + return initial; +} + /** * A promptless provider restart replaces the native turn without adding a * user message. Keep every provider turn since the initiating prompt in one @@ -1030,6 +1060,7 @@ function deriveTurnFolds(input: { /** Finished replies a steer superseded; they answer their prompt, so they stay out. */ completeSupersededReplyEntryIds: ReadonlySet; }): ReadonlyMap { + const initialCompactions = initialCompactionEntryIds(input.timelineEntries); const interruptedRunIds = new Set(); const promptIndexByRunId = new Map(); for (const [index, entry] of input.timelineEntries.entries()) { @@ -1143,6 +1174,13 @@ function deriveTurnFolds(input: { } const isCompaction = entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction"; + if ( + isCompaction && + (entry.entry.toolLifecycleStatus === "failed" || + entry.entry.toolLifecycleStatus === "stopped") + ) { + continue; + } const isFoldableTrailingActivity = entry.kind === "work" && entry.entry.toolLifecycleStatus !== "inProgress" && @@ -1165,14 +1203,18 @@ function deriveTurnFolds(input: { if (hiddenEntryIds.size === 0) { continue; } - // A lone compaction row stays visible on its own; it only folds away as - // part of a turn that already folds other work. const hidesNonCompactionWork = group.entries.some( (entry) => hiddenEntryIds.has(entry.id) && !(entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction"), ); - if (!hidesNonCompactionWork) { + const startsCompacted = group.entries.some( + (entry) => + initialCompactions.has(entry.id) && + entry.kind === "work" && + entry.entry.toolLifecycleStatus === "completed", + ); + if (!hidesNonCompactionWork && !startsCompacted) { continue; } @@ -1186,8 +1228,19 @@ function deriveTurnFolds(input: { input.latestRun?.runId === runId && input.latestRun.status === "interrupted"; // A turn cut short by a steer leaves trailing work entries behind its // terminal message — take whichever ended last. + const lastProjectedItem = + lastEntry.kind === "work" + ? lastEntry.entry.projectedItem + : lastEntry.kind === "event" + ? lastEntry.projectedItem + : undefined; + const lastItemCompletedAt = lastProjectedItem?.item.completedAt; const lastEntryEnd = - lastEntry.kind === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; + lastEntry.kind === "message" + ? lastEntry.message.updatedAt + : lastItemCompletedAt + ? DateTime.formatIso(lastItemCompletedAt) + : lastEntry.createdAt; const elapsedMs = input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt) @@ -1210,7 +1263,7 @@ function deriveTurnFolds(input: { anchorEntryId: group.anchorEntryId, createdAt: group.startBoundary ?? firstEntry.createdAt, hiddenEntryIds, - label, + label: startsCompacted ? `${label} (context compacted)` : label, }); } return foldsByAnchorEntryId; @@ -1483,6 +1536,14 @@ export function deriveMessagesTimelineRows(input: { input.isWorking && entry.toolLifecycleStatus === "inProgress" && (entry.runId == null ? runlessWorkActive : entry.runId === unsettledRunId); + const initialCompactions = initialCompactionEntryIds(timelineEntries); + const activeInitialCompaction = timelineEntries.findLast( + (entry) => + initialCompactions.has(entry.id) && + entry.kind === "work" && + runIdIsActiveResponse(entry.entry.runId) && + (entry.entry.toolLifecycleStatus === "completed" || workEntryIsInActiveRun(entry.entry)), + ); // A steer continues the current turn. Keep its elapsed-time header below // the initiating prompt (or automatic wake), rather than moving it down. @@ -1572,6 +1633,9 @@ export function deriveMessagesTimelineRows(input: { kind: "working", id: "working-indicator-row", createdAt: input.activeTurnStartedAt ?? null, + ...(activeInitialCompaction?.kind === "work" + ? { compaction: activeInitialCompaction.entry } + : {}), }); }; let hasActivityRow = false; @@ -1622,6 +1686,9 @@ export function deriveMessagesTimelineRows(input: { runId: turnFold.runId, label: turnFold.label, expanded: input.expandedRunIds?.has(turnFold.runId) ?? false, + ...([...turnFold.hiddenEntryIds].every((id) => initialCompactions.has(id)) + ? { summaryOnly: true as const } + : {}), }); } @@ -1656,6 +1723,16 @@ export function deriveMessagesTimelineRows(input: { ) { const active = workEntryIsInActiveRun(timelineEntry.entry); hasActiveCompaction ||= active; + if ( + initialCompactions.has(timelineEntry.id) && + ((input.isWorking && runIdIsActiveResponse(timelineEntry.entry.runId)) || + [...foldsByAnchorEntryId.values()].some((fold) => + fold.hiddenEntryIds.has(timelineEntry.id), + )) && + (active || timelineEntry.entry.toolLifecycleStatus === "completed") + ) { + continue; + } nextRows.push({ kind: "context-compaction", id: timelineEntry.id, @@ -2248,7 +2325,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean switch (a.kind) { case "working": - return a.createdAt === (b as typeof a).createdAt; + return ( + a.createdAt === (b as typeof a).createdAt && a.compaction === (b as typeof a).compaction + ); case "thinking": { const bt = b as typeof a; return a.createdAt === bt.createdAt && a.groupId === bt.groupId && a.expanded === bt.expanded; @@ -2269,7 +2348,12 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "turn-fold": { const bf = b as typeof a; - return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded; + return ( + a.createdAt === bf.createdAt && + a.label === bf.label && + a.expanded === bf.expanded && + a.summaryOnly === bf.summaryOnly + ); } case "attempt-fold": { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 01857ac0004f18de53e93c512593b458cf2aeb50..ab4bc4ed589027a6352b3d7139e4fd103bab001f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -492,6 +492,67 @@ function buildSnapShotTimelineEntry(previewUrl?: string) { } describe("MessagesTimeline", () => { + it("times initial compaction in one status and transitions to working without a divider", async () => { + vi.unstubAllGlobals(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:01:22.000Z")); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("requestAnimationFrame", () => 0); + vi.stubGlobal("cancelAnimationFrame", () => {}); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const runId = RunId.make("run-1"); + const render = (completed: boolean) => + root.render( + , + ); + + try { + await act(() => render(false)); + const status = container.querySelector('[role="status"]'); + expect(status?.getAttribute("aria-label")).toBe("Compacting context"); + expect(status?.textContent).toBe("Compacting for 1m 22s"); + expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0); + + await act(() => vi.advanceTimersByTime(1000)); + expect(status?.textContent).toBe("Compacting for 1m 23s"); + await act(() => render(true)); + expect(container.querySelector('[role="status"]')).toBe(status); + expect(status?.getAttribute("aria-label")).toBe("Working (context compacted)"); + expect(status?.textContent).toBe("Working for 1m 43s (context compacted)"); + expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0); + expect(container.textContent).not.toContain("Compacting"); + } finally { + await act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + } + }); + it("shows dynamic tool input without cached output when the row is expanded", async () => { activityTestState.expanded = true; vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 7c33032f909d02366ee1454ff1fa30d035627cfb..c4085341a511b250fc73e8bec31e7c942b4ff50f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1427,7 +1427,12 @@ const ConversationTimeline = memo(function ConversationTimeline({ ], ); const compactionAwaitingRow = - isCompacting && !rows.some((row) => row.kind === "context-compaction" && row.active); + isCompacting && + !rows.some( + (row) => + (row.kind === "context-compaction" && row.active) || + (row.kind === "working" && row.compaction !== undefined), + ); const backgroundWorktreeSetup = worktreeSetup !== null && worktreeSetup.phase === "running" && @@ -2789,16 +2794,22 @@ function TurnFoldTimelineRow({ row }: { row: Extract - + {row.summaryOnly ? ( + + {row.label} + + ) : ( + + )} + ) : compacting || isCompacting ? ( + ) : row.createdAt ? ( <> Working for + {compacted ? " (context compacted)" : null} ) : ( "Working..." @@ -3811,10 +3831,20 @@ function WorkingTimelineRow({ row }: { row: Extract
- {label} + {shimmer ? {label} : null} {backgroundWorktreeSetup ? ( @@ -3867,11 +3897,17 @@ function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnap ); } -function CompactingLabel() { +function CompactingLabel({ createdAt }: { createdAt?: string | undefined }) { return ( ); } diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 4b0078fdb73760d822df6cf20312e7b1534fb771..ea0cb3534bc0d3e237146e1a60800a8168efe5bb 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -6,6 +6,7 @@ import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation"; import { commandDetailRepeatsCommand, + contextCompactionLabel, extractCommandOutputText, liveThoughtLine, resolveViewedImageAsset, @@ -22,6 +23,21 @@ import { workEntryIndicatesToolSuccess, } from "./presentation.js"; +describe("contextCompactionLabel", () => { + it.each(["pending", "running", "waiting"] as const)("reports %s as active", (status) => { + expect(contextCompactionLabel({ status })).toBe("Compacting context"); + }); + + it.each(["failed", "cancelled", "interrupted"] as const)( + "does not report %s compaction as successful even with token counts", + (status) => { + expect(contextCompactionLabel({ status, beforeTokenCount: 1000, afterTokenCount: 100 })).toBe( + status === "failed" ? "Context compaction failed" : "Context compaction stopped", + ); + }, + ); +}); + function commandItem( fields: Partial> = {}, ): OrchestrationV2TurnItem { diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index cb5ac00b0e9645d29c0741b31e7773d12b4d4eb5..14ff673748ece49f95e14337055b767b2845ddc0 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -55,7 +55,13 @@ export function contextCompactionLabel( "status" | "beforeTokenCount" | "afterTokenCount" >, ): string { - if (item.status === "running") return "Compacting context"; + if (item.status === "pending" || item.status === "running" || item.status === "waiting") { + return "Compacting context"; + } + if (item.status === "failed") return "Context compaction failed"; + if (item.status === "cancelled" || item.status === "interrupted") { + return "Context compaction stopped"; + } if (item.beforeTokenCount !== undefined && item.afterTokenCount !== undefined) { return `Context compacted ${formatTokens(item.beforeTokenCount)} → ${formatTokens(item.afterTokenCount)} tokens`; } -- 2.54.0