From dbc88c08c1a0748f773edc94eb7585d648fa5d86 Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Mon, 20 Apr 2026 22:26:09 +0300 Subject: [PATCH] feat(orchestration): add compacting status indicator - Add `compacting: boolean` field to OrchestrationSession to track provider context compaction operations - Derive compacting state from provider events: Claude session.state.changed with CLAUDE_COMPACTING_REASON, Codex context_compaction item lifecycle, and thread.state.changed=compacted - Clear compacting flag on safety events (turn.started/completed, session.exited) to prevent stale state - Display "Compacting..." in MessagesTimeline UI during active compaction - Add comprehensive test coverage for all state transitions --- .../Layers/CheckpointReactor.test.ts | 10 + .../Layers/ProjectionPipeline.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.ts | 3 + .../Layers/ProviderCommandReactor.test.ts | 7 + .../Layers/ProviderCommandReactor.ts | 2 + .../Layers/ProviderRuntimeIngestion.test.ts | 257 ++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 51 +++- .../src/provider/Layers/ClaudeAdapter.ts | 6 +- apps/web/src/components/ChatView.browser.tsx | 5 + .../web/src/components/ChatView.logic.test.ts | 1 + apps/web/src/components/ChatView.tsx | 2 + .../components/KeybindingsToast.browser.tsx | 1 + apps/web/src/components/Sidebar.logic.test.ts | 1 + .../components/chat/MessagesTimeline.test.tsx | 2 + .../src/components/chat/MessagesTimeline.tsx | 10 +- apps/web/src/store.test.ts | 3 + apps/web/src/store.ts | 2 + apps/web/src/types.ts | 1 + packages/contracts/src/orchestration.ts | 3 + 20 files changed, 364 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 9b18ac1230a..023a9f9404a 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -387,6 +387,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + compacting: false, updatedAt: createdAt, }, createdAt, @@ -485,6 +486,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: asTurnId("turn-main"), lastError: null, + compacting: false, updatedAt: createdAt, }, createdAt, @@ -560,6 +562,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + compacting: false, updatedAt: createdAt, }, createdAt, @@ -618,6 +621,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + compacting: false, updatedAt: createdAt, }, createdAt, @@ -706,6 +710,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: asTurnId("turn-missing-cwd"), lastError: null, + compacting: false, updatedAt: createdAt, }, createdAt, @@ -753,6 +758,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + compacting: false, updatedAt: createdAt, }, createdAt, @@ -803,6 +809,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + compacting: false, updatedAt: createdAt, }, createdAt, @@ -855,6 +862,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + compacting: false, updatedAt: createdAt, }, createdAt, @@ -933,6 +941,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + compacting: false, updatedAt: createdAt, }, createdAt, @@ -1002,6 +1011,7 @@ describe("CheckpointReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + compacting: false, updatedAt: createdAt, }, createdAt, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index beb94ebb6d0..da2f119c2e2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1780,6 +1780,7 @@ it.effect("restores pending turn-start metadata across projection pipeline resta runtimeMode: "approval-required", activeTurnId: turnId, lastError: null, + compacting: false, updatedAt: sessionSetAt, }, }, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 779fee490d0..80459282cb2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -339,6 +339,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "approval-required", activeTurnId: asTurnId("turn-1"), lastError: null, + compacting: false, updatedAt: "2026-02-24T00:00:07.000Z", }, }, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index cf9518f035c..19714e3e209 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -820,6 +820,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { runtimeMode: row.runtimeMode, activeTurnId: row.activeTurnId, lastError: row.lastError, + compacting: false, updatedAt: row.updatedAt, }); } @@ -1120,6 +1121,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { runtimeMode: row.runtimeMode, activeTurnId: row.activeTurnId, lastError: row.lastError, + compacting: false, updatedAt: row.updatedAt, }); } @@ -1312,6 +1314,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { runtimeMode: sessionRowOpt.value.runtimeMode, activeTurnId: sessionRowOpt.value.activeTurnId, lastError: sessionRowOpt.value.lastError, + compacting: false, updatedAt: sessionRowOpt.value.updatedAt, } : null; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 28e8ae0d1c5..ca3f75918b6 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1039,6 +1039,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "full-access", activeTurnId: null, lastError: null, + compacting: false, updatedAt: now, }, createdAt: now, @@ -1219,6 +1220,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", activeTurnId: asTurnId("turn-1"), lastError: null, + compacting: false, updatedAt: now, }, createdAt: now, @@ -1257,6 +1259,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + compacting: false, updatedAt: now, }, createdAt: now, @@ -1298,6 +1301,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + compacting: false, updatedAt: now, }, createdAt: now, @@ -1352,6 +1356,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + compacting: false, updatedAt: now, }, createdAt: now, @@ -1447,6 +1452,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + compacting: false, updatedAt: now, }, createdAt: now, @@ -1547,6 +1553,7 @@ describe("ProviderCommandReactor", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + compacting: false, updatedAt: now, }, createdAt: now, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 365854a76d6..58f956bea38 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -313,6 +313,7 @@ const make = Effect.gen(function* () { // Provider turn ids are not orchestration turn ids. activeTurnId: null, lastError: session.lastError ?? null, + compacting: thread.session?.compacting ?? false, updatedAt: session.updatedAt, }, createdAt, @@ -766,6 +767,7 @@ const make = Effect.gen(function* () { runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE, activeTurnId: null, lastError: thread.session?.lastError ?? null, + compacting: false, updatedAt: now, }, createdAt: now, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 649d23efe8d..f19d04f84be 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -9,6 +9,7 @@ import type { } from "@marcode/contracts"; import { ApprovalRequestId, + CLAUDE_COMPACTING_REASON, CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, EventId, @@ -269,6 +270,7 @@ describe("ProviderRuntimeIngestion", () => { activeTurnId: null, updatedAt: createdAt, lastError: null, + compacting: false, }, createdAt, }), @@ -494,6 +496,7 @@ describe("ProviderRuntimeIngestion", () => { activeTurnId: null, updatedAt: seededAt, lastError: null, + compacting: false, }, createdAt: seededAt, }), @@ -792,6 +795,7 @@ describe("ProviderRuntimeIngestion", () => { activeTurnId: null, updatedAt: createdAt, lastError: null, + compacting: false, }, createdAt, }), @@ -827,6 +831,7 @@ describe("ProviderRuntimeIngestion", () => { activeTurnId: null, updatedAt: createdAt, lastError: null, + compacting: false, }, createdAt, }), @@ -979,6 +984,7 @@ describe("ProviderRuntimeIngestion", () => { activeTurnId: null, updatedAt: createdAt, lastError: null, + compacting: false, }, createdAt, }), @@ -1132,6 +1138,7 @@ describe("ProviderRuntimeIngestion", () => { activeTurnId: null, updatedAt: createdAt, lastError: null, + compacting: false, }, createdAt, }), @@ -1167,6 +1174,7 @@ describe("ProviderRuntimeIngestion", () => { activeTurnId: null, updatedAt: createdAt, lastError: null, + compacting: false, }, createdAt, }), @@ -2380,4 +2388,253 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.status).toBe("error"); expect(thread.session?.lastError).toBe("runtime still processed"); }); + + describe("compacting flag", () => { + it("flips compacting=true on Claude session.state.changed with compacting reason", async () => { + const harness = await createHarness(); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-claude-compacting-start"), + provider: "claudeAgent", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + payload: { + state: "waiting", + reason: CLAUDE_COMPACTING_REASON, + }, + }); + + const thread = await waitForThread( + harness.engine, + (entry) => entry.session?.compacting === true, + ); + expect(thread.session?.compacting).toBe(true); + expect(thread.session?.status).toBe("running"); + }); + + it("flips compacting=true on Codex item.started with context_compaction itemType", async () => { + const harness = await createHarness(); + + harness.emit({ + type: "item.started", + eventId: asEventId("evt-codex-compaction-start"), + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + turnId: asTurnId("turn-compaction"), + itemId: asItemId("item-compaction"), + payload: { + itemType: "context_compaction", + status: "in_progress", + }, + }); + + const thread = await waitForThread( + harness.engine, + (entry) => entry.session?.compacting === true, + ); + expect(thread.session?.compacting).toBe(true); + }); + + it("flips compacting=false on Claude thread.state.changed state=compacted", async () => { + const harness = await createHarness(); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-claude-compact-start"), + provider: "claudeAgent", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + payload: { + state: "waiting", + reason: CLAUDE_COMPACTING_REASON, + }, + }); + + await waitForThread(harness.engine, (entry) => entry.session?.compacting === true); + + harness.emit({ + type: "thread.state.changed", + eventId: asEventId("evt-claude-compacted"), + provider: "claudeAgent", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + payload: { + state: "compacted", + }, + }); + + const thread = await waitForThread( + harness.engine, + (entry) => entry.session?.compacting === false, + ); + expect(thread.session?.compacting).toBe(false); + }); + + it("flips compacting=false on Codex item.completed success", async () => { + const harness = await createHarness(); + + harness.emit({ + type: "item.started", + eventId: asEventId("evt-codex-compaction-start-ok"), + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + turnId: asTurnId("turn-compaction-ok"), + itemId: asItemId("item-compaction-ok"), + payload: { + itemType: "context_compaction", + status: "in_progress", + }, + }); + + await waitForThread(harness.engine, (entry) => entry.session?.compacting === true); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-codex-compaction-completed"), + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + turnId: asTurnId("turn-compaction-ok"), + itemId: asItemId("item-compaction-ok"), + payload: { + itemType: "context_compaction", + status: "completed", + }, + }); + + const thread = await waitForThread( + harness.engine, + (entry) => entry.session?.compacting === false, + ); + expect(thread.session?.compacting).toBe(false); + }); + + it("flips compacting=false on Codex item.completed failure", async () => { + const harness = await createHarness(); + + harness.emit({ + type: "item.started", + eventId: asEventId("evt-codex-compaction-start-fail"), + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + turnId: asTurnId("turn-compaction-fail"), + itemId: asItemId("item-compaction-fail"), + payload: { + itemType: "context_compaction", + status: "in_progress", + }, + }); + + await waitForThread(harness.engine, (entry) => entry.session?.compacting === true); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-codex-compaction-failed"), + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + turnId: asTurnId("turn-compaction-fail"), + itemId: asItemId("item-compaction-fail"), + payload: { + itemType: "context_compaction", + status: "failed", + }, + }); + + const thread = await waitForThread( + harness.engine, + (entry) => entry.session?.compacting === false, + ); + expect(thread.session?.compacting).toBe(false); + }); + + it("clears compacting on turn.started / turn.completed / session.exited safety events", async () => { + for (const scenario of [ + { + label: "turn.started", + emit: (harness: Awaited>) => + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-safety-turn-started"), + provider: "claudeAgent", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + turnId: asTurnId("turn-safety-started"), + }), + }, + { + label: "turn.completed", + emit: (harness: Awaited>) => + harness.emit({ + type: "turn.completed", + eventId: asEventId("evt-safety-turn-completed"), + provider: "claudeAgent", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + payload: { state: "completed" }, + }), + }, + { + label: "session.exited", + emit: (harness: Awaited>) => + harness.emit({ + type: "session.exited", + eventId: asEventId("evt-safety-session-exited"), + provider: "claudeAgent", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + }), + }, + { + label: "session.state.changed/error", + emit: (harness: Awaited>) => + harness.emit({ + type: "session.state.changed", + eventId: asEventId("evt-safety-session-error"), + provider: "claudeAgent", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + payload: { state: "error", reason: "boom" }, + }), + }, + ]) { + const harness = await createHarness(); + + harness.emit({ + type: "session.state.changed", + eventId: asEventId(`evt-pre-${scenario.label}`), + provider: "claudeAgent", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + payload: { + state: "waiting", + reason: CLAUDE_COMPACTING_REASON, + }, + }); + + await waitForThread(harness.engine, (entry) => entry.session?.compacting === true); + + scenario.emit(harness); + + const thread = await waitForThread( + harness.engine, + (entry) => entry.session?.compacting === false, + ); + expect(thread.session?.compacting, `safety clear on ${scenario.label}`).toBe(false); + + if (scope) { + await Effect.runPromise(Scope.close(scope, Exit.void)); + scope = null; + } + if (runtime) { + await runtime.dispose(); + runtime = null; + } + } + }); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index b3997bdb44d..4a591d172d5 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1,6 +1,7 @@ import { ApprovalRequestId, type AssistantDeliveryMode, + CLAUDE_COMPACTING_REASON, CommandId, MessageId, type OrchestrationEvent, @@ -144,6 +145,39 @@ function orchestrationSessionStatusFromRuntimeState( } } +function deriveNextCompacting(event: ProviderRuntimeEvent, previous: boolean): boolean { + if (event.type === "session.state.changed" && event.payload.reason === CLAUDE_COMPACTING_REASON) { + return true; + } + if (event.type === "item.started" && event.payload.itemType === "context_compaction") { + return true; + } + + if (event.type === "item.completed" && event.payload.itemType === "context_compaction") { + return false; + } + if (event.type === "thread.state.changed" && event.payload.state === "compacted") { + return false; + } + + if ( + event.type === "turn.started" || + event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "session.exited" + ) { + return false; + } + if ( + event.type === "session.state.changed" && + (event.payload.state === "error" || event.payload.reason !== CLAUDE_COMPACTING_REASON) + ) { + return false; + } + + return previous; +} + function requestKindFromCanonicalRequestType( requestType: string | undefined, ): "command" | "file-read" | "file-change" | undefined { @@ -967,13 +1001,21 @@ const make = Effect.fn("make")(function* () { ? yield* getSourceProposedPlanReferenceForAcceptedTurnStart(thread.id, eventTurnId) : null; + const isCompactionItemLifecycleEvent = + (event.type === "item.started" || event.type === "item.completed") && + event.payload.itemType === "context_compaction"; + const isCompactedThreadStateEvent = + event.type === "thread.state.changed" && event.payload.state === "compacted"; + if ( event.type === "session.started" || event.type === "session.state.changed" || event.type === "session.exited" || event.type === "thread.started" || event.type === "turn.started" || - event.type === "turn.completed" + event.type === "turn.completed" || + isCompactionItemLifecycleEvent || + isCompactedThreadStateEvent ) { const nextActiveTurnId = event.type === "turn.started" @@ -1002,6 +1044,11 @@ const make = Effect.fn("make")(function* () { // Provider thread/session start notifications can arrive during an // active turn; preserve turn-running state in that case. return activeTurnId !== null ? "running" : "ready"; + default: + // Compaction lifecycle events (item.started/completed with + // context_compaction, or thread.state.changed=compacted) only + // toggle `compacting` — preserve the current session status. + return thread.session?.status ?? (activeTurnId !== null ? "running" : "ready"); } })(); const lastError = @@ -1043,6 +1090,7 @@ const make = Effect.fn("make")(function* () { runtimeMode: thread.session?.runtimeMode ?? "full-access", activeTurnId: nextActiveTurnId, lastError, + compacting: deriveNextCompacting(event, thread.session?.compacting ?? false), updatedAt: now, }, createdAt: now, @@ -1213,6 +1261,7 @@ const make = Effect.fn("make")(function* () { runtimeMode: thread.session?.runtimeMode ?? "full-access", activeTurnId: eventTurnId ?? null, lastError: runtimeErrorMessage, + compacting: thread.session?.compacting ?? false, updatedAt: now, }, createdAt: now, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index b81a1df689e..3ae48b6d342 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -23,6 +23,7 @@ import { ApprovalRequestId, type CanonicalItemType, type CanonicalRequestType, + CLAUDE_COMPACTING_REASON, EventId, type ProviderApprovalDecision, ProviderItemId, @@ -2170,7 +2171,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( type: "session.state.changed", payload: { state: message.status === "compacting" ? "waiting" : "running", - reason: `status:${message.status ?? "active"}`, + reason: + message.status === "compacting" + ? CLAUDE_COMPACTING_REASON + : `status:${message.status ?? "active"}`, detail: message, }, }); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 29dc11ecef8..2c0cd880eb3 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -341,6 +341,7 @@ function createSnapshotForTargetUser(options: { runtimeMode: "full-access", activeTurnId: null, lastError: null, + compacting: false, updatedAt: NOW_ISO, }, }, @@ -407,6 +408,7 @@ function addThreadToSnapshot( runtimeMode: "full-access", activeTurnId: null, lastError: null, + compacting: false, updatedAt: NOW_ISO, }, }, @@ -465,6 +467,7 @@ function createThreadSessionSetEvent(threadId: ThreadId, sequence: number): Orch runtimeMode: "full-access", activeTurnId: `turn-${threadId}` as TurnId, lastError: null, + compacting: false, updatedAt: NOW_ISO, }, }, @@ -716,6 +719,7 @@ function createSnapshotWithSecondaryProject(options?: { runtimeMode: "full-access", activeTurnId: null, lastError: null, + compacting: false, updatedAt: isoAt(31), }, archivedAt: null, @@ -749,6 +753,7 @@ function createSnapshotWithSecondaryProject(options?: { runtimeMode: "full-access", activeTurnId: null, lastError: null, + compacting: false, updatedAt: isoAt(25), }, archivedAt: isoAt(26), diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index f5c4ad3b4c1..d6abc0d529e 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -450,6 +450,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { createdAt: "2026-03-29T00:00:00.000Z", updatedAt: "2026-03-29T00:00:10.000Z", orchestrationStatus: "idle" as const, + compacting: false, }; it("does not clear local dispatch before server state changes", () => { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 94764223da1..7bbf0b30a36 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1404,6 +1404,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: threadError: activeThread?.error, }); const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const isCompacting = activeThread?.session?.compacting === true; const isThreadHydrating = activeThread !== undefined && !isThreadHydrated(activeThread); const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( @@ -4901,6 +4902,7 @@ export default function ChatView({ threadId, environmentId: environmentIdProp }: workspaceRoot={activeWorkspaceRoot} isSendBusy={isSendBusy} isPreparingWorktree={isPreparingWorktree} + isCompacting={isCompacting} onSubagentSelect={onSubagentSelect} pendingApprovals={pendingApprovals} editingUserMessageId={editingUserMessageId} diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 0a8559dda78..327fb4fbe86 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -163,6 +163,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { runtimeMode: "full-access", activeTurnId: null, lastError: null, + compacting: false, updatedAt: NOW_ISO, }, }, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index da0689debf2..40eb53b2646 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -406,6 +406,7 @@ describe("resolveThreadStatusPill", () => { createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:00:00.000Z", orchestrationStatus: "running" as const, + compacting: false, }, }; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index dfc77f6b60d..17d77f4dcc9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -102,6 +102,7 @@ describe("MessagesTimeline", () => { workspaceRoot={undefined} isSendBusy={false} isPreparingWorktree={false} + isCompacting={false} onSubagentSelect={() => {}} editingUserMessageId={null} editingUserMessageText="" @@ -165,6 +166,7 @@ describe("MessagesTimeline", () => { workspaceRoot={undefined} isSendBusy={false} isPreparingWorktree={false} + isCompacting={false} onSubagentSelect={() => {}} editingUserMessageId={null} editingUserMessageText="" diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4fd2970a225..4bad615752c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -154,6 +154,7 @@ interface MessagesTimelineProps { workspaceRoot: string | undefined; isSendBusy: boolean; isPreparingWorktree: boolean; + isCompacting: boolean; onSubagentSelect: (taskId: string) => void; pendingApprovals?: ReadonlyArray; editingUserMessageId: MessageId | null; @@ -209,6 +210,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, isSendBusy, isPreparingWorktree, + isCompacting, onSubagentSelect, pendingApprovals, editingUserMessageId, @@ -617,9 +619,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ? "Preparing worktree\u2026" : isSendBusy ? "Starting\u2026" - : row.createdAt - ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` - : "Working\u2026"} + : isCompacting + ? "Compacting\u2026" + : row.createdAt + ? `Working for ${formatWorkingTimer(row.createdAt, nowIso) ?? "0s"}` + : "Working\u2026"} )} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 727cdf74848..d08ebce8bd9 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -535,6 +535,7 @@ describe("store read model sync", () => { runtimeMode: "approval-required", activeTurnId: null, lastError: null, + compacting: false, updatedAt: "2026-02-27T00:00:00.000Z", }, }), @@ -958,6 +959,7 @@ describe("incremental orchestration updates", () => { runtimeMode: "full-access", activeTurnId: TurnId.make("turn-1"), lastError: null, + compacting: false, updatedAt: "2026-02-27T00:00:02.000Z", }, }, @@ -1242,6 +1244,7 @@ describe("incremental orchestration updates", () => { runtimeMode: "full-access", activeTurnId: TurnId.make("turn-3"), lastError: null, + compacting: false, updatedAt: "2026-02-27T00:00:04.000Z", }, }), diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 40cfb45d057..3af1539e006 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -122,6 +122,7 @@ function mapSession(session: OrchestrationSession): ThreadSession { activeTurnId: session.activeTurnId ?? undefined, createdAt: session.updatedAt, updatedAt: session.updatedAt, + compacting: session.compacting, ...(session.lastError ? { lastError: session.lastError } : {}), }; } @@ -1426,6 +1427,7 @@ function applyEnvironmentOrchestrationEvent( status: "closed", orchestrationStatus: "stopped", activeTurnId: undefined, + compacting: false, updatedAt: event.payload.createdAt, }, updatedAt: event.occurredAt, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 65718dc5794..358e2b4137b 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -171,4 +171,5 @@ export interface ThreadSession { updatedAt: string; lastError?: string; orchestrationStatus: OrchestrationSessionStatus; + compacting: boolean; } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index b3c9b066edb..abf902d8275 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -218,10 +218,13 @@ export const OrchestrationSession = Schema.Struct({ runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))), activeTurnId: Schema.NullOr(TurnId), lastError: Schema.NullOr(TrimmedNonEmptyString), + compacting: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), updatedAt: IsoDateTime, }); export type OrchestrationSession = typeof OrchestrationSession.Type; +export const CLAUDE_COMPACTING_REASON = "status:compacting" as const; + export const OrchestrationCheckpointFile = Schema.Struct({ path: TrimmedNonEmptyString, kind: TrimmedNonEmptyString,