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