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`; }