authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-03 21:48:28-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 21:48:28-04:00
logc5ba51d629b3813182cf3e161cc3f23b1e541dc3
tree8eed125300086fb33785b9293da86abc385e31e0
parent6f405370c8e552da9dcfdd6922e85789fd918340
signature Signed by PGP key B5690EEEBB952194

feat(providers): add context compaction command (#9293)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

53 files changed, 1999 insertions(+), 143 deletions(-)

apps/mobile/src/components/AppSymbol.tsx+2
......@@ -16,6 +16,7 @@ import IconArrowUpCircle from "@tabler/icons-react-native/IconArrowUpCircle";
1616import IconArrowUpRight from "@tabler/icons-react-native/IconArrowUpRight";
1717import IconArrowUpRightCircle from "@tabler/icons-react-native/IconArrowUpRightCircle";
1818import IconArrowsMaximize from "@tabler/icons-react-native/IconArrowsMaximize";
19import IconArrowsMinimize from "@tabler/icons-react-native/IconArrowsMinimize";
1920import IconBellRinging from "@tabler/icons-react-native/IconBellRinging";
2021import IconBolt from "@tabler/icons-react-native/IconBolt";
2122import IconBox from "@tabler/icons-react-native/IconBox";
......@@ -99,6 +100,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
99100 "arrow.up": IconArrowUp,
100101 "arrow.up.circle": IconArrowUpCircle,
101102 "arrow.up.left.and.arrow.down.right": IconArrowsMaximize,
103 "arrow.down.right.and.arrow.up.left": IconArrowsMinimize,
102104 "arrow.up.right": IconArrowUpRight,
103105 "arrow.up.right.circle": IconArrowUpRightCircle,
104106 "arrow.uturn.backward": IconArrowBackUp,
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+1
......@@ -319,6 +319,7 @@ export function NewTaskDraftScreen(props: {
319319 : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null,
320320 selectedProviderStatus: flow.selectedProviderStatus,
321321 hasThread: false,
322 hasCompactableConversation: false,
322323 enabled: isComposerFocused && !isComposerInteractionLocked,
323324 onChangeDraftMessage: flow.setPrompt,
324325 onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined,
apps/mobile/src/features/threads/ThreadComposer.tsx+2
......@@ -111,6 +111,7 @@ export interface ThreadComposerProps {
111111 readonly connectionError: string | null;
112112 readonly environmentLabel: string | null;
113113 readonly selectedThread: OrchestrationThreadShell;
114 readonly hasCompactableConversation: boolean;
114115 readonly serverConfig: T3ServerConfig | null;
115116 readonly queueCount: number;
116117 readonly environmentId: EnvironmentId;
......@@ -343,6 +344,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
343344 projectCwd: props.projectCwd,
344345 selectedProviderStatus,
345346 hasThread: true,
347 hasCompactableConversation: props.hasCompactableConversation,
346348 onChangeDraftMessage: props.onChangeDraftMessage,
347349 onUpdateInteractionMode:
348350 selectedProviderStatus?.showInteractionModeToggle === false
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+14
......@@ -100,6 +100,7 @@ export interface ThreadDetailScreenProps {
100100 readonly environmentLabel: string | null;
101101 readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;
102102 readonly activeWorkStartedAt: string | null;
103 readonly isCompacting: boolean;
103104 readonly activePendingApproval: PendingApproval | null;
104105 readonly respondingApprovalId: ApprovalRequestId | null;
105106 readonly activePendingUserInput: PendingUserInput | null;
......@@ -328,6 +329,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
328329 if (threadSyncLabel !== null) {
329330 return { kind: "syncing", label: threadSyncLabel };
330331 }
332 if (props.isCompacting && contentPresentationKind === "ready") {
333 return { kind: "compacting" };
334 }
331335 if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") {
332336 return { kind: "working", startedAt: props.activeWorkStartedAt };
333337 }
......@@ -335,6 +339,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
335339 })();
336340 const showWorkingControl = floatingStatus !== null;
337341 const selectedThreadFeed = props.selectedThreadFeed;
342 const hasCompactableConversation =
343 selectedThreadFeed.some(
344 (entry) =>
345 entry.type === "message" &&
346 entry.message.role === "user" &&
347 ((entry.message.attachments?.length ?? 0) > 0 ||
348 entry.message.text.trim().toLowerCase() !== "/compact"),
349 ) ||
350 (Boolean(props.loadEarlier) && props.selectedThread.latestUserMessageAt !== null);
338351 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
339352 const composerOverlapHeight = composerChrome + composerBottomInset;
340353 // While a user-input request is pending, the questionnaire owns the
......@@ -820,6 +833,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
820833 connectionError={props.connectionError}
821834 environmentLabel={props.environmentLabel}
822835 selectedThread={props.selectedThread}
836 hasCompactableConversation={hasCompactableConversation && !props.isCompacting}
823837 serverConfig={props.serverConfig}
824838 queueCount={props.selectedThreadQueueCount}
825839 environmentId={props.environmentId}
apps/mobile/src/features/threads/ThreadFeed.tsx+27
......@@ -144,6 +144,7 @@ import {
144144} from "@t3tools/mobile-markdown-text/links";
145145import {
146146 deriveThreadFeedPresentation,
147 isContextCompactionActivityGroup,
147148 type ThreadFeedEntry,
148149 type ThreadFeedLatestTurn,
149150} from "../../lib/threadActivity";
......@@ -1557,6 +1558,29 @@ function renderFeedEntry(
15571558 );
15581559 }
15591560
1561 if (entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) {
1562 const label = entry.activities[0]!.summary;
1563 return (
1564 <View
1565 accessible
1566 accessibilityLabel={label}
1567 className="mb-3 flex-row items-center gap-3 px-1 py-1"
1568 >
1569 <View className="h-px flex-1 bg-adaptive-neutral-200-a80-white-a8" />
1570 <View className="shrink-0 flex-row items-center gap-1.5">
1571 <SymbolView
1572 name="arrow.down.right.and.arrow.up.left"
1573 size={12}
1574 tintColor={iconSubtleColor}
1575 type="monochrome"
1576 />
1577 <Text className="font-t3-medium text-xs text-foreground-muted">{label}</Text>
1578 </View>
1579 <View className="h-px flex-1 bg-adaptive-neutral-200-a80-white-a8" />
1580 </View>
1581 );
1582 }
1583
15601584 if (entry.type === "message") {
15611585 const { message } = entry;
15621586 const isUser = message.role === "user";
......@@ -2721,6 +2745,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
27212745 case "work-toggle":
27222746 return WORK_GROUP_TOGGLE_HEIGHT;
27232747 case "activity-group":
2748 if (isContextCompactionActivityGroup(entry)) {
2749 return undefined;
2750 }
27242751 // Expanded rows append a variable detail block — fall back to
27252752 // measurement for those groups.
27262753 return entry.activities.some((activity) => expandedWorkRows[activity.id])
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+1
......@@ -775,6 +775,7 @@ function ThreadRouteContent(
775775 environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null}
776776 selectedThreadFeed={composer.selectedThreadFeed}
777777 activeWorkStartedAt={composer.activeWorkStartedAt}
778 isCompacting={composer.isCompacting}
778779 activePendingApproval={requests.activePendingApproval}
779780 respondingApprovalId={requests.respondingApprovalId}
780781 activePendingUserInput={requests.activePendingUserInput}
apps/mobile/src/features/threads/floating-working-control.tsx+24-1
......@@ -13,6 +13,7 @@ import Animated, {
1313import { withUniwind } from "uniwind";
1414
1515import { AppText as Text } from "../../components/AppText";
16import { SymbolView } from "../../components/AppSymbol";
1617import { ControlPill } from "../../components/ControlPill";
1718import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
1819
......@@ -46,7 +47,8 @@ export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOS
4647 */
4748export type FloatingWorkingStatus =
4849 | { readonly kind: "working"; readonly startedAt: string }
49 | { readonly kind: "syncing"; readonly label: string };
50 | { readonly kind: "syncing"; readonly label: string }
51 | { readonly kind: "compacting" };
5052
5153export function FloatingWorkingControl(props: {
5254 readonly colorScheme: "light" | "dark";
......@@ -161,6 +163,24 @@ export function FloatingWorkingControl(props: {
161163 );
162164}
163165
166function CompactingLabel() {
167 return (
168 <View
169 accessible
170 accessibilityLabel="Compacting"
171 className="h-11 flex-row items-center gap-1.5 px-4"
172 >
173 <SymbolView
174 name="arrow.down.right.and.arrow.up.left"
175 size={13}
176 tintColorClassName="foreground"
177 type="monochrome"
178 />
179 <Text className="font-t3-medium text-xs text-foreground">Compacting…</Text>
180 </View>
181 );
182}
183
164184function FloatingStatusLabel(props: { readonly status: FloatingWorkingStatus }) {
165185 if (props.status.kind === "syncing") {
166186 return (
......@@ -174,6 +194,9 @@ function FloatingStatusLabel(props: { readonly status: FloatingWorkingStatus })
174194 </View>
175195 );
176196 }
197 if (props.status.kind === "compacting") {
198 return <CompactingLabel />;
199 }
177200 return <WorkingDuration startedAt={props.status.startedAt} />;
178201}
179202
apps/mobile/src/features/threads/use-composer-command-menu.ts+6
......@@ -35,6 +35,7 @@ export function buildComposerSlashCommandItems(input: {
3535 readonly query: string;
3636 readonly atMessageStart: boolean;
3737 readonly hasThread: boolean;
38 readonly hasCompactableConversation?: boolean;
3839 readonly allowInteractionMode: boolean;
3940 readonly selectedProviderStatus: Pick<
4041 ServerProvider,
......@@ -76,6 +77,7 @@ export function buildComposerSlashCommandItems(input: {
7677 if (!input.atMessageStart) return items;
7778 for (const command of input.selectedProviderStatus?.slashCommands ?? []) {
7879 if (!command.name.toLowerCase().includes(query)) continue;
80 if (command.name === "compact" && !input.hasCompactableConversation) continue;
7981 if (
8082 !input.hasThread &&
8183 input.selectedProviderStatus?.driver === "codex" &&
......@@ -140,6 +142,7 @@ export function useComposerCommandMenu({
140142 projectCwd,
141143 selectedProviderStatus,
142144 hasThread,
145 hasCompactableConversation,
143146 enabled = true,
144147 onChangeDraftMessage,
145148 onUpdateInteractionMode,
......@@ -150,6 +153,7 @@ export function useComposerCommandMenu({
150153 readonly projectCwd: string | null;
151154 readonly selectedProviderStatus: ServerProvider | null;
152155 readonly hasThread: boolean;
156 readonly hasCompactableConversation: boolean;
153157 readonly enabled?: boolean;
154158 readonly onChangeDraftMessage: (value: string) => void;
155159 readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void;
......@@ -262,6 +266,7 @@ export function useComposerCommandMenu({
262266 query: q,
263267 atMessageStart: trigger.rangeStart === 0,
264268 hasThread,
269 hasCompactableConversation,
265270 allowInteractionMode: onUpdateInteractionMode !== undefined,
266271 selectedProviderStatus,
267272 });
......@@ -379,6 +384,7 @@ export function useComposerCommandMenu({
379384 return [];
380385 }, [
381386 hasThread,
387 hasCompactableConversation,
382388 onUpdateInteractionMode,
383389 pathSearch.entries,
384390 selectedProviderStatus,
apps/mobile/src/lib/threadActivity.test.ts+27
......@@ -353,6 +353,33 @@ function makeThread(
353353}
354354
355355describe("buildThreadFeed", () => {
356 it("keeps context compaction as a standalone timeline row", () => {
357 const thread = makeThread({
358 id: ThreadId.make("thread-context-compaction"),
359 projectId: ProjectId.make("project-1"),
360 title: "Context compaction",
361 activities: [
362 makeActivity({
363 id: EventId.make("context-compaction"),
364 kind: "context-compaction",
365 tone: "info",
366 summary: "Compacted context 899K → 19K tokens",
367 createdAt: "2026-09-01T00:00:00.000Z",
368 turnId: TurnId.make("turn-context-compaction"),
369 }),
370 ],
371 });
372
373 const presented = deriveThreadFeedPresentation(buildThreadFeed(thread), null, new Set());
374 expect(presented).toMatchObject([
375 {
376 type: "activity-group",
377 id: "context-compaction",
378 activities: [{ summary: "Compacted context 899K → 19K tokens" }],
379 },
380 ]);
381 });
382
356383 it("keeps long Claude commands expandable without repeating them in full detail", () => {
357384 const command = `printf 'first line\nsecond line'\n&& printf done`;
358385 const thread = makeThread({
apps/mobile/src/lib/threadActivity.ts+28
......@@ -176,6 +176,15 @@ export type ThreadFeedLatestTurn = Pick<
176176 "turnId" | "state" | "startedAt" | "completedAt"
177177>;
178178
179export function isContextCompactionActivityGroup(
180 entry: Extract<ThreadFeedEntry, { readonly type: "activity-group" }>,
181): boolean {
182 return (
183 entry.activities.length === 1 &&
184 entry.activities[0]?.workEntry.sourceActivityKind === "context-compaction"
185 );
186}
187
179188function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null {
180189 switch (requestType) {
181190 case "command_execution_approval":
......@@ -1266,6 +1275,18 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th
12661275 continue;
12671276 }
12681277
1278 if (entry.activity.workEntry.sourceActivityKind === "context-compaction") {
1279 grouped.push({
1280 type: "activity-group",
1281 id: entry.id,
1282 createdAt: entry.createdAt,
1283 turnId: entry.turnId,
1284 activities: [entry.activity],
1285 });
1286 openGroupActivities = null;
1287 continue;
1288 }
1289
12691290 if (openGroupActivities !== null && openGroupTurnId === entry.turnId) {
12701291 openGroupActivities.push(entry.activity);
12711292 continue;
......@@ -1345,6 +1366,9 @@ function deriveThreadFeedTurnFolds(
13451366 pendingUserBoundary = entry.message.createdAt;
13461367 continue;
13471368 }
1369 if (entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) {
1370 continue;
1371 }
13481372 const turnId =
13491373 entry.type === "message" && entry.message.role === "assistant"
13501374 ? entry.message.turnId
......@@ -1504,6 +1528,10 @@ function appendPresentedFeedEntry(
15041528 result.push(entry);
15051529 return;
15061530 }
1531 if (isContextCompactionActivityGroup(entry)) {
1532 result.push(entry);
1533 return;
1534 }
15071535
15081536 const activities = omitSupersededLifecycleMarkers(
15091537 entry.activities.filter(
apps/mobile/src/state/use-thread-composer-state.ts+45-1
......@@ -56,7 +56,7 @@ import { setPendingConnectionError } from "../state/use-remote-environment-regis
5656import { useSelectedThreadDetail } from "../state/use-thread-detail";
5757import { useThreadSelection } from "../state/use-thread-selection";
5858import { enqueueThreadOutboxMessage } from "./thread-outbox";
59import { useThreadOutboxMessages } from "./use-thread-outbox";
59import { dispatchingQueuedMessageIdAtom, useThreadOutboxMessages } from "./use-thread-outbox";
6060import { threadEnvironment } from "./threads";
6161import { useAtomCommand } from "./use-atom-command";
6262import {
......@@ -107,6 +107,7 @@ export function useThreadComposerState() {
107107 const selectedThreadDetail = useSelectedThreadDetail();
108108 const composerDrafts = useAtomValue(composerDraftsAtom);
109109 const queuedMessagesByThreadKey = useThreadOutboxMessages();
110 const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);
110111 const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<
111112 Record<string, ReadonlyArray<CodexFeedbackSubmission>>
112113 >({});
......@@ -170,6 +171,48 @@ export function useThreadComposerState() {
170171 };
171172 }, [selectedThreadDetail, selectedThreadShell]);
172173
174 const isCompacting = useMemo(() => {
175 const queuedMessage = selectedThreadQueuedMessages.findLast(
176 (message) =>
177 message.messageId === dispatchingQueuedMessageId &&
178 message.text.trim().toLowerCase() === "/compact" &&
179 message.attachments.length === 0,
180 );
181 const latestCompactMessage = selectedThreadDetail?.messages.findLast(
182 (message) =>
183 message.role === "user" &&
184 message.text.trim().toLowerCase() === "/compact" &&
185 !message.attachments?.length,
186 );
187 const compactRequestIsActive =
188 latestCompactMessage !== undefined &&
189 (latestCompactMessage.createdAt >
190 (selectedThread?.latestTurn?.requestedAt ?? latestCompactMessage.createdAt) ||
191 (selectedThread?.latestTurn?.state === "running" &&
192 latestCompactMessage.createdAt === selectedThread.latestTurn.requestedAt));
193 const compactionSettled = selectedThreadDetail?.activities.some((activity) => {
194 if (!["context-compaction", "provider.turn.start.failed"].includes(activity.kind))
195 return false;
196 const payload =
197 typeof activity.payload === "object" && activity.payload !== null
198 ? (activity.payload as { readonly requestId?: unknown })
199 : null;
200 return payload?.requestId === latestCompactMessage?.id;
201 });
202 return (
203 queuedMessage !== undefined ||
204 ((selectedThread?.session?.status === "starting" ||
205 selectedThread?.session?.status === "running") &&
206 compactRequestIsActive &&
207 !compactionSettled)
208 );
209 }, [
210 dispatchingQueuedMessageId,
211 selectedThread,
212 selectedThreadDetail,
213 selectedThreadQueuedMessages,
214 ]);
215
173216 const activeWorkStartedAt = useMemo(() => {
174217 const selectedThread = selectedThreadDetail ?? selectedThreadShell;
175218 if (!selectedThread) {
......@@ -526,6 +569,7 @@ export function useThreadComposerState() {
526569 selectedThreadFeed,
527570 selectedThreadQueueCount,
528571 activeWorkStartedAt,
572 isCompacting,
529573 draftMessage,
530574 draftAttachments,
531575 modelSelection,
apps/mobile/src/state/use-thread-outbox-drain.ts+2-6
......@@ -13,7 +13,7 @@ import {
1313} from "@t3tools/contracts";
1414import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
1515import * as Cause from "effect/Cause";
16import { AsyncResult, Atom } from "effect/unstable/reactivity";
16import { AsyncResult } from "effect/unstable/reactivity";
1717import { useCallback, useEffect, useRef, useState } from "react";
1818
1919import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities";
......@@ -61,6 +61,7 @@ import {
6161} from "./use-composer-drafts";
6262import { useAtomCommand } from "./use-atom-command";
6363import {
64 dispatchingQueuedMessageIdAtom,
6465 editingQueuedMessageIdsAtom,
6566 useThreadOutboxMessages,
6667 useThreadOutboxShellStatuses,
......@@ -70,11 +71,6 @@ import {
7071 useRemoteConnectionStatus,
7172} from "./use-remote-environment-registry";
7273
73export const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe(
74 Atom.keepAlive,
75 Atom.withLabel("mobile:thread-outbox:dispatching-message-id"),
76);
77
7874function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void {
7975 appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId);
8076}
apps/mobile/src/state/use-thread-outbox.ts+5
......@@ -31,6 +31,11 @@ export const editingQueuedMessageIdsAtom = Atom.make<Readonly<Record<MessageId,
3131 Atom.withLabel("mobile:thread-outbox:editing-message-ids"),
3232);
3333
34export const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe(
35 Atom.keepAlive,
36 Atom.withLabel("mobile:thread-outbox:dispatching-message-id"),
37);
38
3439export function holdEditingQueuedMessage(messageId: MessageId): void {
3540 const current = appAtomRegistry.get(editingQueuedMessageIdsAtom);
3641 if (current[messageId]) {
apps/server/integration/orphanedProviderSessionStartup.integration.test.ts+1
......@@ -110,6 +110,7 @@ const startupDependencies = Layer.mergeAll(
110110 Layer.succeed(ProviderService.ProviderService, {
111111 startSession: () => Effect.die("unused"),
112112 sendTurn: () => Effect.die("unused"),
113 compactThread: () => Effect.die("unused"),
113114 interruptTurn: () => Effect.die("unused"),
114115 respondToRequest: () => Effect.die("unused"),
115116 respondToUserInput: () => Effect.die("unused"),
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+1
......@@ -113,6 +113,7 @@ function createProviderServiceHarness(
113113 const service: ProviderServiceShape = {
114114 startSession: () => unsupported(),
115115 sendTurn: () => unsupported(),
116 compactThread: () => unsupported(),
116117 interruptTurn: () => unsupported(),
117118 respondToRequest: () => unsupported(),
118119 respondToUserInput: () => unsupported(),
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+63
......@@ -3196,6 +3196,69 @@ it.layer(makeProjectionPipelinePrefixedTestLayer("t3-pending-turn-terminal-test-
31963196 assert.deepEqual(pendingRows, []);
31973197 }),
31983198 );
3199
3200 it.effect("only clears the compact request that produced the compaction activity", () =>
3201 Effect.gen(function* () {
3202 const projectionPipeline = yield* OrchestrationProjectionPipeline;
3203 const eventStore = yield* OrchestrationEventStore;
3204 const sql = yield* SqlClient.SqlClient;
3205 const threadId = ThreadId.make("thread-compaction-correlation");
3206
3207 for (const [index, messageId] of ["compact-request", "new-message"].entries()) {
3208 const createdAt = `2026-02-26T15:00:0${index}.000Z`;
3209 yield* eventStore.append({
3210 type: "thread.turn-start-requested",
3211 eventId: EventId.make(`evt-compaction-pending-${index}`),
3212 aggregateKind: "thread",
3213 aggregateId: threadId,
3214 occurredAt: createdAt,
3215 commandId: CommandId.make(`cmd-compaction-pending-${index}`),
3216 causationEventId: null,
3217 correlationId: CorrelationId.make(`cmd-compaction-pending-${index}`),
3218 metadata: {},
3219 payload: {
3220 threadId,
3221 messageId: MessageId.make(messageId),
3222 runtimeMode: "full-access",
3223 createdAt,
3224 },
3225 });
3226 }
3227 yield* eventStore.append({
3228 type: "thread.activity-appended",
3229 eventId: EventId.make("evt-compaction-stale"),
3230 aggregateKind: "thread",
3231 aggregateId: threadId,
3232 occurredAt: "2026-02-26T15:00:02.000Z",
3233 commandId: CommandId.make("cmd-compaction-stale"),
3234 causationEventId: null,
3235 correlationId: CorrelationId.make("cmd-compaction-stale"),
3236 metadata: {},
3237 payload: {
3238 threadId,
3239 activity: {
3240 id: EventId.make("activity-compaction-stale"),
3241 tone: "info",
3242 kind: "context-compaction",
3243 summary: "Context compacted",
3244 payload: { requestId: "compact-request" },
3245 turnId: null,
3246 createdAt: "2026-02-26T15:00:02.000Z",
3247 },
3248 },
3249 });
3250 yield* projectionPipeline.bootstrap;
3251
3252 const pendingRows = yield* sql<{ readonly messageId: string }>`
3253 SELECT pending_message_id AS "messageId"
3254 FROM projection_turns
3255 WHERE thread_id = ${threadId}
3256 AND turn_id IS NULL
3257 AND state = 'pending'
3258 `;
3259 assert.deepEqual(pendingRows, [{ messageId: "new-message" }]);
3260 }),
3261 );
31993262 },
32003263);
32013264
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+48
......@@ -1269,6 +1269,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
12691269 return;
12701270
12711271 case "thread.turn-start-requested": {
1272 const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({
1273 threadId: event.payload.threadId,
1274 });
1275 if (Option.isSome(pendingTurnStart)) {
1276 const pendingMessage = yield* projectionThreadMessageRepository.getByMessageId({
1277 messageId: pendingTurnStart.value.messageId,
1278 });
1279 if (
1280 Option.isSome(pendingMessage) &&
1281 pendingMessage.value.role === "user" &&
1282 (pendingMessage.value.attachments?.length ?? 0) === 0 &&
1283 pendingMessage.value.text.trim().toLowerCase() === "/compact"
1284 ) {
1285 return;
1286 }
1287 }
12721288 yield* projectionTurnRepository.replacePendingTurnStart({
12731289 threadId: event.payload.threadId,
12741290 messageId: event.payload.messageId,
......@@ -1279,10 +1295,42 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
12791295 return;
12801296 }
12811297
1298 case "thread.activity-appended": {
1299 if (event.payload.activity.kind === "context-compaction") {
1300 const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId(
1301 event.payload,
1302 );
1303 if (
1304 Option.isNone(pendingTurnStart) ||
1305 String(pendingTurnStart.value.messageId) !==
1306 extractActivityRequestId(event.payload.activity.payload)
1307 ) {
1308 return;
1309 }
1310 yield* projectionTurnRepository.deletePendingTurnStartByThreadId(event.payload);
1311 return;
1312 }
1313 if (event.payload.activity.kind !== "provider.turn.start.failed") return;
1314 const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId(
1315 event.payload,
1316 );
1317 if (
1318 Option.isNone(pendingTurnStart) ||
1319 String(pendingTurnStart.value.messageId) !==
1320 extractActivityRequestId(event.payload.activity.payload)
1321 ) {
1322 return;
1323 }
1324 yield* projectionTurnRepository.deletePendingTurnStartByThreadId(event.payload);
1325 return;
1326 }
1327
12821328 case "thread.session-set": {
12831329 const turnId = event.payload.session.activeTurnId;
12841330 if (turnId === null || event.payload.session.status !== "running") {
12851331 if (
1332 (event.payload.session.status === "ready" &&
1333 event.commandId?.startsWith("server:provider-session-set:") === true) ||
12861334 event.payload.session.status === "error" ||
12871335 event.payload.session.status === "stopped" ||
12881336 event.payload.session.status === "interrupted"
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+279-1
......@@ -173,6 +173,8 @@ describe("ProviderCommandReactor", () => {
173173 readonly titleRegenerationCompletionDispatchFailures?: number;
174174 readonly titleRegenerationBeforeStart?: "one" | "two";
175175 readonly serverActivation?: Effect.Effect<void>;
176 readonly beforeReadySessionDispatch?: () => Effect.Effect<void>;
177 readonly compactThreadEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>;
176178 readonly interruptTurnEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>;
177179 readonly stopSessionEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>;
178180 readonly startSessionEffect?: (
......@@ -264,6 +266,7 @@ describe("ProviderCommandReactor", () => {
264266 turnId: asTurnId("turn-1"),
265267 }),
266268 );
269 const compactThread = vi.fn((_: ThreadId) => input?.compactThreadEffect?.() ?? Effect.void);
267270 const interruptTurn = vi.fn((_: unknown) => input?.interruptTurnEffect?.() ?? Effect.void);
268271 const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void);
269272 const respondToUserInput = vi.fn<ProviderServiceShape["respondToUserInput"]>(() => Effect.void);
......@@ -349,6 +352,7 @@ describe("ProviderCommandReactor", () => {
349352 const service: ProviderServiceShape = {
350353 startSession: startSession as ProviderServiceShape["startSession"],
351354 sendTurn: sendTurn as ProviderServiceShape["sendTurn"],
355 compactThread,
352356 interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"],
353357 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],
354358 respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"],
......@@ -424,7 +428,11 @@ describe("ProviderCommandReactor", () => {
424428 return Effect.die(new Error("Injected title regeneration completion failure"));
425429 }
426430 }
427 return engine.dispatch(command);
431 return (
432 command.type === "thread.session.set" && command.session.status === "ready"
433 ? (input?.beforeReadySessionDispatch?.() ?? Effect.void)
434 : Effect.void
435 ).pipe(Effect.andThen(engine.dispatch(command)));
428436 },
429437 get streamDomainEvents() {
430438 return engine.streamDomainEvents;
......@@ -564,6 +572,7 @@ describe("ProviderCommandReactor", () => {
564572 tryHandlePromptCommand,
565573 startSession,
566574 sendTurn,
575 compactThread,
567576 interruptTurn,
568577 respondToRequest,
569578 respondToUserInput,
......@@ -877,6 +886,275 @@ describe("ProviderCommandReactor", () => {
877886 }),
878887 );
879888
889 effectIt.effect("rejects /compact without conversation context", () =>
890 Effect.gen(function* () {
891 const harness = yield* Effect.promise(() => createHarness());
892 yield* harness.engine.dispatch({
893 type: "thread.turn.start",
894 commandId: CommandId.make("cmd-empty-compact"),
895 threadId: ThreadId.make("thread-1"),
896 message: {
897 messageId: asMessageId("user-message-empty-compact"),
898 role: "user",
899 text: "/compact",
900 attachments: [],
901 },
902 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
903 runtimeMode: "approval-required",
904 createdAt: "2026-01-01T00:00:00.000Z",
905 });
906 yield* Effect.promise(() => harness.drain());
907 expect(harness.compactThread).not.toHaveBeenCalled();
908 }),
909 );
910
911 effectIt.effect("keeps turns blocked until compaction restores the session", () =>
912 Effect.gen(function* () {
913 const readyDispatchStarted = yield* Deferred.make<void>();
914 const releaseReadyDispatch = yield* Deferred.make<void>();
915 let blockReadyDispatch = false;
916 const harness = yield* Effect.promise(() =>
917 createHarness({
918 beforeReadySessionDispatch: () =>
919 blockReadyDispatch
920 ? Deferred.succeed(readyDispatchStarted, undefined).pipe(
921 Effect.andThen(Deferred.await(releaseReadyDispatch)),
922 )
923 : Effect.void,
924 }),
925 );
926 const threadId = ThreadId.make("thread-1");
927 const now = "2026-01-01T00:00:00.000Z";
928 const dispatchTurn = (id: string, text: string, createdAt: string) =>
929 harness.engine.dispatch({
930 type: "thread.turn.start",
931 commandId: CommandId.make(`cmd-${id}`),
932 threadId,
933 message: {
934 messageId: asMessageId(`user-message-${id}`),
935 role: "user",
936 text,
937 attachments: [],
938 },
939 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
940 runtimeMode: "approval-required",
941 createdAt,
942 });
943
944 yield* dispatchTurn("before-blocked-compact", "hello", now);
945 yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1));
946 yield* harness.engine.dispatch({
947 type: "thread.session.set",
948 commandId: CommandId.make("cmd-session-ready-before-blocked-compact"),
949 threadId,
950 session: {
951 threadId,
952 status: "ready",
953 providerName: "codex",
954 providerInstanceId: ProviderInstanceId.make("codex"),
955 runtimeMode: "approval-required",
956 activeTurnId: null,
957 lastError: null,
958 updatedAt: now,
959 },
960 createdAt: now,
961 });
962
963 blockReadyDispatch = true;
964 yield* dispatchTurn("blocked-compact", "/compact", "2026-01-01T00:00:01.000Z");
965 yield* Deferred.await(readyDispatchStarted);
966
967 yield* dispatchTurn("during-compact-recovery", "too soon", "2026-01-01T00:00:02.000Z");
968 yield* Effect.promise(() =>
969 waitFor(async () => {
970 const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId);
971 return (
972 thread?.activities.some(
973 (activity) => activity.kind === "provider.turn.start.failed",
974 ) === true
975 );
976 }),
977 );
978 expect(harness.sendTurn).toHaveBeenCalledTimes(1);
979 expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([
980 { threadId: "thread-1" },
981 ]);
982
983 yield* Deferred.succeed(releaseReadyDispatch, undefined);
984 yield* Effect.promise(() =>
985 waitFor(async () => {
986 const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId);
987 return thread?.session?.status === "ready";
988 }),
989 );
990 }),
991 );
992
993 effectIt.effect("does not overwrite concurrent session state after compaction failure", () =>
994 Effect.gen(function* () {
995 const releaseCompaction = yield* Deferred.make<void>();
996 const releaseRunningCompaction = yield* Deferred.make<void>();
997 const releaseFailedStop = yield* Deferred.make<void>();
998 let compactionCount = 0;
999 const harness = yield* Effect.promise(() =>
1000 createHarness({
1001 compactThreadEffect: () =>
1002 Deferred.await(
1003 compactionCount++ === 0 ? releaseCompaction : releaseRunningCompaction,
1004 ).pipe(Effect.andThen(Effect.die("Compaction stopped"))),
1005 stopSessionEffect: () =>
1006 Deferred.await(releaseFailedStop).pipe(
1007 Effect.andThen(
1008 Effect.fail(
1009 new ProviderAdapterRequestError({
1010 provider: "codex",
1011 method: "session.stop",
1012 detail: "provider stop failed",
1013 }),
1014 ),
1015 ),
1016 ),
1017 }),
1018 );
1019 const threadId = ThreadId.make("thread-1");
1020 const now = "2026-01-01T00:00:00.000Z";
1021 const dispatchCompact = (suffix: string, createdAt: string) =>
1022 harness.engine.dispatch({
1023 type: "thread.turn.start",
1024 commandId: CommandId.make(`cmd-compact-${suffix}`),
1025 threadId,
1026 message: {
1027 messageId: asMessageId(`user-message-compact-${suffix}`),
1028 role: "user",
1029 text: "/compact",
1030 attachments: [],
1031 },
1032 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1033 runtimeMode: "approval-required",
1034 createdAt,
1035 });
1036
1037 yield* harness.engine.dispatch({
1038 type: "thread.turn.start",
1039 commandId: CommandId.make("cmd-message-before-compact"),
1040 threadId,
1041 message: {
1042 messageId: asMessageId("user-message-before-compact"),
1043 role: "user",
1044 text: "hello",
1045 attachments: [],
1046 },
1047 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1048 runtimeMode: "approval-required",
1049 createdAt: now,
1050 });
1051 yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1));
1052 yield* harness.engine.dispatch({
1053 type: "thread.session.set",
1054 commandId: CommandId.make("cmd-session-ready-before-compact"),
1055 threadId,
1056 session: {
1057 threadId,
1058 status: "ready",
1059 providerName: "codex",
1060 providerInstanceId: ProviderInstanceId.make("codex"),
1061 runtimeMode: "approval-required",
1062 activeTurnId: null,
1063 lastError: null,
1064 updatedAt: now,
1065 },
1066 createdAt: now,
1067 });
1068 yield* dispatchCompact("before-stop", now);
1069 yield* Effect.promise(() => waitFor(() => harness.compactThread.mock.calls.length === 1));
1070 const compactingThread = (yield* Effect.promise(() => harness.readModel())).threads.find(
1071 (entry) => entry.id === threadId,
1072 );
1073 expect(compactingThread?.session?.status).toBe("starting");
1074 yield* harness.engine.dispatch({
1075 type: "thread.session.stop",
1076 commandId: CommandId.make("cmd-stop-during-compact"),
1077 threadId,
1078 createdAt: "2026-01-01T00:00:01.000Z",
1079 });
1080 yield* Effect.promise(() => waitFor(() => harness.stopSession.mock.calls.length === 1));
1081 yield* Deferred.succeed(releaseCompaction, undefined);
1082 yield* Effect.promise(() =>
1083 waitFor(async () => {
1084 const compactingThread = (await harness.readModel()).threads.find(
1085 (entry) => entry.id === threadId,
1086 );
1087 return (
1088 compactingThread?.activities.some(
1089 (activity) => activity.kind === "provider.turn.start.failed",
1090 ) === true
1091 );
1092 }),
1093 );
1094 const stoppingThread = (yield* Effect.promise(() => harness.readModel())).threads.find(
1095 (entry) => entry.id === threadId,
1096 );
1097 expect(stoppingThread?.session?.status).toBe("starting");
1098 yield* Deferred.succeed(releaseFailedStop, undefined);
1099 yield* Effect.promise(() => harness.drain());
1100
1101 const recoveredThread = (yield* Effect.promise(() => harness.readModel())).threads.find(
1102 (entry) => entry.id === threadId,
1103 );
1104 expect(recoveredThread?.session?.status).toBe("ready");
1105 expect(
1106 recoveredThread?.activities.find(
1107 (activity) => activity.kind === "provider.session.stop.failed",
1108 ),
1109 ).toMatchObject({
1110 summary: "Provider session stop failed",
1111 payload: { detail: "provider stop failed" },
1112 });
1113
1114 yield* dispatchCompact("before-running", "2026-01-01T00:00:02.000Z");
1115 yield* Effect.promise(() => waitFor(() => harness.compactThread.mock.calls.length === 2));
1116 yield* harness.engine.dispatch({
1117 type: "thread.session.stop",
1118 commandId: CommandId.make("cmd-failed-stop-before-compaction-settles"),
1119 threadId,
1120 createdAt: "2026-01-01T00:00:02.500Z",
1121 });
1122 yield* Effect.promise(() =>
1123 waitFor(async () => {
1124 const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId);
1125 return (
1126 thread?.activities.filter(
1127 (activity) => activity.kind === "provider.session.stop.failed",
1128 ).length === 2
1129 );
1130 }),
1131 );
1132 const restartedThread = (yield* Effect.promise(() => harness.readModel())).threads.find(
1133 (entry) => entry.id === threadId,
1134 );
1135 expect(restartedThread?.session?.status).toBe("starting");
1136 const restartedSession = restartedThread?.session;
1137 if (!restartedSession) return yield* Effect.die("Compaction session missing");
1138 yield* harness.engine.dispatch({
1139 type: "thread.session.set",
1140 commandId: CommandId.make("cmd-running-during-compact"),
1141 threadId,
1142 session: {
1143 ...restartedSession,
1144 status: "running",
1145 activeTurnId: asTurnId("compaction-turn"),
1146 updatedAt: "2026-01-01T00:00:03.000Z",
1147 },
1148 createdAt: "2026-01-01T00:00:03.000Z",
1149 });
1150 yield* Deferred.succeed(releaseRunningCompaction, undefined);
1151 yield* Effect.promise(() => harness.drain());
1152 const runningThread = (yield* Effect.promise(() => harness.readModel())).threads.find(
1153 (entry) => entry.id === threadId,
1154 );
1155 expect(runningThread?.session?.status).toBe("running");
1156 }),
1157 );
8801158 effectIt.effect("projects starting before a slow provider session finishes", () =>
8811159 Effect.gen(function* () {
8821160 const releaseStart = yield* Deferred.make<void>();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+211-41
......@@ -17,6 +17,7 @@ import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shar
1717import * as Cache from "effect/Cache";
1818import * as Cause from "effect/Cause";
1919import * as Crypto from "effect/Crypto";
20import * as DateTime from "effect/DateTime";
2021import * as Duration from "effect/Duration";
2122import * as Effect from "effect/Effect";
2223import * as Equal from "effect/Equal";
......@@ -30,7 +31,10 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
3031
3132import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
3233import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts";
33import { ProviderAdapterRequestError } from "../../provider/Errors.ts";
34import {
35 ProviderAdapterRequestError,
36 ProviderAdapterValidationError,
37} from "../../provider/Errors.ts";
3438import type { ProviderServiceError } from "../../provider/Errors.ts";
3539import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
3640import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts";
......@@ -51,6 +55,7 @@ import {
5155import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
5256import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
5357const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
58const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError);
5459const isProviderDriverKind = Schema.is(ProviderDriverKind);
5560
5661type ProviderIntentEvent = Extract<
......@@ -73,6 +78,10 @@ function toNonEmptyProviderInput(value: string | undefined): string | undefined
7378 return normalized && normalized.length > 0 ? normalized : undefined;
7479}
7580
81const isCompactCommandMessage = (message: ThreadTitleMessage): boolean =>
82 message.role === "user" &&
83 (message.attachments?.length ?? 0) === 0 &&
84 message.text.trim().toLowerCase() === "/compact";
7685function mapProviderSessionStatusToOrchestrationStatus(
7786 status: "connecting" | "ready" | "running" | "error" | "closed",
7887): OrchestrationSession["status"] {
......@@ -334,6 +343,8 @@ const make = Effect.gen(function* () {
334343 );
335344
336345 const threadModelSelections = new Map<string, ModelSelection>();
346 const compactingThreadIds = new Set<ThreadId>();
347 const stoppingThreadIds = new Set<ThreadId>();
337348
338349 const appendProviderFailureActivity = (input: {
339350 readonly threadId: ThreadId;
......@@ -377,11 +388,11 @@ const make = Effect.gen(function* () {
377388
378389 const formatFailureDetail = (cause: Cause.Cause<unknown>): string => {
379390 const failReason = cause.reasons.find(Cause.isFailReason);
380 const providerError = isProviderAdapterRequestError(failReason?.error)
381 ? failReason.error
382 : undefined;
383 if (providerError) {
384 return providerError.detail;
391 if (isProviderAdapterRequestError(failReason?.error)) {
392 return failReason.error.detail;
393 }
394 if (isProviderAdapterValidationError(failReason?.error)) {
395 return failReason.error.issue;
385396 }
386397 return Cause.pretty(cause);
387398 };
......@@ -431,6 +442,37 @@ const make = Effect.gen(function* () {
431442 });
432443 });
433444
445 const restoreCompaction = Effect.fnUntraced(function* (threadId: ThreadId, fromRunning = false) {
446 if (stoppingThreadIds.has(threadId)) {
447 compactingThreadIds.delete(threadId);
448 return;
449 }
450 const thread = yield* resolveThread(threadId);
451 if (!thread?.session) return;
452 if (
453 thread.session.status !== "starting" &&
454 thread.session.status !== "ready" &&
455 (!fromRunning || thread.session.status !== "running")
456 )
457 return;
458 const completedAt = DateTime.formatIso(yield* DateTime.now);
459 if (stoppingThreadIds.has(threadId)) {
460 compactingThreadIds.delete(threadId);
461 return;
462 }
463 yield* setThreadSession({
464 threadId,
465 session: {
466 ...thread.session,
467 status: "ready",
468 activeTurnId: null,
469 lastError: null,
470 updatedAt: completedAt,
471 },
472 createdAt: completedAt,
473 });
474 });
475
434476 const resolveProject = Effect.fnUntraced(function* (projectId: ProjectId) {
435477 return yield* projectionSnapshotQuery
436478 .getProjectShellById(projectId)
......@@ -1139,7 +1181,6 @@ const make = Effect.gen(function* () {
11391181 if (!thread) {
11401182 return;
11411183 }
1142
11431184 const message = thread.messages.find((entry) => entry.id === event.payload.messageId);
11441185 if (!message || message.role !== "user") {
11451186 yield* appendProviderFailureActivity({
......@@ -1149,9 +1190,20 @@ const make = Effect.gen(function* () {
11491190 detail: `User message '${event.payload.messageId}' was not found for turn start request.`,
11501191 turnId: null,
11511192 createdAt: event.payload.createdAt,
1193 requestId: event.payload.messageId,
11521194 });
11531195 return;
11541196 }
1197 const appendTurnStartFailure = (summary: string, detail: string) =>
1198 appendProviderFailureActivity({
1199 threadId: event.payload.threadId,
1200 kind: "provider.turn.start.failed",
1201 summary,
1202 detail,
1203 turnId: null,
1204 createdAt: event.payload.createdAt,
1205 requestId: event.payload.messageId,
1206 });
11551207
11561208 const handleTurnStartFailure = (cause: Cause.Cause<unknown>) => {
11571209 if (Cause.hasInterruptsOnly(cause)) {
......@@ -1163,16 +1215,7 @@ const make = Effect.gen(function* () {
11631215 detail,
11641216 createdAt: event.payload.createdAt,
11651217 }).pipe(
1166 Effect.flatMap(() =>
1167 appendProviderFailureActivity({
1168 threadId: event.payload.threadId,
1169 kind: "provider.turn.start.failed",
1170 summary: "Provider turn start failed",
1171 detail,
1172 turnId: null,
1173 createdAt: event.payload.createdAt,
1174 }),
1175 ),
1218 Effect.flatMap(() => appendTurnStartFailure("Provider turn start failed", detail)),
11761219 Effect.asVoid,
11771220 );
11781221 };
......@@ -1242,9 +1285,11 @@ const make = Effect.gen(function* () {
12421285
12431286 yield* ensureThreadWorktree(thread);
12441287
1245 const isFirstUserMessageTurn =
1246 thread.messages.filter((entry) => entry.role === "user").length === 1;
1247 if (isFirstUserMessageTurn) {
1288 const isCompactCommand = isCompactCommandMessage(message);
1289 const nonCompactUserMessageCount = thread.messages.filter(
1290 (entry) => entry.role === "user" && !isCompactCommandMessage(entry),
1291 ).length;
1292 if (nonCompactUserMessageCount === 1 && !isCompactCommand) {
12481293 const project = yield* resolveProject(thread.projectId);
12491294 const generationCwd =
12501295 resolveThreadWorkspaceCwd({
......@@ -1273,6 +1318,98 @@ const make = Effect.gen(function* () {
12731318 }
12741319 }
12751320
1321 let compactionSessionEnsured = false;
1322 const handleCompactionFailure = (cause: Cause.Cause<unknown>) => {
1323 if (Cause.hasInterruptsOnly(cause)) {
1324 return Effect.void;
1325 }
1326 const detail = formatFailureDetail(cause);
1327 if (!compactionSessionEnsured) {
1328 return setThreadSessionErrorOnTurnStartFailure({
1329 threadId: event.payload.threadId,
1330 detail,
1331 createdAt: event.payload.createdAt,
1332 }).pipe(
1333 Effect.flatMap(() => appendTurnStartFailure("Context compaction failed", detail)),
1334 Effect.asVoid,
1335 );
1336 }
1337 return appendTurnStartFailure("Context compaction failed", detail).pipe(
1338 Effect.ensuring(
1339 restoreCompaction(event.payload.threadId).pipe(
1340 Effect.catchCause((restoreCause) =>
1341 Effect.logWarning("failed to restore provider session after compaction failure", {
1342 threadId: event.payload.threadId,
1343 cause: Cause.pretty(restoreCause),
1344 }),
1345 ),
1346 ),
1347 ),
1348 Effect.asVoid,
1349 );
1350 };
1351 const recoverCompactionFailure = (cause: Cause.Cause<unknown>) =>
1352 handleCompactionFailure(cause).pipe(
1353 Effect.catchCause((recoveryCause) =>
1354 Effect.logWarning("provider command reactor failed to recover compaction failure", {
1355 eventType: event.type,
1356 threadId: event.payload.threadId,
1357 cause: Cause.pretty(recoveryCause),
1358 originalCause: Cause.pretty(cause),
1359 }),
1360 ),
1361 );
1362 if (isCompactCommand) {
1363 if (nonCompactUserMessageCount === 0) {
1364 return yield* appendTurnStartFailure(
1365 "Context compaction failed",
1366 "Context compaction requires an existing conversation.",
1367 );
1368 }
1369 const latestThread = yield* resolveThread(event.payload.threadId);
1370 if (
1371 compactingThreadIds.has(event.payload.threadId) ||
1372 latestThread?.session?.status === "starting" ||
1373 latestThread?.session?.status === "running"
1374 ) {
1375 yield* appendTurnStartFailure(
1376 "Context compaction failed",
1377 "Context compaction is unavailable while a provider turn is running.",
1378 );
1379 return;
1380 }
1381 compactingThreadIds.add(event.payload.threadId);
1382 yield* Effect.gen(function* () {
1383 yield* ensureSessionForThread(
1384 event.payload.threadId,
1385 event.payload.createdAt,
1386 event.payload.modelSelection !== undefined
1387 ? { modelSelection: event.payload.modelSelection, pendingTurnStart: true }
1388 : { pendingTurnStart: true },
1389 );
1390 compactionSessionEnsured = true;
1391 if (event.payload.modelSelection !== undefined) {
1392 threadModelSelections.set(event.payload.threadId, event.payload.modelSelection);
1393 }
1394 yield* providerService.compactThread(
1395 event.payload.threadId,
1396 event.payload.modelSelection,
1397 event.payload.messageId,
1398 );
1399 }).pipe(
1400 Effect.andThen(restoreCompaction(event.payload.threadId, true)),
1401 Effect.catchCause(recoverCompactionFailure),
1402 Effect.ensuring(Effect.sync(() => void compactingThreadIds.delete(event.payload.threadId))),
1403 Effect.forkScoped,
1404 );
1405 return;
1406 }
1407 if (compactingThreadIds.has(event.payload.threadId)) {
1408 return yield* appendTurnStartFailure(
1409 "Provider turn start failed",
1410 "Wait for context compaction to finish before sending another message.",
1411 );
1412 }
12761413 const sendTurnRequest = yield* buildSendTurnRequestForThread({
12771414 threadId: event.payload.threadId,
12781415 messageText: message.text,
......@@ -1293,7 +1430,7 @@ const make = Effect.gen(function* () {
12931430
12941431 yield* providerService
12951432 .sendTurn(sendTurnRequest.value)
1296 .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped);
1433 .pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped);
12971434 });
12981435
12991436 const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* (
......@@ -1488,26 +1625,59 @@ const make = Effect.gen(function* () {
14881625 }
14891626
14901627 const now = event.payload.createdAt;
1491 if (thread.session && thread.session.status !== "stopped") {
1492 yield* providerService.stopSession({ threadId: thread.id });
1493 }
1494
1495 yield* setThreadSession({
1496 threadId: thread.id,
1497 session: {
1498 threadId: thread.id,
1499 status: "stopped",
1500 providerName: thread.session?.providerName ?? null,
1501 ...(thread.session?.providerInstanceId !== undefined
1502 ? { providerInstanceId: thread.session.providerInstanceId }
1503 : {}),
1504 runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
1505 activeTurnId: null,
1506 lastError: thread.session?.lastError ?? null,
1507 updatedAt: now,
1508 },
1509 createdAt: now,
1510 });
1628 const wasCompacting = compactingThreadIds.has(thread.id);
1629 stoppingThreadIds.add(thread.id);
1630 const clearStopping = Effect.sync(() => void stoppingThreadIds.delete(thread.id));
1631 yield* (
1632 thread.session && thread.session.status !== "stopped"
1633 ? providerService.stopSession({ threadId: thread.id })
1634 : Effect.void
1635 ).pipe(
1636 Effect.matchCauseEffect({
1637 onFailure: (cause) => {
1638 if (Cause.hasInterruptsOnly(cause)) {
1639 return Effect.interrupt;
1640 }
1641 const detail = formatFailureDetail(cause);
1642 return Effect.sync(() => {
1643 stoppingThreadIds.delete(thread.id);
1644 return wasCompacting && !compactingThreadIds.has(thread.id);
1645 }).pipe(
1646 Effect.flatMap((compactionSettled) =>
1647 compactionSettled ? restoreCompaction(thread.id) : Effect.void,
1648 ),
1649 Effect.andThen(
1650 appendProviderFailureActivity({
1651 threadId: thread.id,
1652 kind: "provider.session.stop.failed",
1653 summary: "Provider session stop failed",
1654 detail,
1655 turnId: null,
1656 createdAt: now,
1657 }),
1658 ),
1659 );
1660 },
1661 onSuccess: () =>
1662 setThreadSession({
1663 threadId: thread.id,
1664 session: {
1665 threadId: thread.id,
1666 status: "stopped",
1667 providerName: thread.session?.providerName ?? null,
1668 ...(thread.session?.providerInstanceId !== undefined
1669 ? { providerInstanceId: thread.session.providerInstanceId }
1670 : {}),
1671 runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
1672 activeTurnId: null,
1673 lastError: thread.session?.lastError ?? null,
1674 updatedAt: now,
1675 },
1676 createdAt: now,
1677 }),
1678 }),
1679 Effect.ensuring(clearStopping),
1680 );
15111681 });
15121682
15131683 const processDomainEvent = Effect.fn("processDomainEvent")(function* (
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+50-3
......@@ -105,6 +105,7 @@ function createProviderServiceHarness() {
105105 const service: ProviderServiceShape = {
106106 startSession: () => unsupported(),
107107 sendTurn: () => unsupported(),
108 compactThread: () => unsupported(),
108109 interruptTurn: () => unsupported(),
109110 respondToRequest: () => unsupported(),
110111 respondToUserInput: () => unsupported(),
......@@ -3284,10 +3285,55 @@ describe("ProviderRuntimeIngestion", () => {
32843285 const harness = await createHarness();
32853286 const now = "2026-01-01T00:00:00.000Z";
32863287
3288 const compactCommand = {
3289 type: "thread.turn.start",
3290 commandId: CommandId.make("cmd-thread-compact"),
3291 threadId: asThreadId("thread-1"),
3292 message: {
3293 messageId: asMessageId("message-compact"),
3294 role: "user",
3295 text: "/compact",
3296 attachments: [],
3297 },
3298 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
3299 runtimeMode: "approval-required",
3300 createdAt: now,
3301 } satisfies OrchestrationCommand;
3302 await harness.dispatch(compactCommand);
3303 harness.emit({
3304 type: "session.state.changed",
3305 eventId: asEventId("evt-session-starting-compact"),
3306 provider: ProviderDriverKind.make("codex"),
3307 providerInstanceId: ProviderInstanceId.make("codex"),
3308 createdAt: now,
3309 threadId: asThreadId("thread-1"),
3310 payload: { state: "starting" },
3311 });
3312 await waitForThread(harness.readModel, (entry) => entry.session?.status === "starting");
3313
3314 for (const [index, usedTokens] of [899_000, 0].entries()) {
3315 harness.emit({
3316 type: "thread.token-usage.updated",
3317 eventId: asEventId(`evt-thread-token-usage-${index}`),
3318 provider: ProviderDriverKind.make("codex"),
3319 createdAt: now,
3320 threadId: asThreadId("thread-1"),
3321 payload: { usage: { usedTokens } },
3322 });
3323 }
3324 await waitForThread(
3325 harness.readModel,
3326 (entry) =>
3327 entry.activities.filter(
3328 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-window.updated",
3329 ).length === 2,
3330 );
3331
32873332 harness.emit({
32883333 type: "thread.state.changed",
32893334 eventId: asEventId("evt-thread-compacted"),
32903335 provider: ProviderDriverKind.make("codex"),
3336 providerInstanceId: ProviderInstanceId.make("codex"),
32913337 createdAt: now,
32923338 threadId: asThreadId("thread-1"),
32933339 turnId: asTurnId("turn-1"),
......@@ -3299,15 +3345,16 @@ describe("ProviderRuntimeIngestion", () => {
32993345
33003346 const thread = await waitForThread(harness.readModel, (entry) =>
33013347 entry.activities.some(
3302 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-compaction",
3348 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-thread-compacted",
33033349 ),
33043350 );
33053351
33063352 const activity = thread.activities.find(
3307 (candidate: ProviderRuntimeTestActivity) => candidate.kind === "context-compaction",
3353 (candidate: ProviderRuntimeTestActivity) => candidate.id === "evt-thread-compacted",
33083354 );
3309 expect(activity?.summary).toBe("Context compacted");
3355 expect(activity?.summary).toBe("Compacted context 899K → 0 tokens");
33103356 expect(activity?.tone).toBe("info");
3357 expect(activity?.payload).toMatchObject({ requestId: "message-compact" });
33113358 });
33123359
33133360 it("projects Codex task lifecycle chunks into thread activities", async () => {
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+104-4
......@@ -18,16 +18,20 @@ import {
1818 type OrchestrationThread,
1919 type OrchestrationThreadActivity,
2020 type ProviderRuntimeEvent,
21 RuntimeRequestId,
2122} from "@t3tools/contracts";
2223import * as Cache from "effect/Cache";
2324import * as Cause from "effect/Cause";
2425import * as Crypto from "effect/Crypto";
26import * as DateTime from "effect/DateTime";
2527import * as Duration from "effect/Duration";
2628import * as Effect from "effect/Effect";
2729import * as Layer from "effect/Layer";
2830import * as Option from "effect/Option";
31import * as Predicate from "effect/Predicate";
2932import * as Stream from "effect/Stream";
3033import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
34import { formatTokens } from "@t3tools/shared/usageFormat";
3135
3236import { ProviderService } from "../../provider/Services/ProviderService.ts";
3337import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
......@@ -251,12 +255,45 @@ function assistantSegmentMessageId(baseKey: string, segmentIndex: number): Messa
251255function buildContextWindowActivityPayload(
252256 event: ProviderRuntimeEvent,
253257): ThreadTokenUsageSnapshot | undefined {
254 if (event.type !== "thread.token-usage.updated" || event.payload.usage.usedTokens <= 0) {
258 if (event.type !== "thread.token-usage.updated" || event.payload.usage.usedTokens < 0) {
255259 return undefined;
256260 }
257261 return event.payload.usage;
258262}
259263
264function compactedTokenCountsFromActivities(
265 activities: ReadonlyArray<OrchestrationThreadActivity> | undefined,
266): { readonly beforeTokens: number; readonly afterTokens: number } | undefined {
267 const lastCompactionIndex = activities?.findLastIndex(
268 (activity) => activity.kind === "context-compaction",
269 );
270 const lastCompaction =
271 lastCompactionIndex !== undefined && lastCompactionIndex >= 0
272 ? activities?.[lastCompactionIndex]
273 : undefined;
274 const activitiesSinceLastCompaction = activities?.slice((lastCompactionIndex ?? -1) + 1) ?? [];
275 const usedTokens = activitiesSinceLastCompaction.flatMap((activity) => {
276 if (activity.kind !== "context-window.updated") return [];
277 if (lastCompaction !== undefined) {
278 const isAfterLastCompaction =
279 activity.sequence !== undefined && lastCompaction.sequence !== undefined
280 ? activity.sequence > lastCompaction.sequence
281 : activity.createdAt > lastCompaction.createdAt;
282 if (!isAfterLastCompaction) return [];
283 }
284 const payload = Predicate.isObject(activity.payload) ? activity.payload : undefined;
285 return Predicate.isNumber(payload?.usedTokens) && payload.usedTokens >= 0
286 ? [payload.usedTokens]
287 : [];
288 });
289 const beforeTokens = usedTokens.at(-2);
290 const afterTokens = usedTokens.at(-1);
291 if (beforeTokens === undefined || afterTokens === undefined || afterTokens >= beforeTokens) {
292 return undefined;
293 }
294 return { beforeTokens, afterTokens };
295}
296
260297function normalizeRuntimeTurnState(
261298 value: string | undefined,
262299): "completed" | "failed" | "interrupted" | "cancelled" {
......@@ -754,15 +791,24 @@ export function runtimeEventToActivities(
754791 return [];
755792 }
756793
794 const beforeTokens = event.payload.beforeTokens;
795 const afterTokens = event.payload.afterTokens;
796 const summary =
797 beforeTokens !== undefined && afterTokens !== undefined
798 ? `Compacted context ${formatTokens(beforeTokens)} → ${formatTokens(afterTokens)} tokens`
799 : "Context compacted";
757800 return [
758801 {
759802 id: event.eventId,
760803 createdAt: event.createdAt,
761804 tone: "info",
762805 kind: "context-compaction",
763 summary: "Context compacted",
806 summary,
764807 payload: {
765808 state: event.payload.state,
809 ...(beforeTokens !== undefined ? { beforeTokens } : {}),
810 ...(afterTokens !== undefined ? { afterTokens } : {}),
811 ...(event.requestId !== undefined ? { requestId: event.requestId } : {}),
766812 ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}),
767813 },
768814 turnId: toTurnId(event.turnId) ?? null,
......@@ -1532,13 +1578,16 @@ const make = Effect.gen(function* () {
15321578 const now = event.createdAt;
15331579 const eventTurnId = toTurnId(event.turnId);
15341580 const activeTurnId = thread.session?.activeTurnId ?? null;
1581 const isCompactedThreadState =
1582 event.type === "thread.state.changed" && event.payload.state === "compacted";
15351583 const pendingTurnStart =
15361584 event.type === "session.started" ||
15371585 event.type === "session.state.changed" ||
15381586 event.type === "session.exited" ||
15391587 event.type === "thread.started" ||
15401588 event.type === "turn.started" ||
1541 event.type === "turn.completed"
1589 event.type === "turn.completed" ||
1590 isCompactedThreadState
15421591 ? yield* projectionTurnRepository.getPendingTurnStartByThreadId({
15431592 threadId: thread.id,
15441593 })
......@@ -2057,7 +2106,58 @@ const make = Effect.gen(function* () {
20572106 }
20582107 }
20592108
2060 const activities = runtimeEventToActivities(event, taskTitle);
2109 let activityEvent = event;
2110 if (
2111 isCompactedThreadState &&
2112 event.requestId === undefined &&
2113 Option.isSome(pendingTurnStart) &&
2114 thread.session?.status === "starting" &&
2115 activeTurnId === null &&
2116 sameId(thread.session.providerName, event.provider) &&
2117 sameId(thread.session.providerInstanceId, event.providerInstanceId) &&
2118 DateTime.isGreaterThanOrEqualTo(
2119 DateTime.makeUnsafe(event.createdAt),
2120 DateTime.makeUnsafe(pendingTurnStart.value.requestedAt),
2121 )
2122 ) {
2123 const pendingMessage = (yield* getLoadedThreadDetail())?.messages.find(
2124 (message) => message.id === pendingTurnStart.value.messageId,
2125 );
2126 if (
2127 pendingMessage?.role === "user" &&
2128 (pendingMessage.attachments?.length ?? 0) === 0 &&
2129 pendingMessage.text.trim().toLowerCase() === "/compact"
2130 ) {
2131 activityEvent = {
2132 ...event,
2133 requestId: RuntimeRequestId.make(String(pendingTurnStart.value.messageId)),
2134 };
2135 }
2136 }
2137 if (
2138 activityEvent.type === "thread.state.changed" &&
2139 activityEvent.payload.state === "compacted" &&
2140 (activityEvent.payload.beforeTokens === undefined ||
2141 activityEvent.payload.afterTokens === undefined)
2142 ) {
2143 const threadDetail = yield* resolveThreadDetail(thread.id, [
2144 "context-window.updated",
2145 "context-compaction",
2146 ]);
2147 const tokenCounts = compactedTokenCountsFromActivities(threadDetail?.activities);
2148 if (tokenCounts) {
2149 activityEvent = {
2150 ...activityEvent,
2151 payload: {
2152 ...activityEvent.payload,
2153 beforeTokens: activityEvent.payload.beforeTokens ?? tokenCounts.beforeTokens,
2154 afterTokens: activityEvent.payload.afterTokens ?? tokenCounts.afterTokens,
2155 },
2156 };
2157 }
2158 }
2159
2160 const activities = runtimeEventToActivities(activityEvent, taskTitle);
20612161 yield* Effect.forEach(activities, (activity) =>
20622162 providerCommandId(event, "thread-activity-append").pipe(
20632163 Effect.flatMap((commandId) =>
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+16-2
......@@ -2370,7 +2370,7 @@ describe("ClaudeAdapterLive", () => {
23702370 const harness = makeHarness();
23712371 return Effect.gen(function* () {
23722372 const adapter = yield* ClaudeAdapter;
2373 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe(
2373 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 11).pipe(
23742374 Stream.runCollect,
23752375 Effect.forkChild,
23762376 );
......@@ -2410,6 +2410,13 @@ describe("ClaudeAdapterLive", () => {
24102410 session_id: "sdk-session-compacted-usage",
24112411 uuid: "compact-boundary-usage",
24122412 } as unknown as SDKMessage);
2413 harness.query.emit({
2414 type: "system",
2415 subtype: "compact_boundary",
2416 compact_metadata: { post_tokens: 40 },
2417 session_id: "sdk-session-compacted-usage",
2418 uuid: "compact-boundary-post-usage",
2419 } as unknown as SDKMessage);
24132420 harness.query.emit({
24142421 type: "result",
24152422 subtype: "success",
......@@ -2433,6 +2440,14 @@ describe("ClaudeAdapterLive", () => {
24332440 } as unknown as SDKMessage);
24342441
24352442 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
2443 const compactionEvents = runtimeEvents.filter(
2444 (event): event is Extract<ProviderRuntimeEvent, { type: "thread.state.changed" }> =>
2445 event.type === "thread.state.changed" && event.payload.state === "compacted",
2446 );
2447 assert.equal(compactionEvents[0]?.payload.beforeTokens, 200);
2448 assert.equal(compactionEvents[0]?.payload.afterTokens, 40);
2449 assert.equal(compactionEvents[1]?.payload.beforeTokens, undefined);
2450 assert.equal(compactionEvents[1]?.payload.afterTokens, 40);
24362451 const finalUsageEvent = runtimeEvents.findLast(
24372452 (event) => event.type === "thread.token-usage.updated",
24382453 );
......@@ -2440,7 +2455,6 @@ describe("ClaudeAdapterLive", () => {
24402455 if (finalUsageEvent?.type === "thread.token-usage.updated") {
24412456 assert.deepEqual(finalUsageEvent.payload.usage, {
24422457 usedTokens: 40,
2443 lastUsedTokens: 200,
24442458 totalProcessedTokens: 450,
24452459 maxTokens: 200000,
24462460 });
apps/server/src/provider/Layers/ClaudeAdapter.ts+23-14
......@@ -624,12 +624,17 @@ function compactBoundaryTokenUsageSnapshot(
624624 }
625625
626626 const preTokens = finiteNonNegativeInteger(compactMetadata.pre_tokens);
627 return makeClaudeTokenUsageSnapshot({
627 const snapshot = makeClaudeTokenUsageSnapshot({
628628 activeTokens: postTokens,
629629 ...(preTokens !== undefined ? { lastUsedTokens: preTokens } : {}),
630630 ...(contextWindow !== undefined ? { contextWindow } : {}),
631631 ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}),
632632 });
633 if (snapshot === undefined || preTokens !== undefined) {
634 return snapshot;
635 }
636 const { lastUsedTokens: _lastUsedTokens, ...snapshotWithoutBeforeTokens } = snapshot;
637 return snapshotWithoutBeforeTokens;
633638}
634639
635640function normalizeClaudeTaskProgressTokenUsage(
......@@ -3203,32 +3208,36 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
32033208 },
32043209 });
32053210 return;
3206 case "compact_boundary":
3211 case "compact_boundary": {
32073212 if (context.turnState) {
32083213 context.turnState.latestAssistantUsage = undefined;
32093214 context.turnState.compactedSinceLatestAssistantUsage = true;
32103215 }
3211 yield* emitThreadTokenUsage(
3212 context,
3213 compactBoundaryTokenUsageSnapshot(
3214 message as unknown as Record<string, unknown>,
3215 context.lastKnownContextWindow,
3216 context.lastKnownTotalProcessedTokens,
3217 ),
3218 {
3219 rawMethod: "claude/system/compact_boundary",
3220 rawPayload: message,
3221 },
3216 const compactedUsage = compactBoundaryTokenUsageSnapshot(
3217 message as unknown as Record<string, unknown>,
3218 context.lastKnownContextWindow,
3219 context.lastKnownTotalProcessedTokens,
32223220 );
3221 yield* emitThreadTokenUsage(context, compactedUsage, {
3222 rawMethod: "claude/system/compact_boundary",
3223 rawPayload: message,
3224 });
32233225 yield* offerRuntimeEvent({
32243226 ...base,
32253227 type: "thread.state.changed",
32263228 payload: {
32273229 state: "compacted",
3230 ...(compactedUsage?.lastUsedTokens !== undefined
3231 ? { beforeTokens: compactedUsage.lastUsedTokens }
3232 : {}),
3233 ...(compactedUsage?.usedTokens !== undefined
3234 ? { afterTokens: compactedUsage.usedTokens }
3235 : {}),
32283236 detail: message,
32293237 },
32303238 });
32313239 return;
3240 }
32323241 case "hook_started":
32333242 yield* offerRuntimeEvent({
32343243 ...base,
......@@ -3790,7 +3799,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
37903799
37913800 // Same reason as the approvals above: a request nobody can answer any more
37923801 // must not stay open, or the thread can never be settled.
3793 for (const pending of [...context.pendingUserInputs.values()]) {
3802 for (const pending of context.pendingUserInputs.values()) {
37943803 yield* pending.cancel;
37953804 }
37963805
apps/server/src/provider/Layers/ClaudeProvider.ts+2-7
......@@ -24,6 +24,7 @@ import {
2424
2525import {
2626 buildServerProvider,
27 COMPACT_SLASH_COMMAND,
2728 DEFAULT_TIMEOUT_MS,
2829 isCommandMissingCause,
2930 parseGenericCliVersion,
......@@ -529,13 +530,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
529530 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
530531 : undefined;
531532 const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment);
532 const slashCommands = [
533 {
534 name: "compact",
535 description: "Summarize the conversation and reduce context usage",
536 },
537 ...(capabilities?.slashCommands ?? []),
538 ];
533 const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])];
539534 const dedupedSlashCommands = dedupeSlashCommands(slashCommands);
540535
541536 if (!capabilities) {
apps/server/src/provider/Layers/CodexAdapter.test.ts+43
......@@ -84,6 +84,8 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
8484 }),
8585 );
8686
87 public readonly compactThread = Effect.void;
88
8789 public readonly interruptTurnImpl = vi.fn((_turnId?: TurnId): Promise<void> =>
8890 Promise.resolve(undefined),
8991 );
......@@ -334,6 +336,47 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
334336 }),
335337 );
336338
339 it.effect("compacts the active Codex thread and emits compacted state", () =>
340 Effect.gen(function* () {
341 const adapter = yield* CodexAdapter;
342 const threadId = asThreadId("thread-compact");
343 yield* adapter.startSession({
344 provider: ProviderDriverKind.make("codex"),
345 threadId,
346 runtimeMode: "full-access",
347 });
348 const runtime = sessionRuntimeFactory.lastRuntime;
349 NodeAssert.ok(runtime);
350 const compactedEventFiber = yield* adapter.streamEvents.pipe(
351 Stream.filter((event) => event.type === "thread.state.changed"),
352 Stream.runHead,
353 Effect.forkChild,
354 );
355 yield* adapter.compactThread!(threadId);
356 yield* runtime.emit({
357 id: asEventId("evt-compaction-item-completed"),
358 kind: "notification",
359 provider: ProviderDriverKind.make("codex"),
360 createdAt: "2026-01-01T00:00:00.000Z",
361 method: "item/completed",
362 threadId,
363 payload: {
364 completedAtMs: 1_778_000_000_000,
365 threadId: "provider-thread-1",
366 turnId: "provider-compact-turn",
367 item: {
368 id: "provider-compact-item",
369 type: "contextCompaction",
370 },
371 },
372 });
373 const event = Option.getOrThrow(yield* Fiber.join(compactedEventFiber));
374 NodeAssert.ok(event.type === "thread.state.changed");
375 NodeAssert.equal(event.payload.state, "compacted");
376 yield* adapter.stopSession(threadId);
377 }),
378 );
379
337380 it.effect("uploads feedback for the active Codex thread", () =>
338381 Effect.gen(function* () {
339382 const adapter = yield* CodexAdapter;
apps/server/src/provider/Layers/CodexAdapter.ts+22-1
......@@ -1485,7 +1485,18 @@ function mapToRuntimeEvents(
14851485 ];
14861486 }
14871487 const completed = mapItemLifecycle(event, canonicalThreadId, "item.completed");
1488 return completed ? [completed] : [];
1488 if (!completed || itemType !== "context_compaction") {
1489 return completed ? [completed] : [];
1490 }
1491 return [
1492 completed,
1493 {
1494 ...runtimeEventBase(event, canonicalThreadId),
1495 eventId: EventId.make(`${event.id}:thread-compacted`),
1496 type: "thread.state.changed",
1497 payload: { state: "compacted" },
1498 },
1499 ];
14891500 }
14901501
14911502 if (
......@@ -2216,6 +2227,15 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
22162227 ),
22172228 );
22182229
2230 const compactThread: NonNullable<CodexAdapterShape["compactThread"]> = Effect.fn("compactThread")(
2231 function* (threadId) {
2232 const session = yield* requireSession(threadId);
2233 yield* session.runtime.compactThread.pipe(
2234 Effect.mapError((cause) => mapCodexRuntimeError(threadId, "thread/compact/start", cause)),
2235 );
2236 },
2237 );
2238
22192239 const readThread: CodexAdapterShape["readThread"] = (threadId) =>
22202240 requireSession(threadId).pipe(
22212241 Effect.flatMap((session) => session.runtime.readThread),
......@@ -2351,6 +2371,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
23512371 },
23522372 startSession,
23532373 sendTurn,
2374 compactThread,
23542375 interruptTurn,
23552376 readThread,
23562377 rollbackThread,
apps/server/src/provider/Layers/CodexProvider.ts+2
......@@ -30,6 +30,7 @@ import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts
3030import {
3131 AUTH_PROBE_TIMEOUT_MS,
3232 buildServerProvider,
33 COMPACT_SLASH_COMMAND,
3334 type ServerProviderDraft,
3435} from "../providerSnapshot.ts";
3536import { expandHomePath } from "../../pathExpansion.ts";
......@@ -690,6 +691,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
690691 models: snapshot.models,
691692 skills: snapshot.skills,
692693 slashCommands: [
694 COMPACT_SLASH_COMMAND,
693695 {
694696 name: "feedback",
695697 description: "Send this thread and Codex logs to OpenAI",
apps/server/src/provider/Layers/CodexSessionRuntime.ts+5
......@@ -196,6 +196,7 @@ export interface CodexSessionRuntimeShape {
196196 readonly sendTurn: (
197197 input: CodexSessionRuntimeSendTurnInput,
198198 ) => Effect.Effect<ProviderTurnStartResult, CodexSessionRuntimeError>;
199 readonly compactThread: Effect.Effect<void, CodexSessionRuntimeError>;
199200 readonly interruptTurn: (turnId?: TurnId) => Effect.Effect<void, CodexSessionRuntimeError>;
200201 readonly readThread: Effect.Effect<CodexThreadSnapshot, CodexSessionRuntimeError>;
201202 readonly rollbackThread: (
......@@ -2293,6 +2294,10 @@ export const makeCodexSessionRuntime = (
22932294 return {
22942295 start,
22952296 getSession: Ref.get(sessionRef),
2297 compactThread: Effect.gen(function* () {
2298 const providerThreadId = yield* readProviderThreadId;
2299 yield* client.request("thread/compact/start", { threadId: providerThreadId });
2300 }),
22962301 sendTurn: (input) =>
22972302 Effect.gen(function* () {
22982303 const providerThreadId = yield* readProviderThreadId;
apps/server/src/provider/Layers/CursorProvider.ts+2
......@@ -34,6 +34,7 @@ import {
3434 buildBooleanOptionDescriptor,
3535 buildSelectOptionDescriptor,
3636 buildServerProvider,
37 COMPACT_SLASH_COMMAND,
3738 collectStreamAsString,
3839 isCommandMissingCause,
3940 providerModelsFromSettings,
......@@ -639,6 +640,7 @@ export function buildCursorProviderSnapshot(input: {
639640 input.cursorSettings.customModels,
640641 EMPTY_CAPABILITIES,
641642 ),
643 slashCommands: [COMPACT_SLASH_COMMAND],
642644 probe: {
643645 installed: true,
644646 version: input.parsed.version,
apps/server/src/provider/Layers/GrokProvider.ts+2
......@@ -21,6 +21,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell";
2121import {
2222 AUTH_PROBE_TIMEOUT_MS,
2323 buildServerProvider,
24 COMPACT_SLASH_COMMAND,
2425 isCommandMissingCause,
2526 parseGenericCliVersion,
2627 providerModelsFromSettings,
......@@ -499,6 +500,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
499500 checkedAt,
500501 models,
501502 skills,
503 slashCommands: [COMPACT_SLASH_COMMAND],
502504 probe: {
503505 installed: true,
504506 version,
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+39
......@@ -79,6 +79,7 @@ const runtimeMock = {
7979 messageCalls: [] as Array<{ sessionID: string; messageID: string }>,
8080 messageFailures: 0,
8181 promptCalls: [] as Array<unknown>,
82 summarizeCalls: [] as Array<unknown>,
8283 promptAsyncError: null as Error | null,
8384 promptAsyncImplementation: null as (() => Promise<void>) | null,
8485 autoPromptEcho: true,
......@@ -130,6 +131,7 @@ const runtimeMock = {
130131 this.state.messageCalls.length = 0;
131132 this.state.messageFailures = 0;
132133 this.state.promptCalls.length = 0;
134 this.state.summarizeCalls.length = 0;
133135 this.state.promptAsyncError = null;
134136 this.state.promptAsyncImplementation = null;
135137 this.state.autoPromptEcho = true;
......@@ -315,6 +317,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
315317 });
316318 }
317319 },
320 summarize: async (input: unknown) => {
321 runtimeMock.state.summarizeCalls.push(input);
322 return { data: true };
323 },
318324 messages: async () => ({ data: runtimeMock.state.messages }),
319325 message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => {
320326 runtimeMock.state.messageCalls.push({ sessionID, messageID });
......@@ -954,6 +960,39 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
954960 }),
955961 );
956962
963 it.effect("compacts through the native OpenCode session API", () =>
964 Effect.gen(function* () {
965 const adapter = yield* OpenCodeAdapter;
966 const threadId = asThreadId("thread-opencode-compact");
967 runtimeMock.state.subscribedEvents.push({
968 type: "session.compacted",
969 properties: { sessionID: "http://127.0.0.1:9999/session" },
970 });
971 const eventsFiber = yield* adapter.streamEvents.pipe(
972 Stream.filter((event) => event.threadId === threadId),
973 Stream.take(3),
974 Stream.runCollect,
975 Effect.forkChild,
976 );
977 yield* adapter.startSession({
978 provider: ProviderDriverKind.make("opencode"),
979 threadId,
980 runtimeMode: "full-access",
981 });
982 yield* adapter.compactThread!(
983 threadId,
984 createModelSelection(ProviderInstanceId.make("opencode"), "openai/gpt-5"),
985 );
986 const summarizeCall = runtimeMock.state.summarizeCalls[0] as Record<string, unknown>;
987 NodeAssert.equal(summarizeCall.modelID, "gpt-5");
988 const events = Array.from(yield* Fiber.join(eventsFiber));
989 yield* adapter.stopSession(threadId);
990 const compacted = events.some(
991 (event) => event.type === "thread.state.changed" && event.payload.state === "compacted",
992 );
993 NodeAssert.equal(compacted, true);
994 }),
995 );
957996 it.effect("falls back to a fresh session when the persisted session is gone", () =>
958997 Effect.gen(function* () {
959998 const adapter = yield* OpenCodeAdapter;
apps/server/src/provider/Layers/OpenCodeAdapter.ts+81
......@@ -2070,6 +2070,21 @@ export function makeOpenCodeAdapter(
20702070 }
20712071 break;
20722072 }
2073 case "session.compacted": {
2074 yield* emit({
2075 ...(yield* buildEventBase({
2076 threadId: context.session.threadId,
2077 turnId,
2078 raw: event,
2079 })),
2080 type: "thread.state.changed",
2081 payload: {
2082 state: "compacted",
2083 detail: event,
2084 },
2085 });
2086 break;
2087 }
20732088
20742089 case "message.updated": {
20752090 const promptAdmission = context.promptAdmission;
......@@ -3041,6 +3056,71 @@ export function makeOpenCodeAdapter(
30413056 );
30423057 });
30433058
3059 const compactThread: NonNullable<OpenCodeAdapterShape["compactThread"]> = Effect.fn(
3060 "compactThread",
3061 )(function* (threadId, requestedModelSelection) {
3062 const context = yield* ensureSessionContext(sessions, threadId);
3063 yield* awaitOpenCodeContextReady(context);
3064 const modelSelection =
3065 requestedModelSelection ??
3066 (context.session.model
3067 ? { instanceId: boundInstanceId, model: context.session.model }
3068 : undefined);
3069 if (modelSelection !== undefined && modelSelection.instanceId !== boundInstanceId) {
3070 return yield* new ProviderAdapterValidationError({
3071 provider: PROVIDER,
3072 operation: "compactThread",
3073 issue: `OpenCode model selection is bound to instance '${modelSelection.instanceId}', expected '${boundInstanceId}'.`,
3074 });
3075 }
3076 const parsedModel = parseOpenCodeModelSlug(modelSelection?.model);
3077 if (!parsedModel) {
3078 return yield* new ProviderAdapterValidationError({
3079 provider: PROVIDER,
3080 operation: "compactThread",
3081 issue: "OpenCode compaction requires an active 'provider/model' selection.",
3082 });
3083 }
3084 yield* context.promptSemaphore.withPermit(
3085 Effect.gen(function* () {
3086 if (sessions.get(threadId) !== context || (yield* Ref.get(context.stopped))) {
3087 return yield* Effect.interrupt;
3088 }
3089 if (context.activeTurnId !== undefined) {
3090 return yield* new ProviderAdapterValidationError({
3091 provider: PROVIDER,
3092 operation: "compactThread",
3093 issue: "OpenCode cannot compact while a turn is running.",
3094 });
3095 }
3096 yield* runOpenCodeSdk("session.summarize", (signal) =>
3097 context.client.session.summarize(
3098 {
3099 sessionID: context.openCodeSessionId,
3100 ...parsedModel,
3101 auto: false,
3102 },
3103 { signal },
3104 ),
3105 ).pipe(
3106 Effect.timeout("10 minutes"),
3107 Effect.catchTags({
3108 OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)),
3109 TimeoutError: (cause) =>
3110 Effect.fail(
3111 new ProviderAdapterRequestError({
3112 provider: PROVIDER,
3113 method: "session.summarize",
3114 detail: "OpenCode session compaction did not complete within 10 minutes.",
3115 cause,
3116 }),
3117 ),
3118 }),
3119 Effect.asVoid,
3120 );
3121 }),
3122 );
3123 });
30443124 const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
30453125 function* (threadId, turnId) {
30463126 const context = yield* ensureSessionContext(sessions, threadId);
......@@ -3325,6 +3405,7 @@ export function makeOpenCodeAdapter(
33253405 },
33263406 startSession,
33273407 sendTurn,
3408 compactThread,
33283409 interruptTurn,
33293410 respondToRequest,
33303411 respondToUserInput,
apps/server/src/provider/Layers/OpenCodeProvider.ts+2
......@@ -13,6 +13,7 @@ import { createModelCapabilities } from "@t3tools/shared/model";
1313import { compareSemverVersions } from "@t3tools/shared/semver";
1414import {
1515 buildServerProvider,
16 COMPACT_SLASH_COMMAND,
1617 nonEmptyTrimmed,
1718 parseGenericCliVersion,
1819 providerModelsFromSettings,
......@@ -525,6 +526,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
525526 checkedAt,
526527 models,
527528 skills,
529 slashCommands: [COMPACT_SLASH_COMMAND],
528530 probe: {
529531 installed: true,
530532 version,
apps/server/src/provider/Layers/ProviderRegistry.test.ts+4-10
......@@ -49,6 +49,7 @@ import {
4949import * as ServerConfig from "../../config.ts";
5050import * as ServerSettingsModule from "../../serverSettings.ts";
5151import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts";
52import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts";
5253import type { ProviderInstance } from "../ProviderDriver.ts";
5354import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts";
5455import * as ProviderRegistry from "../Services/ProviderRegistry.ts";
......@@ -385,7 +386,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
385386 shortDescription: "Debug failing GitHub Actions checks",
386387 },
387388 ]);
388 assert.deepStrictEqual(status.slashCommands, [
389 assert.deepStrictEqual(status.slashCommands.slice(1), [
389390 {
390391 name: "feedback",
391392 description: "Send this thread and Codex logs to OpenAI",
......@@ -2550,11 +2551,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
25502551 }),
25512552 );
25522553
2553 assert.deepStrictEqual(status.slashCommands, [
2554 {
2555 name: "compact",
2556 description: "Summarize the conversation and reduce context usage",
2557 },
2554 assert.deepStrictEqual(status.slashCommands.slice(1), [
25582555 {
25592556 name: "review",
25602557 description: "Review a pull request",
......@@ -2598,10 +2595,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
25982595 );
25992596
26002597 assert.deepStrictEqual(status.slashCommands, [
2601 {
2602 name: "compact",
2603 description: "Summarize the conversation and reduce context usage",
2604 },
2598 COMPACT_SLASH_COMMAND,
26052599 {
26062600 name: "ui",
26072601 description: "Explore and refine UI",
apps/server/src/provider/Layers/ProviderService.test.ts+251
......@@ -175,6 +175,18 @@ function makeFakeCodexAdapter(
175175 Effect.void,
176176 );
177177
178 const compactThread = vi.fn((threadId: ThreadId) =>
179 Effect.sync(() =>
180 emit({
181 type: "thread.state.changed",
182 eventId: asEventId("evt-native-compact"),
183 provider,
184 createdAt: "2026-01-01T00:00:00.000Z",
185 threadId,
186 payload: { state: "compacted" },
187 }),
188 ),
189 );
178190 const respondToRequest = vi.fn(
179191 (
180192 _threadId: ThreadId,
......@@ -251,6 +263,7 @@ function makeFakeCodexAdapter(
251263 },
252264 startSession,
253265 sendTurn,
266 ...(provider === CODEX_DRIVER ? { compactThread } : {}),
254267 interruptTurn,
255268 respondToRequest,
256269 respondToUserInput,
......@@ -287,6 +300,7 @@ function makeFakeCodexAdapter(
287300 updateSession,
288301 startSession,
289302 sendTurn,
303 compactThread,
290304 interruptTurn,
291305 respondToRequest,
292306 respondToUserInput,
......@@ -1215,6 +1229,243 @@ routing.layer("ProviderServiceLive routing", (it) => {
12151229 }),
12161230 );
12171231
1232 it.effect("marks a successful fallback compaction as compacted", () =>
1233 Effect.gen(function* () {
1234 const provider = yield* ProviderService.ProviderService;
1235 const threadId = asThreadId("thread-compact-cursor");
1236 yield* provider.startSession(threadId, {
1237 provider: CURSOR_DRIVER,
1238 providerInstanceId: ProviderInstanceId.make("cursor"),
1239 threadId,
1240 runtimeMode: "full-access",
1241 });
1242 const compactedEventFiber = yield* provider.streamEvents.pipe(
1243 Stream.filter((event) => event.type === "thread.state.changed"),
1244 Stream.runHead,
1245 Effect.forkChild,
1246 );
1247 const requestId = MessageId.make("message-compact-cursor");
1248 const compactFiber = yield* provider
1249 .compactThread(threadId, undefined, requestId)
1250 .pipe(Effect.forkChild);
1251 yield* advanceTestClock(50);
1252 routing.cursor.emit({
1253 type: "turn.completed",
1254 eventId: asEventId("evt-cursor-stale-turn-completed"),
1255 provider: CURSOR_DRIVER,
1256 createdAt: "2026-01-01T00:00:00.500Z",
1257 threadId,
1258 turnId: asTurnId("turn-before-compaction"),
1259 payload: { state: "completed" },
1260 });
1261 yield* Effect.yieldNow;
1262 assert.equal(compactFiber.pollUnsafe(), undefined);
1263 routing.cursor.emit({
1264 type: "turn.completed",
1265 eventId: asEventId("evt-cursor-compact-completed"),
1266 provider: CURSOR_DRIVER,
1267 createdAt: "2026-01-01T00:00:01.000Z",
1268 threadId,
1269 turnId: asTurnId(`turn-${threadId}`),
1270 payload: { state: "completed" },
1271 });
1272 yield* Fiber.join(compactFiber);
1273
1274 const compacted = yield* Fiber.join(compactedEventFiber);
1275 assert.equal(compacted._tag, "Some");
1276 if (Option.isSome(compacted)) {
1277 assert.equal(compacted.value.requestId, String(requestId));
1278 }
1279
1280 const observedEvents = yield* Ref.make<Array<ProviderRuntimeEvent>>([]);
1281 const observedEventsFiber = yield* provider.streamEvents.pipe(
1282 Stream.filter(
1283 (event) =>
1284 event.threadId === threadId &&
1285 event.type === "thread.state.changed" &&
1286 event.payload.state === "compacted",
1287 ),
1288 Stream.runForEach((event) => Ref.update(observedEvents, (events) => [...events, event])),
1289 Effect.forkChild,
1290 );
1291 const observedRequestId = MessageId.make("message-observed-compact-cursor");
1292 const observedCompactFiber = yield* provider
1293 .compactThread(threadId, undefined, observedRequestId)
1294 .pipe(Effect.forkChild);
1295 yield* advanceTestClock(50);
1296 routing.cursor.emit({
1297 type: "thread.state.changed",
1298 eventId: asEventId("evt-cursor-provider-compacted"),
1299 provider: CURSOR_DRIVER,
1300 createdAt: "2026-01-01T00:00:02.000Z",
1301 threadId,
1302 turnId: asTurnId(`turn-${threadId}`),
1303 payload: { state: "compacted" },
1304 });
1305 routing.cursor.emit({
1306 type: "turn.completed",
1307 eventId: asEventId("evt-cursor-observed-compact-completed"),
1308 provider: CURSOR_DRIVER,
1309 createdAt: "2026-01-01T00:00:03.000Z",
1310 threadId,
1311 turnId: asTurnId(`turn-${threadId}`),
1312 payload: { state: "completed" },
1313 });
1314 yield* Fiber.join(observedCompactFiber);
1315 yield* Effect.yieldNow;
1316 const observed = yield* Ref.get(observedEvents);
1317 assert.equal(observed.length, 1);
1318 assert.equal(observed[0]?.requestId, String(observedRequestId));
1319 yield* Fiber.interrupt(observedEventsFiber);
1320
1321 const failedStartEventId = asEventId("evt-cursor-failed-compact-start");
1322 const failedStartEventFiber = yield* provider.streamEvents.pipe(
1323 Stream.filter((event) => event.eventId === failedStartEventId),
1324 Stream.runHead,
1325 Effect.forkChild,
1326 );
1327 routing.cursor.sendTurn.mockImplementationOnce((input) =>
1328 Effect.gen(function* () {
1329 routing.cursor.emit({
1330 type: "turn.completed",
1331 eventId: failedStartEventId,
1332 provider: CURSOR_DRIVER,
1333 createdAt: "2026-01-01T00:00:04.000Z",
1334 threadId: input.threadId,
1335 turnId: asTurnId("turn-cursor-failed-compact-start"),
1336 payload: { state: "failed" },
1337 });
1338 yield* Effect.yieldNow;
1339 return yield* new ProviderAdapterRequestError({
1340 provider: String(CURSOR_DRIVER),
1341 method: "turn/start",
1342 detail: "Failed after emitting a terminal event.",
1343 });
1344 }),
1345 );
1346 const failedStart = yield* provider.compactThread(threadId).pipe(Effect.result);
1347 assert.equal(failedStart._tag, "Failure");
1348 assert.equal(Option.isSome(yield* Fiber.join(failedStartEventFiber)), true);
1349 yield* provider.stopSession({ threadId });
1350 }),
1351 );
1352
1353 it.effect("serializes native compaction and quarantines timed-out completions", () =>
1354 Effect.gen(function* () {
1355 const provider = yield* ProviderService.ProviderService;
1356 const threadId = asThreadId("thread-compact-timeout");
1357 yield* provider.startSession(threadId, {
1358 provider: CODEX_DRIVER,
1359 providerInstanceId: codexInstanceId,
1360 threadId,
1361 runtimeMode: "full-access",
1362 });
1363 routing.codex.compactThread.mockClear();
1364 routing.codex.compactThread.mockImplementationOnce(() => Effect.never);
1365
1366 const resultFiber = yield* provider
1367 .compactThread(threadId)
1368 .pipe(Effect.result, Effect.forkChild);
1369 yield* advanceTestClock(50);
1370 const concurrent = yield* provider.compactThread(threadId).pipe(Effect.result);
1371 assert.equal(concurrent._tag, "Failure");
1372 assert.equal(routing.codex.compactThread.mock.calls.length, 1);
1373
1374 routing.cursor.emit({
1375 type: "thread.state.changed",
1376 eventId: asEventId("evt-stale-provider-compact"),
1377 provider: CURSOR_DRIVER,
1378 createdAt: "2026-01-01T00:00:00.100Z",
1379 threadId,
1380 payload: { state: "compacted" },
1381 });
1382 yield* Effect.yieldNow;
1383 assert.equal(resultFiber.pollUnsafe(), undefined);
1384
1385 yield* advanceTestClock(600_001);
1386 const result = yield* Fiber.join(resultFiber);
1387 assert.equal(result._tag, "Failure");
1388 if (result._tag === "Failure") {
1389 assert.equal(result.failure._tag, "ProviderAdapterRequestError");
1390 }
1391
1392 const blockedRetry = yield* provider.compactThread(threadId).pipe(Effect.result);
1393 assert.equal(blockedRetry._tag, "Failure");
1394 assert.equal(routing.codex.compactThread.mock.calls.length, 1);
1395
1396 routing.codex.emit({
1397 type: "thread.state.changed",
1398 eventId: asEventId("evt-native-compact-late"),
1399 provider: CODEX_DRIVER,
1400 createdAt: "2026-01-01T00:10:01.000Z",
1401 threadId,
1402 payload: { state: "compacted" },
1403 });
1404 yield* Effect.yieldNow;
1405 yield* provider.compactThread(threadId);
1406 assert.equal(routing.codex.compactThread.mock.calls.length, 2);
1407
1408 routing.codex.compactThread.mockImplementationOnce(() => Effect.void);
1409 const stoppedResultFiber = yield* provider
1410 .compactThread(threadId)
1411 .pipe(Effect.result, Effect.forkChild);
1412 yield* advanceTestClock(50);
1413 yield* provider.stopSession({ threadId });
1414 const stoppedResult = yield* Fiber.join(stoppedResultFiber);
1415 assert.equal(stoppedResult._tag, "Failure");
1416
1417 yield* provider.startSession(threadId, {
1418 provider: CODEX_DRIVER,
1419 providerInstanceId: codexInstanceId,
1420 threadId,
1421 runtimeMode: "full-access",
1422 });
1423 yield* provider.compactThread(threadId);
1424 assert.equal(routing.codex.compactThread.mock.calls.length, 4);
1425 yield* provider.stopSession({ threadId });
1426 }),
1427 );
1428
1429 it.effect("times out fallback compaction when its turn never settles", () =>
1430 Effect.gen(function* () {
1431 const provider = yield* ProviderService.ProviderService;
1432 const threadId = asThreadId("thread-compact-fallback-timeout");
1433 yield* provider.startSession(threadId, {
1434 provider: CURSOR_DRIVER,
1435 providerInstanceId: ProviderInstanceId.make("cursor"),
1436 threadId,
1437 runtimeMode: "full-access",
1438 });
1439
1440 const resultFiber = yield* provider
1441 .compactThread(threadId)
1442 .pipe(Effect.result, Effect.forkChild);
1443 yield* advanceTestClock(600_001);
1444 const result = yield* Fiber.join(resultFiber);
1445 assert.equal(result._tag, "Failure");
1446
1447 routing.cursor.sendTurn.mockImplementationOnce((input) =>
1448 Effect.succeed({
1449 threadId: input.threadId,
1450 turnId: asTurnId("turn-compact-fallback-retry"),
1451 }),
1452 );
1453 const retryFiber = yield* provider.compactThread(threadId).pipe(Effect.forkChild);
1454 yield* advanceTestClock(50);
1455 routing.cursor.emit({
1456 type: "turn.completed",
1457 eventId: asEventId("evt-compact-fallback-retry-completed"),
1458 provider: CURSOR_DRIVER,
1459 createdAt: "2026-01-01T00:10:02.000Z",
1460 threadId,
1461 turnId: asTurnId("turn-compact-fallback-retry"),
1462 payload: { state: "completed" },
1463 });
1464 yield* Fiber.join(retryFiber);
1465 yield* provider.stopSession({ threadId });
1466 }),
1467 );
1468
12181469 it.effect("routes feedback to the Codex adapter and returns its feedback ID", () =>
12191470 Effect.gen(function* () {
12201471 const provider = yield* ProviderService.ProviderService;
apps/server/src/provider/Layers/ProviderService.ts+258-9
......@@ -10,16 +10,20 @@
1010 * @module ProviderServiceLive
1111 */
1212import {
13 EventId,
14 MessageId,
1315 ModelSelection,
1416 NonNegativeInt,
15 ThreadId,
1617 ProviderInterruptTurnInput,
1718 ProviderRespondToRequestInput,
1819 ProviderRespondToUserInputInput,
20 RuntimeRequestId,
1921 ProviderSendTurnInput,
2022 ProviderSessionStartInput,
2123 ProviderStopSessionInput,
2224 ProviderUploadFeedbackInput,
25 ThreadId,
26 TurnId,
2327 type ProviderInstanceId,
2428 type ProviderDriverKind,
2529 type ProviderRuntimeEvent,
......@@ -28,6 +32,7 @@ import {
2832import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";
2933import { causeErrorTag } from "@t3tools/shared/observability";
3034import * as DateTime from "effect/DateTime";
35import * as Deferred from "effect/Deferred";
3136import * as Effect from "effect/Effect";
3237import * as Layer from "effect/Layer";
3338import * as Option from "effect/Option";
......@@ -49,6 +54,7 @@ import {
4954 providerTurnMetricAttributes,
5055 withMetrics,
5156} from "../../observability/Metrics.ts";
57import { ProviderAdapterRequestError } from "../Errors.ts";
5258import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts";
5359import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
5460import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts";
......@@ -62,6 +68,16 @@ import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts";
6268import * as ServerSettings from "../../serverSettings.ts";
6369const isModelSelection = Schema.is(ModelSelection);
6470
71interface PendingCompaction {
72 readonly completion: Deferred.Deferred<string>;
73 readonly native: boolean;
74 readonly providerInstanceId: ProviderInstanceId;
75 readonly requestId: MessageId | undefined;
76 readonly earlyEvents: ProviderRuntimeEvent[];
77 compactedEventObserved: boolean;
78 expectedTurnId: TurnId | undefined;
79}
80
6581/**
6682 * Hook for tests that want to override the canonical event logger pulled
6783 * from `ProviderEventLoggers`. Production wiring leaves this undefined and
......@@ -233,6 +249,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
233249 const revokeMcpCredential =
234250 options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread;
235251 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
252 const pendingCompactions = new Map<ThreadId, PendingCompaction>();
253 const timedOutNativeCompactions = new Set<ThreadId>();
254 const settleCompaction = (threadId: ThreadId, pending: PendingCompaction, terminal: string) =>
255 Effect.gen(function* () {
256 if (pendingCompactions.get(threadId) !== pending) return false;
257 pendingCompactions.delete(threadId);
258 yield* Deferred.succeed(pending.completion, terminal);
259 return true;
260 });
236261 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
237262 /**
238263 * Attach the `t3-code` MCP server to the session that is about to start.
......@@ -295,6 +320,65 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
295320 Effect.asVoid,
296321 );
297322
323 const isCompactedEvent = (
324 event: ProviderRuntimeEvent,
325 ): event is Extract<ProviderRuntimeEvent, { readonly type: "thread.state.changed" }> =>
326 event.type === "thread.state.changed" && event.payload.state === "compacted";
327 const withCompactionRequestId = (
328 event: ProviderRuntimeEvent,
329 pending: PendingCompaction,
330 ): ProviderRuntimeEvent =>
331 pending.requestId === undefined
332 ? event
333 : {
334 ...event,
335 requestId: RuntimeRequestId.make(String(pending.requestId)),
336 };
337 const compactionTerminal = (event: ProviderRuntimeEvent): string | null =>
338 event.type === "turn.completed"
339 ? event.payload.state
340 : event.type === "runtime.error" || event.type === "turn.aborted"
341 ? event.type
342 : null;
343 const processFallbackCompactionEvent = (
344 pending: PendingCompaction,
345 event: ProviderRuntimeEvent,
346 ): Effect.Effect<void> =>
347 Effect.gen(function* () {
348 if (pendingCompactions.get(event.threadId) !== pending) {
349 yield* publishRuntimeEvent(event);
350 return;
351 }
352 const matchesTurn = event.turnId !== undefined && event.turnId === pending.expectedTurnId;
353 if (matchesTurn && isCompactedEvent(event)) {
354 pending.compactedEventObserved = true;
355 yield* publishRuntimeEvent(withCompactionRequestId(event, pending));
356 return;
357 }
358 yield* publishRuntimeEvent(event);
359 const terminal = compactionTerminal(event);
360 if (!matchesTurn || terminal === null) return;
361 const settled = yield* settleCompaction(event.threadId, pending, terminal);
362 if (!settled || terminal !== "completed" || pending.compactedEventObserved) return;
363 const compactedEvent = {
364 ...event,
365 eventId: EventId.make(`${event.eventId}:context-compaction`),
366 type: "thread.state.changed",
367 payload: {
368 state: "compacted",
369 detail: { source: "provider-native-command" },
370 },
371 ...(pending.requestId !== undefined
372 ? { requestId: RuntimeRequestId.make(String(pending.requestId)) }
373 : {}),
374 } satisfies ProviderRuntimeEvent;
375 yield* increment(providerRuntimeEventsTotal, {
376 provider: compactedEvent.provider,
377 eventType: compactedEvent.type,
378 });
379 yield* publishRuntimeEvent(compactedEvent);
380 });
381
298382 const requireBindingInstanceId = (
299383 operation: string,
300384 payload: {
......@@ -345,14 +429,50 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
345429 },
346430 event: ProviderRuntimeEvent,
347431 ): Effect.Effect<void> =>
348 Effect.sync(() => correlateRuntimeEventWithInstance(source, event)).pipe(
349 Effect.flatMap((canonicalEvent) =>
350 increment(providerRuntimeEventsTotal, {
351 provider: canonicalEvent.provider,
352 eventType: canonicalEvent.type,
353 }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))),
354 ),
355 );
432 Effect.gen(function* () {
433 const canonicalEvent = yield* Effect.sync(() =>
434 correlateRuntimeEventWithInstance(source, event),
435 );
436 yield* increment(providerRuntimeEventsTotal, {
437 provider: canonicalEvent.provider,
438 eventType: canonicalEvent.type,
439 });
440 if (
441 isCompactedEvent(canonicalEvent) &&
442 timedOutNativeCompactions.delete(canonicalEvent.threadId)
443 ) {
444 yield* publishRuntimeEvent(canonicalEvent);
445 return;
446 }
447 const pendingCompaction = pendingCompactions.get(canonicalEvent.threadId);
448 if (!pendingCompaction) {
449 yield* publishRuntimeEvent(canonicalEvent);
450 return;
451 }
452 if (pendingCompaction.providerInstanceId !== source.instanceId) {
453 yield* publishRuntimeEvent(canonicalEvent);
454 return;
455 }
456 if (pendingCompaction.native) {
457 const compacted = isCompactedEvent(canonicalEvent);
458 const terminal = compacted ? "completed" : compactionTerminal(canonicalEvent);
459 yield* publishRuntimeEvent(
460 compacted ? withCompactionRequestId(canonicalEvent, pendingCompaction) : canonicalEvent,
461 );
462 if (terminal !== null)
463 yield* settleCompaction(canonicalEvent.threadId, pendingCompaction, terminal);
464 return;
465 }
466 if (
467 pendingCompaction.expectedTurnId === undefined &&
468 canonicalEvent.turnId !== undefined &&
469 (isCompactedEvent(canonicalEvent) || compactionTerminal(canonicalEvent) !== null)
470 ) {
471 pendingCompaction.earlyEvents.push(canonicalEvent);
472 return;
473 }
474 yield* processFallbackCompactionEvent(pendingCompaction, canonicalEvent);
475 });
356476
357477 // `subscribedAdapters` is our source-of-truth for "which instance adapters
358478 // are currently wired into the runtime event bus". It both tracks the set
......@@ -708,6 +828,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
708828 typeof input.modelSelection?.model === "string" &&
709829 input.modelSelection.model.trim().length > 0,
710830 });
831 timedOutNativeCompactions.delete(threadId);
711832
712833 // Changing runtime mode restarts the session, so the transition is only
713834 // observable here, by diffing against the mode the previous session for
......@@ -874,6 +995,128 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
874995 );
875996 });
876997
998 const compactThread: ProviderServiceMethod<"compactThread"> = Effect.fn("compactThread")(
999 function* (threadId, modelSelection, requestId) {
1000 const routed = yield* resolveRoutableSession({
1001 threadId,
1002 operation: "ProviderService.compactThread",
1003 allowRecovery: true,
1004 });
1005 yield* Effect.annotateCurrentSpan({
1006 "provider.operation": "compact-thread",
1007 "provider.kind": routed.adapter.provider,
1008 "provider.thread_id": threadId,
1009 });
1010 yield* McpSessionRegistry.touchActiveMcpThread(threadId);
1011 const nativeCompaction = routed.adapter.compactThread;
1012 const completion = yield* Deferred.make<string>();
1013 const pending: PendingCompaction = {
1014 completion,
1015 native: nativeCompaction !== undefined,
1016 providerInstanceId: routed.instanceId,
1017 requestId,
1018 earlyEvents: [],
1019 compactedEventObserved: false,
1020 expectedTurnId: undefined,
1021 };
1022 if (nativeCompaction !== undefined && timedOutNativeCompactions.has(threadId)) {
1023 return yield* new ProviderAdapterRequestError({
1024 provider: routed.adapter.provider,
1025 method: "thread/compact",
1026 detail:
1027 "The previous context compaction may still be running. Restart the provider session before retrying.",
1028 });
1029 }
1030 const claimed = yield* Effect.sync(() => {
1031 if (pendingCompactions.has(threadId)) return false;
1032 pendingCompactions.set(threadId, pending);
1033 return true;
1034 });
1035 if (!claimed) {
1036 return yield* new ProviderAdapterRequestError({
1037 provider: routed.adapter.provider,
1038 method: "thread/compact",
1039 detail: "Context compaction is already in progress.",
1040 });
1041 }
1042 const clearPending = Effect.sync(() => {
1043 if (pendingCompactions.get(threadId) === pending) {
1044 pendingCompactions.delete(threadId);
1045 }
1046 });
1047 const nativeCompletionTimeout =
1048 routed.adapter.provider === "codex" || routed.adapter.provider === "opencode"
1049 ? "10 minutes"
1050 : "30 seconds";
1051 const awaitNativeCompaction = (start: Effect.Effect<void, ProviderAdapterError>) =>
1052 start.pipe(
1053 Effect.andThen(Deferred.await(completion)),
1054 Effect.timeout(nativeCompletionTimeout),
1055 Effect.catchTag("TimeoutError", (cause) =>
1056 Effect.sync(() => {
1057 timedOutNativeCompactions.add(threadId);
1058 }).pipe(
1059 Effect.andThen(
1060 Effect.fail(
1061 new ProviderAdapterRequestError({
1062 provider: routed.adapter.provider,
1063 method: "thread/compact",
1064 detail: `Provider did not report completed context compaction within ${nativeCompletionTimeout}.`,
1065 cause,
1066 }),
1067 ),
1068 ),
1069 ),
1070 ),
1071 );
1072 const awaitFallbackCompaction = Deferred.await(completion).pipe(
1073 Effect.timeout("10 minutes"),
1074 Effect.mapError(
1075 (cause) =>
1076 new ProviderAdapterRequestError({
1077 provider: routed.adapter.provider,
1078 method: "turn/start",
1079 detail: "Provider did not finish context compaction within 10 minutes.",
1080 cause,
1081 }),
1082 ),
1083 );
1084 const terminal = yield* (
1085 nativeCompaction
1086 ? awaitNativeCompaction(nativeCompaction(routed.threadId, modelSelection))
1087 : Effect.gen(function* () {
1088 const turn = yield* sendTurn({
1089 threadId,
1090 input: routed.adapter.provider === "cursor" ? "/compress" : "/compact",
1091 ...(modelSelection !== undefined ? { modelSelection } : {}),
1092 }).pipe(
1093 Effect.onError(() =>
1094 Effect.forEach(pending.earlyEvents.splice(0), publishRuntimeEvent, {
1095 discard: true,
1096 }),
1097 ),
1098 );
1099 pending.expectedTurnId = turn.turnId;
1100 const earlyEvents = pending.earlyEvents.splice(0);
1101 for (const earlyEvent of earlyEvents) {
1102 yield* processFallbackCompactionEvent(pending, earlyEvent);
1103 }
1104 return yield* awaitFallbackCompaction;
1105 })
1106 ).pipe(Effect.ensuring(clearPending));
1107 if (terminal !== "completed") {
1108 return yield* new ProviderAdapterRequestError({
1109 provider: routed.adapter.provider,
1110 method: nativeCompaction ? "thread/compact" : "turn/start",
1111 detail: `Context compaction ended with ${terminal}.`,
1112 });
1113 }
1114 yield* analytics.record("provider.thread.compacted", {
1115 provider: routed.adapter.provider,
1116 });
1117 },
1118 );
1119
8771120 const interruptTurn: ProviderServiceMethod<"interruptTurn"> = Effect.fn("interruptTurn")(
8781121 function* (rawInput) {
8791122 const input = yield* decodeInputOrValidationError({
......@@ -1006,6 +1249,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
10061249 if (routed.isActive) {
10071250 yield* routed.adapter.stopSession(routed.threadId);
10081251 }
1252 const pendingCompaction = pendingCompactions.get(input.threadId);
1253 if (pendingCompaction !== undefined) {
1254 yield* settleCompaction(input.threadId, pendingCompaction, "turn.aborted");
1255 }
1256 timedOutNativeCompactions.delete(input.threadId);
10091257 yield* clearMcpSession(input.threadId);
10101258 yield* directory.upsert({
10111259 threadId: input.threadId,
......@@ -1285,6 +1533,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
12851533 return {
12861534 startSession,
12871535 sendTurn,
1536 compactThread,
12881537 interruptTurn,
12891538 respondToRequest,
12901539 respondToUserInput,
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
......@@ -164,6 +164,7 @@ describe("ProviderSessionReaper", () => {
164164 const providerService: ProviderServiceShape = {
165165 startSession: () => unsupported(),
166166 sendTurn: () => unsupported(),
167 compactThread: () => unsupported(),
167168 interruptTurn: () => unsupported(),
168169 respondToRequest: () => unsupported(),
169170 respondToUserInput: () => unsupported(),
apps/server/src/provider/Services/ProviderAdapter.ts+5
......@@ -70,6 +70,11 @@ export interface ProviderAdapterShape<TError> {
7070 input: ProviderSendTurnInput,
7171 ) => Effect.Effect<ProviderTurnStartResult, TError>;
7272
73 readonly compactThread?: (
74 threadId: ThreadId,
75 modelSelection?: ProviderSendTurnInput["modelSelection"],
76 ) => Effect.Effect<void, TError>;
77
7378 /**
7479 * Interrupt an active turn.
7580 */
apps/server/src/provider/Services/ProviderService.ts+7
......@@ -23,6 +23,7 @@ import type {
2323 ProviderStopSessionInput,
2424 ProviderUploadFeedbackInput,
2525 ProviderUploadFeedbackResult,
26 MessageId,
2627 ThreadId,
2728 ProviderTurnStartResult,
2829} from "@t3tools/contracts";
......@@ -53,6 +54,12 @@ export interface ProviderServiceShape {
5354 input: ProviderSendTurnInput,
5455 ) => Effect.Effect<ProviderTurnStartResult, ProviderServiceError>;
5556
57 readonly compactThread: (
58 threadId: ThreadId,
59 modelSelection?: ProviderSendTurnInput["modelSelection"],
60 requestId?: MessageId,
61 ) => Effect.Effect<void, ProviderServiceError>;
62
5663 /**
5764 * Interrupt a running provider turn.
5865 */
apps/server/src/provider/providerSnapshot.ts+5
......@@ -23,6 +23,11 @@ export const DEFAULT_TIMEOUT_MS = 4_000;
2323// Auth status checks involve disk/network lookups and can be slow on first run (especially Windows)
2424export const AUTH_PROBE_TIMEOUT_MS = 10_000;
2525
26export const COMPACT_SLASH_COMMAND = {
27 name: "compact",
28 description: "Summarize the conversation and reduce context usage",
29} satisfies ServerProviderSlashCommand;
30
2631export interface CommandResult {
2732 readonly stdout: string;
2833 readonly stderr: string;
apps/server/src/serverRuntimeStartup.reconcile.test.ts+1
......@@ -51,6 +51,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray<ThreadId> = []) =>
5151 ({
5252 startSession: () => Effect.die("unused"),
5353 sendTurn: () => Effect.die("unused"),
54 compactThread: () => Effect.die("unused"),
5455 interruptTurn: () => Effect.die("unused"),
5556 respondToRequest: () => Effect.die("unused"),
5657 respondToUserInput: () => Effect.die("unused"),
apps/web/src/components/ChatView.logic.test.ts+36
......@@ -1566,6 +1566,42 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
15661566
15671567 expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingApproval: true })).toBe(true);
15681568 expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingUserInput: true })).toBe(true);
1569 expect(
1570 hasServerAcknowledgedLocalDispatch({
1571 ...common,
1572 latestTurnStartFailureId: "turn-start-failure-1",
1573 }),
1574 ).toBe(true);
15691575 expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true);
15701576 });
1577
1578 it("acknowledges only a new turn-start failure", () => {
1579 const localDispatch = {
1580 ...createLocalDispatchSnapshot(makeThread()),
1581 latestTurnStartFailureId: "turn-start-failure-old",
1582 };
1583 const common = {
1584 localDispatch,
1585 phase: "ready" as const,
1586 latestTurn: null,
1587 latestUserMessageId: localDispatch.latestUserMessageId,
1588 session: null,
1589 hasPendingApproval: false,
1590 hasPendingUserInput: false,
1591 threadError: null,
1592 };
1593
1594 expect(
1595 hasServerAcknowledgedLocalDispatch({
1596 ...common,
1597 latestTurnStartFailureId: "turn-start-failure-old",
1598 }),
1599 ).toBe(false);
1600 expect(
1601 hasServerAcknowledgedLocalDispatch({
1602 ...common,
1603 latestTurnStartFailureId: "turn-start-failure-new",
1604 }),
1605 ).toBe(true);
1606 });
15711607});
apps/web/src/components/ChatView.logic.ts+27
......@@ -895,6 +895,24 @@ export interface LocalDispatchSnapshot {
895895 latestTurnCompletedAt: string | null;
896896 sessionStatus: NonNullable<Thread["session"]>["status"] | null;
897897 sessionUpdatedAt: string | null;
898 latestTurnStartFailureId: string | null;
899}
900
901export function latestTurnStartFailureId(
902 activeThread: Thread | undefined,
903 latestUserMessageId: ChatMessage["id"] | null,
904): string | null {
905 if (latestUserMessageId === null) return null;
906 return (
907 activeThread?.activities.findLast((activity) => {
908 if (activity.kind !== "provider.turn.start.failed") return false;
909 const payload =
910 typeof activity.payload === "object" && activity.payload !== null
911 ? (activity.payload as { readonly requestId?: unknown })
912 : null;
913 return payload?.requestId === latestUserMessageId;
914 })?.id ?? null
915 );
898916}
899917
900918export function createLocalDispatchSnapshot(
......@@ -918,6 +936,7 @@ export function createLocalDispatchSnapshot(
918936 latestTurnCompletedAt: latestTurn?.completedAt ?? null,
919937 sessionStatus: session?.status ?? null,
920938 sessionUpdatedAt: session?.updatedAt ?? null,
939 latestTurnStartFailureId: latestTurnStartFailureId(activeThread, latestUserMessage?.id ?? null),
921940 };
922941}
923942
......@@ -929,6 +948,7 @@ export function hasServerAcknowledgedLocalDispatch(input: {
929948 session: Thread["session"] | null;
930949 hasPendingApproval: boolean;
931950 hasPendingUserInput: boolean;
951 latestTurnStartFailureId?: string | null;
932952 threadError: string | null | undefined;
933953}): boolean {
934954 if (!input.localDispatch) {
......@@ -937,6 +957,13 @@ export function hasServerAcknowledgedLocalDispatch(input: {
937957 if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) {
938958 return true;
939959 }
960 if (
961 input.latestTurnStartFailureId !== undefined &&
962 input.latestTurnStartFailureId !== null &&
963 input.latestTurnStartFailureId !== input.localDispatch.latestTurnStartFailureId
964 ) {
965 return true;
966 }
940967 if (input.phase === "connecting") {
941968 return false;
942969 }
apps/web/src/components/ChatView.tsx+58-12
......@@ -209,8 +209,8 @@ import { getProviderModelCapabilities } from "../providerModels";
209209import {
210210 applyProviderInstanceSettings,
211211 deriveProviderInstanceEntries,
212 sortProviderInstanceEntries,
213212 NO_PROVIDER_MODEL_SELECTION,
213 sortProviderInstanceEntries,
214214} from "../providerInstances";
215215import {
216216 useClientSettings,
......@@ -330,7 +330,7 @@ import {
330330import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
331331import { ComposerSurface } from "./chat/ComposerSurface";
332332import {
333 hasAvailableClaudeCompactionProvider,
333 hasAvailableCompactionProvider,
334334 hasDismissedResumeCompaction,
335335 shouldOfferResumeCompaction,
336336} from "./chat/ContextWindowMeter.logic";
......@@ -357,6 +357,7 @@ import {
357357 deriveComposerSendState,
358358 dismissBranchMismatchForSession,
359359 hasEnvironmentReconnectWarningGraceElapsed,
360 latestTurnStartFailureId,
360361 scheduleEnvironmentReconnectWarning,
361362 hasServerAcknowledgedLocalDispatch,
362363 isBranchMismatchDismissedForSession,
......@@ -626,6 +627,11 @@ function formatOutgoingPrompt(params: {
626627const SCRIPT_TERMINAL_COLS = 120;
627628const SCRIPT_TERMINAL_ROWS = 30;
628629
630function isCompactCommandMessage(message: ChatMessage): boolean {
631 const text = message.text.trim().toLowerCase();
632 return message.role === "user" && text === "/compact" && !message.attachments?.length;
633}
634
629635type ChatViewProps =
630636 | {
631637 environmentId: EnvironmentId;
......@@ -669,6 +675,10 @@ function useLocalDispatchState(input: {
669675 (message) => message.role === "user",
670676 );
671677 const latestUserMessageId = latestUserMessage?.id ?? null;
678 const currentTurnStartFailureId =
679 localDispatch === null
680 ? null
681 : latestTurnStartFailureId(input.activeThread, latestUserMessageId);
672682
673683 const resetLocalDispatch = useCallback(() => {
674684 setLocalDispatch(null);
......@@ -684,6 +694,7 @@ function useLocalDispatchState(input: {
684694 session: input.activeThread?.session ?? null,
685695 hasPendingApproval: input.activePendingApproval !== null,
686696 hasPendingUserInput: input.activePendingUserInput !== null,
697 latestTurnStartFailureId: currentTurnStartFailureId,
687698 threadError: input.threadError,
688699 }),
689700 [
......@@ -694,6 +705,7 @@ function useLocalDispatchState(input: {
694705 input.phase,
695706 input.threadError,
696707 latestUserMessageId,
708 currentTurnStartFailureId,
697709 localDispatch,
698710 ],
699711 );
......@@ -2587,7 +2599,33 @@ function ChatViewContent(props: ChatViewProps) {
25872599 activePendingUserInput: activePendingUserInput?.requestId ?? null,
25882600 threadError,
25892601 });
2590 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;
2602 const optimisticCompactionMessage = optimisticUserMessages.at(-1);
2603 const pendingCompactionMessage =
2604 isSendBusy &&
2605 optimisticCompactionMessage !== undefined &&
2606 isCompactCommandMessage(optimisticCompactionMessage)
2607 ? optimisticCompactionMessage
2608 : activeThread?.messages.findLast(isCompactCommandMessage);
2609 const compactRequestIsActive =
2610 pendingCompactionMessage !== undefined &&
2611 (pendingCompactionMessage.createdAt >
2612 (activeLatestTurn?.requestedAt ?? pendingCompactionMessage.createdAt) ||
2613 (activeLatestTurn?.state === "running" &&
2614 pendingCompactionMessage.createdAt === activeLatestTurn.requestedAt));
2615 const compactionSettled =
2616 pendingCompactionMessage !== undefined &&
2617 (latestTurnStartFailureId(activeThread, pendingCompactionMessage.id) !== null ||
2618 activeThread?.activities.some((activity) => {
2619 if (activity.kind !== "context-compaction") return false;
2620 const payload = activity.payload as { readonly requestId?: unknown } | null | undefined;
2621 return payload?.requestId === pendingCompactionMessage.id;
2622 }));
2623 const isCompacting =
2624 (isSendBusy || phase === "connecting" || phase === "running") &&
2625 compactRequestIsActive &&
2626 !compactionSettled;
2627 const isWorking =
2628 phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint || isCompacting;
25912629 const activeWorkStartedAt = deriveActiveWorkStartedAt(
25922630 activeLatestTurn,
25932631 activeThread?.session ?? null,
......@@ -2957,10 +2995,11 @@ function ChatViewContent(props: ChatViewProps) {
29572995 });
29582996 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
29592997 const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);
2960 const compactionProviderAvailable = useMemo(
2998 const manualCompactionProviderAvailable = useMemo(
29612999 () =>
2962 hasAvailableClaudeCompactionProvider({
3000 hasAvailableCompactionProvider({
29633001 providers: providerInstanceEntries,
3002 driverKind: selectedProvider,
29643003 instanceId: activeProviderInstanceId,
29653004 lockedInstanceId: lockedProvider
29663005 ? (activeThread?.session?.providerInstanceId ??
......@@ -2974,6 +3013,7 @@ function ChatViewContent(props: ChatViewProps) {
29743013 activeThread?.session?.providerInstanceId,
29753014 lockedProvider,
29763015 providerInstanceEntries,
3016 selectedProvider,
29773017 ],
29783018 );
29793019 const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] =
......@@ -5393,12 +5433,16 @@ function ChatViewContent(props: ChatViewProps) {
53935433 activeThread && activeContextWindow
53945434 ? `${activeThread.id}:${activeContextWindow.updatedAt}`
53955435 : null;
5396 const compactDisabled =
5436 const activeThreadHasCompactableConversation =
5437 activeThread?.messages.some(
5438 (message) => message.role === "user" && !isCompactCommandMessage(message),
5439 ) ?? false;
5440 const compactThreadUnavailable =
53975441 !activeThread ||
5442 !activeThreadHasCompactableConversation ||
53985443 !activeProject ||
53995444 !isServerThread ||
5400 selectedProvider !== "claudeAgent" ||
5401 !compactionProviderAvailable ||
5445 !manualCompactionProviderAvailable ||
54025446 isWorking ||
54035447 threadDetailLoading ||
54045448 isPreparingWorktree ||
......@@ -5406,15 +5450,15 @@ function ChatViewContent(props: ChatViewProps) {
54065450 feedbackUploading ||
54075451 pendingApprovals.length > 0 ||
54085452 pendingUserInputs.length > 0 ||
5409 showPlanFollowUpPrompt ||
5410 composerHasUnsentContent;
5453 showPlanFollowUpPrompt;
5454 const compactDisabled = compactThreadUnavailable || composerHasUnsentContent;
54115455 const compactDisabledReason = compactDisabled
54125456 ? composerHasUnsentContent
54135457 ? "Send or clear your draft before compacting"
54145458 : !activeProject
54155459 ? "Choose a project before compacting"
5416 : !compactionProviderAvailable
5417 ? "Enable a Claude provider before compacting"
5460 : !manualCompactionProviderAvailable
5461 ? "Compaction is unavailable for this provider"
54185462 : "Compacting is unavailable right now"
54195463 : null;
54205464 const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
......@@ -7703,6 +7747,7 @@ function ChatViewContent(props: ChatViewProps) {
77037747 key={activeThread.id}
77047748 isWorking={isWorking}
77057749 isPreparingWorktree={isPreparingWorktree}
7750 isCompacting={isCompacting}
77067751 activeTurnStartedAt={activeWorkStartedAt}
77077752 listRef={legendListRef}
77087753 timelineEntries={timelineEntries}
......@@ -7862,6 +7907,7 @@ function ChatViewContent(props: ChatViewProps) {
78627907 }
78637908 activeThreadModelSelection={activeThread?.modelSelection}
78647909 activeContextWindow={activeContextWindow}
7910 compactThreadUnavailable={compactThreadUnavailable}
78657911 compactDisabled={compactDisabled}
78667912 compactDisabledReason={compactDisabledReason}
78677913 resolvedTheme={resolvedTheme}
apps/web/src/components/chat/ChatComposer.tsx+24-8
......@@ -186,7 +186,10 @@ import {
186186 renderProviderTraitsPicker,
187187} from "./composerProviderState";
188188import { ContextWindowMeter } from "./ContextWindowMeter";
189import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic";
189import {
190 providerSupportsManualCompaction,
191 resolveContextWindowModelDisplayName,
192} from "./ContextWindowMeter.logic";
190193import {
191194 attachVideoThumbnail,
192195 buildExpandedImagePreview,
......@@ -1208,6 +1211,7 @@ export interface ChatComposerProps {
12081211
12091212 // Context window
12101213 activeContextWindow: ContextWindowSnapshot | null;
1214 compactThreadUnavailable: boolean;
12111215 compactDisabled: boolean;
12121216 compactDisabledReason: string | null;
12131217
......@@ -1313,6 +1317,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13131317 activeProjectDefaultModelSelection,
13141318 activeThreadModelSelection,
13151319 activeContextWindow,
1320 compactThreadUnavailable,
13161321 compactDisabled,
13171322 compactDisabledReason,
13181323 resolvedTheme,
......@@ -1603,6 +1608,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
16031608 () => selectedProviderEntry?.snapshot ?? null,
16041609 [selectedProviderEntry],
16051610 );
1611 const compactCommandAvailable = providerSupportsManualCompaction(selectedProviderEntry);
16061612 const selectedProviderSkills = selectedProviderStatus
16071613 ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd)
16081614 : [];
......@@ -1840,7 +1846,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18401846 prompt,
18411847 ],
18421848 );
1843
18441849 // ------------------------------------------------------------------
18451850 // Derived: composer trigger / menu
18461851 // ------------------------------------------------------------------
......@@ -1852,6 +1857,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
18521857 cwd: isPathTrigger ? gitCwd : null,
18531858 query: isPathTrigger ? pathTriggerQuery : null,
18541859 });
1860 const compactSlashCommandAvailable =
1861 composerTrigger?.kind === "slash-command" &&
1862 prompt.slice(0, composerTrigger.rangeStart).trim() === "" &&
1863 !compactThreadUnavailable &&
1864 prompt.slice(composerTrigger.rangeEnd).trim() === "" &&
1865 composerImages.length + composerFiles.length === 0 &&
1866 composerDraft.persistedAttachments.length === 0 &&
1867 composerTerminalContexts.length === 0 &&
1868 composerElementContexts.length === 0 &&
1869 composerPreviewAnnotations.length === 0 &&
1870 composerReviewComments.length === 0;
18551871
18561872 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
18571873 if (!composerTrigger) return [];
......@@ -1920,8 +1936,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19201936 skill.description ??
19211937 (skill.scope ? `${skill.scope} skill` : ""),
19221938 }));
1939 const visibleProviderSlashCommandItems = providerSlashCommandItems.filter(
1940 (item) => item.command.name !== "compact" || compactSlashCommandAvailable,
1941 );
19231942 const slashCommandItems = slashCommandItemsForPromptPosition(
1924 [...builtInSlashCommandItems, ...providerSlashCommandItems, ...skillItems],
1943 [...builtInSlashCommandItems, ...visibleProviderSlashCommandItems, ...skillItems],
19251944 composerTrigger.rangeStart === 0,
19261945 );
19271946 return searchSlashCommandItems(slashCommandItems, query);
......@@ -1941,6 +1960,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
19411960 }
19421961 return [];
19431962 }, [
1963 compactSlashCommandAvailable,
19441964 composerTrigger,
19451965 planModeUiEnabled,
19461966 selectedProvider,
......@@ -2843,7 +2863,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28432863 if (
28442864 compactDisabled ||
28452865 noProviderAvailable ||
2846 composerSendState.hasSendableContent ||
28472866 activePendingApproval !== null ||
28482867 pendingUserInputs.length > 0 ||
28492868 phase === "running" ||
......@@ -2881,7 +2900,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28812900 activeThreadId,
28822901 compactDisabled,
28832902 composerDraftTarget,
2884 composerSendState.hasSendableContent,
28852903 isConnecting,
28862904 isSendBusy,
28872905 noProviderAvailable,
......@@ -5455,9 +5473,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
54555473 compactDisabled || noProviderAvailable || isSendBusy || isConnecting
54565474 }
54575475 compactDisabledReason={resolvedCompactDisabledReason}
5458 {...(selectedProvider === "claudeAgent"
5459 ? { onCompactContext: compactThreadContext }
5460 : {})}
5476 {...(compactCommandAvailable ? { onCompactContext: compactThreadContext } : {})}
54615477 />
54625478 </div>
54635479 </div>
apps/web/src/components/chat/ContextWindowMeter.logic.test.ts+7-5
......@@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
33import { deriveProviderInstanceEntries } from "../../providerInstances";
44import {
55 formatContextWindowCompactionMessage,
6 hasAvailableClaudeCompactionProvider,
6 hasAvailableCompactionProvider,
77 hasDismissedResumeCompaction,
88 resolveContextWindowModelDisplayName,
99 shouldOfferResumeCompaction,
......@@ -25,12 +25,12 @@ function claudeProvider(input: {
2525 auth: { status: "authenticated" },
2626 checkedAt: "2026-08-24T12:00:00.000Z",
2727 models: [],
28 slashCommands: [],
28 slashCommands: [{ name: "compact", description: "" }],
2929 skills: [],
3030 };
3131}
3232
33describe("hasAvailableClaudeCompactionProvider", () => {
33describe("hasAvailableCompactionProvider", () => {
3434 const originalInstanceId = ProviderInstanceId.make("claude_original");
3535
3636 it("rejects a fallback in a different locked continuation group", () => {
......@@ -47,8 +47,9 @@ describe("hasAvailableClaudeCompactionProvider", () => {
4747 ]);
4848
4949 expect(
50 hasAvailableClaudeCompactionProvider({
50 hasAvailableCompactionProvider({
5151 providers,
52 driverKind: ProviderDriverKind.make("claudeAgent"),
5253 instanceId: originalInstanceId,
5354 lockedInstanceId: originalInstanceId,
5455 }),
......@@ -69,8 +70,9 @@ describe("hasAvailableClaudeCompactionProvider", () => {
6970 ]);
7071
7172 expect(
72 hasAvailableClaudeCompactionProvider({
73 hasAvailableCompactionProvider({
7374 providers,
75 driverKind: ProviderDriverKind.make("claudeAgent"),
7476 instanceId: originalInstanceId,
7577 lockedInstanceId: originalInstanceId,
7678 }),
apps/web/src/components/chat/ContextWindowMeter.logic.ts+16-10
......@@ -1,4 +1,4 @@
1import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts";
1import type { ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
22import {
33 CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
44 isClaudeResumeCompactionQuestion,
......@@ -12,27 +12,33 @@ import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils
1212export const CLAUDE_RESUME_COMPACTION_MINUTES = 70;
1313export const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000;
1414
15export function hasAvailableClaudeCompactionProvider(input: {
15export function providerSupportsManualCompaction(
16 provider: ProviderInstanceEntry | null | undefined,
17): boolean {
18 return provider?.snapshot.slashCommands.some((command) => command.name === "compact") ?? false;
19}
20
21export function hasAvailableCompactionProvider(input: {
1622 readonly providers: ReadonlyArray<ProviderInstanceEntry>;
23 readonly driverKind: ProviderDriverKind;
1724 readonly instanceId: ProviderInstanceId | null;
1825 readonly lockedInstanceId: ProviderInstanceId | null;
1926}): boolean {
20 const claudeProviders = input.providers.filter(
21 (provider) => provider.driverKind === "claudeAgent",
27 const driverProviders = input.providers.filter(
28 (provider) => provider.driverKind === input.driverKind,
2229 );
2330 const lockedContinuationGroupKey = input.lockedInstanceId
24 ? claudeProviders.find((provider) => provider.instanceId === input.lockedInstanceId)
31 ? driverProviders.find((provider) => provider.instanceId === input.lockedInstanceId)
2532 ?.continuationGroupKey
2633 : undefined;
2734 const compatibleProviders = lockedContinuationGroupKey
28 ? claudeProviders.filter(
35 ? driverProviders.filter(
2936 (provider) => provider.continuationGroupKey === lockedContinuationGroupKey,
3037 )
31 : claudeProviders;
38 : driverProviders;
3239
33 return (
34 resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined) !==
35 undefined
40 return providerSupportsManualCompaction(
41 resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined),
3642 );
3743}
3844
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+32
......@@ -489,6 +489,38 @@ describe("workEntryIsVisibleInGroup", () => {
489489});
490490
491491describe("deriveMessagesTimelineRows", () => {
492 it("keeps context compaction visible outside folded work", () => {
493 const rows = deriveMessagesTimelineRows({
494 timelineEntries: [
495 {
496 id: "compaction-entry",
497 kind: "work",
498 createdAt: "2026-01-01T00:00:00Z",
499 entry: {
500 id: "compaction",
501 createdAt: "2026-01-01T00:00:00Z",
502 label: "Compacted context 899K → 19K tokens",
503 tone: "info",
504 sourceActivityKind: "context-compaction",
505 },
506 },
507 ],
508 isWorking: false,
509 activeTurnStartedAt: null,
510 turnDiffSummaryByAssistantMessageId: new Map(),
511 revertTurnCountByUserMessageId: new Map(),
512 });
513
514 expect(rows).toEqual([
515 {
516 kind: "context-compaction",
517 id: "compaction-entry",
518 createdAt: "2026-01-01T00:00:00Z",
519 label: "Compacted context 899K → 19K tokens",
520 },
521 ]);
522 });
523
492524 it("only enables assistant copy for the terminal assistant message in a turn", () => {
493525 const rows = deriveMessagesTimelineRows({
494526 timelineEntries: [
apps/web/src/components/chat/MessagesTimeline.logic.ts+31-1
......@@ -332,6 +332,12 @@ export type MessagesTimelineRow =
332332 label: string;
333333 expanded: boolean;
334334 }
335 | {
336 kind: "context-compaction";
337 id: string;
338 createdAt: string;
339 label: string;
340 }
335341 | {
336342 kind: "message";
337343 id: string;
......@@ -601,7 +607,11 @@ function deriveTurnFolds(input: {
601607 // Agent-spawn CTA rows never fold: workflows outlive their launching
602608 // turn (dynamic spawns, background execution), and folding the CTA
603609 // when the turn settles makes a still-running fleet invisible.
604 if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) {
610 if (
611 entry.kind === "work" &&
612 (entry.entry.agentSpawn !== undefined ||
613 entry.entry.sourceActivityKind === "context-compaction")
614 ) {
605615 continue;
606616 }
607617 if (entry.kind === "work" && workEntryRendersImagePreview(entry.entry)) {
......@@ -803,6 +813,7 @@ export function deriveMessagesTimelineRows(input: {
803813 !entryBelongsToActiveTurn(entry, index) ||
804814 entry.kind !== "work" ||
805815 entry.entry.agentSpawn !== undefined ||
816 entry.entry.sourceActivityKind === "context-compaction" ||
806817 entry.entry.tone === "error" ||
807818 workEntryRendersImagePreview(entry.entry)
808819 ) {
......@@ -906,6 +917,19 @@ export function deriveMessagesTimelineRows(input: {
906917 continue;
907918 }
908919
920 if (
921 timelineEntry.kind === "work" &&
922 timelineEntry.entry.sourceActivityKind === "context-compaction"
923 ) {
924 nextRows.push({
925 kind: "context-compaction",
926 id: timelineEntry.id,
927 createdAt: timelineEntry.createdAt,
928 label: timelineEntry.entry.label,
929 });
930 continue;
931 }
932
909933 if (timelineEntry.kind === "work") {
910934 if (
911935 timelineEntry.entry.agentSpawn !== undefined ||
......@@ -929,6 +953,7 @@ export function deriveMessagesTimelineRows(input: {
929953 !nextEntry ||
930954 nextEntry.kind !== "work" ||
931955 nextEntry.entry.agentSpawn !== undefined ||
956 nextEntry.entry.sourceActivityKind === "context-compaction" ||
932957 nextEntry.entry.tone === "error" ||
933958 workEntryRendersImagePreview(nextEntry.entry) ||
934959 activeWorkEntryIds.has(nextEntry.id) ||
......@@ -1150,6 +1175,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
11501175 return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded;
11511176 }
11521177
1178 case "context-compaction": {
1179 const bc = b as typeof a;
1180 return a.createdAt === bc.createdAt && a.label === bc.label;
1181 }
1182
11531183 case "proposed-plan":
11541184 return a.proposedPlan === (b as typeof a).proposedPlan;
11551185
apps/web/src/components/chat/MessagesTimeline.test.tsx+2-2
......@@ -974,7 +974,7 @@ describe("MessagesTimeline", () => {
974974 entry: {
975975 id: "work-1",
976976 createdAt: "2026-03-17T19:12:28.000Z",
977 label: "Context compacted",
977 label: "Compacted context 899K → 19K tokens",
978978 tone: "info",
979979 },
980980 },
......@@ -982,7 +982,7 @@ describe("MessagesTimeline", () => {
982982 />,
983983 );
984984
985 expect(markup).toContain("Context compacted");
985 expect(markup).toContain("Compacted context 899K → 19K tokens");
986986 });
987987
988988 it("summarizes changed files in one line", () => {
apps/web/src/components/chat/MessagesTimeline.tsx+50-5
......@@ -86,6 +86,7 @@ import {
8686 GlobeIcon,
8787 HammerIcon,
8888 MessageCircleIcon,
89 Minimize2Icon,
8990 MousePointerClickIcon,
9091 PaintbrushIcon,
9192 SearchIcon,
......@@ -210,6 +211,7 @@ interface TimelineRowSharedState {
210211interface TimelineRowActivityState {
211212 isWorking: boolean;
212213 isPreparingWorktree: boolean;
214 isCompacting: boolean;
213215 isRevertingCheckpoint: boolean;
214216 latestTurnId: TurnId | null;
215217}
......@@ -294,6 +296,7 @@ interface MessagesTimelineProps {
294296 onOpenAgents?: () => void;
295297 isWorking: boolean;
296298 isPreparingWorktree?: boolean;
299 isCompacting?: boolean;
297300 activeTurnStartedAt: string | null;
298301 listRef: React.RefObject<LegendListRef | null>;
299302 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
......@@ -343,6 +346,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
343346 onCiteAssistantText,
344347 isWorking,
345348 isPreparingWorktree = false,
349 isCompacting = false,
346350 activeTurnStartedAt,
347351 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,
348352 onOpenAgents = NOOP_OPEN_AGENTS,
......@@ -686,10 +690,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
686690 () => ({
687691 isWorking,
688692 isPreparingWorktree,
693 isCompacting,
689694 isRevertingCheckpoint,
690695 latestTurnId: latestTurn?.turnId ?? null,
691696 }),
692 [isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId],
697 [isCompacting, isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId],
693698 );
694699
695700 // Stable renderItem — no closure deps. Row components read shared state
......@@ -1133,6 +1138,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
11331138 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}
11341139 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}
11351140 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}
1141 {row.kind === "context-compaction" ? <ContextCompactionTimelineRow row={row} /> : null}
11361142 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}
11371143 {row.kind === "message" && row.message.role === "assistant" ? (
11381144 <AssistantTimelineRow row={row} />
......@@ -1145,6 +1151,27 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
11451151 );
11461152});
11471153
1154function ContextCompactionTimelineRow({
1155 row,
1156}: {
1157 row: Extract<TimelineRow, { kind: "context-compaction" }>;
1158}) {
1159 return (
1160 <div
1161 role="separator"
1162 aria-label={row.label}
1163 className="mx-auto flex w-full max-w-3xl items-center gap-3 py-1 text-muted-foreground text-xs"
1164 >
1165 <span className="h-px flex-1 bg-border/70" />
1166 <span className="flex shrink-0 items-center gap-1.5">
1167 <Minimize2Icon aria-hidden="true" className="size-3" />
1168 {row.label}
1169 </span>
1170 <span className="h-px flex-1 bg-border/70" />
1171 </div>
1172 );
1173}
1174
11481175function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) {
11491176 const ctx = use(TimelineRowCtx);
11501177 const asset = useMemo(
......@@ -1579,12 +1606,12 @@ function ProposedPlanTimelineRow({
15791606}
15801607
15811608function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
1582 const { isPreparingWorktree } = use(TimelineRowActivityCtx);
1609 const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx);
15831610 return (
15841611 <div className="border-b border-border/60 pb-2 pt-1">
15851612 <div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
15861613 <span
1587 key={isPreparingWorktree ? "setup" : "working"}
1614 key={isPreparingWorktree ? "setup" : isCompacting ? "compacting" : "working"}
15881615 className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none"
15891616 >
15901617 {isPreparingWorktree ? (
......@@ -1592,6 +1619,13 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
15921619 Setting up worktree…
15931620 <ActivityShimmerOverlay>Setting up worktree…</ActivityShimmerOverlay>
15941621 </>
1622 ) : isCompacting ? (
1623 <>
1624 <CompactingLabel />
1625 <ActivityShimmerOverlay>
1626 <CompactingLabel />
1627 </ActivityShimmerOverlay>
1628 </>
15951629 ) : row.createdAt ? (
15961630 <>
15971631 Working for <WorkingTimer createdAt={row.createdAt} />
......@@ -1606,15 +1640,26 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
16061640}
16071641
16081642function ThinkingTimelineRow() {
1609 const { isPreparingWorktree } = use(TimelineRowActivityCtx);
1643 const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx);
16101644 // Reserve the activity row during setup so the handoff keeps the same height.
16111645 return (
16121646 <div className="min-h-7">
1613 {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" iconName="brain" />}
1647 {isPreparingWorktree || isCompacting ? null : (
1648 <LiveActivityRow label="Thinking" iconName="brain" />
1649 )}
16141650 </div>
16151651 );
16161652}
16171653
1654function CompactingLabel() {
1655 return (
1656 <span className="inline-flex items-center gap-1.5">
1657 <Minimize2Icon aria-hidden="true" className="size-3" />
1658 Compacting…
1659 </span>
1660 );
1661}
1662
16181663// ---------------------------------------------------------------------------
16191664// Self-ticking labels — update their own text nodes so elapsed-time display
16201665// does not create a React commit every second while a response is streaming.
docs/user/composer.md+2
......@@ -212,6 +212,8 @@ such as System, Personal, Project, or App.
212212On mobile, these menus are available on the **New task** screen before you start a thread. They
213213use the skills and commands from the selected environment and provider.
214214
215In a thread with prior conversation context, send `/compact` to reduce context usage. Web and desktop also offer this action from the context meter, and the work log records token counts when the provider reports them.
216
215217By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in
216218slash menu** in **Settings → General**. Skill results use the `/skill:Skill Name` label and add the
217219same `$name` skill token to your message. The original skill name remains searchable. If the provider
packages/contracts/src/providerRuntime.ts+2
......@@ -299,6 +299,8 @@ export type ThreadStartedPayload = typeof ThreadStartedPayload.Type;
299299
300300const ThreadStateChangedPayload = Schema.Struct({
301301 state: RuntimeThreadState,
302 beforeTokens: Schema.optional(NonNegativeInt),
303 afterTokens: Schema.optional(NonNegativeInt),
302304 detail: Schema.optional(Schema.Unknown),
303305});
304306export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type;