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";...@@ -16,6 +16,7 @@ import IconArrowUpCircle from "@tabler/icons-react-native/IconArrowUpCircle";
16import IconArrowUpRight from "@tabler/icons-react-native/IconArrowUpRight";16import IconArrowUpRight from "@tabler/icons-react-native/IconArrowUpRight";
17import IconArrowUpRightCircle from "@tabler/icons-react-native/IconArrowUpRightCircle";17import IconArrowUpRightCircle from "@tabler/icons-react-native/IconArrowUpRightCircle";
18import IconArrowsMaximize from "@tabler/icons-react-native/IconArrowsMaximize";18import IconArrowsMaximize from "@tabler/icons-react-native/IconArrowsMaximize";
19import IconArrowsMinimize from "@tabler/icons-react-native/IconArrowsMinimize";
19import IconBellRinging from "@tabler/icons-react-native/IconBellRinging";20import IconBellRinging from "@tabler/icons-react-native/IconBellRinging";
20import IconBolt from "@tabler/icons-react-native/IconBolt";21import IconBolt from "@tabler/icons-react-native/IconBolt";
21import IconBox from "@tabler/icons-react-native/IconBox";22import IconBox from "@tabler/icons-react-native/IconBox";
...@@ -99,6 +100,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {...@@ -99,6 +100,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
99 "arrow.up": IconArrowUp,100 "arrow.up": IconArrowUp,
100 "arrow.up.circle": IconArrowUpCircle,101 "arrow.up.circle": IconArrowUpCircle,
101 "arrow.up.left.and.arrow.down.right": IconArrowsMaximize,102 "arrow.up.left.and.arrow.down.right": IconArrowsMaximize,
103 "arrow.down.right.and.arrow.up.left": IconArrowsMinimize,
102 "arrow.up.right": IconArrowUpRight,104 "arrow.up.right": IconArrowUpRight,
103 "arrow.up.right.circle": IconArrowUpRightCircle,105 "arrow.up.right.circle": IconArrowUpRightCircle,
104 "arrow.uturn.backward": IconArrowBackUp,106 "arrow.uturn.backward": IconArrowBackUp,
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+1
...@@ -319,6 +319,7 @@ export function NewTaskDraftScreen(props: {...@@ -319,6 +319,7 @@ export function NewTaskDraftScreen(props: {
319 : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null,319 : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null,
320 selectedProviderStatus: flow.selectedProviderStatus,320 selectedProviderStatus: flow.selectedProviderStatus,
321 hasThread: false,321 hasThread: false,
322 hasCompactableConversation: false,
322 enabled: isComposerFocused && !isComposerInteractionLocked,323 enabled: isComposerFocused && !isComposerInteractionLocked,
323 onChangeDraftMessage: flow.setPrompt,324 onChangeDraftMessage: flow.setPrompt,
324 onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined,325 onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined,
apps/mobile/src/features/threads/ThreadComposer.tsx+2
...@@ -111,6 +111,7 @@ export interface ThreadComposerProps {...@@ -111,6 +111,7 @@ export interface ThreadComposerProps {
111 readonly connectionError: string | null;111 readonly connectionError: string | null;
112 readonly environmentLabel: string | null;112 readonly environmentLabel: string | null;
113 readonly selectedThread: OrchestrationThreadShell;113 readonly selectedThread: OrchestrationThreadShell;
114 readonly hasCompactableConversation: boolean;
114 readonly serverConfig: T3ServerConfig | null;115 readonly serverConfig: T3ServerConfig | null;
115 readonly queueCount: number;116 readonly queueCount: number;
116 readonly environmentId: EnvironmentId;117 readonly environmentId: EnvironmentId;
...@@ -343,6 +344,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -343,6 +344,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
343 projectCwd: props.projectCwd,344 projectCwd: props.projectCwd,
344 selectedProviderStatus,345 selectedProviderStatus,
345 hasThread: true,346 hasThread: true,
347 hasCompactableConversation: props.hasCompactableConversation,
346 onChangeDraftMessage: props.onChangeDraftMessage,348 onChangeDraftMessage: props.onChangeDraftMessage,
347 onUpdateInteractionMode:349 onUpdateInteractionMode:
348 selectedProviderStatus?.showInteractionModeToggle === false350 selectedProviderStatus?.showInteractionModeToggle === false
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+14
...@@ -100,6 +100,7 @@ export interface ThreadDetailScreenProps {...@@ -100,6 +100,7 @@ export interface ThreadDetailScreenProps {
100 readonly environmentLabel: string | null;100 readonly environmentLabel: string | null;
101 readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;101 readonly selectedThreadFeed: ReadonlyArray<ThreadFeedEntry>;
102 readonly activeWorkStartedAt: string | null;102 readonly activeWorkStartedAt: string | null;
103 readonly isCompacting: boolean;
103 readonly activePendingApproval: PendingApproval | null;104 readonly activePendingApproval: PendingApproval | null;
104 readonly respondingApprovalId: ApprovalRequestId | null;105 readonly respondingApprovalId: ApprovalRequestId | null;
105 readonly activePendingUserInput: PendingUserInput | null;106 readonly activePendingUserInput: PendingUserInput | null;
...@@ -328,6 +329,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -328,6 +329,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
328 if (threadSyncLabel !== null) {329 if (threadSyncLabel !== null) {
329 return { kind: "syncing", label: threadSyncLabel };330 return { kind: "syncing", label: threadSyncLabel };
330 }331 }
332 if (props.isCompacting && contentPresentationKind === "ready") {
333 return { kind: "compacting" };
334 }
331 if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") {335 if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") {
332 return { kind: "working", startedAt: props.activeWorkStartedAt };336 return { kind: "working", startedAt: props.activeWorkStartedAt };
333 }337 }
...@@ -335,6 +339,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -335,6 +339,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
335 })();339 })();
336 const showWorkingControl = floatingStatus !== null;340 const showWorkingControl = floatingStatus !== null;
337 const selectedThreadFeed = props.selectedThreadFeed;341 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);
338 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;351 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
339 const composerOverlapHeight = composerChrome + composerBottomInset;352 const composerOverlapHeight = composerChrome + composerBottomInset;
340 // While a user-input request is pending, the questionnaire owns the353 // While a user-input request is pending, the questionnaire owns the
...@@ -820,6 +833,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -820,6 +833,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
820 connectionError={props.connectionError}833 connectionError={props.connectionError}
821 environmentLabel={props.environmentLabel}834 environmentLabel={props.environmentLabel}
822 selectedThread={props.selectedThread}835 selectedThread={props.selectedThread}
836 hasCompactableConversation={hasCompactableConversation && !props.isCompacting}
823 serverConfig={props.serverConfig}837 serverConfig={props.serverConfig}
824 queueCount={props.selectedThreadQueueCount}838 queueCount={props.selectedThreadQueueCount}
825 environmentId={props.environmentId}839 environmentId={props.environmentId}
apps/mobile/src/features/threads/ThreadFeed.tsx+27
...@@ -144,6 +144,7 @@ import {...@@ -144,6 +144,7 @@ import {
144} from "@t3tools/mobile-markdown-text/links";144} from "@t3tools/mobile-markdown-text/links";
145import {145import {
146 deriveThreadFeedPresentation,146 deriveThreadFeedPresentation,
147 isContextCompactionActivityGroup,
147 type ThreadFeedEntry,148 type ThreadFeedEntry,
148 type ThreadFeedLatestTurn,149 type ThreadFeedLatestTurn,
149} from "../../lib/threadActivity";150} from "../../lib/threadActivity";
...@@ -1557,6 +1558,29 @@ function renderFeedEntry(...@@ -1557,6 +1558,29 @@ function renderFeedEntry(
1557 );1558 );
1558 }1559 }
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
1560 if (entry.type === "message") {1584 if (entry.type === "message") {
1561 const { message } = entry;1585 const { message } = entry;
1562 const isUser = message.role === "user";1586 const isUser = message.role === "user";
...@@ -2721,6 +2745,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2721,6 +2745,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2721 case "work-toggle":2745 case "work-toggle":
2722 return WORK_GROUP_TOGGLE_HEIGHT;2746 return WORK_GROUP_TOGGLE_HEIGHT;
2723 case "activity-group":2747 case "activity-group":
2748 if (isContextCompactionActivityGroup(entry)) {
2749 return undefined;
2750 }
2724 // Expanded rows append a variable detail block — fall back to2751 // Expanded rows append a variable detail block — fall back to
2725 // measurement for those groups.2752 // measurement for those groups.
2726 return entry.activities.some((activity) => expandedWorkRows[activity.id])2753 return entry.activities.some((activity) => expandedWorkRows[activity.id])
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+1
...@@ -775,6 +775,7 @@ function ThreadRouteContent(...@@ -775,6 +775,7 @@ function ThreadRouteContent(
775 environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null}775 environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null}
776 selectedThreadFeed={composer.selectedThreadFeed}776 selectedThreadFeed={composer.selectedThreadFeed}
777 activeWorkStartedAt={composer.activeWorkStartedAt}777 activeWorkStartedAt={composer.activeWorkStartedAt}
778 isCompacting={composer.isCompacting}
778 activePendingApproval={requests.activePendingApproval}779 activePendingApproval={requests.activePendingApproval}
779 respondingApprovalId={requests.respondingApprovalId}780 respondingApprovalId={requests.respondingApprovalId}
780 activePendingUserInput={requests.activePendingUserInput}781 activePendingUserInput={requests.activePendingUserInput}
apps/mobile/src/features/threads/floating-working-control.tsx+24-1
...@@ -13,6 +13,7 @@ import Animated, {...@@ -13,6 +13,7 @@ import Animated, {
13import { withUniwind } from "uniwind";13import { withUniwind } from "uniwind";
1414
15import { AppText as Text } from "../../components/AppText";15import { AppText as Text } from "../../components/AppText";
16import { SymbolView } from "../../components/AppSymbol";
16import { ControlPill } from "../../components/ControlPill";17import { ControlPill } from "../../components/ControlPill";
17import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";18import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
1819
...@@ -46,7 +47,8 @@ export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOS...@@ -46,7 +47,8 @@ export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOS
46 */47 */
47export type FloatingWorkingStatus =48export type FloatingWorkingStatus =
48 | { readonly kind: "working"; readonly startedAt: string }49 | { 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
51export function FloatingWorkingControl(props: {53export function FloatingWorkingControl(props: {
52 readonly colorScheme: "light" | "dark";54 readonly colorScheme: "light" | "dark";
...@@ -161,6 +163,24 @@ export function FloatingWorkingControl(props: {...@@ -161,6 +163,24 @@ export function FloatingWorkingControl(props: {
161 );163 );
162}164}
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
164function FloatingStatusLabel(props: { readonly status: FloatingWorkingStatus }) {184function FloatingStatusLabel(props: { readonly status: FloatingWorkingStatus }) {
165 if (props.status.kind === "syncing") {185 if (props.status.kind === "syncing") {
166 return (186 return (
...@@ -174,6 +194,9 @@ function FloatingStatusLabel(props: { readonly status: FloatingWorkingStatus })...@@ -174,6 +194,9 @@ function FloatingStatusLabel(props: { readonly status: FloatingWorkingStatus })
174 </View>194 </View>
175 );195 );
176 }196 }
197 if (props.status.kind === "compacting") {
198 return <CompactingLabel />;
199 }
177 return <WorkingDuration startedAt={props.status.startedAt} />;200 return <WorkingDuration startedAt={props.status.startedAt} />;
178}201}
179202
apps/mobile/src/features/threads/use-composer-command-menu.ts+6
...@@ -35,6 +35,7 @@ export function buildComposerSlashCommandItems(input: {...@@ -35,6 +35,7 @@ export function buildComposerSlashCommandItems(input: {
35 readonly query: string;35 readonly query: string;
36 readonly atMessageStart: boolean;36 readonly atMessageStart: boolean;
37 readonly hasThread: boolean;37 readonly hasThread: boolean;
38 readonly hasCompactableConversation?: boolean;
38 readonly allowInteractionMode: boolean;39 readonly allowInteractionMode: boolean;
39 readonly selectedProviderStatus: Pick<40 readonly selectedProviderStatus: Pick<
40 ServerProvider,41 ServerProvider,
...@@ -76,6 +77,7 @@ export function buildComposerSlashCommandItems(input: {...@@ -76,6 +77,7 @@ export function buildComposerSlashCommandItems(input: {
76 if (!input.atMessageStart) return items;77 if (!input.atMessageStart) return items;
77 for (const command of input.selectedProviderStatus?.slashCommands ?? []) {78 for (const command of input.selectedProviderStatus?.slashCommands ?? []) {
78 if (!command.name.toLowerCase().includes(query)) continue;79 if (!command.name.toLowerCase().includes(query)) continue;
80 if (command.name === "compact" && !input.hasCompactableConversation) continue;
79 if (81 if (
80 !input.hasThread &&82 !input.hasThread &&
81 input.selectedProviderStatus?.driver === "codex" &&83 input.selectedProviderStatus?.driver === "codex" &&
...@@ -140,6 +142,7 @@ export function useComposerCommandMenu({...@@ -140,6 +142,7 @@ export function useComposerCommandMenu({
140 projectCwd,142 projectCwd,
141 selectedProviderStatus,143 selectedProviderStatus,
142 hasThread,144 hasThread,
145 hasCompactableConversation,
143 enabled = true,146 enabled = true,
144 onChangeDraftMessage,147 onChangeDraftMessage,
145 onUpdateInteractionMode,148 onUpdateInteractionMode,
...@@ -150,6 +153,7 @@ export function useComposerCommandMenu({...@@ -150,6 +153,7 @@ export function useComposerCommandMenu({
150 readonly projectCwd: string | null;153 readonly projectCwd: string | null;
151 readonly selectedProviderStatus: ServerProvider | null;154 readonly selectedProviderStatus: ServerProvider | null;
152 readonly hasThread: boolean;155 readonly hasThread: boolean;
156 readonly hasCompactableConversation: boolean;
153 readonly enabled?: boolean;157 readonly enabled?: boolean;
154 readonly onChangeDraftMessage: (value: string) => void;158 readonly onChangeDraftMessage: (value: string) => void;
155 readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void;159 readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void;
...@@ -262,6 +266,7 @@ export function useComposerCommandMenu({...@@ -262,6 +266,7 @@ export function useComposerCommandMenu({
262 query: q,266 query: q,
263 atMessageStart: trigger.rangeStart === 0,267 atMessageStart: trigger.rangeStart === 0,
264 hasThread,268 hasThread,
269 hasCompactableConversation,
265 allowInteractionMode: onUpdateInteractionMode !== undefined,270 allowInteractionMode: onUpdateInteractionMode !== undefined,
266 selectedProviderStatus,271 selectedProviderStatus,
267 });272 });
...@@ -379,6 +384,7 @@ export function useComposerCommandMenu({...@@ -379,6 +384,7 @@ export function useComposerCommandMenu({
379 return [];384 return [];
380 }, [385 }, [
381 hasThread,386 hasThread,
387 hasCompactableConversation,
382 onUpdateInteractionMode,388 onUpdateInteractionMode,
383 pathSearch.entries,389 pathSearch.entries,
384 selectedProviderStatus,390 selectedProviderStatus,
apps/mobile/src/lib/threadActivity.test.ts+27
...@@ -353,6 +353,33 @@ function makeThread(...@@ -353,6 +353,33 @@ function makeThread(
353}353}
354354
355describe("buildThreadFeed", () => {355describe("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
356 it("keeps long Claude commands expandable without repeating them in full detail", () => {383 it("keeps long Claude commands expandable without repeating them in full detail", () => {
357 const command = `printf 'first line\nsecond line'\n&& printf done`;384 const command = `printf 'first line\nsecond line'\n&& printf done`;
358 const thread = makeThread({385 const thread = makeThread({
apps/mobile/src/lib/threadActivity.ts+28
...@@ -176,6 +176,15 @@ export type ThreadFeedLatestTurn = Pick<...@@ -176,6 +176,15 @@ export type ThreadFeedLatestTurn = Pick<
176 "turnId" | "state" | "startedAt" | "completedAt"176 "turnId" | "state" | "startedAt" | "completedAt"
177>;177>;
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
179function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null {188function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null {
180 switch (requestType) {189 switch (requestType) {
181 case "command_execution_approval":190 case "command_execution_approval":
...@@ -1266,6 +1275,18 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th...@@ -1266,6 +1275,18 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th
1266 continue;1275 continue;
1267 }1276 }
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
1269 if (openGroupActivities !== null && openGroupTurnId === entry.turnId) {1290 if (openGroupActivities !== null && openGroupTurnId === entry.turnId) {
1270 openGroupActivities.push(entry.activity);1291 openGroupActivities.push(entry.activity);
1271 continue;1292 continue;
...@@ -1345,6 +1366,9 @@ function deriveThreadFeedTurnFolds(...@@ -1345,6 +1366,9 @@ function deriveThreadFeedTurnFolds(
1345 pendingUserBoundary = entry.message.createdAt;1366 pendingUserBoundary = entry.message.createdAt;
1346 continue;1367 continue;
1347 }1368 }
1369 if (entry.type === "activity-group" && isContextCompactionActivityGroup(entry)) {
1370 continue;
1371 }
1348 const turnId =1372 const turnId =
1349 entry.type === "message" && entry.message.role === "assistant"1373 entry.type === "message" && entry.message.role === "assistant"
1350 ? entry.message.turnId1374 ? entry.message.turnId
...@@ -1504,6 +1528,10 @@ function appendPresentedFeedEntry(...@@ -1504,6 +1528,10 @@ function appendPresentedFeedEntry(
1504 result.push(entry);1528 result.push(entry);
1505 return;1529 return;
1506 }1530 }
1531 if (isContextCompactionActivityGroup(entry)) {
1532 result.push(entry);
1533 return;
1534 }
15071535
1508 const activities = omitSupersededLifecycleMarkers(1536 const activities = omitSupersededLifecycleMarkers(
1509 entry.activities.filter(1537 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...@@ -56,7 +56,7 @@ import { setPendingConnectionError } from "../state/use-remote-environment-regis
56import { useSelectedThreadDetail } from "../state/use-thread-detail";56import { useSelectedThreadDetail } from "../state/use-thread-detail";
57import { useThreadSelection } from "../state/use-thread-selection";57import { useThreadSelection } from "../state/use-thread-selection";
58import { enqueueThreadOutboxMessage } from "./thread-outbox";58import { enqueueThreadOutboxMessage } from "./thread-outbox";
59import { useThreadOutboxMessages } from "./use-thread-outbox";59import { dispatchingQueuedMessageIdAtom, useThreadOutboxMessages } from "./use-thread-outbox";
60import { threadEnvironment } from "./threads";60import { threadEnvironment } from "./threads";
61import { useAtomCommand } from "./use-atom-command";61import { useAtomCommand } from "./use-atom-command";
62import {62import {
...@@ -107,6 +107,7 @@ export function useThreadComposerState() {...@@ -107,6 +107,7 @@ export function useThreadComposerState() {
107 const selectedThreadDetail = useSelectedThreadDetail();107 const selectedThreadDetail = useSelectedThreadDetail();
108 const composerDrafts = useAtomValue(composerDraftsAtom);108 const composerDrafts = useAtomValue(composerDraftsAtom);
109 const queuedMessagesByThreadKey = useThreadOutboxMessages();109 const queuedMessagesByThreadKey = useThreadOutboxMessages();
110 const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom);
110 const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<111 const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState<
111 Record<string, ReadonlyArray<CodexFeedbackSubmission>>112 Record<string, ReadonlyArray<CodexFeedbackSubmission>>
112 >({});113 >({});
...@@ -170,6 +171,48 @@ export function useThreadComposerState() {...@@ -170,6 +171,48 @@ export function useThreadComposerState() {
170 };171 };
171 }, [selectedThreadDetail, selectedThreadShell]);172 }, [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
173 const activeWorkStartedAt = useMemo(() => {216 const activeWorkStartedAt = useMemo(() => {
174 const selectedThread = selectedThreadDetail ?? selectedThreadShell;217 const selectedThread = selectedThreadDetail ?? selectedThreadShell;
175 if (!selectedThread) {218 if (!selectedThread) {
...@@ -526,6 +569,7 @@ export function useThreadComposerState() {...@@ -526,6 +569,7 @@ export function useThreadComposerState() {
526 selectedThreadFeed,569 selectedThreadFeed,
527 selectedThreadQueueCount,570 selectedThreadQueueCount,
528 activeWorkStartedAt,571 activeWorkStartedAt,
572 isCompacting,
529 draftMessage,573 draftMessage,
530 draftAttachments,574 draftAttachments,
531 modelSelection,575 modelSelection,
apps/mobile/src/state/use-thread-outbox-drain.ts+2-6
...@@ -13,7 +13,7 @@ import {...@@ -13,7 +13,7 @@ import {
13} from "@t3tools/contracts";13} from "@t3tools/contracts";
14import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";14import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
15import * as Cause from "effect/Cause";15import * as Cause from "effect/Cause";
16import { AsyncResult, Atom } from "effect/unstable/reactivity";16import { AsyncResult } from "effect/unstable/reactivity";
17import { useCallback, useEffect, useRef, useState } from "react";17import { useCallback, useEffect, useRef, useState } from "react";
1818
19import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities";19import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities";
...@@ -61,6 +61,7 @@ import {...@@ -61,6 +61,7 @@ import {
61} from "./use-composer-drafts";61} from "./use-composer-drafts";
62import { useAtomCommand } from "./use-atom-command";62import { useAtomCommand } from "./use-atom-command";
63import {63import {
64 dispatchingQueuedMessageIdAtom,
64 editingQueuedMessageIdsAtom,65 editingQueuedMessageIdsAtom,
65 useThreadOutboxMessages,66 useThreadOutboxMessages,
66 useThreadOutboxShellStatuses,67 useThreadOutboxShellStatuses,
...@@ -70,11 +71,6 @@ import {...@@ -70,11 +71,6 @@ import {
70 useRemoteConnectionStatus,71 useRemoteConnectionStatus,
71} from "./use-remote-environment-registry";72} 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
78function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void {74function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void {
79 appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId);75 appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId);
80}76}
apps/mobile/src/state/use-thread-outbox.ts+5
...@@ -31,6 +31,11 @@ export const editingQueuedMessageIdsAtom = Atom.make<Readonly<Record<MessageId,...@@ -31,6 +31,11 @@ export const editingQueuedMessageIdsAtom = Atom.make<Readonly<Record<MessageId,
31 Atom.withLabel("mobile:thread-outbox:editing-message-ids"),31 Atom.withLabel("mobile:thread-outbox:editing-message-ids"),
32);32);
3333
34export const dispatchingQueuedMessageIdAtom = Atom.make<MessageId | null>(null).pipe(
35 Atom.keepAlive,
36 Atom.withLabel("mobile:thread-outbox:dispatching-message-id"),
37);
38
34export function holdEditingQueuedMessage(messageId: MessageId): void {39export function holdEditingQueuedMessage(messageId: MessageId): void {
35 const current = appAtomRegistry.get(editingQueuedMessageIdsAtom);40 const current = appAtomRegistry.get(editingQueuedMessageIdsAtom);
36 if (current[messageId]) {41 if (current[messageId]) {
apps/server/integration/orphanedProviderSessionStartup.integration.test.ts+1
...@@ -110,6 +110,7 @@ const startupDependencies = Layer.mergeAll(...@@ -110,6 +110,7 @@ const startupDependencies = Layer.mergeAll(
110 Layer.succeed(ProviderService.ProviderService, {110 Layer.succeed(ProviderService.ProviderService, {
111 startSession: () => Effect.die("unused"),111 startSession: () => Effect.die("unused"),
112 sendTurn: () => Effect.die("unused"),112 sendTurn: () => Effect.die("unused"),
113 compactThread: () => Effect.die("unused"),
113 interruptTurn: () => Effect.die("unused"),114 interruptTurn: () => Effect.die("unused"),
114 respondToRequest: () => Effect.die("unused"),115 respondToRequest: () => Effect.die("unused"),
115 respondToUserInput: () => Effect.die("unused"),116 respondToUserInput: () => Effect.die("unused"),
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+1
...@@ -113,6 +113,7 @@ function createProviderServiceHarness(...@@ -113,6 +113,7 @@ function createProviderServiceHarness(
113 const service: ProviderServiceShape = {113 const service: ProviderServiceShape = {
114 startSession: () => unsupported(),114 startSession: () => unsupported(),
115 sendTurn: () => unsupported(),115 sendTurn: () => unsupported(),
116 compactThread: () => unsupported(),
116 interruptTurn: () => unsupported(),117 interruptTurn: () => unsupported(),
117 respondToRequest: () => unsupported(),118 respondToRequest: () => unsupported(),
118 respondToUserInput: () => unsupported(),119 respondToUserInput: () => unsupported(),
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+63
...@@ -3196,6 +3196,69 @@ it.layer(makeProjectionPipelinePrefixedTestLayer("t3-pending-turn-terminal-test-...@@ -3196,6 +3196,69 @@ it.layer(makeProjectionPipelinePrefixedTestLayer("t3-pending-turn-terminal-test-
3196 assert.deepEqual(pendingRows, []);3196 assert.deepEqual(pendingRows, []);
3197 }),3197 }),
3198 );3198 );
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 );
3199 },3262 },
3200);3263);
32013264
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+48
...@@ -1269,6 +1269,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1269,6 +1269,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1269 return;1269 return;
12701270
1271 case "thread.turn-start-requested": {1271 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 }
1272 yield* projectionTurnRepository.replacePendingTurnStart({1288 yield* projectionTurnRepository.replacePendingTurnStart({
1273 threadId: event.payload.threadId,1289 threadId: event.payload.threadId,
1274 messageId: event.payload.messageId,1290 messageId: event.payload.messageId,
...@@ -1279,10 +1295,42 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti...@@ -1279,10 +1295,42 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
1279 return;1295 return;
1280 }1296 }
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
1282 case "thread.session-set": {1328 case "thread.session-set": {
1283 const turnId = event.payload.session.activeTurnId;1329 const turnId = event.payload.session.activeTurnId;
1284 if (turnId === null || event.payload.session.status !== "running") {1330 if (turnId === null || event.payload.session.status !== "running") {
1285 if (1331 if (
1332 (event.payload.session.status === "ready" &&
1333 event.commandId?.startsWith("server:provider-session-set:") === true) ||
1286 event.payload.session.status === "error" ||1334 event.payload.session.status === "error" ||
1287 event.payload.session.status === "stopped" ||1335 event.payload.session.status === "stopped" ||
1288 event.payload.session.status === "interrupted"1336 event.payload.session.status === "interrupted"
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+279-1
...@@ -173,6 +173,8 @@ describe("ProviderCommandReactor", () => {...@@ -173,6 +173,8 @@ describe("ProviderCommandReactor", () => {
173 readonly titleRegenerationCompletionDispatchFailures?: number;173 readonly titleRegenerationCompletionDispatchFailures?: number;
174 readonly titleRegenerationBeforeStart?: "one" | "two";174 readonly titleRegenerationBeforeStart?: "one" | "two";
175 readonly serverActivation?: Effect.Effect<void>;175 readonly serverActivation?: Effect.Effect<void>;
176 readonly beforeReadySessionDispatch?: () => Effect.Effect<void>;
177 readonly compactThreadEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>;
176 readonly interruptTurnEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>;178 readonly interruptTurnEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>;
177 readonly stopSessionEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>;179 readonly stopSessionEffect?: () => Effect.Effect<void, ProviderAdapterRequestError>;
178 readonly startSessionEffect?: (180 readonly startSessionEffect?: (
...@@ -264,6 +266,7 @@ describe("ProviderCommandReactor", () => {...@@ -264,6 +266,7 @@ describe("ProviderCommandReactor", () => {
264 turnId: asTurnId("turn-1"),266 turnId: asTurnId("turn-1"),
265 }),267 }),
266 );268 );
269 const compactThread = vi.fn((_: ThreadId) => input?.compactThreadEffect?.() ?? Effect.void);
267 const interruptTurn = vi.fn((_: unknown) => input?.interruptTurnEffect?.() ?? Effect.void);270 const interruptTurn = vi.fn((_: unknown) => input?.interruptTurnEffect?.() ?? Effect.void);
268 const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void);271 const respondToRequest = vi.fn<ProviderServiceShape["respondToRequest"]>(() => Effect.void);
269 const respondToUserInput = vi.fn<ProviderServiceShape["respondToUserInput"]>(() => Effect.void);272 const respondToUserInput = vi.fn<ProviderServiceShape["respondToUserInput"]>(() => Effect.void);
...@@ -349,6 +352,7 @@ describe("ProviderCommandReactor", () => {...@@ -349,6 +352,7 @@ describe("ProviderCommandReactor", () => {
349 const service: ProviderServiceShape = {352 const service: ProviderServiceShape = {
350 startSession: startSession as ProviderServiceShape["startSession"],353 startSession: startSession as ProviderServiceShape["startSession"],
351 sendTurn: sendTurn as ProviderServiceShape["sendTurn"],354 sendTurn: sendTurn as ProviderServiceShape["sendTurn"],
355 compactThread,
352 interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"],356 interruptTurn: interruptTurn as ProviderServiceShape["interruptTurn"],
353 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],357 respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"],
354 respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"],358 respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"],
...@@ -424,7 +428,11 @@ describe("ProviderCommandReactor", () => {...@@ -424,7 +428,11 @@ describe("ProviderCommandReactor", () => {
424 return Effect.die(new Error("Injected title regeneration completion failure"));428 return Effect.die(new Error("Injected title regeneration completion failure"));
425 }429 }
426 }430 }
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)));
428 },436 },
429 get streamDomainEvents() {437 get streamDomainEvents() {
430 return engine.streamDomainEvents;438 return engine.streamDomainEvents;
...@@ -564,6 +572,7 @@ describe("ProviderCommandReactor", () => {...@@ -564,6 +572,7 @@ describe("ProviderCommandReactor", () => {
564 tryHandlePromptCommand,572 tryHandlePromptCommand,
565 startSession,573 startSession,
566 sendTurn,574 sendTurn,
575 compactThread,
567 interruptTurn,576 interruptTurn,
568 respondToRequest,577 respondToRequest,
569 respondToUserInput,578 respondToUserInput,
...@@ -877,6 +886,275 @@ describe("ProviderCommandReactor", () => {...@@ -877,6 +886,275 @@ describe("ProviderCommandReactor", () => {
877 }),886 }),
878 );887 );
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 );
880 effectIt.effect("projects starting before a slow provider session finishes", () =>1158 effectIt.effect("projects starting before a slow provider session finishes", () =>
881 Effect.gen(function* () {1159 Effect.gen(function* () {
882 const releaseStart = yield* Deferred.make<void>();1160 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...@@ -17,6 +17,7 @@ import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shar
17import * as Cache from "effect/Cache";17import * as Cache from "effect/Cache";
18import * as Cause from "effect/Cause";18import * as Cause from "effect/Cause";
19import * as Crypto from "effect/Crypto";19import * as Crypto from "effect/Crypto";
20import * as DateTime from "effect/DateTime";
20import * as Duration from "effect/Duration";21import * as Duration from "effect/Duration";
21import * as Effect from "effect/Effect";22import * as Effect from "effect/Effect";
22import * as Equal from "effect/Equal";23import * as Equal from "effect/Equal";
...@@ -30,7 +31,10 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";...@@ -30,7 +31,10 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
3031
31import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";32import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
32import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts";33import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts";
33import { ProviderAdapterRequestError } from "../../provider/Errors.ts";34import {
35 ProviderAdapterRequestError,
36 ProviderAdapterValidationError,
37} from "../../provider/Errors.ts";
34import type { ProviderServiceError } from "../../provider/Errors.ts";38import type { ProviderServiceError } from "../../provider/Errors.ts";
35import { TextGeneration } from "../../textGeneration/TextGeneration.ts";39import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
36import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts";40import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts";
...@@ -51,6 +55,7 @@ import {...@@ -51,6 +55,7 @@ import {
51import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";55import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
52import { GitWorkflowService } from "../../git/GitWorkflowService.ts";56import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
53const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);57const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
58const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError);
54const isProviderDriverKind = Schema.is(ProviderDriverKind);59const isProviderDriverKind = Schema.is(ProviderDriverKind);
5560
56type ProviderIntentEvent = Extract<61type ProviderIntentEvent = Extract<
...@@ -73,6 +78,10 @@ function toNonEmptyProviderInput(value: string | undefined): string | undefined...@@ -73,6 +78,10 @@ function toNonEmptyProviderInput(value: string | undefined): string | undefined
73 return normalized && normalized.length > 0 ? normalized : undefined;78 return normalized && normalized.length > 0 ? normalized : undefined;
74}79}
7580
81const isCompactCommandMessage = (message: ThreadTitleMessage): boolean =>
82 message.role === "user" &&
83 (message.attachments?.length ?? 0) === 0 &&
84 message.text.trim().toLowerCase() === "/compact";
76function mapProviderSessionStatusToOrchestrationStatus(85function mapProviderSessionStatusToOrchestrationStatus(
77 status: "connecting" | "ready" | "running" | "error" | "closed",86 status: "connecting" | "ready" | "running" | "error" | "closed",
78): OrchestrationSession["status"] {87): OrchestrationSession["status"] {
...@@ -334,6 +343,8 @@ const make = Effect.gen(function* () {...@@ -334,6 +343,8 @@ const make = Effect.gen(function* () {
334 );343 );
335344
336 const threadModelSelections = new Map<string, ModelSelection>();345 const threadModelSelections = new Map<string, ModelSelection>();
346 const compactingThreadIds = new Set<ThreadId>();
347 const stoppingThreadIds = new Set<ThreadId>();
337348
338 const appendProviderFailureActivity = (input: {349 const appendProviderFailureActivity = (input: {
339 readonly threadId: ThreadId;350 readonly threadId: ThreadId;
...@@ -377,11 +388,11 @@ const make = Effect.gen(function* () {...@@ -377,11 +388,11 @@ const make = Effect.gen(function* () {
377388
378 const formatFailureDetail = (cause: Cause.Cause<unknown>): string => {389 const formatFailureDetail = (cause: Cause.Cause<unknown>): string => {
379 const failReason = cause.reasons.find(Cause.isFailReason);390 const failReason = cause.reasons.find(Cause.isFailReason);
380 const providerError = isProviderAdapterRequestError(failReason?.error)391 if (isProviderAdapterRequestError(failReason?.error)) {
381 ? failReason.error392 return failReason.error.detail;
382 : undefined;393 }
383 if (providerError) {394 if (isProviderAdapterValidationError(failReason?.error)) {
384 return providerError.detail;395 return failReason.error.issue;
385 }396 }
386 return Cause.pretty(cause);397 return Cause.pretty(cause);
387 };398 };
...@@ -431,6 +442,37 @@ const make = Effect.gen(function* () {...@@ -431,6 +442,37 @@ const make = Effect.gen(function* () {
431 });442 });
432 });443 });
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
434 const resolveProject = Effect.fnUntraced(function* (projectId: ProjectId) {476 const resolveProject = Effect.fnUntraced(function* (projectId: ProjectId) {
435 return yield* projectionSnapshotQuery477 return yield* projectionSnapshotQuery
436 .getProjectShellById(projectId)478 .getProjectShellById(projectId)
...@@ -1139,7 +1181,6 @@ const make = Effect.gen(function* () {...@@ -1139,7 +1181,6 @@ const make = Effect.gen(function* () {
1139 if (!thread) {1181 if (!thread) {
1140 return;1182 return;
1141 }1183 }
1142
1143 const message = thread.messages.find((entry) => entry.id === event.payload.messageId);1184 const message = thread.messages.find((entry) => entry.id === event.payload.messageId);
1144 if (!message || message.role !== "user") {1185 if (!message || message.role !== "user") {
1145 yield* appendProviderFailureActivity({1186 yield* appendProviderFailureActivity({
...@@ -1149,9 +1190,20 @@ const make = Effect.gen(function* () {...@@ -1149,9 +1190,20 @@ const make = Effect.gen(function* () {
1149 detail: `User message '${event.payload.messageId}' was not found for turn start request.`,1190 detail: `User message '${event.payload.messageId}' was not found for turn start request.`,
1150 turnId: null,1191 turnId: null,
1151 createdAt: event.payload.createdAt,1192 createdAt: event.payload.createdAt,
1193 requestId: event.payload.messageId,
1152 });1194 });
1153 return;1195 return;
1154 }1196 }
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
1156 const handleTurnStartFailure = (cause: Cause.Cause<unknown>) => {1208 const handleTurnStartFailure = (cause: Cause.Cause<unknown>) => {
1157 if (Cause.hasInterruptsOnly(cause)) {1209 if (Cause.hasInterruptsOnly(cause)) {
...@@ -1163,16 +1215,7 @@ const make = Effect.gen(function* () {...@@ -1163,16 +1215,7 @@ const make = Effect.gen(function* () {
1163 detail,1215 detail,
1164 createdAt: event.payload.createdAt,1216 createdAt: event.payload.createdAt,
1165 }).pipe(1217 }).pipe(
1166 Effect.flatMap(() =>1218 Effect.flatMap(() => appendTurnStartFailure("Provider turn start failed", detail)),
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 ),
1176 Effect.asVoid,1219 Effect.asVoid,
1177 );1220 );
1178 };1221 };
...@@ -1242,9 +1285,11 @@ const make = Effect.gen(function* () {...@@ -1242,9 +1285,11 @@ const make = Effect.gen(function* () {
12421285
1243 yield* ensureThreadWorktree(thread);1286 yield* ensureThreadWorktree(thread);
12441287
1245 const isFirstUserMessageTurn =1288 const isCompactCommand = isCompactCommandMessage(message);
1246 thread.messages.filter((entry) => entry.role === "user").length === 1;1289 const nonCompactUserMessageCount = thread.messages.filter(
1247 if (isFirstUserMessageTurn) {1290 (entry) => entry.role === "user" && !isCompactCommandMessage(entry),
1291 ).length;
1292 if (nonCompactUserMessageCount === 1 && !isCompactCommand) {
1248 const project = yield* resolveProject(thread.projectId);1293 const project = yield* resolveProject(thread.projectId);
1249 const generationCwd =1294 const generationCwd =
1250 resolveThreadWorkspaceCwd({1295 resolveThreadWorkspaceCwd({
...@@ -1273,6 +1318,98 @@ const make = Effect.gen(function* () {...@@ -1273,6 +1318,98 @@ const make = Effect.gen(function* () {
1273 }1318 }
1274 }1319 }
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 }
1276 const sendTurnRequest = yield* buildSendTurnRequestForThread({1413 const sendTurnRequest = yield* buildSendTurnRequestForThread({
1277 threadId: event.payload.threadId,1414 threadId: event.payload.threadId,
1278 messageText: message.text,1415 messageText: message.text,
...@@ -1293,7 +1430,7 @@ const make = Effect.gen(function* () {...@@ -1293,7 +1430,7 @@ const make = Effect.gen(function* () {
12931430
1294 yield* providerService1431 yield* providerService
1295 .sendTurn(sendTurnRequest.value)1432 .sendTurn(sendTurnRequest.value)
1296 .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped);1433 .pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped);
1297 });1434 });
12981435
1299 const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* (1436 const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* (
...@@ -1488,26 +1625,59 @@ const make = Effect.gen(function* () {...@@ -1488,26 +1625,59 @@ const make = Effect.gen(function* () {
1488 }1625 }
14891626
1490 const now = event.payload.createdAt;1627 const now = event.payload.createdAt;
1491 if (thread.session && thread.session.status !== "stopped") {1628 const wasCompacting = compactingThreadIds.has(thread.id);
1492 yield* providerService.stopSession({ threadId: thread.id });1629 stoppingThreadIds.add(thread.id);
1493 }1630 const clearStopping = Effect.sync(() => void stoppingThreadIds.delete(thread.id));
14941631 yield* (
1495 yield* setThreadSession({1632 thread.session && thread.session.status !== "stopped"
1496 threadId: thread.id,1633 ? providerService.stopSession({ threadId: thread.id })
1497 session: {1634 : Effect.void
1498 threadId: thread.id,1635 ).pipe(
1499 status: "stopped",1636 Effect.matchCauseEffect({
1500 providerName: thread.session?.providerName ?? null,1637 onFailure: (cause) => {
1501 ...(thread.session?.providerInstanceId !== undefined1638 if (Cause.hasInterruptsOnly(cause)) {
1502 ? { providerInstanceId: thread.session.providerInstanceId }1639 return Effect.interrupt;
1503 : {}),1640 }
1504 runtimeMode: thread.session?.runtimeMode ?? DEFAULT_RUNTIME_MODE,1641 const detail = formatFailureDetail(cause);
1505 activeTurnId: null,1642 return Effect.sync(() => {
1506 lastError: thread.session?.lastError ?? null,1643 stoppingThreadIds.delete(thread.id);
1507 updatedAt: now,1644 return wasCompacting && !compactingThreadIds.has(thread.id);
1508 },1645 }).pipe(
1509 createdAt: now,1646 Effect.flatMap((compactionSettled) =>
1510 });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 );
1511 });1681 });
15121682
1513 const processDomainEvent = Effect.fn("processDomainEvent")(function* (1683 const processDomainEvent = Effect.fn("processDomainEvent")(function* (
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+50-3
...@@ -105,6 +105,7 @@ function createProviderServiceHarness() {...@@ -105,6 +105,7 @@ function createProviderServiceHarness() {
105 const service: ProviderServiceShape = {105 const service: ProviderServiceShape = {
106 startSession: () => unsupported(),106 startSession: () => unsupported(),
107 sendTurn: () => unsupported(),107 sendTurn: () => unsupported(),
108 compactThread: () => unsupported(),
108 interruptTurn: () => unsupported(),109 interruptTurn: () => unsupported(),
109 respondToRequest: () => unsupported(),110 respondToRequest: () => unsupported(),
110 respondToUserInput: () => unsupported(),111 respondToUserInput: () => unsupported(),
...@@ -3284,10 +3285,55 @@ describe("ProviderRuntimeIngestion", () => {...@@ -3284,10 +3285,55 @@ describe("ProviderRuntimeIngestion", () => {
3284 const harness = await createHarness();3285 const harness = await createHarness();
3285 const now = "2026-01-01T00:00:00.000Z";3286 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
3287 harness.emit({3332 harness.emit({
3288 type: "thread.state.changed",3333 type: "thread.state.changed",
3289 eventId: asEventId("evt-thread-compacted"),3334 eventId: asEventId("evt-thread-compacted"),
3290 provider: ProviderDriverKind.make("codex"),3335 provider: ProviderDriverKind.make("codex"),
3336 providerInstanceId: ProviderInstanceId.make("codex"),
3291 createdAt: now,3337 createdAt: now,
3292 threadId: asThreadId("thread-1"),3338 threadId: asThreadId("thread-1"),
3293 turnId: asTurnId("turn-1"),3339 turnId: asTurnId("turn-1"),
...@@ -3299,15 +3345,16 @@ describe("ProviderRuntimeIngestion", () => {...@@ -3299,15 +3345,16 @@ describe("ProviderRuntimeIngestion", () => {
32993345
3300 const thread = await waitForThread(harness.readModel, (entry) =>3346 const thread = await waitForThread(harness.readModel, (entry) =>
3301 entry.activities.some(3347 entry.activities.some(
3302 (activity: ProviderRuntimeTestActivity) => activity.kind === "context-compaction",3348 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-thread-compacted",
3303 ),3349 ),
3304 );3350 );
33053351
3306 const activity = thread.activities.find(3352 const activity = thread.activities.find(
3307 (candidate: ProviderRuntimeTestActivity) => candidate.kind === "context-compaction",3353 (candidate: ProviderRuntimeTestActivity) => candidate.id === "evt-thread-compacted",
3308 );3354 );
3309 expect(activity?.summary).toBe("Context compacted");3355 expect(activity?.summary).toBe("Compacted context 899K → 0 tokens");
3310 expect(activity?.tone).toBe("info");3356 expect(activity?.tone).toBe("info");
3357 expect(activity?.payload).toMatchObject({ requestId: "message-compact" });
3311 });3358 });
33123359
3313 it("projects Codex task lifecycle chunks into thread activities", async () => {3360 it("projects Codex task lifecycle chunks into thread activities", async () => {
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+104-4
...@@ -18,16 +18,20 @@ import {...@@ -18,16 +18,20 @@ import {
18 type OrchestrationThread,18 type OrchestrationThread,
19 type OrchestrationThreadActivity,19 type OrchestrationThreadActivity,
20 type ProviderRuntimeEvent,20 type ProviderRuntimeEvent,
21 RuntimeRequestId,
21} from "@t3tools/contracts";22} from "@t3tools/contracts";
22import * as Cache from "effect/Cache";23import * as Cache from "effect/Cache";
23import * as Cause from "effect/Cause";24import * as Cause from "effect/Cause";
24import * as Crypto from "effect/Crypto";25import * as Crypto from "effect/Crypto";
26import * as DateTime from "effect/DateTime";
25import * as Duration from "effect/Duration";27import * as Duration from "effect/Duration";
26import * as Effect from "effect/Effect";28import * as Effect from "effect/Effect";
27import * as Layer from "effect/Layer";29import * as Layer from "effect/Layer";
28import * as Option from "effect/Option";30import * as Option from "effect/Option";
31import * as Predicate from "effect/Predicate";
29import * as Stream from "effect/Stream";32import * as Stream from "effect/Stream";
30import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";33import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
34import { formatTokens } from "@t3tools/shared/usageFormat";
3135
32import { ProviderService } from "../../provider/Services/ProviderService.ts";36import { ProviderService } from "../../provider/Services/ProviderService.ts";
33import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";37import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
...@@ -251,12 +255,45 @@ function assistantSegmentMessageId(baseKey: string, segmentIndex: number): Messa...@@ -251,12 +255,45 @@ function assistantSegmentMessageId(baseKey: string, segmentIndex: number): Messa
251function buildContextWindowActivityPayload(255function buildContextWindowActivityPayload(
252 event: ProviderRuntimeEvent,256 event: ProviderRuntimeEvent,
253): ThreadTokenUsageSnapshot | undefined {257): 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) {
255 return undefined;259 return undefined;
256 }260 }
257 return event.payload.usage;261 return event.payload.usage;
258}262}
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
260function normalizeRuntimeTurnState(297function normalizeRuntimeTurnState(
261 value: string | undefined,298 value: string | undefined,
262): "completed" | "failed" | "interrupted" | "cancelled" {299): "completed" | "failed" | "interrupted" | "cancelled" {
...@@ -754,15 +791,24 @@ export function runtimeEventToActivities(...@@ -754,15 +791,24 @@ export function runtimeEventToActivities(
754 return [];791 return [];
755 }792 }
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";
757 return [800 return [
758 {801 {
759 id: event.eventId,802 id: event.eventId,
760 createdAt: event.createdAt,803 createdAt: event.createdAt,
761 tone: "info",804 tone: "info",
762 kind: "context-compaction",805 kind: "context-compaction",
763 summary: "Context compacted",806 summary,
764 payload: {807 payload: {
765 state: event.payload.state,808 state: event.payload.state,
809 ...(beforeTokens !== undefined ? { beforeTokens } : {}),
810 ...(afterTokens !== undefined ? { afterTokens } : {}),
811 ...(event.requestId !== undefined ? { requestId: event.requestId } : {}),
766 ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}),812 ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}),
767 },813 },
768 turnId: toTurnId(event.turnId) ?? null,814 turnId: toTurnId(event.turnId) ?? null,
...@@ -1532,13 +1578,16 @@ const make = Effect.gen(function* () {...@@ -1532,13 +1578,16 @@ const make = Effect.gen(function* () {
1532 const now = event.createdAt;1578 const now = event.createdAt;
1533 const eventTurnId = toTurnId(event.turnId);1579 const eventTurnId = toTurnId(event.turnId);
1534 const activeTurnId = thread.session?.activeTurnId ?? null;1580 const activeTurnId = thread.session?.activeTurnId ?? null;
1581 const isCompactedThreadState =
1582 event.type === "thread.state.changed" && event.payload.state === "compacted";
1535 const pendingTurnStart =1583 const pendingTurnStart =
1536 event.type === "session.started" ||1584 event.type === "session.started" ||
1537 event.type === "session.state.changed" ||1585 event.type === "session.state.changed" ||
1538 event.type === "session.exited" ||1586 event.type === "session.exited" ||
1539 event.type === "thread.started" ||1587 event.type === "thread.started" ||
1540 event.type === "turn.started" ||1588 event.type === "turn.started" ||
1541 event.type === "turn.completed"1589 event.type === "turn.completed" ||
1590 isCompactedThreadState
1542 ? yield* projectionTurnRepository.getPendingTurnStartByThreadId({1591 ? yield* projectionTurnRepository.getPendingTurnStartByThreadId({
1543 threadId: thread.id,1592 threadId: thread.id,
1544 })1593 })
...@@ -2057,7 +2106,58 @@ const make = Effect.gen(function* () {...@@ -2057,7 +2106,58 @@ const make = Effect.gen(function* () {
2057 }2106 }
2058 }2107 }
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);
2061 yield* Effect.forEach(activities, (activity) =>2161 yield* Effect.forEach(activities, (activity) =>
2062 providerCommandId(event, "thread-activity-append").pipe(2162 providerCommandId(event, "thread-activity-append").pipe(
2063 Effect.flatMap((commandId) =>2163 Effect.flatMap((commandId) =>
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+16-2
...@@ -2370,7 +2370,7 @@ describe("ClaudeAdapterLive", () => {...@@ -2370,7 +2370,7 @@ describe("ClaudeAdapterLive", () => {
2370 const harness = makeHarness();2370 const harness = makeHarness();
2371 return Effect.gen(function* () {2371 return Effect.gen(function* () {
2372 const adapter = yield* ClaudeAdapter;2372 const adapter = yield* ClaudeAdapter;
2373 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe(2373 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 11).pipe(
2374 Stream.runCollect,2374 Stream.runCollect,
2375 Effect.forkChild,2375 Effect.forkChild,
2376 );2376 );
...@@ -2410,6 +2410,13 @@ describe("ClaudeAdapterLive", () => {...@@ -2410,6 +2410,13 @@ describe("ClaudeAdapterLive", () => {
2410 session_id: "sdk-session-compacted-usage",2410 session_id: "sdk-session-compacted-usage",
2411 uuid: "compact-boundary-usage",2411 uuid: "compact-boundary-usage",
2412 } as unknown as SDKMessage);2412 } 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);
2413 harness.query.emit({2420 harness.query.emit({
2414 type: "result",2421 type: "result",
2415 subtype: "success",2422 subtype: "success",
...@@ -2433,6 +2440,14 @@ describe("ClaudeAdapterLive", () => {...@@ -2433,6 +2440,14 @@ describe("ClaudeAdapterLive", () => {
2433 } as unknown as SDKMessage);2440 } as unknown as SDKMessage);
24342441
2435 const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));2442 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);
2436 const finalUsageEvent = runtimeEvents.findLast(2451 const finalUsageEvent = runtimeEvents.findLast(
2437 (event) => event.type === "thread.token-usage.updated",2452 (event) => event.type === "thread.token-usage.updated",
2438 );2453 );
...@@ -2440,7 +2455,6 @@ describe("ClaudeAdapterLive", () => {...@@ -2440,7 +2455,6 @@ describe("ClaudeAdapterLive", () => {
2440 if (finalUsageEvent?.type === "thread.token-usage.updated") {2455 if (finalUsageEvent?.type === "thread.token-usage.updated") {
2441 assert.deepEqual(finalUsageEvent.payload.usage, {2456 assert.deepEqual(finalUsageEvent.payload.usage, {
2442 usedTokens: 40,2457 usedTokens: 40,
2443 lastUsedTokens: 200,
2444 totalProcessedTokens: 450,2458 totalProcessedTokens: 450,
2445 maxTokens: 200000,2459 maxTokens: 200000,
2446 });2460 });
apps/server/src/provider/Layers/ClaudeAdapter.ts+23-14
...@@ -624,12 +624,17 @@ function compactBoundaryTokenUsageSnapshot(...@@ -624,12 +624,17 @@ function compactBoundaryTokenUsageSnapshot(
624 }624 }
625625
626 const preTokens = finiteNonNegativeInteger(compactMetadata.pre_tokens);626 const preTokens = finiteNonNegativeInteger(compactMetadata.pre_tokens);
627 return makeClaudeTokenUsageSnapshot({627 const snapshot = makeClaudeTokenUsageSnapshot({
628 activeTokens: postTokens,628 activeTokens: postTokens,
629 ...(preTokens !== undefined ? { lastUsedTokens: preTokens } : {}),629 ...(preTokens !== undefined ? { lastUsedTokens: preTokens } : {}),
630 ...(contextWindow !== undefined ? { contextWindow } : {}),630 ...(contextWindow !== undefined ? { contextWindow } : {}),
631 ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}),631 ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}),
632 });632 });
633 if (snapshot === undefined || preTokens !== undefined) {
634 return snapshot;
635 }
636 const { lastUsedTokens: _lastUsedTokens, ...snapshotWithoutBeforeTokens } = snapshot;
637 return snapshotWithoutBeforeTokens;
633}638}
634639
635function normalizeClaudeTaskProgressTokenUsage(640function normalizeClaudeTaskProgressTokenUsage(
...@@ -3203,32 +3208,36 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3203,32 +3208,36 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3203 },3208 },
3204 });3209 });
3205 return;3210 return;
3206 case "compact_boundary":3211 case "compact_boundary": {
3207 if (context.turnState) {3212 if (context.turnState) {
3208 context.turnState.latestAssistantUsage = undefined;3213 context.turnState.latestAssistantUsage = undefined;
3209 context.turnState.compactedSinceLatestAssistantUsage = true;3214 context.turnState.compactedSinceLatestAssistantUsage = true;
3210 }3215 }
3211 yield* emitThreadTokenUsage(3216 const compactedUsage = compactBoundaryTokenUsageSnapshot(
3212 context,3217 message as unknown as Record<string, unknown>,
3213 compactBoundaryTokenUsageSnapshot(3218 context.lastKnownContextWindow,
3214 message as unknown as Record<string, unknown>,3219 context.lastKnownTotalProcessedTokens,
3215 context.lastKnownContextWindow,
3216 context.lastKnownTotalProcessedTokens,
3217 ),
3218 {
3219 rawMethod: "claude/system/compact_boundary",
3220 rawPayload: message,
3221 },
3222 );3220 );
3221 yield* emitThreadTokenUsage(context, compactedUsage, {
3222 rawMethod: "claude/system/compact_boundary",
3223 rawPayload: message,
3224 });
3223 yield* offerRuntimeEvent({3225 yield* offerRuntimeEvent({
3224 ...base,3226 ...base,
3225 type: "thread.state.changed",3227 type: "thread.state.changed",
3226 payload: {3228 payload: {
3227 state: "compacted",3229 state: "compacted",
3230 ...(compactedUsage?.lastUsedTokens !== undefined
3231 ? { beforeTokens: compactedUsage.lastUsedTokens }
3232 : {}),
3233 ...(compactedUsage?.usedTokens !== undefined
3234 ? { afterTokens: compactedUsage.usedTokens }
3235 : {}),
3228 detail: message,3236 detail: message,
3229 },3237 },
3230 });3238 });
3231 return;3239 return;
3240 }
3232 case "hook_started":3241 case "hook_started":
3233 yield* offerRuntimeEvent({3242 yield* offerRuntimeEvent({
3234 ...base,3243 ...base,
...@@ -3790,7 +3799,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3790,7 +3799,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
37903799
3791 // Same reason as the approvals above: a request nobody can answer any more3800 // Same reason as the approvals above: a request nobody can answer any more
3792 // must not stay open, or the thread can never be settled.3801 // 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()) {
3794 yield* pending.cancel;3803 yield* pending.cancel;
3795 }3804 }
37963805
apps/server/src/provider/Layers/ClaudeProvider.ts+2-7
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
2424
25import {25import {
26 buildServerProvider,26 buildServerProvider,
27 COMPACT_SLASH_COMMAND,
27 DEFAULT_TIMEOUT_MS,28 DEFAULT_TIMEOUT_MS,
28 isCommandMissingCause,29 isCommandMissingCause,
29 parseGenericCliVersion,30 parseGenericCliVersion,
...@@ -529,13 +530,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(...@@ -529,13 +530,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
529 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))530 ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined))
530 : undefined;531 : undefined;
531 const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment);532 const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment);
532 const slashCommands = [533 const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])];
533 {
534 name: "compact",
535 description: "Summarize the conversation and reduce context usage",
536 },
537 ...(capabilities?.slashCommands ?? []),
538 ];
539 const dedupedSlashCommands = dedupeSlashCommands(slashCommands);534 const dedupedSlashCommands = dedupeSlashCommands(slashCommands);
540535
541 if (!capabilities) {536 if (!capabilities) {
apps/server/src/provider/Layers/CodexAdapter.test.ts+43
...@@ -84,6 +84,8 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {...@@ -84,6 +84,8 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape {
84 }),84 }),
85 );85 );
8686
87 public readonly compactThread = Effect.void;
88
87 public readonly interruptTurnImpl = vi.fn((_turnId?: TurnId): Promise<void> =>89 public readonly interruptTurnImpl = vi.fn((_turnId?: TurnId): Promise<void> =>
88 Promise.resolve(undefined),90 Promise.resolve(undefined),
89 );91 );
...@@ -334,6 +336,47 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {...@@ -334,6 +336,47 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
334 }),336 }),
335 );337 );
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
337 it.effect("uploads feedback for the active Codex thread", () =>380 it.effect("uploads feedback for the active Codex thread", () =>
338 Effect.gen(function* () {381 Effect.gen(function* () {
339 const adapter = yield* CodexAdapter;382 const adapter = yield* CodexAdapter;
apps/server/src/provider/Layers/CodexAdapter.ts+22-1
...@@ -1485,7 +1485,18 @@ function mapToRuntimeEvents(...@@ -1485,7 +1485,18 @@ function mapToRuntimeEvents(
1485 ];1485 ];
1486 }1486 }
1487 const completed = mapItemLifecycle(event, canonicalThreadId, "item.completed");1487 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 ];
1489 }1500 }
14901501
1491 if (1502 if (
...@@ -2216,6 +2227,15 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2216,6 +2227,15 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2216 ),2227 ),
2217 );2228 );
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
2219 const readThread: CodexAdapterShape["readThread"] = (threadId) =>2239 const readThread: CodexAdapterShape["readThread"] = (threadId) =>
2220 requireSession(threadId).pipe(2240 requireSession(threadId).pipe(
2221 Effect.flatMap((session) => session.runtime.readThread),2241 Effect.flatMap((session) => session.runtime.readThread),
...@@ -2351,6 +2371,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2351,6 +2371,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2351 },2371 },
2352 startSession,2372 startSession,
2353 sendTurn,2373 sendTurn,
2374 compactThread,
2354 interruptTurn,2375 interruptTurn,
2355 readThread,2376 readThread,
2356 rollbackThread,2377 rollbackThread,
apps/server/src/provider/Layers/CodexProvider.ts+2
...@@ -30,6 +30,7 @@ import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts...@@ -30,6 +30,7 @@ import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts
30import {30import {
31 AUTH_PROBE_TIMEOUT_MS,31 AUTH_PROBE_TIMEOUT_MS,
32 buildServerProvider,32 buildServerProvider,
33 COMPACT_SLASH_COMMAND,
33 type ServerProviderDraft,34 type ServerProviderDraft,
34} from "../providerSnapshot.ts";35} from "../providerSnapshot.ts";
35import { expandHomePath } from "../../pathExpansion.ts";36import { expandHomePath } from "../../pathExpansion.ts";
...@@ -690,6 +691,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu...@@ -690,6 +691,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
690 models: snapshot.models,691 models: snapshot.models,
691 skills: snapshot.skills,692 skills: snapshot.skills,
692 slashCommands: [693 slashCommands: [
694 COMPACT_SLASH_COMMAND,
693 {695 {
694 name: "feedback",696 name: "feedback",
695 description: "Send this thread and Codex logs to OpenAI",697 description: "Send this thread and Codex logs to OpenAI",
apps/server/src/provider/Layers/CodexSessionRuntime.ts+5
...@@ -196,6 +196,7 @@ export interface CodexSessionRuntimeShape {...@@ -196,6 +196,7 @@ export interface CodexSessionRuntimeShape {
196 readonly sendTurn: (196 readonly sendTurn: (
197 input: CodexSessionRuntimeSendTurnInput,197 input: CodexSessionRuntimeSendTurnInput,
198 ) => Effect.Effect<ProviderTurnStartResult, CodexSessionRuntimeError>;198 ) => Effect.Effect<ProviderTurnStartResult, CodexSessionRuntimeError>;
199 readonly compactThread: Effect.Effect<void, CodexSessionRuntimeError>;
199 readonly interruptTurn: (turnId?: TurnId) => Effect.Effect<void, CodexSessionRuntimeError>;200 readonly interruptTurn: (turnId?: TurnId) => Effect.Effect<void, CodexSessionRuntimeError>;
200 readonly readThread: Effect.Effect<CodexThreadSnapshot, CodexSessionRuntimeError>;201 readonly readThread: Effect.Effect<CodexThreadSnapshot, CodexSessionRuntimeError>;
201 readonly rollbackThread: (202 readonly rollbackThread: (
...@@ -2293,6 +2294,10 @@ export const makeCodexSessionRuntime = (...@@ -2293,6 +2294,10 @@ export const makeCodexSessionRuntime = (
2293 return {2294 return {
2294 start,2295 start,
2295 getSession: Ref.get(sessionRef),2296 getSession: Ref.get(sessionRef),
2297 compactThread: Effect.gen(function* () {
2298 const providerThreadId = yield* readProviderThreadId;
2299 yield* client.request("thread/compact/start", { threadId: providerThreadId });
2300 }),
2296 sendTurn: (input) =>2301 sendTurn: (input) =>
2297 Effect.gen(function* () {2302 Effect.gen(function* () {
2298 const providerThreadId = yield* readProviderThreadId;2303 const providerThreadId = yield* readProviderThreadId;
apps/server/src/provider/Layers/CursorProvider.ts+2
...@@ -34,6 +34,7 @@ import {...@@ -34,6 +34,7 @@ import {
34 buildBooleanOptionDescriptor,34 buildBooleanOptionDescriptor,
35 buildSelectOptionDescriptor,35 buildSelectOptionDescriptor,
36 buildServerProvider,36 buildServerProvider,
37 COMPACT_SLASH_COMMAND,
37 collectStreamAsString,38 collectStreamAsString,
38 isCommandMissingCause,39 isCommandMissingCause,
39 providerModelsFromSettings,40 providerModelsFromSettings,
...@@ -639,6 +640,7 @@ export function buildCursorProviderSnapshot(input: {...@@ -639,6 +640,7 @@ export function buildCursorProviderSnapshot(input: {
639 input.cursorSettings.customModels,640 input.cursorSettings.customModels,
640 EMPTY_CAPABILITIES,641 EMPTY_CAPABILITIES,
641 ),642 ),
643 slashCommands: [COMPACT_SLASH_COMMAND],
642 probe: {644 probe: {
643 installed: true,645 installed: true,
644 version: input.parsed.version,646 version: input.parsed.version,
apps/server/src/provider/Layers/GrokProvider.ts+2
...@@ -21,6 +21,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell";...@@ -21,6 +21,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell";
21import {21import {
22 AUTH_PROBE_TIMEOUT_MS,22 AUTH_PROBE_TIMEOUT_MS,
23 buildServerProvider,23 buildServerProvider,
24 COMPACT_SLASH_COMMAND,
24 isCommandMissingCause,25 isCommandMissingCause,
25 parseGenericCliVersion,26 parseGenericCliVersion,
26 providerModelsFromSettings,27 providerModelsFromSettings,
...@@ -499,6 +500,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func...@@ -499,6 +500,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
499 checkedAt,500 checkedAt,
500 models,501 models,
501 skills,502 skills,
503 slashCommands: [COMPACT_SLASH_COMMAND],
502 probe: {504 probe: {
503 installed: true,505 installed: true,
504 version,506 version,
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+39
...@@ -79,6 +79,7 @@ const runtimeMock = {...@@ -79,6 +79,7 @@ const runtimeMock = {
79 messageCalls: [] as Array<{ sessionID: string; messageID: string }>,79 messageCalls: [] as Array<{ sessionID: string; messageID: string }>,
80 messageFailures: 0,80 messageFailures: 0,
81 promptCalls: [] as Array<unknown>,81 promptCalls: [] as Array<unknown>,
82 summarizeCalls: [] as Array<unknown>,
82 promptAsyncError: null as Error | null,83 promptAsyncError: null as Error | null,
83 promptAsyncImplementation: null as (() => Promise<void>) | null,84 promptAsyncImplementation: null as (() => Promise<void>) | null,
84 autoPromptEcho: true,85 autoPromptEcho: true,
...@@ -130,6 +131,7 @@ const runtimeMock = {...@@ -130,6 +131,7 @@ const runtimeMock = {
130 this.state.messageCalls.length = 0;131 this.state.messageCalls.length = 0;
131 this.state.messageFailures = 0;132 this.state.messageFailures = 0;
132 this.state.promptCalls.length = 0;133 this.state.promptCalls.length = 0;
134 this.state.summarizeCalls.length = 0;
133 this.state.promptAsyncError = null;135 this.state.promptAsyncError = null;
134 this.state.promptAsyncImplementation = null;136 this.state.promptAsyncImplementation = null;
135 this.state.autoPromptEcho = true;137 this.state.autoPromptEcho = true;
...@@ -315,6 +317,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -315,6 +317,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
315 });317 });
316 }318 }
317 },319 },
320 summarize: async (input: unknown) => {
321 runtimeMock.state.summarizeCalls.push(input);
322 return { data: true };
323 },
318 messages: async () => ({ data: runtimeMock.state.messages }),324 messages: async () => ({ data: runtimeMock.state.messages }),
319 message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => {325 message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => {
320 runtimeMock.state.messageCalls.push({ sessionID, messageID });326 runtimeMock.state.messageCalls.push({ sessionID, messageID });
...@@ -954,6 +960,39 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -954,6 +960,39 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
954 }),960 }),
955 );961 );
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 );
957 it.effect("falls back to a fresh session when the persisted session is gone", () =>996 it.effect("falls back to a fresh session when the persisted session is gone", () =>
958 Effect.gen(function* () {997 Effect.gen(function* () {
959 const adapter = yield* OpenCodeAdapter;998 const adapter = yield* OpenCodeAdapter;
apps/server/src/provider/Layers/OpenCodeAdapter.ts+81
...@@ -2070,6 +2070,21 @@ export function makeOpenCodeAdapter(...@@ -2070,6 +2070,21 @@ export function makeOpenCodeAdapter(
2070 }2070 }
2071 break;2071 break;
2072 }2072 }
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
2074 case "message.updated": {2089 case "message.updated": {
2075 const promptAdmission = context.promptAdmission;2090 const promptAdmission = context.promptAdmission;
...@@ -3041,6 +3056,71 @@ export function makeOpenCodeAdapter(...@@ -3041,6 +3056,71 @@ export function makeOpenCodeAdapter(
3041 );3056 );
3042 });3057 });
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 });
3044 const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(3124 const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
3045 function* (threadId, turnId) {3125 function* (threadId, turnId) {
3046 const context = yield* ensureSessionContext(sessions, threadId);3126 const context = yield* ensureSessionContext(sessions, threadId);
...@@ -3325,6 +3405,7 @@ export function makeOpenCodeAdapter(...@@ -3325,6 +3405,7 @@ export function makeOpenCodeAdapter(
3325 },3405 },
3326 startSession,3406 startSession,
3327 sendTurn,3407 sendTurn,
3408 compactThread,
3328 interruptTurn,3409 interruptTurn,
3329 respondToRequest,3410 respondToRequest,
3330 respondToUserInput,3411 respondToUserInput,
apps/server/src/provider/Layers/OpenCodeProvider.ts+2
...@@ -13,6 +13,7 @@ import { createModelCapabilities } from "@t3tools/shared/model";...@@ -13,6 +13,7 @@ import { createModelCapabilities } from "@t3tools/shared/model";
13import { compareSemverVersions } from "@t3tools/shared/semver";13import { compareSemverVersions } from "@t3tools/shared/semver";
14import {14import {
15 buildServerProvider,15 buildServerProvider,
16 COMPACT_SLASH_COMMAND,
16 nonEmptyTrimmed,17 nonEmptyTrimmed,
17 parseGenericCliVersion,18 parseGenericCliVersion,
18 providerModelsFromSettings,19 providerModelsFromSettings,
...@@ -525,6 +526,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu...@@ -525,6 +526,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
525 checkedAt,526 checkedAt,
526 models,527 models,
527 skills,528 skills,
529 slashCommands: [COMPACT_SLASH_COMMAND],
528 probe: {530 probe: {
529 installed: true,531 installed: true,
530 version,532 version,
apps/server/src/provider/Layers/ProviderRegistry.test.ts+4-10
...@@ -49,6 +49,7 @@ import {...@@ -49,6 +49,7 @@ import {
49import * as ServerConfig from "../../config.ts";49import * as ServerConfig from "../../config.ts";
50import * as ServerSettingsModule from "../../serverSettings.ts";50import * as ServerSettingsModule from "../../serverSettings.ts";
51import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts";51import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts";
52import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts";
52import type { ProviderInstance } from "../ProviderDriver.ts";53import type { ProviderInstance } from "../ProviderDriver.ts";
53import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts";54import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts";
54import * as ProviderRegistry from "../Services/ProviderRegistry.ts";55import * as ProviderRegistry from "../Services/ProviderRegistry.ts";
...@@ -385,7 +386,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -385,7 +386,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
385 shortDescription: "Debug failing GitHub Actions checks",386 shortDescription: "Debug failing GitHub Actions checks",
386 },387 },
387 ]);388 ]);
388 assert.deepStrictEqual(status.slashCommands, [389 assert.deepStrictEqual(status.slashCommands.slice(1), [
389 {390 {
390 name: "feedback",391 name: "feedback",
391 description: "Send this thread and Codex logs to OpenAI",392 description: "Send this thread and Codex logs to OpenAI",
...@@ -2550,11 +2551,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2550,11 +2551,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2550 }),2551 }),
2551 );2552 );
25522553
2553 assert.deepStrictEqual(status.slashCommands, [2554 assert.deepStrictEqual(status.slashCommands.slice(1), [
2554 {
2555 name: "compact",
2556 description: "Summarize the conversation and reduce context usage",
2557 },
2558 {2555 {
2559 name: "review",2556 name: "review",
2560 description: "Review a pull request",2557 description: "Review a pull request",
...@@ -2598,10 +2595,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2598,10 +2595,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2598 );2595 );
25992596
2600 assert.deepStrictEqual(status.slashCommands, [2597 assert.deepStrictEqual(status.slashCommands, [
2601 {2598 COMPACT_SLASH_COMMAND,
2602 name: "compact",
2603 description: "Summarize the conversation and reduce context usage",
2604 },
2605 {2599 {
2606 name: "ui",2600 name: "ui",
2607 description: "Explore and refine UI",2601 description: "Explore and refine UI",
apps/server/src/provider/Layers/ProviderService.test.ts+251
...@@ -175,6 +175,18 @@ function makeFakeCodexAdapter(...@@ -175,6 +175,18 @@ function makeFakeCodexAdapter(
175 Effect.void,175 Effect.void,
176 );176 );
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 );
178 const respondToRequest = vi.fn(190 const respondToRequest = vi.fn(
179 (191 (
180 _threadId: ThreadId,192 _threadId: ThreadId,
...@@ -251,6 +263,7 @@ function makeFakeCodexAdapter(...@@ -251,6 +263,7 @@ function makeFakeCodexAdapter(
251 },263 },
252 startSession,264 startSession,
253 sendTurn,265 sendTurn,
266 ...(provider === CODEX_DRIVER ? { compactThread } : {}),
254 interruptTurn,267 interruptTurn,
255 respondToRequest,268 respondToRequest,
256 respondToUserInput,269 respondToUserInput,
...@@ -287,6 +300,7 @@ function makeFakeCodexAdapter(...@@ -287,6 +300,7 @@ function makeFakeCodexAdapter(
287 updateSession,300 updateSession,
288 startSession,301 startSession,
289 sendTurn,302 sendTurn,
303 compactThread,
290 interruptTurn,304 interruptTurn,
291 respondToRequest,305 respondToRequest,
292 respondToUserInput,306 respondToUserInput,
...@@ -1215,6 +1229,243 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1215,6 +1229,243 @@ routing.layer("ProviderServiceLive routing", (it) => {
1215 }),1229 }),
1216 );1230 );
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
1218 it.effect("routes feedback to the Codex adapter and returns its feedback ID", () =>1469 it.effect("routes feedback to the Codex adapter and returns its feedback ID", () =>
1219 Effect.gen(function* () {1470 Effect.gen(function* () {
1220 const provider = yield* ProviderService.ProviderService;1471 const provider = yield* ProviderService.ProviderService;
apps/server/src/provider/Layers/ProviderService.ts+258-9
...@@ -10,16 +10,20 @@...@@ -10,16 +10,20 @@
10 * @module ProviderServiceLive10 * @module ProviderServiceLive
11 */11 */
12import {12import {
13 EventId,
14 MessageId,
13 ModelSelection,15 ModelSelection,
14 NonNegativeInt,16 NonNegativeInt,
15 ThreadId,
16 ProviderInterruptTurnInput,17 ProviderInterruptTurnInput,
17 ProviderRespondToRequestInput,18 ProviderRespondToRequestInput,
18 ProviderRespondToUserInputInput,19 ProviderRespondToUserInputInput,
20 RuntimeRequestId,
19 ProviderSendTurnInput,21 ProviderSendTurnInput,
20 ProviderSessionStartInput,22 ProviderSessionStartInput,
21 ProviderStopSessionInput,23 ProviderStopSessionInput,
22 ProviderUploadFeedbackInput,24 ProviderUploadFeedbackInput,
25 ThreadId,
26 TurnId,
23 type ProviderInstanceId,27 type ProviderInstanceId,
24 type ProviderDriverKind,28 type ProviderDriverKind,
25 type ProviderRuntimeEvent,29 type ProviderRuntimeEvent,
...@@ -28,6 +32,7 @@ import {...@@ -28,6 +32,7 @@ import {
28import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";32import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";
29import { causeErrorTag } from "@t3tools/shared/observability";33import { causeErrorTag } from "@t3tools/shared/observability";
30import * as DateTime from "effect/DateTime";34import * as DateTime from "effect/DateTime";
35import * as Deferred from "effect/Deferred";
31import * as Effect from "effect/Effect";36import * as Effect from "effect/Effect";
32import * as Layer from "effect/Layer";37import * as Layer from "effect/Layer";
33import * as Option from "effect/Option";38import * as Option from "effect/Option";
...@@ -49,6 +54,7 @@ import {...@@ -49,6 +54,7 @@ import {
49 providerTurnMetricAttributes,54 providerTurnMetricAttributes,
50 withMetrics,55 withMetrics,
51} from "../../observability/Metrics.ts";56} from "../../observability/Metrics.ts";
57import { ProviderAdapterRequestError } from "../Errors.ts";
52import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts";58import { type ProviderAdapterError, ProviderValidationError } from "../Errors.ts";
53import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";59import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
54import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts";60import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts";
...@@ -62,6 +68,16 @@ import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts";...@@ -62,6 +68,16 @@ import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts";
62import * as ServerSettings from "../../serverSettings.ts";68import * as ServerSettings from "../../serverSettings.ts";
63const isModelSelection = Schema.is(ModelSelection);69const 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
65/**81/**
66 * Hook for tests that want to override the canonical event logger pulled82 * Hook for tests that want to override the canonical event logger pulled
67 * from `ProviderEventLoggers`. Production wiring leaves this undefined and83 * from `ProviderEventLoggers`. Production wiring leaves this undefined and
...@@ -233,6 +249,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -233,6 +249,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
233 const revokeMcpCredential =249 const revokeMcpCredential =
234 options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread;250 options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread;
235 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();251 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 });
236 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);261 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
237 /**262 /**
238 * Attach the `t3-code` MCP server to the session that is about to start.263 * Attach the `t3-code` MCP server to the session that is about to start.
...@@ -295,6 +320,65 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -295,6 +320,65 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
295 Effect.asVoid,320 Effect.asVoid,
296 );321 );
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
298 const requireBindingInstanceId = (382 const requireBindingInstanceId = (
299 operation: string,383 operation: string,
300 payload: {384 payload: {
...@@ -345,14 +429,50 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -345,14 +429,50 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
345 },429 },
346 event: ProviderRuntimeEvent,430 event: ProviderRuntimeEvent,
347 ): Effect.Effect<void> =>431 ): Effect.Effect<void> =>
348 Effect.sync(() => correlateRuntimeEventWithInstance(source, event)).pipe(432 Effect.gen(function* () {
349 Effect.flatMap((canonicalEvent) =>433 const canonicalEvent = yield* Effect.sync(() =>
350 increment(providerRuntimeEventsTotal, {434 correlateRuntimeEventWithInstance(source, event),
351 provider: canonicalEvent.provider,435 );
352 eventType: canonicalEvent.type,436 yield* increment(providerRuntimeEventsTotal, {
353 }).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))),437 provider: canonicalEvent.provider,
354 ),438 eventType: canonicalEvent.type,
355 );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
357 // `subscribedAdapters` is our source-of-truth for "which instance adapters477 // `subscribedAdapters` is our source-of-truth for "which instance adapters
358 // are currently wired into the runtime event bus". It both tracks the set478 // are currently wired into the runtime event bus". It both tracks the set
...@@ -708,6 +828,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -708,6 +828,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
708 typeof input.modelSelection?.model === "string" &&828 typeof input.modelSelection?.model === "string" &&
709 input.modelSelection.model.trim().length > 0,829 input.modelSelection.model.trim().length > 0,
710 });830 });
831 timedOutNativeCompactions.delete(threadId);
711832
712 // Changing runtime mode restarts the session, so the transition is only833 // Changing runtime mode restarts the session, so the transition is only
713 // observable here, by diffing against the mode the previous session for834 // observable here, by diffing against the mode the previous session for
...@@ -874,6 +995,128 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -874,6 +995,128 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
874 );995 );
875 });996 });
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
877 const interruptTurn: ProviderServiceMethod<"interruptTurn"> = Effect.fn("interruptTurn")(1120 const interruptTurn: ProviderServiceMethod<"interruptTurn"> = Effect.fn("interruptTurn")(
878 function* (rawInput) {1121 function* (rawInput) {
879 const input = yield* decodeInputOrValidationError({1122 const input = yield* decodeInputOrValidationError({
...@@ -1006,6 +1249,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -1006,6 +1249,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
1006 if (routed.isActive) {1249 if (routed.isActive) {
1007 yield* routed.adapter.stopSession(routed.threadId);1250 yield* routed.adapter.stopSession(routed.threadId);
1008 }1251 }
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);
1009 yield* clearMcpSession(input.threadId);1257 yield* clearMcpSession(input.threadId);
1010 yield* directory.upsert({1258 yield* directory.upsert({
1011 threadId: input.threadId,1259 threadId: input.threadId,
...@@ -1285,6 +1533,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -1285,6 +1533,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
1285 return {1533 return {
1286 startSession,1534 startSession,
1287 sendTurn,1535 sendTurn,
1536 compactThread,
1288 interruptTurn,1537 interruptTurn,
1289 respondToRequest,1538 respondToRequest,
1290 respondToUserInput,1539 respondToUserInput,
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
...@@ -164,6 +164,7 @@ describe("ProviderSessionReaper", () => {...@@ -164,6 +164,7 @@ describe("ProviderSessionReaper", () => {
164 const providerService: ProviderServiceShape = {164 const providerService: ProviderServiceShape = {
165 startSession: () => unsupported(),165 startSession: () => unsupported(),
166 sendTurn: () => unsupported(),166 sendTurn: () => unsupported(),
167 compactThread: () => unsupported(),
167 interruptTurn: () => unsupported(),168 interruptTurn: () => unsupported(),
168 respondToRequest: () => unsupported(),169 respondToRequest: () => unsupported(),
169 respondToUserInput: () => unsupported(),170 respondToUserInput: () => unsupported(),
apps/server/src/provider/Services/ProviderAdapter.ts+5
...@@ -70,6 +70,11 @@ export interface ProviderAdapterShape<TError> {...@@ -70,6 +70,11 @@ export interface ProviderAdapterShape<TError> {
70 input: ProviderSendTurnInput,70 input: ProviderSendTurnInput,
71 ) => Effect.Effect<ProviderTurnStartResult, TError>;71 ) => Effect.Effect<ProviderTurnStartResult, TError>;
7272
73 readonly compactThread?: (
74 threadId: ThreadId,
75 modelSelection?: ProviderSendTurnInput["modelSelection"],
76 ) => Effect.Effect<void, TError>;
77
73 /**78 /**
74 * Interrupt an active turn.79 * Interrupt an active turn.
75 */80 */
apps/server/src/provider/Services/ProviderService.ts+7
...@@ -23,6 +23,7 @@ import type {...@@ -23,6 +23,7 @@ import type {
23 ProviderStopSessionInput,23 ProviderStopSessionInput,
24 ProviderUploadFeedbackInput,24 ProviderUploadFeedbackInput,
25 ProviderUploadFeedbackResult,25 ProviderUploadFeedbackResult,
26 MessageId,
26 ThreadId,27 ThreadId,
27 ProviderTurnStartResult,28 ProviderTurnStartResult,
28} from "@t3tools/contracts";29} from "@t3tools/contracts";
...@@ -53,6 +54,12 @@ export interface ProviderServiceShape {...@@ -53,6 +54,12 @@ export interface ProviderServiceShape {
53 input: ProviderSendTurnInput,54 input: ProviderSendTurnInput,
54 ) => Effect.Effect<ProviderTurnStartResult, ProviderServiceError>;55 ) => Effect.Effect<ProviderTurnStartResult, ProviderServiceError>;
5556
57 readonly compactThread: (
58 threadId: ThreadId,
59 modelSelection?: ProviderSendTurnInput["modelSelection"],
60 requestId?: MessageId,
61 ) => Effect.Effect<void, ProviderServiceError>;
62
56 /**63 /**
57 * Interrupt a running provider turn.64 * Interrupt a running provider turn.
58 */65 */
apps/server/src/provider/providerSnapshot.ts+5
...@@ -23,6 +23,11 @@ export const DEFAULT_TIMEOUT_MS = 4_000;...@@ -23,6 +23,11 @@ export const DEFAULT_TIMEOUT_MS = 4_000;
23// Auth status checks involve disk/network lookups and can be slow on first run (especially Windows)23// Auth status checks involve disk/network lookups and can be slow on first run (especially Windows)
24export const AUTH_PROBE_TIMEOUT_MS = 10_000;24export 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
26export interface CommandResult {31export interface CommandResult {
27 readonly stdout: string;32 readonly stdout: string;
28 readonly stderr: string;33 readonly stderr: string;
apps/server/src/serverRuntimeStartup.reconcile.test.ts+1
...@@ -51,6 +51,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray<ThreadId> = []) =>...@@ -51,6 +51,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray<ThreadId> = []) =>
51 ({51 ({
52 startSession: () => Effect.die("unused"),52 startSession: () => Effect.die("unused"),
53 sendTurn: () => Effect.die("unused"),53 sendTurn: () => Effect.die("unused"),
54 compactThread: () => Effect.die("unused"),
54 interruptTurn: () => Effect.die("unused"),55 interruptTurn: () => Effect.die("unused"),
55 respondToRequest: () => Effect.die("unused"),56 respondToRequest: () => Effect.die("unused"),
56 respondToUserInput: () => Effect.die("unused"),57 respondToUserInput: () => Effect.die("unused"),
apps/web/src/components/ChatView.logic.test.ts+36
...@@ -1566,6 +1566,42 @@ describe("hasServerAcknowledgedLocalDispatch", () => {...@@ -1566,6 +1566,42 @@ describe("hasServerAcknowledgedLocalDispatch", () => {
15661566
1567 expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingApproval: true })).toBe(true);1567 expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingApproval: true })).toBe(true);
1568 expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingUserInput: true })).toBe(true);1568 expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingUserInput: true })).toBe(true);
1569 expect(
1570 hasServerAcknowledgedLocalDispatch({
1571 ...common,
1572 latestTurnStartFailureId: "turn-start-failure-1",
1573 }),
1574 ).toBe(true);
1569 expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true);1575 expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true);
1570 });1576 });
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 });
1571});1607});
apps/web/src/components/ChatView.logic.ts+27
...@@ -895,6 +895,24 @@ export interface LocalDispatchSnapshot {...@@ -895,6 +895,24 @@ export interface LocalDispatchSnapshot {
895 latestTurnCompletedAt: string | null;895 latestTurnCompletedAt: string | null;
896 sessionStatus: NonNullable<Thread["session"]>["status"] | null;896 sessionStatus: NonNullable<Thread["session"]>["status"] | null;
897 sessionUpdatedAt: string | null;897 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 );
898}916}
899917
900export function createLocalDispatchSnapshot(918export function createLocalDispatchSnapshot(
...@@ -918,6 +936,7 @@ export function createLocalDispatchSnapshot(...@@ -918,6 +936,7 @@ export function createLocalDispatchSnapshot(
918 latestTurnCompletedAt: latestTurn?.completedAt ?? null,936 latestTurnCompletedAt: latestTurn?.completedAt ?? null,
919 sessionStatus: session?.status ?? null,937 sessionStatus: session?.status ?? null,
920 sessionUpdatedAt: session?.updatedAt ?? null,938 sessionUpdatedAt: session?.updatedAt ?? null,
939 latestTurnStartFailureId: latestTurnStartFailureId(activeThread, latestUserMessage?.id ?? null),
921 };940 };
922}941}
923942
...@@ -929,6 +948,7 @@ export function hasServerAcknowledgedLocalDispatch(input: {...@@ -929,6 +948,7 @@ export function hasServerAcknowledgedLocalDispatch(input: {
929 session: Thread["session"] | null;948 session: Thread["session"] | null;
930 hasPendingApproval: boolean;949 hasPendingApproval: boolean;
931 hasPendingUserInput: boolean;950 hasPendingUserInput: boolean;
951 latestTurnStartFailureId?: string | null;
932 threadError: string | null | undefined;952 threadError: string | null | undefined;
933}): boolean {953}): boolean {
934 if (!input.localDispatch) {954 if (!input.localDispatch) {
...@@ -937,6 +957,13 @@ export function hasServerAcknowledgedLocalDispatch(input: {...@@ -937,6 +957,13 @@ export function hasServerAcknowledgedLocalDispatch(input: {
937 if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) {957 if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) {
938 return true;958 return true;
939 }959 }
960 if (
961 input.latestTurnStartFailureId !== undefined &&
962 input.latestTurnStartFailureId !== null &&
963 input.latestTurnStartFailureId !== input.localDispatch.latestTurnStartFailureId
964 ) {
965 return true;
966 }
940 if (input.phase === "connecting") {967 if (input.phase === "connecting") {
941 return false;968 return false;
942 }969 }
apps/web/src/components/ChatView.tsx+58-12
...@@ -209,8 +209,8 @@ import { getProviderModelCapabilities } from "../providerModels";...@@ -209,8 +209,8 @@ import { getProviderModelCapabilities } from "../providerModels";
209import {209import {
210 applyProviderInstanceSettings,210 applyProviderInstanceSettings,
211 deriveProviderInstanceEntries,211 deriveProviderInstanceEntries,
212 sortProviderInstanceEntries,
213 NO_PROVIDER_MODEL_SELECTION,212 NO_PROVIDER_MODEL_SELECTION,
213 sortProviderInstanceEntries,
214} from "../providerInstances";214} from "../providerInstances";
215import {215import {
216 useClientSettings,216 useClientSettings,
...@@ -330,7 +330,7 @@ import {...@@ -330,7 +330,7 @@ import {
330import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";330import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
331import { ComposerSurface } from "./chat/ComposerSurface";331import { ComposerSurface } from "./chat/ComposerSurface";
332import {332import {
333 hasAvailableClaudeCompactionProvider,333 hasAvailableCompactionProvider,
334 hasDismissedResumeCompaction,334 hasDismissedResumeCompaction,
335 shouldOfferResumeCompaction,335 shouldOfferResumeCompaction,
336} from "./chat/ContextWindowMeter.logic";336} from "./chat/ContextWindowMeter.logic";
...@@ -357,6 +357,7 @@ import {...@@ -357,6 +357,7 @@ import {
357 deriveComposerSendState,357 deriveComposerSendState,
358 dismissBranchMismatchForSession,358 dismissBranchMismatchForSession,
359 hasEnvironmentReconnectWarningGraceElapsed,359 hasEnvironmentReconnectWarningGraceElapsed,
360 latestTurnStartFailureId,
360 scheduleEnvironmentReconnectWarning,361 scheduleEnvironmentReconnectWarning,
361 hasServerAcknowledgedLocalDispatch,362 hasServerAcknowledgedLocalDispatch,
362 isBranchMismatchDismissedForSession,363 isBranchMismatchDismissedForSession,
...@@ -626,6 +627,11 @@ function formatOutgoingPrompt(params: {...@@ -626,6 +627,11 @@ function formatOutgoingPrompt(params: {
626const SCRIPT_TERMINAL_COLS = 120;627const SCRIPT_TERMINAL_COLS = 120;
627const SCRIPT_TERMINAL_ROWS = 30;628const 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
629type ChatViewProps =635type ChatViewProps =
630 | {636 | {
631 environmentId: EnvironmentId;637 environmentId: EnvironmentId;
...@@ -669,6 +675,10 @@ function useLocalDispatchState(input: {...@@ -669,6 +675,10 @@ function useLocalDispatchState(input: {
669 (message) => message.role === "user",675 (message) => message.role === "user",
670 );676 );
671 const latestUserMessageId = latestUserMessage?.id ?? null;677 const latestUserMessageId = latestUserMessage?.id ?? null;
678 const currentTurnStartFailureId =
679 localDispatch === null
680 ? null
681 : latestTurnStartFailureId(input.activeThread, latestUserMessageId);
672682
673 const resetLocalDispatch = useCallback(() => {683 const resetLocalDispatch = useCallback(() => {
674 setLocalDispatch(null);684 setLocalDispatch(null);
...@@ -684,6 +694,7 @@ function useLocalDispatchState(input: {...@@ -684,6 +694,7 @@ function useLocalDispatchState(input: {
684 session: input.activeThread?.session ?? null,694 session: input.activeThread?.session ?? null,
685 hasPendingApproval: input.activePendingApproval !== null,695 hasPendingApproval: input.activePendingApproval !== null,
686 hasPendingUserInput: input.activePendingUserInput !== null,696 hasPendingUserInput: input.activePendingUserInput !== null,
697 latestTurnStartFailureId: currentTurnStartFailureId,
687 threadError: input.threadError,698 threadError: input.threadError,
688 }),699 }),
689 [700 [
...@@ -694,6 +705,7 @@ function useLocalDispatchState(input: {...@@ -694,6 +705,7 @@ function useLocalDispatchState(input: {
694 input.phase,705 input.phase,
695 input.threadError,706 input.threadError,
696 latestUserMessageId,707 latestUserMessageId,
708 currentTurnStartFailureId,
697 localDispatch,709 localDispatch,
698 ],710 ],
699 );711 );
...@@ -2587,7 +2599,33 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2587,7 +2599,33 @@ function ChatViewContent(props: ChatViewProps) {
2587 activePendingUserInput: activePendingUserInput?.requestId ?? null,2599 activePendingUserInput: activePendingUserInput?.requestId ?? null,
2588 threadError,2600 threadError,
2589 });2601 });
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;
2591 const activeWorkStartedAt = deriveActiveWorkStartedAt(2629 const activeWorkStartedAt = deriveActiveWorkStartedAt(
2592 activeLatestTurn,2630 activeLatestTurn,
2593 activeThread?.session ?? null,2631 activeThread?.session ?? null,
...@@ -2957,10 +2995,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2957,10 +2995,11 @@ function ChatViewContent(props: ChatViewProps) {
2957 });2995 });
2958 const keybindings = useAtomValue(primaryServerKeybindingsAtom);2996 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
2959 const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);2997 const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);
2960 const compactionProviderAvailable = useMemo(2998 const manualCompactionProviderAvailable = useMemo(
2961 () =>2999 () =>
2962 hasAvailableClaudeCompactionProvider({3000 hasAvailableCompactionProvider({
2963 providers: providerInstanceEntries,3001 providers: providerInstanceEntries,
3002 driverKind: selectedProvider,
2964 instanceId: activeProviderInstanceId,3003 instanceId: activeProviderInstanceId,
2965 lockedInstanceId: lockedProvider3004 lockedInstanceId: lockedProvider
2966 ? (activeThread?.session?.providerInstanceId ??3005 ? (activeThread?.session?.providerInstanceId ??
...@@ -2974,6 +3013,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2974,6 +3013,7 @@ function ChatViewContent(props: ChatViewProps) {
2974 activeThread?.session?.providerInstanceId,3013 activeThread?.session?.providerInstanceId,
2975 lockedProvider,3014 lockedProvider,
2976 providerInstanceEntries,3015 providerInstanceEntries,
3016 selectedProvider,
2977 ],3017 ],
2978 );3018 );
2979 const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] =3019 const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] =
...@@ -5393,12 +5433,16 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5393,12 +5433,16 @@ function ChatViewContent(props: ChatViewProps) {
5393 activeThread && activeContextWindow5433 activeThread && activeContextWindow
5394 ? `${activeThread.id}:${activeContextWindow.updatedAt}`5434 ? `${activeThread.id}:${activeContextWindow.updatedAt}`
5395 : null;5435 : null;
5396 const compactDisabled =5436 const activeThreadHasCompactableConversation =
5437 activeThread?.messages.some(
5438 (message) => message.role === "user" && !isCompactCommandMessage(message),
5439 ) ?? false;
5440 const compactThreadUnavailable =
5397 !activeThread ||5441 !activeThread ||
5442 !activeThreadHasCompactableConversation ||
5398 !activeProject ||5443 !activeProject ||
5399 !isServerThread ||5444 !isServerThread ||
5400 selectedProvider !== "claudeAgent" ||5445 !manualCompactionProviderAvailable ||
5401 !compactionProviderAvailable ||
5402 isWorking ||5446 isWorking ||
5403 threadDetailLoading ||5447 threadDetailLoading ||
5404 isPreparingWorktree ||5448 isPreparingWorktree ||
...@@ -5406,15 +5450,15 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5406,15 +5450,15 @@ function ChatViewContent(props: ChatViewProps) {
5406 feedbackUploading ||5450 feedbackUploading ||
5407 pendingApprovals.length > 0 ||5451 pendingApprovals.length > 0 ||
5408 pendingUserInputs.length > 0 ||5452 pendingUserInputs.length > 0 ||
5409 showPlanFollowUpPrompt ||5453 showPlanFollowUpPrompt;
5410 composerHasUnsentContent;5454 const compactDisabled = compactThreadUnavailable || composerHasUnsentContent;
5411 const compactDisabledReason = compactDisabled5455 const compactDisabledReason = compactDisabled
5412 ? composerHasUnsentContent5456 ? composerHasUnsentContent
5413 ? "Send or clear your draft before compacting"5457 ? "Send or clear your draft before compacting"
5414 : !activeProject5458 : !activeProject
5415 ? "Choose a project before compacting"5459 ? "Choose a project before compacting"
5416 : !compactionProviderAvailable5460 : !manualCompactionProviderAvailable
5417 ? "Enable a Claude provider before compacting"5461 ? "Compaction is unavailable for this provider"
5418 : "Compacting is unavailable right now"5462 : "Compacting is unavailable right now"
5419 : null;5463 : null;
5420 const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => {5464 const resumeCompactionBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
...@@ -7703,6 +7747,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7703,6 +7747,7 @@ function ChatViewContent(props: ChatViewProps) {
7703 key={activeThread.id}7747 key={activeThread.id}
7704 isWorking={isWorking}7748 isWorking={isWorking}
7705 isPreparingWorktree={isPreparingWorktree}7749 isPreparingWorktree={isPreparingWorktree}
7750 isCompacting={isCompacting}
7706 activeTurnStartedAt={activeWorkStartedAt}7751 activeTurnStartedAt={activeWorkStartedAt}
7707 listRef={legendListRef}7752 listRef={legendListRef}
7708 timelineEntries={timelineEntries}7753 timelineEntries={timelineEntries}
...@@ -7862,6 +7907,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7862,6 +7907,7 @@ function ChatViewContent(props: ChatViewProps) {
7862 }7907 }
7863 activeThreadModelSelection={activeThread?.modelSelection}7908 activeThreadModelSelection={activeThread?.modelSelection}
7864 activeContextWindow={activeContextWindow}7909 activeContextWindow={activeContextWindow}
7910 compactThreadUnavailable={compactThreadUnavailable}
7865 compactDisabled={compactDisabled}7911 compactDisabled={compactDisabled}
7866 compactDisabledReason={compactDisabledReason}7912 compactDisabledReason={compactDisabledReason}
7867 resolvedTheme={resolvedTheme}7913 resolvedTheme={resolvedTheme}
apps/web/src/components/chat/ChatComposer.tsx+24-8
...@@ -186,7 +186,10 @@ import {...@@ -186,7 +186,10 @@ import {
186 renderProviderTraitsPicker,186 renderProviderTraitsPicker,
187} from "./composerProviderState";187} from "./composerProviderState";
188import { ContextWindowMeter } from "./ContextWindowMeter";188import { ContextWindowMeter } from "./ContextWindowMeter";
189import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic";189import {
190 providerSupportsManualCompaction,
191 resolveContextWindowModelDisplayName,
192} from "./ContextWindowMeter.logic";
190import {193import {
191 attachVideoThumbnail,194 attachVideoThumbnail,
192 buildExpandedImagePreview,195 buildExpandedImagePreview,
...@@ -1208,6 +1211,7 @@ export interface ChatComposerProps {...@@ -1208,6 +1211,7 @@ export interface ChatComposerProps {
12081211
1209 // Context window1212 // Context window
1210 activeContextWindow: ContextWindowSnapshot | null;1213 activeContextWindow: ContextWindowSnapshot | null;
1214 compactThreadUnavailable: boolean;
1211 compactDisabled: boolean;1215 compactDisabled: boolean;
1212 compactDisabledReason: string | null;1216 compactDisabledReason: string | null;
12131217
...@@ -1313,6 +1317,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1313,6 +1317,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1313 activeProjectDefaultModelSelection,1317 activeProjectDefaultModelSelection,
1314 activeThreadModelSelection,1318 activeThreadModelSelection,
1315 activeContextWindow,1319 activeContextWindow,
1320 compactThreadUnavailable,
1316 compactDisabled,1321 compactDisabled,
1317 compactDisabledReason,1322 compactDisabledReason,
1318 resolvedTheme,1323 resolvedTheme,
...@@ -1603,6 +1608,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1603,6 +1608,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1603 () => selectedProviderEntry?.snapshot ?? null,1608 () => selectedProviderEntry?.snapshot ?? null,
1604 [selectedProviderEntry],1609 [selectedProviderEntry],
1605 );1610 );
1611 const compactCommandAvailable = providerSupportsManualCompaction(selectedProviderEntry);
1606 const selectedProviderSkills = selectedProviderStatus1612 const selectedProviderSkills = selectedProviderStatus
1607 ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd)1613 ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd)
1608 : [];1614 : [];
...@@ -1840,7 +1846,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1840,7 +1846,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1840 prompt,1846 prompt,
1841 ],1847 ],
1842 );1848 );
1843
1844 // ------------------------------------------------------------------1849 // ------------------------------------------------------------------
1845 // Derived: composer trigger / menu1850 // Derived: composer trigger / menu
1846 // ------------------------------------------------------------------1851 // ------------------------------------------------------------------
...@@ -1852,6 +1857,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1852,6 +1857,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1852 cwd: isPathTrigger ? gitCwd : null,1857 cwd: isPathTrigger ? gitCwd : null,
1853 query: isPathTrigger ? pathTriggerQuery : null,1858 query: isPathTrigger ? pathTriggerQuery : null,
1854 });1859 });
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
1856 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {1872 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
1857 if (!composerTrigger) return [];1873 if (!composerTrigger) return [];
...@@ -1920,8 +1936,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1920,8 +1936,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1920 skill.description ??1936 skill.description ??
1921 (skill.scope ? `${skill.scope} skill` : ""),1937 (skill.scope ? `${skill.scope} skill` : ""),
1922 }));1938 }));
1939 const visibleProviderSlashCommandItems = providerSlashCommandItems.filter(
1940 (item) => item.command.name !== "compact" || compactSlashCommandAvailable,
1941 );
1923 const slashCommandItems = slashCommandItemsForPromptPosition(1942 const slashCommandItems = slashCommandItemsForPromptPosition(
1924 [...builtInSlashCommandItems, ...providerSlashCommandItems, ...skillItems],1943 [...builtInSlashCommandItems, ...visibleProviderSlashCommandItems, ...skillItems],
1925 composerTrigger.rangeStart === 0,1944 composerTrigger.rangeStart === 0,
1926 );1945 );
1927 return searchSlashCommandItems(slashCommandItems, query);1946 return searchSlashCommandItems(slashCommandItems, query);
...@@ -1941,6 +1960,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1941,6 +1960,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1941 }1960 }
1942 return [];1961 return [];
1943 }, [1962 }, [
1963 compactSlashCommandAvailable,
1944 composerTrigger,1964 composerTrigger,
1945 planModeUiEnabled,1965 planModeUiEnabled,
1946 selectedProvider,1966 selectedProvider,
...@@ -2843,7 +2863,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2843,7 +2863,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2843 if (2863 if (
2844 compactDisabled ||2864 compactDisabled ||
2845 noProviderAvailable ||2865 noProviderAvailable ||
2846 composerSendState.hasSendableContent ||
2847 activePendingApproval !== null ||2866 activePendingApproval !== null ||
2848 pendingUserInputs.length > 0 ||2867 pendingUserInputs.length > 0 ||
2849 phase === "running" ||2868 phase === "running" ||
...@@ -2881,7 +2900,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2881,7 +2900,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2881 activeThreadId,2900 activeThreadId,
2882 compactDisabled,2901 compactDisabled,
2883 composerDraftTarget,2902 composerDraftTarget,
2884 composerSendState.hasSendableContent,
2885 isConnecting,2903 isConnecting,
2886 isSendBusy,2904 isSendBusy,
2887 noProviderAvailable,2905 noProviderAvailable,
...@@ -5455,9 +5473,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5455,9 +5473,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5455 compactDisabled || noProviderAvailable || isSendBusy || isConnecting5473 compactDisabled || noProviderAvailable || isSendBusy || isConnecting
5456 }5474 }
5457 compactDisabledReason={resolvedCompactDisabledReason}5475 compactDisabledReason={resolvedCompactDisabledReason}
5458 {...(selectedProvider === "claudeAgent"5476 {...(compactCommandAvailable ? { onCompactContext: compactThreadContext } : {})}
5459 ? { onCompactContext: compactThreadContext }
5460 : {})}
5461 />5477 />
5462 </div>5478 </div>
5463 </div>5479 </div>
apps/web/src/components/chat/ContextWindowMeter.logic.test.ts+7-5
...@@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
3import { deriveProviderInstanceEntries } from "../../providerInstances";3import { deriveProviderInstanceEntries } from "../../providerInstances";
4import {4import {
5 formatContextWindowCompactionMessage,5 formatContextWindowCompactionMessage,
6 hasAvailableClaudeCompactionProvider,6 hasAvailableCompactionProvider,
7 hasDismissedResumeCompaction,7 hasDismissedResumeCompaction,
8 resolveContextWindowModelDisplayName,8 resolveContextWindowModelDisplayName,
9 shouldOfferResumeCompaction,9 shouldOfferResumeCompaction,
...@@ -25,12 +25,12 @@ function claudeProvider(input: {...@@ -25,12 +25,12 @@ function claudeProvider(input: {
25 auth: { status: "authenticated" },25 auth: { status: "authenticated" },
26 checkedAt: "2026-08-24T12:00:00.000Z",26 checkedAt: "2026-08-24T12:00:00.000Z",
27 models: [],27 models: [],
28 slashCommands: [],28 slashCommands: [{ name: "compact", description: "" }],
29 skills: [],29 skills: [],
30 };30 };
31}31}
3232
33describe("hasAvailableClaudeCompactionProvider", () => {33describe("hasAvailableCompactionProvider", () => {
34 const originalInstanceId = ProviderInstanceId.make("claude_original");34 const originalInstanceId = ProviderInstanceId.make("claude_original");
3535
36 it("rejects a fallback in a different locked continuation group", () => {36 it("rejects a fallback in a different locked continuation group", () => {
...@@ -47,8 +47,9 @@ describe("hasAvailableClaudeCompactionProvider", () => {...@@ -47,8 +47,9 @@ describe("hasAvailableClaudeCompactionProvider", () => {
47 ]);47 ]);
4848
49 expect(49 expect(
50 hasAvailableClaudeCompactionProvider({50 hasAvailableCompactionProvider({
51 providers,51 providers,
52 driverKind: ProviderDriverKind.make("claudeAgent"),
52 instanceId: originalInstanceId,53 instanceId: originalInstanceId,
53 lockedInstanceId: originalInstanceId,54 lockedInstanceId: originalInstanceId,
54 }),55 }),
...@@ -69,8 +70,9 @@ describe("hasAvailableClaudeCompactionProvider", () => {...@@ -69,8 +70,9 @@ describe("hasAvailableClaudeCompactionProvider", () => {
69 ]);70 ]);
7071
71 expect(72 expect(
72 hasAvailableClaudeCompactionProvider({73 hasAvailableCompactionProvider({
73 providers,74 providers,
75 driverKind: ProviderDriverKind.make("claudeAgent"),
74 instanceId: originalInstanceId,76 instanceId: originalInstanceId,
75 lockedInstanceId: originalInstanceId,77 lockedInstanceId: originalInstanceId,
76 }),78 }),
apps/web/src/components/chat/ContextWindowMeter.logic.ts+16-10
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts";1import type { ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
2import {2import {
3 CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,3 CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
4 isClaudeResumeCompactionQuestion,4 isClaudeResumeCompactionQuestion,
...@@ -12,27 +12,33 @@ import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils...@@ -12,27 +12,33 @@ import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils
12export const CLAUDE_RESUME_COMPACTION_MINUTES = 70;12export const CLAUDE_RESUME_COMPACTION_MINUTES = 70;
13export const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000;13export 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: {
16 readonly providers: ReadonlyArray<ProviderInstanceEntry>;22 readonly providers: ReadonlyArray<ProviderInstanceEntry>;
23 readonly driverKind: ProviderDriverKind;
17 readonly instanceId: ProviderInstanceId | null;24 readonly instanceId: ProviderInstanceId | null;
18 readonly lockedInstanceId: ProviderInstanceId | null;25 readonly lockedInstanceId: ProviderInstanceId | null;
19}): boolean {26}): boolean {
20 const claudeProviders = input.providers.filter(27 const driverProviders = input.providers.filter(
21 (provider) => provider.driverKind === "claudeAgent",28 (provider) => provider.driverKind === input.driverKind,
22 );29 );
23 const lockedContinuationGroupKey = input.lockedInstanceId30 const lockedContinuationGroupKey = input.lockedInstanceId
24 ? claudeProviders.find((provider) => provider.instanceId === input.lockedInstanceId)31 ? driverProviders.find((provider) => provider.instanceId === input.lockedInstanceId)
25 ?.continuationGroupKey32 ?.continuationGroupKey
26 : undefined;33 : undefined;
27 const compatibleProviders = lockedContinuationGroupKey34 const compatibleProviders = lockedContinuationGroupKey
28 ? claudeProviders.filter(35 ? driverProviders.filter(
29 (provider) => provider.continuationGroupKey === lockedContinuationGroupKey,36 (provider) => provider.continuationGroupKey === lockedContinuationGroupKey,
30 )37 )
31 : claudeProviders;38 : driverProviders;
3239
33 return (40 return providerSupportsManualCompaction(
34 resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined) !==41 resolveSelectableProviderInstanceEntry(compatibleProviders, input.instanceId ?? undefined),
35 undefined
36 );42 );
37}43}
3844
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+32
...@@ -489,6 +489,38 @@ describe("workEntryIsVisibleInGroup", () => {...@@ -489,6 +489,38 @@ describe("workEntryIsVisibleInGroup", () => {
489});489});
490490
491describe("deriveMessagesTimelineRows", () => {491describe("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
492 it("only enables assistant copy for the terminal assistant message in a turn", () => {524 it("only enables assistant copy for the terminal assistant message in a turn", () => {
493 const rows = deriveMessagesTimelineRows({525 const rows = deriveMessagesTimelineRows({
494 timelineEntries: [526 timelineEntries: [
apps/web/src/components/chat/MessagesTimeline.logic.ts+31-1
...@@ -332,6 +332,12 @@ export type MessagesTimelineRow =...@@ -332,6 +332,12 @@ export type MessagesTimelineRow =
332 label: string;332 label: string;
333 expanded: boolean;333 expanded: boolean;
334 }334 }
335 | {
336 kind: "context-compaction";
337 id: string;
338 createdAt: string;
339 label: string;
340 }
335 | {341 | {
336 kind: "message";342 kind: "message";
337 id: string;343 id: string;
...@@ -601,7 +607,11 @@ function deriveTurnFolds(input: {...@@ -601,7 +607,11 @@ function deriveTurnFolds(input: {
601 // Agent-spawn CTA rows never fold: workflows outlive their launching607 // Agent-spawn CTA rows never fold: workflows outlive their launching
602 // turn (dynamic spawns, background execution), and folding the CTA608 // turn (dynamic spawns, background execution), and folding the CTA
603 // when the turn settles makes a still-running fleet invisible.609 // 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 ) {
605 continue;615 continue;
606 }616 }
607 if (entry.kind === "work" && workEntryRendersImagePreview(entry.entry)) {617 if (entry.kind === "work" && workEntryRendersImagePreview(entry.entry)) {
...@@ -803,6 +813,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -803,6 +813,7 @@ export function deriveMessagesTimelineRows(input: {
803 !entryBelongsToActiveTurn(entry, index) ||813 !entryBelongsToActiveTurn(entry, index) ||
804 entry.kind !== "work" ||814 entry.kind !== "work" ||
805 entry.entry.agentSpawn !== undefined ||815 entry.entry.agentSpawn !== undefined ||
816 entry.entry.sourceActivityKind === "context-compaction" ||
806 entry.entry.tone === "error" ||817 entry.entry.tone === "error" ||
807 workEntryRendersImagePreview(entry.entry)818 workEntryRendersImagePreview(entry.entry)
808 ) {819 ) {
...@@ -906,6 +917,19 @@ export function deriveMessagesTimelineRows(input: {...@@ -906,6 +917,19 @@ export function deriveMessagesTimelineRows(input: {
906 continue;917 continue;
907 }918 }
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
909 if (timelineEntry.kind === "work") {933 if (timelineEntry.kind === "work") {
910 if (934 if (
911 timelineEntry.entry.agentSpawn !== undefined ||935 timelineEntry.entry.agentSpawn !== undefined ||
...@@ -929,6 +953,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -929,6 +953,7 @@ export function deriveMessagesTimelineRows(input: {
929 !nextEntry ||953 !nextEntry ||
930 nextEntry.kind !== "work" ||954 nextEntry.kind !== "work" ||
931 nextEntry.entry.agentSpawn !== undefined ||955 nextEntry.entry.agentSpawn !== undefined ||
956 nextEntry.entry.sourceActivityKind === "context-compaction" ||
932 nextEntry.entry.tone === "error" ||957 nextEntry.entry.tone === "error" ||
933 workEntryRendersImagePreview(nextEntry.entry) ||958 workEntryRendersImagePreview(nextEntry.entry) ||
934 activeWorkEntryIds.has(nextEntry.id) ||959 activeWorkEntryIds.has(nextEntry.id) ||
...@@ -1150,6 +1175,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -1150,6 +1175,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
1150 return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded;1175 return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded;
1151 }1176 }
11521177
1178 case "context-compaction": {
1179 const bc = b as typeof a;
1180 return a.createdAt === bc.createdAt && a.label === bc.label;
1181 }
1182
1153 case "proposed-plan":1183 case "proposed-plan":
1154 return a.proposedPlan === (b as typeof a).proposedPlan;1184 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", () => {...@@ -974,7 +974,7 @@ describe("MessagesTimeline", () => {
974 entry: {974 entry: {
975 id: "work-1",975 id: "work-1",
976 createdAt: "2026-03-17T19:12:28.000Z",976 createdAt: "2026-03-17T19:12:28.000Z",
977 label: "Context compacted",977 label: "Compacted context 899K → 19K tokens",
978 tone: "info",978 tone: "info",
979 },979 },
980 },980 },
...@@ -982,7 +982,7 @@ describe("MessagesTimeline", () => {...@@ -982,7 +982,7 @@ describe("MessagesTimeline", () => {
982 />,982 />,
983 );983 );
984984
985 expect(markup).toContain("Context compacted");985 expect(markup).toContain("Compacted context 899K → 19K tokens");
986 });986 });
987987
988 it("summarizes changed files in one line", () => {988 it("summarizes changed files in one line", () => {
apps/web/src/components/chat/MessagesTimeline.tsx+50-5
...@@ -86,6 +86,7 @@ import {...@@ -86,6 +86,7 @@ import {
86 GlobeIcon,86 GlobeIcon,
87 HammerIcon,87 HammerIcon,
88 MessageCircleIcon,88 MessageCircleIcon,
89 Minimize2Icon,
89 MousePointerClickIcon,90 MousePointerClickIcon,
90 PaintbrushIcon,91 PaintbrushIcon,
91 SearchIcon,92 SearchIcon,
...@@ -210,6 +211,7 @@ interface TimelineRowSharedState {...@@ -210,6 +211,7 @@ interface TimelineRowSharedState {
210interface TimelineRowActivityState {211interface TimelineRowActivityState {
211 isWorking: boolean;212 isWorking: boolean;
212 isPreparingWorktree: boolean;213 isPreparingWorktree: boolean;
214 isCompacting: boolean;
213 isRevertingCheckpoint: boolean;215 isRevertingCheckpoint: boolean;
214 latestTurnId: TurnId | null;216 latestTurnId: TurnId | null;
215}217}
...@@ -294,6 +296,7 @@ interface MessagesTimelineProps {...@@ -294,6 +296,7 @@ interface MessagesTimelineProps {
294 onOpenAgents?: () => void;296 onOpenAgents?: () => void;
295 isWorking: boolean;297 isWorking: boolean;
296 isPreparingWorktree?: boolean;298 isPreparingWorktree?: boolean;
299 isCompacting?: boolean;
297 activeTurnStartedAt: string | null;300 activeTurnStartedAt: string | null;
298 listRef: React.RefObject<LegendListRef | null>;301 listRef: React.RefObject<LegendListRef | null>;
299 timelineEntries: ReturnType<typeof deriveTimelineEntries>;302 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
...@@ -343,6 +346,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -343,6 +346,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
343 onCiteAssistantText,346 onCiteAssistantText,
344 isWorking,347 isWorking,
345 isPreparingWorktree = false,348 isPreparingWorktree = false,
349 isCompacting = false,
346 activeTurnStartedAt,350 activeTurnStartedAt,
347 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,351 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,
348 onOpenAgents = NOOP_OPEN_AGENTS,352 onOpenAgents = NOOP_OPEN_AGENTS,
...@@ -686,10 +690,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -686,10 +690,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
686 () => ({690 () => ({
687 isWorking,691 isWorking,
688 isPreparingWorktree,692 isPreparingWorktree,
693 isCompacting,
689 isRevertingCheckpoint,694 isRevertingCheckpoint,
690 latestTurnId: latestTurn?.turnId ?? null,695 latestTurnId: latestTurn?.turnId ?? null,
691 }),696 }),
692 [isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId],697 [isCompacting, isRevertingCheckpoint, isWorking, isPreparingWorktree, latestTurn?.turnId],
693 );698 );
694699
695 // Stable renderItem — no closure deps. Row components read shared state700 // Stable renderItem — no closure deps. Row components read shared state
...@@ -1133,6 +1138,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -1133,6 +1138,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
1133 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}1138 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}
1134 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}1139 {row.kind === "work-toggle" ? <WorkGroupToggleTimelineRow row={row} /> : null}
1135 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}1140 {row.kind === "turn-fold" ? <TurnFoldTimelineRow row={row} /> : null}
1141 {row.kind === "context-compaction" ? <ContextCompactionTimelineRow row={row} /> : null}
1136 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}1142 {row.kind === "message" && row.message.role === "user" ? <UserTimelineRow row={row} /> : null}
1137 {row.kind === "message" && row.message.role === "assistant" ? (1143 {row.kind === "message" && row.message.role === "assistant" ? (
1138 <AssistantTimelineRow row={row} />1144 <AssistantTimelineRow row={row} />
...@@ -1145,6 +1151,27 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -1145,6 +1151,27 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
1145 );1151 );
1146});1152});
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
1148function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) {1175function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) {
1149 const ctx = use(TimelineRowCtx);1176 const ctx = use(TimelineRowCtx);
1150 const asset = useMemo(1177 const asset = useMemo(
...@@ -1579,12 +1606,12 @@ function ProposedPlanTimelineRow({...@@ -1579,12 +1606,12 @@ function ProposedPlanTimelineRow({
1579}1606}
15801607
1581function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {1608function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
1582 const { isPreparingWorktree } = use(TimelineRowActivityCtx);1609 const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx);
1583 return (1610 return (
1584 <div className="border-b border-border/60 pb-2 pt-1">1611 <div className="border-b border-border/60 pb-2 pt-1">
1585 <div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">1612 <div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
1586 <span1613 <span
1587 key={isPreparingWorktree ? "setup" : "working"}1614 key={isPreparingWorktree ? "setup" : isCompacting ? "compacting" : "working"}
1588 className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none"1615 className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none"
1589 >1616 >
1590 {isPreparingWorktree ? (1617 {isPreparingWorktree ? (
...@@ -1592,6 +1619,13 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin...@@ -1592,6 +1619,13 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
1592 Setting up worktree…1619 Setting up worktree…
1593 <ActivityShimmerOverlay>Setting up worktree…</ActivityShimmerOverlay>1620 <ActivityShimmerOverlay>Setting up worktree…</ActivityShimmerOverlay>
1594 </>1621 </>
1622 ) : isCompacting ? (
1623 <>
1624 <CompactingLabel />
1625 <ActivityShimmerOverlay>
1626 <CompactingLabel />
1627 </ActivityShimmerOverlay>
1628 </>
1595 ) : row.createdAt ? (1629 ) : row.createdAt ? (
1596 <>1630 <>
1597 Working for <WorkingTimer createdAt={row.createdAt} />1631 Working for <WorkingTimer createdAt={row.createdAt} />
...@@ -1606,15 +1640,26 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin...@@ -1606,15 +1640,26 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
1606}1640}
16071641
1608function ThinkingTimelineRow() {1642function ThinkingTimelineRow() {
1609 const { isPreparingWorktree } = use(TimelineRowActivityCtx);1643 const { isCompacting, isPreparingWorktree } = use(TimelineRowActivityCtx);
1610 // Reserve the activity row during setup so the handoff keeps the same height.1644 // Reserve the activity row during setup so the handoff keeps the same height.
1611 return (1645 return (
1612 <div className="min-h-7">1646 <div className="min-h-7">
1613 {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" iconName="brain" />}1647 {isPreparingWorktree || isCompacting ? null : (
1648 <LiveActivityRow label="Thinking" iconName="brain" />
1649 )}
1614 </div>1650 </div>
1615 );1651 );
1616}1652}
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
1618// ---------------------------------------------------------------------------1663// ---------------------------------------------------------------------------
1619// Self-ticking labels — update their own text nodes so elapsed-time display1664// Self-ticking labels — update their own text nodes so elapsed-time display
1620// does not create a React commit every second while a response is streaming.1665// 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....@@ -212,6 +212,8 @@ such as System, Personal, Project, or App.
212On mobile, these menus are available on the **New task** screen before you start a thread. They212On mobile, these menus are available on the **New task** screen before you start a thread. They
213use the skills and commands from the selected environment and provider.213use 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
215By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in217By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in
216slash menu** in **Settings → General**. Skill results use the `/skill:Skill Name` label and add the218slash menu** in **Settings → General**. Skill results use the `/skill:Skill Name` label and add the
217same `$name` skill token to your message. The original skill name remains searchable. If the provider219same `$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;...@@ -299,6 +299,8 @@ export type ThreadStartedPayload = typeof ThreadStartedPayload.Type;
299299
300const ThreadStateChangedPayload = Schema.Struct({300const ThreadStateChangedPayload = Schema.Struct({
301 state: RuntimeThreadState,301 state: RuntimeThreadState,
302 beforeTokens: Schema.optional(NonNegativeInt),
303 afterTokens: Schema.optional(NonNegativeInt),
302 detail: Schema.optional(Schema.Unknown),304 detail: Schema.optional(Schema.Unknown),
303});305});
304export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type;306export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type;