diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 0fdd022f2a49..32e5bdbed55e 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -51,6 +51,7 @@ import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet"; import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet"; import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl"; import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen"; +import { McpAppFullscreenScreen } from "./features/threads/McpAppFullscreenScreen"; import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen"; import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen"; import { HomeRouteScreen } from "./features/home/HomeRouteScreen"; @@ -755,6 +756,15 @@ const RootStackConfig = createNativeStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/files/:path*`, options: SOLID_HEADER_OPTIONS, }), + ThreadMcpApp: createNativeStackScreen({ + screen: McpAppFullscreenScreen, + linking: `${THREAD_LINKING_PREFIX}/apps/:itemId`, + options: { + presentation: "fullScreenModal", + headerShown: false, + gestureEnabled: false, + }, + }), ThreadAttachment: createNativeStackScreen({ screen: AttachmentFileScreen, linking: `${THREAD_LINKING_PREFIX}/attachments/:attachmentId`, diff --git a/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx new file mode 100644 index 000000000000..0a09941fe78d --- /dev/null +++ b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx @@ -0,0 +1,168 @@ +import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; +import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { orchestrationEnvironment } from "../../state/orchestration"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { useThreadShell, useThreadShells } from "../../state/entities"; +import { useEnvironmentQuery } from "../../state/query"; +import { ThreadMcpApp } from "./McpAppWebView"; + +type McpAppFullscreenScreenProps = StaticScreenProps<{ + readonly environmentId: string; + readonly threadId: string; + readonly conversationThreadId?: string; + readonly itemId: string; + readonly revision?: string; +}>; + +/** + * The thread an app's messages go to. A link only names it, so it counts only + * when it is the app's own thread or a fork of it; anything else falls back to + * the app's thread. + */ +function useConversationThreadId( + environmentId: EnvironmentId, + threadId: ThreadId, + requested: string | undefined, +): ThreadId { + const shells = useThreadShells(); + return useMemo( + () => + requested !== undefined && descendsFrom(shells, environmentId, requested, threadId) + ? ThreadId.make(requested) + : threadId, + [shells, environmentId, threadId, requested], + ); +} + +/** True when `threadId` is `ancestorId` or one of its forks, at any depth. */ +function descendsFrom( + shells: ReadonlyArray, + environmentId: EnvironmentId, + threadId: string, + ancestorId: ThreadId, +): boolean { + const parents = new Map( + shells + .filter((shell) => shell.environmentId === environmentId) + // Only forks continue a conversation; a subagent is its own. + .filter((shell) => shell.lineage.relationshipToParent === "fork") + .map((shell) => [shell.id as string, shell.lineage.parentThreadId]), + ); + let current: string | null | undefined = threadId; + for (let depth = 0; depth < 64 && current != null; depth += 1) { + if (current === ancestorId) return true; + current = parents.get(current); + } + return false; +} + +/** + * An MCP App full screen (`ui/request-display-mode`), in a modal over the + * thread. It is its own view of the app; closing it returns to the row. The + * app is read from its stored tool call, never from the route, so a link can + * only open an app that call really produced. + */ +export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + // A plain goBack pops whatever is on top, so it only runs while this modal + // is: an exit that lands while another screen covers it (after a teardown + // wait) waits for the modal to be on top again, rather than popping that + // screen or being lost. + const focused = useIsFocused(); + const [exitRequested, setExitRequested] = useState(false); + const onClose = useCallback(() => { + if (navigation.isFocused()) navigation.goBack(); + else setExitRequested(true); + }, [navigation]); + const [size, setSize] = useState({ width: 0, height: 0 }); + const params = route.params; + const environmentId = EnvironmentId.make(params.environmentId); + const threadId = ThreadId.make(params.threadId); + const itemId = TurnItemId.make(params.itemId); + const conversationThreadId = useConversationThreadId( + environmentId, + threadId, + params.conversationThreadId, + ); + const detail = useEnvironmentQuery( + orchestrationEnvironment.turnItem({ + environmentId, + input: { + threadId, + itemId, + ...(params.revision === undefined ? {} : { revision: params.revision }), + }, + }), + ); + const item = detail.data?.item; + // One reference per app: the item is a new object on every refetch (a + // reconnect, for one), and a new reference would rebuild the app's host. + const derived = item?.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined; + const [app, setApp] = useState(derived); + if (derived?.attachmentId !== app?.attachmentId) setApp(derived); + // The approval or question the agent waits on renders on the thread this + // modal covers, so the app steps aside for it. + const conversation = useThreadShell(scopeThreadRef(environmentId, conversationThreadId)); + const awaitingUser = + conversation?.hasPendingApprovals === true || conversation?.hasPendingUserInput === true; + useEffect(() => { + if ((awaitingUser || exitRequested) && focused) navigation.goBack(); + }, [awaitingUser, exitRequested, focused, navigation]); + + return ( + + + + {app?.server ?? "App"} + + + + + + + setSize({ + width: Math.round(event.nativeEvent.layout.width), + height: Math.round(event.nativeEvent.layout.height - insets.bottom), + }) + } + > + {app === undefined ? ( + detail.data === undefined ? null : ( + This app cannot be shown. + ) + ) : size.width > 0 ? ( + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/threads/McpAppWebView.tsx b/apps/mobile/src/features/threads/McpAppWebView.tsx new file mode 100644 index 000000000000..119f5f946817 --- /dev/null +++ b/apps/mobile/src/features/threads/McpAppWebView.tsx @@ -0,0 +1,611 @@ +import type { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; +import { + makeMcpAppHost, + McpAppHostRefusal, + mcpAppStyleVariables, + mcpResourceBytes, + type McpAppCallToolResult, + type McpAppHost, + type McpAppHostContext, +} from "@t3tools/client-runtime/mcp-apps"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { CommandId, MessageId } from "@t3tools/contracts"; +import { + mcpAppAllowAttribute, + mcpAppFileName, + mcpAppReferencesEqual, + type McpAppReference, +} from "@t3tools/shared/mcpApp"; +import * as Predicate from "effect/Predicate"; +import Constants from "expo-constants"; +import { useNavigation } from "@react-navigation/native"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { WebView, type WebViewMessageEvent } from "react-native-webview"; + +import { AppText as Text } from "../../components/AppText"; +import { shareGeneratedAttachment } from "../../lib/attachmentDownload"; +import { mobileHtmlRenderTheme } from "../../lib/htmlRenderTheme"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; +import { uuidv4 } from "../../lib/uuid"; +import { useAssetUrlState } from "../../state/assets"; +import { useThreadShell } from "../../state/entities"; +import { mcpAppEnvironment } from "../../state/mcpApps"; +import { orchestrationEnvironment } from "../../state/orchestration"; +import { useEnvironmentQuery } from "../../state/query"; +import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; + +/** The feed reserves a fixed box for an app; a taller app scrolls inside it. */ +const MCP_APP_ROW_HEIGHT = 420; +const ROW_BOTTOM_MARGIN = 8; + +export function mcpAppRowHeight() { + return MCP_APP_ROW_HEIGHT + ROW_BOTTOM_MARGIN; +} + +// The WebView loads a tiny outer page that hosts the app in a real +// opaque-origin iframe, as web does, so the app's `window.parent` and the +// `event.source` of host replies are a real window, which the MCP Apps SDK +// requires. The outer page only relays: app → React Native, and host +// replies (injected as `__t3McpAppReceive(...)`) → app. +// +// Android exposes `ReactNativeWebView` to every frame, so a frame nested in +// the app could post to React Native directly. The outer page therefore wraps +// what it relays with a secret only it holds, and React Native drops anything +// else. The bridge belongs to the captured document: once the app frame loads +// a second time (it navigated itself), the outer page stops relaying and says +// so, rather than letting a page T3 never served pose as the app. This is not +// a confidentiality boundary: a frame can always navigate itself, so the app +// could carry anything it read out in a URL either way. +function outerDocument(src: string, allow: string, secret: string) { + const attribute = (value: string) => + value.replace(/&/g, "&").replace(/"/g, """).replace(/ + + +`; +} + +const commandFailure = (result: { + readonly cause: Parameters[0]["cause"]; +}) => { + const error = squashAtomCommandFailure(result); + return new McpAppHostRefusal( + error instanceof Error && error.message.trim() !== "" ? error.message : "Request failed.", + ); +}; + +const confirm = (title: string, message: string, action: string) => + new Promise((resolve) => { + Alert.alert( + title, + message, + [ + { text: "Cancel", style: "cancel", onPress: () => resolve(false) }, + { text: action, onPress: () => resolve(true) }, + ], + // Android dismisses an open alert when the next one shows; a dismissed + // request is declined rather than left holding an in-flight slot. + { cancelable: true, onDismiss: () => resolve(false) }, + ); + }); + +/** Largest file an app may hand the user through `ui/download-file`. */ +const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; + +/** + * A captured MCP App, hosted in a WebView over the MCP Apps bridge: inline as + * a fixed row of the thread feed, or full screen in its own modal screen. A + * WebView cannot move between the two without reloading, so each is its own + * view of the app: the inline one is torn down before full screen opens, and + * comes back when it closes. + */ +export function ThreadMcpApp(props: { + readonly environmentId: EnvironmentId; + /** The thread that produced the app, which its requests run against. */ + readonly threadId: ThreadId; + /** The thread on screen, which approved app messages are sent to. */ + readonly conversationThreadId: ThreadId; + readonly itemId: TurnItemId; + readonly revision: string; + readonly app: McpAppReference; + readonly width: number; + /** Full screen fills its screen; inline is the feed's fixed row. */ + readonly displayMode?: "inline" | "fullscreen"; + /** Full screen only: leaves it, back to the inline row. */ + readonly onExitFullscreen?: () => void; + /** Full screen only: the screen's height, which the app is told it fills. */ + readonly height?: number; +}) { + // One reference per app: a new object with the same content (a refetch, a + // rerender) must not rebuild the host of a document that is already live. + const [app, setApp] = useState(props.app); + if (!mcpAppReferencesEqual(app, props.app)) setApp(props.app); + const fullscreen = props.displayMode === "fullscreen"; + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + // The app asked to be closed; the row falls back to a note that brings it back. + const [closed, setClosed] = useState(false); + // Full screen is a separate view of the app, so the inline one steps aside + // (and is torn down) while it is open, and comes back when the thread is + // shown again. + const [presentedFullscreen, setPresentedFullscreen] = useState(false); + // Counts the documents this row has shown on purpose (back from full screen, + // or reopened after closing); each gets its own view and host. + const [documentKey, setDocumentKeyState] = useState(0); + + const { themeId, themeAppearance, themeVariables, systemColorsActive } = + useAppearancePreferences(); + const theme = useMemo( + () => + mobileHtmlRenderTheme({ + themeId, + appearance: themeAppearance, + variables: themeVariables, + systemColors: systemColorsActive, + platform: Platform.OS, + }), + [themeId, themeAppearance, themeVariables, systemColorsActive], + ); + const resource = useMemo( + () => ({ + _tag: "attachment" as const, + attachmentId: app.attachmentId, + fileName: mcpAppFileName(app), + mimeType: "text/html", + disposition: "inline" as const, + }), + [app], + ); + const asset = useAssetUrlState(props.environmentId, resource); + // The view keeps its first URL: a re-minted one would reload the app. + const [uri, setUri] = useState(null); + if (uri === null && asset._tag === "Success") setUri(asset.url); + const [loaded, setLoaded] = useState(false); + const [navigatedAway, setNavigatedAway] = useState(false); + // Bumped when the OS reclaims the web process: a fresh view, document and + // host, so the reloaded app is initialized and replayed again. Once only, so + // an app that keeps crashing its process is not reloaded forever. + const [generation, setGeneration] = useState(0); + const [crashed, setCrashed] = useState(false); + const restart = () => { + if (generation > 0) { + setCrashed(true); + return; + } + setLoaded(false); + // The first URL's token may have expired by now; the asset query keeps + // a refreshed one, which this new view can load. + if (asset._tag === "Success") setUri(asset.url); + setGeneration(1); + }; + // Minted per view, so only this view's outer page can speak for its app. + const [secret] = useState(uuidv4); + // A new document starts loading, is not yet one that navigated away, and + // loads the asset query's current URL: the first one's token may be gone. + const setDocumentKey = (next: (value: number) => number) => { + setLoaded(false); + setNavigatedAway(false); + if (asset._tag === "Success") setUri(asset.url); + setDocumentKeyState(next); + }; + // Back from full screen: the thread shows a fresh inline view of the app. + // The listener reads the latest helper, whose URL comes from this render. + const openNewDocument = useRef(setDocumentKey); + useEffect(() => { + openNewDocument.current = setDocumentKey; + }); + useEffect(() => { + if (!presentedFullscreen) return; + return navigation.addListener("focus", () => { + openNewDocument.current((value) => value + 1); + setPresentedFullscreen(false); + }); + }, [navigation, presentedFullscreen]); + + // The feed omits tool input and output; the app needs both. + const detail = useEnvironmentQuery( + orchestrationEnvironment.turnItem({ + environmentId: props.environmentId, + input: { threadId: props.threadId, itemId: props.itemId, revision: props.revision }, + }), + ); + const storedItem = detail.data?.item; + const toolCall = useMemo(() => { + if (storedItem?.type !== "dynamic_tool") return undefined; + const output = storedItem.output as { readonly result?: unknown } | undefined; + return { + arguments: storedItem.input, + result: output?.result as McpAppCallToolResult | undefined, + }; + }, [storedItem]); + + const callTool = useAtomCommand(mcpAppEnvironment.callTool, { reportFailure: false }); + const toolInfo = useAtomCommand(mcpAppEnvironment.toolInfo, { reportFailure: false }); + const readResource = useAtomCommand(mcpAppEnvironment.readResource, { reportFailure: false }); + const updateModelContext = useAtomCommand(mcpAppEnvironment.updateModelContext, { + reportFailure: false, + }); + // The tool's definition, which the app receives as `toolInfo` at initialize. + const [toolDefinition, setToolDefinition] = useState(undefined); + // Read by the host on every message, so it always sees current values + // without being rebuilt (which would drop the app's session). + // The approval or question the agent waits on renders on this thread, and a + // full-screen app would cover it. + const conversation = useThreadShell( + scopeThreadRef(props.environmentId, props.conversationThreadId), + ); + const awaitingUser = + conversation?.hasPendingApprovals === true || conversation?.hasPendingUserInput === true; + const live = { + awaitingUser, + theme, + props, + callTool, + toolInfo, + readResource, + updateModelContext, + insets, + toolDefinition, + navigation, + }; + const latest = useRef(live); + useEffect(() => { + latest.current = live; + }); + useEffect(() => { + let cancelled = false; + void latest.current + .toolInfo({ + environmentId: props.environmentId, + input: { threadId: props.threadId, itemId: props.itemId, name: app.tool }, + }) + .then((info) => { + if (!cancelled && info._tag === "Success" && info.value.tool !== undefined) { + setToolDefinition(info.value.tool); + } + }); + return () => { + cancelled = true; + }; + }, [props.environmentId, props.threadId, props.itemId, app.tool]); + const webView = useRef>(null); + const hostRef = useRef(null); + + // One host per loaded document. + useEffect(() => { + if (uri === null) return; + const scope = () => { + const { environmentId, threadId, itemId } = latest.current.props; + return { environmentId, input: { threadId, itemId } }; + }; + const hostContext = (): McpAppHostContext => { + const current = latest.current; + const isFullscreen = current.props.displayMode === "fullscreen"; + return { + theme: current.theme.appearance, + styles: { variables: mcpAppStyleVariables(current.theme.variables) }, + displayMode: isFullscreen ? "fullscreen" : "inline", + availableDisplayModes: ["inline", "fullscreen"], + // Both are fixed boxes, so the app is told its exact size. + containerDimensions: { + width: current.props.width, + height: isFullscreen ? (current.props.height ?? MCP_APP_ROW_HEIGHT) : MCP_APP_ROW_HEIGHT, + }, + platform: "mobile", + locale: Intl.DateTimeFormat().resolvedOptions().locale, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + userAgent: `t3-code/${Constants.expoConfig?.version ?? "0.0.0"}`, + deviceCapabilities: { touch: true, hover: false }, + // The full-screen modal keeps the top and bottom insets for its + // header and the home indicator; only the sides reach the app. + safeAreaInsets: isFullscreen + ? { top: 0, right: current.insets.right, bottom: 0, left: current.insets.left } + : { top: 0, right: 0, bottom: 0, left: 0 }, + ...(current.toolDefinition === undefined + ? {} + : { toolInfo: { tool: current.toolDefinition } }), + }; + }; + const next = makeMcpAppHost({ + app, + hostVersion: Constants.expoConfig?.version ?? "0.0.0", + post: (message) => + webView.current?.injectJavaScript( + `window.__t3McpAppReceive&&window.__t3McpAppReceive(${JSON.stringify(message)});true;`, + ), + hostContext, + callTool: async ({ name, arguments: args }) => { + const { environmentId, input } = scope(); + const info = await latest.current.toolInfo({ environmentId, input: { ...input, name } }); + if (info._tag !== "Success") throw commandFailure(info); + if (!info.value.callable) throw new McpAppHostRefusal("This app cannot call that tool."); + if ( + !info.value.readOnly && + !(await confirm( + `Allow ${app.server} to run ${info.value.title ?? name}?`, + JSON.stringify(args, null, 2), + "Allow", + )) + ) { + throw new McpAppHostRefusal("Declined by the user."); + } + const result = await latest.current.callTool({ + environmentId, + input: { ...input, name, arguments: args }, + }); + if (result._tag !== "Success") throw commandFailure(result); + return result.value; + }, + readResource: async ({ uri: resourceUri }) => { + const { environmentId, input } = scope(); + const result = await latest.current.readResource({ + environmentId, + input: { ...input, uri: resourceUri }, + }); + if (result._tag !== "Success") throw commandFailure(result); + return result.value; + }, + openLink: async (url) => { + // A WebView cannot tell whether the reader just tapped the app, so it + // asks, rather than letting an app leave T3 on a timer. + if (!(await confirm(`Open a link from ${app.server}?`, url, "Open"))) { + throw new McpAppHostRefusal("Declined by the user."); + } + if (!(await tryOpenExternalUrl(url, "mcp-app"))) { + throw new McpAppHostRefusal("The link could not be opened."); + } + }, + sendMessage: async (text) => { + if (!(await confirm(`Send this message from ${app.server}?`, text, "Send"))) { + throw new McpAppHostRefusal("Declined by the user."); + } + // Through the outbox like a typed message, so it survives a dropped + // connection; the thread's own settings fill in when it sends. + await enqueueThreadOutboxMessage({ + environmentId: latest.current.props.environmentId, + threadId: latest.current.props.conversationThreadId, + messageId: MessageId.make(uuidv4()), + commandId: CommandId.make(uuidv4()), + text, + attachments: [], + dispatchMode: "queue", + createdAt: new Date().toISOString(), + }); + }, + updateModelContext: async (context) => { + const { environmentId, input } = scope(); + const result = await latest.current.updateModelContext({ + environmentId, + input: { + ...input, + ...context, + conversationThreadId: latest.current.props.conversationThreadId, + }, + }); + if (result._tag !== "Success") throw commandFailure(result); + }, + requestDisplayMode: async (mode) => { + const current = latest.current.props; + // Full screen would cover the approval or question the agent waits + // on, or a screen the user moved to. + if ( + mode === "fullscreen" && + (latest.current.awaitingUser || !latest.current.navigation.isFocused()) + ) { + return current.displayMode ?? "inline"; + } + if (mode === "fullscreen" && current.displayMode !== "fullscreen") { + // The inline view is torn down by the switch (this row unmounts + // while the modal covers it); the modal opens a fresh view. + // The inline view steps aside for the modal; it gets its teardown first. + await hostRef.current?.teardown(); + // The wait may have let an approval arrive, or the user moved to + // another screen; either way the view reloads inline. + if (latest.current.awaitingUser || !latest.current.navigation.isFocused()) { + openNewDocument.current((value) => value + 1); + return "inline"; + } + setPresentedFullscreen(true); + latest.current.navigation.navigate("ThreadMcpApp", { + environmentId: String(current.environmentId), + threadId: String(current.threadId), + conversationThreadId: String(current.conversationThreadId), + itemId: String(current.itemId), + revision: current.revision, + }); + return "fullscreen"; + } + if (mode === "inline" && current.displayMode === "fullscreen") { + await hostRef.current?.teardown(); + current.onExitFullscreen?.(); + return "inline"; + } + return current.displayMode ?? "inline"; + }, + downloadFile: async (files) => { + const names = files.map((file) => file.name).join(", "); + if (!(await confirm(`Save a file from ${app.server}?`, names, "Save"))) { + throw new McpAppHostRefusal("Declined by the user."); + } + for (const file of files) { + // A linked file is read from the app's own server, like its other reads. + let bytes: Uint8Array | undefined; + let mimeType = file.mimeType ?? "application/octet-stream"; + if (file._tag === "embedded") { + bytes = file.bytes; + } else { + const { environmentId, input } = scope(); + const read = await latest.current.readResource({ + environmentId, + input: { ...input, uri: file.uri }, + }); + if (read._tag !== "Success") throw commandFailure(read); + const content = read.value.contents[0]; + bytes = mcpResourceBytes(content); + const declared = (content as { readonly mimeType?: unknown } | undefined)?.mimeType; + if (typeof declared === "string") mimeType = declared; + } + if (bytes === undefined) throw new McpAppHostRefusal(`${file.name} has no contents.`); + if (bytes.byteLength > MAX_DOWNLOAD_BYTES) { + throw new McpAppHostRefusal(`${file.name} is too large to save.`); + } + const shared = await shareGeneratedAttachment({ + bytes, + attachment: { name: file.name, mimeType }, + signal: new AbortController().signal, + }); + if (!shared) throw new McpAppHostRefusal("Sharing is not available on this device."); + } + }, + onRequestTeardown: () => { + const current = latest.current.props; + if (current.displayMode === "fullscreen") { + void hostRef.current?.teardown().then(() => current.onExitFullscreen?.()); + } else { + void hostRef.current?.teardown().then(() => setClosed(true)); + } + }, + // Both modes are fixed boxes, so the app's own height only decides + // whether it scrolls inside one. + onSizeChanged: () => undefined, + }); + hostRef.current = next; + return () => { + // An unmount cannot wait: the request goes out before the view does. + void next.teardown(); + hostRef.current = null; + }; + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A restarted view needs a new host. + }, [uri, app, generation, documentKey]); + + // The host reads the context through `latest`; these only say when to resend. + useEffect(() => { + hostRef.current?.updateHostContext(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Context changes trigger a resend. + }, [theme, props.width, props.height, insets, toolDefinition]); + // A new document gets a new host, which needs the call again. + useEffect(() => { + if (toolCall !== undefined) hostRef.current?.setToolCall(toolCall); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each new document needs the call. + }, [toolCall, uri, generation, documentKey]); + + const source = useMemo( + () => + uri === null + ? null + : { html: outerDocument(uri, mcpAppAllowAttribute(app.permissions), secret) }, + [uri, app.permissions, secret], + ); + + if (presentedFullscreen) { + return ( + + + The {app.server} app is open full screen + + + ); + } + + if (closed) { + return ( + + The {app.server} app was closed + { + setDocumentKey((value) => value + 1); + setClosed(false); + }} + > + Show app + + + ); + } + + return ( + + {navigatedAway ? ( + + + The {app.server} app left its page and was stopped + + + ) : uri !== null && !crashed ? ( + + key={`${documentKey}:${generation}`} + ref={webView} + onContentProcessDidTerminate={restart} + onRenderProcessGone={restart} + source={source!} + accessibilityLabel={`${app.server} app`} + style={{ flex: 1, backgroundColor: "transparent" }} + nestedScrollEnabled + originWhitelist={["*"]} + // Only the outer page loads at the top; the app opens links through + // the bridge. Android reports every navigation as top-frame, frames + // the app nests included, so it has no gate to apply: there the + // outer page never navigates, and the app frame's sandbox already + // forbids top navigation and popups. + onShouldStartLoadWithRequest={(request) => + Platform.OS === "android" || + request.isTopFrame === false || + request.url === "about:blank" + } + setSupportMultipleWindows={false} + onLoadEnd={() => setLoaded(true)} + // The outer page is inline HTML, so these report a broken view, not + // an app page; the row shows the failure instead of a blank box. + onError={() => setCrashed(true)} + onHttpError={() => setCrashed(true)} + onMessage={(event: WebViewMessageEvent) => { + let envelope: unknown; + try { + envelope = JSON.parse(event.nativeEvent.data); + } catch { + return; // Not JSON: not a bridge message. + } + if (!Predicate.isObject(envelope) || envelope.secret !== secret) return; + const message = envelope.message; + if (Predicate.isObject(message) && message.t3 === "navigated") { + hostRef.current?.dispose(); + setNavigatedAway(true); + return; + } + hostRef.current?.receive(message); + }} + /> + ) : asset._tag === "Failure" || crashed ? ( + + Unable to load the {app.server} app + + ) : null} + {uri !== null && !loaded && !crashed && !navigatedAway ? ( + + + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 4d81c6f07063..c18874b1cb03 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -188,6 +188,7 @@ import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size"; import { htmlRenderFrameHeight } from "@t3tools/shared/htmlRender"; import { htmlRenderRowHeight, ThreadHtmlRender } from "./HtmlRenderWebView"; +import { mcpAppRowHeight, ThreadMcpApp } from "./McpAppWebView"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { assetEnvironment, @@ -1597,6 +1598,20 @@ function renderFeedEntry( ); } + if (entry.type === "mcp-app") { + return ( + + ); + } + if (entry.type === "work-toggle") { return ( { + // An unavailable share sheet is a refusal for the caller, not a failure. + const sharing = await availableSharing(input.signal).catch(() => null); + if (sharing === null) return false; + const cached = await createCachedAttachmentFile(input.attachment); + try { + if (input.signal.aborted) return false; + cached.file.write(input.bytes); + await cached.preview.share(input.signal); + return true; + } finally { + cached.preview.dispose(); + } +} diff --git a/apps/mobile/src/lib/openExternalUrl.ts b/apps/mobile/src/lib/openExternalUrl.ts index 0963044b9ffb..f4feb41dcc66 100644 --- a/apps/mobile/src/lib/openExternalUrl.ts +++ b/apps/mobile/src/lib/openExternalUrl.ts @@ -7,6 +7,7 @@ const ExternalUrlTarget = Schema.Literals([ "pull-request", "provider-auth", "html-render", + "mcp-app", ]); export type ExternalUrlTarget = typeof ExternalUrlTarget.Type; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 1cf78cc29f3f..6743876a2fe7 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -975,6 +975,74 @@ describe("buildThreadFeed", () => { ).toBe(true); }); + it("keeps a settled run's still-running subagents visible while its other work folds", () => { + const subagent = ( + id: string, + updatedAt: string, + ordinal: number, + status: OrchestrationV2TurnItem["status"], + ): OrchestrationV2TurnItem => ({ + ...base(id, updatedAt, ordinal), + providerTurnId: ProviderTurnId.make("provider-turn-1"), + status, + completedAt: status === "running" ? null : DateTime.makeUnsafe(updatedAt), + type: "subagent", + subagentId: NodeId.make(id), + origin: "app_owned", + driver: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + childThreadId: sourceThreadId, + prompt: `Inspect ${id}`, + result: null, + }); + const latestRun = { + runId, + status: "completed" as const, + startedAt: "2026-06-20T00:00:01.000Z", + completedAt: "2026-06-20T00:00:05.000Z", + }; + const present = (children: ReadonlyArray) => + deriveThreadFeedPresentation( + buildThreadFeed([ + projected(userMessage(), 0), + projected(command("2026-06-20T00:00:02.000Z"), 1), + ...children.map((child, index) => projected(child, index + 2)), + projected(assistantMessage("2026-06-20T00:00:05.000Z"), 5), + ]), + latestRun, + new Set(), + ); + const visibleSubagentIds = (presented: ReadonlyArray) => + presented.flatMap((entry) => + entry.type === "activity-group" + ? entry.activities.flatMap((activity) => + activity.projectedItem.item.type === "subagent" + ? [activity.projectedItem.item.id] + : [], + ) + : [], + ); + + const live = present([subagent("item-live", "2026-06-20T00:00:03.000Z", 2, "running")]); + expect(live.map((entry) => entry.type)).toEqual([ + "message", + "run-fold", + "activity-group", + "message", + ]); + expect(visibleSubagentIds(live)).toEqual(["item-live"]); + + // A launch batch stays whole while any member is live. + const mixed = present([ + subagent("item-done", "2026-06-20T00:00:03.000Z", 2, "completed"), + subagent("item-live", "2026-06-20T00:00:04.000Z", 3, "running"), + ]); + expect(visibleSubagentIds(mixed)).toEqual(["item-done", "item-live"]); + + const finished = present([subagent("item-done", "2026-06-20T00:00:03.000Z", 2, "completed")]); + expect(finished.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]); + }); + it("folds settled V2 run work while keeping the terminal assistant message visible", () => { const feed = buildThreadFeed([ projected(userMessage(), 0), @@ -2443,3 +2511,35 @@ describe("html renders", () => { } }); }); + +describe("MCP apps", () => { + const app = { + attachmentId: "attachment-app", + server: "weather", + tool: "get_weather", + resourceUri: "ui://weather/dashboard", + }; + const appCall = (status: OrchestrationV2TurnItem["status"]): OrchestrationV2TurnItem => ({ + ...base("item-app", "2026-06-20T00:00:02.500Z", 2), + status, + type: "dynamic_tool", + toolName: "weather.get_weather", + input: { city: "Oslo" }, + output: { t3McpApp: app, result: { content: [] } }, + }); + const feed = (status: OrchestrationV2TurnItem["status"]) => + buildThreadFeed([ + projected(userMessage(), 0), + projected(appCall(status), 1), + projected(assistantMessage("2026-06-20T00:00:04.000Z"), 2), + ]); + + it("hosts a completed app in place of its tool row, owned by its source item", () => { + const entry = feed("completed").find((candidate) => candidate.type === "mcp-app"); + expect(entry).toMatchObject({ type: "mcp-app", app, itemId: "item-app", runId }); + }); + + it("keeps a running app call an ordinary work row", () => { + expect(feed("running").some((candidate) => candidate.type === "mcp-app")).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 927fd59b7dec..d88b1dba2ea2 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -5,8 +5,10 @@ import type { } from "@t3tools/client-runtime/state/thread-requests"; import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; +import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; import { + turnItemDetailRevision, turnItemHasDetail, turnItemNeedsDetailFetch, } from "@t3tools/client-runtime/work-log/item-detail"; @@ -47,6 +49,7 @@ import type { OrchestrationV2UserMessageInputIntent, RunAttemptId, ScheduledTaskId, + TurnItemId, } from "@t3tools/contracts"; import { RunId, ThreadId } from "@t3tools/contracts"; import { @@ -58,7 +61,12 @@ import { } from "@t3tools/shared/toolActivity"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import type { HtmlRenderReference } from "@t3tools/shared/htmlRender"; -import { compactDynamicToolOutput, htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; +import type { McpAppReference } from "@t3tools/shared/mcpApp"; +import { + compactDynamicToolOutput, + htmlRenderFromToolItem, + mcpAppFromToolItem, +} from "@t3tools/shared/toolOutput"; import * as DateTime from "effect/DateTime"; export type PendingApproval = ThreadPendingApproval; @@ -164,12 +172,24 @@ type RawThreadFeedEntry = readonly createdAt: string; readonly runId: RunId | null; readonly render: HtmlRenderReference; + } + | { + /** An MCP App a completed tool call captured, hosted in place of its work row. */ + readonly type: "mcp-app"; + readonly id: string; + readonly createdAt: string; + readonly runId: RunId | null; + /** The thread and item that own the app; a fork's inherited app is its source's. */ + readonly sourceThreadId: ThreadId; + readonly itemId: TurnItemId; + readonly revision: string; + readonly app: McpAppReference; }; export type ThreadFeedEntry = ThreadFeedEntryContent & { readonly continuesWorkLog?: boolean }; type ThreadFeedEntryContent = - | Extract + | Extract | { readonly type: "activity-group"; readonly id: string; @@ -1048,7 +1068,9 @@ function deriveThreadFeedRunFolds( const runId = entry.type === "message" && entry.message.role === "assistant" ? (entry.message.runId ?? runlessKey) - : entry.type === "activity-group" || entry.type === "html-render" + : entry.type === "activity-group" || + entry.type === "html-render" || + entry.type === "mcp-app" ? (entry.runId ?? runlessKey) : null; if (!runId) continue; @@ -1101,13 +1123,17 @@ function deriveThreadFeedRunFolds( entry.id !== firstAssistantId && entry.id !== terminalAssistantId && entry.type !== "html-render" && + entry.type !== "mcp-app" && !( entry.type === "activity-group" && entry.activities.some( (activity) => activity.prominent || activity.projectedItem.item.type === "notification" || - activity.projectedItem.item.type === "handoff", + activity.projectedItem.item.type === "handoff" || + // A child still working outlives its settled launching run; + // its group stays visible while any member is live. + isLiveSubagentTurnItem(activity.projectedItem.item), ) ), ) @@ -1746,6 +1772,25 @@ export function buildThreadFeed( entries.push(entry); continue; } + const app = + item.type === "dynamic_tool" && item.status === "completed" + ? mcpAppFromToolItem(item) + : undefined; + if (app) { + const entry: RawThreadFeedEntry = { + type: "mcp-app", + id: `mcp-app:${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`, + createdAt, + runId: item.runId, + sourceThreadId: row.sourceThreadId, + itemId: row.sourceItemId, + revision: turnItemDetailRevision(item), + app, + }; + projectedEntriesCache.set(row, { attemptId, entry }); + entries.push(entry); + continue; + } if (item.type === "user_message" || item.type === "assistant_message") { const updatedAt = DateTime.formatIso(item.updatedAt); const entry: RawThreadFeedEntry = { diff --git a/apps/mobile/src/state/mcpApps.ts b/apps/mobile/src/state/mcpApps.ts new file mode 100644 index 000000000000..84dafef26313 --- /dev/null +++ b/apps/mobile/src/state/mcpApps.ts @@ -0,0 +1,5 @@ +import { createMcpAppEnvironmentAtoms } from "@t3tools/client-runtime/state/mcp-apps"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const mcpAppEnvironment = createMcpAppEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/server/scripts/record-codex-app-server-replay-fixture.ts b/apps/server/scripts/record-codex-app-server-replay-fixture.ts index 936a7e8b4e06..4343aec87ba1 100644 --- a/apps/server/scripts/record-codex-app-server-replay-fixture.ts +++ b/apps/server/scripts/record-codex-app-server-replay-fixture.ts @@ -72,6 +72,7 @@ const CODEX_REPLAY_PLAN_MODE_DEVELOPER_INSTRUCTIONS = const CODEX_CLIENT_CAPABILITIES = { experimentalApi: true, optOutNotificationMethods: ["turn/diff/updated"], + extensions: { "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] } }, } as const; const CODEX_REPLAY_DEFAULT_MODEL = "gpt-6-luna"; const CODEX_REPLAY_MODEL_OVERRIDE = readArgValue("--model") ?? process.env.T3_CODEX_REPLAY_MODEL; diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index 1a952d1a6344..a8b7927b624d 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -243,4 +243,24 @@ describe("threadHtmlRenderAttachmentIds", () => { ]), ).toEqual([own]); }); + + it("includes captured MCP App documents from any tool", () => { + const app = createAttachmentId("thread-a", "html")!; + expect( + threadHtmlRenderAttachmentIds("thread-a", [ + { + toolName: "weather.get_weather", + output: { + t3McpApp: { + attachmentId: app, + server: "weather", + tool: "get_weather", + resourceUri: "ui://weather/dashboard", + }, + result: { content: [] }, + }, + }, + ]), + ).toEqual([app]); + }); }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index c959e8f3a2d0..0927b03761f7 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -4,7 +4,7 @@ import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import type { ChatAttachment } from "@t3tools/contracts"; -import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; +import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; import { normalizeAttachmentRelativePath, @@ -116,8 +116,9 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string } /** - * Attachments a thread's `html_render` tool calls published. Only ids minted - * for this thread count, so deleting a fork never removes its source's pages. + * Attachments a thread's tool calls published: `html_render` pages and + * captured MCP App documents. Only ids minted for this thread count, so + * deleting a fork never removes its source's pages. */ export function threadHtmlRenderAttachmentIds( threadId: string, @@ -126,7 +127,8 @@ export function threadHtmlRenderAttachmentIds( const segment = toSafeThreadAttachmentSegment(threadId); if (segment === null) return []; return Array.from(items).flatMap((item) => { - const attachmentId = htmlRenderFromToolItem(item)?.attachmentId; + const attachmentId = + htmlRenderFromToolItem(item)?.attachmentId ?? mcpAppFromToolItem(item)?.attachmentId; return attachmentId !== undefined && parseThreadSegmentFromAttachmentId(attachmentId) === segment ? [attachmentId] diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 82384b62ac20..a87b52b0d3c0 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -159,6 +159,11 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.voiceLearnCorrections]: AuthOrchestrationOperateScope, [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, + // An app's tool calls can change things on its server, like a user action. + [WS_METHODS.mcpAppsCallTool]: AuthOrchestrationOperateScope, + [WS_METHODS.mcpAppsToolInfo]: AuthOrchestrationReadScope, + [WS_METHODS.mcpAppsReadResource]: AuthOrchestrationReadScope, + [WS_METHODS.mcpAppsUpdateModelContext]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index adef6ab42afe..61dd000633ef 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1368,6 +1368,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { url: "https://github.com/pingdotgg/t3code/pull/216", baseRefName: "main", headRefName: "feature/saved-branch", + headRefOid: "a".repeat(40), state: "OPEN", updatedAt: "2026-04-03T15:00:00Z", }, @@ -1387,6 +1388,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { url: "https://github.com/pingdotgg/t3code/pull/216", baseRef: "main", headRef: "feature/saved-branch", + headSha: "a".repeat(40), state: "open", closedAt: null, mergedAt: null, @@ -1664,6 +1666,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { url: "https://github.com/pingdotgg/codething-mvp/pull/220", baseRefName: "main", headRefName: "feature/shared-pr-cache", + headRefOid: "a".repeat(40), state: "MERGED", updatedAt: "2026-04-07T15:00:00Z", }, @@ -1675,6 +1678,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { url: "https://github.com/pingdotgg/codething-mvp/pull/221", baseRefName: "main", headRefName: "feature/shared-pr-cache", + headRefOid: "b".repeat(40), state: "OPEN", updatedAt: "2026-04-08T15:00:00Z", }, @@ -1691,6 +1695,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(status.pr?.state).toBe("merged"); expect(pullRequest?.state).toBe("merged"); + expect(pullRequest?.headSha).toBe("a".repeat(40)); expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); const refreshed = yield* manager.branchPullRequest( { cwd: repoDir, branch: "feature/shared-pr-cache" }, @@ -1699,6 +1704,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(refreshed).toMatchObject({ number: 221, state: "open", + headSha: "b".repeat(40), repositoryKey: "github.com/pingdotgg/codething-mvp", }); expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 4f68f669fe6e..cbc0490a95c1 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -95,6 +95,8 @@ export type GitBranchPullRequest = NonNullable & { readonly updatedAt: string | null; readonly closedAt?: string | null; readonly mergedAt?: string | null; + /** The pull request's head commit, when the host read reports it. */ + readonly headSha?: string | null; }; interface SourceControlTextGenerationSettings { @@ -201,6 +203,7 @@ interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { closedAt?: string | null; mergedAt?: string | null; updatedAt: Option.Option; + headSha?: string | undefined; } const pullRequestUpdatedAtDescOrder: Order.Order = Order.mapInput( @@ -460,6 +463,7 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { url: summary.url, baseRefName: summary.baseRefName, headRefName: summary.headRefName, + ...(summary.headSha !== undefined ? { headSha: summary.headSha } : {}), state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), closedAt: summary.closedAt ?? null, @@ -2321,6 +2325,7 @@ export const make = Effect.gen(function* () { ...toStatusPr(latest), closedAt: latest.closedAt ?? null, mergedAt: latest.mergedAt ?? null, + headSha: latest.headSha ?? null, // Hosting CLIs can select an upstream repository instead of origin. // The returned PR URL names the repository that actually owns it. repositoryKey: pullRequestRepositoryKey(latest.url), diff --git a/apps/server/src/mcpApps/McpAppModelContext.test.ts b/apps/server/src/mcpApps/McpAppModelContext.test.ts new file mode 100644 index 000000000000..f5fd7f331456 --- /dev/null +++ b/apps/server/src/mcpApps/McpAppModelContext.test.ts @@ -0,0 +1,68 @@ +import { ThreadId, TurnItemId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { runMigrations } from "../persistence/Migrations.ts"; +import * as McpAppModelContext from "./McpAppModelContext.ts"; + +const database = NodeSqliteClient.layer({ filename: ":memory:" }); +const layer = it.layer( + Layer.merge(database, McpAppModelContext.layer.pipe(Layer.provide(database))), +); + +const threadId = ThreadId.make("thread-context"); +const forkId = ThreadId.make("thread-context-fork"); + +/** An app's tool call in the projection, in a run with the given status. */ +const insertAppItem = (itemId: string, runId: string, runStatus: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO orchestration_v2_projection_runs + (run_id, thread_id, ordinal, provider, status, requested_at, payload_json) + VALUES (${runId}, ${threadId}, ${runId.length}, 'codex', ${runStatus}, '2026-01-01', '{}') + `; + yield* sql` + INSERT INTO orchestration_v2_projection_turn_items + (turn_item_id, thread_id, run_id, ordinal, type, status, updated_at, payload_json) + VALUES (${itemId}, ${threadId}, ${runId}, 0, 'dynamic_tool', 'completed', '2026-01-01', '{}') + `; + }); + +layer("McpAppModelContext", (it) => { + it.effect("sends only apps still in the thread's history, including a fork's", () => + Effect.gen(function* () { + yield* runMigrations(); + const store = yield* McpAppModelContext.McpAppModelContext; + yield* insertAppItem("item-kept", "run-a", "completed"); + yield* insertAppItem("item-rolled-back", "run-bb", "rolled_back"); + const set = (thread: ThreadId, itemId: string, text: string) => + store.set({ + threadId: thread, + itemId: TurnItemId.make(itemId), + server: "todos", + tool: "list_todos", + text, + }); + yield* set(threadId, "item-kept", "kept"); + yield* set(threadId, "item-rolled-back", "from a rolled-back run"); + yield* set(threadId, "item-deleted", "from an item that is gone"); + // A fork showing the source thread's app keeps its own context, even + // after the source rolls that app's run back. + yield* set(forkId, "item-kept", "from the fork"); + yield* set(forkId, "item-rolled-back", "inherited before the rollback"); + + const texts = (thread: ThreadId) => + store.forThread(thread).pipe(Effect.map((entries) => entries.map((entry) => entry.text))); + assert.deepEqual(yield* texts(threadId), ["kept"]); + assert.deepEqual(yield* texts(forkId), ["from the fork", "inherited before the rollback"]); + + // Clearing removes the app's context. + yield* set(threadId, "item-kept", ""); + assert.deepEqual(yield* texts(threadId), []); + }), + ); +}); diff --git a/apps/server/src/mcpApps/McpAppModelContext.ts b/apps/server/src/mcpApps/McpAppModelContext.ts new file mode 100644 index 000000000000..dcdd5cd786bb --- /dev/null +++ b/apps/server/src/mcpApps/McpAppModelContext.ts @@ -0,0 +1,105 @@ +import type { ThreadId, TurnItemId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/sql/SqlClient"; + +/** Largest context one app may keep for the agent, in UTF-8 bytes. */ +export const MCP_APP_MODEL_CONTEXT_MAX_BYTES = 16 * 1024; + +export class McpAppModelContextError extends Schema.TaggedError()( + "McpAppModelContextError", + { cause: Schema.optional(Schema.Defect()) }, +) {} + +export interface McpAppModelContextEntry { + readonly itemId: string; + readonly server: string; + readonly tool: string; + readonly text: string; +} + +/** + * What each MCP App told the agent through `ui/update-model-context`. The spec + * replaces an app's context on every update, so there is one entry per app (its + * tool call); the thread's next turn carries the entries to the provider. + */ +export class McpAppModelContext extends Context.Service< + McpAppModelContext, + { + /** Replaces the app's context; empty text removes it. */ + readonly set: (input: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + readonly server: string; + readonly tool: string; + readonly text: string; + }) => Effect.Effect; + readonly forThread: ( + threadId: ThreadId, + ) => Effect.Effect, McpAppModelContextError>; + } +>()("t3/mcpApps/McpAppModelContext") {} + +export const layer = Layer.effect( + McpAppModelContext, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const fail = (cause: unknown) => new McpAppModelContextError({ cause }); + + const set = Effect.fn("McpAppModelContext.set")(function* (input: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + readonly server: string; + readonly tool: string; + readonly text: string; + }) { + if (input.text.trim() === "") { + yield* sql` + DELETE FROM mcp_app_model_context + WHERE thread_id = ${input.threadId} AND item_id = ${input.itemId} + `.pipe(Effect.mapError(fail)); + return; + } + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* sql` + INSERT INTO mcp_app_model_context (thread_id, item_id, server, tool, text, updated_at) + VALUES (${input.threadId}, ${input.itemId}, ${input.server}, ${input.tool}, ${input.text}, ${updatedAt}) + ON CONFLICT(thread_id, item_id) DO UPDATE SET text = excluded.text, updated_at = excluded.updated_at + `.pipe(Effect.mapError(fail)); + }); + + const forThread = Effect.fn("McpAppModelContext.forThread")(function* (threadId: ThreadId) { + // Only apps still in the thread's history count: an app whose item is + // gone, or whose run this thread rolled back, stops informing the agent. + // The item can live in a thread this one was forked from, so it is + // matched by id alone; that thread rolling the run back later leaves the + // fork's copy in place. + return yield* sql` + SELECT context.item_id AS "itemId", context.server, context.tool, context.text + FROM mcp_app_model_context AS context + JOIN orchestration_v2_projection_turn_items AS item + ON item.turn_item_id = context.item_id + LEFT JOIN orchestration_v2_projection_runs AS run + ON run.run_id = item.run_id + WHERE context.thread_id = ${threadId} + AND ( + run.status IS NULL + OR run.status <> 'rolled_back' + OR run.thread_id <> context.thread_id + ) + ORDER BY context.updated_at, context.item_id + `.pipe(Effect.mapError(fail)); + }); + + return McpAppModelContext.of({ set, forThread }); + }), +); + +/** No app context: for code paths and tests with no MCP Apps. */ +export const layerEmpty = Layer.succeed( + McpAppModelContext, + McpAppModelContext.of({ set: () => Effect.void, forThread: () => Effect.succeed([]) }), +); diff --git a/apps/server/src/mcpApps/McpAppRequests.test.ts b/apps/server/src/mcpApps/McpAppRequests.test.ts new file mode 100644 index 000000000000..1fdc8e0c50ca --- /dev/null +++ b/apps/server/src/mcpApps/McpAppRequests.test.ts @@ -0,0 +1,284 @@ +import { + ProviderSessionId, + ProviderThreadId, + ThreadId, + TurnItemId, + type McpAppRequestError, + type OrchestrationV2ProviderThread, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import type { + ProviderAdapterV2McpApps, + ProviderAdapterV2SessionRuntime, +} from "../orchestration-v2/ProviderAdapter.ts"; +import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as McpAppModelContext from "./McpAppModelContext.ts"; +import * as McpAppRequests from "./McpAppRequests.ts"; + +const threadId = ThreadId.make("thread-app"); +const itemId = TurnItemId.make("item-app"); +const providerThreadId = ProviderThreadId.make("provider-thread-app"); +const providerSessionId = ProviderSessionId.make("provider-session-app"); +const forkId = ThreadId.make("thread-fork"); +const unrelatedId = ThreadId.make("thread-unrelated"); +// A chain of forks deeper than any fixed walk limit: deep-70 → … → deep-1 → app. +const deepForkId = (depth: number) => ThreadId.make(`thread-deep-${depth}`); +const deepParent = (id: ThreadId): ThreadId | null => { + const match = /^thread-deep-(\d+)$/.exec(id); + if (match === null) return null; + const depth = Number(match[1]); + return depth === 1 ? threadId : deepForkId(depth - 1); +}; + +const appItem = (output: unknown): OrchestrationV2TurnItem => ({ + id: itemId, + threadId, + runId: null, + nodeId: null, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 0, + status: "completed", + title: null, + startedAt: null, + completedAt: null, + updatedAt: DateTime.makeUnsafe(0), + type: "dynamic_tool", + toolName: "weather.get_weather", + input: {}, + output, +}); + +const app = { + attachmentId: "thread-app-00000000-0000-0000-0000-000000000000-html", + server: "weather", + tool: "get_weather", + resourceUri: "ui://weather/dashboard", +}; + +/** Each app's stored context, keyed by item, as the real store keeps it. */ +const storedContext = new Map(); + +function makeLayer(input: { + readonly item: OrchestrationV2TurnItem | null; + readonly mcpApps?: ProviderAdapterV2McpApps; + readonly live?: boolean; +}) { + return McpAppRequests.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(McpAppModelContext.McpAppModelContext)({ + set: (entry) => + Effect.sync(() => { + const key = `${entry.threadId}/${entry.itemId}`; + if (entry.text.trim() === "") storedContext.delete(key); + else storedContext.set(key, entry.text); + }), + }), + Layer.mock(Orchestrator.OrchestratorV2)({ + getTurnItem: () => Effect.succeed(input.item), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (id: ThreadId) => + Effect.succeed({ + // A fork of the app's thread, and an unrelated thread. + thread: { + lineage: + deepParent(id) !== null + ? { + parentThreadId: deepParent(id), + relationshipToParent: "fork", + rootThreadId: threadId, + } + : id === forkId + ? { + parentThreadId: threadId, + relationshipToParent: "fork", + rootThreadId: threadId, + } + : { parentThreadId: null, relationshipToParent: null, rootThreadId: id }, + }, + providerThreads: [ + { id: providerThreadId, providerSessionId } as OrchestrationV2ProviderThread, + ], + } as never), + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + get: () => + Effect.succeed( + input.live === false + ? Option.none() + : Option.some( + (input.mcpApps === undefined + ? {} + : { mcpApps: input.mcpApps }) as ProviderAdapterV2SessionRuntime, + ), + ), + }), + ), + ), + ); +} + +const reason = (effect: Effect.Effect) => + effect.pipe( + Effect.flip, + Effect.map((error) => error.reason), + ); + +describe("McpAppRequests", () => { + const calls: Array<{ server: string; tool: string }> = []; + const mcpApps: ProviderAdapterV2McpApps = { + listTools: () => + Effect.succeed([ + { name: "refresh", annotations: { readOnlyHint: true } }, + { name: "model_only", _meta: { ui: { visibility: ["model"] } } }, + ]), + callTool: (input) => + Effect.sync(() => { + calls.push({ server: input.server, tool: input.tool }); + return { content: [{ type: "text", text: "ok" }] }; + }), + readResource: () => Effect.succeed({ contents: [] }), + }; + + it.effect("calls tools on the app's own server and reports their read-only hint", () => + Effect.gen(function* () { + const requests = yield* McpAppRequests.McpAppRequests; + const result = yield* requests.callTool({ + threadId, + itemId, + name: "refresh", + arguments: {}, + }); + assert.deepEqual(result, { content: [{ type: "text", text: "ok" }] }); + assert.deepEqual(calls.at(-1), { server: "weather", tool: "refresh" }); + assert.deepEqual(yield* requests.toolInfo({ threadId, itemId, name: "refresh" }), { + callable: true, + readOnly: true, + tool: { name: "refresh", annotations: { readOnlyHint: true } }, + }); + }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))), + ); + + it.effect("refuses tools hidden from apps or missing from the server", () => + Effect.gen(function* () { + const requests = yield* McpAppRequests.McpAppRequests; + const before = calls.length; + for (const name of ["model_only", "unknown"]) { + assert.equal( + yield* reason(requests.callTool({ threadId, itemId, name, arguments: {} })), + "tool-not-callable", + ); + } + assert.equal(calls.length, before); + }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))), + ); + + it.effect("keeps each app's latest model context, without a live session", () => + Effect.gen(function* () { + const requests = yield* McpAppRequests.McpAppRequests; + const own = { threadId, itemId, conversationThreadId: threadId }; + // The app's text is stored as sent, whitespace included. + yield* requests.updateModelContext({ + ...own, + content: [{ type: "text", text: " indented value " }], + }); + assert.equal(storedContext.get(`${threadId}/${itemId}`), " indented value "); + // An update replaces the app's context rather than adding to it. + yield* requests.updateModelContext({ + ...own, + content: [{ type: "text", text: "Filtered to overdue" }], + structuredContent: { filter: "overdue" }, + }); + assert.equal( + storedContext.get(`${threadId}/${itemId}`), + 'Filtered to overdue\n{"filter":"overdue"}', + ); + assert.equal( + yield* reason( + requests.updateModelContext({ ...own, content: [{ type: "image", data: "x" }] }), + ), + "unsupported-content", + ); + // The cap is in UTF-8 bytes: 6,000 three-byte characters exceed 16 KiB. + assert.equal( + yield* reason( + requests.updateModelContext({ + ...own, + content: [{ type: "text", text: "表".repeat(6000) }], + }), + ), + "request-failed", + ); + yield* requests.updateModelContext(own); + assert.isFalse(storedContext.has(`${threadId}/${itemId}`)); + }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), live: false }))), + ); + + it.effect("stores a fork's model context under the fork, and refuses unrelated threads", () => + Effect.gen(function* () { + const requests = yield* McpAppRequests.McpAppRequests; + const content = [{ type: "text", text: "Seen from the fork" }]; + yield* requests.updateModelContext({ + threadId, + itemId, + conversationThreadId: forkId, + content, + }); + assert.equal(storedContext.get(`${forkId}/${itemId}`), "Seen from the fork"); + assert.isFalse(storedContext.has(`${threadId}/${itemId}`)); + assert.equal( + yield* reason( + requests.updateModelContext({ + threadId, + itemId, + conversationThreadId: unrelatedId, + content, + }), + ), + "not-an-app", + ); + // However many forks deep, a descendant still counts. + yield* requests.updateModelContext({ + threadId, + itemId, + conversationThreadId: deepForkId(70), + content, + }); + assert.isTrue(storedContext.has(`${deepForkId(70)}/${itemId}`)); + }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), live: false }))), + ); + + it.effect("reports why a request cannot run", () => + Effect.gen(function* () { + const run = (layer: ReturnType) => + Effect.gen(function* () { + const requests = yield* McpAppRequests.McpAppRequests; + return yield* reason(requests.readResource({ threadId, itemId, uri: "ui://weather/x" })); + }).pipe(Effect.provide(layer)); + assert.equal( + yield* run(makeLayer({ item: appItem({ result: "plain" }), mcpApps })), + "not-an-app", + ); + assert.equal( + yield* run(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps, live: false })), + "session-stopped", + ); + assert.equal( + yield* run(makeLayer({ item: appItem({ t3McpApp: app }) })), + "provider-unsupported", + ); + }), + ); +}); diff --git a/apps/server/src/mcpApps/McpAppRequests.ts b/apps/server/src/mcpApps/McpAppRequests.ts new file mode 100644 index 000000000000..c95606ccd215 --- /dev/null +++ b/apps/server/src/mcpApps/McpAppRequests.ts @@ -0,0 +1,240 @@ +import { + McpAppRequestError, + type McpAppCallToolInput, + type McpAppCallToolResult, + type McpAppReadResourceInput, + type McpAppReadResourceResult, + type McpAppToolInfo, + type McpAppToolInfoInput, + type McpAppUpdateModelContextInput, + type OrchestrationV2AppThread, + type ThreadId, + type TurnItemId, +} from "@t3tools/contracts"; +import { mcpAppToolCallableByApp, type McpAppReference } from "@t3tools/shared/mcpApp"; +import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import type { + ProviderAdapterV2McpApps, + ProviderAdapterV2McpTool, +} from "../orchestration-v2/ProviderAdapter.ts"; +import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as McpAppModelContext from "./McpAppModelContext.ts"; + +/** + * Requests an MCP App makes of its own MCP server. The app is resolved from the + * tool call that produced it, so it reaches only that server, and each request + * runs through the thread's live provider session, whose MCP client owns the + * connection and its credentials. A stopped session is reported, never + * started: an app view must not spend a provider turn. + */ +export class McpAppRequests extends Context.Service< + McpAppRequests, + { + readonly callTool: ( + input: McpAppCallToolInput, + ) => Effect.Effect; + readonly toolInfo: ( + input: McpAppToolInfoInput, + ) => Effect.Effect; + readonly readResource: ( + input: McpAppReadResourceInput, + ) => Effect.Effect; + readonly updateModelContext: ( + input: McpAppUpdateModelContextInput, + ) => Effect.Effect; + } +>()("t3/mcpApps/McpAppRequests") {} + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const utf8 = new TextEncoder(); + +const readOnlyHint = (tool: ProviderAdapterV2McpTool) => + Predicate.isObject(tool.annotations) && tool.annotations.readOnlyHint === true; + +const toolTitle = (tool: ProviderAdapterV2McpTool) => + Predicate.isObject(tool.annotations) && typeof tool.annotations.title === "string" + ? tool.annotations.title + : undefined; + +const make = Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadManagement = yield* ThreadManagementService.ThreadManagementService; + const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; + const modelContext = yield* McpAppModelContext.McpAppModelContext; + + const fail = (threadId: ThreadId, reason: McpAppRequestError["reason"], cause?: unknown) => + new McpAppRequestError({ threadId, reason, ...(cause === undefined ? {} : { cause }) }); + + /** The app a tool call produced, read from the stored item. */ + const resolveApp = Effect.fn("McpAppRequests.resolveApp")(function* (input: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + }) { + // The stored item, not the wire projection, so the reference is intact. + const item = yield* orchestrator + .getTurnItem({ threadId: input.threadId, itemId: input.itemId }) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + const app: McpAppReference | undefined = + item?.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined; + if (item === null || app === undefined || item.providerThreadId === null) { + return yield* fail(input.threadId, "not-an-app"); + } + return { app, providerThreadId: item.providerThreadId }; + }); + + /** The app, its provider thread, and the live session's MCP Apps operations. */ + const resolve = Effect.fn("McpAppRequests.resolve")(function* (input: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + }) { + const { app, providerThreadId } = yield* resolveApp(input); + const projection = yield* threadManagement + .getThreadRecords(input.threadId, ["providerThreads"]) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + const providerThread = projection.providerThreads.find( + (candidate) => candidate.id === providerThreadId, + ); + if (providerThread?.providerSessionId == null) { + return yield* fail(input.threadId, "session-stopped"); + } + const runtime = Option.getOrUndefined( + yield* providerSessions + .get(providerThread.providerSessionId) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))), + ); + if (runtime === undefined) return yield* fail(input.threadId, "session-stopped"); + const mcpApps: ProviderAdapterV2McpApps | undefined = runtime.mcpApps; + if (mcpApps === undefined) return yield* fail(input.threadId, "provider-unsupported"); + return { app, providerThread, mcpApps }; + }); + + const findTool = Effect.fn("McpAppRequests.findTool")(function* ( + threadId: ThreadId, + resolved: Effect.Success>, + name: string, + ) { + const tools = yield* resolved.mcpApps + .listTools({ providerThread: resolved.providerThread, server: resolved.app.server }) + .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause))); + return tools.find((tool) => tool.name === name); + }); + + const toolInfo = Effect.fn("McpAppRequests.toolInfo")(function* (input: McpAppToolInfoInput) { + const resolved = yield* resolve(input); + const tool = yield* findTool(input.threadId, resolved, input.name); + const title = tool === undefined ? undefined : toolTitle(tool); + return { + callable: tool !== undefined && mcpAppToolCallableByApp(tool._meta), + readOnly: tool !== undefined && readOnlyHint(tool), + ...(title === undefined ? {} : { title }), + ...(tool === undefined ? {} : { tool }), + } satisfies McpAppToolInfo; + }); + + const callTool = Effect.fn("McpAppRequests.callTool")(function* (input: McpAppCallToolInput) { + const resolved = yield* resolve(input); + // The spec forbids apps calling tools hidden from them; checked here too, + // since a client's own check is only a courtesy to its user. + const tool = yield* findTool(input.threadId, resolved, input.name); + if (tool === undefined || !mcpAppToolCallableByApp(tool._meta)) { + return yield* fail(input.threadId, "tool-not-callable"); + } + return yield* resolved.mcpApps + .callTool({ + providerThread: resolved.providerThread, + server: resolved.app.server, + tool: input.name, + arguments: input.arguments, + }) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + }); + + const readResource = Effect.fn("McpAppRequests.readResource")(function* ( + input: McpAppReadResourceInput, + ) { + const resolved = yield* resolve(input); + return yield* resolved.mcpApps + .readResource({ + providerThread: resolved.providerThread, + server: resolved.app.server, + uri: input.uri, + }) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + }); + + // Context is state for the agent's next turn, so it needs the app but not a + // live session. Text blocks are kept as written and structured content as + // JSON; other content kinds are not declared by the host. + /** Whether `threadId` is `ancestorId` or forked from it, however many forks deep. */ + const descendsFrom = Effect.fn("McpAppRequests.descendsFrom")(function* ( + threadId: ThreadId, + ancestorId: ThreadId, + ) { + // Walks fork links until the chain ends; a thread seen twice means a + // corrupt cycle, which proves nothing. + const seen = new Set(); + let current: ThreadId | null = threadId; + while (current !== null && !seen.has(current)) { + if (current === ancestorId) return true; + seen.add(current); + const records: { readonly thread: OrchestrationV2AppThread } = yield* threadManagement + .getThreadRecords(current, []) + .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause))); + const { lineage } = records.thread; + current = lineage.relationshipToParent === "fork" ? lineage.parentThreadId : null; + } + return false; + }); + + const updateModelContext = Effect.fn("McpAppRequests.updateModelContext")(function* ( + input: McpAppUpdateModelContextInput, + ) { + const { app } = yield* resolveApp(input); + // Context belongs to the conversation on screen, which only shows this app + // when it is the app's own thread or one forked from it. + if (!(yield* descendsFrom(input.conversationThreadId, input.threadId))) { + return yield* fail(input.threadId, "not-an-app"); + } + const texts: Array = []; + for (const block of input.content ?? []) { + if (!Predicate.isObject(block) || block.type !== "text" || typeof block.text !== "string") { + return yield* fail(input.threadId, "unsupported-content"); + } + texts.push(block.text); + } + if (input.structuredContent !== undefined) { + texts.push(encodeJson(input.structuredContent)); + } + // Kept as sent; blank text clears the app's context in the store. + const text = texts.join("\n"); + if (utf8.encode(text).byteLength > McpAppModelContext.MCP_APP_MODEL_CONTEXT_MAX_BYTES) { + return yield* fail( + input.threadId, + "request-failed", + new Error("Model context is too large."), + ); + } + yield* modelContext + .set({ + threadId: input.conversationThreadId, + itemId: input.itemId, + server: app.server, + tool: app.tool, + text, + }) + .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + }); + + return McpAppRequests.of({ callTool, toolInfo, readResource, updateModelContext }); +}); + +export const layer = Layer.effect(McpAppRequests, make); diff --git a/apps/server/src/mcpApps/McpAppSnapshot.ts b/apps/server/src/mcpApps/McpAppSnapshot.ts new file mode 100644 index 000000000000..12a2a6378401 --- /dev/null +++ b/apps/server/src/mcpApps/McpAppSnapshot.ts @@ -0,0 +1,108 @@ +import type { ThreadId } from "@t3tools/contracts"; +import { + injectMcpAppCsp, + MCP_APP_MAX_HTML_BYTES, + MCP_APP_MIME_TYPE, + MCP_APP_RESOURCE_SCHEME, + readMcpAppCsp, + readMcpAppPermissions, + type McpAppReference, +} from "@t3tools/shared/mcpApp"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Predicate from "effect/Predicate"; + +import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; +import { createAttachmentId } from "../attachmentStore.ts"; + +const encoder = new TextEncoder(); + +/** + * The HTML document in a `resources/read` result, with the `_meta.ui` the + * resource declared. Only `text/html;profile=mcp-app` content counts; a blob is + * base64 per MCP. A null document is one too large to store. + */ +function readMcpAppDocument( + contents: ReadonlyArray, + uri: string, +): { readonly html: string | null; readonly meta: unknown } | undefined { + for (const content of contents) { + if (!Predicate.isObject(content)) continue; + if (content.uri !== undefined && content.uri !== uri) continue; + const mimeType = typeof content.mimeType === "string" ? content.mimeType : ""; + if (mimeType.replace(/\s+/g, "").toLowerCase() !== MCP_APP_MIME_TYPE) continue; + // Sizes are checked before decoding, so an oversized resource is + // refused without first being copied: a string's UTF-8 form is at least + // its length, and base64 decodes to three quarters of its own. + const html = + typeof content.text === "string" + ? content.text.length > MCP_APP_MAX_HTML_BYTES + ? null + : content.text + : typeof content.blob === "string" + ? content.blob.length > Math.ceil(MCP_APP_MAX_HTML_BYTES / 3) * 4 + ? null + : Buffer.from(content.blob, "base64").toString("utf8") + : undefined; + if (html === null) return { html: null, meta: undefined }; + if (html === undefined || html.trim() === "") continue; + const meta = Predicate.isObject(content._meta) ? content._meta.ui : undefined; + return { html, meta }; + } + return undefined; +} + +/** + * Stores an app's document as a thread attachment and returns the reference + * clients host it from. Saving a snapshot keeps the app viewable after the + * provider exits; interaction still goes through a live provider session. + * Undefined when the resource is not an MCP App document or is too large. + */ +export const snapshotMcpApp = Effect.fn("McpAppSnapshot.snapshot")(function* (input: { + readonly attachmentsDir: string; + readonly threadId: ThreadId; + readonly server: string; + readonly tool: string; + readonly resourceUri: string; + readonly contents: ReadonlyArray; +}) { + if (!input.resourceUri.startsWith(MCP_APP_RESOURCE_SCHEME)) return undefined; + const document = readMcpAppDocument(input.contents, input.resourceUri); + if (document === undefined) return undefined; + if (document.html === null || encoder.encode(document.html).byteLength > MCP_APP_MAX_HTML_BYTES) { + yield* Effect.logWarning("MCP app document exceeds the size limit.", { + server: input.server, + resourceUri: input.resourceUri, + }); + return undefined; + } + // The `-html` suffix makes the asset route serve it as a sandboxed document. + const attachmentId = createAttachmentId(input.threadId, "html"); + const filePath = + attachmentId === null + ? null + : resolveAttachmentRelativePath({ + attachmentsDir: input.attachmentsDir, + relativePath: `${attachmentId}.html`, + }); + if (attachmentId === null || filePath === null) return undefined; + const html = document.html; + const ui = Predicate.isObject(document.meta) ? document.meta : {}; + const csp = readMcpAppCsp(ui.csp); + const fileSystem = yield* FileSystem.FileSystem; + // Only the returned reference lets thread deletion find the document, so a + // write that fails or is interrupted removes it. + yield* fileSystem + .writeFileString(filePath, injectMcpAppCsp(html, csp)) + .pipe(Effect.onError(() => fileSystem.remove(filePath, { force: true }).pipe(Effect.ignore))); + const permissions = readMcpAppPermissions(ui.permissions); + return { + attachmentId, + server: input.server, + tool: input.tool, + resourceUri: input.resourceUri, + ...(csp === undefined ? {} : { csp }), + ...(permissions === undefined ? {} : { permissions }), + ...(typeof ui.prefersBorder === "boolean" ? { prefersBorder: ui.prefersBorder } : {}), + } satisfies McpAppReference; +}); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 6026c9b1d2dd..fd43df0b11b8 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -146,6 +146,10 @@ const RPC_AGGREGATES = { [WS_METHODS.attachmentsCreateUploadUrl]: "workspace", [WS_METHODS.attachmentsDelete]: "workspace", [WS_METHODS.providerUploadFeedback]: "provider", + [WS_METHODS.mcpAppsCallTool]: "provider", + [WS_METHODS.mcpAppsToolInfo]: "provider", + [WS_METHODS.mcpAppsUpdateModelContext]: "provider", + [WS_METHODS.mcpAppsReadResource]: "provider", [WS_METHODS.subscribeVcsStatus]: "vcs", [WS_METHODS.subscribeWorktreeSetup]: "vcs", [WS_METHODS.worktreeSetupCancel]: "vcs", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 785c4b1c9ec1..595fe28b1b9e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -44,6 +44,7 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { Tool } from "effect/ai"; import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeCompaction"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -2085,6 +2086,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly close?: (sdkMessages: Queue.Queue) => Effect.Effect; readonly interrupt?: Effect.Effect; readonly environment?: NodeJS.ProcessEnv; + // A CLI process opened after the first streams from its own queue, so the + // first one can exit (Queue.shutdown) and a later turn can start another. + readonly freshQueueOnReopen?: boolean; }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -2093,6 +2097,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { prefix: "t3-claude-v2-wake-", }); const sdkMessages = yield* Queue.unbounded(); + const processQueues: Array> = []; const processedMessages = new WeakMap>(); const offerAndWait = Effect.fnUntraced(function* (message: SDKMessage) { const processed = yield* Deferred.make(); @@ -2126,8 +2131,24 @@ describe("ClaudeAdapterV2 background wake turns", () => { queryRunner: { allocateSessionId: Effect.succeed(WAKE_NATIVE_SESSION), open: (input) => - Effect.sync(() => { + Effect.gen(function* () { openedOptions = input.options; + if (options?.freshQueueOnReopen === true && processQueues.length > 0) { + const processMessages = yield* Queue.unbounded(); + processQueues.push(processMessages); + return { + messages: Stream.fromQueue(processMessages), + offer: (message: SDKUserMessage) => + Effect.sync(() => { + offeredMessages.push(message); + }), + setModel: () => Effect.void, + setPermissionMode: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(processMessages), + }; + } + processQueues.push(sdkMessages); return { messages: Stream.fromQueue(sdkMessages).pipe( Stream.flatMap((message) => @@ -2205,8 +2226,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerThread, threadId, sdkMessages, + processQueues, offerAndWait, offeredMessages, + // The uuid Claude echoes on the turn answering the nth offered prompt. + promptUuid: (index: number) => { + const uuid = offeredMessages[index]?.uuid; + if (uuid === undefined) throw new Error(`No prompt offered at index ${index}.`); + return uuid; + }, permissionModeChanges, continuationRequests, events, @@ -4399,12 +4427,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { const continuationAttempt = RunAttemptId.make("attempt-claude-wake-callback-3"); const planToolUseId = "toolu_01WakePlanExitPlanMode"; const planMarkdown = "# Wake plan\n\n1. Report the background result."; - const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) { - return claudeSdkFrame({ + const stamp = (frame: SDKMessage, promptIndex: number) => + claudeSdkFrame({ ...frame, - user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId), + user_message_uuid: harness.promptUuid(promptIndex), }); - }); const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`); const planToolUse = claudeSdkFrame({ type: "assistant", @@ -4457,12 +4484,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { runtimePolicy, }), ); - yield* Queue.offer(harness.sdkMessages, yield* stamp(wakeTaskStarted, firstAttempt)); + yield* Queue.offer(harness.sdkMessages, stamp(wakeTaskStarted, 0)); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000740", result: "STARTED" }), - firstAttempt, + 0, ), ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); @@ -4519,22 +4546,22 @@ describe("ClaudeAdapterV2 background wake turns", () => { // The prompt's own turn follows and echoes its uuid. yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000743", text: "USER_REPLY", }), - userAttempt, + 1, ), ); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000744", result: "USER_REPLY", }), - userAttempt, + 1, ), ); yield* awaitUntil(() => harness.terminalEvents().length === 2, "user turn terminal"); @@ -4699,12 +4726,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { const firstAttempt = RunAttemptId.make("attempt-claude-wake-approval-1"); const userAttempt = RunAttemptId.make("attempt-claude-wake-approval-2"); const bashToolUseId = "toolu_01WakeApprovalBash"; - const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) { - return claudeSdkFrame({ + const stamp = (frame: SDKMessage, promptIndex: number) => + claudeSdkFrame({ ...frame, - user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId), + user_message_uuid: harness.promptUuid(promptIndex), }); - }); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -4719,16 +4745,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000780", text: "One." }), - firstAttempt, + 0, ), ); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000781", result: "One." }), - firstAttempt, + 0, ), ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); @@ -4823,12 +4849,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { const now = yield* DateTime.now; const firstAttempt = RunAttemptId.make("attempt-claude-echo-debris-1"); const secondAttempt = RunAttemptId.make("attempt-claude-echo-debris-2"); - const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) { - return claudeSdkFrame({ + const stamp = (frame: SDKMessage, promptIndex: number) => + claudeSdkFrame({ ...frame, - user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId), + user_message_uuid: harness.promptUuid(promptIndex), }); - }); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -4843,16 +4868,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { // The first turn echoes on its first frame: this process echoes early. yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000760", text: "One." }), - firstAttempt, + 0, ), ); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000761", result: "One." }), - firstAttempt, + 0, ), ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); @@ -4877,16 +4902,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.offer(harness.sdkMessages, staleTaskNotificationResult); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000762", text: "Two." }), - secondAttempt, + 1, ), ); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000763", result: "Two." }), - secondAttempt, + 1, ), ); yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal"); @@ -4922,12 +4947,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { const now = yield* DateTime.now; const firstAttempt = RunAttemptId.make("attempt-claude-echo-1"); const secondAttempt = RunAttemptId.make("attempt-claude-echo-2"); - const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) { - return claudeSdkFrame({ + const stamp = (frame: SDKMessage, promptIndex: number) => + claudeSdkFrame({ ...frame, - user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId), + user_message_uuid: harness.promptUuid(promptIndex), }); - }); const assistantTexts = () => harness.events.flatMap((event) => event.type === "message.updated" && event.message.role === "assistant" @@ -4947,22 +4971,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); - assert.equal( - harness.offeredMessages[0]?.uuid, - yield* ClaudeAdapterV2.claudePromptUuid(firstAttempt), - ); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000701", text: "One." }), - firstAttempt, + 0, ), ); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000702", result: "One." }), - firstAttempt, + 0, ), ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); @@ -4996,6 +5016,118 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("gives the same run attempt a fresh prompt uuid on every offer", () => + Effect.scoped( + Effect.gen(function* () { + // Two servers sharing a database copy allocate identical run attempt + // ids and resume the same Claude session. Claude acks a prompt whose + // uuid its transcript already holds without ever running a turn. + const original = yield* makeWakeHarness; + const copy = yield* makeWakeHarness; + const now = yield* DateTime.now; + for (const harness of [original, copy]) { + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-shared-copy"), + text: "Continue where you left off.", + attachments: [], + }), + ); + } + assert.notEqual(original.promptUuid(0), copy.promptUuid(0)); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect("fails a prompt Claude completes without starting a turn for it", () => + Effect.scoped( + Effect.gen(function* () { + // Claude answers a prompt whose uuid its transcript already holds with + // a lone completed lifecycle frame and never runs a turn for it. + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-duplicate-prompt"), + text: "Bump this PR to the latest main.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "command_lifecycle", + command_uuid: harness.promptUuid(0), + state: "completed", + uuid: "00000000-0000-4000-8000-000000000790", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + const [terminal] = harness.terminalEvents(); + assert.equal(terminal?.status, "failed"); + if (terminal?.status !== "failed") return; + assert.equal(terminal.threadDisposition, "reusable"); + assert.include(terminal.failure.message, "never started a turn"); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect("keeps a started prompt running until its result", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-started-prompt"), + text: "Bump this PR to the latest main.", + attachments: [], + }), + ); + const lifecycle = (state: string, uuid: string) => + claudeSdkFrame({ + type: "command_lifecycle", + command_uuid: harness.promptUuid(0), + state, + uuid, + session_id: WAKE_NATIVE_SESSION, + }); + yield* Queue.offer( + harness.sdkMessages, + lifecycle("queued", "00000000-0000-4000-8000-000000000791"), + ); + yield* Queue.offer( + harness.sdkMessages, + lifecycle("started", "00000000-0000-4000-8000-000000000792"), + ); + // The CLI can report the prompt completed before its turn's result. + yield* Queue.offer( + harness.sdkMessages, + lifecycle("completed", "00000000-0000-4000-8000-000000000793"), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000794", result: "Done." }), + user_message_uuid: harness.promptUuid(0), + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + it.effect("buffers wake output and requests a single continuation run", () => Effect.scoped( Effect.gen(function* () { @@ -6009,12 +6141,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { const userAttempt = RunAttemptId.make("attempt-claude-wake-subagent-2"); const continuationAttempt = RunAttemptId.make("attempt-claude-wake-subagent-3"); const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`); - const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) { - return claudeSdkFrame({ + const stamp = (frame: SDKMessage, promptIndex: number) => + claudeSdkFrame({ ...frame, - user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId), + user_message_uuid: harness.promptUuid(promptIndex), }); - }); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -6026,12 +6157,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); - yield* Queue.offer(harness.sdkMessages, yield* stamp(wakeTaskStarted, firstAttempt)); + yield* Queue.offer(harness.sdkMessages, stamp(wakeTaskStarted, 0)); yield* Queue.offer( harness.sdkMessages, - yield* stamp( + stamp( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000770", result: "STARTED" }), - firstAttempt, + 0, ), ); yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); @@ -6098,16 +6229,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { session_id: WAKE_NATIVE_SESSION, }), wakeResult, - yield* stamp( + stamp( makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000776", text: "USER_REPLY", }), - userAttempt, + 1, ), - yield* stamp( + stamp( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000777", result: "USER_REPLY" }), - userAttempt, + 1, ), ]; for (const frame of wakeFrames) { @@ -6261,150 +6392,1240 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("releases held frames before the notification that first names the tool use", () => + it.effect("streams a background subagent's work while the root turn is idle", () => Effect.scoped( Effect.gen(function* () { - const TASK_ID = "task-late-tool-use"; - const TOOL_USE_ID = "toolu-late-tool-use"; + const TASK_ID = "task-live-idle"; + const TOOL_USE_ID = "toolu-live-idle"; + const STRADDLING_BASH = "toolu-live-straddling-bash"; + const IDLE_BASH = "toolu-live-idle-bash"; const harness = yield* makeWakeHarness; const now = yield* DateTime.now; + const latestToolItem = (nativeId: string) => + harness.events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === nativeId, + ); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-late-tool-use"), - text: "Run an auditor.", + attemptId: RunAttemptId.make("attempt-claude-live-idle-1"), + text: "Audit in the background.", attachments: [], }), ); - const frames = [ - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: TASK_ID, - description: "Audit recent commits", - task_type: "local_agent", - prompt: "Audit the last five commits.", - uuid: "00000000-0000-4000-8000-000000000341", - session_id: WAKE_NATIVE_SESSION, + // The root settles while the subagent's first Bash call still runs. + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: TASK_ID, + toolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000951", }), - // task_started carried no tool_use_id, so these frames are held - // until the notification pairs the task with its tool use. The SDK - // types tool_use_id as optional; no recorded or logged task_started - // has omitted it, so this guards the typed contract only. ...makeSubagentAssistantFrames({ parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000342", - text: "Working.", + uuid: "00000000-0000-4000-8000-000000000952", + bashToolUseId: STRADDLING_BASH, }), - // The final answer arrives as one snapshot per text block. - ...makeSubagentAssistantFrames({ + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000953", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + + for (const frame of [ + makeSubagentToolResultFrame({ parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000344", - messageId: "msg_late_final", - text: "Part one.", + uuid: "00000000-0000-4000-8000-000000000954", + toolUseId: STRADDLING_BASH, }), ...makeSubagentAssistantFrames({ parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000345", - messageId: "msg_late_final", - text: "Part two.", + uuid: "00000000-0000-4000-8000-000000000955", + text: "Still auditing.", + bashToolUseId: IDLE_BASH, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: TASK_ID, + description: "Reading the last commits", + uuid: "00000000-0000-4000-8000-000000000956", + session_id: WAKE_NATIVE_SESSION, + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + // Before anything wakes the root, the idle work is already projected. + yield* awaitUntil(() => { + const event = latestToolItem(IDLE_BASH); + return event?.type === "turn_item.updated" && event.turnItem.status === "running"; + }, "idle Bash call"); + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.progress === "Reading the last commits", + ), + "idle progress", + ); + const routing = subagentRouting(harness.events, [STRADDLING_BASH, IDLE_BASH]); + assert.deepEqual(routing.assistantTexts(routing.childThreadId), ["Still auditing."]); + // The call that spans the turn end keeps the type and input its + // tool_use gave it during the open turn. + const straddling = latestToolItem(STRADDLING_BASH); + const straddlingItem = + straddling?.type === "turn_item.updated" ? straddling.turnItem : undefined; + assert.equal(straddlingItem?.status, "completed"); + assert.equal( + straddlingItem?.type === "command_execution" ? straddlingItem.input : undefined, + "git log -5", + ); + assert.equal(harness.continuationRequests.length, 0); + + for (const frame of [ + makeSubagentToolResultFrame({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000957", + toolUseId: IDLE_BASH, }), makeSubagentNotificationFrame({ taskId: TASK_ID, toolUseId: TOOL_USE_ID, - summary: "Part one.\n\nPart two.", - uuid: "00000000-0000-4000-8000-000000000346", + summary: "Still auditing.", + uuid: "00000000-0000-4000-8000-000000000958", }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); + yield* Queue.offer( + harness.sdkMessages, makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000347", + uuid: "00000000-0000-4000-8000-000000000959", result: "The auditor finished.", }), - ]; - for (const frame of frames) { - yield* Queue.offer(harness.sdkMessages, frame); - } - yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); - - const routing = subagentRouting(harness.events, []); - assert.deepEqual(routing.assistantTexts(routing.childThreadId), [ - "Working.", - "Part one.", - "Part two.", - ]); - assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]); - }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), - ), - ); - - it.effect("does not resolve a newer API retry when replaying a held subagent frame", () => - Effect.scoped( - Effect.gen(function* () { - const TOOL_USE_ID = "toolu-retry-subagent"; - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; + ); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now, - attemptId: RunAttemptId.make("attempt-claude-retry-subagent"), - text: "Run an auditor.", + attemptId: RunAttemptId.make("attempt-claude-live-idle-2"), + text: "Background task completed.", attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", }), ); - const frames = [ - ...makeSubagentAssistantFrames({ - parentToolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000351", - text: "Held before registration.", - }), - claudeSdkFrame({ - type: "system", - subtype: "api_retry", - attempt: 2, - max_retries: 10, - retry_delay_ms: 1_500, - error_status: 529, - error: "overloaded", - uuid: "00000000-0000-4000-8000-000000000352", - session_id: WAKE_NATIVE_SESSION, - }), - makeSubagentTaskStartedFrame({ - taskId: "task-retry-subagent", - toolUseId: TOOL_USE_ID, - uuid: "00000000-0000-4000-8000-000000000353", - }), - ]; - for (const frame of frames) { - yield* Queue.offer(harness.sdkMessages, frame); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + + const finalRouting = subagentRouting(harness.events, [STRADDLING_BASH, IDLE_BASH]); + assert.deepEqual(finalRouting.assistantTexts(finalRouting.childThreadId), [ + "Still auditing.", + ]); + for (const nativeId of [STRADDLING_BASH, IDLE_BASH]) { + assert.deepEqual( + [...(finalRouting.toolThreadIds.get(nativeId) ?? [])], + [finalRouting.childThreadId], + ); + const statuses = harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === nativeId + ? [`${event.turnItem.type}:${event.turnItem.status}`] + : [], + ); + assert.deepEqual(statuses, ["command_execution:running", "command_execution:completed"]); } - const childTexts = () => { - const routing = subagentRouting(harness.events, []); - return routing.childThreadId === undefined - ? [] - : routing.assistantTexts(routing.childThreadId); - }; - yield* awaitUntil(() => childTexts().length === 1, "replayed subagent text"); - const retryStatuses = harness.events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.type === "error" && - event.turnItem.retry !== undefined - ? [event.turnItem.status] - : [], + const finalSubagent = harness.events.findLast((event) => event.type === "subagent.updated"); + assert.equal( + finalSubagent?.type === "subagent.updated" && finalSubagent.subagent.status, + "completed", ); - assert.deepEqual(retryStatuses, ["running"]); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), ); - it.effect("holds subagent frames that precede task_started and shows its result once", () => + it.effect("keeps a nested subagent's launch in order while the root turn is idle", () => Effect.scoped( Effect.gen(function* () { - const TASK_ID = "task-early-subagent"; - const TOOL_USE_ID = "toolu-early-subagent"; - const FINAL_REPORT = "Early auditor done."; + const OUTER_TASK_ID = "task-idle-outer"; + const OUTER_TOOL_USE_ID = "toolu-idle-outer"; + const NESTED_TASK_ID = "task-idle-nested"; + const NESTED_TOOL_USE_ID = "toolu-idle-nested"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-idle-nested-1"), + text: "Audit in the background.", + attachments: [], + }), + ); + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000971", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000972", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + + // While the root is idle, the subagent runs a nested one to completion. + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_idle_nested_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: NESTED_TOOL_USE_ID, + name: "Agent", + input: { description: "Nested check", prompt: "Reply with NESTED_OK" }, + }, + ], + }, + parent_tool_use_id: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000973", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: NESTED_TASK_ID, + tool_use_id: NESTED_TOOL_USE_ID, + description: "Nested check", + subagent_type: "general-purpose", + is_backgrounded: false, + spawn_depth: 2, + task_type: "local_agent", + prompt: "Reply with NESTED_OK", + uuid: "00000000-0000-4000-8000-000000000974", + session_id: WAKE_NATIVE_SESSION, + }), + // The outer subagent's progress and own work don't wait for the + // nested start. + claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: OUTER_TASK_ID, + description: "Waiting on the nested check", + uuid: "00000000-0000-4000-8000-000000000984", + session_id: WAKE_NATIVE_SESSION, + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000988", + messageId: "msg_idle_outer_working", + text: "OUTER_WORKING", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil( + () => + harness.events.some( + (event) => event.type === "message.updated" && event.message.text === "OUTER_WORKING", + ), + "outer work while the nested start waits", + ); + assert.lengthOf(harness.continuationRequests, 0); + for (const frame of [ + ...makeSubagentAssistantFrames({ + parentToolUseId: NESTED_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000975", + text: "NESTED_OK", + }), + makeSubagentNotificationFrame({ + taskId: NESTED_TASK_ID, + toolUseId: NESTED_TOOL_USE_ID, + summary: "NESTED_OK", + uuid: "00000000-0000-4000-8000-000000000976", + }), + makeSubagentToolResultFrame({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000977", + toolUseId: NESTED_TOOL_USE_ID, + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000978", + text: "OUTER_DONE", + }), + makeSubagentNotificationFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + summary: "OUTER_DONE", + uuid: "00000000-0000-4000-8000-000000000979", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.continuationRequests.length === 1, "continuation request"); + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.progress === "Waiting on the nested check", + ), + "outer progress", + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000980", + result: "The audit finished.", + }), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-idle-nested-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + + // The nested Agent call is the nested subagent, never a plain tool row. + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === NESTED_TOOL_USE_ID, + ), + ); + const finalSubagents = new Map( + harness.events.flatMap((event) => + event.type === "subagent.updated" + ? [[event.subagent.nativeTaskRef?.nativeId, event.subagent] as const] + : [], + ), + ); + const outer = finalSubagents.get(OUTER_TASK_ID); + const nested = finalSubagents.get(NESTED_TASK_ID); + assert.equal(outer?.status, "completed"); + assert.equal(nested?.status, "completed"); + assert.equal(nested?.parentNodeId, outer?.id); + assert.deepEqual( + subagentRouting(harness.events, []).assistantTexts(outer?.childThreadId ?? undefined), + ["OUTER_WORKING", "OUTER_DONE"], + ); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect("ends an earlier turn's nested subagent while the root turn is idle", () => + Effect.scoped( + Effect.gen(function* () { + const OUTER_TASK_ID = "task-earlier-outer"; + const OUTER_TOOL_USE_ID = "toolu-earlier-outer"; + const NESTED_TASK_ID = "task-earlier-nested"; + const NESTED_TOOL_USE_ID = "toolu-earlier-nested"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const userTurn = (attemptId: string, providerTurnOrdinal: number) => + harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(attemptId), + text: "Keep auditing.", + attachments: [], + providerTurnOrdinal, + }), + ); + // Turn 1 launches the outer subagent, which starts a nested one. + yield* userTurn("attempt-claude-earlier-nested-1", 1); + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a01", + }), + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_earlier_nested_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: NESTED_TOOL_USE_ID, + name: "Agent", + input: { description: "Nested check", prompt: "Reply with NESTED_OK" }, + }, + ], + }, + parent_tool_use_id: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a02", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: NESTED_TASK_ID, + tool_use_id: NESTED_TOOL_USE_ID, + description: "Nested check", + subagent_type: "general-purpose", + is_backgrounded: false, + spawn_depth: 2, + task_type: "local_agent", + prompt: "Reply with NESTED_OK", + uuid: "00000000-0000-4000-8000-000000000a03", + session_id: WAKE_NATIVE_SESSION, + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000a04", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + // A later turn settles, so its context, which never saw the nested + // launch, is the one the root's idle frames go through. + yield* userTurn("attempt-claude-earlier-nested-2", 2); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a05", result: "Still on it." }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal"); + + // The nested subagent ends and its Agent call returns while the root + // is idle. The outer subagent's next message marks when both are handled. + for (const frame of [ + makeSubagentNotificationFrame({ + taskId: NESTED_TASK_ID, + toolUseId: NESTED_TOOL_USE_ID, + summary: "NESTED_OK", + uuid: "00000000-0000-4000-8000-000000000a06", + }), + makeSubagentToolResultFrame({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a07", + toolUseId: NESTED_TOOL_USE_ID, + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a08", + text: "OUTER_AFTER_NESTED", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil( + () => + harness.events.some( + (event) => + event.type === "message.updated" && event.message.text === "OUTER_AFTER_NESTED", + ), + "outer work after the nested call returns", + ); + + // The nested Agent call is the nested subagent, never a plain tool row. + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === NESTED_TOOL_USE_ID, + ), + ); + const nested = harness.events.findLast( + (event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === NESTED_TASK_ID, + ); + assert.equal(nested?.type === "subagent.updated" && nested.subagent.status, "completed"); + assert.lengthOf(harness.continuationRequests, 0); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect("ends a subagent's open call when the CLI exits while the root turn is idle", () => + Effect.scoped( + Effect.gen(function* () { + const TOOL_USE_ID = "toolu-idle-exit"; + const BASH = "toolu-idle-exit-bash"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const bashStatuses = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH + ? [`${event.turnItem.type}:${event.turnItem.status}`] + : [], + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-idle-exit"), + text: "Audit in the background.", + attachments: [], + }), + ); + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: "task-idle-exit", + toolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000981", + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000982", + bashToolUseId: BASH, + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000983", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + assert.deepEqual(bashStatuses(), ["command_execution:running"]); + + // The CLI exits before the call returns; its result can never arrive. + yield* Queue.shutdown(harness.sdkMessages); + yield* awaitUntil(() => bashStatuses().length === 2, "call ended"); + assert.deepEqual(bashStatuses(), ["command_execution:running", "command_execution:failed"]); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect("ends a subagent's open call when Stop's close of the CLI times out", () => + Effect.scoped( + Effect.gen(function* () { + const TOOL_USE_ID = "toolu-idle-close-timeout"; + const BASH = "toolu-idle-close-timeout-bash"; + // close() leaves the stream open, so Stop gives up waiting for it. + const harness = yield* makeWakeHarnessWithOptions(); + const now = yield* DateTime.now; + const bashStatuses = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH + ? [`${event.turnItem.type}:${event.turnItem.status}`] + : [], + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-idle-close-timeout"), + text: "Audit in the background.", + attachments: [], + }), + ); + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: "task-idle-close-timeout", + toolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000985", + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000986", + bashToolUseId: BASH, + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000987", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + assert.deepEqual(bashStatuses(), ["command_execution:running"]); + + const stop = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: harness.terminalEvents()[0]!.providerTurnId, + requestRuntimeRestart: true, + }) + .pipe(Effect.forkScoped); + yield* TestClock.adjust("10 seconds"); + yield* Fiber.join(stop); + assert.deepEqual(bashStatuses(), [ + "command_execution:running", + "command_execution:interrupted", + ]); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect.each(["before", "after"] as const)( + "keeps a subagent call's result when the CLI exits before the drain (result %s the nested launch's)", + (bashResultOrder) => + Effect.scoped( + Effect.gen(function* () { + const OUTER_TASK_ID = "task-exit-buffered-outer"; + const OUTER_TOOL_USE_ID = "toolu-exit-buffered-outer"; + const NESTED_TASK_ID = "task-exit-buffered-nested"; + const NESTED_TOOL_USE_ID = "toolu-exit-buffered-nested"; + const BASH = "toolu-exit-buffered-bash"; + const harness = yield* makeWakeHarnessWithOptions({ freshQueueOnReopen: true }); + const now = yield* DateTime.now; + const bashItems = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH + ? [ + `${event.turnItem.type}:${event.turnItem.status}:${JSON.stringify("input" in event.turnItem ? event.turnItem.input : null)}`, + ] + : [], + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-exit-buffered-1"), + text: "Audit in the background.", + attachments: [], + }), + ); + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001001", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001002", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + + // While the root is idle, the subagent starts Bash; it is projected at once. + for (const frame of makeSubagentAssistantFrames({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001003", + bashToolUseId: BASH, + })) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => bashItems().length === 1, "idle Bash call"); + + // A nested start buffers, and its launch result waits behind it. Bash's + // successful result arrives before or after that launch result. + const bashResultFrame = makeSubagentToolResultFrame({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001006", + toolUseId: BASH, + }); + for (const frame of [ + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_exit_buffered_nested_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: NESTED_TOOL_USE_ID, + name: "Agent", + input: { description: "Nested check", prompt: "Reply with NESTED_OK" }, + }, + ], + }, + parent_tool_use_id: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001004", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: NESTED_TASK_ID, + tool_use_id: NESTED_TOOL_USE_ID, + description: "Nested check", + subagent_type: "general-purpose", + is_backgrounded: false, + spawn_depth: 2, + task_type: "local_agent", + prompt: "Reply with NESTED_OK", + uuid: "00000000-0000-4000-8000-000000001005", + session_id: WAKE_NATIVE_SESSION, + }), + ...(bashResultOrder === "before" ? [bashResultFrame] : []), + ...makeSubagentAssistantFrames({ + parentToolUseId: NESTED_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001007", + text: "NESTED_OK", + }), + makeSubagentNotificationFrame({ + taskId: NESTED_TASK_ID, + toolUseId: NESTED_TOOL_USE_ID, + summary: "NESTED_OK", + uuid: "00000000-0000-4000-8000-000000001008", + }), + makeSubagentToolResultFrame({ + parentToolUseId: OUTER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001009", + toolUseId: NESTED_TOOL_USE_ID, + }), + ...(bashResultOrder === "after" ? [bashResultFrame] : []), + makeSubagentNotificationFrame({ + taskId: OUTER_TASK_ID, + toolUseId: OUTER_TOOL_USE_ID, + summary: "OUTER_DONE", + uuid: "00000000-0000-4000-8000-000000001010", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil( + () => harness.continuationRequests.length === 1, + "continuation request", + ); + + // The CLI exits before the continuation run drains the wake buffer. + yield* Queue.shutdown(harness.sdkMessages); + let quietYields = 0; + yield* awaitUntil(() => quietYields++ >= 50, "query exit"); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-exit-buffered-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + // The continuation opened a new CLI process: the first one's exit was handled. + assert.equal(harness.processQueues.length, 2); + yield* Queue.offer( + harness.processQueues[1]!, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001011", + result: "The audit finished.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + + const input = JSON.stringify("git log -5"); + assert.deepEqual(bashItems(), [ + `command_execution:running:${input}`, + `command_execution:completed:${input}`, + ]); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect( + "ends a subagent's SendMessage with its result when the CLI exits while the resume waits", + () => + Effect.scoped( + Effect.gen(function* () { + const SENDER_TASK_ID = "task-exit-resume-sender"; + const SENDER_TOOL_USE_ID = "toolu-exit-resume-sender"; + const RESUMED_TASK_ID = "task-exit-resume-target"; + const RESUMED_TOOL_USE_ID = "toolu-exit-resume-target"; + const SEND = "toolu-exit-resume-sendmessage"; + const SEND_INPUT = { to: RESUMED_TASK_ID, message: "Check the last commit again." }; + const harness = yield* makeWakeHarnessWithOptions({ freshQueueOnReopen: true }); + const now = yield* DateTime.now; + const sendItems = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === SEND + ? [ + `${event.turnItem.type}:${event.turnItem.status}:${JSON.stringify("input" in event.turnItem ? event.turnItem.input : null)}`, + ] + : [], + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-exit-resume-1"), + text: "Run two auditors in the background.", + attachments: [], + }), + ); + // The target subagent finishes inside the turn; the sender keeps running. + for (const frame of [ + makeSubagentTaskStartedFrame({ + taskId: SENDER_TASK_ID, + toolUseId: SENDER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001101", + }), + makeSubagentTaskStartedFrame({ + taskId: RESUMED_TASK_ID, + toolUseId: RESUMED_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001102", + }), + makeSubagentNotificationFrame({ + taskId: RESUMED_TASK_ID, + toolUseId: RESUMED_TOOL_USE_ID, + summary: "First answer.", + uuid: "00000000-0000-4000-8000-000000001103", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001104", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + + // While the root is idle, the sender resumes the finished subagent. + // Its SendMessage call is projected at once. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_exit_resume_sendmessage", + type: "message", + role: "assistant", + content: [{ type: "tool_use", id: SEND, name: "SendMessage", input: SEND_INPUT }], + }, + parent_tool_use_id: SENDER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001105", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* awaitUntil(() => sendItems().length >= 1, "idle SendMessage call"); + + // The resume's task_started buffers. The SendMessage result names that + // start's tool use, but its call already shows as a row, so it ends it. + for (const frame of [ + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: RESUMED_TASK_ID, + tool_use_id: SEND, + description: "Audit recent commits", + is_backgrounded: true, + task_type: "local_agent", + prompt: SEND_INPUT.message, + uuid: "00000000-0000-4000-8000-000000001106", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: SEND, content: "Message sent." }], + }, + parent_tool_use_id: SENDER_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000001107", + session_id: WAKE_NATIVE_SESSION, + }), + makeSubagentNotificationFrame({ + taskId: RESUMED_TASK_ID, + toolUseId: SEND, + summary: "Second answer.", + uuid: "00000000-0000-4000-8000-000000001108", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil( + () => harness.continuationRequests.length === 1, + "continuation request", + ); + const beforeExit = sendItems(); + + // The CLI exits before the continuation run drains the wake buffer. + yield* Queue.shutdown(harness.sdkMessages); + let quietYields = 0; + yield* awaitUntil(() => quietYields++ >= 50, "query exit"); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-exit-resume-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + // The continuation opened a new CLI process: the first one's exit was handled. + assert.equal(harness.processQueues.length, 2); + yield* Queue.offer( + harness.processQueues[1]!, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000001109", + result: "The audit finished.", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + + const input = JSON.stringify(SEND_INPUT); + assert.deepEqual(beforeExit, [`dynamic_tool:running:${input}`]); + assert.deepEqual(sendItems(), [ + `dynamic_tool:running:${input}`, + `dynamic_tool:completed:${input}`, + ]); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect("releases held frames before the notification that first names the tool use", () => + Effect.scoped( + Effect.gen(function* () { + const TASK_ID = "task-late-tool-use"; + const TOOL_USE_ID = "toolu-late-tool-use"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-late-tool-use"), + text: "Run an auditor.", + attachments: [], + }), + ); + const frames = [ + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: TASK_ID, + description: "Audit recent commits", + task_type: "local_agent", + prompt: "Audit the last five commits.", + uuid: "00000000-0000-4000-8000-000000000341", + session_id: WAKE_NATIVE_SESSION, + }), + // task_started carried no tool_use_id, so these frames are held + // until the notification pairs the task with its tool use. The SDK + // types tool_use_id as optional; no recorded or logged task_started + // has omitted it, so this guards the typed contract only. + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000342", + text: "Working.", + }), + // The final answer arrives as one snapshot per text block. + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000344", + messageId: "msg_late_final", + text: "Part one.", + }), + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000345", + messageId: "msg_late_final", + text: "Part two.", + }), + makeSubagentNotificationFrame({ + taskId: TASK_ID, + toolUseId: TOOL_USE_ID, + summary: "Part one.\n\nPart two.", + uuid: "00000000-0000-4000-8000-000000000346", + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000347", + result: "The auditor finished.", + }), + ]; + for (const frame of frames) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + + const routing = subagentRouting(harness.events, []); + assert.deepEqual(routing.assistantTexts(routing.childThreadId), [ + "Working.", + "Part one.", + "Part two.", + ]); + assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect.each([ + ["", "none"], + [" behind another subagent's start without one", "before"], + [" when another subagent's start without one arrives between its frames", "between"], + ] as const)( + "keeps a subagent's frames in order when a late progress names its tool use while the root is idle%s", + ([, otherStart]) => + Effect.scoped( + Effect.gen(function* () { + const TASK_ID = "task-late-owner-idle"; + const TOOL_USE_ID = "toolu-late-owner-idle"; + const BASH = "toolu-late-owner-idle-bash"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const bashStatuses = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.nativeItemRef?.nativeId === BASH + ? [`${event.turnItem.type}:${event.turnItem.status}`] + : [], + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-late-owner-idle-1"), + text: "Audit in the background.", + attachments: [], + }), + ); + // task_started without tool_use_id (optional in the SDK type), then + // the root settles. + for (const frame of [ + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: TASK_ID, + description: "Audit recent commits", + subagent_type: "general-purpose", + is_backgrounded: true, + task_type: "local_agent", + prompt: "Audit the last five commits.", + uuid: "00000000-0000-4000-8000-000000000a01", + session_id: WAKE_NATIVE_SESSION, + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000a02", + result: "Auditing in the background.", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "launch turn terminal"); + + // Another subagent starts while the root is idle, also without a + // tool_use_id, so its start waits in the wake buffer. + const otherStartFrame = claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: "task-late-owner-other", + description: "Check the changelog", + subagent_type: "general-purpose", + is_backgrounded: true, + task_type: "local_agent", + prompt: "Check the changelog.", + uuid: "00000000-0000-4000-8000-000000000a09", + session_id: WAKE_NATIVE_SESSION, + }); + const progressFrame = claudeSdkFrame({ + type: "system", + subtype: "task_progress", + task_id: TASK_ID, + tool_use_id: TOOL_USE_ID, + description: "Reading the last commits", + usage: { total_tokens: 1, tool_uses: 1, duration_ms: 1 }, + uuid: "00000000-0000-4000-8000-000000000a04", + session_id: WAKE_NATIVE_SESSION, + }); + const bashResultFrame = makeSubagentToolResultFrame({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a05", + toolUseId: BASH, + }); + // Root idle: the child's Bash call arrives before anything names the + // owner's tool use; progress names it before or after the call returns. + for (const frame of [ + ...(otherStart === "before" ? [otherStartFrame] : []), + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a03", + text: "First.", + bashToolUseId: BASH, + }), + ...(otherStart === "between" + ? [otherStartFrame, bashResultFrame, progressFrame] + : [progressFrame, bashResultFrame]), + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a06", + text: "Second.", + }), + makeSubagentNotificationFrame({ + taskId: TASK_ID, + toolUseId: TOOL_USE_ID, + summary: "Second.", + uuid: "00000000-0000-4000-8000-000000000a07", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil( + () => harness.continuationRequests.length === 1, + "continuation request", + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000a08", + result: "The auditor finished.", + }), + ); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-late-owner-idle-2"), + text: "Background task completed.", + attachments: [], + providerTurnOrdinal: 2, + messageCreatedBy: "agent", + messageCreationSource: "provider", + }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 2, "continuation terminal"); + + const routing = subagentRouting(harness.events, [BASH]); + const childTexts = routing.assistantTexts(routing.childThreadId); + const statuses = bashStatuses(); + // Observed order of child texts and Bash item updates, for diagnosis. + const trace = harness.events.flatMap((event) => + event.type === "message.updated" && + event.message.role === "assistant" && + event.message.threadId === routing.childThreadId + ? [`text:${event.message.text}#${event.message.id}`] + : event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === BASH + ? [`bash:${event.turnItem.status}`] + : [], + ); + assert.deepEqual(childTexts, ["First.", "Second."], `trace: ${JSON.stringify(trace)}`); + assert.deepEqual( + [...(routing.toolThreadIds.get(BASH) ?? [])], + [routing.childThreadId], + `trace: ${JSON.stringify(trace)}`, + ); + assert.deepEqual( + statuses, + ["command_execution:running", "command_execution:completed"], + `trace: ${JSON.stringify(trace)}`, + ); + // Progress that waited behind the subagent's frames still reaches its + // card, and asks for no continuation of its own. + assert.isTrue( + harness.events.some( + (event) => + event.type === "subagent.updated" && + event.subagent.progress === "Reading the last commits", + ), + ); + assert.lengthOf(harness.continuationRequests, 1); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect("does not resolve a newer API retry when replaying a held subagent frame", () => + Effect.scoped( + Effect.gen(function* () { + const TOOL_USE_ID = "toolu-retry-subagent"; + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-retry-subagent"), + text: "Run an auditor.", + attachments: [], + }), + ); + const frames = [ + ...makeSubagentAssistantFrames({ + parentToolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000351", + text: "Held before registration.", + }), + claudeSdkFrame({ + type: "system", + subtype: "api_retry", + attempt: 2, + max_retries: 10, + retry_delay_ms: 1_500, + error_status: 529, + error: "overloaded", + uuid: "00000000-0000-4000-8000-000000000352", + session_id: WAKE_NATIVE_SESSION, + }), + makeSubagentTaskStartedFrame({ + taskId: "task-retry-subagent", + toolUseId: TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000353", + }), + ]; + for (const frame of frames) { + yield* Queue.offer(harness.sdkMessages, frame); + } + const childTexts = () => { + const routing = subagentRouting(harness.events, []); + return routing.childThreadId === undefined + ? [] + : routing.assistantTexts(routing.childThreadId); + }; + yield* awaitUntil(() => childTexts().length === 1, "replayed subagent text"); + const retryStatuses = harness.events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "error" && + event.turnItem.retry !== undefined + ? [event.turnItem.status] + : [], + ); + assert.deepEqual(retryStatuses, ["running"]); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + + it.effect("holds subagent frames that precede task_started and shows its result once", () => + Effect.scoped( + Effect.gen(function* () { + const TASK_ID = "task-early-subagent"; + const TOOL_USE_ID = "toolu-early-subagent"; + const FINAL_REPORT = "Early auditor done."; const harness = yield* makeWakeHarness; const now = yield* DateTime.now; yield* harness.runtime.startTurn( @@ -6949,6 +8170,23 @@ describe("ClaudeAdapterV2 background wake turns", () => { tool_use_result: resumeAck, }), ); + // The resumed run's own work follows its task_started into the wake + // buffer: nothing ingests the child thread until the drain. + for (const frame of [ + ...makeSubagentAssistantFrames({ + parentToolUseId: SUBAGENT_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000961", + text: "RESUMED_WORK", + bashToolUseId: "toolu-resume-postsettle-bash", + }), + makeSubagentToolResultFrame({ + parentToolUseId: SUBAGENT_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000962", + toolUseId: "toolu-resume-postsettle-bash", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } yield* Queue.offer(harness.sdkMessages, secondNotification); yield* awaitUntil( () => harness.continuationRequests.length === 2, @@ -7010,6 +8248,19 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(finalSubagent?.result, SECOND_SUMMARY); // The completion keeps the resuming run's attribution. assert.equal(finalSubagent?.runId, "run-attempt-claude-wake-9c"); + // Its work is projected once, by the drain that re-opens it. + const reopenIndex = harness.events.findIndex( + (event) => + event.type === "subagent.updated" && + event.subagent.status === "running" && + event.subagent.runId === "run-attempt-claude-wake-9c", + ); + assert.isAtLeast(reopenIndex, 0); + const resumedWorkIndexes = harness.events.flatMap((event, index) => + event.type === "message.updated" && event.message.text === "RESUMED_WORK" ? [index] : [], + ); + assert.lengthOf(resumedWorkIndexes, 1); + assert.isAbove(resumedWorkIndexes[0] ?? -1, reopenIndex); assert.isFalse(yield* harness.hasPendingBackgroundWork); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), ), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index 2f73c0db11e6..3e70e3d9a8ff 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -18,6 +18,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -1577,11 +1578,13 @@ async function recordClaudeStreamingQuery(input: { }; try { for (const [index, prompt] of input.prompts.entries()) { - // Like the adapter, give each prompt a uuid Claude echoes on its turn. + // Like the adapter, give each prompt a fresh uuid Claude echoes on its turn. const message = ClaudeAdapterV2.makeClaudeUserMessage({ text: prompt, uuid: await Effect.runPromise( - ClaudeAdapterV2.claudePromptUuid(`${input.sessionId}:prompt:${index + 1}`).pipe( + Crypto.Crypto.pipe( + Effect.flatMap((crypto) => crypto.randomUUIDv4), + Effect.filterOrFail(ClaudeAdapterV2.isClaudePromptUuid), Effect.provide(NodeServices.layer), ), ), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 81f9abc001a3..26246b2038ba 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -71,7 +71,6 @@ import { import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Hex from "effect/encoding/Hex"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -1417,19 +1416,9 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: } satisfies SDKUserMessage; }); -// Stable per run attempt, so a replayed prompt offer matches its recording. -// Claude echoes it back as user_message_uuid on the turn that answers it. -export const claudePromptUuid = Effect.fn("claudePromptUuid")(function* (attemptId: string) { - const crypto = yield* Crypto.Crypto; - const digest = yield* crypto - .digest("SHA-256", new TextEncoder().encode(`t3-claude-prompt:${attemptId}`)) - .pipe(Effect.orDie); - const hex = Hex.encode(digest); - const variant = ((Number.parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16); - const uuid: NonNullable = - `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`; - return uuid; -}); +export function isClaudePromptUuid(value: string): value is NonNullable { + return /^[^-]+-[^-]+-[^-]+-[^-]+-[^-]+$/.test(value); +} type ClaudeAssistantContentBlock = SDKAssistantMessage["message"]["content"][number]; type ClaudeToolUseContentBlock = Extract< @@ -2477,14 +2466,17 @@ function claudeEchoedPromptUuids(message: SDKMessage): ReadonlyArray { return typeof uuid === "string" ? [uuid] : []; } -// The prompt uuid a command_lifecycle frame (queued, started, completed) +// The prompt a command_lifecycle frame (queued, started, completed) // acknowledges. A CLI that sends them also echoes that uuid on the result of // the turn answering the prompt; one that does not keeps the old path. -function claudeAcknowledgedPromptUuid(message: SDKMessage): string | null { +function claudePromptLifecycle( + message: SDKMessage, +): { readonly uuid: string; readonly state: string } | null { const type: unknown = Reflect.get(message, "type"); if (type !== "command_lifecycle") return null; const uuid: unknown = Reflect.get(message, "command_uuid"); - return typeof uuid === "string" ? uuid : null; + const state: unknown = Reflect.get(message, "state"); + return typeof uuid === "string" && typeof state === "string" ? { uuid, state } : null; } // A result that answers a turn other than the pending prompt's. An echo @@ -2709,6 +2701,7 @@ function formatClaudeUsageLimitWait(waitMs: number): string { interface ActiveClaudeTurnContext { readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; + readonly nativeThreadId: string; readonly nativeTurnId: string; nativeMessageCursor: string | null; readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; @@ -2744,12 +2737,13 @@ interface ActiveClaudeTurnContext { readonly subagentsByTaskId: Map; readonly subagentsByToolUseId: Map; readonly subagentNodesByTaskId: Map; - readonly pendingSubagentLaunchesByToolUseId: Map; // Set on turns that offered a prompt. Claude runs a wake turn it queued // for background work before the next prompt's turn, and only the // prompt's turn echoes this uuid (see handleSdkMessage). readonly promptUuid: string | null; promptEcho: "pending" | "confirmed"; + // Claude acknowledged starting the prompt (command_lifecycle "started"). + promptStarted: boolean; // Root frames seen before the echo; held only when the CLI echoes early. gatedFramesBeforeEcho: number; readonly heldRootFrames: Array; @@ -2856,12 +2850,12 @@ function rememberPendingClaudeSubagentLaunch( */ function rememberClaudeSubagentLaunch( context: ActiveClaudeTurnContext, + pending: Map, toolUseId: string, input: ClaudeNativeToolInput, ownerToolUseId: string | null, ): void { if (context.subagentsByToolUseId.has(toolUseId)) return; - const pending = context.pendingSubagentLaunchesByToolUseId; const requested = firstStringInputField(input, ["model"]); // "inherit" is the caller's model: the session's here, an owner's once // task_started resolves it. @@ -3129,6 +3123,18 @@ export function makeClaudeAdapterV2( // the turn that launched the subagent (wake drain, later turns) resolve // their subagent through this index into sessionSubagentsByTaskId. const sessionSubagentTaskIdsByToolUseId = yield* Ref.make(new Map()); + // A subagent's tool calls can outlive the turn that started them, so + // they live here rather than on a turn context: a turn that ends while + // one runs neither fails it nor hides it from the turn that follows. + const subagentToolCalls = new Map(); + // Launches seen before their task_started. A running subagent's Agent + // call can be projected while the root is idle and its task_started + // only in the continuation turn, so these outlive a turn too. + const pendingSubagentLaunchesByToolUseId = new Map(); + // The turn that settled last on each native thread. While the root is + // idle, a running subagent's frames are projected through it at once + // instead of waiting in the wake buffer for a continuation run. + const settledTurnByNativeThread = new Map(); // Subagent frames can precede the task_started that registers their // subagent (the same race rememberPendingClaudeSubagentLaunch covers). // They wait here and replay once task_started registers the owner. @@ -4025,7 +4031,7 @@ export function makeClaudeAdapterV2( // tracked tool call is a resume. if ( resume.toolUseId === undefined || - !resume.context.toolCalls.has(resume.toolUseId) || + findToolCall(resume.context, resume.toolUseId) === undefined || resume.context.subagentsByTaskId.has(resume.taskId) || (yield* Ref.get(sessionSubagentsByTaskId)).has(resume.taskId) ) { @@ -4146,6 +4152,23 @@ export function makeClaudeAdapterV2( ) { return; } + // A call the subagent left open never returns once it ends. It ends + // first, while the run that owns the child thread still takes its + // events. + if (existingSubagent !== undefined && input.status !== "running") { + const openCalls = [...subagentToolCalls.values()].filter( + (toolCall) => toolCall.threadId === existingSubagent.childThreadId, + ); + yield* endToolCalls({ + context: input.context, + toolCalls: openCalls, + status: input.status === "cancelled" ? "interrupted" : "failed", + completedAt: yield* DateTime.now, + }); + for (const toolCall of openCalls) { + subagentToolCalls.delete(toolCall.nativeItemId); + } + } // A task_started under a tool call other than the current run's is // a resume: SendMessage re-emits task_started for the same task id // under its own tool_use_id, with the sent message as the prompt. @@ -4647,6 +4670,12 @@ export function makeClaudeAdapterV2( return registered ?? context.subagentsByToolUseId.get(toolUseId); }); + const findToolCall = (context: ActiveClaudeTurnContext, nativeItemId: string) => + context.toolCalls.get(nativeItemId) ?? subagentToolCalls.get(nativeItemId); + // Only a subagent's calls have no run: they belong to its child thread. + const toolCallsFor = (context: ActiveClaudeTurnContext, toolCall: ActiveClaudeToolCall) => + toolCall.runId === null ? subagentToolCalls : context.toolCalls; + const ensureToolCallStarted = Effect.fnUntraced(function* (input: { readonly context: ActiveClaudeTurnContext; readonly nativeItemId: string; @@ -4655,7 +4684,7 @@ export function makeClaudeAdapterV2( readonly parentToolUseId: string | null; readonly presentation?: ClaudeToolPresentation | undefined; }) { - const existing = input.context.toolCalls.get(input.nativeItemId); + const existing = findToolCall(input.context, input.nativeItemId); if (existing !== undefined) { // The permission callback can start a call before its assistant // frame arrives with the tool's display name and icon. @@ -4663,7 +4692,7 @@ export function makeClaudeAdapterV2( return existing; } const presented = { ...existing, presentation: input.presentation }; - input.context.toolCalls.set(input.nativeItemId, presented); + toolCallsFor(input.context, presented).set(input.nativeItemId, presented); const updatedAt = yield* DateTime.now; yield* emitToolCallArtifacts( buildToolCallArtifacts({ @@ -4713,7 +4742,7 @@ export function makeClaudeAdapterV2( startedAt, ...(input.presentation === undefined ? {} : { presentation: input.presentation }), }; - input.context.toolCalls.set(input.nativeItemId, toolCall); + toolCallsFor(input.context, toolCall).set(input.nativeItemId, toolCall); yield* emitToolCallArtifacts( buildToolCallArtifacts({ context: input.context, @@ -4891,6 +4920,57 @@ export function makeClaudeAdapterV2( }), }); + // Ends open calls whose results will never arrive. + const endToolCalls = Effect.fnUntraced(function* (input: { + readonly context: ActiveClaudeTurnContext; + readonly toolCalls: ReadonlyArray; + readonly status: Extract< + OrchestrationV2ProviderTurn["status"], + "interrupted" | "failed" | "cancelled" + >; + readonly completedAt: DateTime.Utc; + }) { + for (const toolCall of input.toolCalls) { + yield* emitToolCallArtifacts( + buildToolCallArtifacts({ + context: input.context, + nativeItemId: toolCall.nativeItemId, + toolName: toolCall.toolName, + classification: toolCall.classification, + toolInput: toolCall.input, + threadId: toolCall.threadId, + runId: toolCall.runId, + rootNodeId: toolCall.rootNodeId, + parentNodeId: toolCall.parentNodeId, + ordinal: toolCall.ordinal, + output: NO_CLAUDE_NATIVE_TOOL_OUTPUT, + status: input.status, + startedAt: toolCall.startedAt, + updatedAt: input.completedAt, + presentation: toolCall.presentation, + }), + ); + } + }); + // Ends the calls a subagent kept open past the root's last turn, once + // the CLI process that ran them is gone. + const endSubagentCallsOfClosedQuery = Effect.fnUntraced(function* ( + nativeThreadId: string, + status: "interrupted" | "failed", + ) { + const settled = settledTurnByNativeThread.get(nativeThreadId); + if (settled === undefined || subagentToolCalls.size === 0) { + return; + } + yield* endToolCalls({ + context: settled, + toolCalls: [...subagentToolCalls.values()], + status, + completedAt: yield* DateTime.now, + }); + subagentToolCalls.clear(); + }); + const finalizeActiveTurn = Effect.fnUntraced(function* (input: { readonly context: ActiveClaudeTurnContext; readonly status: Extract< @@ -4903,29 +4983,34 @@ export function makeClaudeAdapterV2( readonly result?: SDKResultMessage; }) { yield* reasoningDeltas.flushTurn(input.context.nativeTurnId); - for (const toolCall of input.context.toolCalls.values()) { - const artifacts = buildToolCallArtifacts({ - context: input.context, - nativeItemId: toolCall.nativeItemId, - toolName: toolCall.toolName, - classification: toolCall.classification, - toolInput: toolCall.input, - threadId: toolCall.threadId, - runId: toolCall.runId, - rootNodeId: toolCall.rootNodeId, - parentNodeId: toolCall.parentNodeId, - ordinal: toolCall.ordinal, - output: NO_CLAUDE_NATIVE_TOOL_OUTPUT, - // A stopped turn cuts its open tools short; only a turn that - // ended on its own leaves them failed. - status: input.status === "completed" ? "failed" : input.status, - startedAt: toolCall.startedAt, - updatedAt: input.completedAt, - presentation: toolCall.presentation, - }); - yield* emitToolCallArtifacts(artifacts); - } + // A subagent still running in the background keeps its open calls: + // their results arrive after this turn. One left by an earlier CLI + // process is gone and never reports them. + const liveQuery = yield* Ref.get(queryContext); + const runningChildThreadIds = new Set( + [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].flatMap((subagent) => + subagent.task.status === "running" && + liveQuery?.subagentsFromEarlierProcesses.has(subagent) !== true + ? [subagent.childThreadId] + : [], + ), + ); + const endingSubagentCalls = [...subagentToolCalls.values()].filter( + (toolCall) => + input.status !== "completed" || !runningChildThreadIds.has(toolCall.threadId), + ); + yield* endToolCalls({ + context: input.context, + toolCalls: [...input.context.toolCalls.values(), ...endingSubagentCalls], + // A stopped turn cuts its open tools short; only a turn that + // ended on its own leaves them failed. + status: input.status === "completed" ? "failed" : input.status, + completedAt: input.completedAt, + }); input.context.toolCalls.clear(); + for (const toolCall of endingSubagentCalls) { + subagentToolCalls.delete(toolCall.nativeItemId); + } if ( input.context.assistant.emittedNativeItemIds.size === 0 && @@ -5132,6 +5217,11 @@ export function makeClaudeAdapterV2( yield* Ref.update(activeTurn, (current) => current?.providerTurnId === input.context.providerTurnId ? null : current, ); + if (input.status === "completed") { + settledTurnByNativeThread.set(input.context.nativeThreadId, input.context); + } else { + settledTurnByNativeThread.delete(input.context.nativeThreadId); + } yield* Ref.update(interruptedTurns, (current) => { const next = new Set(current); next.delete(input.context.providerTurnId); @@ -5539,6 +5629,84 @@ export function makeClaudeAdapterV2( return true; }); + // A background subagent keeps working after the root turn settles. Its + // own frames and progress reach its thread and card at once, through + // the turn that settled last; root output and wake evidence still wait + // in the wake buffer for a continuation run. + const settledTurnForSubagentFrame = Effect.fnUntraced(function* ( + nativeThreadId: string, + message: SDKMessage, + ) { + const settled = settledTurnByNativeThread.get(nativeThreadId); + if (settled === undefined) { + return null; + } + // The result of a call already shown as its row only ends that row, + // so it never waits. + const userResults = claudeToolResultBlocksFromUserMessage(message); + if ( + message.type === "user" && + userResults.length > 0 && + userResults.length === message.message.content.length && + userResults.every( + (toolResult) => findToolCall(settled, toolResult.tool_use_id) !== undefined, + ) + ) { + return settled; + } + // A subagent's task_started still in the wake buffer registers or + // re-opens it only when a continuation drains it, and only that run + // stores the work that follows. So that subagent's own frames, and + // the result of the Agent call that launched it, wait behind it. + const bufferedStarts = ( + (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? [] + ).flatMap((entry) => + entry.type === "system" && + entry.subtype === "task_started" && + !isClaudeNonSubagentTask(entry) + ? [entry] + : [], + ); + const parentToolUseId = parentToolUseIdFromSdkMessage(message); + const taskId = + message.type === "system" && message.subtype === "task_progress" + ? message.task_id + : parentToolUseId === null + ? undefined + : (yield* Ref.get(sessionSubagentTaskIdsByToolUseId)).get(parentToolUseId); + const returnedToolUseIds = new Set( + [...claudeToolResultBlocksFromAssistantMessage(message), ...userResults].map( + (toolResult) => toolResult.tool_use_id, + ), + ); + if ( + bufferedStarts.some( + (start) => + start.task_id === taskId || + (start.tool_use_id !== undefined && + (start.tool_use_id === parentToolUseId || + returnedToolUseIds.has(start.tool_use_id))), + ) + ) { + return null; + } + // A frame whose owner is named later, by progress, waits in the hold + // used during a turn, keyed by that owner, so its later frames queue + // behind it there and are released in order. + if (taskId === undefined) { + return parentToolUseId === null ? null : settled; + } + // Without the launching call's id, any result may be its launch. + if ( + returnedToolUseIds.size > 0 && + bufferedStarts.some((start) => start.tool_use_id === undefined) + ) { + return null; + } + const subagent = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId); + return subagent?.task.status === "running" ? settled : null; + }); + const handleSdkMessageFrame = Effect.fnUntraced(function* (input: { readonly query: ClaudeAgentSdkQuerySession; readonly message: SDKMessage; @@ -5662,7 +5830,9 @@ export function makeClaudeAdapterV2( } return; } - const context = yield* Ref.get(activeTurn); + const context = + (yield* Ref.get(activeTurn)) ?? + (yield* settledTurnForSubagentFrame(liveQuery.nativeThreadId, message)); if (context === null) { // task_notification must buffer wake evidence while still tracked // on the roster; clearing first would drop the wake pin. @@ -5958,7 +6128,7 @@ export function makeClaudeAdapterV2( const subagent = yield* resolveSubagentByToolUseId(context, parentToolUseId); if (subagent === undefined) { rememberPendingClaudeSubagentLaunch( - context.pendingSubagentLaunchesByToolUseId, + pendingSubagentLaunchesByToolUseId, parentToolUseId, { model }, ); @@ -6027,9 +6197,9 @@ export function makeClaudeAdapterV2( const launch = message.tool_use_id === undefined ? undefined - : context.pendingSubagentLaunchesByToolUseId.get(message.tool_use_id); + : pendingSubagentLaunchesByToolUseId.get(message.tool_use_id); if (message.tool_use_id !== undefined) { - context.pendingSubagentLaunchesByToolUseId.delete(message.tool_use_id); + pendingSubagentLaunchesByToolUseId.delete(message.tool_use_id); } const owner = launch?.ownerToolUseId === undefined @@ -6162,6 +6332,7 @@ export function makeClaudeAdapterV2( if (toolUse.name === "Agent") { rememberClaudeSubagentLaunch( context, + pendingSubagentLaunchesByToolUseId, toolUse.id, nativeToolInput, parentToolUseIdFromSdkMessage(message), @@ -6201,13 +6372,19 @@ export function makeClaudeAdapterV2( } for (const { toolResult, output } of claudeToolResultEntriesFromMessage(message)) { - const subagent = context.subagentsByToolUseId.get(toolResult.tool_use_id); + // Session scope: while the root is idle, this context is the turn + // that settled last, which never saw a subagent an earlier turn + // launched (a nested one whose Agent call returns now). + const subagent = yield* resolveSubagentByToolUseId(context, toolResult.tool_use_id); // A resume task_started reuses the resuming tool call's // tool_use_id (e.g. SendMessage), whose tool_result only // acknowledges delivery. Only the Agent launch's tool_result may // terminalize the subagent, and Agent tool_uses never enter // toolCalls (they project as subagent rows instead). - if (subagent !== undefined && !context.toolCalls.has(toolResult.tool_use_id)) { + if ( + subagent !== undefined && + findToolCall(context, toolResult.tool_use_id) === undefined + ) { // A background Agent launch resolves its tool_use immediately // with an async-launch ACK while the task keeps running; only // the eventual task_notification terminalizes the subagent. @@ -6226,7 +6403,7 @@ export function makeClaudeAdapterV2( } const parentToolUseId = parentToolUseIdFromSdkMessage(message); const toolCall = - context.toolCalls.get(toolResult.tool_use_id) ?? + findToolCall(context, toolResult.tool_use_id) ?? (yield* ensureToolCallStarted({ context, nativeItemId: toolResult.tool_use_id, @@ -6263,7 +6440,7 @@ export function makeClaudeAdapterV2( presentation: toolCall.presentation, }); yield* emitToolCallArtifacts(artifacts); - context.toolCalls.delete(toolCall.nativeItemId); + toolCallsFor(context, toolCall).delete(toolCall.nativeItemId); } const assistantParentToolUseId = parentToolUseIdFromSdkMessage(message); @@ -6618,11 +6795,32 @@ export function makeClaudeAdapterV2( yield* handleRoutedSdkMessage(input); return; } - if ( - liveQuery.promptEchoMode === "unknown" && - claudeAcknowledgedPromptUuid(message) === context.promptUuid - ) { - liveQuery.promptEchoMode = "acknowledged"; + const lifecycle = claudePromptLifecycle(message); + if (lifecycle?.uuid === context.promptUuid) { + if (liveQuery.promptEchoMode === "unknown") { + liveQuery.promptEchoMode = "acknowledged"; + } + if (lifecycle.state === "started") { + context.promptStarted = true; + } + // Claude finishes a prompt whose uuid its transcript already + // holds without starting it, and no turn will ever answer it. + if (lifecycle.state === "completed" && !context.promptStarted) { + yield* Effect.logWarning("orchestration-v2.claude-prompt-completed-without-turn", { + providerTurnId: context.providerTurnId, + promptUuid: context.promptUuid, + }); + yield* finalizeActiveTurn({ + context, + status: "failed", + completedAt: yield* DateTime.now, + failure: makeProviderFailure({ + class: "provider_error", + message: "Claude acknowledged the message but never started a turn for it.", + }), + }); + return; + } } if (!isClaudePromptEchoGatedFrame(message)) { // Frames the held turn produced through its own tool uses (a @@ -6730,7 +6928,13 @@ export function makeClaudeAdapterV2( // ExitPlanMode plan) in whichever run it is released to. const heldForEcho = context.heldRootFrames.length > 0; if (toolName === "Agent") { - rememberClaudeSubagentLaunch(context, nativeRequestId, nativeToolInput, null); + rememberClaudeSubagentLaunch( + context, + pendingSubagentLaunchesByToolUseId, + nativeRequestId, + nativeToolInput, + null, + ); } else if (!heldForEcho) { yield* ensureToolCallStarted({ context, @@ -7235,6 +7439,10 @@ export function makeClaudeAdapterV2( yield* finalizeActiveTurnAfterQueryExit( exit._tag === "Failure" ? exit.cause : undefined, ); + yield* endSubagentCallsOfClosedQuery( + context.nativeThreadId, + context.stopping ? "interrupted" : "failed", + ); } }), ), @@ -7249,10 +7457,22 @@ export function makeClaudeAdapterV2( const startedAt = yield* DateTime.now; const nativeThreadId = yield* getNativeThreadId(turnInput.providerThread); const nativeTurnId = `turn:${turnInput.attemptId}`; + // Fresh for every offer: Claude acks a prompt whose uuid its + // transcript already holds without running a turn, and a session + // can outlive this database (or be resumed from a copy of it), so + // a uuid derived from run ids can repeat. const promptUuid = isClaudeProviderContinuationTurn(turnInput) ? null - : yield* claudePromptUuid(turnInput.attemptId).pipe( - Effect.provideService(Crypto.Crypto, crypto), + : yield* crypto.randomUUIDv4.pipe( + Effect.filterOrFail(isClaudePromptUuid), + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Failed to allocate a Claude prompt uuid.", + payload: cause, + }), + ), ); const providerTurnId = idAllocator.derive.providerTurn({ driver: CLAUDE_PROVIDER, @@ -7280,6 +7500,7 @@ export function makeClaudeAdapterV2( } const context: ActiveClaudeTurnContext = { input: turnInput, + nativeThreadId, nativeTurnId, nativeMessageCursor: null, providerTurnId, @@ -7309,9 +7530,9 @@ export function makeClaudeAdapterV2( subagentsByTaskId: new Map(), subagentsByToolUseId: new Map(), subagentNodesByTaskId: new Map(), - pendingSubagentLaunchesByToolUseId: new Map(), promptUuid, promptEcho: isClaudeProviderContinuationTurn(turnInput) ? "confirmed" : "pending", + promptStarted: false, gatedFramesBeforeEcho: 0, heldRootFrames: [], }; @@ -7598,9 +7819,14 @@ export function makeClaudeAdapterV2( providerSessionId: input.providerSessionId, nativeThreadId, }); - yield* Ref.update(queryContext, (current) => - current?.query === existing.query ? null : current, + // Once ownership is dropped here, the old stream's exit skips the + // subagent's open calls, so they end now. + const ownedLiveQuery = yield* Ref.modify(queryContext, (current) => + current?.query === existing.query ? [true, null] : [false, current], ); + if (ownedLiveQuery) { + yield* endSubagentCallsOfClosedQuery(nativeThreadId, "interrupted"); + } yield* Deferred.succeed(existing.closed, undefined); }); yield* Effect.addFinalizer(() => closeSession()); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 63f0cbdae66b..602052f08ea7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -18,6 +18,7 @@ import { type OrchestrationV2AppThread, type OrchestrationV2ProviderThread, type OrchestrationV2ProviderTurn, + type OrchestrationV2TurnItem, ProjectId, ProviderInstanceId, ProviderSessionId, @@ -40,6 +41,8 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Predicate from "effect/Predicate"; import * as FileSystem from "effect/FileSystem"; +import { MCP_APP_OUTPUT_KEY, readMcpAppReference } from "@t3tools/shared/mcpApp"; +import { resolveAttachmentPathById } from "../../attachmentStore.ts"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -463,6 +466,38 @@ describe("CodexAdapterV2 runtime policy", () => { }), ); + it.effect("sends MCP app model context as untrusted Codex context", () => + Effect.gen(function* () { + const policy = { runtimeMode: "full-access", interactionMode: "default", cwd: null } as const; + const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; + const appContext = [{ key: "mcp_app_todos_list_todos_item-1", text: "Filtered to overdue" }]; + const alone = yield* CodexAdapterV2.buildCodexTurnStartParams({ + nativeThreadId: "native-app-context", + codexInput: [{ type: "text", text: "what's on my list?" }], + runtimePolicy: policy, + modelSelection, + appContext, + }); + assert.deepEqual(alone.additionalContext, { + "mcp_app_todos_list_todos_item-1": { kind: "untrusted", value: "Filtered to overdue" }, + }); + // Alongside T3's own context, both are kept. + const withT3 = yield* CodexAdapterV2.buildCodexTurnStartParams({ + nativeThreadId: "native-app-context", + codexInput: [{ type: "text", text: "what's on my list?" }], + runtimePolicy: policy, + modelSelection, + hasT3Mcp: true, + appContext, + }); + assert.deepEqual(withT3.additionalContext?.["mcp_app_todos_list_todos_item-1"], { + kind: "untrusted", + value: "Filtered to overdue", + }); + assert.isDefined(withT3.additionalContext?.["t3_code_runtime"]); + }), + ); + it.effect("adds default-mode developer instructions when the T3 MCP server is attached", () => Effect.gen(function* () { const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ @@ -1548,6 +1583,9 @@ function codexReplayPreamble(input: { capabilities: { experimentalApi: true, optOutNotificationMethods: ["turn/diff/updated"], + extensions: { + "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] }, + }, }, }, }, @@ -1972,6 +2010,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { runtime, providerThread, threadId, + serverConfig, events, continuationRequests, terminalEvents, @@ -2113,6 +2152,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { capabilities: { experimentalApi: true, optOutNotificationMethods: ["turn/diff/updated"], + extensions: { + "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] }, + }, }, }, ]); @@ -6305,6 +6347,253 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + const MCP_APP_SCENARIO = "codex-mcp-app-capture"; + const MCP_APP_NATIVE_THREAD = "native-codex-mcp-app-thread"; + const MCP_APP_NATIVE_TURN = "native-codex-mcp-app-turn"; + const MCP_APP_ITEM = "mcp-weather-call"; + const MCP_APP_PROMPT = "Show the weather in Oslo."; + const MCP_APP_RESOURCE = "ui://weather/dashboard"; + const MCP_APP_HTML = "

Weather

"; + const mcpAppToolItem = (status: "inProgress" | "completed") => ({ + type: "mcpToolCall", + id: MCP_APP_ITEM, + server: "weather", + tool: "get_weather", + status, + arguments: { city: "Oslo" }, + mcpAppResourceUri: MCP_APP_RESOURCE, + ...(status === "completed" + ? { result: { content: [{ type: "text", text: "Sunny" }], structuredContent: { temp: 21 } } } + : {}), + }); + + const mcpAppTranscript = makeCodexReplayTranscript({ + scenario: MCP_APP_SCENARIO, + entries: [ + ...codexReplayPreamble({ + nativeThreadId: MCP_APP_NATIVE_THREAD, + nativeTurnId: MCP_APP_NATIVE_TURN, + prompt: MCP_APP_PROMPT, + }), + { + type: "emit_inbound", + label: "item/started/app-tool", + frame: { + method: "item/started", + params: { + item: mcpAppToolItem("inProgress"), + threadId: MCP_APP_NATIVE_THREAD, + turnId: MCP_APP_NATIVE_TURN, + startedAtMs: 1782622440500, + }, + }, + }, + { + type: "emit_inbound", + label: "item/completed/app-tool", + frame: { + method: "item/completed", + params: { + item: mcpAppToolItem("completed"), + threadId: MCP_APP_NATIVE_THREAD, + turnId: MCP_APP_NATIVE_TURN, + completedAtMs: 1782622441500, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/completed", + frame: { + method: "turn/completed", + params: { + threadId: MCP_APP_NATIVE_THREAD, + turn: makeCodexReplayTurn({ id: MCP_APP_NATIVE_TURN, status: "completed" }), + }, + }, + }, + { + type: "expect_outbound", + label: "mcpServer/resource/read", + frame: { + id: 4, + method: "mcpServer/resource/read", + params: { threadId: MCP_APP_NATIVE_THREAD, server: "weather", uri: MCP_APP_RESOURCE }, + }, + }, + { + type: "emit_inbound", + label: "mcpServer/resource/read", + frame: { + id: 4, + result: { + contents: [ + { + uri: MCP_APP_RESOURCE, + mimeType: "text/html;profile=mcp-app", + text: MCP_APP_HTML, + _meta: { ui: { csp: { connectDomains: ["https://api.weather.test"] } } }, + }, + ], + }, + }, + }, + ], + }); + + it.effect("captures an MCP app's resource and holds the turn open until it lands", () => + Effect.scoped( + Effect.gen(function* () { + const appCaptured = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness(mcpAppTranscript, (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.status === "completed" + ? Deferred.succeed(appCaptured, event.turnItem) + : Effect.void, + ); + const now = yield* DateTime.now; + + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-codex-mcp-app"), + text: MCP_APP_PROMPT, + }), + ); + const item = yield* Deferred.await(appCaptured); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + + // The completed tool row is emitted once, with the app, never as a bare row first. + const completedRows = harness.events.filter( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.status === "completed", + ); + assert.lengthOf(completedRows, 1); + + assert.equal(item.type, "dynamic_tool"); + const output = item.type === "dynamic_tool" ? (item.output as Record) : {}; + const reference = readMcpAppReference(output[MCP_APP_OUTPUT_KEY]); + assert.deepEqual( + { ...reference, attachmentId: undefined }, + { + attachmentId: undefined, + server: "weather", + tool: "get_weather", + resourceUri: MCP_APP_RESOURCE, + csp: { connectDomains: ["https://api.weather.test"] }, + }, + ); + assert.deepEqual(output.result, { + content: [{ type: "text", text: "Sunny" }], + structuredContent: { temp: 21 }, + }); + + const fileSystem = yield* FileSystem.FileSystem; + const stored = yield* fileSystem.readFileString( + resolveAttachmentPathById({ + attachmentsDir: harness.serverConfig.attachmentsDir, + attachmentId: reference!.attachmentId, + })!, + ); + // Stored with the app's declared policy ahead of its scripts. + assert.include(stored, "connect-src https://api.weather.test"); + assert.isBelow(stored.indexOf("Content-Security-Policy"), stored.indexOf(""; + const injected = injectMcpAppCsp(html, undefined); + expect(injected.startsWith('")); + expect(injectMcpAppCsp("

hi

", undefined).startsWith(" { + it("drops entries that could widen or break the policy", () => { + expect( + readMcpAppCsp({ + connectDomains: [ + "https://ok.test", + "*", + "'self'", + "https://evil.test; script-src *", + "http://localhost:3000", + "javascript:alert(1)", + ], + resourceDomains: "https://not-an-array.test", + }), + ).toEqual({ connectDomains: ["https://ok.test", "http://localhost:3000"] }); + expect(readMcpAppCsp({ connectDomains: ["*"] })).toBeUndefined(); + }); +}); + +describe("readMcpAppReference", () => { + it("requires a ui:// resource and the owning server and tool", () => { + const reference = { + attachmentId: "thread-1-abc-html", + server: "weather", + tool: "get_weather", + resourceUri: "ui://weather/dashboard", + }; + expect(readMcpAppReference(reference)).toEqual(reference); + expect(readMcpAppReference({ ...reference, resourceUri: "https://x.test" })).toBeUndefined(); + expect(readMcpAppReference({ ...reference, server: "" })).toBeUndefined(); + }); +}); + +describe("tool metadata", () => { + it("reads the resource URI from either spelling and honors app visibility", () => { + expect(readMcpAppResourceUri({ ui: { resourceUri: "ui://a/b" } })).toBe("ui://a/b"); + expect(readMcpAppResourceUri({ "ui/resourceUri": "ui://a/c" })).toBe("ui://a/c"); + expect(readMcpAppResourceUri({ ui: { resourceUri: "https://a" } })).toBeUndefined(); + expect(mcpAppToolCallableByApp(undefined)).toBe(true); + expect(mcpAppToolCallableByApp({ ui: { visibility: ["model"] } })).toBe(false); + expect(mcpAppToolCallableByApp({ ui: { visibility: ["model", "app"] } })).toBe(true); + }); +}); + +describe("mcpAppFromToolItem", () => { + it("reads the app from both the stored output and its compact wire form", async () => { + const { compactDynamicToolOutput, mcpAppFromToolItem } = await import("./toolOutput.ts"); + const app = { + attachmentId: "thread-1-abc-html", + server: "weather", + tool: "get_weather", + resourceUri: "ui://weather/dashboard", + }; + const stored = { t3McpApp: app, result: { content: [] } }; + const toolName = "weather.get_weather"; + expect(mcpAppFromToolItem({ toolName, output: stored })).toEqual(app); + expect(mcpAppFromToolItem({ toolName, output: compactDynamicToolOutput(stored) })).toEqual(app); + }); + + it("ignores a reference naming a server or tool other than the item's own", async () => { + const { mcpAppFromToolItem } = await import("./toolOutput.ts"); + // A tool result shaped like an app reference, claiming another server. + const forged = { + t3McpApp: { + attachmentId: "thread-1-abc-html", + server: "bank", + tool: "transfer", + resourceUri: "ui://bank/app", + }, + }; + expect(mcpAppFromToolItem({ toolName: "evil.lookup", output: forged })).toBeUndefined(); + expect(mcpAppFromToolItem({ toolName: "bank.transfer", output: forged })).toBeDefined(); + }); + + it("keeps an app whose resource URI is longer than a name", async () => { + const { compactDynamicToolOutput } = await import("./toolOutput.ts"); + const resourceUri = `ui://weather/${"segment/".repeat(60)}dashboard`; + const output = compactDynamicToolOutput({ + t3McpApp: { + attachmentId: "thread-1-abc-html", + server: "weather", + tool: "get_weather", + resourceUri, + }, + }); + expect(output?.t3McpApp?.resourceUri).toBe(resourceUri); + }); + + it("keeps the compact app reference within the wire budget", async () => { + const { compactDynamicToolOutput } = await import("./toolOutput.ts"); + const domains = Array.from( + { length: 32 }, + (_, index) => `https://${"a".repeat(200)}${index}.test`, + ); + const output = compactDynamicToolOutput({ + t3McpApp: { + attachmentId: "thread-1-abc-html", + server: "weather", + tool: "get_weather", + resourceUri: "ui://weather/dashboard", + csp: { connectDomains: domains, resourceDomains: domains }, + }, + }); + expect(new TextEncoder().encode(JSON.stringify(output)).byteLength).toBeLessThanOrEqual(8_192); + expect(output?.t3McpApp?.server).toBe("weather"); + }); +}); diff --git a/packages/shared/src/mcpApp.ts b/packages/shared/src/mcpApp.ts new file mode 100644 index 000000000000..e3d43eb49aa1 --- /dev/null +++ b/packages/shared/src/mcpApp.ts @@ -0,0 +1,236 @@ +import * as Predicate from "effect/Predicate"; + +/** + * MCP Apps (https://github.com/modelcontextprotocol/ext-apps, spec 2026-01-26): + * an MCP tool can name a `ui://` HTML resource that hosts render as an + * interactive view of its result. T3 snapshots the resource when the tool call + * completes, stores it as a thread attachment, and records an `McpAppReference` + * in the tool item's output; clients host it in an opaque-origin frame and + * speak the spec's JSON-RPC bridge with it. + */ + +export const MCP_APP_PROTOCOL_VERSION = "2026-01-26"; +export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"; +/** The MCP extension id a client declares to receive UI resources. */ +export const MCP_APP_EXTENSION_ID = "io.modelcontextprotocol/ui"; +export const MCP_APP_RESOURCE_SCHEME = "ui://"; +/** Where T3 records the app reference inside a tool item's output object. */ +export const MCP_APP_OUTPUT_KEY = "t3McpApp"; + +const MCP_APP_MIN_HEIGHT = 80; +export const MCP_APP_DEFAULT_HEIGHT = 320; +export const MCP_APP_MAX_HEIGHT = 2000; +/** Largest app document T3 stores. */ +export const MCP_APP_MAX_HTML_BYTES = 5 * 1024 * 1024; +const MAX_DOMAINS = 32; +const MAX_DOMAIN_LENGTH = 256; +const MAX_NAME_LENGTH = 256; +// A `ui://` URI can carry a path and query; the compact wire budget still +// bounds the whole reference. +const MAX_RESOURCE_URI_LENGTH = 4096; + +/** `_meta.ui.csp` on a UI resource: origins the app needs, by kind. */ +export interface McpAppCsp { + readonly connectDomains?: ReadonlyArray; + readonly resourceDomains?: ReadonlyArray; + readonly frameDomains?: ReadonlyArray; + readonly baseUriDomains?: ReadonlyArray; +} + +/** `_meta.ui.permissions` on a UI resource. */ +export interface McpAppPermissions { + readonly camera?: Record; + readonly microphone?: Record; + readonly geolocation?: Record; + readonly clipboardWrite?: Record; +} + +/** What a tool item's output carries so clients can host the app. */ +export interface McpAppReference { + readonly attachmentId: string; + /** The MCP server the tool belongs to, as the provider names it. */ + readonly server: string; + readonly tool: string; + readonly resourceUri: string; + readonly csp?: McpAppCsp; + readonly permissions?: McpAppPermissions; + readonly prefersBorder?: boolean; +} + +// Only scheme-qualified origins, optionally with a leading `*.` subdomain +// wildcard, survive. Anything else (keywords, paths, quotes, whitespace) could +// widen or break the generated policy, so it is dropped. +const DOMAIN_PATTERN = /^(?:https?|wss?):\/\/(?:\*\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)*(?::\d{1,5})?$/i; + +function readDomains(value: unknown): ReadonlyArray | undefined { + if (!Array.isArray(value)) return undefined; + const domains = value + .filter( + (domain): domain is string => + typeof domain === "string" && + domain.length <= MAX_DOMAIN_LENGTH && + DOMAIN_PATTERN.test(domain), + ) + .slice(0, MAX_DOMAINS); + return domains.length === 0 ? undefined : domains; +} + +/** The well-formed part of a resource's declared `_meta.ui.csp`. */ +export function readMcpAppCsp(value: unknown): McpAppCsp | undefined { + if (!Predicate.isObject(value)) return undefined; + const csp: { + connectDomains?: ReadonlyArray; + resourceDomains?: ReadonlyArray; + frameDomains?: ReadonlyArray; + baseUriDomains?: ReadonlyArray; + } = {}; + for (const key of [ + "connectDomains", + "resourceDomains", + "frameDomains", + "baseUriDomains", + ] as const) { + const domains = readDomains(value[key]); + if (domains !== undefined) csp[key] = domains; + } + return Object.keys(csp).length === 0 ? undefined : csp; +} + +const PERMISSION_KEYS = ["camera", "microphone", "geolocation", "clipboardWrite"] as const; + +export function readMcpAppPermissions(value: unknown): McpAppPermissions | undefined { + if (!Predicate.isObject(value)) return undefined; + const permissions: Partial>> = {}; + for (const key of PERMISSION_KEYS) { + if (Predicate.isObject(value[key])) permissions[key] = {}; + } + return Object.keys(permissions).length === 0 ? undefined : permissions; +} + +const boundedName = (value: unknown) => + typeof value === "string" && value.trim() !== "" && value.length <= MAX_NAME_LENGTH + ? value + : undefined; + +export function readMcpAppReference(value: unknown): McpAppReference | undefined { + if (!Predicate.isObject(value)) return undefined; + const attachmentId = boundedName(value.attachmentId); + const server = boundedName(value.server); + const tool = boundedName(value.tool); + const resourceUri = + typeof value.resourceUri === "string" && value.resourceUri.length <= MAX_RESOURCE_URI_LENGTH + ? value.resourceUri + : undefined; + if ( + attachmentId === undefined || + server === undefined || + tool === undefined || + resourceUri === undefined || + !resourceUri.startsWith(MCP_APP_RESOURCE_SCHEME) + ) { + return undefined; + } + const csp = readMcpAppCsp(value.csp); + const permissions = readMcpAppPermissions(value.permissions); + return { + attachmentId, + server, + tool, + resourceUri, + ...(csp === undefined ? {} : { csp }), + ...(permissions === undefined ? {} : { permissions }), + ...(typeof value.prefersBorder === "boolean" ? { prefersBorder: value.prefersBorder } : {}), + }; +} + +export function mcpAppReferencesEqual(left: McpAppReference, right: McpAppReference): boolean { + return ( + left.attachmentId === right.attachmentId && + left.server === right.server && + left.tool === right.tool && + left.resourceUri === right.resourceUri + ); +} + +/** The tool's `_meta.ui.resourceUri` (or the deprecated flat key), if it names a UI resource. */ +export function readMcpAppResourceUri(meta: unknown): string | undefined { + if (!Predicate.isObject(meta)) return undefined; + const nested = Predicate.isObject(meta.ui) ? meta.ui.resourceUri : undefined; + const uri = nested ?? meta["ui/resourceUri"]; + return typeof uri === "string" && uri.startsWith(MCP_APP_RESOURCE_SCHEME) ? uri : undefined; +} + +/** + * Whether the tool's `_meta.ui.visibility` lets an app call it. The spec + * defaults to visible to both the model and apps. + */ +export function mcpAppToolCallableByApp(meta: unknown): boolean { + const visibility = + Predicate.isObject(meta) && Predicate.isObject(meta.ui) ? meta.ui.visibility : undefined; + return !Array.isArray(visibility) || visibility.includes("app"); +} + +/** + * The Content-Security-Policy for an app document, per the spec's + * construction: undeclared origins stay blocked, and nothing loads from the + * host's own origin ('self' would reach T3's API). + */ +export function mcpAppContentSecurityPolicy(csp: McpAppCsp | undefined): string { + const resources = csp?.resourceDomains?.join(" ") ?? ""; + const list = (domains: ReadonlyArray | undefined, fallback: string) => + domains === undefined || domains.length === 0 ? fallback : domains.join(" "); + return [ + "default-src 'none'", + `script-src 'unsafe-inline' ${resources}`.trim(), + `style-src 'unsafe-inline' ${resources}`.trim(), + `img-src data: blob: ${resources}`.trim(), + `font-src data: ${resources}`.trim(), + `media-src data: blob: ${resources}`.trim(), + `connect-src ${list(csp?.connectDomains, "'none'")}`, + `frame-src ${list(csp?.frameDomains, "'none'")}`, + "object-src 'none'", + `base-uri ${list(csp?.baseUriDomains, "'none'")}`, + "form-action 'none'", + ].join("; "); +} + +const escapeAttribute = (value: string) => + value.replace(/&/g, "&").replace(/"/g, """).replace(/`; + // After a leading doctype, so the document stays in standards mode; the + // parser moves the tag into the head it opens. + const doctype = /^\uFEFF?\s*]*>/i.exec(html); + return doctype + ? `${doctype[0]}${meta}${html.slice(doctype[0].length)}` + : `${meta}${html}`; +} + +/** The iframe `allow` attribute for the permissions an app declared. */ +export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string { + const features: Array = []; + if (permissions?.camera) features.push("camera"); + if (permissions?.microphone) features.push("microphone"); + if (permissions?.geolocation) features.push("geolocation"); + if (permissions?.clipboardWrite) features.push("clipboard-write"); + return features.join("; "); +} + +export function clampMcpAppHeight(height: number): number { + if (!Number.isFinite(height)) return MCP_APP_DEFAULT_HEIGHT; + return Math.round(Math.min(MCP_APP_MAX_HEIGHT, Math.max(MCP_APP_MIN_HEIGHT, height))); +} + +export function mcpAppFileName(reference: McpAppReference): string { + const name = reference.tool.replace(/[^\w.-]+/g, "-").slice(0, 80) || "app"; + return `${name}.html`; +} diff --git a/packages/shared/src/toolOutput.ts b/packages/shared/src/toolOutput.ts index a771e0159ccc..5cfb1463aaea 100644 --- a/packages/shared/src/toolOutput.ts +++ b/packages/shared/src/toolOutput.ts @@ -9,6 +9,7 @@ import { readHtmlRenderReference, type HtmlRenderReference, } from "./htmlRender.ts"; +import { MCP_APP_OUTPUT_KEY, readMcpAppReference, type McpAppReference } from "./mcpApp.ts"; import { resolveT3McpToolId } from "./t3McpToolPresentation.ts"; const MAX_PARSED_BYTES = 16_384; @@ -39,6 +40,7 @@ interface CompactToolOutput { scheduledTaskId?: string; status?: "rolled_back"; htmlRender?: HtmlRenderReference; + [MCP_APP_OUTPUT_KEY]?: McpAppReference; thread?: { threadId: string }; threads?: Array<{ threadId?: string; status?: "rolled_back" }>; } @@ -124,6 +126,9 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un if (data.status === "rolled_back") output.status = "rolled_back"; const htmlRender = readHtmlRenderReference(data.htmlRender); if (htmlRender !== undefined) output.htmlRender = htmlRender; + const mcpApp = readMcpAppReference(data[MCP_APP_OUTPUT_KEY]); + // Kept under its stored key, so the compact wire output reads back the same way. + if (mcpApp !== undefined) output[MCP_APP_OUTPUT_KEY] = mcpApp; const nestedThreadId = Predicate.isObject(data.thread) ? boundedId(data.thread.threadId) : undefined; @@ -159,11 +164,21 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un } } } - if (encoder.encode(JSON.stringify(output)).byteLength > MAX_METADATA_BYTES) { + const oversized = () => encoder.encode(JSON.stringify(output)).byteLength > MAX_METADATA_BYTES; + if (oversized()) { delete output.threads; delete output.threadId; delete output.status; } + // An app's declared origins are the only unbounded-ish part of its + // reference; hosting works without them (the stored document carries its + // own policy), so they go before the app does. + const app = output[MCP_APP_OUTPUT_KEY]; + if (app?.csp !== undefined && oversized()) { + const { csp: _csp, ...rest } = app; + output[MCP_APP_OUTPUT_KEY] = rest; + } + if (oversized()) delete output[MCP_APP_OUTPUT_KEY]; return Object.keys(output).length === 0 ? undefined : output; } @@ -177,6 +192,21 @@ export function htmlRenderFromToolItem(item: { return output?.isError ? undefined : output?.htmlRender; } +/** + * The MCP App a completed tool call carries, if any. The adapter that captured + * it put the reference in the output, beside the tool's own result. A tool's + * result can imitate that shape, so the reference only counts when it names + * the very server and tool the item records: a server can then only ever + * point at an app of its own. + */ +export function mcpAppFromToolItem(item: { + readonly toolName: string | null | undefined; + readonly output?: unknown; +}): McpAppReference | undefined { + const app = compactDynamicToolOutput(item.output)?.[MCP_APP_OUTPUT_KEY]; + return app !== undefined && item.toolName === `${app.server}.${app.tool}` ? app : undefined; +} + /** Some providers report completion even when command output describes a failure. */ export function toolOutputIndicatesFailure(text: string): boolean { return (