diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 1516b7cbc73..600cf1ba4c6 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -407,7 +407,13 @@ export const RootStack = createNativeStackNavigator({ Thread: createNativeStackScreen({ screen: ThreadRouteScreen, linking: THREAD_LINKING_PREFIX, - options: GLASS_HEADER_OPTIONS, + options: { + ...GLASS_HEADER_OPTIONS, + // Android owns an in-flow thread header. Hide the native header before + // the route's loading state mounts so hydration cannot move the whole + // screen by one toolbar height when runtime options arrive. + ...(Platform.OS === "android" ? { headerShown: false } : null), + }, }), ThreadTerminal: createNativeStackScreen({ screen: ThreadTerminalRouteScreen, diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index 7fe21fb44ff..279e3a47676 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -48,6 +48,7 @@ export function AndroidHeaderIconButton(props: { export function AndroidScreenHeader(props: { readonly title: string; readonly subtitle?: string | null; + readonly reserveSubtitleSpace?: boolean; readonly actions?: ReadonlyArray; readonly trailing?: ReactNode; readonly onBack?: () => void; @@ -85,12 +86,15 @@ export function AndroidScreenHeader(props: { {props.title} - {props.subtitle ? ( + {props.subtitle || props.reserveSubtitleSpace ? ( - {props.subtitle} + {props.subtitle || " "} ) : null} diff --git a/apps/mobile/src/components/CopyTextButton.tsx b/apps/mobile/src/components/CopyTextButton.tsx index 7f4e060eda0..2fa849de8dd 100644 --- a/apps/mobile/src/components/CopyTextButton.tsx +++ b/apps/mobile/src/components/CopyTextButton.tsx @@ -16,7 +16,8 @@ export const CopyTextButton = memo(function CopyTextButton(props: { readonly iconSize?: number; readonly buttonSize?: number; }) { - const [copied, setCopied] = useState(false); + const [copiedText, setCopiedText] = useState(null); + const copied = copiedText === props.text; const resetTimeoutRef = useRef | null>(null); useEffect( @@ -36,12 +37,12 @@ export const CopyTextButton = memo(function CopyTextButton(props: { hitSlop={8} onPress={() => { copyTextWithHaptic(props.text); - setCopied(true); + setCopiedText(props.text); if (resetTimeoutRef.current) { clearTimeout(resetTimeoutRef.current); } resetTimeoutRef.current = setTimeout(() => { - setCopied(false); + setCopiedText(null); resetTimeoutRef.current = null; }, COPY_FEEDBACK_DURATION_MS); }} diff --git a/apps/mobile/src/features/review/reviewHighlighterEngine.test.ts b/apps/mobile/src/features/review/reviewHighlighterEngine.test.ts index e3e1ab0eead..203caa448be 100644 --- a/apps/mobile/src/features/review/reviewHighlighterEngine.test.ts +++ b/apps/mobile/src/features/review/reviewHighlighterEngine.test.ts @@ -6,9 +6,9 @@ import { } from "./reviewHighlighterEngine"; describe("resolveReviewHighlighterEnginePreference", () => { - it("defaults invalid values to native", () => { - expect(resolveReviewHighlighterEnginePreference(undefined)).toBe("native"); - expect(resolveReviewHighlighterEnginePreference("bogus")).toBe("native"); + it("defaults invalid values to javascript", () => { + expect(resolveReviewHighlighterEnginePreference(undefined)).toBe("javascript"); + expect(resolveReviewHighlighterEnginePreference("bogus")).toBe("javascript"); }); it("accepts supported values", () => { diff --git a/apps/mobile/src/features/review/reviewHighlighterEngine.ts b/apps/mobile/src/features/review/reviewHighlighterEngine.ts index 4287685376d..5ef9f055a65 100644 --- a/apps/mobile/src/features/review/reviewHighlighterEngine.ts +++ b/apps/mobile/src/features/review/reviewHighlighterEngine.ts @@ -11,7 +11,7 @@ export function resolveReviewHighlighterEnginePreference( case "native": return "native"; default: - return "native"; + return "javascript"; } } diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index 008a0761949..44065875708 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -59,8 +59,7 @@ const SHIKI_THEME_NAME_BY_SCHEME = { dark: "github-dark-default", } as const; const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE = - process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ?? - (process.env.NODE_ENV === "test" ? "javascript" : "native"); + process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ?? "javascript"; const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference( REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, ); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 8ad117c8635..4a3b2afd5b8 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -275,7 +275,10 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { readonly host: string; readonly href: string; }) { - const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host)); + const [failedHost, setFailedHost] = useState(() => + failedMarkdownFaviconHosts.has(props.host) ? props.host : null, + ); + const failed = failedHost === props.host || failedMarkdownFaviconHosts.has(props.host); return ( { failedMarkdownFaviconHosts.add(props.host); - setFailed(true); + setFailedHost(props.host); }} /> ) : ( @@ -1855,6 +1858,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } maintainVisibleContentPosition={maintainVisibleContentPosition} data={presentedFeed} + recycleItems={Platform.OS === "android"} extraData={listAppearanceData} renderItem={renderItem} keyExtractor={(entry) => entry.id} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 7fb4740ddce..dbbe39fc435 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -96,6 +96,27 @@ function OpeningThreadLoadingScreen() { return ; } +const ANDROID_THREAD_LOADING_ACTIONS: ReadonlyArray = [ + { + accessibilityLabel: "Open files", + icon: "folder", + disabled: true, + onPress: () => undefined, + }, + { + accessibilityLabel: "Open terminal", + icon: "terminal", + disabled: true, + onPress: () => undefined, + }, + { + accessibilityLabel: "Open git controls", + icon: "point.topleft.down.curvedto.point.bottomright.up", + disabled: true, + onPress: () => undefined, + }, +]; + type ThreadRouteScreenRouteProps = StaticScreenProps<{ readonly environmentId: string; readonly threadId: string; @@ -106,27 +127,65 @@ interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps { readonly renderInspector?: (headerInset: number) => ReactNode; } -function ThreadUnavailableScreen() { +function ThreadRouteStateScreen(props: { + readonly children: ReactNode; + readonly onNavigateUp: () => void; + readonly title: string; +}) { return ( - - - + <> + {Platform.OS === "android" ? ( + + ) : null} + {props.children} + + ); +} + +function ThreadUnavailableScreen(props: { readonly onNavigateUp: () => void }) { + return ( + + + + + + ); +} + +function OpeningThreadRouteScreen(props: { readonly onNavigateUp: () => void }) { + return ( + + + ); } export function ThreadRouteScreen(props: ThreadRouteScreenProps) { + const navigation = useNavigation(); + const handleNavigateUp = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + navigation.dispatch(StackActions.replace("Home")); + }, [navigation]); const { state: workspaceState } = useWorkspaceState(); const { connectionState } = useRemoteConnectionStatus(); const { selectedThread } = useThreadSelection(); @@ -148,7 +207,7 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { const selectedThreadDetailState = useSelectedThreadDetailState(); if (environmentId === null || threadIdRaw === null) { - return ; + return ; } // Render the full thread chrome (header, feed, composer) as soon as the @@ -165,10 +224,10 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { routeConnectionState === "reconnecting"; if (stillHydrating) { - return ; + return ; } - return ; + return ; } function ThreadRouteContent( @@ -671,30 +730,29 @@ function ThreadRouteContent( onPress: props.onReturnToThread, }); } - if (selectedThreadCwd !== null) { - actions.push({ - accessibilityLabel: "Open files", - icon: "folder", - onPress: handleOpenFilesInspector, - }); - } - if (selectedThreadProject?.workspaceRoot) { - actions.push({ - accessibilityLabel: "Open terminal", - icon: "terminal", - onPress: () => handleOpenTerminal(null), - }); - } + actions.push({ + accessibilityLabel: "Open files", + icon: "folder", + onPress: handleOpenFilesInspector, + disabled: selectedThreadCwd === null, + }); + actions.push({ + accessibilityLabel: "Open terminal", + icon: "terminal", + onPress: () => handleOpenTerminal(null), + disabled: !selectedThreadProject?.workspaceRoot, + }); actions.push({ accessibilityLabel: "Open git controls", icon: "point.topleft.down.curvedto.point.bottomright.up", onPress: handleOpenGitInspector, }); - if (fileInspector.supported && selectedThreadCwd !== null) { + if (fileInspector.supported) { actions.push({ accessibilityLabel: "Toggle inspector", icon: "sidebar.right", onPress: handleToggleInspector, + disabled: selectedThreadCwd === null, }); } return actions; @@ -713,6 +771,13 @@ function ThreadRouteContent( // native back button does not render. Provide an explicit Home escape for // that case; when history exists the native back button is used instead. const canGoBack = navigation.canGoBack(); + const handleNavigateUp = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + navigation.dispatch(StackActions.replace("Home")); + }, [navigation]); const compactHomeHeaderItems = useMemo( () => [ withNativeGlassHeaderItem({ @@ -837,7 +902,8 @@ function ThreadRouteContent( navigation.goBack()} + reserveSubtitleSpace + onBack={layout.usesSplitView ? undefined : handleNavigateUp} actions={androidHeaderActions} /> ) : null} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 5d323516dd4..b9b9b2d273c 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -13,6 +13,7 @@ import { import { buildThreadFeed, + derivePendingApprovals, deriveThreadFeedPresentation, type ThreadFeedActivity, type ThreadFeedEntry, @@ -531,4 +532,84 @@ describe("buildThreadFeed", () => { expanded: true, }); }); + + it("orders feed entries chronologically without relying on timestamp string order", () => { + const thread = makeThread({ + id: ThreadId.make("thread-ordering"), + projectId: ProjectId.make("project-1"), + title: "Ordering", + messages: [ + { + id: MessageId.make("message-late"), + role: "assistant", + text: "Last", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + { + id: MessageId.make("message-early"), + role: "user", + text: "First", + turnId: null, + streaming: false, + createdAt: "2026-04-01T01:00:00.000+02:00", + updatedAt: "2026-04-01T01:00:00.000+02:00", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("warning-between"), + kind: "runtime.warning", + summary: "Runtime warning", + createdAt: "2026-03-31T23:30:00.000Z", + payload: { message: "Between the messages" }, + }), + ], + }); + + expect(buildThreadFeed(thread).map((entry) => entry.id)).toEqual([ + "message-early", + "warning-between", + "message-late", + ]); + }); +}); + +describe("derivePendingApprovals", () => { + it("returns open approvals oldest-first regardless of event order", () => { + const approvals = derivePendingApprovals([ + makeActivity({ + id: EventId.make("approval-late"), + kind: "approval.requested", + summary: "Approve command", + createdAt: "2026-04-01T00:00:09.000Z", + payload: { requestId: "request-late", requestKind: "command" }, + }), + makeActivity({ + id: EventId.make("approval-early"), + kind: "approval.requested", + summary: "Approve file change", + createdAt: "2026-04-01T00:00:03.000Z", + payload: { requestId: "request-early", requestKind: "file-change" }, + }), + makeActivity({ + id: EventId.make("approval-resolved"), + kind: "approval.requested", + summary: "Approve command", + createdAt: "2026-04-01T00:00:01.000Z", + payload: { requestId: "request-resolved", requestKind: "command" }, + }), + makeActivity({ + id: EventId.make("approval-resolution"), + kind: "approval.resolved", + summary: "Approved", + createdAt: "2026-04-01T00:00:02.000Z", + payload: { requestId: "request-resolved" }, + }), + ]); + + expect(approvals.map(({ requestId }) => requestId)).toEqual(["request-early", "request-late"]); + }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index dd568e6f045..5179f19159b 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -946,6 +946,16 @@ const activityOrder = Order.combineAll([ Order.mapInput(Order.String, (activity) => activity.id), ]); +function sortByCreatedAt(items: Iterable): T[] { + const decorated = Array.from(items, (item) => ({ item, timestamp: Date.parse(item.createdAt) })); + decorated.sort((left, right) => { + if (left.timestamp < right.timestamp) return -1; + if (left.timestamp > right.timestamp) return 1; + return 0; + }); + return decorated.map(({ item }) => item); +} + function isEmptyMessage(entry: RawThreadFeedEntry): boolean { if (entry.type !== "message") { return false; @@ -1284,7 +1294,7 @@ export function derivePendingApprovals( } } - return Arr.sortWith([...openByRequestId.values()], (s) => new Date(s.createdAt), Order.Date); + return sortByCreatedAt(openByRequestId.values()); } export function derivePendingUserInputs( @@ -1327,7 +1337,7 @@ export function derivePendingUserInputs( } } - return Arr.sortWith(openByRequestId.values(), (s) => new Date(s.createdAt), Order.Date); + return sortByCreatedAt(openByRequestId.values()); } export function setPendingUserInputCustomAnswer( @@ -1369,58 +1379,54 @@ export function buildThreadFeed( const oldestLoadedMessageCreatedAt = options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; const workLogEntries = deriveWorkLogEntries(thread.activities); - const entries = Arr.sortWith( - [ - ...loadedMessages.map((message) => ({ - type: "message", - id: message.id, - createdAt: message.createdAt, - message, - })), - ...workLogEntries - .filter((entry) => { - if (options?.loadedMessages === undefined) { - return true; - } - return ( - oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt - ); - }) - .map((entry) => { - const summary = workEntryHeading(entry); - const detail = workEntryPreview(entry); - const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry)); - const getCopyText = memoizeValue(() => - [summary, detail, getFullDetail()] - .filter((value, index, values): value is string => { - return Boolean(value) && values.indexOf(value) === index; - }) - .join("\n"), - ); - return { - type: "activity", + const entries = sortByCreatedAt([ + ...loadedMessages.map((message) => ({ + type: "message", + id: message.id, + createdAt: message.createdAt, + message, + })), + ...workLogEntries + .filter((entry) => { + if (options?.loadedMessages === undefined) { + return true; + } + return ( + oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt + ); + }) + .map((entry) => { + const summary = workEntryHeading(entry); + const detail = workEntryPreview(entry); + const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry)); + const getCopyText = memoizeValue(() => + [summary, detail, getFullDetail()] + .filter((value, index, values): value is string => { + return Boolean(value) && values.indexOf(value) === index; + }) + .join("\n"), + ); + return { + type: "activity", + id: entry.id, + createdAt: entry.createdAt, + turnId: entry.turnId, + activity: { id: entry.id, createdAt: entry.createdAt, turnId: entry.turnId, - activity: { - id: entry.id, - createdAt: entry.createdAt, - turnId: entry.turnId, - summary, - detail, - canExpand: workEntryHasExpandedBody(entry), - getFullDetail, - getCopyText, - icon: workEntryIcon(entry), - toolLike: workLogEntryIsToolLike(entry), - status: workEntryStatus(entry), - }, - }; - }), - ], - (s) => new Date(s.createdAt), - Order.Date, - ); + summary, + detail, + canExpand: workEntryHasExpandedBody(entry), + getFullDetail, + getCopyText, + icon: workEntryIcon(entry), + toolLike: workLogEntryIsToolLike(entry), + status: workEntryStatus(entry), + }, + }; + }), + ]); return groupAdjacentActivities(entries); } diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 67896961b38..fb10b8e83bd 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -4,6 +4,27 @@ import type { OrchestrationThreadDetailSnapshot, } from "@t3tools/contracts"; +const MAX_MCP_HISTORY_DATA_CHARS = 8_000; +const MAX_MCP_HISTORY_STRING_CHARS = 2_000; +const MAX_COMMAND_HISTORY_CHARS = 8_000; +const MAX_MCP_HISTORY_COLLECTION_ENTRIES = 100; +const MAX_MCP_HISTORY_DEPTH = 8; +const MCP_HISTORY_TRUNCATION_NOTICE = "Large MCP payload truncated for display"; +const COMMAND_HISTORY_TRUNCATION_NOTICE = "Large command truncated for display"; +const MCP_ITEM_PRIORITY_KEYS = [ + "type", + "server", + "tool", + "status", + "id", + "arguments", + "error", + "durationMs", + "appContext", + "pluginId", + "result", +] as const; + function asRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -18,6 +39,140 @@ function asTrimmedString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +interface PreviewBudget { + remaining: number; +} + +function consumePreviewBudget(budget: PreviewBudget, amount: number): void { + budget.remaining = Math.max(0, budget.remaining - amount); +} + +function truncatePreviewString(value: string, budget: PreviewBudget): string { + const available = Math.min(MAX_MCP_HISTORY_STRING_CHARS, budget.remaining); + if (value.length <= available) { + consumePreviewBudget(budget, value.length); + return value; + } + + const suffix = `… [${value.length.toLocaleString()} chars truncated]`; + const prefixLength = Math.max(0, available - suffix.length); + const preview = `${value.slice(0, prefixLength)}${suffix}`; + consumePreviewBudget(budget, preview.length); + return preview; +} + +function projectJsonPreview(value: unknown, budget: PreviewBudget, depth = 0): unknown { + if (depth >= MAX_MCP_HISTORY_DEPTH || budget.remaining === 0) { + return MCP_HISTORY_TRUNCATION_NOTICE; + } + if (typeof value === "string") { + return truncatePreviewString(value, budget); + } + if (Array.isArray(value)) { + const preview: unknown[] = []; + const entryLimit = Math.min(value.length, MAX_MCP_HISTORY_COLLECTION_ENTRIES); + for (let index = 0; index < entryLimit && budget.remaining > 0; index += 1) { + preview.push(projectJsonPreview(value[index], budget, depth + 1)); + } + if (preview.length < value.length) { + preview.push(`… [${value.length - preview.length} entries truncated]`); + } + return preview; + } + + const record = asRecord(value); + if (record) { + const preview: Record = {}; + const entries = Object.entries(record); + const entryLimit = Math.min(entries.length, MAX_MCP_HISTORY_COLLECTION_ENTRIES); + for (let index = 0; index < entryLimit && budget.remaining > 0; index += 1) { + const [key, entry] = entries[index]!; + consumePreviewBudget(budget, key.length); + preview[key] = projectJsonPreview(entry, budget, depth + 1); + } + if (Object.keys(preview).length < entries.length) { + preview.__t3HistoryNotice = MCP_HISTORY_TRUNCATION_NOTICE; + } + return preview; + } + + consumePreviewBudget(budget, 8); + return value; +} + +function prioritizeMcpItem(value: unknown): unknown { + const item = asRecord(value); + if (!item) { + return value; + } + + const prioritized: Record = {}; + for (const key of MCP_ITEM_PRIORITY_KEYS) { + if (key in item) { + prioritized[key] = item[key]; + } + } + for (const [key, entry] of Object.entries(item)) { + if (!(key in prioritized)) { + prioritized[key] = entry; + } + } + return prioritized; +} + +function serializedLength(value: unknown): number | null { + try { + return JSON.stringify(value)?.length ?? 0; + } catch { + return null; + } +} + +function projectMcpData(data: Record): Record | null { + const dataLength = serializedLength(data); + if (dataLength === null || dataLength <= MAX_MCP_HISTORY_DATA_CHARS) { + return null; + } + + const budget: PreviewBudget = { remaining: MAX_MCP_HISTORY_DATA_CHARS }; + const itemPreview = projectJsonPreview(prioritizeMcpItem(data.item), budget); + const item = asRecord(itemPreview); + const projectedItem = item + ? { ...item, __t3HistoryNotice: MCP_HISTORY_TRUNCATION_NOTICE } + : { value: itemPreview, __t3HistoryNotice: MCP_HISTORY_TRUNCATION_NOTICE }; + const projectedData: Record = { item: projectedItem }; + for (const key of ["completedAtMs", "threadId", "turnId", "toolCallId", "kind"] as const) { + const value = data[key]; + if ( + value === null || + typeof value === "boolean" || + typeof value === "number" || + (typeof value === "string" && value.length <= 1_000) + ) { + projectedData[key] = value; + } + } + + if ((serializedLength(projectedData) ?? Number.POSITIVE_INFINITY) <= MAX_MCP_HISTORY_DATA_CHARS) { + return projectedData; + } + + const fallbackItem: Record = { + __t3HistoryNotice: MCP_HISTORY_TRUNCATION_NOTICE, + }; + const originalItem = asRecord(data.item); + for (const key of ["type", "server", "tool", "status", "id"] as const) { + const value = originalItem?.[key]; + if (typeof value === "string" && value.length <= 1_000) { + fallbackItem[key] = value; + } + } + const fallbackData = { item: fallbackItem }; + return (serializedLength(fallbackData) ?? Number.POSITIVE_INFINITY) <= MAX_MCP_HISTORY_DATA_CHARS + ? fallbackData + : { item: { __t3HistoryNotice: MCP_HISTORY_TRUNCATION_NOTICE } }; +} + function pushChangedFile(target: string[], seen: Set, value: unknown): void { const normalized = asTrimmedString(value); if (!normalized || seen.has(normalized)) { @@ -88,22 +243,33 @@ function projectCommandData(data: Record): Record = {}; if ("command" in item) { - projectedItem.command = item.command; + projectedItem.command = projectCommandValue(item.command); } const input = asRecord(item.input); if (input && "command" in input) { - projectedItem.input = { command: input.command }; + projectedItem.input = { command: projectCommandValue(input.command) }; } const result = asRecord(item.result); if (result && "command" in result) { - projectedItem.result = { command: result.command }; + projectedItem.result = { command: projectCommandValue(result.command) }; } return Object.keys(projectedItem).length > 0 ? projectedItem : undefined; } +function projectCommandValue(value: unknown): unknown { + const length = serializedLength(value); + if (length === null || length <= MAX_COMMAND_HISTORY_CHARS) { + return value; + } + const preview = projectJsonPreview(value, { remaining: MAX_COMMAND_HISTORY_CHARS }); + return (serializedLength(preview) ?? Number.POSITIVE_INFINITY) <= MAX_COMMAND_HISTORY_CHARS + ? preview + : COMMAND_HISTORY_TRUNCATION_NOTICE; +} + function summarizeToolTextOutput(value: string): string | null { const lines: string[] = []; for (const rawLine of value.split(/\r?\n/u)) { @@ -160,9 +326,21 @@ export function projectActivityPayload( ): OrchestrationThreadActivity { const payload = asRecord(activity.payload); const data = asRecord(payload?.data); - if (!payload || !data || payload.itemType === "mcp_tool_call") { + if (!payload || !data) { return activity; } + if (payload.itemType === "mcp_tool_call") { + const projectedData = projectMcpData(data); + return projectedData + ? { + ...activity, + payload: { + ...payload, + data: projectedData, + }, + } + : activity; + } const projectedData: Record = {}; const item = projectCommandData(data); @@ -170,7 +348,7 @@ export function projectActivityPayload( projectedData.item = item; } if ("command" in data) { - projectedData.command = data.command; + projectedData.command = projectCommandValue(data.command); } const changedFiles: string[] = []; @@ -228,7 +406,7 @@ function isResolvableContextWindowActivity(activity: OrchestrationThreadActivity * newer updates still stream through and supersede the retained rows on the * client. */ -function dropStaleContextWindowActivities( +export function compactContextWindowActivities( activities: ReadonlyArray, ): ReadonlyArray { const latestIndexByTurn = new Map(); @@ -254,7 +432,7 @@ export function projectThreadDetailSnapshot( ...snapshot, thread: { ...snapshot.thread, - activities: dropStaleContextWindowActivities(snapshot.thread.activities).map( + activities: compactContextWindowActivities(snapshot.thread.activities).map( projectActivityPayload, ), }, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 9c4a81b185d..29a211e1ecf 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -17,6 +17,7 @@ import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; +import { MAX_THREAD_ACTIVITIES, MAX_THREAD_CHECKPOINTS } from "../historyLimits.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); @@ -966,10 +967,72 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ], }); } + + yield* sql` + WITH RECURSIVE checkpoint_numbers(value) AS ( + SELECT 3 + UNION ALL + SELECT value + 1 + FROM checkpoint_numbers + WHERE value < ${MAX_THREAD_CHECKPOINTS + 3} + ) + INSERT INTO projection_turns ( + thread_id, + turn_id, + pending_message_id, + source_proposed_plan_thread_id, + source_proposed_plan_id, + assistant_message_id, + state, + requested_at, + started_at, + completed_at, + checkpoint_turn_count, + checkpoint_ref, + checkpoint_status, + checkpoint_files_json + ) + SELECT + 'thread-context', + printf('turn-%04d', value), + NULL, + NULL, + NULL, + NULL, + 'completed', + '2026-03-02T00:00:06.000Z', + '2026-03-02T00:00:06.000Z', + '2026-03-02T00:00:06.000Z', + value, + printf('checkpoint-%04d', value), + 'ready', + '[]' + FROM checkpoint_numbers + `; + + const oversizedContext = yield* snapshotQuery.getThreadCheckpointContext( + ThreadId.make("thread-context"), + ); + assert.equal(oversizedContext._tag, "Some"); + if (oversizedContext._tag === "Some") { + assert.equal(oversizedContext.value.checkpoints.length, MAX_THREAD_CHECKPOINTS + 3); + assert.equal(oversizedContext.value.checkpoints[0]?.checkpointTurnCount, 1); + assert.equal(oversizedContext.value.checkpoints.at(-1)?.checkpointTurnCount, 503); + } + + const boundedDetail = yield* snapshotQuery.getThreadDetailById( + ThreadId.make("thread-context"), + ); + assert.equal(boundedDetail._tag, "Some"); + if (boundedDetail._tag === "Some") { + assert.equal(boundedDetail.value.checkpoints.length, MAX_THREAD_CHECKPOINTS); + assert.equal(boundedDetail.value.checkpoints[0]?.checkpointTurnCount, 4); + assert.equal(boundedDetail.value.checkpoints.at(-1)?.checkpointTurnCount, 503); + } }), ); - it.effect("keeps thread detail activity ordering consistent with shell snapshot ordering", () => + it.effect("keeps thread detail activities ordered and bounds oversized histories", () => Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; const sql = yield* SqlClient.SqlClient; @@ -1128,6 +1191,125 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { createdAt: "2026-04-01T00:00:04.000Z", }, ]); + + yield* sql` + WITH RECURSIVE activity_numbers(value) AS ( + SELECT 0 + UNION ALL + SELECT value + 1 + FROM activity_numbers + WHERE value < ${MAX_THREAD_ACTIVITIES} + ) + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + SELECT + printf('activity-window-%04d', value), + 'thread-1', + NULL, + 'info', + 'runtime.note', + printf('window activity %d', value), + '{}', + 100 + value, + '2026-04-01T00:00:07.000Z' + FROM activity_numbers + `; + + const boundedThreadDetail = yield* snapshotQuery.getThreadDetailById( + ThreadId.make("thread-1"), + ); + assert.equal(boundedThreadDetail._tag, "Some"); + if (boundedThreadDetail._tag === "Some") { + assert.equal(boundedThreadDetail.value.activities.length, MAX_THREAD_ACTIVITIES); + assert.equal(boundedThreadDetail.value.activities[0]?.id, "activity-window-0001"); + assert.equal(boundedThreadDetail.value.activities.at(-1)?.id, "activity-window-0500"); + } + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) VALUES ( + 'context-window-unresolved', + 'thread-1', + NULL, + 'info', + 'context-window.updated', + 'Context window payload unavailable', + '{}', + 999, + '2026-04-01T00:00:07.000Z' + ) + `; + + yield* sql` + WITH RECURSIVE context_window_numbers(value) AS ( + SELECT 0 + UNION ALL + SELECT value + 1 + FROM context_window_numbers + WHERE value < 199 + ) + INSERT INTO projection_thread_activities ( + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + ) + SELECT + printf('context-window-%04d', value), + 'thread-1', + NULL, + 'info', + 'context-window.updated', + 'Context window updated', + printf('{"usedTokens":%d}', value), + 1000 + value, + '2026-04-01T00:00:08.000Z' + FROM context_window_numbers + `; + + const compactedThreadDetail = yield* snapshotQuery.getThreadDetailById( + ThreadId.make("thread-1"), + ); + assert.equal(compactedThreadDetail._tag, "Some"); + if (compactedThreadDetail._tag === "Some") { + assert.equal(compactedThreadDetail.value.activities.length, MAX_THREAD_ACTIVITIES); + assert.equal( + compactedThreadDetail.value.activities.filter( + (activity) => activity.kind === "context-window.updated", + ).length, + 2, + ); + assert.ok( + compactedThreadDetail.value.activities.some( + (activity) => activity.id === "context-window-unresolved", + ), + ); + assert.equal(compactedThreadDetail.value.activities[0]?.id, "activity-window-0003"); + assert.equal(compactedThreadDetail.value.activities.at(-1)?.id, "context-window-0199"); + } }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 766f8a93dbe..c2f88f1c694 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -59,6 +59,11 @@ import { type ProjectionThreadCheckpointContext, type ProjectionSnapshotQueryShape, } from "../Services/ProjectionSnapshotQuery.ts"; +import { + MAX_THREAD_ACTIVITIES, + MAX_THREAD_CHECKPOINTS, + MAX_THREAD_MESSAGES, +} from "../historyLimits.ts"; const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); @@ -924,8 +929,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" - FROM projection_thread_messages - WHERE thread_id = ${threadId} + FROM ( + SELECT * + FROM projection_thread_messages + WHERE thread_id = ${threadId} + ORDER BY created_at DESC, message_id DESC + LIMIT ${MAX_THREAD_MESSAGES} + ) AS recent_messages ORDER BY created_at ASC, message_id ASC `, }); @@ -955,6 +965,37 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { Result: ProjectionThreadActivityDbRowSchema, execute: ({ threadId }) => sql` + WITH ranked_context_window_activities AS ( + SELECT + activity_id, + ROW_NUMBER() OVER ( + PARTITION BY turn_id + ORDER BY sequence DESC, created_at DESC, activity_id DESC + ) AS recency_rank + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND kind = 'context-window.updated' + AND json_type(payload_json, '$.usedTokens') IN ('integer', 'real') + AND json_extract(payload_json, '$.usedTokens') BETWEEN 0 AND 1.7976931348623157e308 + ), + compacted_activities AS ( + SELECT activities.* + FROM projection_thread_activities AS activities + WHERE activities.thread_id = ${threadId} + AND ( + activities.kind <> 'context-window.updated' + OR json_type(activities.payload_json, '$.usedTokens') IS NULL + OR json_type(activities.payload_json, '$.usedTokens') NOT IN ('integer', 'real') + OR json_extract(activities.payload_json, '$.usedTokens') + NOT BETWEEN 0 AND 1.7976931348623157e308 + ) + UNION ALL + SELECT activities.* + FROM ranked_context_window_activities AS ranked + JOIN projection_thread_activities AS activities + ON activities.activity_id = ranked.activity_id + WHERE ranked.recency_rank = 1 + ) SELECT activity_id AS "activityId", thread_id AS "threadId", @@ -965,8 +1006,15 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { payload_json AS "payload", sequence, created_at AS "createdAt" - FROM projection_thread_activities - WHERE thread_id = ${threadId} + FROM ( + SELECT * + FROM compacted_activities + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${MAX_THREAD_ACTIVITIES} + ) AS recent_activities ORDER BY sequence ASC, created_at ASC, @@ -1041,6 +1089,32 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listRecentCheckpointRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionCheckpointDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + turn_id AS "turnId", + checkpoint_turn_count AS "checkpointTurnCount", + checkpoint_ref AS "checkpointRef", + checkpoint_status AS "status", + checkpoint_files_json AS "files", + assistant_message_id AS "assistantMessageId", + completed_at AS "completedAt" + FROM ( + SELECT * + FROM projection_turns + WHERE thread_id = ${threadId} + AND checkpoint_turn_count IS NOT NULL + ORDER BY checkpoint_turn_count DESC + LIMIT ${MAX_THREAD_CHECKPOINTS} + ) AS recent_checkpoints + ORDER BY checkpoint_turn_count ASC + `, + }); + const getFullThreadDiffContextRow = SqlSchema.findOneOption({ Request: FullThreadDiffContextLookupInput, Result: ProjectionFullThreadDiffContextRowSchema, @@ -2136,7 +2210,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - listCheckpointRowsByThread({ threadId }).pipe( + listRecentCheckpointRowsByThread({ threadId }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getThreadDetailById:listCheckpoints:query", diff --git a/apps/server/src/orchestration/historyLimits.ts b/apps/server/src/orchestration/historyLimits.ts new file mode 100644 index 00000000000..e16036bf667 --- /dev/null +++ b/apps/server/src/orchestration/historyLimits.ts @@ -0,0 +1,3 @@ +export const MAX_THREAD_MESSAGES = 2_000; +export const MAX_THREAD_ACTIVITIES = 500; +export const MAX_THREAD_CHECKPOINTS = 500; diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 9c07a312023..df88687df86 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -4,10 +4,12 @@ import { ProjectId, ProviderDriverKind, ThreadId, + TurnId, type OrchestrationEvent, + type OrchestrationThreadActivity, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; import { createEmptyReadModel, projectEvent } from "./projector.ts"; @@ -103,6 +105,93 @@ describe("orchestration projector", () => { ]); }); + it("compacts context-window updates before bounding in-memory activity history", async () => { + const now = "2026-01-01T00:00:00.000Z"; + const created = await Effect.runPromise( + projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: now, + commandId: "cmd-thread-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ), + ); + const normalActivities = Array.from( + { length: 499 }, + (_, index): OrchestrationThreadActivity => ({ + id: EventId.make(`activity-${String(index).padStart(4, "0")}`), + tone: "info", + kind: "runtime.note", + summary: "Runtime note", + payload: {}, + turnId: null, + createdAt: now, + }), + ); + const contextWindowActivity = ( + index: number, + id = `context-window-${String(index).padStart(4, "0")}`, + ): OrchestrationThreadActivity => ({ + id: EventId.make(id), + tone: "info", + kind: "context-window.updated", + summary: "Context window updated", + payload: { usedTokens: index }, + turnId: TurnId.make("turn-1"), + createdAt: now, + }); + const oversizedModel = { + ...created, + threads: [ + { + ...created.threads[0]!, + activities: [...normalActivities, contextWindowActivity(1), contextWindowActivity(2)], + }, + ], + }; + + const next = await Effect.runPromise( + projectEvent( + oversizedModel, + makeEvent({ + sequence: 2, + type: "thread.activity-appended", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: now, + commandId: "cmd-context-window", + payload: { + threadId: "thread-1", + activity: contextWindowActivity(3, "context-window-latest"), + }, + }), + ), + ); + + expect(next.threads[0]?.activities).toHaveLength(500); + expect( + next.threads[0]?.activities.filter((activity) => activity.kind === "context-window.updated"), + ).toEqual([contextWindowActivity(3, "context-window-latest")]); + }); + it("fails when event payload cannot be decoded by runtime schema", async () => { const now = "2026-01-01T00:00:00.000Z"; const model = createEmptyReadModel(now); @@ -856,12 +945,12 @@ describe("orchestration projector", () => { ).toEqual([{ id: "assistant-keep", role: "assistant", turnId: "turn-1" }]); }); - it("caps message and checkpoint retention for long-lived threads", async () => { - const createdAt = "2026-03-01T10:00:00.000Z"; - const model = createEmptyReadModel(createdAt); + it.effect("caps message and checkpoint retention for long-lived threads", () => + Effect.gen(function* () { + const createdAt = "2026-03-01T10:00:00.000Z"; + const model = createEmptyReadModel(createdAt); - const afterCreate = await Effect.runPromise( - projectEvent( + const afterCreate = yield* projectEvent( model, makeEvent({ sequence: 1, @@ -885,75 +974,69 @@ describe("orchestration projector", () => { updatedAt: createdAt, }, }), - ), - ); + ); - const messageEvents: ReadonlyArray = Array.from( - { length: 2_100 }, - (_, index) => - makeEvent({ - sequence: index + 2, - type: "thread.message-sent", - aggregateKind: "thread", - aggregateId: "thread-capped", - occurredAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, - commandId: `cmd-message-${index}`, - payload: { - threadId: "thread-capped", - messageId: `msg-${index}`, - role: "assistant", - text: `message-${index}`, - turnId: `turn-${index}`, - streaming: false, - createdAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, - updatedAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, - }, - }), - ); - const afterMessages = await messageEvents.reduce< - Promise> - >( - (statePromise, event) => - statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), - Promise.resolve(afterCreate), - ); + const messageEvents: ReadonlyArray = Array.from( + { length: 2_100 }, + (_, index) => + makeEvent({ + sequence: index + 2, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-capped", + occurredAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, + commandId: `cmd-message-${index}`, + payload: { + threadId: "thread-capped", + messageId: `msg-${index}`, + role: "assistant", + text: `message-${index}`, + turnId: `turn-${index}`, + streaming: false, + createdAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, + updatedAt: `2026-03-01T10:00:${String(index % 60).padStart(2, "0")}.000Z`, + }, + }), + ); + let afterMessages = afterCreate; + for (const event of messageEvents) { + afterMessages = yield* projectEvent(afterMessages, event); + } - const checkpointEvents: ReadonlyArray = Array.from( - { length: 600 }, - (_, index) => - makeEvent({ - sequence: index + 2_102, - type: "thread.turn-diff-completed", - aggregateKind: "thread", - aggregateId: "thread-capped", - occurredAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, - commandId: `cmd-checkpoint-${index}`, - payload: { - threadId: "thread-capped", - turnId: `turn-${index}`, - checkpointTurnCount: index + 1, - checkpointRef: `refs/t3/checkpoints/thread-capped/turn/${index + 1}`, - status: "ready", - files: [], - assistantMessageId: `msg-${index}`, - completedAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, - }, - }), - ); - const finalState = await checkpointEvents.reduce< - Promise> - >( - (statePromise, event) => - statePromise.then((state) => Effect.runPromise(projectEvent(state, event))), - Promise.resolve(afterMessages), - ); + const checkpointEvents: ReadonlyArray = Array.from( + { length: 600 }, + (_, index) => + makeEvent({ + sequence: index + 2_102, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-capped", + occurredAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, + commandId: `cmd-checkpoint-${index}`, + payload: { + threadId: "thread-capped", + turnId: `turn-${index}`, + checkpointTurnCount: index + 1, + checkpointRef: `refs/t3/checkpoints/thread-capped/turn/${index + 1}`, + status: "ready", + files: [], + assistantMessageId: `msg-${index}`, + completedAt: `2026-03-01T10:30:${String(index % 60).padStart(2, "0")}.000Z`, + }, + }), + ); + let finalState = afterMessages; + for (const event of checkpointEvents) { + finalState = yield* projectEvent(finalState, event); + } - const thread = finalState.threads[0]; - expect(thread?.messages).toHaveLength(2_000); - expect(thread?.messages[0]?.id).toBe("msg-100"); - expect(thread?.messages.at(-1)?.id).toBe("msg-2099"); - expect(thread?.checkpoints).toHaveLength(500); - expect(thread?.checkpoints[0]?.turnId).toBe("turn-100"); - expect(thread?.checkpoints.at(-1)?.turnId).toBe("turn-599"); - }); + const thread = finalState.threads[0]; + expect(thread?.messages).toHaveLength(2_000); + expect(thread?.messages[0]?.id).toBe("msg-100"); + expect(thread?.messages.at(-1)?.id).toBe("msg-2099"); + expect(thread?.checkpoints).toHaveLength(500); + expect(thread?.checkpoints[0]?.turnId).toBe("turn-100"); + expect(thread?.checkpoints.at(-1)?.turnId).toBe("turn-599"); + }), + ); }); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index ed4b084e4f9..1edbea13130 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -8,6 +8,7 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import { compactContextWindowActivities } from "./ActivityPayloadProjection.ts"; import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts"; import { MessageSentPayloadSchema, @@ -33,10 +34,13 @@ import { ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, } from "./Schemas.ts"; +import { + MAX_THREAD_ACTIVITIES, + MAX_THREAD_CHECKPOINTS, + MAX_THREAD_MESSAGES, +} from "./historyLimits.ts"; type ThreadPatch = Partial>; -const MAX_THREAD_MESSAGES = 2_000; -const MAX_THREAD_CHECKPOINTS = 500; function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { if (status === "error") return "error" as const; @@ -759,12 +763,12 @@ export function projectEvent( return nextBase; } - const activities = [ - ...thread.activities.filter((entry) => entry.id !== payload.activity.id), - payload.activity, - ] - .toSorted(compareThreadActivities) - .slice(-500); + const activities = compactContextWindowActivities( + [ + ...thread.activities.filter((entry) => entry.id !== payload.activity.id), + payload.activity, + ].toSorted(compareThreadActivities), + ).slice(-MAX_THREAD_ACTIVITIES); return { ...nextBase, diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index d6098937e7f..19b4a13e381 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -188,6 +188,79 @@ describe("projectActivityPayload", () => { expect(projectActivityPayload(fixtures[4]!)).toBe(fixtures[4]); }); + it("bounds oversized MCP tool data while retaining useful call metadata", () => { + const activity = makeActivity("large-mcp", "mcp_tool_call", { + item: { + type: "mcpToolCall", + server: "preview", + tool: "preview_screenshot", + status: "completed", + arguments: { tabId: "tab-1" }, + result: { + content: [{ type: "text", text: "x".repeat(1_000_000) }], + }, + }, + threadId: "thread-projection", + turnId: "turn-large-mcp", + }); + + const projected = projectActivityPayload(activity); + const encodedPayload = JSON.stringify(projected.payload); + + expect(projected).not.toBe(activity); + expect(encodedPayload.length).toBeLessThan(10_000); + expect(encodedPayload).toContain("preview_screenshot"); + expect(encodedPayload).toContain("Large MCP payload truncated for display"); + expect(JSON.stringify(activity.payload).length).toBeGreaterThan(1_000_000); + }); + + it("bounds MCP fallback metadata after JSON escaping", () => { + const escapedMetadata = "\u0000".repeat(1_000); + const activity = makeActivity("escaped-mcp", "mcp_tool_call", { + item: { + type: escapedMetadata, + server: escapedMetadata, + tool: escapedMetadata, + status: escapedMetadata, + id: escapedMetadata, + result: "x".repeat(1_000_000), + }, + }); + + const encodedPayload = JSON.stringify(projectActivityPayload(activity).payload); + + expect(encodedPayload.length).toBeLessThan(10_000); + expect(encodedPayload).toContain("Large MCP payload truncated for display"); + expect(encodedPayload).not.toContain("\\u0000"); + }); + + it("bounds oversized command previews without changing persisted activity data", () => { + const activity = makeActivity("large-command", "command_execution", { + item: { command: `node -e '${"x".repeat(1_000_000)}'` }, + }); + + const projected = projectActivityPayload(activity); + const encodedPayload = JSON.stringify(projected.payload); + + expect(encodedPayload.length).toBeLessThan(10_000); + expect(encodedPayload).toContain("chars truncated"); + expect(JSON.stringify(activity.payload).length).toBeGreaterThan(1_000_000); + }); + + it("falls back when structural JSON overhead exceeds the command preview budget", () => { + const activity = makeActivity("nested-command", "command_execution", { + item: { + command: Array.from({ length: 100 }, () => Array.from({ length: 100 }, () => ({}))), + }, + }); + + const projected = projectActivityPayload(activity); + const encodedPayload = JSON.stringify(projected.payload); + + expect(encodedPayload.length).toBeLessThan(10_000); + expect(encodedPayload).toContain("Large command truncated for display"); + }); + it("keeps current web and mobile derived output identical for every tool item type", () => { for (const activity of fixtures) { const projected = projectActivityPayload(activity);