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