| author | |
| committer | |
| log | 4d976d1fc233d3aa971fa7dcbf2e7316bfa3ac72 |
| tree | e6bff08450862016a740e7b642e4230806b548c7 |
| parent | 6ecb82dc42c4c721fd2c03c92316ee5361306cdb |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>83 files changed, 4990 insertions(+), 55 deletions(-)
apps/mobile/src/Stack.tsx+10| ... | @@ -51,6 +51,7 @@ import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet"; | ... | @@ -51,6 +51,7 @@ import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet"; |
| 51 | import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet"; | 51 | import { ThreadAgentsSheet } from "./features/threads/ThreadAgentsSheet"; |
| 52 | import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl"; | 52 | import { ThreadQueueSheet } from "./features/threads/ThreadQueueControl"; |
| 53 | import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen"; | 53 | import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen"; |
| 54 | import { McpAppFullscreenScreen } from "./features/threads/McpAppFullscreenScreen"; | ||
| 54 | import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen"; | 55 | import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen"; |
| 55 | import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen"; | 56 | import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen"; |
| 56 | import { HomeRouteScreen } from "./features/home/HomeRouteScreen"; | 57 | import { HomeRouteScreen } from "./features/home/HomeRouteScreen"; |
| ... | @@ -747,6 +748,15 @@ const RootStackConfig = createNativeStackNavigator({ | ... | @@ -747,6 +748,15 @@ const RootStackConfig = createNativeStackNavigator({ |
| 747 | linking: `${THREAD_LINKING_PREFIX}/files/:path*`, | 748 | linking: `${THREAD_LINKING_PREFIX}/files/:path*`, |
| 748 | options: SOLID_HEADER_OPTIONS, | 749 | options: SOLID_HEADER_OPTIONS, |
| 749 | }), | 750 | }), |
| 751 | ThreadMcpApp: createNativeStackScreen({ | ||
| 752 | screen: McpAppFullscreenScreen, | ||
| 753 | linking: `${THREAD_LINKING_PREFIX}/apps/:itemId`, | ||
| 754 | options: { | ||
| 755 | presentation: "fullScreenModal", | ||
| 756 | headerShown: false, | ||
| 757 | gestureEnabled: false, | ||
| 758 | }, | ||
| 759 | }), | ||
| 750 | ThreadAttachment: createNativeStackScreen({ | 760 | ThreadAttachment: createNativeStackScreen({ |
| 751 | screen: AttachmentFileScreen, | 761 | screen: AttachmentFileScreen, |
| 752 | linking: `${THREAD_LINKING_PREFIX}/attachments/:attachmentId`, | 762 | linking: `${THREAD_LINKING_PREFIX}/attachments/:attachmentId`, |
apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx created+168| ... | @@ -0,0 +1,168 @@ | ||
| 1 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | ||
| 2 | import { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; | ||
| 3 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | ||
| 4 | import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; | ||
| 5 | import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; | ||
| 6 | import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| 7 | import { Pressable, View } from "react-native"; | ||
| 8 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 9 | |||
| 10 | import { AppText as Text } from "../../components/AppText"; | ||
| 11 | import { SymbolView } from "../../components/AppSymbol"; | ||
| 12 | import { orchestrationEnvironment } from "../../state/orchestration"; | ||
| 13 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | ||
| 14 | import { useThreadShell, useThreadShells } from "../../state/entities"; | ||
| 15 | import { useEnvironmentQuery } from "../../state/query"; | ||
| 16 | import { ThreadMcpApp } from "./McpAppWebView"; | ||
| 17 | |||
| 18 | type McpAppFullscreenScreenProps = StaticScreenProps<{ | ||
| 19 | readonly environmentId: string; | ||
| 20 | readonly threadId: string; | ||
| 21 | readonly conversationThreadId?: string; | ||
| 22 | readonly itemId: string; | ||
| 23 | readonly revision?: string; | ||
| 24 | }>; | ||
| 25 | |||
| 26 | /** | ||
| 27 | * The thread an app's messages go to. A link only names it, so it counts only | ||
| 28 | * when it is the app's own thread or a fork of it; anything else falls back to | ||
| 29 | * the app's thread. | ||
| 30 | */ | ||
| 31 | function useConversationThreadId( | ||
| 32 | environmentId: EnvironmentId, | ||
| 33 | threadId: ThreadId, | ||
| 34 | requested: string | undefined, | ||
| 35 | ): ThreadId { | ||
| 36 | const shells = useThreadShells(); | ||
| 37 | return useMemo( | ||
| 38 | () => | ||
| 39 | requested !== undefined && descendsFrom(shells, environmentId, requested, threadId) | ||
| 40 | ? ThreadId.make(requested) | ||
| 41 | : threadId, | ||
| 42 | [shells, environmentId, threadId, requested], | ||
| 43 | ); | ||
| 44 | } | ||
| 45 | |||
| 46 | /** True when `threadId` is `ancestorId` or one of its forks, at any depth. */ | ||
| 47 | function descendsFrom( | ||
| 48 | shells: ReadonlyArray<EnvironmentThreadShell>, | ||
| 49 | environmentId: EnvironmentId, | ||
| 50 | threadId: string, | ||
| 51 | ancestorId: ThreadId, | ||
| 52 | ): boolean { | ||
| 53 | const parents = new Map( | ||
| 54 | shells | ||
| 55 | .filter((shell) => shell.environmentId === environmentId) | ||
| 56 | // Only forks continue a conversation; a subagent is its own. | ||
| 57 | .filter((shell) => shell.lineage.relationshipToParent === "fork") | ||
| 58 | .map((shell) => [shell.id as string, shell.lineage.parentThreadId]), | ||
| 59 | ); | ||
| 60 | let current: string | null | undefined = threadId; | ||
| 61 | for (let depth = 0; depth < 64 && current != null; depth += 1) { | ||
| 62 | if (current === ancestorId) return true; | ||
| 63 | current = parents.get(current); | ||
| 64 | } | ||
| 65 | return false; | ||
| 66 | } | ||
| 67 | |||
| 68 | /** | ||
| 69 | * An MCP App full screen (`ui/request-display-mode`), in a modal over the | ||
| 70 | * thread. It is its own view of the app; closing it returns to the row. The | ||
| 71 | * app is read from its stored tool call, never from the route, so a link can | ||
| 72 | * only open an app that call really produced. | ||
| 73 | */ | ||
| 74 | export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) { | ||
| 75 | const navigation = useNavigation(); | ||
| 76 | const insets = useSafeAreaInsets(); | ||
| 77 | // A plain goBack pops whatever is on top, so it only runs while this modal | ||
| 78 | // is: an exit that lands while another screen covers it (after a teardown | ||
| 79 | // wait) waits for the modal to be on top again, rather than popping that | ||
| 80 | // screen or being lost. | ||
| 81 | const focused = useIsFocused(); | ||
| 82 | const [exitRequested, setExitRequested] = useState(false); | ||
| 83 | const onClose = useCallback(() => { | ||
| 84 | if (navigation.isFocused()) navigation.goBack(); | ||
| 85 | else setExitRequested(true); | ||
| 86 | }, [navigation]); | ||
| 87 | const [size, setSize] = useState({ width: 0, height: 0 }); | ||
| 88 | const params = route.params; | ||
| 89 | const environmentId = EnvironmentId.make(params.environmentId); | ||
| 90 | const threadId = ThreadId.make(params.threadId); | ||
| 91 | const itemId = TurnItemId.make(params.itemId); | ||
| 92 | const conversationThreadId = useConversationThreadId( | ||
| 93 | environmentId, | ||
| 94 | threadId, | ||
| 95 | params.conversationThreadId, | ||
| 96 | ); | ||
| 97 | const detail = useEnvironmentQuery( | ||
| 98 | orchestrationEnvironment.turnItem({ | ||
| 99 | environmentId, | ||
| 100 | input: { | ||
| 101 | threadId, | ||
| 102 | itemId, | ||
| 103 | ...(params.revision === undefined ? {} : { revision: params.revision }), | ||
| 104 | }, | ||
| 105 | }), | ||
| 106 | ); | ||
| 107 | const item = detail.data?.item; | ||
| 108 | // One reference per app: the item is a new object on every refetch (a | ||
| 109 | // reconnect, for one), and a new reference would rebuild the app's host. | ||
| 110 | const derived = item?.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined; | ||
| 111 | const [app, setApp] = useState(derived); | ||
| 112 | if (derived?.attachmentId !== app?.attachmentId) setApp(derived); | ||
| 113 | // The approval or question the agent waits on renders on the thread this | ||
| 114 | // modal covers, so the app steps aside for it. | ||
| 115 | const conversation = useThreadShell(scopeThreadRef(environmentId, conversationThreadId)); | ||
| 116 | const awaitingUser = | ||
| 117 | conversation?.hasPendingApprovals === true || conversation?.hasPendingUserInput === true; | ||
| 118 | useEffect(() => { | ||
| 119 | if ((awaitingUser || exitRequested) && focused) navigation.goBack(); | ||
| 120 | }, [awaitingUser, exitRequested, focused, navigation]); | ||
| 121 | |||
| 122 | return ( | ||
| 123 | <View className="flex-1 bg-background" style={{ paddingTop: insets.top }}> | ||
| 124 | <View className="h-11 flex-row items-center justify-between px-3"> | ||
| 125 | <Text className="text-base font-semibold text-foreground" numberOfLines={1}> | ||
| 126 | {app?.server ?? "App"} | ||
| 127 | </Text> | ||
| 128 | <Pressable | ||
| 129 | accessibilityRole="button" | ||
| 130 | accessibilityLabel="Exit full screen" | ||
| 131 | hitSlop={8} | ||
| 132 | onPress={onClose} | ||
| 133 | > | ||
| 134 | <SymbolView name="xmark" size={18} tintColor="gray" /> | ||
| 135 | </Pressable> | ||
| 136 | </View> | ||
| 137 | <View | ||
| 138 | className="flex-1" | ||
| 139 | style={{ paddingBottom: insets.bottom }} | ||
| 140 | onLayout={(event) => | ||
| 141 | setSize({ | ||
| 142 | width: Math.round(event.nativeEvent.layout.width), | ||
| 143 | height: Math.round(event.nativeEvent.layout.height - insets.bottom), | ||
| 144 | }) | ||
| 145 | } | ||
| 146 | > | ||
| 147 | {app === undefined ? ( | ||
| 148 | detail.data === undefined ? null : ( | ||
| 149 | <Text className="m-6 text-sm text-foreground-muted">This app cannot be shown.</Text> | ||
| 150 | ) | ||
| 151 | ) : size.width > 0 ? ( | ||
| 152 | <ThreadMcpApp | ||
| 153 | environmentId={environmentId} | ||
| 154 | threadId={threadId} | ||
| 155 | conversationThreadId={conversationThreadId} | ||
| 156 | itemId={itemId} | ||
| 157 | revision={params.revision ?? (item == null ? "" : turnItemDetailRevision(item))} | ||
| 158 | app={app} | ||
| 159 | width={size.width} | ||
| 160 | height={size.height} | ||
| 161 | displayMode="fullscreen" | ||
| 162 | onExitFullscreen={onClose} | ||
| 163 | /> | ||
| 164 | ) : null} | ||
| 165 | </View> | ||
| 166 | </View> | ||
| 167 | ); | ||
| 168 | } | ||
apps/mobile/src/features/threads/McpAppWebView.tsx created+611| ... | @@ -0,0 +1,611 @@ | ||
| 1 | import type { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; | ||
| 2 | import { | ||
| 3 | makeMcpAppHost, | ||
| 4 | McpAppHostRefusal, | ||
| 5 | mcpAppStyleVariables, | ||
| 6 | mcpResourceBytes, | ||
| 7 | type McpAppCallToolResult, | ||
| 8 | type McpAppHost, | ||
| 9 | type McpAppHostContext, | ||
| 10 | } from "@t3tools/client-runtime/mcp-apps"; | ||
| 11 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | ||
| 12 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | ||
| 13 | import { CommandId, MessageId } from "@t3tools/contracts"; | ||
| 14 | import { | ||
| 15 | mcpAppAllowAttribute, | ||
| 16 | mcpAppFileName, | ||
| 17 | mcpAppReferencesEqual, | ||
| 18 | type McpAppReference, | ||
| 19 | } from "@t3tools/shared/mcpApp"; | ||
| 20 | import * as Predicate from "effect/Predicate"; | ||
| 21 | import Constants from "expo-constants"; | ||
| 22 | import { useNavigation } from "@react-navigation/native"; | ||
| 23 | import { useEffect, useMemo, useRef, useState } from "react"; | ||
| 24 | import { ActivityIndicator, Alert, Platform, Pressable, View } from "react-native"; | ||
| 25 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 26 | import { WebView, type WebViewMessageEvent } from "react-native-webview"; | ||
| 27 | |||
| 28 | import { AppText as Text } from "../../components/AppText"; | ||
| 29 | import { shareGeneratedAttachment } from "../../lib/attachmentDownload"; | ||
| 30 | import { mobileHtmlRenderTheme } from "../../lib/htmlRenderTheme"; | ||
| 31 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | ||
| 32 | import { uuidv4 } from "../../lib/uuid"; | ||
| 33 | import { useAssetUrlState } from "../../state/assets"; | ||
| 34 | import { useThreadShell } from "../../state/entities"; | ||
| 35 | import { mcpAppEnvironment } from "../../state/mcpApps"; | ||
| 36 | import { orchestrationEnvironment } from "../../state/orchestration"; | ||
| 37 | import { useEnvironmentQuery } from "../../state/query"; | ||
| 38 | import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; | ||
| 39 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 40 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| 41 | |||
| 42 | /** The feed reserves a fixed box for an app; a taller app scrolls inside it. */ | ||
| 43 | const MCP_APP_ROW_HEIGHT = 420; | ||
| 44 | const ROW_BOTTOM_MARGIN = 8; | ||
| 45 | |||
| 46 | export function mcpAppRowHeight() { | ||
| 47 | return MCP_APP_ROW_HEIGHT + ROW_BOTTOM_MARGIN; | ||
| 48 | } | ||
| 49 | |||
| 50 | // The WebView loads a tiny outer page that hosts the app in a real | ||
| 51 | // opaque-origin iframe, as web does, so the app's `window.parent` and the | ||
| 52 | // `event.source` of host replies are a real window, which the MCP Apps SDK | ||
| 53 | // requires. The outer page only relays: app → React Native, and host | ||
| 54 | // replies (injected as `__t3McpAppReceive(...)`) → app. | ||
| 55 | // | ||
| 56 | // Android exposes `ReactNativeWebView` to every frame, so a frame nested in | ||
| 57 | // the app could post to React Native directly. The outer page therefore wraps | ||
| 58 | // what it relays with a secret only it holds, and React Native drops anything | ||
| 59 | // else. The bridge belongs to the captured document: once the app frame loads | ||
| 60 | // a second time (it navigated itself), the outer page stops relaying and says | ||
| 61 | // so, rather than letting a page T3 never served pose as the app. This is not | ||
| 62 | // a confidentiality boundary: a frame can always navigate itself, so the app | ||
| 63 | // could carry anything it read out in a URL either way. | ||
| 64 | function outerDocument(src: string, allow: string, secret: string) { | ||
| 65 | const attribute = (value: string) => | ||
| 66 | value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;"); | ||
| 67 | return `<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 68 | <style>html,body{margin:0;height:100%;background:transparent}iframe{border:0;display:block;width:100%;height:100%}</style></head> | ||
| 69 | <body><iframe id="app" sandbox="allow-scripts allow-forms" allow="${attribute(allow)}"></iframe> | ||
| 70 | <script>(function(){var frame=document.getElementById("app"),secret=${JSON.stringify(secret)},loads=0,live=true; | ||
| 71 | var send=function(m){window.ReactNativeWebView.postMessage(JSON.stringify({secret:secret,message:m}));}; | ||
| 72 | frame.addEventListener("load",function(){loads+=1;if(loads>1&&live){live=false;send({t3:"navigated"});}}); | ||
| 73 | window.addEventListener("message",function(e){if(live&&e.source===frame.contentWindow)send(e.data);}); | ||
| 74 | window.__t3McpAppReceive=function(m){live&&frame.contentWindow&&frame.contentWindow.postMessage(m,"*");}; | ||
| 75 | frame.src=${JSON.stringify(src).replace(/</g, "\\u003c")};})();</script></body></html>`; | ||
| 76 | } | ||
| 77 | |||
| 78 | const commandFailure = (result: { | ||
| 79 | readonly cause: Parameters<typeof squashAtomCommandFailure>[0]["cause"]; | ||
| 80 | }) => { | ||
| 81 | const error = squashAtomCommandFailure(result); | ||
| 82 | return new McpAppHostRefusal( | ||
| 83 | error instanceof Error && error.message.trim() !== "" ? error.message : "Request failed.", | ||
| 84 | ); | ||
| 85 | }; | ||
| 86 | |||
| 87 | const confirm = (title: string, message: string, action: string) => | ||
| 88 | new Promise<boolean>((resolve) => { | ||
| 89 | Alert.alert( | ||
| 90 | title, | ||
| 91 | message, | ||
| 92 | [ | ||
| 93 | { text: "Cancel", style: "cancel", onPress: () => resolve(false) }, | ||
| 94 | { text: action, onPress: () => resolve(true) }, | ||
| 95 | ], | ||
| 96 | // Android dismisses an open alert when the next one shows; a dismissed | ||
| 97 | // request is declined rather than left holding an in-flight slot. | ||
| 98 | { cancelable: true, onDismiss: () => resolve(false) }, | ||
| 99 | ); | ||
| 100 | }); | ||
| 101 | |||
| 102 | /** Largest file an app may hand the user through `ui/download-file`. */ | ||
| 103 | const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; | ||
| 104 | |||
| 105 | /** | ||
| 106 | * A captured MCP App, hosted in a WebView over the MCP Apps bridge: inline as | ||
| 107 | * a fixed row of the thread feed, or full screen in its own modal screen. A | ||
| 108 | * WebView cannot move between the two without reloading, so each is its own | ||
| 109 | * view of the app: the inline one is torn down before full screen opens, and | ||
| 110 | * comes back when it closes. | ||
| 111 | */ | ||
| 112 | export function ThreadMcpApp(props: { | ||
| 113 | readonly environmentId: EnvironmentId; | ||
| 114 | /** The thread that produced the app, which its requests run against. */ | ||
| 115 | readonly threadId: ThreadId; | ||
| 116 | /** The thread on screen, which approved app messages are sent to. */ | ||
| 117 | readonly conversationThreadId: ThreadId; | ||
| 118 | readonly itemId: TurnItemId; | ||
| 119 | readonly revision: string; | ||
| 120 | readonly app: McpAppReference; | ||
| 121 | readonly width: number; | ||
| 122 | /** Full screen fills its screen; inline is the feed's fixed row. */ | ||
| 123 | readonly displayMode?: "inline" | "fullscreen"; | ||
| 124 | /** Full screen only: leaves it, back to the inline row. */ | ||
| 125 | readonly onExitFullscreen?: () => void; | ||
| 126 | /** Full screen only: the screen's height, which the app is told it fills. */ | ||
| 127 | readonly height?: number; | ||
| 128 | }) { | ||
| 129 | // One reference per app: a new object with the same content (a refetch, a | ||
| 130 | // rerender) must not rebuild the host of a document that is already live. | ||
| 131 | const [app, setApp] = useState(props.app); | ||
| 132 | if (!mcpAppReferencesEqual(app, props.app)) setApp(props.app); | ||
| 133 | const fullscreen = props.displayMode === "fullscreen"; | ||
| 134 | const navigation = useNavigation(); | ||
| 135 | const insets = useSafeAreaInsets(); | ||
| 136 | // The app asked to be closed; the row falls back to a note that brings it back. | ||
| 137 | const [closed, setClosed] = useState(false); | ||
| 138 | // Full screen is a separate view of the app, so the inline one steps aside | ||
| 139 | // (and is torn down) while it is open, and comes back when the thread is | ||
| 140 | // shown again. | ||
| 141 | const [presentedFullscreen, setPresentedFullscreen] = useState(false); | ||
| 142 | // Counts the documents this row has shown on purpose (back from full screen, | ||
| 143 | // or reopened after closing); each gets its own view and host. | ||
| 144 | const [documentKey, setDocumentKeyState] = useState(0); | ||
| 145 | |||
| 146 | const { themeId, themeAppearance, themeVariables, systemColorsActive } = | ||
| 147 | useAppearancePreferences(); | ||
| 148 | const theme = useMemo( | ||
| 149 | () => | ||
| 150 | mobileHtmlRenderTheme({ | ||
| 151 | themeId, | ||
| 152 | appearance: themeAppearance, | ||
| 153 | variables: themeVariables, | ||
| 154 | systemColors: systemColorsActive, | ||
| 155 | platform: Platform.OS, | ||
| 156 | }), | ||
| 157 | [themeId, themeAppearance, themeVariables, systemColorsActive], | ||
| 158 | ); | ||
| 159 | const resource = useMemo( | ||
| 160 | () => ({ | ||
| 161 | _tag: "attachment" as const, | ||
| 162 | attachmentId: app.attachmentId, | ||
| 163 | fileName: mcpAppFileName(app), | ||
| 164 | mimeType: "text/html", | ||
| 165 | disposition: "inline" as const, | ||
| 166 | }), | ||
| 167 | [app], | ||
| 168 | ); | ||
| 169 | const asset = useAssetUrlState(props.environmentId, resource); | ||
| 170 | // The view keeps its first URL: a re-minted one would reload the app. | ||
| 171 | const [uri, setUri] = useState<string | null>(null); | ||
| 172 | if (uri === null && asset._tag === "Success") setUri(asset.url); | ||
| 173 | const [loaded, setLoaded] = useState(false); | ||
| 174 | const [navigatedAway, setNavigatedAway] = useState(false); | ||
| 175 | // Bumped when the OS reclaims the web process: a fresh view, document and | ||
| 176 | // host, so the reloaded app is initialized and replayed again. Once only, so | ||
| 177 | // an app that keeps crashing its process is not reloaded forever. | ||
| 178 | const [generation, setGeneration] = useState(0); | ||
| 179 | const [crashed, setCrashed] = useState(false); | ||
| 180 | const restart = () => { | ||
| 181 | if (generation > 0) { | ||
| 182 | setCrashed(true); | ||
| 183 | return; | ||
| 184 | } | ||
| 185 | setLoaded(false); | ||
| 186 | // The first URL's token may have expired by now; the asset query keeps | ||
| 187 | // a refreshed one, which this new view can load. | ||
| 188 | if (asset._tag === "Success") setUri(asset.url); | ||
| 189 | setGeneration(1); | ||
| 190 | }; | ||
| 191 | // Minted per view, so only this view's outer page can speak for its app. | ||
| 192 | const [secret] = useState(uuidv4); | ||
| 193 | // A new document starts loading, is not yet one that navigated away, and | ||
| 194 | // loads the asset query's current URL: the first one's token may be gone. | ||
| 195 | const setDocumentKey = (next: (value: number) => number) => { | ||
| 196 | setLoaded(false); | ||
| 197 | setNavigatedAway(false); | ||
| 198 | if (asset._tag === "Success") setUri(asset.url); | ||
| 199 | setDocumentKeyState(next); | ||
| 200 | }; | ||
| 201 | // Back from full screen: the thread shows a fresh inline view of the app. | ||
| 202 | // The listener reads the latest helper, whose URL comes from this render. | ||
| 203 | const openNewDocument = useRef(setDocumentKey); | ||
| 204 | useEffect(() => { | ||
| 205 | openNewDocument.current = setDocumentKey; | ||
| 206 | }); | ||
| 207 | useEffect(() => { | ||
| 208 | if (!presentedFullscreen) return; | ||
| 209 | return navigation.addListener("focus", () => { | ||
| 210 | openNewDocument.current((value) => value + 1); | ||
| 211 | setPresentedFullscreen(false); | ||
| 212 | }); | ||
| 213 | }, [navigation, presentedFullscreen]); | ||
| 214 | |||
| 215 | // The feed omits tool input and output; the app needs both. | ||
| 216 | const detail = useEnvironmentQuery( | ||
| 217 | orchestrationEnvironment.turnItem({ | ||
| 218 | environmentId: props.environmentId, | ||
| 219 | input: { threadId: props.threadId, itemId: props.itemId, revision: props.revision }, | ||
| 220 | }), | ||
| 221 | ); | ||
| 222 | const storedItem = detail.data?.item; | ||
| 223 | const toolCall = useMemo(() => { | ||
| 224 | if (storedItem?.type !== "dynamic_tool") return undefined; | ||
| 225 | const output = storedItem.output as { readonly result?: unknown } | undefined; | ||
| 226 | return { | ||
| 227 | arguments: storedItem.input, | ||
| 228 | result: output?.result as McpAppCallToolResult | undefined, | ||
| 229 | }; | ||
| 230 | }, [storedItem]); | ||
| 231 | |||
| 232 | const callTool = useAtomCommand(mcpAppEnvironment.callTool, { reportFailure: false }); | ||
| 233 | const toolInfo = useAtomCommand(mcpAppEnvironment.toolInfo, { reportFailure: false }); | ||
| 234 | const readResource = useAtomCommand(mcpAppEnvironment.readResource, { reportFailure: false }); | ||
| 235 | const updateModelContext = useAtomCommand(mcpAppEnvironment.updateModelContext, { | ||
| 236 | reportFailure: false, | ||
| 237 | }); | ||
| 238 | // The tool's definition, which the app receives as `toolInfo` at initialize. | ||
| 239 | const [toolDefinition, setToolDefinition] = useState<unknown>(undefined); | ||
| 240 | // Read by the host on every message, so it always sees current values | ||
| 241 | // without being rebuilt (which would drop the app's session). | ||
| 242 | // The approval or question the agent waits on renders on this thread, and a | ||
| 243 | // full-screen app would cover it. | ||
| 244 | const conversation = useThreadShell( | ||
| 245 | scopeThreadRef(props.environmentId, props.conversationThreadId), | ||
| 246 | ); | ||
| 247 | const awaitingUser = | ||
| 248 | conversation?.hasPendingApprovals === true || conversation?.hasPendingUserInput === true; | ||
| 249 | const live = { | ||
| 250 | awaitingUser, | ||
| 251 | theme, | ||
| 252 | props, | ||
| 253 | callTool, | ||
| 254 | toolInfo, | ||
| 255 | readResource, | ||
| 256 | updateModelContext, | ||
| 257 | insets, | ||
| 258 | toolDefinition, | ||
| 259 | navigation, | ||
| 260 | }; | ||
| 261 | const latest = useRef(live); | ||
| 262 | useEffect(() => { | ||
| 263 | latest.current = live; | ||
| 264 | }); | ||
| 265 | useEffect(() => { | ||
| 266 | let cancelled = false; | ||
| 267 | void latest.current | ||
| 268 | .toolInfo({ | ||
| 269 | environmentId: props.environmentId, | ||
| 270 | input: { threadId: props.threadId, itemId: props.itemId, name: app.tool }, | ||
| 271 | }) | ||
| 272 | .then((info) => { | ||
| 273 | if (!cancelled && info._tag === "Success" && info.value.tool !== undefined) { | ||
| 274 | setToolDefinition(info.value.tool); | ||
| 275 | } | ||
| 276 | }); | ||
| 277 | return () => { | ||
| 278 | cancelled = true; | ||
| 279 | }; | ||
| 280 | }, [props.environmentId, props.threadId, props.itemId, app.tool]); | ||
| 281 | const webView = useRef<WebView<object>>(null); | ||
| 282 | const hostRef = useRef<McpAppHost | null>(null); | ||
| 283 | |||
| 284 | // One host per loaded document. | ||
| 285 | useEffect(() => { | ||
| 286 | if (uri === null) return; | ||
| 287 | const scope = () => { | ||
| 288 | const { environmentId, threadId, itemId } = latest.current.props; | ||
| 289 | return { environmentId, input: { threadId, itemId } }; | ||
| 290 | }; | ||
| 291 | const hostContext = (): McpAppHostContext => { | ||
| 292 | const current = latest.current; | ||
| 293 | const isFullscreen = current.props.displayMode === "fullscreen"; | ||
| 294 | return { | ||
| 295 | theme: current.theme.appearance, | ||
| 296 | styles: { variables: mcpAppStyleVariables(current.theme.variables) }, | ||
| 297 | displayMode: isFullscreen ? "fullscreen" : "inline", | ||
| 298 | availableDisplayModes: ["inline", "fullscreen"], | ||
| 299 | // Both are fixed boxes, so the app is told its exact size. | ||
| 300 | containerDimensions: { | ||
| 301 | width: current.props.width, | ||
| 302 | height: isFullscreen ? (current.props.height ?? MCP_APP_ROW_HEIGHT) : MCP_APP_ROW_HEIGHT, | ||
| 303 | }, | ||
| 304 | platform: "mobile", | ||
| 305 | locale: Intl.DateTimeFormat().resolvedOptions().locale, | ||
| 306 | timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, | ||
| 307 | userAgent: `t3-code/${Constants.expoConfig?.version ?? "0.0.0"}`, | ||
| 308 | deviceCapabilities: { touch: true, hover: false }, | ||
| 309 | // The full-screen modal keeps the top and bottom insets for its | ||
| 310 | // header and the home indicator; only the sides reach the app. | ||
| 311 | safeAreaInsets: isFullscreen | ||
| 312 | ? { top: 0, right: current.insets.right, bottom: 0, left: current.insets.left } | ||
| 313 | : { top: 0, right: 0, bottom: 0, left: 0 }, | ||
| 314 | ...(current.toolDefinition === undefined | ||
| 315 | ? {} | ||
| 316 | : { toolInfo: { tool: current.toolDefinition } }), | ||
| 317 | }; | ||
| 318 | }; | ||
| 319 | const next = makeMcpAppHost({ | ||
| 320 | app, | ||
| 321 | hostVersion: Constants.expoConfig?.version ?? "0.0.0", | ||
| 322 | post: (message) => | ||
| 323 | webView.current?.injectJavaScript( | ||
| 324 | `window.__t3McpAppReceive&&window.__t3McpAppReceive(${JSON.stringify(message)});true;`, | ||
| 325 | ), | ||
| 326 | hostContext, | ||
| 327 | callTool: async ({ name, arguments: args }) => { | ||
| 328 | const { environmentId, input } = scope(); | ||
| 329 | const info = await latest.current.toolInfo({ environmentId, input: { ...input, name } }); | ||
| 330 | if (info._tag !== "Success") throw commandFailure(info); | ||
| 331 | if (!info.value.callable) throw new McpAppHostRefusal("This app cannot call that tool."); | ||
| 332 | if ( | ||
| 333 | !info.value.readOnly && | ||
| 334 | !(await confirm( | ||
| 335 | `Allow ${app.server} to run ${info.value.title ?? name}?`, | ||
| 336 | JSON.stringify(args, null, 2), | ||
| 337 | "Allow", | ||
| 338 | )) | ||
| 339 | ) { | ||
| 340 | throw new McpAppHostRefusal("Declined by the user."); | ||
| 341 | } | ||
| 342 | const result = await latest.current.callTool({ | ||
| 343 | environmentId, | ||
| 344 | input: { ...input, name, arguments: args }, | ||
| 345 | }); | ||
| 346 | if (result._tag !== "Success") throw commandFailure(result); | ||
| 347 | return result.value; | ||
| 348 | }, | ||
| 349 | readResource: async ({ uri: resourceUri }) => { | ||
| 350 | const { environmentId, input } = scope(); | ||
| 351 | const result = await latest.current.readResource({ | ||
| 352 | environmentId, | ||
| 353 | input: { ...input, uri: resourceUri }, | ||
| 354 | }); | ||
| 355 | if (result._tag !== "Success") throw commandFailure(result); | ||
| 356 | return result.value; | ||
| 357 | }, | ||
| 358 | openLink: async (url) => { | ||
| 359 | // A WebView cannot tell whether the reader just tapped the app, so it | ||
| 360 | // asks, rather than letting an app leave T3 on a timer. | ||
| 361 | if (!(await confirm(`Open a link from ${app.server}?`, url, "Open"))) { | ||
| 362 | throw new McpAppHostRefusal("Declined by the user."); | ||
| 363 | } | ||
| 364 | if (!(await tryOpenExternalUrl(url, "mcp-app"))) { | ||
| 365 | throw new McpAppHostRefusal("The link could not be opened."); | ||
| 366 | } | ||
| 367 | }, | ||
| 368 | sendMessage: async (text) => { | ||
| 369 | if (!(await confirm(`Send this message from ${app.server}?`, text, "Send"))) { | ||
| 370 | throw new McpAppHostRefusal("Declined by the user."); | ||
| 371 | } | ||
| 372 | // Through the outbox like a typed message, so it survives a dropped | ||
| 373 | // connection; the thread's own settings fill in when it sends. | ||
| 374 | await enqueueThreadOutboxMessage({ | ||
| 375 | environmentId: latest.current.props.environmentId, | ||
| 376 | threadId: latest.current.props.conversationThreadId, | ||
| 377 | messageId: MessageId.make(uuidv4()), | ||
| 378 | commandId: CommandId.make(uuidv4()), | ||
| 379 | text, | ||
| 380 | attachments: [], | ||
| 381 | dispatchMode: "queue", | ||
| 382 | createdAt: new Date().toISOString(), | ||
| 383 | }); | ||
| 384 | }, | ||
| 385 | updateModelContext: async (context) => { | ||
| 386 | const { environmentId, input } = scope(); | ||
| 387 | const result = await latest.current.updateModelContext({ | ||
| 388 | environmentId, | ||
| 389 | input: { | ||
| 390 | ...input, | ||
| 391 | ...context, | ||
| 392 | conversationThreadId: latest.current.props.conversationThreadId, | ||
| 393 | }, | ||
| 394 | }); | ||
| 395 | if (result._tag !== "Success") throw commandFailure(result); | ||
| 396 | }, | ||
| 397 | requestDisplayMode: async (mode) => { | ||
| 398 | const current = latest.current.props; | ||
| 399 | // Full screen would cover the approval or question the agent waits | ||
| 400 | // on, or a screen the user moved to. | ||
| 401 | if ( | ||
| 402 | mode === "fullscreen" && | ||
| 403 | (latest.current.awaitingUser || !latest.current.navigation.isFocused()) | ||
| 404 | ) { | ||
| 405 | return current.displayMode ?? "inline"; | ||
| 406 | } | ||
| 407 | if (mode === "fullscreen" && current.displayMode !== "fullscreen") { | ||
| 408 | // The inline view is torn down by the switch (this row unmounts | ||
| 409 | // while the modal covers it); the modal opens a fresh view. | ||
| 410 | // The inline view steps aside for the modal; it gets its teardown first. | ||
| 411 | await hostRef.current?.teardown(); | ||
| 412 | // The wait may have let an approval arrive, or the user moved to | ||
| 413 | // another screen; either way the view reloads inline. | ||
| 414 | if (latest.current.awaitingUser || !latest.current.navigation.isFocused()) { | ||
| 415 | openNewDocument.current((value) => value + 1); | ||
| 416 | return "inline"; | ||
| 417 | } | ||
| 418 | setPresentedFullscreen(true); | ||
| 419 | latest.current.navigation.navigate("ThreadMcpApp", { | ||
| 420 | environmentId: String(current.environmentId), | ||
| 421 | threadId: String(current.threadId), | ||
| 422 | conversationThreadId: String(current.conversationThreadId), | ||
| 423 | itemId: String(current.itemId), | ||
| 424 | revision: current.revision, | ||
| 425 | }); | ||
| 426 | return "fullscreen"; | ||
| 427 | } | ||
| 428 | if (mode === "inline" && current.displayMode === "fullscreen") { | ||
| 429 | await hostRef.current?.teardown(); | ||
| 430 | current.onExitFullscreen?.(); | ||
| 431 | return "inline"; | ||
| 432 | } | ||
| 433 | return current.displayMode ?? "inline"; | ||
| 434 | }, | ||
| 435 | downloadFile: async (files) => { | ||
| 436 | const names = files.map((file) => file.name).join(", "); | ||
| 437 | if (!(await confirm(`Save a file from ${app.server}?`, names, "Save"))) { | ||
| 438 | throw new McpAppHostRefusal("Declined by the user."); | ||
| 439 | } | ||
| 440 | for (const file of files) { | ||
| 441 | // A linked file is read from the app's own server, like its other reads. | ||
| 442 | let bytes: Uint8Array | undefined; | ||
| 443 | let mimeType = file.mimeType ?? "application/octet-stream"; | ||
| 444 | if (file._tag === "embedded") { | ||
| 445 | bytes = file.bytes; | ||
| 446 | } else { | ||
| 447 | const { environmentId, input } = scope(); | ||
| 448 | const read = await latest.current.readResource({ | ||
| 449 | environmentId, | ||
| 450 | input: { ...input, uri: file.uri }, | ||
| 451 | }); | ||
| 452 | if (read._tag !== "Success") throw commandFailure(read); | ||
| 453 | const content = read.value.contents[0]; | ||
| 454 | bytes = mcpResourceBytes(content); | ||
| 455 | const declared = (content as { readonly mimeType?: unknown } | undefined)?.mimeType; | ||
| 456 | if (typeof declared === "string") mimeType = declared; | ||
| 457 | } | ||
| 458 | if (bytes === undefined) throw new McpAppHostRefusal(`${file.name} has no contents.`); | ||
| 459 | if (bytes.byteLength > MAX_DOWNLOAD_BYTES) { | ||
| 460 | throw new McpAppHostRefusal(`${file.name} is too large to save.`); | ||
| 461 | } | ||
| 462 | const shared = await shareGeneratedAttachment({ | ||
| 463 | bytes, | ||
| 464 | attachment: { name: file.name, mimeType }, | ||
| 465 | signal: new AbortController().signal, | ||
| 466 | }); | ||
| 467 | if (!shared) throw new McpAppHostRefusal("Sharing is not available on this device."); | ||
| 468 | } | ||
| 469 | }, | ||
| 470 | onRequestTeardown: () => { | ||
| 471 | const current = latest.current.props; | ||
| 472 | if (current.displayMode === "fullscreen") { | ||
| 473 | void hostRef.current?.teardown().then(() => current.onExitFullscreen?.()); | ||
| 474 | } else { | ||
| 475 | void hostRef.current?.teardown().then(() => setClosed(true)); | ||
| 476 | } | ||
| 477 | }, | ||
| 478 | // Both modes are fixed boxes, so the app's own height only decides | ||
| 479 | // whether it scrolls inside one. | ||
| 480 | onSizeChanged: () => undefined, | ||
| 481 | }); | ||
| 482 | hostRef.current = next; | ||
| 483 | return () => { | ||
| 484 | // An unmount cannot wait: the request goes out before the view does. | ||
| 485 | void next.teardown(); | ||
| 486 | hostRef.current = null; | ||
| 487 | }; | ||
| 488 | // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A restarted view needs a new host. | ||
| 489 | }, [uri, app, generation, documentKey]); | ||
| 490 | |||
| 491 | // The host reads the context through `latest`; these only say when to resend. | ||
| 492 | useEffect(() => { | ||
| 493 | hostRef.current?.updateHostContext(); | ||
| 494 | // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Context changes trigger a resend. | ||
| 495 | }, [theme, props.width, props.height, insets, toolDefinition]); | ||
| 496 | // A new document gets a new host, which needs the call again. | ||
| 497 | useEffect(() => { | ||
| 498 | if (toolCall !== undefined) hostRef.current?.setToolCall(toolCall); | ||
| 499 | // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each new document needs the call. | ||
| 500 | }, [toolCall, uri, generation, documentKey]); | ||
| 501 | |||
| 502 | const source = useMemo( | ||
| 503 | () => | ||
| 504 | uri === null | ||
| 505 | ? null | ||
| 506 | : { html: outerDocument(uri, mcpAppAllowAttribute(app.permissions), secret) }, | ||
| 507 | [uri, app.permissions, secret], | ||
| 508 | ); | ||
| 509 | |||
| 510 | if (presentedFullscreen) { | ||
| 511 | return ( | ||
| 512 | <View | ||
| 513 | style={{ height: MCP_APP_ROW_HEIGHT, marginBottom: ROW_BOTTOM_MARGIN }} | ||
| 514 | className="items-center justify-center rounded-lg border border-border" | ||
| 515 | > | ||
| 516 | <Text className="text-sm text-foreground-muted"> | ||
| 517 | The {app.server} app is open full screen | ||
| 518 | </Text> | ||
| 519 | </View> | ||
| 520 | ); | ||
| 521 | } | ||
| 522 | |||
| 523 | if (closed) { | ||
| 524 | return ( | ||
| 525 | <View | ||
| 526 | style={{ height: MCP_APP_ROW_HEIGHT, marginBottom: ROW_BOTTOM_MARGIN }} | ||
| 527 | className="items-center justify-center gap-2 rounded-lg border border-border" | ||
| 528 | > | ||
| 529 | <Text className="text-sm text-foreground-muted">The {app.server} app was closed</Text> | ||
| 530 | <Pressable | ||
| 531 | accessibilityRole="button" | ||
| 532 | onPress={() => { | ||
| 533 | setDocumentKey((value) => value + 1); | ||
| 534 | setClosed(false); | ||
| 535 | }} | ||
| 536 | > | ||
| 537 | <Text className="text-sm text-foreground">Show app</Text> | ||
| 538 | </Pressable> | ||
| 539 | </View> | ||
| 540 | ); | ||
| 541 | } | ||
| 542 | |||
| 543 | return ( | ||
| 544 | <View | ||
| 545 | style={ | ||
| 546 | fullscreen ? { flex: 1 } : { height: MCP_APP_ROW_HEIGHT, marginBottom: ROW_BOTTOM_MARGIN } | ||
| 547 | } | ||
| 548 | > | ||
| 549 | {navigatedAway ? ( | ||
| 550 | <View className="flex-1 items-center justify-center"> | ||
| 551 | <Text className="text-sm text-foreground-muted"> | ||
| 552 | The {app.server} app left its page and was stopped | ||
| 553 | </Text> | ||
| 554 | </View> | ||
| 555 | ) : uri !== null && !crashed ? ( | ||
| 556 | <WebView<object> | ||
| 557 | key={`${documentKey}:${generation}`} | ||
| 558 | ref={webView} | ||
| 559 | onContentProcessDidTerminate={restart} | ||
| 560 | onRenderProcessGone={restart} | ||
| 561 | source={source!} | ||
| 562 | accessibilityLabel={`${app.server} app`} | ||
| 563 | style={{ flex: 1, backgroundColor: "transparent" }} | ||
| 564 | nestedScrollEnabled | ||
| 565 | originWhitelist={["*"]} | ||
| 566 | // Only the outer page loads at the top; the app opens links through | ||
| 567 | // the bridge. Android reports every navigation as top-frame, frames | ||
| 568 | // the app nests included, so it has no gate to apply: there the | ||
| 569 | // outer page never navigates, and the app frame's sandbox already | ||
| 570 | // forbids top navigation and popups. | ||
| 571 | onShouldStartLoadWithRequest={(request) => | ||
| 572 | Platform.OS === "android" || | ||
| 573 | request.isTopFrame === false || | ||
| 574 | request.url === "about:blank" | ||
| 575 | } | ||
| 576 | setSupportMultipleWindows={false} | ||
| 577 | onLoadEnd={() => setLoaded(true)} | ||
| 578 | // The outer page is inline HTML, so these report a broken view, not | ||
| 579 | // an app page; the row shows the failure instead of a blank box. | ||
| 580 | onError={() => setCrashed(true)} | ||
| 581 | onHttpError={() => setCrashed(true)} | ||
| 582 | onMessage={(event: WebViewMessageEvent) => { | ||
| 583 | let envelope: unknown; | ||
| 584 | try { | ||
| 585 | envelope = JSON.parse(event.nativeEvent.data); | ||
| 586 | } catch { | ||
| 587 | return; // Not JSON: not a bridge message. | ||
| 588 | } | ||
| 589 | if (!Predicate.isObject(envelope) || envelope.secret !== secret) return; | ||
| 590 | const message = envelope.message; | ||
| 591 | if (Predicate.isObject(message) && message.t3 === "navigated") { | ||
| 592 | hostRef.current?.dispose(); | ||
| 593 | setNavigatedAway(true); | ||
| 594 | return; | ||
| 595 | } | ||
| 596 | hostRef.current?.receive(message); | ||
| 597 | }} | ||
| 598 | /> | ||
| 599 | ) : asset._tag === "Failure" || crashed ? ( | ||
| 600 | <View className="flex-1 items-center justify-center"> | ||
| 601 | <Text className="text-sm text-foreground-muted">Unable to load the {app.server} app</Text> | ||
| 602 | </View> | ||
| 603 | ) : null} | ||
| 604 | {uri !== null && !loaded && !crashed && !navigatedAway ? ( | ||
| 605 | <View pointerEvents="none" className="absolute inset-0 items-center justify-center"> | ||
| 606 | <ActivityIndicator /> | ||
| 607 | </View> | ||
| 608 | ) : null} | ||
| 609 | </View> | ||
| 610 | ); | ||
| 611 | } | ||
apps/mobile/src/features/threads/ThreadFeed.tsx+16| ... | @@ -188,6 +188,7 @@ import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; | ... | @@ -188,6 +188,7 @@ import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; |
| 188 | import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size"; | 188 | import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size"; |
| 189 | import { htmlRenderFrameHeight } from "@t3tools/shared/htmlRender"; | 189 | import { htmlRenderFrameHeight } from "@t3tools/shared/htmlRender"; |
| 190 | import { htmlRenderRowHeight, ThreadHtmlRender } from "./HtmlRenderWebView"; | 190 | import { htmlRenderRowHeight, ThreadHtmlRender } from "./HtmlRenderWebView"; |
| 191 | import { mcpAppRowHeight, ThreadMcpApp } from "./McpAppWebView"; | ||
| 191 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; | 192 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; |
| 192 | import { | 193 | import { |
| 193 | assetEnvironment, | 194 | assetEnvironment, |
| ... | @@ -1597,6 +1598,20 @@ function renderFeedEntry( | ... | @@ -1597,6 +1598,20 @@ function renderFeedEntry( |
| 1597 | ); | 1598 | ); |
| 1598 | } | 1599 | } |
| 1599 | 1600 | ||
| 1601 | if (entry.type === "mcp-app") { | ||
| 1602 | return ( | ||
| 1603 | <ThreadMcpApp | ||
| 1604 | environmentId={props.environmentId} | ||
| 1605 | threadId={entry.sourceThreadId} | ||
| 1606 | conversationThreadId={props.threadId} | ||
| 1607 | itemId={entry.itemId} | ||
| 1608 | revision={entry.revision} | ||
| 1609 | app={entry.app} | ||
| 1610 | width={props.contentWidth} | ||
| 1611 | /> | ||
| 1612 | ); | ||
| 1613 | } | ||
| 1614 | |||
| 1600 | if (entry.type === "work-toggle") { | 1615 | if (entry.type === "work-toggle") { |
| 1601 | return ( | 1616 | return ( |
| 1602 | <ThreadWorkGroupToggle | 1617 | <ThreadWorkGroupToggle |
| ... | @@ -2914,6 +2929,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -2914,6 +2929,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 2914 | if (entry.type === "html-render") { | 2929 | if (entry.type === "html-render") { |
| 2915 | return htmlRenderRowHeight(htmlRenderFrameHeight(entry.render, contentWidth)); | 2930 | return htmlRenderRowHeight(htmlRenderFrameHeight(entry.render, contentWidth)); |
| 2916 | } | 2931 | } |
| 2932 | if (entry.type === "mcp-app") return mcpAppRowHeight(); | ||
| 2917 | if (workRowSizing.fixedRowHeight === undefined) { | 2933 | if (workRowSizing.fixedRowHeight === undefined) { |
| 2918 | return undefined; | 2934 | return undefined; |
| 2919 | } | 2935 | } |
apps/mobile/src/lib/attachmentDownload.ts+23| ... | @@ -274,3 +274,26 @@ export async function shareLocalAttachment(input: { | ... | @@ -274,3 +274,26 @@ export async function shareLocalAttachment(input: { |
| 274 | cached.preview.dispose(); | 274 | cached.preview.dispose(); |
| 275 | } | 275 | } |
| 276 | } | 276 | } |
| 277 | |||
| 278 | /** | ||
| 279 | * Shares bytes made on this device (an MCP App's download) through the same | ||
| 280 | * cache and share sheet. False when the share sheet could not be shown. | ||
| 281 | */ | ||
| 282 | export async function shareGeneratedAttachment(input: { | ||
| 283 | readonly bytes: Uint8Array; | ||
| 284 | readonly attachment: AttachmentFileMetadata; | ||
| 285 | readonly signal: AbortSignal; | ||
| 286 | }): Promise<boolean> { | ||
| 287 | // An unavailable share sheet is a refusal for the caller, not a failure. | ||
| 288 | const sharing = await availableSharing(input.signal).catch(() => null); | ||
| 289 | if (sharing === null) return false; | ||
| 290 | const cached = await createCachedAttachmentFile(input.attachment); | ||
| 291 | try { | ||
| 292 | if (input.signal.aborted) return false; | ||
| 293 | cached.file.write(input.bytes); | ||
| 294 | await cached.preview.share(input.signal); | ||
| 295 | return true; | ||
| 296 | } finally { | ||
| 297 | cached.preview.dispose(); | ||
| 298 | } | ||
| 299 | } |
apps/mobile/src/lib/openExternalUrl.ts+1| ... | @@ -7,6 +7,7 @@ const ExternalUrlTarget = Schema.Literals([ | ... | @@ -7,6 +7,7 @@ const ExternalUrlTarget = Schema.Literals([ |
| 7 | "pull-request", | 7 | "pull-request", |
| 8 | "provider-auth", | 8 | "provider-auth", |
| 9 | "html-render", | 9 | "html-render", |
| 10 | "mcp-app", | ||
| 10 | ]); | 11 | ]); |
| 11 | 12 | ||
| 12 | export type ExternalUrlTarget = typeof ExternalUrlTarget.Type; | 13 | export type ExternalUrlTarget = typeof ExternalUrlTarget.Type; |
apps/mobile/src/lib/threadActivity.test.ts+32| ... | @@ -2511,3 +2511,35 @@ describe("html renders", () => { | ... | @@ -2511,3 +2511,35 @@ describe("html renders", () => { |
| 2511 | } | 2511 | } |
| 2512 | }); | 2512 | }); |
| 2513 | }); | 2513 | }); |
| 2514 | |||
| 2515 | describe("MCP apps", () => { | ||
| 2516 | const app = { | ||
| 2517 | attachmentId: "attachment-app", | ||
| 2518 | server: "weather", | ||
| 2519 | tool: "get_weather", | ||
| 2520 | resourceUri: "ui://weather/dashboard", | ||
| 2521 | }; | ||
| 2522 | const appCall = (status: OrchestrationV2TurnItem["status"]): OrchestrationV2TurnItem => ({ | ||
| 2523 | ...base("item-app", "2026-06-20T00:00:02.500Z", 2), | ||
| 2524 | status, | ||
| 2525 | type: "dynamic_tool", | ||
| 2526 | toolName: "weather.get_weather", | ||
| 2527 | input: { city: "Oslo" }, | ||
| 2528 | output: { t3McpApp: app, result: { content: [] } }, | ||
| 2529 | }); | ||
| 2530 | const feed = (status: OrchestrationV2TurnItem["status"]) => | ||
| 2531 | buildThreadFeed([ | ||
| 2532 | projected(userMessage(), 0), | ||
| 2533 | projected(appCall(status), 1), | ||
| 2534 | projected(assistantMessage("2026-06-20T00:00:04.000Z"), 2), | ||
| 2535 | ]); | ||
| 2536 | |||
| 2537 | it("hosts a completed app in place of its tool row, owned by its source item", () => { | ||
| 2538 | const entry = feed("completed").find((candidate) => candidate.type === "mcp-app"); | ||
| 2539 | expect(entry).toMatchObject({ type: "mcp-app", app, itemId: "item-app", runId }); | ||
| 2540 | }); | ||
| 2541 | |||
| 2542 | it("keeps a running app call an ordinary work row", () => { | ||
| 2543 | expect(feed("running").some((candidate) => candidate.type === "mcp-app")).toBe(false); | ||
| 2544 | }); | ||
| 2545 | }); |
apps/mobile/src/lib/threadActivity.ts+44-3| ... | @@ -8,6 +8,7 @@ import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subage | ... | @@ -8,6 +8,7 @@ import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subage |
| 8 | import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; | 8 | import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 9 | import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; | 9 | import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; |
| 10 | import { | 10 | import { |
| 11 | turnItemDetailRevision, | ||
| 11 | turnItemHasDetail, | 12 | turnItemHasDetail, |
| 12 | turnItemNeedsDetailFetch, | 13 | turnItemNeedsDetailFetch, |
| 13 | } from "@t3tools/client-runtime/work-log/item-detail"; | 14 | } from "@t3tools/client-runtime/work-log/item-detail"; |
| ... | @@ -48,6 +49,7 @@ import type { | ... | @@ -48,6 +49,7 @@ import type { |
| 48 | OrchestrationV2UserMessageInputIntent, | 49 | OrchestrationV2UserMessageInputIntent, |
| 49 | RunAttemptId, | 50 | RunAttemptId, |
| 50 | ScheduledTaskId, | 51 | ScheduledTaskId, |
| 52 | TurnItemId, | ||
| 51 | } from "@t3tools/contracts"; | 53 | } from "@t3tools/contracts"; |
| 52 | import { RunId, ThreadId } from "@t3tools/contracts"; | 54 | import { RunId, ThreadId } from "@t3tools/contracts"; |
| 53 | import { | 55 | import { |
| ... | @@ -59,7 +61,12 @@ import { | ... | @@ -59,7 +61,12 @@ import { |
| 59 | } from "@t3tools/shared/toolActivity"; | 61 | } from "@t3tools/shared/toolActivity"; |
| 60 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; | 62 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 61 | import type { HtmlRenderReference } from "@t3tools/shared/htmlRender"; | 63 | import type { HtmlRenderReference } from "@t3tools/shared/htmlRender"; |
| 62 | import { compactDynamicToolOutput, htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; | 64 | import type { McpAppReference } from "@t3tools/shared/mcpApp"; |
| 65 | import { | ||
| 66 | compactDynamicToolOutput, | ||
| 67 | htmlRenderFromToolItem, | ||
| 68 | mcpAppFromToolItem, | ||
| 69 | } from "@t3tools/shared/toolOutput"; | ||
| 63 | import * as DateTime from "effect/DateTime"; | 70 | import * as DateTime from "effect/DateTime"; |
| 64 | 71 | ||
| 65 | export type PendingApproval = ThreadPendingApproval; | 72 | export type PendingApproval = ThreadPendingApproval; |
| ... | @@ -165,12 +172,24 @@ type RawThreadFeedEntry = | ... | @@ -165,12 +172,24 @@ type RawThreadFeedEntry = |
| 165 | readonly createdAt: string; | 172 | readonly createdAt: string; |
| 166 | readonly runId: RunId | null; | 173 | readonly runId: RunId | null; |
| 167 | readonly render: HtmlRenderReference; | 174 | readonly render: HtmlRenderReference; |
| 175 | } | ||
| 176 | | { | ||
| 177 | /** An MCP App a completed tool call captured, hosted in place of its work row. */ | ||
| 178 | readonly type: "mcp-app"; | ||
| 179 | readonly id: string; | ||
| 180 | readonly createdAt: string; | ||
| 181 | readonly runId: RunId | null; | ||
| 182 | /** The thread and item that own the app; a fork's inherited app is its source's. */ | ||
| 183 | readonly sourceThreadId: ThreadId; | ||
| 184 | readonly itemId: TurnItemId; | ||
| 185 | readonly revision: string; | ||
| 186 | readonly app: McpAppReference; | ||
| 168 | }; | 187 | }; |
| 169 | 188 | ||
| 170 | export type ThreadFeedEntry = ThreadFeedEntryContent & { readonly continuesWorkLog?: boolean }; | 189 | export type ThreadFeedEntry = ThreadFeedEntryContent & { readonly continuesWorkLog?: boolean }; |
| 171 | 190 | ||
| 172 | type ThreadFeedEntryContent = | 191 | type ThreadFeedEntryContent = |
| 173 | | Extract<RawThreadFeedEntry, { type: "message" | "html-render" }> | 192 | | Extract<RawThreadFeedEntry, { type: "message" | "html-render" | "mcp-app" }> |
| 174 | | { | 193 | | { |
| 175 | readonly type: "activity-group"; | 194 | readonly type: "activity-group"; |
| 176 | readonly id: string; | 195 | readonly id: string; |
| ... | @@ -1049,7 +1068,9 @@ function deriveThreadFeedRunFolds( | ... | @@ -1049,7 +1068,9 @@ function deriveThreadFeedRunFolds( |
| 1049 | const runId = | 1068 | const runId = |
| 1050 | entry.type === "message" && entry.message.role === "assistant" | 1069 | entry.type === "message" && entry.message.role === "assistant" |
| 1051 | ? (entry.message.runId ?? runlessKey) | 1070 | ? (entry.message.runId ?? runlessKey) |
| 1052 | : entry.type === "activity-group" || entry.type === "html-render" | 1071 | : entry.type === "activity-group" || |
| 1072 | entry.type === "html-render" || | ||
| 1073 | entry.type === "mcp-app" | ||
| 1053 | ? (entry.runId ?? runlessKey) | 1074 | ? (entry.runId ?? runlessKey) |
| 1054 | : null; | 1075 | : null; |
| 1055 | if (!runId) continue; | 1076 | if (!runId) continue; |
| ... | @@ -1102,6 +1123,7 @@ function deriveThreadFeedRunFolds( | ... | @@ -1102,6 +1123,7 @@ function deriveThreadFeedRunFolds( |
| 1102 | entry.id !== firstAssistantId && | 1123 | entry.id !== firstAssistantId && |
| 1103 | entry.id !== terminalAssistantId && | 1124 | entry.id !== terminalAssistantId && |
| 1104 | entry.type !== "html-render" && | 1125 | entry.type !== "html-render" && |
| 1126 | entry.type !== "mcp-app" && | ||
| 1105 | !( | 1127 | !( |
| 1106 | entry.type === "activity-group" && | 1128 | entry.type === "activity-group" && |
| 1107 | entry.activities.some( | 1129 | entry.activities.some( |
| ... | @@ -1750,6 +1772,25 @@ export function buildThreadFeed( | ... | @@ -1750,6 +1772,25 @@ export function buildThreadFeed( |
| 1750 | entries.push(entry); | 1772 | entries.push(entry); |
| 1751 | continue; | 1773 | continue; |
| 1752 | } | 1774 | } |
| 1775 | const app = | ||
| 1776 | item.type === "dynamic_tool" && item.status === "completed" | ||
| 1777 | ? mcpAppFromToolItem(item) | ||
| 1778 | : undefined; | ||
| 1779 | if (app) { | ||
| 1780 | const entry: RawThreadFeedEntry = { | ||
| 1781 | type: "mcp-app", | ||
| 1782 | id: `mcp-app:${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`, | ||
| 1783 | createdAt, | ||
| 1784 | runId: item.runId, | ||
| 1785 | sourceThreadId: row.sourceThreadId, | ||
| 1786 | itemId: row.sourceItemId, | ||
| 1787 | revision: turnItemDetailRevision(item), | ||
| 1788 | app, | ||
| 1789 | }; | ||
| 1790 | projectedEntriesCache.set(row, { attemptId, entry }); | ||
| 1791 | entries.push(entry); | ||
| 1792 | continue; | ||
| 1793 | } | ||
| 1753 | if (item.type === "user_message" || item.type === "assistant_message") { | 1794 | if (item.type === "user_message" || item.type === "assistant_message") { |
| 1754 | const updatedAt = DateTime.formatIso(item.updatedAt); | 1795 | const updatedAt = DateTime.formatIso(item.updatedAt); |
| 1755 | const entry: RawThreadFeedEntry = { | 1796 | const entry: RawThreadFeedEntry = { |
apps/mobile/src/state/mcpApps.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createMcpAppEnvironmentAtoms } from "@t3tools/client-runtime/state/mcp-apps"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const mcpAppEnvironment = createMcpAppEnvironmentAtoms(connectionAtomRuntime); | ||
apps/server/scripts/record-codex-app-server-replay-fixture.ts+1| ... | @@ -72,6 +72,7 @@ const CODEX_REPLAY_PLAN_MODE_DEVELOPER_INSTRUCTIONS = | ... | @@ -72,6 +72,7 @@ const CODEX_REPLAY_PLAN_MODE_DEVELOPER_INSTRUCTIONS = |
| 72 | const CODEX_CLIENT_CAPABILITIES = { | 72 | const CODEX_CLIENT_CAPABILITIES = { |
| 73 | experimentalApi: true, | 73 | experimentalApi: true, |
| 74 | optOutNotificationMethods: ["turn/diff/updated"], | 74 | optOutNotificationMethods: ["turn/diff/updated"], |
| 75 | extensions: { "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] } }, | ||
| 75 | } as const; | 76 | } as const; |
| 76 | const CODEX_REPLAY_DEFAULT_MODEL = "gpt-6-luna"; | 77 | const CODEX_REPLAY_DEFAULT_MODEL = "gpt-6-luna"; |
| 77 | const CODEX_REPLAY_MODEL_OVERRIDE = readArgValue("--model") ?? process.env.T3_CODEX_REPLAY_MODEL; | 78 | const CODEX_REPLAY_MODEL_OVERRIDE = readArgValue("--model") ?? process.env.T3_CODEX_REPLAY_MODEL; |
apps/server/src/attachmentStore.test.ts+20| ... | @@ -243,4 +243,24 @@ describe("threadHtmlRenderAttachmentIds", () => { | ... | @@ -243,4 +243,24 @@ describe("threadHtmlRenderAttachmentIds", () => { |
| 243 | ]), | 243 | ]), |
| 244 | ).toEqual([own]); | 244 | ).toEqual([own]); |
| 245 | }); | 245 | }); |
| 246 | |||
| 247 | it("includes captured MCP App documents from any tool", () => { | ||
| 248 | const app = createAttachmentId("thread-a", "html")!; | ||
| 249 | expect( | ||
| 250 | threadHtmlRenderAttachmentIds("thread-a", [ | ||
| 251 | { | ||
| 252 | toolName: "weather.get_weather", | ||
| 253 | output: { | ||
| 254 | t3McpApp: { | ||
| 255 | attachmentId: app, | ||
| 256 | server: "weather", | ||
| 257 | tool: "get_weather", | ||
| 258 | resourceUri: "ui://weather/dashboard", | ||
| 259 | }, | ||
| 260 | result: { content: [] }, | ||
| 261 | }, | ||
| 262 | }, | ||
| 263 | ]), | ||
| 264 | ).toEqual([app]); | ||
| 265 | }); | ||
| 246 | }); | 266 | }); |
apps/server/src/attachmentStore.ts+6-4| ... | @@ -4,7 +4,7 @@ import * as NodeFS from "node:fs"; | ... | @@ -4,7 +4,7 @@ import * as NodeFS from "node:fs"; |
| 4 | import * as NodePath from "node:path"; | 4 | import * as NodePath from "node:path"; |
| 5 | 5 | ||
| 6 | import type { ChatAttachment } from "@t3tools/contracts"; | 6 | import type { ChatAttachment } from "@t3tools/contracts"; |
| 7 | import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; | 7 | import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; |
| 8 | 8 | ||
| 9 | import { | 9 | import { |
| 10 | normalizeAttachmentRelativePath, | 10 | normalizeAttachmentRelativePath, |
| ... | @@ -116,8 +116,9 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | ... | @@ -116,8 +116,9 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string |
| 116 | } | 116 | } |
| 117 | 117 | ||
| 118 | /** | 118 | /** |
| 119 | * Attachments a thread's `html_render` tool calls published. Only ids minted | 119 | * Attachments a thread's tool calls published: `html_render` pages and |
| 120 | * for this thread count, so deleting a fork never removes its source's pages. | 120 | * captured MCP App documents. Only ids minted for this thread count, so |
| 121 | * deleting a fork never removes its source's pages. | ||
| 121 | */ | 122 | */ |
| 122 | export function threadHtmlRenderAttachmentIds( | 123 | export function threadHtmlRenderAttachmentIds( |
| 123 | threadId: string, | 124 | threadId: string, |
| ... | @@ -126,7 +127,8 @@ export function threadHtmlRenderAttachmentIds( | ... | @@ -126,7 +127,8 @@ export function threadHtmlRenderAttachmentIds( |
| 126 | const segment = toSafeThreadAttachmentSegment(threadId); | 127 | const segment = toSafeThreadAttachmentSegment(threadId); |
| 127 | if (segment === null) return []; | 128 | if (segment === null) return []; |
| 128 | return Array.from(items).flatMap((item) => { | 129 | return Array.from(items).flatMap((item) => { |
| 129 | const attachmentId = htmlRenderFromToolItem(item)?.attachmentId; | 130 | const attachmentId = |
| 131 | htmlRenderFromToolItem(item)?.attachmentId ?? mcpAppFromToolItem(item)?.attachmentId; | ||
| 130 | return attachmentId !== undefined && | 132 | return attachmentId !== undefined && |
| 131 | parseThreadSegmentFromAttachmentId(attachmentId) === segment | 133 | parseThreadSegmentFromAttachmentId(attachmentId) === segment |
| 132 | ? [attachmentId] | 134 | ? [attachmentId] |
apps/server/src/auth/RpcAuthorization.ts+5| ... | @@ -155,6 +155,11 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -155,6 +155,11 @@ export const RPC_REQUIRED_SCOPES = { |
| 155 | [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, | 155 | [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, |
| 156 | [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, | 156 | [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, |
| 157 | [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, | 157 | [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, |
| 158 | // An app's tool calls can change things on its server, like a user action. | ||
| 159 | [WS_METHODS.mcpAppsCallTool]: AuthOrchestrationOperateScope, | ||
| 160 | [WS_METHODS.mcpAppsToolInfo]: AuthOrchestrationReadScope, | ||
| 161 | [WS_METHODS.mcpAppsReadResource]: AuthOrchestrationReadScope, | ||
| 162 | [WS_METHODS.mcpAppsUpdateModelContext]: AuthOrchestrationOperateScope, | ||
| 158 | [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, | 163 | [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, |
| 159 | [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, | 164 | [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, |
| 160 | [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, | 165 | [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, |
apps/server/src/mcpApps/McpAppModelContext.test.ts created+68| ... | @@ -0,0 +1,68 @@ | ||
| 1 | import { ThreadId, TurnItemId } from "@t3tools/contracts"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 7 | |||
| 8 | import { runMigrations } from "../persistence/Migrations.ts"; | ||
| 9 | import * as McpAppModelContext from "./McpAppModelContext.ts"; | ||
| 10 | |||
| 11 | const database = NodeSqliteClient.layer({ filename: ":memory:" }); | ||
| 12 | const layer = it.layer( | ||
| 13 | Layer.merge(database, McpAppModelContext.layer.pipe(Layer.provide(database))), | ||
| 14 | ); | ||
| 15 | |||
| 16 | const threadId = ThreadId.make("thread-context"); | ||
| 17 | const forkId = ThreadId.make("thread-context-fork"); | ||
| 18 | |||
| 19 | /** An app's tool call in the projection, in a run with the given status. */ | ||
| 20 | const insertAppItem = (itemId: string, runId: string, runStatus: string) => | ||
| 21 | Effect.gen(function* () { | ||
| 22 | const sql = yield* SqlClient.SqlClient; | ||
| 23 | yield* sql` | ||
| 24 | INSERT INTO orchestration_v2_projection_runs | ||
| 25 | (run_id, thread_id, ordinal, provider, status, requested_at, payload_json) | ||
| 26 | VALUES (${runId}, ${threadId}, ${runId.length}, 'codex', ${runStatus}, '2026-01-01', '{}') | ||
| 27 | `; | ||
| 28 | yield* sql` | ||
| 29 | INSERT INTO orchestration_v2_projection_turn_items | ||
| 30 | (turn_item_id, thread_id, run_id, ordinal, type, status, updated_at, payload_json) | ||
| 31 | VALUES (${itemId}, ${threadId}, ${runId}, 0, 'dynamic_tool', 'completed', '2026-01-01', '{}') | ||
| 32 | `; | ||
| 33 | }); | ||
| 34 | |||
| 35 | layer("McpAppModelContext", (it) => { | ||
| 36 | it.effect("sends only apps still in the thread's history, including a fork's", () => | ||
| 37 | Effect.gen(function* () { | ||
| 38 | yield* runMigrations(); | ||
| 39 | const store = yield* McpAppModelContext.McpAppModelContext; | ||
| 40 | yield* insertAppItem("item-kept", "run-a", "completed"); | ||
| 41 | yield* insertAppItem("item-rolled-back", "run-bb", "rolled_back"); | ||
| 42 | const set = (thread: ThreadId, itemId: string, text: string) => | ||
| 43 | store.set({ | ||
| 44 | threadId: thread, | ||
| 45 | itemId: TurnItemId.make(itemId), | ||
| 46 | server: "todos", | ||
| 47 | tool: "list_todos", | ||
| 48 | text, | ||
| 49 | }); | ||
| 50 | yield* set(threadId, "item-kept", "kept"); | ||
| 51 | yield* set(threadId, "item-rolled-back", "from a rolled-back run"); | ||
| 52 | yield* set(threadId, "item-deleted", "from an item that is gone"); | ||
| 53 | // A fork showing the source thread's app keeps its own context, even | ||
| 54 | // after the source rolls that app's run back. | ||
| 55 | yield* set(forkId, "item-kept", "from the fork"); | ||
| 56 | yield* set(forkId, "item-rolled-back", "inherited before the rollback"); | ||
| 57 | |||
| 58 | const texts = (thread: ThreadId) => | ||
| 59 | store.forThread(thread).pipe(Effect.map((entries) => entries.map((entry) => entry.text))); | ||
| 60 | assert.deepEqual(yield* texts(threadId), ["kept"]); | ||
| 61 | assert.deepEqual(yield* texts(forkId), ["from the fork", "inherited before the rollback"]); | ||
| 62 | |||
| 63 | // Clearing removes the app's context. | ||
| 64 | yield* set(threadId, "item-kept", ""); | ||
| 65 | assert.deepEqual(yield* texts(threadId), []); | ||
| 66 | }), | ||
| 67 | ); | ||
| 68 | }); | ||
apps/server/src/mcpApps/McpAppModelContext.ts created+105| ... | @@ -0,0 +1,105 @@ | ||
| 1 | import type { ThreadId, TurnItemId } from "@t3tools/contracts"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 8 | |||
| 9 | /** Largest context one app may keep for the agent, in UTF-8 bytes. */ | ||
| 10 | export const MCP_APP_MODEL_CONTEXT_MAX_BYTES = 16 * 1024; | ||
| 11 | |||
| 12 | export class McpAppModelContextError extends Schema.TaggedError<McpAppModelContextError>()( | ||
| 13 | "McpAppModelContextError", | ||
| 14 | { cause: Schema.optional(Schema.Defect()) }, | ||
| 15 | ) {} | ||
| 16 | |||
| 17 | export interface McpAppModelContextEntry { | ||
| 18 | readonly itemId: string; | ||
| 19 | readonly server: string; | ||
| 20 | readonly tool: string; | ||
| 21 | readonly text: string; | ||
| 22 | } | ||
| 23 | |||
| 24 | /** | ||
| 25 | * What each MCP App told the agent through `ui/update-model-context`. The spec | ||
| 26 | * replaces an app's context on every update, so there is one entry per app (its | ||
| 27 | * tool call); the thread's next turn carries the entries to the provider. | ||
| 28 | */ | ||
| 29 | export class McpAppModelContext extends Context.Service< | ||
| 30 | McpAppModelContext, | ||
| 31 | { | ||
| 32 | /** Replaces the app's context; empty text removes it. */ | ||
| 33 | readonly set: (input: { | ||
| 34 | readonly threadId: ThreadId; | ||
| 35 | readonly itemId: TurnItemId; | ||
| 36 | readonly server: string; | ||
| 37 | readonly tool: string; | ||
| 38 | readonly text: string; | ||
| 39 | }) => Effect.Effect<void, McpAppModelContextError>; | ||
| 40 | readonly forThread: ( | ||
| 41 | threadId: ThreadId, | ||
| 42 | ) => Effect.Effect<ReadonlyArray<McpAppModelContextEntry>, McpAppModelContextError>; | ||
| 43 | } | ||
| 44 | >()("t3/mcpApps/McpAppModelContext") {} | ||
| 45 | |||
| 46 | export const layer = Layer.effect( | ||
| 47 | McpAppModelContext, | ||
| 48 | Effect.gen(function* () { | ||
| 49 | const sql = yield* SqlClient.SqlClient; | ||
| 50 | const fail = (cause: unknown) => new McpAppModelContextError({ cause }); | ||
| 51 | |||
| 52 | const set = Effect.fn("McpAppModelContext.set")(function* (input: { | ||
| 53 | readonly threadId: ThreadId; | ||
| 54 | readonly itemId: TurnItemId; | ||
| 55 | readonly server: string; | ||
| 56 | readonly tool: string; | ||
| 57 | readonly text: string; | ||
| 58 | }) { | ||
| 59 | if (input.text.trim() === "") { | ||
| 60 | yield* sql` | ||
| 61 | DELETE FROM mcp_app_model_context | ||
| 62 | WHERE thread_id = ${input.threadId} AND item_id = ${input.itemId} | ||
| 63 | `.pipe(Effect.mapError(fail)); | ||
| 64 | return; | ||
| 65 | } | ||
| 66 | const updatedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 67 | yield* sql` | ||
| 68 | INSERT INTO mcp_app_model_context (thread_id, item_id, server, tool, text, updated_at) | ||
| 69 | VALUES (${input.threadId}, ${input.itemId}, ${input.server}, ${input.tool}, ${input.text}, ${updatedAt}) | ||
| 70 | ON CONFLICT(thread_id, item_id) DO UPDATE SET text = excluded.text, updated_at = excluded.updated_at | ||
| 71 | `.pipe(Effect.mapError(fail)); | ||
| 72 | }); | ||
| 73 | |||
| 74 | const forThread = Effect.fn("McpAppModelContext.forThread")(function* (threadId: ThreadId) { | ||
| 75 | // Only apps still in the thread's history count: an app whose item is | ||
| 76 | // gone, or whose run this thread rolled back, stops informing the agent. | ||
| 77 | // The item can live in a thread this one was forked from, so it is | ||
| 78 | // matched by id alone; that thread rolling the run back later leaves the | ||
| 79 | // fork's copy in place. | ||
| 80 | return yield* sql<McpAppModelContextEntry>` | ||
| 81 | SELECT context.item_id AS "itemId", context.server, context.tool, context.text | ||
| 82 | FROM mcp_app_model_context AS context | ||
| 83 | JOIN orchestration_v2_projection_turn_items AS item | ||
| 84 | ON item.turn_item_id = context.item_id | ||
| 85 | LEFT JOIN orchestration_v2_projection_runs AS run | ||
| 86 | ON run.run_id = item.run_id | ||
| 87 | WHERE context.thread_id = ${threadId} | ||
| 88 | AND ( | ||
| 89 | run.status IS NULL | ||
| 90 | OR run.status <> 'rolled_back' | ||
| 91 | OR run.thread_id <> context.thread_id | ||
| 92 | ) | ||
| 93 | ORDER BY context.updated_at, context.item_id | ||
| 94 | `.pipe(Effect.mapError(fail)); | ||
| 95 | }); | ||
| 96 | |||
| 97 | return McpAppModelContext.of({ set, forThread }); | ||
| 98 | }), | ||
| 99 | ); | ||
| 100 | |||
| 101 | /** No app context: for code paths and tests with no MCP Apps. */ | ||
| 102 | export const layerEmpty = Layer.succeed( | ||
| 103 | McpAppModelContext, | ||
| 104 | McpAppModelContext.of({ set: () => Effect.void, forThread: () => Effect.succeed([]) }), | ||
| 105 | ); | ||
apps/server/src/mcpApps/McpAppRequests.test.ts created+284| ... | @@ -0,0 +1,284 @@ | ||
| 1 | import { | ||
| 2 | ProviderSessionId, | ||
| 3 | ProviderThreadId, | ||
| 4 | ThreadId, | ||
| 5 | TurnItemId, | ||
| 6 | type McpAppRequestError, | ||
| 7 | type OrchestrationV2ProviderThread, | ||
| 8 | type OrchestrationV2TurnItem, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { assert, describe, it } from "@effect/vitest"; | ||
| 11 | import * as DateTime from "effect/DateTime"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Layer from "effect/Layer"; | ||
| 14 | import * as Option from "effect/Option"; | ||
| 15 | |||
| 16 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; | ||
| 17 | import type { | ||
| 18 | ProviderAdapterV2McpApps, | ||
| 19 | ProviderAdapterV2SessionRuntime, | ||
| 20 | } from "../orchestration-v2/ProviderAdapter.ts"; | ||
| 21 | import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; | ||
| 22 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | ||
| 23 | import * as McpAppModelContext from "./McpAppModelContext.ts"; | ||
| 24 | import * as McpAppRequests from "./McpAppRequests.ts"; | ||
| 25 | |||
| 26 | const threadId = ThreadId.make("thread-app"); | ||
| 27 | const itemId = TurnItemId.make("item-app"); | ||
| 28 | const providerThreadId = ProviderThreadId.make("provider-thread-app"); | ||
| 29 | const providerSessionId = ProviderSessionId.make("provider-session-app"); | ||
| 30 | const forkId = ThreadId.make("thread-fork"); | ||
| 31 | const unrelatedId = ThreadId.make("thread-unrelated"); | ||
| 32 | // A chain of forks deeper than any fixed walk limit: deep-70 → … → deep-1 → app. | ||
| 33 | const deepForkId = (depth: number) => ThreadId.make(`thread-deep-${depth}`); | ||
| 34 | const deepParent = (id: ThreadId): ThreadId | null => { | ||
| 35 | const match = /^thread-deep-(\d+)$/.exec(id); | ||
| 36 | if (match === null) return null; | ||
| 37 | const depth = Number(match[1]); | ||
| 38 | return depth === 1 ? threadId : deepForkId(depth - 1); | ||
| 39 | }; | ||
| 40 | |||
| 41 | const appItem = (output: unknown): OrchestrationV2TurnItem => ({ | ||
| 42 | id: itemId, | ||
| 43 | threadId, | ||
| 44 | runId: null, | ||
| 45 | nodeId: null, | ||
| 46 | providerThreadId, | ||
| 47 | providerTurnId: null, | ||
| 48 | nativeItemRef: null, | ||
| 49 | parentItemId: null, | ||
| 50 | ordinal: 0, | ||
| 51 | status: "completed", | ||
| 52 | title: null, | ||
| 53 | startedAt: null, | ||
| 54 | completedAt: null, | ||
| 55 | updatedAt: DateTime.makeUnsafe(0), | ||
| 56 | type: "dynamic_tool", | ||
| 57 | toolName: "weather.get_weather", | ||
| 58 | input: {}, | ||
| 59 | output, | ||
| 60 | }); | ||
| 61 | |||
| 62 | const app = { | ||
| 63 | attachmentId: "thread-app-00000000-0000-0000-0000-000000000000-html", | ||
| 64 | server: "weather", | ||
| 65 | tool: "get_weather", | ||
| 66 | resourceUri: "ui://weather/dashboard", | ||
| 67 | }; | ||
| 68 | |||
| 69 | /** Each app's stored context, keyed by item, as the real store keeps it. */ | ||
| 70 | const storedContext = new Map<string, string>(); | ||
| 71 | |||
| 72 | function makeLayer(input: { | ||
| 73 | readonly item: OrchestrationV2TurnItem | null; | ||
| 74 | readonly mcpApps?: ProviderAdapterV2McpApps; | ||
| 75 | readonly live?: boolean; | ||
| 76 | }) { | ||
| 77 | return McpAppRequests.layer.pipe( | ||
| 78 | Layer.provide( | ||
| 79 | Layer.mergeAll( | ||
| 80 | Layer.mock(McpAppModelContext.McpAppModelContext)({ | ||
| 81 | set: (entry) => | ||
| 82 | Effect.sync(() => { | ||
| 83 | const key = `${entry.threadId}/${entry.itemId}`; | ||
| 84 | if (entry.text.trim() === "") storedContext.delete(key); | ||
| 85 | else storedContext.set(key, entry.text); | ||
| 86 | }), | ||
| 87 | }), | ||
| 88 | Layer.mock(Orchestrator.OrchestratorV2)({ | ||
| 89 | getTurnItem: () => Effect.succeed(input.item), | ||
| 90 | }), | ||
| 91 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | ||
| 92 | getThreadRecords: (id: ThreadId) => | ||
| 93 | Effect.succeed({ | ||
| 94 | // A fork of the app's thread, and an unrelated thread. | ||
| 95 | thread: { | ||
| 96 | lineage: | ||
| 97 | deepParent(id) !== null | ||
| 98 | ? { | ||
| 99 | parentThreadId: deepParent(id), | ||
| 100 | relationshipToParent: "fork", | ||
| 101 | rootThreadId: threadId, | ||
| 102 | } | ||
| 103 | : id === forkId | ||
| 104 | ? { | ||
| 105 | parentThreadId: threadId, | ||
| 106 | relationshipToParent: "fork", | ||
| 107 | rootThreadId: threadId, | ||
| 108 | } | ||
| 109 | : { parentThreadId: null, relationshipToParent: null, rootThreadId: id }, | ||
| 110 | }, | ||
| 111 | providerThreads: [ | ||
| 112 | { id: providerThreadId, providerSessionId } as OrchestrationV2ProviderThread, | ||
| 113 | ], | ||
| 114 | } as never), | ||
| 115 | }), | ||
| 116 | Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ | ||
| 117 | get: () => | ||
| 118 | Effect.succeed( | ||
| 119 | input.live === false | ||
| 120 | ? Option.none() | ||
| 121 | : Option.some( | ||
| 122 | (input.mcpApps === undefined | ||
| 123 | ? {} | ||
| 124 | : { mcpApps: input.mcpApps }) as ProviderAdapterV2SessionRuntime, | ||
| 125 | ), | ||
| 126 | ), | ||
| 127 | }), | ||
| 128 | ), | ||
| 129 | ), | ||
| 130 | ); | ||
| 131 | } | ||
| 132 | |||
| 133 | const reason = <A, R>(effect: Effect.Effect<A, McpAppRequestError, R>) => | ||
| 134 | effect.pipe( | ||
| 135 | Effect.flip, | ||
| 136 | Effect.map((error) => error.reason), | ||
| 137 | ); | ||
| 138 | |||
| 139 | describe("McpAppRequests", () => { | ||
| 140 | const calls: Array<{ server: string; tool: string }> = []; | ||
| 141 | const mcpApps: ProviderAdapterV2McpApps = { | ||
| 142 | listTools: () => | ||
| 143 | Effect.succeed([ | ||
| 144 | { name: "refresh", annotations: { readOnlyHint: true } }, | ||
| 145 | { name: "model_only", _meta: { ui: { visibility: ["model"] } } }, | ||
| 146 | ]), | ||
| 147 | callTool: (input) => | ||
| 148 | Effect.sync(() => { | ||
| 149 | calls.push({ server: input.server, tool: input.tool }); | ||
| 150 | return { content: [{ type: "text", text: "ok" }] }; | ||
| 151 | }), | ||
| 152 | readResource: () => Effect.succeed({ contents: [] }), | ||
| 153 | }; | ||
| 154 | |||
| 155 | it.effect("calls tools on the app's own server and reports their read-only hint", () => | ||
| 156 | Effect.gen(function* () { | ||
| 157 | const requests = yield* McpAppRequests.McpAppRequests; | ||
| 158 | const result = yield* requests.callTool({ | ||
| 159 | threadId, | ||
| 160 | itemId, | ||
| 161 | name: "refresh", | ||
| 162 | arguments: {}, | ||
| 163 | }); | ||
| 164 | assert.deepEqual(result, { content: [{ type: "text", text: "ok" }] }); | ||
| 165 | assert.deepEqual(calls.at(-1), { server: "weather", tool: "refresh" }); | ||
| 166 | assert.deepEqual(yield* requests.toolInfo({ threadId, itemId, name: "refresh" }), { | ||
| 167 | callable: true, | ||
| 168 | readOnly: true, | ||
| 169 | tool: { name: "refresh", annotations: { readOnlyHint: true } }, | ||
| 170 | }); | ||
| 171 | }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))), | ||
| 172 | ); | ||
| 173 | |||
| 174 | it.effect("refuses tools hidden from apps or missing from the server", () => | ||
| 175 | Effect.gen(function* () { | ||
| 176 | const requests = yield* McpAppRequests.McpAppRequests; | ||
| 177 | const before = calls.length; | ||
| 178 | for (const name of ["model_only", "unknown"]) { | ||
| 179 | assert.equal( | ||
| 180 | yield* reason(requests.callTool({ threadId, itemId, name, arguments: {} })), | ||
| 181 | "tool-not-callable", | ||
| 182 | ); | ||
| 183 | } | ||
| 184 | assert.equal(calls.length, before); | ||
| 185 | }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))), | ||
| 186 | ); | ||
| 187 | |||
| 188 | it.effect("keeps each app's latest model context, without a live session", () => | ||
| 189 | Effect.gen(function* () { | ||
| 190 | const requests = yield* McpAppRequests.McpAppRequests; | ||
| 191 | const own = { threadId, itemId, conversationThreadId: threadId }; | ||
| 192 | // The app's text is stored as sent, whitespace included. | ||
| 193 | yield* requests.updateModelContext({ | ||
| 194 | ...own, | ||
| 195 | content: [{ type: "text", text: " indented value " }], | ||
| 196 | }); | ||
| 197 | assert.equal(storedContext.get(`${threadId}/${itemId}`), " indented value "); | ||
| 198 | // An update replaces the app's context rather than adding to it. | ||
| 199 | yield* requests.updateModelContext({ | ||
| 200 | ...own, | ||
| 201 | content: [{ type: "text", text: "Filtered to overdue" }], | ||
| 202 | structuredContent: { filter: "overdue" }, | ||
| 203 | }); | ||
| 204 | assert.equal( | ||
| 205 | storedContext.get(`${threadId}/${itemId}`), | ||
| 206 | 'Filtered to overdue\n{"filter":"overdue"}', | ||
| 207 | ); | ||
| 208 | assert.equal( | ||
| 209 | yield* reason( | ||
| 210 | requests.updateModelContext({ ...own, content: [{ type: "image", data: "x" }] }), | ||
| 211 | ), | ||
| 212 | "unsupported-content", | ||
| 213 | ); | ||
| 214 | // The cap is in UTF-8 bytes: 6,000 three-byte characters exceed 16 KiB. | ||
| 215 | assert.equal( | ||
| 216 | yield* reason( | ||
| 217 | requests.updateModelContext({ | ||
| 218 | ...own, | ||
| 219 | content: [{ type: "text", text: "表".repeat(6000) }], | ||
| 220 | }), | ||
| 221 | ), | ||
| 222 | "request-failed", | ||
| 223 | ); | ||
| 224 | yield* requests.updateModelContext(own); | ||
| 225 | assert.isFalse(storedContext.has(`${threadId}/${itemId}`)); | ||
| 226 | }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), live: false }))), | ||
| 227 | ); | ||
| 228 | |||
| 229 | it.effect("stores a fork's model context under the fork, and refuses unrelated threads", () => | ||
| 230 | Effect.gen(function* () { | ||
| 231 | const requests = yield* McpAppRequests.McpAppRequests; | ||
| 232 | const content = [{ type: "text", text: "Seen from the fork" }]; | ||
| 233 | yield* requests.updateModelContext({ | ||
| 234 | threadId, | ||
| 235 | itemId, | ||
| 236 | conversationThreadId: forkId, | ||
| 237 | content, | ||
| 238 | }); | ||
| 239 | assert.equal(storedContext.get(`${forkId}/${itemId}`), "Seen from the fork"); | ||
| 240 | assert.isFalse(storedContext.has(`${threadId}/${itemId}`)); | ||
| 241 | assert.equal( | ||
| 242 | yield* reason( | ||
| 243 | requests.updateModelContext({ | ||
| 244 | threadId, | ||
| 245 | itemId, | ||
| 246 | conversationThreadId: unrelatedId, | ||
| 247 | content, | ||
| 248 | }), | ||
| 249 | ), | ||
| 250 | "not-an-app", | ||
| 251 | ); | ||
| 252 | // However many forks deep, a descendant still counts. | ||
| 253 | yield* requests.updateModelContext({ | ||
| 254 | threadId, | ||
| 255 | itemId, | ||
| 256 | conversationThreadId: deepForkId(70), | ||
| 257 | content, | ||
| 258 | }); | ||
| 259 | assert.isTrue(storedContext.has(`${deepForkId(70)}/${itemId}`)); | ||
| 260 | }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), live: false }))), | ||
| 261 | ); | ||
| 262 | |||
| 263 | it.effect("reports why a request cannot run", () => | ||
| 264 | Effect.gen(function* () { | ||
| 265 | const run = (layer: ReturnType<typeof makeLayer>) => | ||
| 266 | Effect.gen(function* () { | ||
| 267 | const requests = yield* McpAppRequests.McpAppRequests; | ||
| 268 | return yield* reason(requests.readResource({ threadId, itemId, uri: "ui://weather/x" })); | ||
| 269 | }).pipe(Effect.provide(layer)); | ||
| 270 | assert.equal( | ||
| 271 | yield* run(makeLayer({ item: appItem({ result: "plain" }), mcpApps })), | ||
| 272 | "not-an-app", | ||
| 273 | ); | ||
| 274 | assert.equal( | ||
| 275 | yield* run(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps, live: false })), | ||
| 276 | "session-stopped", | ||
| 277 | ); | ||
| 278 | assert.equal( | ||
| 279 | yield* run(makeLayer({ item: appItem({ t3McpApp: app }) })), | ||
| 280 | "provider-unsupported", | ||
| 281 | ); | ||
| 282 | }), | ||
| 283 | ); | ||
| 284 | }); | ||
apps/server/src/mcpApps/McpAppRequests.ts created+240| ... | @@ -0,0 +1,240 @@ | ||
| 1 | import { | ||
| 2 | McpAppRequestError, | ||
| 3 | type McpAppCallToolInput, | ||
| 4 | type McpAppCallToolResult, | ||
| 5 | type McpAppReadResourceInput, | ||
| 6 | type McpAppReadResourceResult, | ||
| 7 | type McpAppToolInfo, | ||
| 8 | type McpAppToolInfoInput, | ||
| 9 | type McpAppUpdateModelContextInput, | ||
| 10 | type OrchestrationV2AppThread, | ||
| 11 | type ThreadId, | ||
| 12 | type TurnItemId, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import { mcpAppToolCallableByApp, type McpAppReference } from "@t3tools/shared/mcpApp"; | ||
| 15 | import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; | ||
| 16 | import * as Context from "effect/Context"; | ||
| 17 | import * as Effect from "effect/Effect"; | ||
| 18 | import * as Layer from "effect/Layer"; | ||
| 19 | import * as Option from "effect/Option"; | ||
| 20 | import * as Predicate from "effect/Predicate"; | ||
| 21 | import * as Schema from "effect/Schema"; | ||
| 22 | |||
| 23 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; | ||
| 24 | import type { | ||
| 25 | ProviderAdapterV2McpApps, | ||
| 26 | ProviderAdapterV2McpTool, | ||
| 27 | } from "../orchestration-v2/ProviderAdapter.ts"; | ||
| 28 | import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; | ||
| 29 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; | ||
| 30 | import * as McpAppModelContext from "./McpAppModelContext.ts"; | ||
| 31 | |||
| 32 | /** | ||
| 33 | * Requests an MCP App makes of its own MCP server. The app is resolved from the | ||
| 34 | * tool call that produced it, so it reaches only that server, and each request | ||
| 35 | * runs through the thread's live provider session, whose MCP client owns the | ||
| 36 | * connection and its credentials. A stopped session is reported, never | ||
| 37 | * started: an app view must not spend a provider turn. | ||
| 38 | */ | ||
| 39 | export class McpAppRequests extends Context.Service< | ||
| 40 | McpAppRequests, | ||
| 41 | { | ||
| 42 | readonly callTool: ( | ||
| 43 | input: McpAppCallToolInput, | ||
| 44 | ) => Effect.Effect<McpAppCallToolResult, McpAppRequestError>; | ||
| 45 | readonly toolInfo: ( | ||
| 46 | input: McpAppToolInfoInput, | ||
| 47 | ) => Effect.Effect<McpAppToolInfo, McpAppRequestError>; | ||
| 48 | readonly readResource: ( | ||
| 49 | input: McpAppReadResourceInput, | ||
| 50 | ) => Effect.Effect<McpAppReadResourceResult, McpAppRequestError>; | ||
| 51 | readonly updateModelContext: ( | ||
| 52 | input: McpAppUpdateModelContextInput, | ||
| 53 | ) => Effect.Effect<void, McpAppRequestError>; | ||
| 54 | } | ||
| 55 | >()("t3/mcpApps/McpAppRequests") {} | ||
| 56 | |||
| 57 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 58 | const utf8 = new TextEncoder(); | ||
| 59 | |||
| 60 | const readOnlyHint = (tool: ProviderAdapterV2McpTool) => | ||
| 61 | Predicate.isObject(tool.annotations) && tool.annotations.readOnlyHint === true; | ||
| 62 | |||
| 63 | const toolTitle = (tool: ProviderAdapterV2McpTool) => | ||
| 64 | Predicate.isObject(tool.annotations) && typeof tool.annotations.title === "string" | ||
| 65 | ? tool.annotations.title | ||
| 66 | : undefined; | ||
| 67 | |||
| 68 | const make = Effect.gen(function* () { | ||
| 69 | const orchestrator = yield* Orchestrator.OrchestratorV2; | ||
| 70 | const threadManagement = yield* ThreadManagementService.ThreadManagementService; | ||
| 71 | const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; | ||
| 72 | const modelContext = yield* McpAppModelContext.McpAppModelContext; | ||
| 73 | |||
| 74 | const fail = (threadId: ThreadId, reason: McpAppRequestError["reason"], cause?: unknown) => | ||
| 75 | new McpAppRequestError({ threadId, reason, ...(cause === undefined ? {} : { cause }) }); | ||
| 76 | |||
| 77 | /** The app a tool call produced, read from the stored item. */ | ||
| 78 | const resolveApp = Effect.fn("McpAppRequests.resolveApp")(function* (input: { | ||
| 79 | readonly threadId: ThreadId; | ||
| 80 | readonly itemId: TurnItemId; | ||
| 81 | }) { | ||
| 82 | // The stored item, not the wire projection, so the reference is intact. | ||
| 83 | const item = yield* orchestrator | ||
| 84 | .getTurnItem({ threadId: input.threadId, itemId: input.itemId }) | ||
| 85 | .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); | ||
| 86 | const app: McpAppReference | undefined = | ||
| 87 | item?.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined; | ||
| 88 | if (item === null || app === undefined || item.providerThreadId === null) { | ||
| 89 | return yield* fail(input.threadId, "not-an-app"); | ||
| 90 | } | ||
| 91 | return { app, providerThreadId: item.providerThreadId }; | ||
| 92 | }); | ||
| 93 | |||
| 94 | /** The app, its provider thread, and the live session's MCP Apps operations. */ | ||
| 95 | const resolve = Effect.fn("McpAppRequests.resolve")(function* (input: { | ||
| 96 | readonly threadId: ThreadId; | ||
| 97 | readonly itemId: TurnItemId; | ||
| 98 | }) { | ||
| 99 | const { app, providerThreadId } = yield* resolveApp(input); | ||
| 100 | const projection = yield* threadManagement | ||
| 101 | .getThreadRecords(input.threadId, ["providerThreads"]) | ||
| 102 | .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); | ||
| 103 | const providerThread = projection.providerThreads.find( | ||
| 104 | (candidate) => candidate.id === providerThreadId, | ||
| 105 | ); | ||
| 106 | if (providerThread?.providerSessionId == null) { | ||
| 107 | return yield* fail(input.threadId, "session-stopped"); | ||
| 108 | } | ||
| 109 | const runtime = Option.getOrUndefined( | ||
| 110 | yield* providerSessions | ||
| 111 | .get(providerThread.providerSessionId) | ||
| 112 | .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))), | ||
| 113 | ); | ||
| 114 | if (runtime === undefined) return yield* fail(input.threadId, "session-stopped"); | ||
| 115 | const mcpApps: ProviderAdapterV2McpApps | undefined = runtime.mcpApps; | ||
| 116 | if (mcpApps === undefined) return yield* fail(input.threadId, "provider-unsupported"); | ||
| 117 | return { app, providerThread, mcpApps }; | ||
| 118 | }); | ||
| 119 | |||
| 120 | const findTool = Effect.fn("McpAppRequests.findTool")(function* ( | ||
| 121 | threadId: ThreadId, | ||
| 122 | resolved: Effect.Success<ReturnType<typeof resolve>>, | ||
| 123 | name: string, | ||
| 124 | ) { | ||
| 125 | const tools = yield* resolved.mcpApps | ||
| 126 | .listTools({ providerThread: resolved.providerThread, server: resolved.app.server }) | ||
| 127 | .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause))); | ||
| 128 | return tools.find((tool) => tool.name === name); | ||
| 129 | }); | ||
| 130 | |||
| 131 | const toolInfo = Effect.fn("McpAppRequests.toolInfo")(function* (input: McpAppToolInfoInput) { | ||
| 132 | const resolved = yield* resolve(input); | ||
| 133 | const tool = yield* findTool(input.threadId, resolved, input.name); | ||
| 134 | const title = tool === undefined ? undefined : toolTitle(tool); | ||
| 135 | return { | ||
| 136 | callable: tool !== undefined && mcpAppToolCallableByApp(tool._meta), | ||
| 137 | readOnly: tool !== undefined && readOnlyHint(tool), | ||
| 138 | ...(title === undefined ? {} : { title }), | ||
| 139 | ...(tool === undefined ? {} : { tool }), | ||
| 140 | } satisfies McpAppToolInfo; | ||
| 141 | }); | ||
| 142 | |||
| 143 | const callTool = Effect.fn("McpAppRequests.callTool")(function* (input: McpAppCallToolInput) { | ||
| 144 | const resolved = yield* resolve(input); | ||
| 145 | // The spec forbids apps calling tools hidden from them; checked here too, | ||
| 146 | // since a client's own check is only a courtesy to its user. | ||
| 147 | const tool = yield* findTool(input.threadId, resolved, input.name); | ||
| 148 | if (tool === undefined || !mcpAppToolCallableByApp(tool._meta)) { | ||
| 149 | return yield* fail(input.threadId, "tool-not-callable"); | ||
| 150 | } | ||
| 151 | return yield* resolved.mcpApps | ||
| 152 | .callTool({ | ||
| 153 | providerThread: resolved.providerThread, | ||
| 154 | server: resolved.app.server, | ||
| 155 | tool: input.name, | ||
| 156 | arguments: input.arguments, | ||
| 157 | }) | ||
| 158 | .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); | ||
| 159 | }); | ||
| 160 | |||
| 161 | const readResource = Effect.fn("McpAppRequests.readResource")(function* ( | ||
| 162 | input: McpAppReadResourceInput, | ||
| 163 | ) { | ||
| 164 | const resolved = yield* resolve(input); | ||
| 165 | return yield* resolved.mcpApps | ||
| 166 | .readResource({ | ||
| 167 | providerThread: resolved.providerThread, | ||
| 168 | server: resolved.app.server, | ||
| 169 | uri: input.uri, | ||
| 170 | }) | ||
| 171 | .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); | ||
| 172 | }); | ||
| 173 | |||
| 174 | // Context is state for the agent's next turn, so it needs the app but not a | ||
| 175 | // live session. Text blocks are kept as written and structured content as | ||
| 176 | // JSON; other content kinds are not declared by the host. | ||
| 177 | /** Whether `threadId` is `ancestorId` or forked from it, however many forks deep. */ | ||
| 178 | const descendsFrom = Effect.fn("McpAppRequests.descendsFrom")(function* ( | ||
| 179 | threadId: ThreadId, | ||
| 180 | ancestorId: ThreadId, | ||
| 181 | ) { | ||
| 182 | // Walks fork links until the chain ends; a thread seen twice means a | ||
| 183 | // corrupt cycle, which proves nothing. | ||
| 184 | const seen = new Set<ThreadId>(); | ||
| 185 | let current: ThreadId | null = threadId; | ||
| 186 | while (current !== null && !seen.has(current)) { | ||
| 187 | if (current === ancestorId) return true; | ||
| 188 | seen.add(current); | ||
| 189 | const records: { readonly thread: OrchestrationV2AppThread } = yield* threadManagement | ||
| 190 | .getThreadRecords(current, []) | ||
| 191 | .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause))); | ||
| 192 | const { lineage } = records.thread; | ||
| 193 | current = lineage.relationshipToParent === "fork" ? lineage.parentThreadId : null; | ||
| 194 | } | ||
| 195 | return false; | ||
| 196 | }); | ||
| 197 | |||
| 198 | const updateModelContext = Effect.fn("McpAppRequests.updateModelContext")(function* ( | ||
| 199 | input: McpAppUpdateModelContextInput, | ||
| 200 | ) { | ||
| 201 | const { app } = yield* resolveApp(input); | ||
| 202 | // Context belongs to the conversation on screen, which only shows this app | ||
| 203 | // when it is the app's own thread or one forked from it. | ||
| 204 | if (!(yield* descendsFrom(input.conversationThreadId, input.threadId))) { | ||
| 205 | return yield* fail(input.threadId, "not-an-app"); | ||
| 206 | } | ||
| 207 | const texts: Array<string> = []; | ||
| 208 | for (const block of input.content ?? []) { | ||
| 209 | if (!Predicate.isObject(block) || block.type !== "text" || typeof block.text !== "string") { | ||
| 210 | return yield* fail(input.threadId, "unsupported-content"); | ||
| 211 | } | ||
| 212 | texts.push(block.text); | ||
| 213 | } | ||
| 214 | if (input.structuredContent !== undefined) { | ||
| 215 | texts.push(encodeJson(input.structuredContent)); | ||
| 216 | } | ||
| 217 | // Kept as sent; blank text clears the app's context in the store. | ||
| 218 | const text = texts.join("\n"); | ||
| 219 | if (utf8.encode(text).byteLength > McpAppModelContext.MCP_APP_MODEL_CONTEXT_MAX_BYTES) { | ||
| 220 | return yield* fail( | ||
| 221 | input.threadId, | ||
| 222 | "request-failed", | ||
| 223 | new Error("Model context is too large."), | ||
| 224 | ); | ||
| 225 | } | ||
| 226 | yield* modelContext | ||
| 227 | .set({ | ||
| 228 | threadId: input.conversationThreadId, | ||
| 229 | itemId: input.itemId, | ||
| 230 | server: app.server, | ||
| 231 | tool: app.tool, | ||
| 232 | text, | ||
| 233 | }) | ||
| 234 | .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); | ||
| 235 | }); | ||
| 236 | |||
| 237 | return McpAppRequests.of({ callTool, toolInfo, readResource, updateModelContext }); | ||
| 238 | }); | ||
| 239 | |||
| 240 | export const layer = Layer.effect(McpAppRequests, make); | ||
apps/server/src/mcpApps/McpAppSnapshot.ts created+108| ... | @@ -0,0 +1,108 @@ | ||
| 1 | import type { ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { | ||
| 3 | injectMcpAppCsp, | ||
| 4 | MCP_APP_MAX_HTML_BYTES, | ||
| 5 | MCP_APP_MIME_TYPE, | ||
| 6 | MCP_APP_RESOURCE_SCHEME, | ||
| 7 | readMcpAppCsp, | ||
| 8 | readMcpAppPermissions, | ||
| 9 | type McpAppReference, | ||
| 10 | } from "@t3tools/shared/mcpApp"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as FileSystem from "effect/FileSystem"; | ||
| 13 | import * as Predicate from "effect/Predicate"; | ||
| 14 | |||
| 15 | import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; | ||
| 16 | import { createAttachmentId } from "../attachmentStore.ts"; | ||
| 17 | |||
| 18 | const encoder = new TextEncoder(); | ||
| 19 | |||
| 20 | /** | ||
| 21 | * The HTML document in a `resources/read` result, with the `_meta.ui` the | ||
| 22 | * resource declared. Only `text/html;profile=mcp-app` content counts; a blob is | ||
| 23 | * base64 per MCP. A null document is one too large to store. | ||
| 24 | */ | ||
| 25 | function readMcpAppDocument( | ||
| 26 | contents: ReadonlyArray<unknown>, | ||
| 27 | uri: string, | ||
| 28 | ): { readonly html: string | null; readonly meta: unknown } | undefined { | ||
| 29 | for (const content of contents) { | ||
| 30 | if (!Predicate.isObject(content)) continue; | ||
| 31 | if (content.uri !== undefined && content.uri !== uri) continue; | ||
| 32 | const mimeType = typeof content.mimeType === "string" ? content.mimeType : ""; | ||
| 33 | if (mimeType.replace(/\s+/g, "").toLowerCase() !== MCP_APP_MIME_TYPE) continue; | ||
| 34 | // Sizes are checked before decoding, so an oversized resource is | ||
| 35 | // refused without first being copied: a string's UTF-8 form is at least | ||
| 36 | // its length, and base64 decodes to three quarters of its own. | ||
| 37 | const html = | ||
| 38 | typeof content.text === "string" | ||
| 39 | ? content.text.length > MCP_APP_MAX_HTML_BYTES | ||
| 40 | ? null | ||
| 41 | : content.text | ||
| 42 | : typeof content.blob === "string" | ||
| 43 | ? content.blob.length > Math.ceil(MCP_APP_MAX_HTML_BYTES / 3) * 4 | ||
| 44 | ? null | ||
| 45 | : Buffer.from(content.blob, "base64").toString("utf8") | ||
| 46 | : undefined; | ||
| 47 | if (html === null) return { html: null, meta: undefined }; | ||
| 48 | if (html === undefined || html.trim() === "") continue; | ||
| 49 | const meta = Predicate.isObject(content._meta) ? content._meta.ui : undefined; | ||
| 50 | return { html, meta }; | ||
| 51 | } | ||
| 52 | return undefined; | ||
| 53 | } | ||
| 54 | |||
| 55 | /** | ||
| 56 | * Stores an app's document as a thread attachment and returns the reference | ||
| 57 | * clients host it from. Saving a snapshot keeps the app viewable after the | ||
| 58 | * provider exits; interaction still goes through a live provider session. | ||
| 59 | * Undefined when the resource is not an MCP App document or is too large. | ||
| 60 | */ | ||
| 61 | export const snapshotMcpApp = Effect.fn("McpAppSnapshot.snapshot")(function* (input: { | ||
| 62 | readonly attachmentsDir: string; | ||
| 63 | readonly threadId: ThreadId; | ||
| 64 | readonly server: string; | ||
| 65 | readonly tool: string; | ||
| 66 | readonly resourceUri: string; | ||
| 67 | readonly contents: ReadonlyArray<unknown>; | ||
| 68 | }) { | ||
| 69 | if (!input.resourceUri.startsWith(MCP_APP_RESOURCE_SCHEME)) return undefined; | ||
| 70 | const document = readMcpAppDocument(input.contents, input.resourceUri); | ||
| 71 | if (document === undefined) return undefined; | ||
| 72 | if (document.html === null || encoder.encode(document.html).byteLength > MCP_APP_MAX_HTML_BYTES) { | ||
| 73 | yield* Effect.logWarning("MCP app document exceeds the size limit.", { | ||
| 74 | server: input.server, | ||
| 75 | resourceUri: input.resourceUri, | ||
| 76 | }); | ||
| 77 | return undefined; | ||
| 78 | } | ||
| 79 | // The `-html` suffix makes the asset route serve it as a sandboxed document. | ||
| 80 | const attachmentId = createAttachmentId(input.threadId, "html"); | ||
| 81 | const filePath = | ||
| 82 | attachmentId === null | ||
| 83 | ? null | ||
| 84 | : resolveAttachmentRelativePath({ | ||
| 85 | attachmentsDir: input.attachmentsDir, | ||
| 86 | relativePath: `${attachmentId}.html`, | ||
| 87 | }); | ||
| 88 | if (attachmentId === null || filePath === null) return undefined; | ||
| 89 | const html = document.html; | ||
| 90 | const ui = Predicate.isObject(document.meta) ? document.meta : {}; | ||
| 91 | const csp = readMcpAppCsp(ui.csp); | ||
| 92 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 93 | // Only the returned reference lets thread deletion find the document, so a | ||
| 94 | // write that fails or is interrupted removes it. | ||
| 95 | yield* fileSystem | ||
| 96 | .writeFileString(filePath, injectMcpAppCsp(html, csp)) | ||
| 97 | .pipe(Effect.onError(() => fileSystem.remove(filePath, { force: true }).pipe(Effect.ignore))); | ||
| 98 | const permissions = readMcpAppPermissions(ui.permissions); | ||
| 99 | return { | ||
| 100 | attachmentId, | ||
| 101 | server: input.server, | ||
| 102 | tool: input.tool, | ||
| 103 | resourceUri: input.resourceUri, | ||
| 104 | ...(csp === undefined ? {} : { csp }), | ||
| 105 | ...(permissions === undefined ? {} : { permissions }), | ||
| 106 | ...(typeof ui.prefersBorder === "boolean" ? { prefersBorder: ui.prefersBorder } : {}), | ||
| 107 | } satisfies McpAppReference; | ||
| 108 | }); | ||
apps/server/src/observability/RpcInstrumentation.ts+4| ... | @@ -142,6 +142,10 @@ const RPC_AGGREGATES = { | ... | @@ -142,6 +142,10 @@ const RPC_AGGREGATES = { |
| 142 | [WS_METHODS.attachmentsCreateUploadUrl]: "workspace", | 142 | [WS_METHODS.attachmentsCreateUploadUrl]: "workspace", |
| 143 | [WS_METHODS.attachmentsDelete]: "workspace", | 143 | [WS_METHODS.attachmentsDelete]: "workspace", |
| 144 | [WS_METHODS.providerUploadFeedback]: "provider", | 144 | [WS_METHODS.providerUploadFeedback]: "provider", |
| 145 | [WS_METHODS.mcpAppsCallTool]: "provider", | ||
| 146 | [WS_METHODS.mcpAppsToolInfo]: "provider", | ||
| 147 | [WS_METHODS.mcpAppsUpdateModelContext]: "provider", | ||
| 148 | [WS_METHODS.mcpAppsReadResource]: "provider", | ||
| 145 | [WS_METHODS.subscribeVcsStatus]: "vcs", | 149 | [WS_METHODS.subscribeVcsStatus]: "vcs", |
| 146 | [WS_METHODS.subscribeWorktreeSetup]: "vcs", | 150 | [WS_METHODS.subscribeWorktreeSetup]: "vcs", |
| 147 | [WS_METHODS.worktreeSetupCancel]: "vcs", | 151 | [WS_METHODS.worktreeSetupCancel]: "vcs", |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+289| ... | @@ -18,6 +18,7 @@ import { | ... | @@ -18,6 +18,7 @@ import { |
| 18 | type OrchestrationV2AppThread, | 18 | type OrchestrationV2AppThread, |
| 19 | type OrchestrationV2ProviderThread, | 19 | type OrchestrationV2ProviderThread, |
| 20 | type OrchestrationV2ProviderTurn, | 20 | type OrchestrationV2ProviderTurn, |
| 21 | type OrchestrationV2TurnItem, | ||
| 21 | ProjectId, | 22 | ProjectId, |
| 22 | ProviderInstanceId, | 23 | ProviderInstanceId, |
| 23 | ProviderSessionId, | 24 | ProviderSessionId, |
| ... | @@ -40,6 +41,8 @@ import * as Crypto from "effect/Crypto"; | ... | @@ -40,6 +41,8 @@ import * as Crypto from "effect/Crypto"; |
| 40 | import * as Effect from "effect/Effect"; | 41 | import * as Effect from "effect/Effect"; |
| 41 | import * as Predicate from "effect/Predicate"; | 42 | import * as Predicate from "effect/Predicate"; |
| 42 | import * as FileSystem from "effect/FileSystem"; | 43 | import * as FileSystem from "effect/FileSystem"; |
| 44 | import { MCP_APP_OUTPUT_KEY, readMcpAppReference } from "@t3tools/shared/mcpApp"; | ||
| 45 | import { resolveAttachmentPathById } from "../../attachmentStore.ts"; | ||
| 43 | import * as Fiber from "effect/Fiber"; | 46 | import * as Fiber from "effect/Fiber"; |
| 44 | import * as Layer from "effect/Layer"; | 47 | import * as Layer from "effect/Layer"; |
| 45 | import * as Path from "effect/Path"; | 48 | import * as Path from "effect/Path"; |
| ... | @@ -463,6 +466,38 @@ describe("CodexAdapterV2 runtime policy", () => { | ... | @@ -463,6 +466,38 @@ describe("CodexAdapterV2 runtime policy", () => { |
| 463 | }), | 466 | }), |
| 464 | ); | 467 | ); |
| 465 | 468 | ||
| 469 | it.effect("sends MCP app model context as untrusted Codex context", () => | ||
| 470 | Effect.gen(function* () { | ||
| 471 | const policy = { runtimeMode: "full-access", interactionMode: "default", cwd: null } as const; | ||
| 472 | const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }; | ||
| 473 | const appContext = [{ key: "mcp_app_todos_list_todos_item-1", text: "Filtered to overdue" }]; | ||
| 474 | const alone = yield* CodexAdapterV2.buildCodexTurnStartParams({ | ||
| 475 | nativeThreadId: "native-app-context", | ||
| 476 | codexInput: [{ type: "text", text: "what's on my list?" }], | ||
| 477 | runtimePolicy: policy, | ||
| 478 | modelSelection, | ||
| 479 | appContext, | ||
| 480 | }); | ||
| 481 | assert.deepEqual(alone.additionalContext, { | ||
| 482 | "mcp_app_todos_list_todos_item-1": { kind: "untrusted", value: "Filtered to overdue" }, | ||
| 483 | }); | ||
| 484 | // Alongside T3's own context, both are kept. | ||
| 485 | const withT3 = yield* CodexAdapterV2.buildCodexTurnStartParams({ | ||
| 486 | nativeThreadId: "native-app-context", | ||
| 487 | codexInput: [{ type: "text", text: "what's on my list?" }], | ||
| 488 | runtimePolicy: policy, | ||
| 489 | modelSelection, | ||
| 490 | hasT3Mcp: true, | ||
| 491 | appContext, | ||
| 492 | }); | ||
| 493 | assert.deepEqual(withT3.additionalContext?.["mcp_app_todos_list_todos_item-1"], { | ||
| 494 | kind: "untrusted", | ||
| 495 | value: "Filtered to overdue", | ||
| 496 | }); | ||
| 497 | assert.isDefined(withT3.additionalContext?.["t3_code_runtime"]); | ||
| 498 | }), | ||
| 499 | ); | ||
| 500 | |||
| 466 | it.effect("adds default-mode developer instructions when the T3 MCP server is attached", () => | 501 | it.effect("adds default-mode developer instructions when the T3 MCP server is attached", () => |
| 467 | Effect.gen(function* () { | 502 | Effect.gen(function* () { |
| 468 | const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ | 503 | const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ |
| ... | @@ -1548,6 +1583,9 @@ function codexReplayPreamble(input: { | ... | @@ -1548,6 +1583,9 @@ function codexReplayPreamble(input: { |
| 1548 | capabilities: { | 1583 | capabilities: { |
| 1549 | experimentalApi: true, | 1584 | experimentalApi: true, |
| 1550 | optOutNotificationMethods: ["turn/diff/updated"], | 1585 | optOutNotificationMethods: ["turn/diff/updated"], |
| 1586 | extensions: { | ||
| 1587 | "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] }, | ||
| 1588 | }, | ||
| 1551 | }, | 1589 | }, |
| 1552 | }, | 1590 | }, |
| 1553 | }, | 1591 | }, |
| ... | @@ -1972,6 +2010,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { | ... | @@ -1972,6 +2010,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 1972 | runtime, | 2010 | runtime, |
| 1973 | providerThread, | 2011 | providerThread, |
| 1974 | threadId, | 2012 | threadId, |
| 2013 | serverConfig, | ||
| 1975 | events, | 2014 | events, |
| 1976 | continuationRequests, | 2015 | continuationRequests, |
| 1977 | terminalEvents, | 2016 | terminalEvents, |
| ... | @@ -2113,6 +2152,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { | ... | @@ -2113,6 +2152,9 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 2113 | capabilities: { | 2152 | capabilities: { |
| 2114 | experimentalApi: true, | 2153 | experimentalApi: true, |
| 2115 | optOutNotificationMethods: ["turn/diff/updated"], | 2154 | optOutNotificationMethods: ["turn/diff/updated"], |
| 2155 | extensions: { | ||
| 2156 | "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] }, | ||
| 2157 | }, | ||
| 2116 | }, | 2158 | }, |
| 2117 | }, | 2159 | }, |
| 2118 | ]); | 2160 | ]); |
| ... | @@ -6305,6 +6347,253 @@ describe("CodexAdapterV2 post-settle continuation", () => { | ... | @@ -6305,6 +6347,253 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 6305 | ), | 6347 | ), |
| 6306 | ); | 6348 | ); |
| 6307 | 6349 | ||
| 6350 | const MCP_APP_SCENARIO = "codex-mcp-app-capture"; | ||
| 6351 | const MCP_APP_NATIVE_THREAD = "native-codex-mcp-app-thread"; | ||
| 6352 | const MCP_APP_NATIVE_TURN = "native-codex-mcp-app-turn"; | ||
| 6353 | const MCP_APP_ITEM = "mcp-weather-call"; | ||
| 6354 | const MCP_APP_PROMPT = "Show the weather in Oslo."; | ||
| 6355 | const MCP_APP_RESOURCE = "ui://weather/dashboard"; | ||
| 6356 | const MCP_APP_HTML = "<!doctype html><html><body><p>Weather</p><script>1</script></body></html>"; | ||
| 6357 | const mcpAppToolItem = (status: "inProgress" | "completed") => ({ | ||
| 6358 | type: "mcpToolCall", | ||
| 6359 | id: MCP_APP_ITEM, | ||
| 6360 | server: "weather", | ||
| 6361 | tool: "get_weather", | ||
| 6362 | status, | ||
| 6363 | arguments: { city: "Oslo" }, | ||
| 6364 | mcpAppResourceUri: MCP_APP_RESOURCE, | ||
| 6365 | ...(status === "completed" | ||
| 6366 | ? { result: { content: [{ type: "text", text: "Sunny" }], structuredContent: { temp: 21 } } } | ||
| 6367 | : {}), | ||
| 6368 | }); | ||
| 6369 | |||
| 6370 | const mcpAppTranscript = makeCodexReplayTranscript({ | ||
| 6371 | scenario: MCP_APP_SCENARIO, | ||
| 6372 | entries: [ | ||
| 6373 | ...codexReplayPreamble({ | ||
| 6374 | nativeThreadId: MCP_APP_NATIVE_THREAD, | ||
| 6375 | nativeTurnId: MCP_APP_NATIVE_TURN, | ||
| 6376 | prompt: MCP_APP_PROMPT, | ||
| 6377 | }), | ||
| 6378 | { | ||
| 6379 | type: "emit_inbound", | ||
| 6380 | label: "item/started/app-tool", | ||
| 6381 | frame: { | ||
| 6382 | method: "item/started", | ||
| 6383 | params: { | ||
| 6384 | item: mcpAppToolItem("inProgress"), | ||
| 6385 | threadId: MCP_APP_NATIVE_THREAD, | ||
| 6386 | turnId: MCP_APP_NATIVE_TURN, | ||
| 6387 | startedAtMs: 1782622440500, | ||
| 6388 | }, | ||
| 6389 | }, | ||
| 6390 | }, | ||
| 6391 | { | ||
| 6392 | type: "emit_inbound", | ||
| 6393 | label: "item/completed/app-tool", | ||
| 6394 | frame: { | ||
| 6395 | method: "item/completed", | ||
| 6396 | params: { | ||
| 6397 | item: mcpAppToolItem("completed"), | ||
| 6398 | threadId: MCP_APP_NATIVE_THREAD, | ||
| 6399 | turnId: MCP_APP_NATIVE_TURN, | ||
| 6400 | completedAtMs: 1782622441500, | ||
| 6401 | }, | ||
| 6402 | }, | ||
| 6403 | }, | ||
| 6404 | { | ||
| 6405 | type: "emit_inbound", | ||
| 6406 | label: "turn/completed", | ||
| 6407 | frame: { | ||
| 6408 | method: "turn/completed", | ||
| 6409 | params: { | ||
| 6410 | threadId: MCP_APP_NATIVE_THREAD, | ||
| 6411 | turn: makeCodexReplayTurn({ id: MCP_APP_NATIVE_TURN, status: "completed" }), | ||
| 6412 | }, | ||
| 6413 | }, | ||
| 6414 | }, | ||
| 6415 | { | ||
| 6416 | type: "expect_outbound", | ||
| 6417 | label: "mcpServer/resource/read", | ||
| 6418 | frame: { | ||
| 6419 | id: 4, | ||
| 6420 | method: "mcpServer/resource/read", | ||
| 6421 | params: { threadId: MCP_APP_NATIVE_THREAD, server: "weather", uri: MCP_APP_RESOURCE }, | ||
| 6422 | }, | ||
| 6423 | }, | ||
| 6424 | { | ||
| 6425 | type: "emit_inbound", | ||
| 6426 | label: "mcpServer/resource/read", | ||
| 6427 | frame: { | ||
| 6428 | id: 4, | ||
| 6429 | result: { | ||
| 6430 | contents: [ | ||
| 6431 | { | ||
| 6432 | uri: MCP_APP_RESOURCE, | ||
| 6433 | mimeType: "text/html;profile=mcp-app", | ||
| 6434 | text: MCP_APP_HTML, | ||
| 6435 | _meta: { ui: { csp: { connectDomains: ["https://api.weather.test"] } } }, | ||
| 6436 | }, | ||
| 6437 | ], | ||
| 6438 | }, | ||
| 6439 | }, | ||
| 6440 | }, | ||
| 6441 | ], | ||
| 6442 | }); | ||
| 6443 | |||
| 6444 | it.effect("captures an MCP app's resource and holds the turn open until it lands", () => | ||
| 6445 | Effect.scoped( | ||
| 6446 | Effect.gen(function* () { | ||
| 6447 | const appCaptured = yield* Deferred.make<OrchestrationV2TurnItem>(); | ||
| 6448 | const harness = yield* makeCodexReplayHarness(mcpAppTranscript, (event) => | ||
| 6449 | event.type === "turn_item.updated" && | ||
| 6450 | event.turnItem.type === "dynamic_tool" && | ||
| 6451 | event.turnItem.status === "completed" | ||
| 6452 | ? Deferred.succeed(appCaptured, event.turnItem) | ||
| 6453 | : Effect.void, | ||
| 6454 | ); | ||
| 6455 | const now = yield* DateTime.now; | ||
| 6456 | |||
| 6457 | yield* harness.runtime.startTurn( | ||
| 6458 | makeCodexTestTurnInput({ | ||
| 6459 | threadId: harness.threadId, | ||
| 6460 | providerThread: harness.providerThread, | ||
| 6461 | now, | ||
| 6462 | attemptId: RunAttemptId.make("attempt-codex-mcp-app"), | ||
| 6463 | text: MCP_APP_PROMPT, | ||
| 6464 | }), | ||
| 6465 | ); | ||
| 6466 | const item = yield* Deferred.await(appCaptured); | ||
| 6467 | assert.equal(harness.terminalEvents()[0]?.status, "completed"); | ||
| 6468 | |||
| 6469 | // The completed tool row is emitted once, with the app, never as a bare row first. | ||
| 6470 | const completedRows = harness.events.filter( | ||
| 6471 | (event) => | ||
| 6472 | event.type === "turn_item.updated" && | ||
| 6473 | event.turnItem.type === "dynamic_tool" && | ||
| 6474 | event.turnItem.status === "completed", | ||
| 6475 | ); | ||
| 6476 | assert.lengthOf(completedRows, 1); | ||
| 6477 | |||
| 6478 | assert.equal(item.type, "dynamic_tool"); | ||
| 6479 | const output = item.type === "dynamic_tool" ? (item.output as Record<string, unknown>) : {}; | ||
| 6480 | const reference = readMcpAppReference(output[MCP_APP_OUTPUT_KEY]); | ||
| 6481 | assert.deepEqual( | ||
| 6482 | { ...reference, attachmentId: undefined }, | ||
| 6483 | { | ||
| 6484 | attachmentId: undefined, | ||
| 6485 | server: "weather", | ||
| 6486 | tool: "get_weather", | ||
| 6487 | resourceUri: MCP_APP_RESOURCE, | ||
| 6488 | csp: { connectDomains: ["https://api.weather.test"] }, | ||
| 6489 | }, | ||
| 6490 | ); | ||
| 6491 | assert.deepEqual(output.result, { | ||
| 6492 | content: [{ type: "text", text: "Sunny" }], | ||
| 6493 | structuredContent: { temp: 21 }, | ||
| 6494 | }); | ||
| 6495 | |||
| 6496 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 6497 | const stored = yield* fileSystem.readFileString( | ||
| 6498 | resolveAttachmentPathById({ | ||
| 6499 | attachmentsDir: harness.serverConfig.attachmentsDir, | ||
| 6500 | attachmentId: reference!.attachmentId, | ||
| 6501 | })!, | ||
| 6502 | ); | ||
| 6503 | // Stored with the app's declared policy ahead of its scripts. | ||
| 6504 | assert.include(stored, "connect-src https://api.weather.test"); | ||
| 6505 | assert.isBelow(stored.indexOf("Content-Security-Policy"), stored.indexOf("<script>")); | ||
| 6506 | assert.isFalse(yield* harness.hasPendingBackgroundWork); | ||
| 6507 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), | ||
| 6508 | ), | ||
| 6509 | ); | ||
| 6510 | |||
| 6511 | // The turn fails while the app's resource read is still outstanding (Codex | ||
| 6512 | // never answers it), so the capture must be cancelled and its tool row | ||
| 6513 | // settled before the terminal event closes ingestion. | ||
| 6514 | const failedMcpAppTranscript = makeCodexReplayTranscript({ | ||
| 6515 | scenario: "codex-mcp-app-capture-failed-turn", | ||
| 6516 | entries: [ | ||
| 6517 | ...codexReplayPreamble({ | ||
| 6518 | nativeThreadId: MCP_APP_NATIVE_THREAD, | ||
| 6519 | nativeTurnId: MCP_APP_NATIVE_TURN, | ||
| 6520 | prompt: MCP_APP_PROMPT, | ||
| 6521 | }), | ||
| 6522 | { | ||
| 6523 | type: "emit_inbound", | ||
| 6524 | label: "item/completed/app-tool", | ||
| 6525 | frame: { | ||
| 6526 | method: "item/completed", | ||
| 6527 | params: { | ||
| 6528 | item: mcpAppToolItem("completed"), | ||
| 6529 | threadId: MCP_APP_NATIVE_THREAD, | ||
| 6530 | turnId: MCP_APP_NATIVE_TURN, | ||
| 6531 | completedAtMs: 1782622441500, | ||
| 6532 | }, | ||
| 6533 | }, | ||
| 6534 | }, | ||
| 6535 | { | ||
| 6536 | type: "expect_outbound", | ||
| 6537 | label: "mcpServer/resource/read", | ||
| 6538 | frame: { | ||
| 6539 | id: 4, | ||
| 6540 | method: "mcpServer/resource/read", | ||
| 6541 | params: { threadId: MCP_APP_NATIVE_THREAD, server: "weather", uri: MCP_APP_RESOURCE }, | ||
| 6542 | }, | ||
| 6543 | }, | ||
| 6544 | { | ||
| 6545 | type: "emit_inbound", | ||
| 6546 | label: "turn/completed", | ||
| 6547 | frame: { | ||
| 6548 | method: "turn/completed", | ||
| 6549 | params: { | ||
| 6550 | threadId: MCP_APP_NATIVE_THREAD, | ||
| 6551 | turn: { | ||
| 6552 | ...makeCodexReplayTurn({ id: MCP_APP_NATIVE_TURN, status: "failed" }), | ||
| 6553 | error: { message: "provider failed mid-capture" }, | ||
| 6554 | }, | ||
| 6555 | }, | ||
| 6556 | }, | ||
| 6557 | }, | ||
| 6558 | ], | ||
| 6559 | }); | ||
| 6560 | |||
| 6561 | it.effect("settles a pending MCP app capture before a failed turn's terminal event", () => | ||
| 6562 | Effect.scoped( | ||
| 6563 | Effect.gen(function* () { | ||
| 6564 | const harness = yield* makeCodexReplayHarness(failedMcpAppTranscript); | ||
| 6565 | const now = yield* DateTime.now; | ||
| 6566 | |||
| 6567 | yield* harness.runtime.startTurn( | ||
| 6568 | makeCodexTestTurnInput({ | ||
| 6569 | threadId: harness.threadId, | ||
| 6570 | providerThread: harness.providerThread, | ||
| 6571 | now, | ||
| 6572 | attemptId: RunAttemptId.make("attempt-codex-mcp-app-failed"), | ||
| 6573 | text: MCP_APP_PROMPT, | ||
| 6574 | }), | ||
| 6575 | ); | ||
| 6576 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "failed terminal"); | ||
| 6577 | assert.equal(harness.terminalEvents()[0]?.status, "failed"); | ||
| 6578 | |||
| 6579 | const terminalIndex = harness.events.findIndex((event) => event.type === "turn.terminal"); | ||
| 6580 | const rows = harness.events.flatMap((event, index) => | ||
| 6581 | event.type === "turn_item.updated" && | ||
| 6582 | event.turnItem.type === "dynamic_tool" && | ||
| 6583 | event.turnItem.status === "completed" | ||
| 6584 | ? [{ index, turnItem: event.turnItem }] | ||
| 6585 | : [], | ||
| 6586 | ); | ||
| 6587 | // Settled once, as a plain tool row, ahead of the terminal event. | ||
| 6588 | assert.lengthOf(rows, 1); | ||
| 6589 | assert.isBelow(rows[0]!.index, terminalIndex); | ||
| 6590 | const output = rows[0]!.turnItem.type === "dynamic_tool" ? rows[0]!.turnItem.output : null; | ||
| 6591 | assert.isUndefined((output as Record<string, unknown> | null)?.[MCP_APP_OUTPUT_KEY]); | ||
| 6592 | assert.isFalse(yield* harness.hasPendingBackgroundWork); | ||
| 6593 | }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), | ||
| 6594 | ), | ||
| 6595 | ); | ||
| 6596 | |||
| 6308 | const ORPHAN_WAIT_SCENARIO = "codex-orphaned-dynamic-tool"; | 6597 | const ORPHAN_WAIT_SCENARIO = "codex-orphaned-dynamic-tool"; |
| 6309 | const ORPHAN_WAIT_NATIVE_THREAD = "native-codex-orphan-wait-thread"; | 6598 | const ORPHAN_WAIT_NATIVE_THREAD = "native-codex-orphan-wait-thread"; |
| 6310 | const ORPHAN_WAIT_NATIVE_TURN = "native-codex-orphan-wait-turn"; | 6599 | const ORPHAN_WAIT_NATIVE_TURN = "native-codex-orphan-wait-turn"; |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+316-7| ... | @@ -65,11 +65,13 @@ import type { | ... | @@ -65,11 +65,13 @@ import type { |
| 65 | import * as CodexClient from "effect-codex-app-server/client"; | 65 | import * as CodexClient from "effect-codex-app-server/client"; |
| 66 | import * as CodexErrors from "effect-codex-app-server/errors"; | 66 | import * as CodexErrors from "effect-codex-app-server/errors"; |
| 67 | import * as CodexSchema from "effect-codex-app-server/schema"; | 67 | import * as CodexSchema from "effect-codex-app-server/schema"; |
| 68 | import * as Cause from "effect/Cause"; | ||
| 68 | import * as Context from "effect/Context"; | 69 | import * as Context from "effect/Context"; |
| 69 | import * as Crypto from "effect/Crypto"; | 70 | import * as Crypto from "effect/Crypto"; |
| 70 | import * as DateTime from "effect/DateTime"; | 71 | import * as DateTime from "effect/DateTime"; |
| 71 | import * as Deferred from "effect/Deferred"; | 72 | import * as Deferred from "effect/Deferred"; |
| 72 | import * as Effect from "effect/Effect"; | 73 | import * as Effect from "effect/Effect"; |
| 74 | import * as Fiber from "effect/Fiber"; | ||
| 73 | import * as FileSystem from "effect/FileSystem"; | 75 | import * as FileSystem from "effect/FileSystem"; |
| 74 | import * as Layer from "effect/Layer"; | 76 | import * as Layer from "effect/Layer"; |
| 75 | import * as Option from "effect/Option"; | 77 | import * as Option from "effect/Option"; |
| ... | @@ -82,7 +84,7 @@ import * as Semaphore from "effect/Semaphore"; | ... | @@ -82,7 +84,7 @@ import * as Semaphore from "effect/Semaphore"; |
| 82 | import * as Stream from "effect/Stream"; | 84 | import * as Stream from "effect/Stream"; |
| 83 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; | 85 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 84 | 86 | ||
| 85 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 87 | import { resolveAttachmentPath, resolveAttachmentPathById } from "../../attachmentStore.ts"; |
| 86 | import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; | 88 | import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; |
| 87 | import { ServerConfig } from "../../config.ts"; | 89 | import { ServerConfig } from "../../config.ts"; |
| 88 | import { expandHomePath } from "../../pathExpansion.ts"; | 90 | import { expandHomePath } from "../../pathExpansion.ts"; |
| ... | @@ -107,6 +109,13 @@ import { ProviderEventLoggers } from "../../provider/ProviderEventLoggers.ts"; | ... | @@ -107,6 +109,13 @@ import { ProviderEventLoggers } from "../../provider/ProviderEventLoggers.ts"; |
| 107 | import { codexAppServerArgs, resolveCodexLaunchArgs } from "../../provider/codexLaunchArgs.ts"; | 109 | import { codexAppServerArgs, resolveCodexLaunchArgs } from "../../provider/codexLaunchArgs.ts"; |
| 108 | import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; | 110 | import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; |
| 109 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; | 111 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 112 | import { | ||
| 113 | MCP_APP_EXTENSION_ID, | ||
| 114 | MCP_APP_MIME_TYPE, | ||
| 115 | MCP_APP_OUTPUT_KEY, | ||
| 116 | MCP_APP_RESOURCE_SCHEME, | ||
| 117 | } from "@t3tools/shared/mcpApp"; | ||
| 118 | import { snapshotMcpApp } from "../../mcpApps/McpAppSnapshot.ts"; | ||
| 110 | import { | 119 | import { |
| 111 | ProviderAdapterDriverCreateError, | 120 | ProviderAdapterDriverCreateError, |
| 112 | type ProviderAdapterDriver, | 121 | type ProviderAdapterDriver, |
| ... | @@ -146,6 +155,7 @@ import { | ... | @@ -146,6 +155,7 @@ import { |
| 146 | type ProviderAdapterV2ForkThreadInput, | 155 | type ProviderAdapterV2ForkThreadInput, |
| 147 | type ProviderAdapterV2RollbackThreadInput, | 156 | type ProviderAdapterV2RollbackThreadInput, |
| 148 | type ProviderAdapterV2RuntimePolicy, | 157 | type ProviderAdapterV2RuntimePolicy, |
| 158 | type ProviderAdapterV2McpApps, | ||
| 149 | type ProviderAdapterV2SessionRuntime, | 159 | type ProviderAdapterV2SessionRuntime, |
| 150 | type ProviderAdapterV2InterruptInput, | 160 | type ProviderAdapterV2InterruptInput, |
| 151 | type ProviderAdapterV2SteerInput, | 161 | type ProviderAdapterV2SteerInput, |
| ... | @@ -232,6 +242,9 @@ const decodeCodexBackgroundTerminalsListResponse = Schema.decodeUnknownEffect( | ... | @@ -232,6 +242,9 @@ const decodeCodexBackgroundTerminalsListResponse = Schema.decodeUnknownEffect( |
| 232 | const CODEX_CLIENT_CAPABILITIES = { | 242 | const CODEX_CLIENT_CAPABILITIES = { |
| 233 | experimentalApi: true, | 243 | experimentalApi: true, |
| 234 | optOutNotificationMethods: ["turn/diff/updated"], | 244 | optOutNotificationMethods: ["turn/diff/updated"], |
| 245 | // Declares MCP Apps support (SEP-1724 extension negotiation), so servers | ||
| 246 | // attach their UI resources and Codex reports them on tool call items. | ||
| 247 | extensions: { [MCP_APP_EXTENSION_ID]: { mimeTypes: [MCP_APP_MIME_TYPE] } }, | ||
| 235 | } as const; | 248 | } as const; |
| 236 | 249 | ||
| 237 | export const CodexProviderCapabilitiesV2 = { | 250 | export const CodexProviderCapabilitiesV2 = { |
| ... | @@ -471,6 +484,17 @@ function codexMcpToolOutput( | ... | @@ -471,6 +484,17 @@ function codexMcpToolOutput( |
| 471 | : { error: item.error.message, result: resultOutput }; | 484 | : { error: item.error.message, result: resultOutput }; |
| 472 | } | 485 | } |
| 473 | 486 | ||
| 487 | // Reading an app's resource is one MCP round trip; a server that hangs must not | ||
| 488 | // hold the item (and the run's ingestion) open. | ||
| 489 | const MCP_APP_CAPTURE_TIMEOUT = "20 seconds"; | ||
| 490 | |||
| 491 | /** The UI resource a completed Codex MCP tool call names, under any field Codex reports it in. */ | ||
| 492 | function codexMcpAppResourceUri(item: CodexDynamicToolItem): string | undefined { | ||
| 493 | if (item.type !== "mcpToolCall") return undefined; | ||
| 494 | const uri = item.mcpAppUi?.resourceUri ?? item.mcpAppResourceUri ?? item.appContext?.resourceUri; | ||
| 495 | return typeof uri === "string" && uri.startsWith(MCP_APP_RESOURCE_SCHEME) ? uri : undefined; | ||
| 496 | } | ||
| 497 | |||
| 474 | function codexDynamicToolOutput( | 498 | function codexDynamicToolOutput( |
| 475 | item: Extract<CodexDynamicToolItem, { readonly type: "dynamicToolCall" }>, | 499 | item: Extract<CodexDynamicToolItem, { readonly type: "dynamicToolCall" }>, |
| 476 | ): unknown | undefined { | 500 | ): unknown | undefined { |
| ... | @@ -707,6 +731,8 @@ export function buildCodexTurnStartParams(input: { | ... | @@ -707,6 +731,8 @@ export function buildCodexTurnStartParams(input: { |
| 707 | readonly deviceToolsAvailable?: boolean; | 731 | readonly deviceToolsAvailable?: boolean; |
| 708 | /** ChatGPT token sharing does not accept service tiers. */ | 732 | /** ChatGPT token sharing does not accept service tiers. */ |
| 709 | readonly omitServiceTier?: boolean; | 733 | readonly omitServiceTier?: boolean; |
| 734 | /** What the thread's MCP Apps want the agent to know (`ui/update-model-context`). */ | ||
| 735 | readonly appContext?: ProviderAdapterV2TurnInput["appContext"]; | ||
| 710 | }) { | 736 | }) { |
| 711 | return Effect.gen(function* () { | 737 | return Effect.gen(function* () { |
| 712 | const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode); | 738 | const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode); |
| ... | @@ -732,7 +758,16 @@ export function buildCodexTurnStartParams(input: { | ... | @@ -732,7 +758,16 @@ export function buildCodexTurnStartParams(input: { |
| 732 | input.hasT3Mcp !== true | 758 | input.hasT3Mcp !== true |
| 733 | ? undefined | 759 | ? undefined |
| 734 | : buildCodexDeveloperInstructions(input.runtimePolicy.interactionMode); | 760 | : buildCodexDeveloperInstructions(input.runtimePolicy.interactionMode); |
| 735 | const additionalContext = | 761 | // An app's context is text an MCP server wrote, so it goes in as untrusted |
| 762 | // context: Codex renders it as quoted user-side input, never as developer | ||
| 763 | // instructions. Codex resends it only when it changes. | ||
| 764 | const appContext = Object.fromEntries( | ||
| 765 | (input.appContext ?? []).map((entry) => [ | ||
| 766 | entry.key, | ||
| 767 | { kind: "untrusted" as const, value: entry.text }, | ||
| 768 | ]), | ||
| 769 | ); | ||
| 770 | const t3Context = | ||
| 736 | input.hasT3Mcp === true | 771 | input.hasT3Mcp === true |
| 737 | ? buildCodexAdditionalContext( | 772 | ? buildCodexAdditionalContext( |
| 738 | { model: input.modelSelection.model, reasoningEffort: effort ?? "medium" }, | 773 | { model: input.modelSelection.model, reasoningEffort: effort ?? "medium" }, |
| ... | @@ -742,6 +777,10 @@ export function buildCodexTurnStartParams(input: { | ... | @@ -742,6 +777,10 @@ export function buildCodexTurnStartParams(input: { |
| 742 | }, | 777 | }, |
| 743 | ) | 778 | ) |
| 744 | : undefined; | 779 | : undefined; |
| 780 | const additionalContext = | ||
| 781 | t3Context === undefined && Object.keys(appContext).length === 0 | ||
| 782 | ? undefined | ||
| 783 | : { ...t3Context, ...appContext }; | ||
| 745 | const collaborationMode: CodexSchema.ClientRequest__CollaborationMode | undefined = | 784 | const collaborationMode: CodexSchema.ClientRequest__CollaborationMode | undefined = |
| 746 | input.runtimePolicy.interactionMode !== "plan" && developerInstructions === undefined | 785 | input.runtimePolicy.interactionMode !== "plan" && developerInstructions === undefined |
| 747 | ? undefined | 786 | ? undefined |
| ... | @@ -1688,11 +1727,26 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi | ... | @@ -1688,11 +1727,26 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi |
| 1688 | if (!context) return; | 1727 | if (!context) return; |
| 1689 | yield* client.request("thread/inject_items", { | 1728 | yield* client.request("thread/inject_items", { |
| 1690 | threadId, | 1729 | threadId, |
| 1691 | items: Object.entries(context).map(([key, entry]) => ({ | 1730 | // Restored as Codex sent them: application context as developer |
| 1692 | type: "message", | 1731 | // input, untrusted context as quoted user-side input. |
| 1693 | role: "developer", | 1732 | items: Object.entries(context).map(([key, entry]) => |
| 1694 | content: [{ type: "input_text", text: `<${key}>${entry.value}</${key}>` }], | 1733 | entry.kind === "untrusted" |
| 1695 | })), | 1734 | ? { |
| 1735 | type: "message", | ||
| 1736 | role: "user", | ||
| 1737 | content: [ | ||
| 1738 | { | ||
| 1739 | type: "input_text", | ||
| 1740 | text: `<external_${key}>${entry.value}</external_${key}>`, | ||
| 1741 | }, | ||
| 1742 | ], | ||
| 1743 | } | ||
| 1744 | : { | ||
| 1745 | type: "message", | ||
| 1746 | role: "developer", | ||
| 1747 | content: [{ type: "input_text", text: `<${key}>${entry.value}</${key}>` }], | ||
| 1748 | }, | ||
| 1749 | ), | ||
| 1696 | }); | 1750 | }); |
| 1697 | }).pipe( | 1751 | }).pipe( |
| 1698 | Effect.timeout("10 seconds"), | 1752 | Effect.timeout("10 seconds"), |
| ... | @@ -2037,6 +2091,33 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi | ... | @@ -2037,6 +2091,33 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi |
| 2037 | return [remaining.size === 0, updated] as const; | 2091 | return [remaining.size === 0, updated] as const; |
| 2038 | }); | 2092 | }); |
| 2039 | 2093 | ||
| 2094 | // MCP app captures still reading their resource, by native turn and | ||
| 2095 | // item. Each counts as retained background work, so the turn's context | ||
| 2096 | // and the run's ingestion stay open until its app lands on the item. | ||
| 2097 | // A turn that ends badly cancels its captures and settles their items | ||
| 2098 | // itself (terminalizeMcpAppCaptures). | ||
| 2099 | interface PendingMcpAppCapture { | ||
| 2100 | readonly item: Extract<CodexDynamicToolItem, { readonly type: "mcpToolCall" }>; | ||
| 2101 | readonly fiber: Fiber.Fiber<void>; | ||
| 2102 | } | ||
| 2103 | const pendingMcpAppCaptures = yield* Ref.make( | ||
| 2104 | new Map<string, ReadonlyMap<string, PendingMcpAppCapture>>(), | ||
| 2105 | ); | ||
| 2106 | /** Removes a capture; true when this call removed it (each settles once). */ | ||
| 2107 | const takePendingMcpAppCapture = (nativeTurnId: string, nativeItemId: string) => | ||
| 2108 | Ref.modify(pendingMcpAppCaptures, (current) => { | ||
| 2109 | const captures = current.get(nativeTurnId); | ||
| 2110 | if (captures === undefined || !captures.has(nativeItemId)) { | ||
| 2111 | return [false, current] as const; | ||
| 2112 | } | ||
| 2113 | const remaining = new Map(captures); | ||
| 2114 | remaining.delete(nativeItemId); | ||
| 2115 | const updated = new Map(current); | ||
| 2116 | if (remaining.size === 0) updated.delete(nativeTurnId); | ||
| 2117 | else updated.set(nativeTurnId, remaining); | ||
| 2118 | return [true, updated] as const; | ||
| 2119 | }); | ||
| 2120 | |||
| 2040 | const trackRunningDynamicTool = (nativeTurnId: string, item: CodexDynamicToolItem) => | 2121 | const trackRunningDynamicTool = (nativeTurnId: string, item: CodexDynamicToolItem) => |
| 2041 | Ref.update(runningDynamicToolsByTurn, (current) => { | 2122 | Ref.update(runningDynamicToolsByTurn, (current) => { |
| 2042 | const updated = new Map(current); | 2123 | const updated = new Map(current); |
| ... | @@ -2069,6 +2150,9 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi | ... | @@ -2069,6 +2150,9 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi |
| 2069 | if (commands !== undefined && commands.size > 0) { | 2150 | if (commands !== undefined && commands.size > 0) { |
| 2070 | return true; | 2151 | return true; |
| 2071 | } | 2152 | } |
| 2153 | if ((yield* Ref.get(pendingMcpAppCaptures)).has(nativeTurnId)) { | ||
| 2154 | return true; | ||
| 2155 | } | ||
| 2072 | const tools = (yield* Ref.get(runningDynamicToolsByTurn)).get(nativeTurnId); | 2156 | const tools = (yield* Ref.get(runningDynamicToolsByTurn)).get(nativeTurnId); |
| 2073 | if (tools === undefined) { | 2157 | if (tools === undefined) { |
| 2074 | return false; | 2158 | return false; |
| ... | @@ -3555,6 +3639,157 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi | ... | @@ -3555,6 +3639,157 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi |
| 3555 | return { node, turnItem }; | 3639 | return { node, turnItem }; |
| 3556 | }); | 3640 | }); |
| 3557 | 3641 | ||
| 3642 | /** | ||
| 3643 | * Completes an MCP tool call whose tool names a UI resource: reads the | ||
| 3644 | * resource through Codex's MCP client, stores it, and records the app | ||
| 3645 | * in the item's output. The item stays running until then, which keeps | ||
| 3646 | * the run's event ingestion open past turn/completed; it runs off the | ||
| 3647 | * notification reader so a slow server cannot stall other events. | ||
| 3648 | */ | ||
| 3649 | const captureMcpApp = ( | ||
| 3650 | context: ActiveCodexTurnContext, | ||
| 3651 | nativeTurnId: string, | ||
| 3652 | item: Extract<CodexDynamicToolItem, { readonly type: "mcpToolCall" }>, | ||
| 3653 | resourceUri: string, | ||
| 3654 | ) => | ||
| 3655 | Effect.gen(function* () { | ||
| 3656 | // Registered before it can run, so a turn ending right away sees it. | ||
| 3657 | const started = yield* Deferred.make<void>(); | ||
| 3658 | const fiber = yield* Deferred.await(started).pipe( | ||
| 3659 | Effect.andThen(captureMcpAppItem(context, nativeTurnId, item, resourceUri)), | ||
| 3660 | Effect.forkIn(scope, { startImmediately: true }), | ||
| 3661 | ); | ||
| 3662 | yield* Ref.update(pendingMcpAppCaptures, (current) => { | ||
| 3663 | const updated = new Map(current); | ||
| 3664 | updated.set( | ||
| 3665 | nativeTurnId, | ||
| 3666 | new Map(current.get(nativeTurnId) ?? []).set(item.id, { item, fiber }), | ||
| 3667 | ); | ||
| 3668 | return updated; | ||
| 3669 | }); | ||
| 3670 | yield* Deferred.succeed(started, undefined); | ||
| 3671 | }); | ||
| 3672 | |||
| 3673 | const captureMcpAppItem = ( | ||
| 3674 | context: ActiveCodexTurnContext, | ||
| 3675 | nativeTurnId: string, | ||
| 3676 | item: Extract<CodexDynamicToolItem, { readonly type: "mcpToolCall" }>, | ||
| 3677 | resourceUri: string, | ||
| 3678 | ) => | ||
| 3679 | // Only reading and storing the document can be cancelled; a write | ||
| 3680 | // cut short removes its file. Once a document is stored, the capture | ||
| 3681 | // either lands it on the item or, when its turn already settled the | ||
| 3682 | // item, removes it, so no stored app goes unreferenced. | ||
| 3683 | Effect.uninterruptibleMask((restore) => | ||
| 3684 | Effect.gen(function* () { | ||
| 3685 | const reference = | ||
| 3686 | item.status === "completed" && item.result != null && item.error == null | ||
| 3687 | ? yield* restore( | ||
| 3688 | getNativeThreadId(context.providerThread).pipe( | ||
| 3689 | Effect.flatMap((threadId) => | ||
| 3690 | client.request("mcpServer/resource/read", { | ||
| 3691 | threadId, | ||
| 3692 | server: item.server, | ||
| 3693 | uri: resourceUri, | ||
| 3694 | }), | ||
| 3695 | ), | ||
| 3696 | Effect.timeout(MCP_APP_CAPTURE_TIMEOUT), | ||
| 3697 | Effect.flatMap((response) => | ||
| 3698 | snapshotMcpApp({ | ||
| 3699 | attachmentsDir: serverConfig.attachmentsDir, | ||
| 3700 | threadId: context.projectionThreadId, | ||
| 3701 | server: item.server, | ||
| 3702 | tool: item.tool, | ||
| 3703 | resourceUri, | ||
| 3704 | contents: response.contents, | ||
| 3705 | }), | ||
| 3706 | ), | ||
| 3707 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 3708 | // Every failure and defect becomes a plain tool row, | ||
| 3709 | // so a crashed read never holds the turn open; only | ||
| 3710 | // interruption, the turn cancelling the capture, ends | ||
| 3711 | // the fiber here. Nothing typed is left for orDie. | ||
| 3712 | Effect.catchCauseIf( | ||
| 3713 | (cause) => !Cause.hasInterruptsOnly(cause), | ||
| 3714 | (cause) => | ||
| 3715 | Effect.logWarning( | ||
| 3716 | "Failed to capture an MCP app; showing a plain tool row.", | ||
| 3717 | { server: item.server, tool: item.tool, resourceUri, cause }, | ||
| 3718 | ).pipe(Effect.as(undefined)), | ||
| 3719 | ), | ||
| 3720 | Effect.orDie, | ||
| 3721 | ), | ||
| 3722 | ) | ||
| 3723 | : undefined; | ||
| 3724 | const artifacts = yield* buildDynamicToolArtifacts(context, item); | ||
| 3725 | // Cleared before the final emit: ingestion re-checks pending work | ||
| 3726 | // after each event, and must see this capture done by then. A turn | ||
| 3727 | // that already ended badly took the capture and settled its item. | ||
| 3728 | if (!(yield* takePendingMcpAppCapture(nativeTurnId, item.id))) { | ||
| 3729 | if (reference !== undefined) { | ||
| 3730 | const filePath = resolveAttachmentPathById({ | ||
| 3731 | attachmentsDir: serverConfig.attachmentsDir, | ||
| 3732 | attachmentId: reference.attachmentId, | ||
| 3733 | }); | ||
| 3734 | if (filePath !== null) { | ||
| 3735 | yield* fileSystem.remove(filePath, { force: true }).pipe(Effect.ignore); | ||
| 3736 | } | ||
| 3737 | } | ||
| 3738 | return; | ||
| 3739 | } | ||
| 3740 | const turnItem = | ||
| 3741 | reference === undefined || artifacts.turnItem.type !== "dynamic_tool" | ||
| 3742 | ? artifacts.turnItem | ||
| 3743 | : { | ||
| 3744 | ...artifacts.turnItem, | ||
| 3745 | // The full CallToolResult the app replays, beside the app. | ||
| 3746 | output: { [MCP_APP_OUTPUT_KEY]: reference, result: item.result }, | ||
| 3747 | }; | ||
| 3748 | yield* emitProviderEvent({ | ||
| 3749 | type: "node.updated", | ||
| 3750 | driver: CODEX_PROVIDER, | ||
| 3751 | node: artifacts.node, | ||
| 3752 | }); | ||
| 3753 | yield* emitProviderEvent({ | ||
| 3754 | type: "turn_item.updated", | ||
| 3755 | driver: CODEX_PROVIDER, | ||
| 3756 | turnItem, | ||
| 3757 | }); | ||
| 3758 | // Only a turn that already ended is released; an active one | ||
| 3759 | // still needs its final-answer bookkeeping. | ||
| 3760 | if ((yield* Ref.get(settledTurns)).has(nativeTurnId)) { | ||
| 3761 | yield* releaseSettledTurnIfIdle(nativeTurnId); | ||
| 3762 | } | ||
| 3763 | }), | ||
| 3764 | ); | ||
| 3765 | |||
| 3766 | /** | ||
| 3767 | * Settles the items of app captures still running when their turn is | ||
| 3768 | * interrupted or fails: the capture is cancelled and its tool call is | ||
| 3769 | * reported as it completed, without the app, before the turn's | ||
| 3770 | * terminal event closes ingestion. | ||
| 3771 | */ | ||
| 3772 | const terminalizeMcpAppCaptures = (context: ActiveCodexTurnContext, nativeTurnId: string) => | ||
| 3773 | Effect.gen(function* () { | ||
| 3774 | const captures = (yield* Ref.get(pendingMcpAppCaptures)).get(nativeTurnId); | ||
| 3775 | if (captures === undefined) return; | ||
| 3776 | for (const { item, fiber } of captures.values()) { | ||
| 3777 | if (!(yield* takePendingMcpAppCapture(nativeTurnId, item.id))) continue; | ||
| 3778 | yield* Fiber.interrupt(fiber); | ||
| 3779 | const artifacts = yield* buildDynamicToolArtifacts(context, item); | ||
| 3780 | yield* emitProviderEvent({ | ||
| 3781 | type: "node.updated", | ||
| 3782 | driver: CODEX_PROVIDER, | ||
| 3783 | node: artifacts.node, | ||
| 3784 | }); | ||
| 3785 | yield* emitProviderEvent({ | ||
| 3786 | type: "turn_item.updated", | ||
| 3787 | driver: CODEX_PROVIDER, | ||
| 3788 | turnItem: artifacts.turnItem, | ||
| 3789 | }); | ||
| 3790 | } | ||
| 3791 | }); | ||
| 3792 | |||
| 3558 | const buildProposedPlanArtifacts = (input: { | 3793 | const buildProposedPlanArtifacts = (input: { |
| 3559 | readonly context: ActiveCodexTurnContext; | 3794 | readonly context: ActiveCodexTurnContext; |
| 3560 | readonly nativeItemId: string; | 3795 | readonly nativeItemId: string; |
| ... | @@ -4531,6 +4766,11 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi | ... | @@ -4531,6 +4766,11 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi |
| 4531 | 4766 | ||
| 4532 | if (payload.item.type === "mcpToolCall" || payload.item.type === "dynamicToolCall") { | 4767 | if (payload.item.type === "mcpToolCall" || payload.item.type === "dynamicToolCall") { |
| 4533 | yield* clearRunningDynamicTool(payload.turnId, payload.item.id); | 4768 | yield* clearRunningDynamicTool(payload.turnId, payload.item.id); |
| 4769 | const appResourceUri = codexMcpAppResourceUri(payload.item); | ||
| 4770 | if (appResourceUri !== undefined && payload.item.type === "mcpToolCall") { | ||
| 4771 | yield* captureMcpApp(context, payload.turnId, payload.item, appResourceUri); | ||
| 4772 | return; | ||
| 4773 | } | ||
| 4534 | const artifacts = yield* buildDynamicToolArtifacts(context, payload.item); | 4774 | const artifacts = yield* buildDynamicToolArtifacts(context, payload.item); |
| 4535 | yield* emitProviderEvent({ | 4775 | yield* emitProviderEvent({ |
| 4536 | type: "node.updated", | 4776 | type: "node.updated", |
| ... | @@ -5563,6 +5803,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi | ... | @@ -5563,6 +5803,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi |
| 5563 | input.status, | 5803 | input.status, |
| 5564 | input.completedAt, | 5804 | input.completedAt, |
| 5565 | ); | 5805 | ); |
| 5806 | yield* terminalizeMcpAppCaptures(input.context, input.nativeTurnId); | ||
| 5566 | } | 5807 | } |
| 5567 | const dynamicToolStatus: "cancelled" | "interrupted" | "failed" = | 5808 | const dynamicToolStatus: "cancelled" | "interrupted" | "failed" = |
| 5568 | input.status === "interrupted" || input.status === "failed" | 5809 | input.status === "interrupted" || input.status === "failed" |
| ... | @@ -5601,6 +5842,10 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi | ... | @@ -5601,6 +5842,10 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi |
| 5601 | updated.set(input.nativeTurnId, input.context); | 5842 | updated.set(input.nativeTurnId, input.context); |
| 5602 | return updated; | 5843 | return updated; |
| 5603 | }); | 5844 | }); |
| 5845 | // Background work that finished between the check above and | ||
| 5846 | // the turn being recorded as settled saw no settled turn to | ||
| 5847 | // release, so it is re-checked once recorded. | ||
| 5848 | yield* releaseSettledTurnIfIdle(input.nativeTurnId); | ||
| 5604 | } | 5849 | } |
| 5605 | yield* Ref.update(activeTurns, (current) => { | 5850 | yield* Ref.update(activeTurns, (current) => { |
| 5606 | const updated = new Map(current); | 5851 | const updated = new Map(current); |
| ... | @@ -5874,6 +6119,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi | ... | @@ -5874,6 +6119,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi |
| 5874 | browserToolsAvailable: mcpSession?.browserToolsAvailable ?? true, | 6119 | browserToolsAvailable: mcpSession?.browserToolsAvailable ?? true, |
| 5875 | deviceToolsAvailable: mcpSession?.capabilities?.has("device") ?? false, | 6120 | deviceToolsAvailable: mcpSession?.capabilities?.has("device") ?? false, |
| 5876 | omitServiceTier: adapterOptions.resolveRuntime !== undefined, | 6121 | omitServiceTier: adapterOptions.resolveRuntime !== undefined, |
| 6122 | ...(turnInput.appContext === undefined ? {} : { appContext: turnInput.appContext }), | ||
| 5877 | }); | 6123 | }); |
| 5878 | yield* Ref.update(pendingRootTurns, (current) => { | 6124 | yield* Ref.update(pendingRootTurns, (current) => { |
| 5879 | const updated = new Map(current); | 6125 | const updated = new Map(current); |
| ... | @@ -6022,6 +6268,64 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi | ... | @@ -6022,6 +6268,64 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi |
| 6022 | ), | 6268 | ), |
| 6023 | ); | 6269 | ); |
| 6024 | 6270 | ||
| 6271 | // MCP Apps reach their server through Codex's own MCP client, so any | ||
| 6272 | // server the user configured for Codex works without T3 connecting to it. | ||
| 6273 | const mcpAppsError = (detail: string) => (cause: unknown) => | ||
| 6274 | new ProviderAdapterProtocolError({ driver: CODEX_PROVIDER, detail, payload: cause }); | ||
| 6275 | const mcpApps: ProviderAdapterV2McpApps = { | ||
| 6276 | listTools: (toolsInput) => | ||
| 6277 | Effect.gen(function* () { | ||
| 6278 | const threadId = yield* getNativeThreadId(toolsInput.providerThread); | ||
| 6279 | const response = yield* ensureInitialized.pipe( | ||
| 6280 | Effect.andThen( | ||
| 6281 | client.request("mcpServerStatus/list", { | ||
| 6282 | threadId, | ||
| 6283 | serverName: toolsInput.server, | ||
| 6284 | detail: "toolsAndAuthOnly", | ||
| 6285 | }), | ||
| 6286 | ), | ||
| 6287 | ); | ||
| 6288 | const server = response.data.find((entry) => entry.name === toolsInput.server); | ||
| 6289 | return Object.values(server?.tools ?? {}); | ||
| 6290 | }).pipe(Effect.mapError(mcpAppsError("Failed to list Codex MCP tools."))), | ||
| 6291 | callTool: (callInput) => | ||
| 6292 | Effect.gen(function* () { | ||
| 6293 | const threadId = yield* getNativeThreadId(callInput.providerThread); | ||
| 6294 | const response = yield* ensureInitialized.pipe( | ||
| 6295 | Effect.andThen( | ||
| 6296 | client.request("mcpServer/tool/call", { | ||
| 6297 | threadId, | ||
| 6298 | server: callInput.server, | ||
| 6299 | tool: callInput.tool, | ||
| 6300 | arguments: callInput.arguments as Schema.Json, | ||
| 6301 | }), | ||
| 6302 | ), | ||
| 6303 | ); | ||
| 6304 | return { | ||
| 6305 | content: response.content, | ||
| 6306 | ...(response.structuredContent === undefined | ||
| 6307 | ? {} | ||
| 6308 | : { structuredContent: response.structuredContent }), | ||
| 6309 | ...(response.isError === true ? { isError: true } : {}), | ||
| 6310 | ...(response._meta === undefined ? {} : { _meta: response._meta }), | ||
| 6311 | }; | ||
| 6312 | }).pipe(Effect.mapError(mcpAppsError("Codex MCP tool call failed."))), | ||
| 6313 | readResource: (readInput) => | ||
| 6314 | Effect.gen(function* () { | ||
| 6315 | const threadId = yield* getNativeThreadId(readInput.providerThread); | ||
| 6316 | const response = yield* ensureInitialized.pipe( | ||
| 6317 | Effect.andThen( | ||
| 6318 | client.request("mcpServer/resource/read", { | ||
| 6319 | threadId, | ||
| 6320 | server: readInput.server, | ||
| 6321 | uri: readInput.uri, | ||
| 6322 | }), | ||
| 6323 | ), | ||
| 6324 | ); | ||
| 6325 | return { contents: response.contents }; | ||
| 6326 | }).pipe(Effect.mapError(mcpAppsError("Codex MCP resource read failed."))), | ||
| 6327 | }; | ||
| 6328 | |||
| 6025 | const runtime: ProviderAdapterV2SessionRuntime = { | 6329 | const runtime: ProviderAdapterV2SessionRuntime = { |
| 6026 | instanceId: adapterOptions.instanceId, | 6330 | instanceId: adapterOptions.instanceId, |
| 6027 | driver: CODEX_PROVIDER, | 6331 | driver: CODEX_PROVIDER, |
| ... | @@ -6044,6 +6348,9 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi | ... | @@ -6044,6 +6348,9 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi |
| 6044 | return true; | 6348 | return true; |
| 6045 | } | 6349 | } |
| 6046 | } | 6350 | } |
| 6351 | if ((yield* Ref.get(pendingMcpAppCaptures)).size > 0) { | ||
| 6352 | return true; | ||
| 6353 | } | ||
| 6047 | for (const subagent of (yield* Ref.get(subagentThreads)).values()) { | 6354 | for (const subagent of (yield* Ref.get(subagentThreads)).values()) { |
| 6048 | if (subagent.task.status === "running") { | 6355 | if (subagent.task.status === "running") { |
| 6049 | return true; | 6356 | return true; |
| ... | @@ -6669,6 +6976,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi | ... | @@ -6669,6 +6976,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi |
| 6669 | completedAt, | 6976 | completedAt, |
| 6670 | true, | 6977 | true, |
| 6671 | ); | 6978 | ); |
| 6979 | yield* terminalizeMcpAppCaptures(context, context.nativeTurnId); | ||
| 6672 | yield* Ref.update(runningCommandItemsByTurn, (current) => { | 6980 | yield* Ref.update(runningCommandItemsByTurn, (current) => { |
| 6673 | const updated = new Map(current); | 6981 | const updated = new Map(current); |
| 6674 | updated.delete(context.nativeTurnId); | 6982 | updated.delete(context.nativeTurnId); |
| ... | @@ -6765,6 +7073,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi | ... | @@ -6765,6 +7073,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi |
| 6765 | }), | 7073 | }), |
| 6766 | ), | 7074 | ), |
| 6767 | ), | 7075 | ), |
| 7076 | mcpApps, | ||
| 6768 | readThreadSnapshot: (threadInput) => | 7077 | readThreadSnapshot: (threadInput) => |
| 6769 | Effect.gen(function* () { | 7078 | Effect.gen(function* () { |
| 6770 | const threadId = yield* getNativeThreadId(threadInput.providerThread); | 7079 | const threadId = yield* getNativeThreadId(threadInput.providerThread); |
apps/server/src/orchestration-v2/ProjectionStore.ts+4-2| ... | @@ -70,6 +70,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -70,6 +70,7 @@ import * as Schema from "effect/Schema"; |
| 70 | import * as SqlClient from "effect/sql/SqlClient"; | 70 | import * as SqlClient from "effect/sql/SqlClient"; |
| 71 | import type * as Statement from "effect/sql/Statement"; | 71 | import type * as Statement from "effect/sql/Statement"; |
| 72 | 72 | ||
| 73 | import { MCP_APP_OUTPUT_KEY } from "@t3tools/shared/mcpApp"; | ||
| 73 | import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts"; | 74 | import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts"; |
| 74 | import { | 75 | import { |
| 75 | isThreadHistoryUserTurn, | 76 | isThreadHistoryUserTurn, |
| ... | @@ -4603,13 +4604,14 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = | ... | @@ -4603,13 +4604,14 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 4603 | json_each(message.payload_json, '$.attachments') AS attachment | 4604 | json_each(message.payload_json, '$.attachments') AS attachment |
| 4604 | WHERE message.thread_id = ${threadId} | 4605 | WHERE message.thread_id = ${threadId} |
| 4605 | `, | 4606 | `, |
| 4606 | // Pages published by html_render live in the attachment store too. | 4607 | // Pages published by html_render, and captured MCP App documents, |
| 4608 | // live in the attachment store too. | ||
| 4607 | sql<{ payload_json: string }>` | 4609 | sql<{ payload_json: string }>` |
| 4608 | SELECT payload_json | 4610 | SELECT payload_json |
| 4609 | FROM orchestration_v2_projection_turn_items | 4611 | FROM orchestration_v2_projection_turn_items |
| 4610 | WHERE thread_id = ${threadId} | 4612 | WHERE thread_id = ${threadId} |
| 4611 | AND type = 'dynamic_tool' | 4613 | AND type = 'dynamic_tool' |
| 4612 | AND payload_json LIKE '%htmlRender%' | 4614 | AND (payload_json LIKE '%htmlRender%' OR payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`}) |
| 4613 | `, | 4615 | `, |
| 4614 | ]).pipe( | 4616 | ]).pipe( |
| 4615 | Effect.map(([messages, renders]) => [ | 4617 | Effect.map(([messages, renders]) => [ |
apps/server/src/orchestration-v2/ProviderAdapter.ts+45| ... | @@ -408,6 +408,12 @@ export interface ProviderAdapterV2TurnInput { | ... | @@ -408,6 +408,12 @@ export interface ProviderAdapterV2TurnInput { |
| 408 | readonly message: ProviderAdapterV2TurnMessage; | 408 | readonly message: ProviderAdapterV2TurnMessage; |
| 409 | readonly modelSelection: ModelSelection; | 409 | readonly modelSelection: ModelSelection; |
| 410 | readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; | 410 | readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; |
| 411 | /** | ||
| 412 | * What the thread's MCP Apps want the agent to know (`ui/update-model-context`), | ||
| 413 | * latest per app, keyed stably per app. Adapters that host apps deliver it as | ||
| 414 | * application context; others never receive any. | ||
| 415 | */ | ||
| 416 | readonly appContext?: ReadonlyArray<{ readonly key: string; readonly text: string }>; | ||
| 411 | } | 417 | } |
| 412 | 418 | ||
| 413 | export interface ProviderAdapterV2SteerInput { | 419 | export interface ProviderAdapterV2SteerInput { |
| ... | @@ -483,6 +489,39 @@ export interface ProviderAdapterV2HistoricalContext { | ... | @@ -483,6 +489,39 @@ export interface ProviderAdapterV2HistoricalContext { |
| 483 | readonly context: string; | 489 | readonly context: string; |
| 484 | } | 490 | } |
| 485 | 491 | ||
| 492 | /** An MCP tool as the provider's MCP client lists it. */ | ||
| 493 | export interface ProviderAdapterV2McpTool { | ||
| 494 | readonly name: string; | ||
| 495 | readonly _meta?: unknown; | ||
| 496 | readonly annotations?: unknown; | ||
| 497 | } | ||
| 498 | |||
| 499 | /** MCP `CallToolResult` / `ReadResourceResult` shapes, passed through unchanged. */ | ||
| 500 | export interface ProviderAdapterV2McpCallToolResult { | ||
| 501 | readonly content: ReadonlyArray<unknown>; | ||
| 502 | readonly structuredContent?: unknown; | ||
| 503 | readonly isError?: boolean; | ||
| 504 | readonly _meta?: unknown; | ||
| 505 | } | ||
| 506 | |||
| 507 | export interface ProviderAdapterV2McpApps { | ||
| 508 | readonly listTools: (input: { | ||
| 509 | readonly providerThread: OrchestrationV2ProviderThread; | ||
| 510 | readonly server: string; | ||
| 511 | }) => Effect.Effect<ReadonlyArray<ProviderAdapterV2McpTool>, ProviderAdapterV2Error>; | ||
| 512 | readonly callTool: (input: { | ||
| 513 | readonly providerThread: OrchestrationV2ProviderThread; | ||
| 514 | readonly server: string; | ||
| 515 | readonly tool: string; | ||
| 516 | readonly arguments: Record<string, unknown>; | ||
| 517 | }) => Effect.Effect<ProviderAdapterV2McpCallToolResult, ProviderAdapterV2Error>; | ||
| 518 | readonly readResource: (input: { | ||
| 519 | readonly providerThread: OrchestrationV2ProviderThread; | ||
| 520 | readonly server: string; | ||
| 521 | readonly uri: string; | ||
| 522 | }) => Effect.Effect<{ readonly contents: ReadonlyArray<unknown> }, ProviderAdapterV2Error>; | ||
| 523 | } | ||
| 524 | |||
| 486 | export interface ProviderAdapterV2SessionRuntime { | 525 | export interface ProviderAdapterV2SessionRuntime { |
| 487 | readonly instanceId: ProviderInstanceId; | 526 | readonly instanceId: ProviderInstanceId; |
| 488 | readonly driver: ProviderDriverKind; | 527 | readonly driver: ProviderDriverKind; |
| ... | @@ -572,6 +611,12 @@ export interface ProviderAdapterV2SessionRuntime { | ... | @@ -572,6 +611,12 @@ export interface ProviderAdapterV2SessionRuntime { |
| 572 | readonly providerThread: OrchestrationV2ProviderThread; | 611 | readonly providerThread: OrchestrationV2ProviderThread; |
| 573 | readonly reason?: string; | 612 | readonly reason?: string; |
| 574 | }) => Effect.Effect<{ readonly feedbackId: string }, ProviderAdapterV2Error>; | 613 | }) => Effect.Effect<{ readonly feedbackId: string }, ProviderAdapterV2Error>; |
| 614 | /** | ||
| 615 | * MCP Apps host operations through the provider's own MCP client, for | ||
| 616 | * drivers whose protocol exposes them (Codex). Absent means apps from this | ||
| 617 | * provider are not interactive and their tool calls stay plain tool rows. | ||
| 618 | */ | ||
| 619 | readonly mcpApps?: ProviderAdapterV2McpApps; | ||
| 575 | readonly rollbackThread: ( | 620 | readonly rollbackThread: ( |
| 576 | input: ProviderAdapterV2RollbackThreadInput, | 621 | input: ProviderAdapterV2RollbackThreadInput, |
| 577 | ) => Effect.Effect<ProviderAdapterV2ThreadSnapshot, ProviderAdapterV2Error>; | 622 | ) => Effect.Effect<ProviderAdapterV2ThreadSnapshot, ProviderAdapterV2Error>; |
apps/server/src/orchestration-v2/RunExecutionService.test.ts+99| ... | @@ -49,9 +49,11 @@ import { | ... | @@ -49,9 +49,11 @@ import { |
| 49 | type ProviderAdapterV2Error, | 49 | type ProviderAdapterV2Error, |
| 50 | type ProviderAdapterV2Event, | 50 | type ProviderAdapterV2Event, |
| 51 | type ProviderAdapterV2SessionRuntime, | 51 | type ProviderAdapterV2SessionRuntime, |
| 52 | type ProviderAdapterV2TurnInput, | ||
| 52 | } from "./ProviderAdapter.ts"; | 53 | } from "./ProviderAdapter.ts"; |
| 53 | import * as ProjectionStore from "./ProjectionStore.ts"; | 54 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 54 | import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; | 55 | import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; |
| 56 | import * as McpAppModelContext from "../mcpApps/McpAppModelContext.ts"; | ||
| 55 | import * as RunExecutionService from "./RunExecutionService.ts"; | 57 | import * as RunExecutionService from "./RunExecutionService.ts"; |
| 56 | import * as RunFinalizationService from "./RunFinalizationService.ts"; | 58 | import * as RunFinalizationService from "./RunFinalizationService.ts"; |
| 57 | 59 | ||
| ... | @@ -60,6 +62,7 @@ const driver = ProviderDriverKind.make("codex"); | ... | @@ -60,6 +62,7 @@ const driver = ProviderDriverKind.make("codex"); |
| 60 | const layerRunExecutionTest = RunExecutionService.layer.pipe( | 62 | const layerRunExecutionTest = RunExecutionService.layer.pipe( |
| 61 | Layer.provide( | 63 | Layer.provide( |
| 62 | Layer.mergeAll( | 64 | Layer.mergeAll( |
| 65 | McpAppModelContext.layerEmpty, | ||
| 63 | Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), | 66 | Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), |
| 64 | Layer.mock(EventSink.EventSinkV2)({}), | 67 | Layer.mock(EventSink.EventSinkV2)({}), |
| 65 | IdAllocator.layer, | 68 | IdAllocator.layer, |
| ... | @@ -591,6 +594,90 @@ it.effect("rechecks run ownership immediately before calling the provider", () = | ... | @@ -591,6 +594,90 @@ it.effect("rechecks run ownership immediately before calling the provider", () = |
| 591 | }).pipe(Effect.provide(layerRunExecutionTest)), | 594 | }).pipe(Effect.provide(layerRunExecutionTest)), |
| 592 | ); | 595 | ); |
| 593 | 596 | ||
| 597 | it.effect("passes the thread's MCP app context to the provider under a safe key", () => | ||
| 598 | Effect.gen(function* () { | ||
| 599 | const runExecution = yield* RunExecutionService.RunExecutionServiceV2; | ||
| 600 | const started = yield* Deferred.make<ProviderAdapterV2TurnInput>(); | ||
| 601 | const threadId = ThreadId.make("thread:run-execution-app-context"); | ||
| 602 | const providerInstanceId = ProviderInstanceId.make("codex"); | ||
| 603 | const attemptId = RunAttemptId.make("attempt:run-execution-app-context"); | ||
| 604 | const session = { | ||
| 605 | events: Stream.never, | ||
| 606 | startTurn: (input: ProviderAdapterV2TurnInput) => Deferred.succeed(started, input), | ||
| 607 | } as unknown as ProviderAdapterV2SessionRuntime; | ||
| 608 | |||
| 609 | yield* runExecution.startRootRun({ | ||
| 610 | commandId: CommandId.make("command:run-execution-app-context"), | ||
| 611 | appThread: { id: threadId } as OrchestrationV2AppThread, | ||
| 612 | providerSessionId: ProviderSessionId.make("session:run-execution-app-context"), | ||
| 613 | session, | ||
| 614 | run: { | ||
| 615 | id: RunId.make("run:run-execution-app-context"), | ||
| 616 | threadId, | ||
| 617 | ordinal: 1, | ||
| 618 | providerInstanceId, | ||
| 619 | } as OrchestrationV2Run, | ||
| 620 | rootNode: { | ||
| 621 | id: NodeId.make("node:run-execution-app-context"), | ||
| 622 | } as OrchestrationV2ExecutionNode, | ||
| 623 | checkpointScope: { | ||
| 624 | id: CheckpointScopeId.make("checkpoint-scope:run-execution-app-context"), | ||
| 625 | } as OrchestrationV2CheckpointScope, | ||
| 626 | providerThread: { | ||
| 627 | id: ProviderThreadId.make("provider-thread:run-execution-app-context"), | ||
| 628 | driver, | ||
| 629 | } as OrchestrationV2ProviderThread, | ||
| 630 | attempt: { id: attemptId, providerTurnId: null } as OrchestrationV2RunAttempt, | ||
| 631 | attemptId, | ||
| 632 | providerTurnOrdinal: 1, | ||
| 633 | message: { | ||
| 634 | messageId: MessageId.make("message:run-execution-app-context"), | ||
| 635 | text: "What is on my list?", | ||
| 636 | attachments: [], | ||
| 637 | createdBy: "user", | ||
| 638 | creationSource: "web", | ||
| 639 | }, | ||
| 640 | modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, | ||
| 641 | runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: process.cwd() }, | ||
| 642 | }); | ||
| 643 | |||
| 644 | const input = yield* Deferred.await(started); | ||
| 645 | // Item ids carry colons, and server names are free text; neither reaches the key. | ||
| 646 | assert.deepEqual(input.appContext, [ | ||
| 647 | { key: "mcp_app_turn-item_provider_codex_native-item_call-1", text: "2 overdue" }, | ||
| 648 | ]); | ||
| 649 | }).pipe( | ||
| 650 | Effect.provide( | ||
| 651 | RunExecutionService.layer.pipe( | ||
| 652 | Layer.provide( | ||
| 653 | Layer.mergeAll( | ||
| 654 | Layer.mock(McpAppModelContext.McpAppModelContext)({ | ||
| 655 | forThread: () => | ||
| 656 | Effect.succeed([ | ||
| 657 | { | ||
| 658 | itemId: "turn-item:provider:codex:native-item:call-1", | ||
| 659 | server: "my tools>", | ||
| 660 | tool: "list", | ||
| 661 | text: "2 overdue", | ||
| 662 | }, | ||
| 663 | ]), | ||
| 664 | }), | ||
| 665 | Layer.mock(CheckpointService.CheckpointServiceV2)({ | ||
| 666 | captureBaseline: () => Effect.void, | ||
| 667 | }), | ||
| 668 | Layer.mock(EventSink.EventSinkV2)({}), | ||
| 669 | IdAllocator.layer, | ||
| 670 | Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ | ||
| 671 | ingestNormalized: () => Effect.succeed([]), | ||
| 672 | }), | ||
| 673 | ServerSettings.layerTest(), | ||
| 674 | ), | ||
| 675 | ), | ||
| 676 | ), | ||
| 677 | ), | ||
| 678 | ), | ||
| 679 | ); | ||
| 680 | |||
| 594 | it.effect("fails the run when its ownership check cannot be read before calling the provider", () => | 681 | it.effect("fails the run when its ownership check cannot be read before calling the provider", () => |
| 595 | Effect.gen(function* () { | 682 | Effect.gen(function* () { |
| 596 | const guardCalls = yield* Ref.make(0); | 683 | const guardCalls = yield* Ref.make(0); |
| ... | @@ -603,6 +690,7 @@ it.effect("fails the run when its ownership check cannot be read before calling | ... | @@ -603,6 +690,7 @@ it.effect("fails the run when its ownership check cannot be read before calling |
| 603 | const layerTest = RunExecutionService.layer.pipe( | 690 | const layerTest = RunExecutionService.layer.pipe( |
| 604 | Layer.provide( | 691 | Layer.provide( |
| 605 | Layer.mergeAll( | 692 | Layer.mergeAll( |
| 693 | McpAppModelContext.layerEmpty, | ||
| 606 | Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), | 694 | Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), |
| 607 | Layer.mock(EventSink.EventSinkV2)({ | 695 | Layer.mock(EventSink.EventSinkV2)({ |
| 608 | writeIfRunCurrent: (input) => | 696 | writeIfRunCurrent: (input) => |
| ... | @@ -876,6 +964,7 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => | ... | @@ -876,6 +964,7 @@ it.effect("starts the provider when checkpoint baseline capture fails", () => |
| 876 | const layerTest = RunExecutionService.layer.pipe( | 964 | const layerTest = RunExecutionService.layer.pipe( |
| 877 | Layer.provide( | 965 | Layer.provide( |
| 878 | Layer.mergeAll( | 966 | Layer.mergeAll( |
| 967 | McpAppModelContext.layerEmpty, | ||
| 879 | Layer.mock(CheckpointService.CheckpointServiceV2)({ | 968 | Layer.mock(CheckpointService.CheckpointServiceV2)({ |
| 880 | captureBaseline: () => | 969 | captureBaseline: () => |
| 881 | Effect.fail( | 970 | Effect.fail( |
| ... | @@ -993,6 +1082,7 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co | ... | @@ -993,6 +1082,7 @@ it.effect.each(["failure", "interruption", "stale-attempt", "start-guard"] as co |
| 993 | const layerTest = RunExecutionService.layer.pipe( | 1082 | const layerTest = RunExecutionService.layer.pipe( |
| 994 | Layer.provide( | 1083 | Layer.provide( |
| 995 | Layer.mergeAll( | 1084 | Layer.mergeAll( |
| 1085 | McpAppModelContext.layerEmpty, | ||
| 996 | Layer.mock(CheckpointService.CheckpointServiceV2)({ | 1086 | Layer.mock(CheckpointService.CheckpointServiceV2)({ |
| 997 | captureBaseline: () => | 1087 | captureBaseline: () => |
| 998 | scenario === "start-guard" ? Effect.void : Effect.die("not reached"), | 1088 | scenario === "start-guard" ? Effect.void : Effect.die("not reached"), |
| ... | @@ -1163,6 +1253,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", | ... | @@ -1163,6 +1253,7 @@ it.effect("keeps ingesting owned child events after the root turn terminalizes", |
| 1163 | const layerTest = RunExecutionService.layer.pipe( | 1253 | const layerTest = RunExecutionService.layer.pipe( |
| 1164 | Layer.provide( | 1254 | Layer.provide( |
| 1165 | Layer.mergeAll( | 1255 | Layer.mergeAll( |
| 1256 | McpAppModelContext.layerEmpty, | ||
| 1166 | Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), | 1257 | Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), |
| 1167 | Layer.mock(EventSink.EventSinkV2)({ | 1258 | Layer.mock(EventSink.EventSinkV2)({ |
| 1168 | write: () => Effect.succeed([]), | 1259 | write: () => Effect.succeed([]), |
| ... | @@ -1588,6 +1679,7 @@ it.effect( | ... | @@ -1588,6 +1679,7 @@ it.effect( |
| 1588 | const layerTest = RunExecutionService.layer.pipe( | 1679 | const layerTest = RunExecutionService.layer.pipe( |
| 1589 | Layer.provide( | 1680 | Layer.provide( |
| 1590 | Layer.mergeAll( | 1681 | Layer.mergeAll( |
| 1682 | McpAppModelContext.layerEmpty, | ||
| 1591 | Layer.mock(CheckpointService.CheckpointServiceV2)({ | 1683 | Layer.mock(CheckpointService.CheckpointServiceV2)({ |
| 1592 | captureBaseline: () => Effect.void, | 1684 | captureBaseline: () => Effect.void, |
| 1593 | }), | 1685 | }), |
| ... | @@ -1805,6 +1897,7 @@ it.effect("drops late root provider-thread writes from a superseded attempt", () | ... | @@ -1805,6 +1897,7 @@ it.effect("drops late root provider-thread writes from a superseded attempt", () |
| 1805 | const layerTest = RunExecutionService.layer.pipe( | 1897 | const layerTest = RunExecutionService.layer.pipe( |
| 1806 | Layer.provide( | 1898 | Layer.provide( |
| 1807 | Layer.mergeAll( | 1899 | Layer.mergeAll( |
| 1900 | McpAppModelContext.layerEmpty, | ||
| 1808 | Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), | 1901 | Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), |
| 1809 | Layer.mock(EventSink.EventSinkV2)({ | 1902 | Layer.mock(EventSink.EventSinkV2)({ |
| 1810 | write: () => Effect.succeed([]), | 1903 | write: () => Effect.succeed([]), |
| ... | @@ -2009,6 +2102,7 @@ it.effect( | ... | @@ -2009,6 +2102,7 @@ it.effect( |
| 2009 | const layerTest = RunExecutionService.layer.pipe( | 2102 | const layerTest = RunExecutionService.layer.pipe( |
| 2010 | Layer.provide( | 2103 | Layer.provide( |
| 2011 | Layer.mergeAll( | 2104 | Layer.mergeAll( |
| 2105 | McpAppModelContext.layerEmpty, | ||
| 2012 | Layer.mock(CheckpointService.CheckpointServiceV2)({ | 2106 | Layer.mock(CheckpointService.CheckpointServiceV2)({ |
| 2013 | captureBaseline: () => Effect.void, | 2107 | captureBaseline: () => Effect.void, |
| 2014 | }), | 2108 | }), |
| ... | @@ -2180,6 +2274,7 @@ it.effect( | ... | @@ -2180,6 +2274,7 @@ it.effect( |
| 2180 | const layerTest = RunExecutionService.layer.pipe( | 2274 | const layerTest = RunExecutionService.layer.pipe( |
| 2181 | Layer.provide( | 2275 | Layer.provide( |
| 2182 | Layer.mergeAll( | 2276 | Layer.mergeAll( |
| 2277 | McpAppModelContext.layerEmpty, | ||
| 2183 | Layer.mock(CheckpointService.CheckpointServiceV2)({ | 2278 | Layer.mock(CheckpointService.CheckpointServiceV2)({ |
| 2184 | captureBaseline: () => Effect.void, | 2279 | captureBaseline: () => Effect.void, |
| 2185 | }), | 2280 | }), |
| ... | @@ -2349,6 +2444,7 @@ it.effect( | ... | @@ -2349,6 +2444,7 @@ it.effect( |
| 2349 | const layerTest = RunExecutionService.layer.pipe( | 2444 | const layerTest = RunExecutionService.layer.pipe( |
| 2350 | Layer.provide( | 2445 | Layer.provide( |
| 2351 | Layer.mergeAll( | 2446 | Layer.mergeAll( |
| 2447 | McpAppModelContext.layerEmpty, | ||
| 2352 | Layer.mock(CheckpointService.CheckpointServiceV2)({ | 2448 | Layer.mock(CheckpointService.CheckpointServiceV2)({ |
| 2353 | captureBaseline: () => Effect.void, | 2449 | captureBaseline: () => Effect.void, |
| 2354 | }), | 2450 | }), |
| ... | @@ -2722,6 +2818,7 @@ it.effect( | ... | @@ -2722,6 +2818,7 @@ it.effect( |
| 2722 | const layerTest = RunExecutionService.layer.pipe( | 2818 | const layerTest = RunExecutionService.layer.pipe( |
| 2723 | Layer.provide( | 2819 | Layer.provide( |
| 2724 | Layer.mergeAll( | 2820 | Layer.mergeAll( |
| 2821 | McpAppModelContext.layerEmpty, | ||
| 2725 | Layer.mock(CheckpointService.CheckpointServiceV2)({ | 2822 | Layer.mock(CheckpointService.CheckpointServiceV2)({ |
| 2726 | captureBaseline: () => Effect.void, | 2823 | captureBaseline: () => Effect.void, |
| 2727 | }), | 2824 | }), |
| ... | @@ -3444,6 +3541,7 @@ function captureRootRunTermination(input: { | ... | @@ -3444,6 +3541,7 @@ function captureRootRunTermination(input: { |
| 3444 | const layerTest = RunExecutionService.layer.pipe( | 3541 | const layerTest = RunExecutionService.layer.pipe( |
| 3445 | Layer.provide( | 3542 | Layer.provide( |
| 3446 | Layer.mergeAll( | 3543 | Layer.mergeAll( |
| 3544 | McpAppModelContext.layerEmpty, | ||
| 3447 | Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), | 3545 | Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), |
| 3448 | Layer.mock(EventSink.EventSinkV2)({ | 3546 | Layer.mock(EventSink.EventSinkV2)({ |
| 3449 | write: (payload) => | 3547 | write: (payload) => |
| ... | @@ -3922,6 +4020,7 @@ function runBackgroundItemScenario( | ... | @@ -3922,6 +4020,7 @@ function runBackgroundItemScenario( |
| 3922 | const layerTest = RunExecutionService.layer.pipe( | 4020 | const layerTest = RunExecutionService.layer.pipe( |
| 3923 | Layer.provide( | 4021 | Layer.provide( |
| 3924 | Layer.mergeAll( | 4022 | Layer.mergeAll( |
| 4023 | McpAppModelContext.layerEmpty, | ||
| 3925 | Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), | 4024 | Layer.mock(CheckpointService.CheckpointServiceV2)({ captureBaseline: () => Effect.void }), |
| 3926 | Layer.mock(EventSink.EventSinkV2)({ | 4025 | Layer.mock(EventSink.EventSinkV2)({ |
| 3927 | write: () => Effect.succeed([]), | 4026 | write: () => Effect.succeed([]), |
apps/server/src/orchestration-v2/RunExecutionService.ts+21| ... | @@ -36,6 +36,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -36,6 +36,7 @@ import * as Schema from "effect/Schema"; |
| 36 | import * as Stream from "effect/Stream"; | 36 | import * as Stream from "effect/Stream"; |
| 37 | 37 | ||
| 38 | import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; | 38 | import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; |
| 39 | import * as McpAppModelContext from "../mcpApps/McpAppModelContext.ts"; | ||
| 39 | import * as ServerSettings from "../serverSettings.ts"; | 40 | import * as ServerSettings from "../serverSettings.ts"; |
| 40 | import * as CheckpointService from "./CheckpointService.ts"; | 41 | import * as CheckpointService from "./CheckpointService.ts"; |
| 41 | import * as EventSink from "./EventSink.ts"; | 42 | import * as EventSink from "./EventSink.ts"; |
| ... | @@ -544,6 +545,7 @@ export const layer: Layer.Layer< | ... | @@ -544,6 +545,7 @@ export const layer: Layer.Layer< |
| 544 | | IdAllocator.IdAllocatorV2 | 545 | | IdAllocator.IdAllocatorV2 |
| 545 | | ProviderEventIngestor.ProviderEventIngestorV2 | 546 | | ProviderEventIngestor.ProviderEventIngestorV2 |
| 546 | | ServerSettings.ServerSettingsService | 547 | | ServerSettings.ServerSettingsService |
| 548 | | McpAppModelContext.McpAppModelContext | ||
| 547 | > = Layer.effect( | 549 | > = Layer.effect( |
| 548 | RunExecutionServiceV2, | 550 | RunExecutionServiceV2, |
| 549 | Effect.gen(function* () { | 551 | Effect.gen(function* () { |
| ... | @@ -552,6 +554,7 @@ export const layer: Layer.Layer< | ... | @@ -552,6 +554,7 @@ export const layer: Layer.Layer< |
| 552 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | 554 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 553 | const providerEventIngestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; | 555 | const providerEventIngestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; |
| 554 | const serverSettings = yield* ServerSettings.ServerSettingsService; | 556 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 557 | const mcpAppModelContext = yield* McpAppModelContext.McpAppModelContext; | ||
| 555 | const finalizationObserver = yield* RunFinalizationService.RunFinalizationObserver; | 558 | const finalizationObserver = yield* RunFinalizationService.RunFinalizationObserver; |
| 556 | 559 | ||
| 557 | const writeFinalRunEvents = (input: { | 560 | const writeFinalRunEvents = (input: { |
| ... | @@ -1352,6 +1355,23 @@ export const layer: Layer.Layer< | ... | @@ -1352,6 +1355,23 @@ export const layer: Layer.Layer< |
| 1352 | // its already-issued MCP credential valid even when the agent goes | 1355 | // its already-issued MCP credential valid even when the agent goes |
| 1353 | // a long time between browser-tool calls. | 1356 | // a long time between browser-tool calls. |
| 1354 | yield* McpSessionRegistry.touchActiveMcpThread(input.run.threadId); | 1357 | yield* McpSessionRegistry.touchActiveMcpThread(input.run.threadId); |
| 1358 | // A context read that fails costs the agent the apps' notes for | ||
| 1359 | // this turn, not the turn itself. | ||
| 1360 | const appContext = (yield* mcpAppModelContext | ||
| 1361 | .forThread(input.run.threadId) | ||
| 1362 | .pipe( | ||
| 1363 | Effect.catch((cause) => | ||
| 1364 | Effect.logWarning("Failed to read MCP app model context.", { cause }).pipe( | ||
| 1365 | Effect.as([]), | ||
| 1366 | ), | ||
| 1367 | ), | ||
| 1368 | )).map((entry) => ({ | ||
| 1369 | // The item id alone is unique and needs no escaping; server and | ||
| 1370 | // tool names are free text that would break the tag Codex wraps | ||
| 1371 | // the context in. | ||
| 1372 | key: `mcp_app_${entry.itemId.replace(/[^\w.-]/g, "_")}`, | ||
| 1373 | text: entry.text, | ||
| 1374 | })); | ||
| 1355 | const turnInput = { | 1375 | const turnInput = { |
| 1356 | appThread: input.appThread, | 1376 | appThread: input.appThread, |
| 1357 | threadId: input.run.threadId, | 1377 | threadId: input.run.threadId, |
| ... | @@ -1372,6 +1392,7 @@ export const layer: Layer.Layer< | ... | @@ -1372,6 +1392,7 @@ export const layer: Layer.Layer< |
| 1372 | message: input.message, | 1392 | message: input.message, |
| 1373 | modelSelection: input.modelSelection, | 1393 | modelSelection: input.modelSelection, |
| 1374 | runtimePolicy: input.runtimePolicy, | 1394 | runtimePolicy: input.runtimePolicy, |
| 1395 | ...(appContext.length === 0 ? {} : { appContext }), | ||
| 1375 | }; | 1396 | }; |
| 1376 | const compact = | 1397 | const compact = |
| 1377 | input.message.attachments.length === 0 && | 1398 | input.message.attachments.length === 0 && |
apps/server/src/orchestration-v2/runtimeLayer.ts+15| ... | @@ -3,6 +3,8 @@ import * as Scheduler from "../scheduling/Scheduler.ts"; | ... | @@ -3,6 +3,8 @@ import * as Scheduler from "../scheduling/Scheduler.ts"; |
| 3 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as OrchestrationCommandReceipts from "../persistence/OrchestrationCommandReceipts.ts"; | 4 | import * as OrchestrationCommandReceipts from "../persistence/OrchestrationCommandReceipts.ts"; |
| 5 | import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; | 5 | import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; |
| 6 | import * as McpAppModelContext from "../mcpApps/McpAppModelContext.ts"; | ||
| 7 | import * as McpAppRequests from "../mcpApps/McpAppRequests.ts"; | ||
| 6 | import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; | 8 | import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; |
| 7 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; | 9 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 8 | import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; | 10 | import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; |
| ... | @@ -141,6 +143,7 @@ const layerProviderAuthServiceProvided = ProviderAuthService.layer.pipe( | ... | @@ -141,6 +143,7 @@ const layerProviderAuthServiceProvided = ProviderAuthService.layer.pipe( |
| 141 | const layerRunExecutionServiceProvided = RunExecutionService.layer.pipe( | 143 | const layerRunExecutionServiceProvided = RunExecutionService.layer.pipe( |
| 142 | Layer.provide( | 144 | Layer.provide( |
| 143 | Layer.mergeAll( | 145 | Layer.mergeAll( |
| 146 | McpAppModelContext.layer, | ||
| 144 | layerCheckpointServiceProvided, | 147 | layerCheckpointServiceProvided, |
| 145 | layerEventSinkProvided, | 148 | layerEventSinkProvided, |
| 146 | IdAllocator.layer, | 149 | IdAllocator.layer, |
| ... | @@ -314,8 +317,20 @@ const layerProviderRuntimeRecoveryProvided = ProviderRuntimeRecoveryService.laye | ... | @@ -314,8 +317,20 @@ const layerProviderRuntimeRecoveryProvided = ProviderRuntimeRecoveryService.laye |
| 314 | ), | 317 | ), |
| 315 | ); | 318 | ); |
| 316 | 319 | ||
| 320 | const layerMcpAppRequestsProvided = McpAppRequests.layer.pipe( | ||
| 321 | Layer.provide( | ||
| 322 | Layer.mergeAll( | ||
| 323 | McpAppModelContext.layer, | ||
| 324 | layerOrchestratorProvided, | ||
| 325 | layerThreadManagementProvided, | ||
| 326 | layerProviderSessionManagerProvided, | ||
| 327 | ), | ||
| 328 | ), | ||
| 329 | ); | ||
| 330 | |||
| 317 | export const layer = Layer.mergeAll( | 331 | export const layer = Layer.mergeAll( |
| 318 | layerOrchestratorProvided, | 332 | layerOrchestratorProvided, |
| 333 | layerMcpAppRequestsProvided, | ||
| 319 | layerThreadManagementProvided, | 334 | layerThreadManagementProvided, |
| 320 | layerEffectWorkerProvided, | 335 | layerEffectWorkerProvided, |
| 321 | layerProviderSessionManagerProvided, | 336 | layerProviderSessionManagerProvided, |
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+2| ... | @@ -44,6 +44,7 @@ import * as ProviderSwitchService from "../ProviderSwitchService.ts"; | ... | @@ -44,6 +44,7 @@ import * as ProviderSwitchService from "../ProviderSwitchService.ts"; |
| 44 | import * as ProviderTurnControlService from "../ProviderTurnControlService.ts"; | 44 | import * as ProviderTurnControlService from "../ProviderTurnControlService.ts"; |
| 45 | import * as ProviderTurnStartService from "../ProviderTurnStartService.ts"; | 45 | import * as ProviderTurnStartService from "../ProviderTurnStartService.ts"; |
| 46 | import * as ProviderTurnStartServiceTestkit from "../ProviderTurnStartService.testkit.ts"; | 46 | import * as ProviderTurnStartServiceTestkit from "../ProviderTurnStartService.testkit.ts"; |
| 47 | import * as McpAppModelContext from "../../mcpApps/McpAppModelContext.ts"; | ||
| 47 | import * as RunExecutionService from "../RunExecutionService.ts"; | 48 | import * as RunExecutionService from "../RunExecutionService.ts"; |
| 48 | import * as RunFinalizationService from "../RunFinalizationService.ts"; | 49 | import * as RunFinalizationService from "../RunFinalizationService.ts"; |
| 49 | import * as ThreadTitleRegenerationService from "../ThreadTitleRegenerationService.ts"; | 50 | import * as ThreadTitleRegenerationService from "../ThreadTitleRegenerationService.ts"; |
| ... | @@ -355,6 +356,7 @@ export function layerWithRegistry<Error>( | ... | @@ -355,6 +356,7 @@ export function layerWithRegistry<Error>( |
| 355 | const layerRunExecutionServiceProvided = RunExecutionService.layer.pipe( | 356 | const layerRunExecutionServiceProvided = RunExecutionService.layer.pipe( |
| 356 | Layer.provide( | 357 | Layer.provide( |
| 357 | Layer.mergeAll( | 358 | Layer.mergeAll( |
| 359 | McpAppModelContext.layerEmpty, | ||
| 358 | layerCheckpointServiceProvided, | 360 | layerCheckpointServiceProvided, |
| 359 | layerEventSinkProvided, | 361 | layerEventSinkProvided, |
| 360 | IdAllocator.layer, | 362 | IdAllocator.layer, |
apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"delegated_task_status","metadata":{"source":"constructed-codex-app-server-replay-fixture","fileName":"codex_transcript.ndjson","normalization":"Provider-valid frames were composed into a deterministic multi-turn lifecycle; native IDs and timestamps were normalized.","description":"An active parent delegates a child, interrupts a running follow-up, and lets queued work complete."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"delegated_task_status","metadata":{"source":"constructed-codex-app-server-replay-fixture","fileName":"codex_transcript.ndjson","normalization":"Provider-valid frames were composed into a deterministic multi-turn lifecycle; native IDs and timestamps were normalized.","description":"An active parent delegates a child, interrupts a running follow-up, and lets queued work complete."}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0 (Mac OS 26.4.1; arm64) dumb (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"parent.thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true}}}} | 5 | {"type":"expect_outbound","label":"parent.thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true}}}} |
apps/server/src/orchestration-v2/testkit/fixtures/message_steering/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"message_steering","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"message_steering.ndjson","description":"One active turn receives an immediate turn/steer request.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"message_steering","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"message_steering.ndjson","description":"One active turn receives an immediate turn/steer request.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"multi_turn","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"multi_turn.ndjson","description":"One thread with two sequential user turns.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"multi_turn","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"multi_turn.ndjson","description":"One thread with two sequential user turns.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/plan_questions/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"plan_questions","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"plan_questions.ndjson","description":"One plan-mode turn that asks a structured clarifying question.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"plan_questions","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"plan_questions.ndjson","description":"One plan-mode turn that asks a structured clarifying question.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/proposed_plan/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"proposed_plan","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"proposed_plan.ndjson","description":"One plan-mode turn that emits a proposed plan document.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"proposed_plan","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"proposed_plan.ndjson","description":"One plan-mode turn that emits a proposed plan document.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/provider_thread_resume/codex_transcript.ndjson+2-2| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"provider_thread_resume","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"provider_thread_resume.ndjson","description":"One provider-native thread is started, completed, then resumed by thread id in a fresh app-server session.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"provider_thread_resume","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"provider_thread_resume.ndjson","description":"One provider-native thread is started, completed, then resumed by thread id in a fresh app-server session.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
| ... | @@ -32,7 +32,7 @@ | ... | @@ -32,7 +32,7 @@ |
| 32 | {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","status":{"type":"idle"}},"emittedAtMs":1790295635342}} | 32 | {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","status":{"type":"idle"}},"emittedAtMs":1790295635342}} |
| 33 | {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","turn":{"id":"01a0d5ef-637e-7ba2-bf9a-5534a6f98a83","items":[{"type":"agentMessage","id":"msg_0b6c69e800b2ec4c016ab5be52efd887d092b6695452f82b22","text":"provider thread resume fixture first turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790295630,"completedAt":1790295635,"durationMs":4607}},"emittedAtMs":1790295635342}} | 33 | {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd","turn":{"id":"01a0d5ef-637e-7ba2-bf9a-5534a6f98a83","items":[{"type":"agentMessage","id":"msg_0b6c69e800b2ec4c016ab5be52efd887d092b6695452f82b22","text":"provider thread resume fixture first turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790295630,"completedAt":1790295635,"durationMs":4607}},"emittedAtMs":1790295635342}} |
| 34 | {"type":"runtime_exit","status":"success"} | 34 | {"type":"runtime_exit","status":"success"} |
| 35 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 35 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 36 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 36 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 37 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 37 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 38 | {"type":"expect_outbound","label":"thread/resume","frame":{"id":2,"method":"thread/resume","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","excludeTurns":true,"model":"gpt-6-luna","threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd"}}} | 38 | {"type":"expect_outbound","label":"thread/resume","frame":{"id":2,"method":"thread/resume","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","excludeTurns":true,"model":"gpt-6-luna","threadId":"01a0d5ef-62bd-7c20-861b-0528ff7d86bd"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"queued_turn","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"queued_turn.ndjson","description":"A second user turn queued behind an active turn starts after it completes.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"queued_turn","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"queued_turn.ndjson","description":"A second user turn queued behind an active turn starts after it completes.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/simple/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"simple","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"simple.ndjson","description":"One thread and one turn with a deterministic text-only response.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"simple","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"simple.ndjson","description":"One thread and one turn with a deterministic text-only response.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent.ndjson","description":"One root turn that asks Codex to spawn two collab agents.","model":"gpt-5.6-luna","recorderThreadConfigKeys":["skills.include_instructions"]}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent.ndjson","description":"One root turn that asks Codex to spawn two collab agents.","model":"gpt-5.6-luna","recorderThreadConfigKeys":["skills.include_instructions"]}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"skills.include_instructions":false},"cwd":"<workspace>","model":"gpt-5.6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"skills.include_instructions":false},"cwd":"<workspace>","model":"gpt-5.6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/subagent_continue/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_continue.ndjson","description":"A root turn spawns one native Codex subagent, then a later root turn resumes and messages the same child thread.","model":"gpt-5.6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_continue.ndjson","description":"A root turn spawns one native Codex subagent, then a later root turn resumes and messages the same child thread.","model":"gpt-5.6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-5.6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-5.6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent_v2","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_v2.ndjson","description":"One root turn on a multi-agent v2 model that spawns one native subagent and waits for it.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent_v2","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_v2.ndjson","description":"One root turn on a multi-agent v2 model that spawns one native subagent and waits for it.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_approval/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent_v2_approval","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_v2_approval.ndjson","description":"One root turn in approval-required mode whose multi-agent v2 subagent runs a command that needs approval.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent_v2_approval","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_v2_approval.ndjson","description":"One root turn in approval-required mode whose multi-agent v2 subagent runs a command that needs approval.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/tmp/codex-subagent-approval-rec/codex-home","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/tmp/codex-subagent-approval-rec/codex-home","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent_v2_nested","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_v2_nested.ndjson","description":"One root turn on a multi-agent v2 model whose subagent spawns a subagent that spawns a leaf.","model":"gpt-6-luna","recorderThreadConfigKeys":["agents.max_depth"]}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent_v2_nested","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_v2_nested.ndjson","description":"One root turn on a multi-agent v2 model whose subagent spawns a subagent that spawns a leaf.","model":"gpt-6-luna","recorderThreadConfigKeys":["agents.max_depth"]}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"agents.max_depth":3},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"agents.max_depth":3},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2_nested_approval/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent_v2_nested_approval","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_v2_nested_approval.ndjson","description":"One root turn in approval-required mode whose multi-agent v2 subagent spawns a subagent that runs a command that needs approval.","model":"gpt-6-luna","recorderThreadConfigKeys":["agents.max_depth"]}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"subagent_v2_nested_approval","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"subagent_v2_nested_approval.ndjson","description":"One root turn in approval-required mode whose multi-agent v2 subagent spawns a subagent that runs a command that needs approval.","model":"gpt-6-luna","recorderThreadConfigKeys":["agents.max_depth"]}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.cache/codex-nested-approval-rec/codex-home","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.cache/codex-nested-approval-rec/codex-home","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"agents.max_depth":2},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"agents.max_depth":2},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"thread_fork_native","metadata":{"source":"hand-authored-replay","description":"One completed source turn, a native thread/fork, and the first target fork turn.","normalization":"The thread/fork expected request gains lastTurnId (inclusive native boundary) to match the V2 adapter's native-boundary fork."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.120.0","scenario":"thread_fork_native","metadata":{"source":"hand-authored-replay","description":"One completed source turn, a native thread/fork, and the first target fork turn.","normalization":"The thread/fork expected request gains lastTurnId (inclusive native boundary) to match the V2 adapter's native-boundary fork."}} |
| 2 | {"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize/source","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0","codexHome":"/tmp/codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize/source","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.120.0","codexHome":"/tmp/codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start/source","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true}}}} | 5 | {"type":"expect_outbound","label":"thread/start/source","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true}}}} |
apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_continue/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_fork_native_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_fork_native_continue.ndjson","description":"One source marker turn, a native thread fork, a fork-local marker turn, and a recall turn that requires both contexts.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_fork_native_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_fork_native_continue.ndjson","description":"One source marker turn, a native thread fork, a fork-local marker turn, and a recall turn that requires both contexts.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_prior_turn/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.124.0-alpha.3","scenario":"thread_fork_native_prior_turn","metadata":{"source":"codex-app-server-live-probe-normalized-for-orchestrator-replay","description":"Two completed source turns, a native thread/fork through the first source turn, and a fork turn that only sees the first turn.","sourceThreadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","forkThreadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","firstTurnId":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","secondTurnId":"019dd6ba-416d-7902-95cf-d4179c3f4738","repeatTurnId":"019dd6ba-47eb-7041-ad45-5abe752c28c9","normalization":"Native frames are live. Initialize frames are normalized to the T3 adapter client info, and a fork-session initialize was inserted to match orchestrator provider-session lifecycle. The thread/fork expected request gains lastTurnId (the first source turn, inclusive), the emitted fork response now contains only turns through that boundary, and the thread/rollback pair was removed to match the V2 adapter's native-boundary fork."}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.124.0-alpha.3","scenario":"thread_fork_native_prior_turn","metadata":{"source":"codex-app-server-live-probe-normalized-for-orchestrator-replay","description":"Two completed source turns, a native thread/fork through the first source turn, and a fork turn that only sees the first turn.","sourceThreadId":"019dd6ba-2681-7bf0-b051-141b0cbcbb27","forkThreadId":"019dd6ba-47b7-7092-8688-9cf7fe5f6498","firstTurnId":"019dd6ba-268b-7f82-8f4c-9fde0edcfd55","secondTurnId":"019dd6ba-416d-7902-95cf-d4179c3f4738","repeatTurnId":"019dd6ba-47eb-7041-ad45-5abe752c28c9","normalization":"Native frames are live. Initialize frames are normalized to the T3 adapter client info, and a fork-session initialize was inserted to match orchestrator provider-session lifecycle. The thread/fork expected request gains lastTurnId (the first source turn, inclusive), the emitted fork response now contains only turns through that boundary, and the thread/rollback pair was removed to match the V2 adapter's native-boundary fork."}} |
| 2 | {"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize/source","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize/source","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.124.0-alpha.3","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"macos"}}} | 3 | {"type":"emit_inbound","label":"initialize/source","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.124.0-alpha.3","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"macos"}}} |
| 4 | {"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized/source","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true}}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true}}}} |
apps/server/src/orchestration-v2/testkit/fixtures/thread_fork_native_siblings/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_fork_native_siblings","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_fork_native_siblings.ndjson","description":"One source marker turn and two independent native forks that each recall the source plus only their own fork marker.","model":"gpt-6-sol"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_fork_native_siblings","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_fork_native_siblings.ndjson","description":"One source marker turn and two independent native forks that each recall the source plus only their own fork marker.","model":"gpt-6-sol"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-sol"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-sol"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_continue/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_merge_back_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_merge_back_continue.ndjson","description":"A source thread consumes one fork-delta handoff and later recalls source and transferred context.","model":"gpt-6-sol"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_merge_back_continue","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_merge_back_continue.ndjson","description":"A source thread consumes one fork-delta handoff and later recalls source and transferred context.","model":"gpt-6-sol"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-sol"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-sol"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/thread_merge_back_siblings/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_merge_back_siblings","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_merge_back_siblings.ndjson","description":"Two sibling fork deltas are merged sequentially into one source provider thread and recalled together.","model":"gpt-6-sol"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_merge_back_siblings","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_merge_back_siblings.ndjson","description":"Two sibling fork deltas are merged sequentially into one source provider thread and recalled together.","model":"gpt-6-sol"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-sol"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-sol"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_rollback","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_rollback.ndjson","description":"One thread completes two turns, rolls back the most recent turn, then starts another turn.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_rollback","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_rollback.ndjson","description":"One thread completes two turns, rolls back the most recent turn, then starts another turn.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_restart/codex_transcript.ndjson+2-2| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_rollback_after_restart","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_rollback_after_restart.ndjson","description":"One thread completes two turns, the app-server restarts, then the thread rolls back its latest turn and starts another turn.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_rollback_after_restart","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_rollback_after_restart.ndjson","description":"One thread completes two turns, the app-server restarts, then the thread rolls back its latest turn and starts another turn.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/tmp/bughunt-codex/codex-home","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/tmp/bughunt-codex/codex-home","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
| ... | @@ -45,7 +45,7 @@ | ... | @@ -45,7 +45,7 @@ |
| 45 | {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","status":{"type":"idle"}},"emittedAtMs":1790404934811}} | 45 | {"type":"emit_inbound","label":"thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","status":{"type":"idle"}},"emittedAtMs":1790404934811}} |
| 46 | {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","turn":{"id":"01a0dc73-2dc4-7911-a448-c66b9bf3318b","items":[{"type":"agentMessage","id":"msg_0f3ce1c685f16088016ab769447c6087d08bd0018d724b5401","text":"rollback fixture second turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790404931,"completedAt":1790404934,"durationMs":3756}},"emittedAtMs":1790404934811}} | 46 | {"type":"emit_inbound","label":"turn/completed","frame":{"method":"turn/completed","params":{"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a","turn":{"id":"01a0dc73-2dc4-7911-a448-c66b9bf3318b","items":[{"type":"agentMessage","id":"msg_0f3ce1c685f16088016ab769447c6087d08bd0018d724b5401","text":"rollback fixture second turn complete","phase":"final_answer","memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1790404931,"completedAt":1790404934,"durationMs":3756}},"emittedAtMs":1790404934811}} |
| 47 | {"type":"runtime_exit","status":"success"} | 47 | {"type":"runtime_exit","status":"success"} |
| 48 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 48 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 49 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/tmp/bughunt-codex/codex-home","platformFamily":"unix","platformOs":"linux"}}} | 49 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/tmp/bughunt-codex/codex-home","platformFamily":"unix","platformOs":"linux"}}} |
| 50 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 50 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 51 | {"type":"expect_outbound","label":"thread/read","frame":{"id":2,"method":"thread/read","params":{"includeTurns":false,"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a"}}} | 51 | {"type":"expect_outbound","label":"thread/read","frame":{"id":2,"method":"thread/read","params":{"includeTurns":false,"threadId":"01a0dc73-1bb4-74f1-977f-2fc39b8aaa6a"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_to_stopped_turn/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_rollback_to_stopped_turn","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_rollback_to_stopped_turn.ndjson","description":"One completed turn, one turn stopped mid-tool, one completed turn, thread/revert back to the stopped turn, then a post-rollback turn.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"thread_rollback_to_stopped_turn","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"thread_rollback_to_stopped_turn.ndjson","description":"One completed turn, one turn stopped mid-tool, one completed turn, thread/revert back to the stopped turn, then a post-rollback turn.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/todo_list/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"todo_list","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"todo_list.ndjson","description":"One turn that asks Codex to emit progress through update_plan.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"todo_list","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"todo_list.ndjson","description":"One turn that asks Codex to emit progress through update_plan.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"tool_call_read_only_on_request","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"tool_call_read_only_on_request.ndjson","description":"Write a small fixture file with read-only full filesystem visibility and on-request approvals.","model":"gpt-6-sol"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"tool_call_read_only_on_request","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"tool_call_read_only_on_request.ndjson","description":"Write a small fixture file with read-only full filesystem visibility and on-request approvals.","model":"gpt-6-sol"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-sol"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-sol"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/tool_call_restricted_granular/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"tool_call_restricted_granular","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"tool_call_restricted_granular.ndjson","description":"Write a small fixture file with restricted read access and granular approval flags enabled.","model":"gpt-5.6-terra"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"tool_call_restricted_granular","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"tool_call_restricted_granular.ndjson","description":"Write a small fixture file with restricted read access and granular approval flags enabled.","model":"gpt-5.6-terra"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-5.6-terra"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-5.6-terra"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/tool_call_workspace_never/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"tool_call_workspace_never","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"tool_call_workspace_never.ndjson","description":"Write a small fixture file with workspace-write sandbox policy and never approvals.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"tool_call_workspace_never","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"tool_call_workspace_never.ndjson","description":"Write a small fixture file with workspace-write sandbox policy and never approvals.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"turn_interrupt","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"turn_interrupt.ndjson","description":"One active turn is interrupted before it finishes naturally.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"turn_interrupt","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"turn_interrupt.ndjson","description":"One active turn is interrupted before it finishes naturally.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"turn_interrupt_mid_tool","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"turn_interrupt_mid_tool.ndjson","description":"One active turn is interrupted after Codex has already executed a local command.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"turn_interrupt_mid_tool","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"turn_interrupt_mid_tool.ndjson","description":"One active turn is interrupted after Codex has already executed a local command.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/orchestration-v2/testkit/fixtures/web_search/codex_transcript.ndjson+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"web_search","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"web_search.ndjson","description":"One turn with a native Codex webSearch item that should become normalized progress.","model":"gpt-6-luna"}} | 1 | {"type":"transcript_start","provider":"codex","protocol":"codex.app-server","version":"0.156.1","scenario":"web_search","metadata":{"source":"record-codex-app-server-replay-fixture","fileName":"web_search.ndjson","description":"One turn with a native Codex webSearch item that should become normalized progress.","model":"gpt-6-luna"}} |
| 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"]},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} | 2 | {"type":"expect_outbound","label":"initialize","frame":{"id":1,"method":"initialize","params":{"capabilities":{"experimentalApi":true,"optOutNotificationMethods":["turn/diff/updated"],"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}},"clientInfo":{"name":"T3 Code","title":"T3 Code","version":"0.0.44"}}}} |
| 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} | 3 | {"type":"emit_inbound","label":"initialize","frame":{"id":1,"result":{"userAgent":"t3code_desktop/0.156.1 (Debian 13.0.0; x86_64) unknown (t3code_desktop; 0.1.0)","codexHome":"/home/replay-user/.codex","platformFamily":"unix","platformOs":"linux"}}} |
| 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} | 4 | {"type":"expect_outbound","label":"initialized","frame":{"method":"initialized"}} |
| 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} | 5 | {"type":"expect_outbound","label":"thread/start","frame":{"id":2,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true},"cwd":"<workspace>","model":"gpt-6-luna"}}} |
apps/server/src/persistence/Migrations.ts+2| ... | @@ -72,6 +72,7 @@ import Migration0055 from "./Migrations/055_OrchestrationV2.ts"; | ... | @@ -72,6 +72,7 @@ import Migration0055 from "./Migrations/055_OrchestrationV2.ts"; |
| 72 | import Migration0056 from "./Migrations/056_RemoveRedundantProjectionIndexes.ts"; | 72 | import Migration0056 from "./Migrations/056_RemoveRedundantProjectionIndexes.ts"; |
| 73 | import Migration0057 from "./Migrations/057_ScheduledTaskWebhooks.ts"; | 73 | import Migration0057 from "./Migrations/057_ScheduledTaskWebhooks.ts"; |
| 74 | import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts"; | 74 | import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts"; |
| 75 | import Migration0059 from "./Migrations/059_McpAppModelContext.ts"; | ||
| 75 | 76 | ||
| 76 | /** | 77 | /** |
| 77 | * Migration loader with all migrations defined inline. | 78 | * Migration loader with all migrations defined inline. |
| ... | @@ -144,6 +145,7 @@ export const migrationEntries = [ | ... | @@ -144,6 +145,7 @@ export const migrationEntries = [ |
| 144 | [56, "RemoveRedundantProjectionIndexes", Migration0056], | 145 | [56, "RemoveRedundantProjectionIndexes", Migration0056], |
| 145 | [57, "ScheduledTaskWebhooks", Migration0057], | 146 | [57, "ScheduledTaskWebhooks", Migration0057], |
| 146 | [58, "WebhookRelayDeliveries", Migration0058], | 147 | [58, "WebhookRelayDeliveries", Migration0058], |
| 148 | [59, "McpAppModelContext", Migration0059], | ||
| 147 | ] as const; | 149 | ] as const; |
| 148 | 150 | ||
| 149 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); | 151 | export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); |
apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts+3-1| ... | @@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => { | ... | @@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => { |
| 13 | Effect.sync(() => { | 13 | Effect.sync(() => { |
| 14 | assert.deepStrictEqual( | 14 | assert.deepStrictEqual( |
| 15 | migrationEntries.map(([id]) => id), | 15 | migrationEntries.map(([id]) => id), |
| 16 | Array.from({ length: 58 }, (_, index) => index + 1), | 16 | Array.from({ length: 59 }, (_, index) => index + 1), |
| 17 | ); | 17 | ); |
| 18 | }), | 18 | }), |
| 19 | ); | 19 | ); |
| ... | @@ -30,6 +30,7 @@ layer("055_OrchestrationV2", (it) => { | ... | @@ -30,6 +30,7 @@ layer("055_OrchestrationV2", (it) => { |
| 30 | [56, "RemoveRedundantProjectionIndexes"], | 30 | [56, "RemoveRedundantProjectionIndexes"], |
| 31 | [57, "ScheduledTaskWebhooks"], | 31 | [57, "ScheduledTaskWebhooks"], |
| 32 | [58, "WebhookRelayDeliveries"], | 32 | [58, "WebhookRelayDeliveries"], |
| 33 | [59, "McpAppModelContext"], | ||
| 33 | ]); | 34 | ]); |
| 34 | assert.deepStrictEqual(yield* runMigrations(), []); | 35 | assert.deepStrictEqual(yield* runMigrations(), []); |
| 35 | 36 | ||
| ... | @@ -54,6 +55,7 @@ layer("055_OrchestrationV2", (it) => { | ... | @@ -54,6 +55,7 @@ layer("055_OrchestrationV2", (it) => { |
| 54 | { migration_id: 56, name: "RemoveRedundantProjectionIndexes" }, | 55 | { migration_id: 56, name: "RemoveRedundantProjectionIndexes" }, |
| 55 | { migration_id: 57, name: "ScheduledTaskWebhooks" }, | 56 | { migration_id: 57, name: "ScheduledTaskWebhooks" }, |
| 56 | { migration_id: 58, name: "WebhookRelayDeliveries" }, | 57 | { migration_id: 58, name: "WebhookRelayDeliveries" }, |
| 58 | { migration_id: 59, name: "McpAppModelContext" }, | ||
| 57 | ]); | 59 | ]); |
| 58 | 60 | ||
| 59 | const tables = yield* sql<{ readonly name: string }>` | 61 | const tables = yield* sql<{ readonly name: string }>` |
apps/server/src/persistence/Migrations/059_McpAppModelContext.ts created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 3 | |||
| 4 | export default Effect.gen(function* () { | ||
| 5 | const sql = yield* SqlClient.SqlClient; | ||
| 6 | |||
| 7 | // The latest context each MCP App gave the agent (`ui/update-model-context`), | ||
| 8 | // one row per app, replaced on every update and sent with the thread's next | ||
| 9 | // turn. | ||
| 10 | yield* sql` | ||
| 11 | CREATE TABLE IF NOT EXISTS mcp_app_model_context ( | ||
| 12 | thread_id TEXT NOT NULL, | ||
| 13 | item_id TEXT NOT NULL, | ||
| 14 | server TEXT NOT NULL, | ||
| 15 | tool TEXT NOT NULL, | ||
| 16 | text TEXT NOT NULL, | ||
| 17 | updated_at TEXT NOT NULL, | ||
| 18 | PRIMARY KEY (thread_id, item_id) | ||
| 19 | ) | ||
| 20 | `; | ||
| 21 | }); | ||
apps/server/src/persistence/reconcileV2PreviewMigration.test.ts+2| ... | @@ -39,6 +39,7 @@ describe("V2 preview upgrade", () => { | ... | @@ -39,6 +39,7 @@ describe("V2 preview upgrade", () => { |
| 39 | [56, "RemoveRedundantProjectionIndexes"], | 39 | [56, "RemoveRedundantProjectionIndexes"], |
| 40 | [57, "ScheduledTaskWebhooks"], | 40 | [57, "ScheduledTaskWebhooks"], |
| 41 | [58, "WebhookRelayDeliveries"], | 41 | [58, "WebhookRelayDeliveries"], |
| 42 | [59, "McpAppModelContext"], | ||
| 42 | ]); | 43 | ]); |
| 43 | assert.deepStrictEqual(yield* runMigrations(), []); | 44 | assert.deepStrictEqual(yield* runMigrations(), []); |
| 44 | assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); | 45 | assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); |
| ... | @@ -120,6 +121,7 @@ describe("V2 preview upgrade", () => { | ... | @@ -120,6 +121,7 @@ describe("V2 preview upgrade", () => { |
| 120 | [56, "RemoveRedundantProjectionIndexes"], | 121 | [56, "RemoveRedundantProjectionIndexes"], |
| 121 | [57, "ScheduledTaskWebhooks"], | 122 | [57, "ScheduledTaskWebhooks"], |
| 122 | [58, "WebhookRelayDeliveries"], | 123 | [58, "WebhookRelayDeliveries"], |
| 124 | [59, "McpAppModelContext"], | ||
| 123 | ]); | 125 | ]); |
| 124 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), | 126 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), |
| 125 | ); | 127 | ); |
apps/server/src/ws.ts+6| ... | @@ -116,6 +116,7 @@ import * as EnvironmentTheme from "./environmentTheme.ts"; | ... | @@ -116,6 +116,7 @@ import * as EnvironmentTheme from "./environmentTheme.ts"; |
| 116 | import * as Keybindings from "./keybindings.ts"; | 116 | import * as Keybindings from "./keybindings.ts"; |
| 117 | import * as ExternalLauncher from "./process/externalLauncher.ts"; | 117 | import * as ExternalLauncher from "./process/externalLauncher.ts"; |
| 118 | import * as ThreadManagementService from "./orchestration-v2/ThreadManagementService.ts"; | 118 | import * as ThreadManagementService from "./orchestration-v2/ThreadManagementService.ts"; |
| 119 | import * as McpAppRequests from "./mcpApps/McpAppRequests.ts"; | ||
| 119 | import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; | 120 | import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; |
| 120 | import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; | 121 | import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; |
| 121 | import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts"; | 122 | import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts"; |
| ... | @@ -1203,6 +1204,7 @@ const layerWsRpc = ( | ... | @@ -1203,6 +1204,7 @@ const layerWsRpc = ( |
| 1203 | const threadSearch = yield* ThreadSearch.ThreadSearch; | 1204 | const threadSearch = yield* ThreadSearch.ThreadSearch; |
| 1204 | 1205 | ||
| 1205 | const providerSessionsV2 = yield* ProviderSessionManager.ProviderSessionManagerV2; | 1206 | const providerSessionsV2 = yield* ProviderSessionManager.ProviderSessionManagerV2; |
| 1207 | const mcpAppRequests = yield* McpAppRequests.McpAppRequests; | ||
| 1206 | const analytics = yield* AnalyticsService.AnalyticsService; | 1208 | const analytics = yield* AnalyticsService.AnalyticsService; |
| 1207 | // Client-origin attribution (#7774): every thread/turn the connecting | 1209 | // Client-origin attribution (#7774): every thread/turn the connecting |
| 1208 | // client starts is credited to its surface + app version. Best-effort: | 1210 | // client starts is credited to its surface + app version. Best-effort: |
| ... | @@ -2226,6 +2228,10 @@ const layerWsRpc = ( | ... | @@ -2226,6 +2228,10 @@ const layerWsRpc = ( |
| 2226 | } | 2228 | } |
| 2227 | return { providers }; | 2229 | return { providers }; |
| 2228 | }), | 2230 | }), |
| 2231 | [WS_METHODS.mcpAppsCallTool]: (input) => mcpAppRequests.callTool(input), | ||
| 2232 | [WS_METHODS.mcpAppsToolInfo]: (input) => mcpAppRequests.toolInfo(input), | ||
| 2233 | [WS_METHODS.mcpAppsUpdateModelContext]: (input) => mcpAppRequests.updateModelContext(input), | ||
| 2234 | [WS_METHODS.mcpAppsReadResource]: (input) => mcpAppRequests.readResource(input), | ||
| 2229 | [WS_METHODS.providerUploadFeedback]: (input) => | 2235 | [WS_METHODS.providerUploadFeedback]: (input) => |
| 2230 | Effect.gen(function* () { | 2236 | Effect.gen(function* () { |
| 2231 | const projection = yield* threadManagement.getThreadRecords(input.threadId, [ | 2237 | const projection = yield* threadManagement.getThreadRecords(input.threadId, [ |
apps/web/src/components/ChatView.tsx+34-1| ... | @@ -4606,6 +4606,30 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4606,6 +4606,30 @@ export default function ChatView(props: ChatViewProps) { |
| 4606 | }, | 4606 | }, |
| 4607 | [composerRef, scheduleComposerFocus], | 4607 | [composerRef, scheduleComposerFocus], |
| 4608 | ); | 4608 | ); |
| 4609 | // An MCP App's approved `ui/message`: queued like a typed message, so it | ||
| 4610 | // never steers or interrupts a running turn. | ||
| 4611 | const sendAppMessage = useCallback( | ||
| 4612 | async (text: string) => { | ||
| 4613 | if (!isServerThread || activeThreadId === null) { | ||
| 4614 | throw new Error("Messages from apps need a started thread."); | ||
| 4615 | } | ||
| 4616 | const result = await startThreadTurn({ | ||
| 4617 | environmentId, | ||
| 4618 | input: { | ||
| 4619 | threadId: activeThreadId, | ||
| 4620 | message: { messageId: newMessageId(), role: "user", text, attachments: [] }, | ||
| 4621 | runtimeMode, | ||
| 4622 | interactionMode, | ||
| 4623 | dispatchMode: "queue", | ||
| 4624 | }, | ||
| 4625 | }); | ||
| 4626 | if (result._tag === "Failure") { | ||
| 4627 | const error = squashAtomCommandFailure(result); | ||
| 4628 | throw error instanceof Error ? error : new Error("Could not send the app's message."); | ||
| 4629 | } | ||
| 4630 | }, | ||
| 4631 | [activeThreadId, environmentId, interactionMode, isServerThread, runtimeMode, startThreadTurn], | ||
| 4632 | ); | ||
| 4609 | const editQueuedRunCommand = useAtomCommand(threadEnvironment.editQueuedRun, { | 4633 | const editQueuedRunCommand = useAtomCommand(threadEnvironment.editQueuedRun, { |
| 4610 | reportFailure: false, | 4634 | reportFailure: false, |
| 4611 | }); | 4635 | }); |
| ... | @@ -6823,6 +6847,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -6823,6 +6847,8 @@ export default function ChatView(props: ChatViewProps) { |
| 6823 | frame = window.requestAnimationFrame(() => { | 6847 | frame = window.requestAnimationFrame(() => { |
| 6824 | frame = window.requestAnimationFrame(() => { | 6848 | frame = window.requestAnimationFrame(() => { |
| 6825 | frame = null; | 6849 | frame = null; |
| 6850 | // A full-screen app owns the page; refocusing the composer would close it. | ||
| 6851 | if (document.querySelector("[data-mcp-app-fullscreen]") !== null) return; | ||
| 6826 | if (shouldRefocusComposerOnWindowFocus(document.activeElement)) focusComposer(); | 6852 | if (shouldRefocusComposerOnWindowFocus(document.activeElement)) focusComposer(); |
| 6827 | }); | 6853 | }); |
| 6828 | }); | 6854 | }); |
| ... | @@ -11280,6 +11306,13 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -11280,6 +11306,13 @@ export default function ChatView(props: ChatViewProps) { |
| 11280 | !paintOnlyDisplayedTimeline && (isWorking || !latestRunSettled) | 11306 | !paintOnlyDisplayedTimeline && (isWorking || !latestRunSettled) |
| 11281 | } | 11307 | } |
| 11282 | isCompacting={!paintOnlyDisplayedTimeline && isCompacting} | 11308 | isCompacting={!paintOnlyDisplayedTimeline && isCompacting} |
| 11309 | awaitingUser={ | ||
| 11310 | !paintOnlyDisplayedTimeline && | ||
| 11311 | (activePendingApproval !== null || | ||
| 11312 | activePendingUserInput !== null || | ||
| 11313 | // A secret request has no runtime request; the shell carries it. | ||
| 11314 | activeThreadShell?.hasPendingUserInput === true) | ||
| 11315 | } | ||
| 11283 | activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt} | 11316 | activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt} |
| 11284 | worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup} | 11317 | worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup} |
| 11285 | onCancelWorktreeSetup={onCancelWorktreeSetup} | 11318 | onCancelWorktreeSetup={onCancelWorktreeSetup} |
| ... | @@ -11322,7 +11355,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -11322,7 +11355,7 @@ export default function ChatView(props: ChatViewProps) { |
| 11322 | paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurn | 11355 | paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurn |
| 11323 | } | 11356 | } |
| 11324 | {...(!paintOnlyDisplayedTimeline | 11357 | {...(!paintOnlyDisplayedTimeline |
| 11325 | ? { onUseArtifactTemplate: useArtifactTemplate } | 11358 | ? { onUseArtifactTemplate: useArtifactTemplate, onSendAppMessage: sendAppMessage } |
| 11326 | : {})} | 11359 | : {})} |
| 11327 | isRevertingCheckpoint={isRevertingCheckpoint} | 11360 | isRevertingCheckpoint={isRevertingCheckpoint} |
| 11328 | onImageExpand={onExpandTimelineImage} | 11361 | onImageExpand={onExpandTimelineImage} |
apps/web/src/components/chat/McpAppFrame.tsx created+550| ... | @@ -0,0 +1,550 @@ | ||
| 1 | import type { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; | ||
| 2 | import { | ||
| 3 | makeMcpAppHost, | ||
| 4 | McpAppHostRefusal, | ||
| 5 | mcpAppStyleVariables, | ||
| 6 | mcpResourceBytes, | ||
| 7 | type McpAppCallToolResult, | ||
| 8 | type McpAppDisplayMode, | ||
| 9 | type McpAppHost, | ||
| 10 | type McpAppHostContext, | ||
| 11 | } from "@t3tools/client-runtime/mcp-apps"; | ||
| 12 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | ||
| 13 | import { | ||
| 14 | clampMcpAppHeight, | ||
| 15 | MCP_APP_DEFAULT_HEIGHT, | ||
| 16 | MCP_APP_MAX_HEIGHT, | ||
| 17 | mcpAppAllowAttribute, | ||
| 18 | mcpAppFileName, | ||
| 19 | type McpAppReference, | ||
| 20 | } from "@t3tools/shared/mcpApp"; | ||
| 21 | import { Minimize2Icon } from "lucide-react"; | ||
| 22 | import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | ||
| 23 | import { flushSync } from "react-dom"; | ||
| 24 | |||
| 25 | import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls"; | ||
| 26 | import { APP_VERSION } from "~/branding"; | ||
| 27 | import { isConfirmDialogActive, requestConfirmDialog } from "~/confirmDialog"; | ||
| 28 | import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme"; | ||
| 29 | import { Button } from "~/components/ui/button"; | ||
| 30 | import { isElectron } from "~/env"; | ||
| 31 | import { cn } from "~/lib/utils"; | ||
| 32 | import { useTurnItemDetail } from "~/state/queries"; | ||
| 33 | import { mcpAppEnvironment } from "~/state/mcpApps"; | ||
| 34 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 35 | |||
| 36 | const commandFailure = (result: { | ||
| 37 | readonly cause: Parameters<typeof squashAtomCommandFailure>[0]["cause"]; | ||
| 38 | }) => { | ||
| 39 | const error = squashAtomCommandFailure(result); | ||
| 40 | return new McpAppHostRefusal( | ||
| 41 | error instanceof Error && error.message.trim() !== "" ? error.message : "Request failed.", | ||
| 42 | ); | ||
| 43 | }; | ||
| 44 | |||
| 45 | /** Full screen shows the box in the top layer, which needs the Popover API. */ | ||
| 46 | /** A cached asset URL with less life than this is minted afresh first. */ | ||
| 47 | const MIN_URL_LIFE_MS = 5 * 60_000; | ||
| 48 | const fullscreenSupported = | ||
| 49 | typeof HTMLElement !== "undefined" && "popover" in HTMLElement.prototype; | ||
| 50 | |||
| 51 | /** Largest file an app may hand the user through `ui/download-file`. */ | ||
| 52 | const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; | ||
| 53 | |||
| 54 | /** Saves a file through the browser's own download, which asks where when it is set to. */ | ||
| 55 | function saveBlob(blob: Blob, name: string) { | ||
| 56 | const url = URL.createObjectURL(blob); | ||
| 57 | const link = document.createElement("a"); | ||
| 58 | link.href = url; | ||
| 59 | link.download = name; | ||
| 60 | link.click(); | ||
| 61 | // Long after the browser has read it: some start the download a task or | ||
| 62 | // more after the click, and a revoked URL saves nothing. | ||
| 63 | setTimeout(() => URL.revokeObjectURL(url), 60_000); | ||
| 64 | } | ||
| 65 | |||
| 66 | /** | ||
| 67 | * An MCP App inline in the thread: the captured document in an opaque-origin | ||
| 68 | * frame, speaking the MCP Apps bridge. Its tool calls and resource reads reach | ||
| 69 | * its own MCP server through the environment; calls to tools the server does | ||
| 70 | * not mark read-only, chat messages, and downloads ask first. Full screen keeps | ||
| 71 | * the same frame (and the app's state) and only restyles its box. | ||
| 72 | */ | ||
| 73 | export function McpAppFrame(props: { | ||
| 74 | readonly environmentId: EnvironmentId; | ||
| 75 | /** The thread that produced the app, which its requests run against. */ | ||
| 76 | readonly threadId: ThreadId; | ||
| 77 | /** The thread on screen, whose next turn the app's model context informs. */ | ||
| 78 | readonly conversationThreadId: ThreadId; | ||
| 79 | readonly itemId: TurnItemId; | ||
| 80 | /** The item's revision, so its stored call is fetched once per version. */ | ||
| 81 | readonly revision: string; | ||
| 82 | readonly app: McpAppReference; | ||
| 83 | readonly onSendMessage: ((text: string) => Promise<void>) | undefined; | ||
| 84 | /** The agent waits on the user, in a panel a full-screen app would cover. */ | ||
| 85 | readonly awaitingUser?: boolean; | ||
| 86 | /** Told when the app enters or leaves full screen. */ | ||
| 87 | readonly onFullscreenChange?: (fullscreen: boolean) => void; | ||
| 88 | }) { | ||
| 89 | const { app } = props; | ||
| 90 | const theme = useHtmlRenderTheme(); | ||
| 91 | const frameRef = useRef<HTMLIFrameElement>(null); | ||
| 92 | const boxRef = useRef<HTMLDivElement>(null); | ||
| 93 | const [width, setWidth] = useState(0); | ||
| 94 | const [height, setHeight] = useState(MCP_APP_DEFAULT_HEIGHT); | ||
| 95 | const [navigatedAway, setNavigatedAway] = useState(false); | ||
| 96 | const [displayMode, setDisplayMode] = useState<McpAppDisplayMode>("inline"); | ||
| 97 | // The app asked to be closed; the row falls back to its plain tool call. | ||
| 98 | const [closed, setClosed] = useState(false); | ||
| 99 | // Counts the app documents this row has shown. Reopening a closed app loads | ||
| 100 | // a new one, which needs its own host and its own load tracking. | ||
| 101 | const [documentGeneration, setDocumentGeneration] = useState(0); | ||
| 102 | useLayoutEffect(() => { | ||
| 103 | const box = boxRef.current; | ||
| 104 | if (!box) return; | ||
| 105 | setWidth(box.clientWidth); | ||
| 106 | const observer = new ResizeObserver(([entry]) => { | ||
| 107 | if (entry) setWidth(Math.round(entry.contentRect.width)); | ||
| 108 | }); | ||
| 109 | observer.observe(box); | ||
| 110 | return () => observer.disconnect(); | ||
| 111 | // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Closing and reopening replaces the box. | ||
| 112 | }, [closed]); | ||
| 113 | |||
| 114 | const resource = useMemo( | ||
| 115 | () => ({ | ||
| 116 | _tag: "attachment" as const, | ||
| 117 | attachmentId: app.attachmentId, | ||
| 118 | fileName: mcpAppFileName(app), | ||
| 119 | mimeType: "text/html", | ||
| 120 | disposition: "inline" as const, | ||
| 121 | }), | ||
| 122 | [app], | ||
| 123 | ); | ||
| 124 | const asset = useAssetUrlState(props.environmentId, resource); | ||
| 125 | const refreshAsset = useAssetUrlRefresh(props.environmentId, resource); | ||
| 126 | // The frame keeps its first URL: a re-minted one would reload the app. A | ||
| 127 | // cached URL near expiry is minted afresh first, since a frame cannot | ||
| 128 | // report a failed load. At most one mint per document, so a skewed clock | ||
| 129 | // cannot mint on every update. | ||
| 130 | const [src, setSrc] = useState<string | null>(null); | ||
| 131 | const [mintFailed, setMintFailed] = useState(false); | ||
| 132 | const minting = useRef(false); | ||
| 133 | const cachedUrl = asset._tag === "Success" ? asset.url : null; | ||
| 134 | const cachedExpiresAt = asset._tag === "Success" ? asset.expiresAt : 0; | ||
| 135 | useEffect(() => { | ||
| 136 | if (src !== null || cachedUrl === null || minting.current) return; | ||
| 137 | if (cachedExpiresAt - Date.now() > MIN_URL_LIFE_MS) { | ||
| 138 | // oxlint-disable-next-line react/set-state-in-effect -- Adopts the cached URL once it is known to last. | ||
| 139 | setSrc(cachedUrl); | ||
| 140 | return; | ||
| 141 | } | ||
| 142 | minting.current = true; | ||
| 143 | void refreshAsset().then( | ||
| 144 | (url) => (url === null ? setMintFailed(true) : setSrc(url)), | ||
| 145 | () => setMintFailed(true), | ||
| 146 | ); | ||
| 147 | }, [src, cachedUrl, cachedExpiresAt, refreshAsset]); | ||
| 148 | |||
| 149 | // The wire timeline omits tool input and output; the app needs both. | ||
| 150 | const detail = useTurnItemDetail({ | ||
| 151 | environmentId: props.environmentId, | ||
| 152 | threadId: props.threadId, | ||
| 153 | itemId: props.itemId, | ||
| 154 | revision: props.revision, | ||
| 155 | }); | ||
| 156 | const storedItem = detail.data?.item; | ||
| 157 | const toolCall = useMemo(() => { | ||
| 158 | if (storedItem?.type !== "dynamic_tool") return undefined; | ||
| 159 | const output = storedItem.output as { readonly result?: unknown } | undefined; | ||
| 160 | const result = output?.result as McpAppCallToolResult | undefined; | ||
| 161 | return { arguments: storedItem.input, result }; | ||
| 162 | }, [storedItem]); | ||
| 163 | |||
| 164 | const callTool = useAtomCommand(mcpAppEnvironment.callTool, { reportFailure: false }); | ||
| 165 | const toolInfo = useAtomCommand(mcpAppEnvironment.toolInfo, { reportFailure: false }); | ||
| 166 | const readResource = useAtomCommand(mcpAppEnvironment.readResource, { reportFailure: false }); | ||
| 167 | const updateModelContext = useAtomCommand(mcpAppEnvironment.updateModelContext, { | ||
| 168 | reportFailure: false, | ||
| 169 | }); | ||
| 170 | // The tool's definition, which the app receives as `toolInfo` at initialize. | ||
| 171 | const [toolDefinition, setToolDefinition] = useState<unknown>(undefined); | ||
| 172 | |||
| 173 | // Read by the host on every message, so it always sees current values | ||
| 174 | // without being rebuilt (which would drop the app's session). | ||
| 175 | const live = { | ||
| 176 | theme, | ||
| 177 | width, | ||
| 178 | props, | ||
| 179 | callTool, | ||
| 180 | toolInfo, | ||
| 181 | readResource, | ||
| 182 | updateModelContext, | ||
| 183 | displayMode, | ||
| 184 | toolDefinition, | ||
| 185 | }; | ||
| 186 | const hostRef = useRef<McpAppHost | null>(null); | ||
| 187 | const latest = useRef(live); | ||
| 188 | useEffect(() => { | ||
| 189 | latest.current = live; | ||
| 190 | }); | ||
| 191 | |||
| 192 | useEffect(() => { | ||
| 193 | const box = boxRef.current; | ||
| 194 | if (box === null || !fullscreenSupported) return; | ||
| 195 | const shown = box.matches(":popover-open"); | ||
| 196 | if (displayMode === "fullscreen" && !shown) box.showPopover(); | ||
| 197 | if (displayMode !== "fullscreen" && shown) box.hidePopover(); | ||
| 198 | // Reported only on a change, so an inline row never touches another | ||
| 199 | // row's pin. | ||
| 200 | if (displayMode === "fullscreen") { | ||
| 201 | latest.current.props.onFullscreenChange?.(true); | ||
| 202 | return () => latest.current.props.onFullscreenChange?.(false); | ||
| 203 | } | ||
| 204 | }, [displayMode]); | ||
| 205 | // The approval or question the agent waits on renders in the page, so a | ||
| 206 | // full-screen app steps aside for it. | ||
| 207 | const [stepAsideFor, setStepAsideFor] = useState(props.awaitingUser); | ||
| 208 | if (stepAsideFor !== props.awaitingUser) { | ||
| 209 | setStepAsideFor(props.awaitingUser); | ||
| 210 | if (props.awaitingUser === true) setDisplayMode("inline"); | ||
| 211 | } | ||
| 212 | // Full screen sits above everything else in the page. Anything that takes | ||
| 213 | // focus outside it (an approval, the command palette, a dialog) returns the | ||
| 214 | // app inline so it can be seen; so does Escape pressed outside the app. | ||
| 215 | // Keys pressed inside the app stay with the app. | ||
| 216 | useEffect(() => { | ||
| 217 | if (displayMode !== "fullscreen") return; | ||
| 218 | const leave = () => setDisplayMode("inline"); | ||
| 219 | const onFocus = (event: FocusEvent) => { | ||
| 220 | if (event.target instanceof Node && !boxRef.current?.contains(event.target)) leave(); | ||
| 221 | }; | ||
| 222 | const onKey = (event: KeyboardEvent) => { | ||
| 223 | if (event.key === "Escape") leave(); | ||
| 224 | }; | ||
| 225 | const onResize = () => hostRef.current?.updateHostContext(); | ||
| 226 | document.addEventListener("focusin", onFocus); | ||
| 227 | window.addEventListener("keydown", onKey); | ||
| 228 | window.addEventListener("resize", onResize); | ||
| 229 | return () => { | ||
| 230 | document.removeEventListener("focusin", onFocus); | ||
| 231 | window.removeEventListener("keydown", onKey); | ||
| 232 | window.removeEventListener("resize", onResize); | ||
| 233 | }; | ||
| 234 | }, [displayMode]); | ||
| 235 | |||
| 236 | // The definition is read once per app, before the host is built, so the | ||
| 237 | // initialize response can carry it; a failure only leaves it out. | ||
| 238 | useEffect(() => { | ||
| 239 | let cancelled = false; | ||
| 240 | void latest.current | ||
| 241 | .toolInfo({ | ||
| 242 | environmentId: props.environmentId, | ||
| 243 | input: { threadId: props.threadId, itemId: props.itemId, name: app.tool }, | ||
| 244 | }) | ||
| 245 | .then((info) => { | ||
| 246 | if (!cancelled && info._tag === "Success" && info.value.tool !== undefined) { | ||
| 247 | setToolDefinition(info.value.tool); | ||
| 248 | } | ||
| 249 | }); | ||
| 250 | return () => { | ||
| 251 | cancelled = true; | ||
| 252 | }; | ||
| 253 | }, [props.environmentId, props.threadId, props.itemId, app.tool]); | ||
| 254 | // The bridge belongs to the captured document. A frame that navigates keeps | ||
| 255 | // its window, so a second load stops the app rather than letting a page T3 | ||
| 256 | // never served pose as it. This is not a confidentiality boundary: a frame | ||
| 257 | // can always navigate itself, so the app could carry anything it read out | ||
| 258 | // in a URL either way. | ||
| 259 | // Counted per document: a reopened app is a new document, not a navigation. | ||
| 260 | const loads = useRef({ generation: 0, count: 0 }); | ||
| 261 | const onFrameLoad = () => { | ||
| 262 | if (loads.current.generation !== documentGeneration) { | ||
| 263 | loads.current = { generation: documentGeneration, count: 0 }; | ||
| 264 | } | ||
| 265 | loads.current.count += 1; | ||
| 266 | if (loads.current.count > 1) { | ||
| 267 | hostRef.current?.dispose(); | ||
| 268 | setNavigatedAway(true); | ||
| 269 | } | ||
| 270 | }; | ||
| 271 | |||
| 272 | useEffect(() => { | ||
| 273 | if (src === null) return; | ||
| 274 | const hostContext = (): McpAppHostContext => { | ||
| 275 | const current = latest.current; | ||
| 276 | return { | ||
| 277 | theme: current.theme.appearance, | ||
| 278 | styles: { variables: mcpAppStyleVariables(current.theme.variables) }, | ||
| 279 | displayMode: current.displayMode, | ||
| 280 | availableDisplayModes: fullscreenSupported ? ["inline", "fullscreen"] : ["inline"], | ||
| 281 | containerDimensions: | ||
| 282 | current.displayMode === "fullscreen" | ||
| 283 | ? { | ||
| 284 | width: frameRef.current?.clientWidth || window.innerWidth, | ||
| 285 | height: frameRef.current?.clientHeight || window.innerHeight, | ||
| 286 | } | ||
| 287 | : { width: current.width, maxHeight: MCP_APP_MAX_HEIGHT }, | ||
| 288 | platform: isElectron ? "desktop" : "web", | ||
| 289 | locale: navigator.language, | ||
| 290 | timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, | ||
| 291 | userAgent: `t3-code/${APP_VERSION}`, | ||
| 292 | deviceCapabilities: { | ||
| 293 | touch: window.matchMedia("(pointer: coarse)").matches, | ||
| 294 | hover: window.matchMedia("(hover: hover)").matches, | ||
| 295 | }, | ||
| 296 | safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 }, | ||
| 297 | ...(current.toolDefinition === undefined | ||
| 298 | ? {} | ||
| 299 | : { toolInfo: { tool: current.toolDefinition } }), | ||
| 300 | }; | ||
| 301 | }; | ||
| 302 | // Approvals render in the page, beneath the top layer a full-screen app | ||
| 303 | // occupies, so the app returns inline before T3 asks. | ||
| 304 | const ask = async (message: string) => { | ||
| 305 | flushSync(() => setDisplayMode("inline")); | ||
| 306 | const approved = await requestConfirmDialog(message); | ||
| 307 | // No dialog host is mounted, so nobody was asked; say so rather than | ||
| 308 | // reporting that the user declined. | ||
| 309 | if (approved === undefined) { | ||
| 310 | throw new McpAppHostRefusal("T3 could not ask for approval here."); | ||
| 311 | } | ||
| 312 | return approved; | ||
| 313 | }; | ||
| 314 | const target = () => frameRef.current?.contentWindow ?? null; | ||
| 315 | const scope = () => { | ||
| 316 | const { environmentId, threadId, itemId } = latest.current.props; | ||
| 317 | return { environmentId, input: { threadId, itemId } }; | ||
| 318 | }; | ||
| 319 | const host = makeMcpAppHost({ | ||
| 320 | app, | ||
| 321 | hostVersion: APP_VERSION, | ||
| 322 | // The document's origin is opaque, so "*" is the only target that reaches it; | ||
| 323 | // the window reference itself is what scopes delivery to this frame. | ||
| 324 | post: (message) => target()?.postMessage(message, "*"), | ||
| 325 | hostContext, | ||
| 326 | callTool: async ({ name, arguments: args }) => { | ||
| 327 | const { environmentId, input } = scope(); | ||
| 328 | const info = await latest.current.toolInfo({ environmentId, input: { ...input, name } }); | ||
| 329 | if (info._tag !== "Success") throw commandFailure(info); | ||
| 330 | if (!info.value.callable) throw new McpAppHostRefusal("This app cannot call that tool."); | ||
| 331 | if (!info.value.readOnly) { | ||
| 332 | const approved = await ask( | ||
| 333 | `Allow ${app.server} to run ${info.value.title ?? name}?\n${JSON.stringify(args, null, 2)}`, | ||
| 334 | ); | ||
| 335 | if (approved !== true) throw new McpAppHostRefusal("Declined by the user."); | ||
| 336 | } | ||
| 337 | const result = await latest.current.callTool({ | ||
| 338 | environmentId, | ||
| 339 | input: { ...input, name, arguments: args }, | ||
| 340 | }); | ||
| 341 | if (result._tag !== "Success") throw commandFailure(result); | ||
| 342 | return result.value; | ||
| 343 | }, | ||
| 344 | readResource: async ({ uri }) => { | ||
| 345 | const { environmentId, input } = scope(); | ||
| 346 | const result = await latest.current.readResource({ | ||
| 347 | environmentId, | ||
| 348 | input: { ...input, uri }, | ||
| 349 | }); | ||
| 350 | if (result._tag !== "Success") throw commandFailure(result); | ||
| 351 | return result.value; | ||
| 352 | }, | ||
| 353 | openLink: async (url) => { | ||
| 354 | // Only right after the reader used this frame, as with HTML renders. | ||
| 355 | if (document.activeElement !== frameRef.current || !navigator.userActivation?.isActive) { | ||
| 356 | throw new McpAppHostRefusal("Links open only from a click in the app."); | ||
| 357 | } | ||
| 358 | window.open(url, "_blank", "noopener,noreferrer"); | ||
| 359 | }, | ||
| 360 | sendMessage: async (text) => { | ||
| 361 | const send = latest.current.props.onSendMessage; | ||
| 362 | if (send === undefined) throw new McpAppHostRefusal("Messages are not available here."); | ||
| 363 | const approved = await ask(`Send this message from ${app.server}?\n${text}`); | ||
| 364 | if (approved !== true) throw new McpAppHostRefusal("Declined by the user."); | ||
| 365 | await send(text); | ||
| 366 | }, | ||
| 367 | updateModelContext: async (context) => { | ||
| 368 | const { environmentId, input } = scope(); | ||
| 369 | const result = await latest.current.updateModelContext({ | ||
| 370 | environmentId, | ||
| 371 | input: { | ||
| 372 | ...input, | ||
| 373 | ...context, | ||
| 374 | conversationThreadId: latest.current.props.conversationThreadId, | ||
| 375 | }, | ||
| 376 | }); | ||
| 377 | if (result._tag !== "Success") throw commandFailure(result); | ||
| 378 | }, | ||
| 379 | requestDisplayMode: async (mode) => { | ||
| 380 | // Full screen would cover the approval or question the agent waits | ||
| 381 | // on, or a confirmation any app is waiting on. Another app already | ||
| 382 | // full screen keeps the page; two would stack. | ||
| 383 | const otherFullscreen = document.querySelector("[data-mcp-app-fullscreen]"); | ||
| 384 | if ( | ||
| 385 | mode === "fullscreen" && | ||
| 386 | (latest.current.props.awaitingUser === true || | ||
| 387 | isConfirmDialogActive() || | ||
| 388 | (otherFullscreen !== null && otherFullscreen !== boxRef.current)) | ||
| 389 | ) { | ||
| 390 | return latest.current.displayMode; | ||
| 391 | } | ||
| 392 | setDisplayMode(mode); | ||
| 393 | return mode; | ||
| 394 | }, | ||
| 395 | downloadFile: async (files) => { | ||
| 396 | const names = files.map((file) => file.name).join(", "); | ||
| 397 | const approved = await ask(`Save ${names} from ${app.server}?`); | ||
| 398 | if (approved !== true) throw new McpAppHostRefusal("Declined by the user."); | ||
| 399 | for (const file of files) { | ||
| 400 | // A linked file is read from the app's own server, like its other reads. | ||
| 401 | let bytes: Uint8Array | undefined; | ||
| 402 | let mimeType = file.mimeType ?? "application/octet-stream"; | ||
| 403 | if (file._tag === "embedded") { | ||
| 404 | bytes = file.bytes; | ||
| 405 | } else { | ||
| 406 | const { environmentId, input } = scope(); | ||
| 407 | const read = await latest.current.readResource({ | ||
| 408 | environmentId, | ||
| 409 | input: { ...input, uri: file.uri }, | ||
| 410 | }); | ||
| 411 | if (read._tag !== "Success") throw commandFailure(read); | ||
| 412 | const content = read.value.contents[0]; | ||
| 413 | bytes = mcpResourceBytes(content); | ||
| 414 | const declared = (content as { readonly mimeType?: unknown } | undefined)?.mimeType; | ||
| 415 | if (typeof declared === "string") mimeType = declared; | ||
| 416 | } | ||
| 417 | if (bytes === undefined) throw new McpAppHostRefusal(`${file.name} has no contents.`); | ||
| 418 | if (bytes.byteLength > MAX_DOWNLOAD_BYTES) { | ||
| 419 | throw new McpAppHostRefusal(`${file.name} is too large to save.`); | ||
| 420 | } | ||
| 421 | // A copy backed by a plain ArrayBuffer, which Blob requires. | ||
| 422 | saveBlob(new Blob([bytes.slice()], { type: mimeType }), file.name); | ||
| 423 | } | ||
| 424 | }, | ||
| 425 | onRequestTeardown: () => { | ||
| 426 | if (latest.current.displayMode === "fullscreen") { | ||
| 427 | setDisplayMode("inline"); | ||
| 428 | return; | ||
| 429 | } | ||
| 430 | // The app asked to go, so it gets its teardown before the frame does. | ||
| 431 | void host.teardown().then(() => setClosed(true)); | ||
| 432 | }, | ||
| 433 | onSizeChanged: (size) => { | ||
| 434 | // Full screen is a fixed box; what the app reports there would leave | ||
| 435 | // the inline row its full-screen height on the way back. | ||
| 436 | if (latest.current.displayMode === "fullscreen") return; | ||
| 437 | if (size.height !== undefined) setHeight(clampMcpAppHeight(size.height)); | ||
| 438 | }, | ||
| 439 | }); | ||
| 440 | hostRef.current = host; | ||
| 441 | const receive = (event: MessageEvent) => { | ||
| 442 | // Only messages from this frame's own window reach its host. | ||
| 443 | if (event.source !== null && event.source === target()) host.receive(event.data); | ||
| 444 | }; | ||
| 445 | window.addEventListener("message", receive); | ||
| 446 | return () => { | ||
| 447 | window.removeEventListener("message", receive); | ||
| 448 | // An unmount cannot wait: the request goes out before the frame does, | ||
| 449 | // which is all a list that recycles rows can offer the app. | ||
| 450 | void host.teardown(); | ||
| 451 | hostRef.current = null; | ||
| 452 | }; | ||
| 453 | // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A reopened document needs a new host. | ||
| 454 | }, [src, app, documentGeneration]); | ||
| 455 | |||
| 456 | // The host reads the context through `latest`; these only say when to resend. | ||
| 457 | useEffect(() => { | ||
| 458 | hostRef.current?.updateHostContext(); | ||
| 459 | // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Context changes trigger a resend. | ||
| 460 | }, [theme, width, displayMode, toolDefinition]); | ||
| 461 | |||
| 462 | // A new document gets a new host, which needs the call again. | ||
| 463 | useEffect(() => { | ||
| 464 | if (toolCall !== undefined) hostRef.current?.setToolCall(toolCall); | ||
| 465 | // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each new document needs the call. | ||
| 466 | }, [toolCall, src, documentGeneration]); | ||
| 467 | |||
| 468 | // An app that asked to close keeps the box an inline row had, so the | ||
| 469 | // timeline does not jump, and says how to bring it back. | ||
| 470 | if (closed) { | ||
| 471 | return ( | ||
| 472 | <div className="flex items-center justify-between gap-2 rounded-lg border border-border px-3 py-2 text-muted-foreground text-xs"> | ||
| 473 | <span>The {app.server} app was closed</span> | ||
| 474 | <Button | ||
| 475 | size="xs" | ||
| 476 | variant="ghost" | ||
| 477 | onClick={() => { | ||
| 478 | // The first URL's token may have expired: the new document | ||
| 479 | // takes a fresh one, minted again if it is near expiry. | ||
| 480 | minting.current = false; | ||
| 481 | setSrc(null); | ||
| 482 | setDocumentGeneration((value) => value + 1); | ||
| 483 | setClosed(false); | ||
| 484 | }} | ||
| 485 | > | ||
| 486 | Show app | ||
| 487 | </Button> | ||
| 488 | </div> | ||
| 489 | ); | ||
| 490 | } | ||
| 491 | |||
| 492 | const fullscreen = displayMode === "fullscreen"; | ||
| 493 | return ( | ||
| 494 | // The row keeps its inline height while the app is full screen, so the | ||
| 495 | // timeline does not jump; the box itself is what goes full screen. | ||
| 496 | <div style={{ height }}> | ||
| 497 | <div | ||
| 498 | ref={boxRef} | ||
| 499 | // Full screen is the same box shown in the browser's top layer: moving | ||
| 500 | // the frame to another parent would reload the app, and the timeline's | ||
| 501 | // rows contain fixed positioning, which the top layer escapes. | ||
| 502 | {...(fullscreenSupported ? { popover: "manual" as const } : {})} | ||
| 503 | data-mcp-app-fullscreen={fullscreen ? "" : undefined} | ||
| 504 | className={cn( | ||
| 505 | "relative size-full overflow-hidden", | ||
| 506 | app.prefersBorder === true && !fullscreen && "rounded-lg border border-border", | ||
| 507 | fullscreen | ||
| 508 | ? "fixed inset-0 m-0 flex h-dvh max-h-none w-dvw max-w-none flex-col border-0 bg-background p-0" | ||
| 509 | : // A closed popover is hidden; inline, the box is a plain block. | ||
| 510 | "block! static! m-0 border-0 bg-transparent p-0 text-inherit", | ||
| 511 | )} | ||
| 512 | > | ||
| 513 | {fullscreen ? ( | ||
| 514 | <div className="flex h-10 shrink-0 items-center justify-between border-border border-b px-3 text-sm"> | ||
| 515 | <span className="truncate">{app.server}</span> | ||
| 516 | <Button | ||
| 517 | aria-label="Exit full screen" | ||
| 518 | size="icon-sm" | ||
| 519 | variant="ghost" | ||
| 520 | onClick={() => setDisplayMode("inline")} | ||
| 521 | > | ||
| 522 | <Minimize2Icon className="size-4" /> | ||
| 523 | </Button> | ||
| 524 | </div> | ||
| 525 | ) : null} | ||
| 526 | {navigatedAway ? ( | ||
| 527 | <p className="flex size-full items-center justify-center text-muted-foreground text-xs"> | ||
| 528 | The {app.server} app left its page and was stopped | ||
| 529 | </p> | ||
| 530 | ) : src !== null ? ( | ||
| 531 | <iframe | ||
| 532 | ref={frameRef} | ||
| 533 | src={src} | ||
| 534 | title={`${app.server} app`} | ||
| 535 | // Never allow-same-origin: the opaque origin keeps the app out of the session. | ||
| 536 | sandbox="allow-scripts allow-forms" | ||
| 537 | allow={mcpAppAllowAttribute(app.permissions)} | ||
| 538 | onLoad={onFrameLoad} | ||
| 539 | className={cn("block w-full border-0", fullscreen ? "min-h-0 flex-1" : "h-full")} | ||
| 540 | style={{ colorScheme: theme.appearance }} | ||
| 541 | /> | ||
| 542 | ) : asset._tag === "Failure" || mintFailed ? ( | ||
| 543 | <p className="flex size-full items-center justify-center text-muted-foreground text-xs"> | ||
| 544 | Unable to load the {app.server} app | ||
| 545 | </p> | ||
| 546 | ) : null} | ||
| 547 | </div> | ||
| 548 | </div> | ||
| 549 | ); | ||
| 550 | } | ||
apps/web/src/components/chat/MessagesTimeline.logic.ts+37-1| ... | @@ -41,6 +41,8 @@ import { | ... | @@ -41,6 +41,8 @@ import { |
| 41 | type OrchestrationV2ProjectedTurnItem, | 41 | type OrchestrationV2ProjectedTurnItem, |
| 42 | type RunAttemptId, | 42 | type RunAttemptId, |
| 43 | RunId, | 43 | RunId, |
| 44 | type ThreadId, | ||
| 45 | type TurnItemId, | ||
| 44 | } from "@t3tools/contracts"; | 46 | } from "@t3tools/contracts"; |
| 45 | import type { ThreadRunSummary } from "@t3tools/client-runtime/state/shell"; | 47 | import type { ThreadRunSummary } from "@t3tools/client-runtime/state/shell"; |
| 46 | import { | 48 | import { |
| ... | @@ -50,6 +52,7 @@ import { | ... | @@ -50,6 +52,7 @@ import { |
| 50 | } from "@t3tools/shared/t3McpToolPresentation"; | 52 | } from "@t3tools/shared/t3McpToolPresentation"; |
| 51 | import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; | 53 | import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; |
| 52 | import { htmlRenderReferencesEqual, type HtmlRenderReference } from "@t3tools/shared/htmlRender"; | 54 | import { htmlRenderReferencesEqual, type HtmlRenderReference } from "@t3tools/shared/htmlRender"; |
| 55 | import { mcpAppReferencesEqual, type McpAppReference } from "@t3tools/shared/mcpApp"; | ||
| 53 | import { dynamicToolTitle } from "@t3tools/shared/toolActivity"; | 56 | import { dynamicToolTitle } from "@t3tools/shared/toolActivity"; |
| 54 | import { formatWorkspaceRelativePath } from "../../filePathDisplay"; | 57 | import { formatWorkspaceRelativePath } from "../../filePathDisplay"; |
| 55 | import { | 58 | import { |
| ... | @@ -66,7 +69,7 @@ function timelineEntryRunId(entry: TimelineEntry): RunId | null { | ... | @@ -66,7 +69,7 @@ function timelineEntryRunId(entry: TimelineEntry): RunId | null { |
| 66 | if (entry.kind === "proposed-plan") { | 69 | if (entry.kind === "proposed-plan") { |
| 67 | return entry.proposedPlan.runId; | 70 | return entry.proposedPlan.runId; |
| 68 | } | 71 | } |
| 69 | if (entry.kind === "html-render") return entry.runId; | 72 | if (entry.kind === "html-render" || entry.kind === "mcp-app") return entry.runId; |
| 70 | return entry.kind === "work" ? (entry.entry.runId ?? null) : null; | 73 | return entry.kind === "work" ? (entry.entry.runId ?? null) : null; |
| 71 | } | 74 | } |
| 72 | 75 | ||
| ... | @@ -596,6 +599,15 @@ type MessagesTimelineRowContent = | ... | @@ -596,6 +599,15 @@ type MessagesTimelineRowContent = |
| 596 | id: string; | 599 | id: string; |
| 597 | createdAt: string; | 600 | createdAt: string; |
| 598 | htmlRender: HtmlRenderReference; | 601 | htmlRender: HtmlRenderReference; |
| 602 | } | ||
| 603 | | { | ||
| 604 | kind: "mcp-app"; | ||
| 605 | id: string; | ||
| 606 | createdAt: string; | ||
| 607 | sourceThreadId: ThreadId; | ||
| 608 | itemId: TurnItemId; | ||
| 609 | revision: string; | ||
| 610 | mcpApp: McpAppReference; | ||
| 599 | }; | 611 | }; |
| 600 | 612 | ||
| 601 | export interface StableMessagesTimelineRowsState { | 613 | export interface StableMessagesTimelineRowsState { |
| ... | @@ -720,6 +732,7 @@ function deriveSupersededAttemptFolds( | ... | @@ -720,6 +732,7 @@ function deriveSupersededAttemptFolds( |
| 720 | (entry.kind === "message" && entry.message.role === "user") || | 732 | (entry.kind === "message" && entry.message.role === "user") || |
| 721 | // A published page stays visible, as it does when its turn folds. | 733 | // A published page stays visible, as it does when its turn folds. |
| 722 | entry.kind === "html-render" || | 734 | entry.kind === "html-render" || |
| 735 | entry.kind === "mcp-app" || | ||
| 723 | timelineEntryIsPersistentResourceCard(entry) || | 736 | timelineEntryIsPersistentResourceCard(entry) || |
| 724 | // A steer supersedes the attempt but leaves its children running. | 737 | // A steer supersedes the attempt but leaves its children running. |
| 725 | liveSubagentEntryIds.has(entry.id) || | 738 | liveSubagentEntryIds.has(entry.id) || |
| ... | @@ -1674,6 +1687,19 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -1674,6 +1687,19 @@ export function deriveMessagesTimelineRows(input: { |
| 1674 | continue; | 1687 | continue; |
| 1675 | } | 1688 | } |
| 1676 | 1689 | ||
| 1690 | if (timelineEntry.kind === "mcp-app") { | ||
| 1691 | nextRows.push({ | ||
| 1692 | kind: "mcp-app", | ||
| 1693 | id: timelineEntry.id, | ||
| 1694 | createdAt: timelineEntry.createdAt, | ||
| 1695 | sourceThreadId: timelineEntry.sourceThreadId, | ||
| 1696 | itemId: timelineEntry.itemId, | ||
| 1697 | revision: timelineEntry.revision, | ||
| 1698 | mcpApp: timelineEntry.mcpApp, | ||
| 1699 | }); | ||
| 1700 | continue; | ||
| 1701 | } | ||
| 1702 | |||
| 1677 | if (timelineEntry.kind === "event") { | 1703 | if (timelineEntry.kind === "event") { |
| 1678 | const previous = nextRows.at(-1); | 1704 | const previous = nextRows.at(-1); |
| 1679 | if ( | 1705 | if ( |
| ... | @@ -2090,6 +2116,16 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean | ... | @@ -2090,6 +2116,16 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 2090 | return a.createdAt === bh.createdAt && htmlRenderReferencesEqual(a.htmlRender, bh.htmlRender); | 2116 | return a.createdAt === bh.createdAt && htmlRenderReferencesEqual(a.htmlRender, bh.htmlRender); |
| 2091 | } | 2117 | } |
| 2092 | 2118 | ||
| 2119 | case "mcp-app": { | ||
| 2120 | // Same reason: an equal app keeps its live frame and its state. | ||
| 2121 | const bm = b as typeof a; | ||
| 2122 | return ( | ||
| 2123 | a.createdAt === bm.createdAt && | ||
| 2124 | a.revision === bm.revision && | ||
| 2125 | mcpAppReferencesEqual(a.mcpApp, bm.mcpApp) | ||
| 2126 | ); | ||
| 2127 | } | ||
| 2128 | |||
| 2093 | case "event": | 2129 | case "event": |
| 2094 | return ( | 2130 | return ( |
| 2095 | a.projectedItem === (b as typeof a).projectedItem && | 2131 | a.projectedItem === (b as typeof a).projectedItem && |
apps/web/src/components/chat/MessagesTimeline.tsx+69-2| ... | @@ -169,6 +169,7 @@ import { | ... | @@ -169,6 +169,7 @@ import { |
| 169 | } from "./SnapShotAttachmentDetails"; | 169 | } from "./SnapShotAttachmentDetails"; |
| 170 | import { ProposedPlanCard } from "./ProposedPlanCard"; | 170 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 171 | import { HtmlRenderFrame } from "./HtmlRenderFrame"; | 171 | import { HtmlRenderFrame } from "./HtmlRenderFrame"; |
| 172 | import { McpAppFrame } from "./McpAppFrame"; | ||
| 172 | import { ChangedFilesCard } from "./ChangedFilesTree"; | 173 | import { ChangedFilesCard } from "./ChangedFilesTree"; |
| 173 | import { useFileContextMenuHandler } from "../../fileContextMenu"; | 174 | import { useFileContextMenuHandler } from "../../fileContextMenu"; |
| 174 | import { useProject, useThreadShell } from "../../state/entities"; | 175 | import { useProject, useThreadShell } from "../../state/entities"; |
| ... | @@ -312,6 +313,14 @@ interface TimelineRowSharedState { | ... | @@ -312,6 +313,14 @@ interface TimelineRowSharedState { |
| 312 | activeThreadEnvironmentId: EnvironmentId; | 313 | activeThreadEnvironmentId: EnvironmentId; |
| 313 | onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; | 314 | onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; |
| 314 | onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; | 315 | onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; |
| 316 | /** Sends text an MCP App asked to post, after the user approved it. */ | ||
| 317 | onSendAppMessage: ((text: string) => Promise<void>) | undefined; | ||
| 318 | /** | ||
| 319 | * An MCP App row entering or leaving full screen. The row stays rendered | ||
| 320 | * and the list stops following new output meanwhile, so the app is not | ||
| 321 | * virtualized away while the reader is using it. | ||
| 322 | */ | ||
| 323 | onAppFullscreenChange: (rowId: string, fullscreen: boolean) => void; | ||
| 315 | onRunShellCommand: ((command: string) => void) | undefined; | 324 | onRunShellCommand: ((command: string) => void) | undefined; |
| 316 | onImageExpand: (preview: ExpandedImagePreview) => void; | 325 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 317 | displayThreadKey?: string; | 326 | displayThreadKey?: string; |
| ... | @@ -345,6 +354,8 @@ interface TimelineRowActivityState { | ... | @@ -345,6 +354,8 @@ interface TimelineRowActivityState { |
| 345 | isCompacting: boolean; | 354 | isCompacting: boolean; |
| 346 | isRevertingCheckpoint: boolean; | 355 | isRevertingCheckpoint: boolean; |
| 347 | activeTurnInProgress: boolean; | 356 | activeTurnInProgress: boolean; |
| 357 | /** The agent waits on an approval or an answer from the user. */ | ||
| 358 | awaitingUser: boolean; | ||
| 348 | isPreparingWorktree: boolean; | 359 | isPreparingWorktree: boolean; |
| 349 | latestRunId: RunId | null; | 360 | latestRunId: RunId | null; |
| 350 | /** | 361 | /** |
| ... | @@ -441,6 +452,8 @@ interface MessagesTimelineProps { | ... | @@ -441,6 +452,8 @@ interface MessagesTimelineProps { |
| 441 | onOpenWorktreeSetupTerminal?: (terminalId: string) => void; | 452 | onOpenWorktreeSetupTerminal?: (terminalId: string) => void; |
| 442 | isPreparingWorktree?: boolean; | 453 | isPreparingWorktree?: boolean; |
| 443 | isCompacting?: boolean; | 454 | isCompacting?: boolean; |
| 455 | /** The agent waits on an approval or an answer from the user. */ | ||
| 456 | awaitingUser?: boolean; | ||
| 444 | /** Thread state shown after the last message, such as a settled or snoozed line. */ | 457 | /** Thread state shown after the last message, such as a settled or snoozed line. */ |
| 445 | footer?: ReactNode; | 458 | footer?: ReactNode; |
| 446 | 459 | ||
| ... | @@ -468,6 +481,7 @@ interface MessagesTimelineProps { | ... | @@ -468,6 +481,7 @@ interface MessagesTimelineProps { |
| 468 | supportsConversationRollback: boolean; | 481 | supportsConversationRollback: boolean; |
| 469 | onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; | 482 | onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; |
| 470 | onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; | 483 | onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; |
| 484 | onSendAppMessage?: (text: string) => Promise<void>; | ||
| 471 | onRunShellCommand?: (command: string) => void; | 485 | onRunShellCommand?: (command: string) => void; |
| 472 | isRevertingCheckpoint: boolean; | 486 | isRevertingCheckpoint: boolean; |
| 473 | onImageExpand: (preview: ExpandedImagePreview) => void; | 487 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| ... | @@ -529,6 +543,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -529,6 +543,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 529 | onOpenWorktreeSetupTerminal, | 543 | onOpenWorktreeSetupTerminal, |
| 530 | isPreparingWorktree = false, | 544 | isPreparingWorktree = false, |
| 531 | isCompacting = false, | 545 | isCompacting = false, |
| 546 | awaitingUser = false, | ||
| 532 | listRef, | 547 | listRef, |
| 533 | timelineEntries, | 548 | timelineEntries, |
| 534 | latestRun, | 549 | latestRun, |
| ... | @@ -544,6 +559,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -544,6 +559,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 544 | supportsConversationRollback, | 559 | supportsConversationRollback, |
| 545 | onRevertToTurnCount, | 560 | onRevertToTurnCount, |
| 546 | onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE, | 561 | onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE, |
| 562 | onSendAppMessage, | ||
| 547 | onRunShellCommand, | 563 | onRunShellCommand, |
| 548 | isRevertingCheckpoint, | 564 | isRevertingCheckpoint, |
| 549 | onImageExpand, | 565 | onImageExpand, |
| ... | @@ -959,7 +975,26 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -959,7 +975,26 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 959 | onManualNavigation, | 975 | onManualNavigation, |
| 960 | }); | 976 | }); |
| 961 | const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); | 977 | const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); |
| 962 | const alwaysRender = citationAlwaysRender ?? restoringAlwaysRender; | 978 | const [fullscreenAppRowId, setFullscreenAppRowId] = useState<string | null>(null); |
| 979 | // Only the row that holds the pin can release it. | ||
| 980 | const onAppFullscreenChange = useCallback((rowId: string, fullscreen: boolean) => { | ||
| 981 | setFullscreenAppRowId((current) => (fullscreen ? rowId : current === rowId ? null : current)); | ||
| 982 | }, []); | ||
| 983 | // Every pin holds at once, so navigating to a citation or restoring a | ||
| 984 | // position never drops a full-screen app's row. The app is pinned by key, | ||
| 985 | // which stays right as earlier rows load in. | ||
| 986 | const alwaysRender = useMemo(() => { | ||
| 987 | const indices = restoringAlwaysRender?.indices ?? []; | ||
| 988 | const keys = [ | ||
| 989 | ...(citationAlwaysRender?.keys ?? []), | ||
| 990 | ...(fullscreenAppRowId === null ? [] : [fullscreenAppRowId]), | ||
| 991 | ]; | ||
| 992 | if (indices.length === 0 && keys.length === 0) return undefined; | ||
| 993 | return { | ||
| 994 | ...(indices.length === 0 ? {} : { indices }), | ||
| 995 | ...(keys.length === 0 ? {} : { keys }), | ||
| 996 | }; | ||
| 997 | }, [citationAlwaysRender, restoringAlwaysRender, fullscreenAppRowId]); | ||
| 963 | const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); | 998 | const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); |
| 964 | const [minimapCurrentIndex, setMinimapCurrentIndex] = useState<number | null>(null); | 999 | const [minimapCurrentIndex, setMinimapCurrentIndex] = useState<number | null>(null); |
| 965 | const handleAnchorReady = useCallback( | 1000 | const handleAnchorReady = useCallback( |
| ... | @@ -1183,6 +1218,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -1183,6 +1218,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 1183 | onImageExpand, | 1218 | onImageExpand, |
| 1184 | onFileOpen, | 1219 | onFileOpen, |
| 1185 | onUseArtifactTemplate, | 1220 | onUseArtifactTemplate, |
| 1221 | onSendAppMessage, | ||
| 1222 | onAppFullscreenChange, | ||
| 1186 | onFileDownload, | 1223 | onFileDownload, |
| 1187 | openPullRequest, | 1224 | openPullRequest, |
| 1188 | onOpenTurnDiff, | 1225 | onOpenTurnDiff, |
| ... | @@ -1218,6 +1255,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -1218,6 +1255,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 1218 | onImageExpand, | 1255 | onImageExpand, |
| 1219 | onFileOpen, | 1256 | onFileOpen, |
| 1220 | onUseArtifactTemplate, | 1257 | onUseArtifactTemplate, |
| 1258 | onSendAppMessage, | ||
| 1259 | onAppFullscreenChange, | ||
| 1221 | onFileDownload, | 1260 | onFileDownload, |
| 1222 | openPullRequest, | 1261 | openPullRequest, |
| 1223 | onOpenTurnDiff, | 1262 | onOpenTurnDiff, |
| ... | @@ -1252,10 +1291,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -1252,10 +1291,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 1252 | isRevertingCheckpoint, | 1291 | isRevertingCheckpoint, |
| 1253 | backgroundWorktreeSetup, | 1292 | backgroundWorktreeSetup, |
| 1254 | activeTurnInProgress, | 1293 | activeTurnInProgress, |
| 1294 | awaitingUser, | ||
| 1255 | isPreparingWorktree, | 1295 | isPreparingWorktree, |
| 1256 | latestRunId: latestRun?.runId ?? null, | 1296 | latestRunId: latestRun?.runId ?? null, |
| 1257 | }), | 1297 | }), |
| 1258 | [ | 1298 | [ |
| 1299 | awaitingUser, | ||
| 1259 | compactionAwaitingRow, | 1300 | compactionAwaitingRow, |
| 1260 | backgroundWorktreeSetup, | 1301 | backgroundWorktreeSetup, |
| 1261 | activeTurnInProgress, | 1302 | activeTurnInProgress, |
| ... | @@ -1375,6 +1416,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -1375,6 +1416,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 1375 | (restoringThreadPosition && rememberedPosition?.atEnd === false) || | 1416 | (restoringThreadPosition && rememberedPosition?.atEnd === false) || |
| 1376 | anchoredEndSpace || | 1417 | anchoredEndSpace || |
| 1377 | !liveFollowEnabled || | 1418 | !liveFollowEnabled || |
| 1419 | fullscreenAppRowId !== null || | ||
| 1378 | disclosureToggleSettling | 1420 | disclosureToggleSettling |
| 1379 | ? false | 1421 | ? false |
| 1380 | : isWorking && !prefersReducedMotion && settlingListIdentity === null | 1422 | : isWorking && !prefersReducedMotion && settlingListIdentity === null |
| ... | @@ -1798,7 +1840,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time | ... | @@ -1798,7 +1840,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1798 | row.kind === "worktree-setup" || | 1840 | row.kind === "worktree-setup" || |
| 1799 | row.kind === "event" || | 1841 | row.kind === "event" || |
| 1800 | row.kind === "attempt-fold" || | 1842 | row.kind === "attempt-fold" || |
| 1801 | row.kind === "html-render" | 1843 | row.kind === "html-render" || |
| 1844 | row.kind === "mcp-app" | ||
| 1802 | ? "pb-2" | 1845 | ? "pb-2" |
| 1803 | : "pb-4", | 1846 | : "pb-4", |
| 1804 | (row.kind === "message" && row.message.role === "assistant") || | 1847 | (row.kind === "message" && row.message.role === "assistant") || |
| ... | @@ -1847,6 +1890,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time | ... | @@ -1847,6 +1890,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time |
| 1847 | {row.kind === "assistant-meta" ? <AssistantMetaTimelineRow row={row} /> : null} | 1890 | {row.kind === "assistant-meta" ? <AssistantMetaTimelineRow row={row} /> : null} |
| 1848 | {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} | 1891 | {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null} |
| 1849 | {row.kind === "html-render" ? <HtmlRenderTimelineRow row={row} /> : null} | 1892 | {row.kind === "html-render" ? <HtmlRenderTimelineRow row={row} /> : null} |
| 1893 | {row.kind === "mcp-app" ? <McpAppTimelineRow row={row} /> : null} | ||
| 1850 | {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null} | 1894 | {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null} |
| 1851 | {row.kind === "worktree-setup" ? <WorktreeSetupTimelineRow row={row} /> : null} | 1895 | {row.kind === "worktree-setup" ? <WorktreeSetupTimelineRow row={row} /> : null} |
| 1852 | {row.kind === "event" ? <V2EventTimelineRow row={row} /> : null} | 1896 | {row.kind === "event" ? <V2EventTimelineRow row={row} /> : null} |
| ... | @@ -2736,6 +2780,29 @@ function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "htm | ... | @@ -2736,6 +2780,29 @@ function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "htm |
| 2736 | ); | 2780 | ); |
| 2737 | } | 2781 | } |
| 2738 | 2782 | ||
| 2783 | function McpAppTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mcp-app" }> }) { | ||
| 2784 | const ctx = use(TimelineRowCtx); | ||
| 2785 | const { awaitingUser } = use(TimelineRowActivityCtx); | ||
| 2786 | |||
| 2787 | return ( | ||
| 2788 | <div className="min-w-0 px-1"> | ||
| 2789 | <McpAppFrame | ||
| 2790 | // A recycled row must not keep another app's live document. | ||
| 2791 | key={row.mcpApp.attachmentId} | ||
| 2792 | environmentId={ctx.activeThreadEnvironmentId} | ||
| 2793 | threadId={row.sourceThreadId} | ||
| 2794 | conversationThreadId={ctx.threadRef?.threadId ?? row.sourceThreadId} | ||
| 2795 | itemId={row.itemId} | ||
| 2796 | revision={row.revision} | ||
| 2797 | app={row.mcpApp} | ||
| 2798 | onSendMessage={ctx.onSendAppMessage} | ||
| 2799 | awaitingUser={awaitingUser} | ||
| 2800 | onFullscreenChange={(fullscreen) => ctx.onAppFullscreenChange(row.id, fullscreen)} | ||
| 2801 | /> | ||
| 2802 | </div> | ||
| 2803 | ); | ||
| 2804 | } | ||
| 2805 | |||
| 2739 | type V2EventTone = "muted" | "warning" | "danger" | "success"; | 2806 | type V2EventTone = "muted" | "warning" | "danger" | "success"; |
| 2740 | 2807 | ||
| 2741 | function v2EventPresentation(item: OrchestrationV2TurnItem): { | 2808 | function v2EventPresentation(item: OrchestrationV2TurnItem): { |
apps/web/src/confirmDialog.test.ts+4| ... | @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; | ... | @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | completeConfirmDialogClose, | 4 | completeConfirmDialogClose, |
| 5 | isConfirmDialogActive, | ||
| 5 | readConfirmDialogState, | 6 | readConfirmDialogState, |
| 6 | registerConfirmDialogHost, | 7 | registerConfirmDialogHost, |
| 7 | requestConfirmDialog, | 8 | requestConfirmDialog, |
| ... | @@ -40,6 +41,8 @@ describe("confirm dialog coordinator", () => { | ... | @@ -40,6 +41,8 @@ describe("confirm dialog coordinator", () => { |
| 40 | 41 | ||
| 41 | respondToConfirmDialog(true); | 42 | respondToConfirmDialog(true); |
| 42 | await expect(confirmation).resolves.toBe(true); | 43 | await expect(confirmation).resolves.toBe(true); |
| 44 | // Still on screen while it closes, so nothing may cover it yet. | ||
| 45 | expect(isConfirmDialogActive()).toBe(true); | ||
| 43 | expect(readConfirmDialogState()).toEqual({ | 46 | expect(readConfirmDialogState()).toEqual({ |
| 44 | status: "closing", | 47 | status: "closing", |
| 45 | message: "Delete this thread?", | 48 | message: "Delete this thread?", |
| ... | @@ -48,6 +51,7 @@ describe("confirm dialog coordinator", () => { | ... | @@ -48,6 +51,7 @@ describe("confirm dialog coordinator", () => { |
| 48 | 51 | ||
| 49 | completeConfirmDialogClose(); | 52 | completeConfirmDialogClose(); |
| 50 | expect(readConfirmDialogState()).toEqual({ status: "idle" }); | 53 | expect(readConfirmDialogState()).toEqual({ status: "idle" }); |
| 54 | expect(isConfirmDialogActive()).toBe(false); | ||
| 51 | unregister(); | 55 | unregister(); |
| 52 | }); | 56 | }); |
| 53 | 57 |
apps/web/src/confirmDialog.ts+5| ... | @@ -101,6 +101,11 @@ export function requestConfirmDialog( | ... | @@ -101,6 +101,11 @@ export function requestConfirmDialog( |
| 101 | return confirmation; | 101 | return confirmation; |
| 102 | } | 102 | } |
| 103 | 103 | ||
| 104 | /** True while a confirmation is shown, closing, or waiting to be shown. */ | ||
| 105 | export function isConfirmDialogActive(): boolean { | ||
| 106 | return state.status !== "idle" || activeConfirmation !== null || queuedConfirmations.length > 0; | ||
| 107 | } | ||
| 108 | |||
| 104 | export function respondToConfirmDialog(confirmed: boolean): void { | 109 | export function respondToConfirmDialog(confirmed: boolean): void { |
| 105 | if (state.status !== "confirming" || !activeConfirmation) return; | 110 | if (state.status !== "confirming" || !activeConfirmation) return; |
| 106 | 111 |
apps/web/src/session-logic.test.ts+19| ... | @@ -1319,6 +1319,25 @@ describe("HTML renders in the timeline", () => { | ... | @@ -1319,6 +1319,25 @@ describe("HTML renders in the timeline", () => { |
| 1319 | ]); | 1319 | ]); |
| 1320 | }); | 1320 | }); |
| 1321 | 1321 | ||
| 1322 | it("hosts a captured MCP app in place, from any provider's tool", () => { | ||
| 1323 | const mcpApp = { | ||
| 1324 | attachmentId: "render-thread-app-html", | ||
| 1325 | server: "weather", | ||
| 1326 | tool: "get_weather", | ||
| 1327 | resourceUri: "ui://weather/dashboard", | ||
| 1328 | }; | ||
| 1329 | const call = renderCall("completed", { t3McpApp: mcpApp, result: { content: [] } }); | ||
| 1330 | const entries = turn( | ||
| 1331 | call.type === "dynamic_tool" ? { ...call, toolName: "weather.get_weather" } : call, | ||
| 1332 | ); | ||
| 1333 | expect(entries.find((entry) => entry.kind === "mcp-app")).toMatchObject({ | ||
| 1334 | id: "render", | ||
| 1335 | itemId: "render", | ||
| 1336 | mcpApp, | ||
| 1337 | }); | ||
| 1338 | expect(rowsFor(entries, false)).toEqual(["message", "turn-fold", "mcp-app", "message"]); | ||
| 1339 | }); | ||
| 1340 | |||
| 1322 | it("keeps a render visible when its superseded attempt folds", () => { | 1341 | it("keeps a render visible when its superseded attempt folds", () => { |
| 1323 | const attempt: OrchestrationV2RunAttempt = { | 1342 | const attempt: OrchestrationV2RunAttempt = { |
| 1324 | id: RunAttemptId.make("attempt-superseded"), | 1343 | id: RunAttemptId.make("attempt-superseded"), |
apps/web/src/session-logic.ts+36-1| ... | @@ -12,6 +12,8 @@ import { | ... | @@ -12,6 +12,8 @@ import { |
| 12 | type ToolActivitySurface, | 12 | type ToolActivitySurface, |
| 13 | type ToolActivityIcon, | 13 | type ToolActivityIcon, |
| 14 | type ToolActivitySource, | 14 | type ToolActivitySource, |
| 15 | type ThreadId, | ||
| 16 | type TurnItemId, | ||
| 15 | } from "@t3tools/contracts"; | 17 | } from "@t3tools/contracts"; |
| 16 | import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; | 18 | import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; |
| 17 | import { | 19 | import { |
| ... | @@ -21,7 +23,9 @@ import { | ... | @@ -21,7 +23,9 @@ import { |
| 21 | formatSearchToolLabel, | 23 | formatSearchToolLabel, |
| 22 | } from "@t3tools/shared/toolActivity"; | 24 | } from "@t3tools/shared/toolActivity"; |
| 23 | import type { HtmlRenderReference } from "@t3tools/shared/htmlRender"; | 25 | import type { HtmlRenderReference } from "@t3tools/shared/htmlRender"; |
| 24 | import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; | 26 | import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; |
| 27 | import type { McpAppReference } from "@t3tools/shared/mcpApp"; | ||
| 28 | import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; | ||
| 25 | import { | 29 | import { |
| 26 | contextCompactionLabel, | 30 | contextCompactionLabel, |
| 27 | workEntryIndicatesToolFailure, | 31 | workEntryIndicatesToolFailure, |
| ... | @@ -140,6 +144,18 @@ export type TimelineEntry = ( | ... | @@ -140,6 +144,18 @@ export type TimelineEntry = ( |
| 140 | readonly runId: RunId | null; | 144 | readonly runId: RunId | null; |
| 141 | readonly htmlRender: HtmlRenderReference; | 145 | readonly htmlRender: HtmlRenderReference; |
| 142 | } | 146 | } |
| 147 | | { | ||
| 148 | /** An MCP App a completed tool call captured, hosted where the call happened. */ | ||
| 149 | readonly id: string; | ||
| 150 | readonly kind: "mcp-app"; | ||
| 151 | readonly createdAt: string; | ||
| 152 | readonly runId: RunId | null; | ||
| 153 | /** The thread and item that own the app; a fork's inherited app is its source's. */ | ||
| 154 | readonly sourceThreadId: ThreadId; | ||
| 155 | readonly itemId: TurnItemId; | ||
| 156 | readonly revision: string; | ||
| 157 | readonly mcpApp: McpAppReference; | ||
| 158 | } | ||
| 143 | | { | 159 | | { |
| 144 | readonly id: string; | 160 | readonly id: string; |
| 145 | readonly kind: "work"; | 161 | readonly kind: "work"; |
| ... | @@ -714,6 +730,25 @@ export function deriveTimelineEntriesFromVisibleTurnItems( | ... | @@ -714,6 +730,25 @@ export function deriveTimelineEntriesFromVisibleTurnItems( |
| 714 | continue; | 730 | continue; |
| 715 | } | 731 | } |
| 716 | 732 | ||
| 733 | const mcpApp = | ||
| 734 | item.type === "dynamic_tool" && item.status === "completed" | ||
| 735 | ? mcpAppFromToolItem(item) | ||
| 736 | : undefined; | ||
| 737 | if (mcpApp !== undefined) { | ||
| 738 | entries.push({ | ||
| 739 | id: item.id, | ||
| 740 | kind: "mcp-app", | ||
| 741 | createdAt, | ||
| 742 | runId: item.runId, | ||
| 743 | sourceThreadId: row.sourceThreadId, | ||
| 744 | itemId: row.sourceItemId, | ||
| 745 | revision: turnItemDetailRevision(item), | ||
| 746 | mcpApp, | ||
| 747 | ...attemptMetadata, | ||
| 748 | }); | ||
| 749 | continue; | ||
| 750 | } | ||
| 751 | |||
| 717 | if (STANDALONE_V2_ITEM_TYPES.has(item.type)) { | 752 | if (STANDALONE_V2_ITEM_TYPES.has(item.type)) { |
| 718 | entries.push({ | 753 | entries.push({ |
| 719 | id: item.id, | 754 | id: item.id, |
apps/web/src/state/mcpApps.ts created+5| ... | @@ -0,0 +1,5 @@ | ||
| 1 | import { createMcpAppEnvironmentAtoms } from "@t3tools/client-runtime/state/mcp-apps"; | ||
| 2 | |||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | ||
| 4 | |||
| 5 | export const mcpAppEnvironment = createMcpAppEnvironmentAtoms(connectionAtomRuntime); | ||
docs/user/html-renders.md+8| ... | @@ -7,3 +7,11 @@ Pages use your current theme, including custom themes, and follow light and dark | ... | @@ -7,3 +7,11 @@ Pages use your current theme, including custom themes, and follow light and dark |
| 7 | Agents can place local images in a page by file path. T3 Code embeds them when the page is published, so the page keeps working after the original files move or are deleted. Deleting the thread deletes its pages. | 7 | Agents can place local images in a page by file path. T3 Code embeds them when the page is published, so the page keeps working after the original files move or are deleted. Deleting the thread deletes its pages. |
| 8 | 8 | ||
| 9 | Before publishing, agents check their work with screenshots from a small headless browser that T3 Code keeps for itself; it never uses a browser you installed. The first preview on a machine downloads it once (about 120 MB) into T3 Code's data folder, so that preview can take a minute. Browser tabs on a remote environment use the same browser, so whichever comes first downloads it. Some Linux hosts need [setup](remote-access.md#browser-host-setup) before it can start. Pages publish without it. | 9 | Before publishing, agents check their work with screenshots from a small headless browser that T3 Code keeps for itself; it never uses a browser you installed. The first preview on a machine downloads it once (about 120 MB) into T3 Code's data folder, so that preview can take a minute. Browser tabs on a remote environment use the same browser, so whichever comes first downloads it. Some Linux hosts need [setup](remote-access.md#browser-host-setup) before it can start. Pages publish without it. |
| 10 | |||
| 11 | ## MCP apps | ||
| 12 | |||
| 13 | Some MCP servers return an interactive app with their tool results, following the [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) standard. When an agent calls one of those tools, the app appears in the thread in place of the tool call, on web, desktop, and mobile. Codex supports this today, for any MCP server you have configured for it; other providers show these calls as ordinary tool calls. | ||
| 14 | |||
| 15 | Apps follow your theme. An app can call tools on its own server and post a message to the thread; T3 Code asks first unless the server marks the tool as read-only, and it asks before every message. On mobile, an app also asks before opening a link, and T3 Code asks before saving a file an app offers. An app stays viewable after its agent stops, but using it needs the agent of the thread that created it running, so send a message in that thread first if the app says it is unavailable. An app that navigates away from its own page is stopped. | ||
| 16 | |||
| 17 | An app can open full screen; use the button in its top corner, or press Escape outside the app, to return it to the thread. Anything else that needs your attention, such as an approval, also returns it to the thread. On mobile, full screen opens the app again in its own screen, so anything the app does not save itself starts over. An app can also keep the agent informed of what you did in it, such as a filter you picked; T3 Code sends the latest note from each app with your next message. |
packages/client-runtime/package.json+8| ... | @@ -390,6 +390,14 @@ | ... | @@ -390,6 +390,14 @@ |
| 390 | "./device/duo-control": { | 390 | "./device/duo-control": { |
| 391 | "types": "./src/device/duoControl.ts", | 391 | "types": "./src/device/duoControl.ts", |
| 392 | "default": "./src/device/duoControl.ts" | 392 | "default": "./src/device/duoControl.ts" |
| 393 | }, | ||
| 394 | "./mcp-apps": { | ||
| 395 | "types": "./src/mcpApps/host.ts", | ||
| 396 | "default": "./src/mcpApps/host.ts" | ||
| 397 | }, | ||
| 398 | "./state/mcp-apps": { | ||
| 399 | "types": "./src/state/mcpApps.ts", | ||
| 400 | "default": "./src/state/mcpApps.ts" | ||
| 393 | } | 401 | } |
| 394 | }, | 402 | }, |
| 395 | "scripts": { | 403 | "scripts": { |
packages/client-runtime/src/mcpApps/host.test.ts created+419| ... | @@ -0,0 +1,419 @@ | ||
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { makeMcpAppHost, McpAppHostRefusal, type McpAppHostContext } from "./host.ts"; | ||
| 4 | |||
| 5 | const app = { | ||
| 6 | attachmentId: "thread-1-app", | ||
| 7 | server: "weather", | ||
| 8 | tool: "get_weather", | ||
| 9 | resourceUri: "ui://weather/dashboard", | ||
| 10 | csp: { connectDomains: ["https://api.weather.test"] }, | ||
| 11 | }; | ||
| 12 | |||
| 13 | const context = ( | ||
| 14 | theme: "light" | "dark" = "dark", | ||
| 15 | displayMode: "inline" | "fullscreen" = "inline", | ||
| 16 | ): McpAppHostContext => ({ | ||
| 17 | theme, | ||
| 18 | styles: { variables: { "--color-background-primary": theme === "dark" ? "#000" : "#fff" } }, | ||
| 19 | displayMode, | ||
| 20 | availableDisplayModes: ["inline", "fullscreen"], | ||
| 21 | containerDimensions: { width: 600, maxHeight: 2000 }, | ||
| 22 | platform: "web", | ||
| 23 | }); | ||
| 24 | |||
| 25 | // Lets the host settle the promises its callbacks return. | ||
| 26 | const flush = () => new Promise<void>((resolve) => queueMicrotask(resolve)).then(() => undefined); | ||
| 27 | |||
| 28 | const initialize = ( | ||
| 29 | host: ReturnType<typeof makeMcpAppHost>, | ||
| 30 | appCapabilities: Record<string, unknown> = {}, | ||
| 31 | ) => { | ||
| 32 | host.receive({ | ||
| 33 | jsonrpc: "2.0", | ||
| 34 | id: "init", | ||
| 35 | method: "ui/initialize", | ||
| 36 | params: { appCapabilities }, | ||
| 37 | }); | ||
| 38 | host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized" }); | ||
| 39 | }; | ||
| 40 | |||
| 41 | function setup(overrides: Partial<Parameters<typeof makeMcpAppHost>[0]> = {}) { | ||
| 42 | const sent: Array<Record<string, unknown>> = []; | ||
| 43 | let theme: "light" | "dark" = "dark"; | ||
| 44 | let mode: "inline" | "fullscreen" = "inline"; | ||
| 45 | const host = makeMcpAppHost({ | ||
| 46 | app, | ||
| 47 | hostVersion: "1.0.0", | ||
| 48 | post: (message) => sent.push(message as Record<string, unknown>), | ||
| 49 | hostContext: () => context(theme, mode), | ||
| 50 | callTool: async (input) => ({ content: [{ type: "text", text: `called ${input.name}` }] }), | ||
| 51 | readResource: async () => ({ contents: [] }), | ||
| 52 | openLink: async () => undefined, | ||
| 53 | sendMessage: async () => undefined, | ||
| 54 | updateModelContext: async () => undefined, | ||
| 55 | requestDisplayMode: async (next) => { | ||
| 56 | mode = next === "fullscreen" ? "fullscreen" : "inline"; | ||
| 57 | return mode; | ||
| 58 | }, | ||
| 59 | downloadFile: async () => undefined, | ||
| 60 | onRequestTeardown: () => undefined, | ||
| 61 | onSizeChanged: () => undefined, | ||
| 62 | ...overrides, | ||
| 63 | }); | ||
| 64 | return { | ||
| 65 | host, | ||
| 66 | sent, | ||
| 67 | setTheme: (next: "light" | "dark") => { | ||
| 68 | theme = next; | ||
| 69 | }, | ||
| 70 | }; | ||
| 71 | } | ||
| 72 | |||
| 73 | describe("makeMcpAppHost", () => { | ||
| 74 | it("initializes, then replays the tool input and result after initialized", () => { | ||
| 75 | const { host, sent } = setup(); | ||
| 76 | host.setToolCall({ | ||
| 77 | arguments: { city: "Oslo" }, | ||
| 78 | result: { content: [{ type: "text", text: "Sunny" }] }, | ||
| 79 | }); | ||
| 80 | host.receive({ | ||
| 81 | jsonrpc: "2.0", | ||
| 82 | id: 1, | ||
| 83 | method: "ui/initialize", | ||
| 84 | params: { protocolVersion: "2026-01-26", appInfo: { name: "a", version: "1" } }, | ||
| 85 | }); | ||
| 86 | expect(sent[0]).toMatchObject({ | ||
| 87 | id: 1, | ||
| 88 | result: { | ||
| 89 | protocolVersion: "2026-01-26", | ||
| 90 | hostCapabilities: { serverTools: {}, sandbox: { csp: app.csp } }, | ||
| 91 | hostContext: { theme: "dark", displayMode: "inline" }, | ||
| 92 | }, | ||
| 93 | }); | ||
| 94 | expect(sent).toHaveLength(1); | ||
| 95 | |||
| 96 | host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized", params: {} }); | ||
| 97 | expect(sent.slice(1)).toEqual([ | ||
| 98 | { | ||
| 99 | jsonrpc: "2.0", | ||
| 100 | method: "ui/notifications/tool-input", | ||
| 101 | params: { arguments: { city: "Oslo" } }, | ||
| 102 | }, | ||
| 103 | { | ||
| 104 | jsonrpc: "2.0", | ||
| 105 | method: "ui/notifications/tool-result", | ||
| 106 | params: { content: [{ type: "text", text: "Sunny" }] }, | ||
| 107 | }, | ||
| 108 | ]); | ||
| 109 | }); | ||
| 110 | |||
| 111 | it("replays a tool call that arrives after initialization, once", () => { | ||
| 112 | const { host, sent } = setup(); | ||
| 113 | host.receive({ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: {} }); | ||
| 114 | host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized" }); | ||
| 115 | sent.length = 0; | ||
| 116 | host.setToolCall({ arguments: { city: "Oslo" }, result: undefined }); | ||
| 117 | host.setToolCall({ arguments: { city: "Bergen" }, result: undefined }); | ||
| 118 | expect(sent).toEqual([ | ||
| 119 | { | ||
| 120 | jsonrpc: "2.0", | ||
| 121 | method: "ui/notifications/tool-input", | ||
| 122 | params: { arguments: { city: "Oslo" } }, | ||
| 123 | }, | ||
| 124 | ]); | ||
| 125 | }); | ||
| 126 | |||
| 127 | it("drops null fields providers report, which the MCP Apps SDK rejects", async () => { | ||
| 128 | const { host, sent } = setup({ | ||
| 129 | callTool: async () => ({ content: [], structuredContent: null, _meta: null }), | ||
| 130 | }); | ||
| 131 | host.receive({ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: {} }); | ||
| 132 | host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized" }); | ||
| 133 | host.setToolCall({ | ||
| 134 | arguments: {}, | ||
| 135 | result: { | ||
| 136 | content: [{ type: "text", text: "t" }], | ||
| 137 | structuredContent: { time: "t" }, | ||
| 138 | _meta: null, | ||
| 139 | }, | ||
| 140 | }); | ||
| 141 | host.receive({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "get-time" } }); | ||
| 142 | await flush(); | ||
| 143 | expect(sent.find((m) => m.method === "ui/notifications/tool-result")?.params).toEqual({ | ||
| 144 | content: [{ type: "text", text: "t" }], | ||
| 145 | structuredContent: { time: "t" }, | ||
| 146 | }); | ||
| 147 | expect(sent.find((m) => m.id === 2)?.result).toEqual({ content: [] }); | ||
| 148 | }); | ||
| 149 | |||
| 150 | it("accepts ui/message as one text block or the SDK's block array", async () => { | ||
| 151 | const messages: Array<string> = []; | ||
| 152 | const { host, sent } = setup({ sendMessage: async (text) => void messages.push(text) }); | ||
| 153 | host.receive({ | ||
| 154 | jsonrpc: "2.0", | ||
| 155 | id: 1, | ||
| 156 | method: "ui/message", | ||
| 157 | params: { role: "user", content: { type: "text", text: "one" } }, | ||
| 158 | }); | ||
| 159 | host.receive({ | ||
| 160 | jsonrpc: "2.0", | ||
| 161 | id: 2, | ||
| 162 | method: "ui/message", | ||
| 163 | params: { role: "user", content: [{ type: "text", text: "two" }] }, | ||
| 164 | }); | ||
| 165 | host.receive({ | ||
| 166 | jsonrpc: "2.0", | ||
| 167 | id: 3, | ||
| 168 | method: "ui/message", | ||
| 169 | params: { role: "user", content: [{ type: "image", data: "x" }] }, | ||
| 170 | }); | ||
| 171 | host.receive({ | ||
| 172 | jsonrpc: "2.0", | ||
| 173 | id: 4, | ||
| 174 | method: "ui/message", | ||
| 175 | params: { role: "user", content: { type: "text", text: " " } }, | ||
| 176 | }); | ||
| 177 | await flush(); | ||
| 178 | expect(messages).toEqual(["one", "two"]); | ||
| 179 | expect(sent.find((m) => m.id === 3)?.error).toMatchObject({ code: -32602 }); | ||
| 180 | // Blank text would ask to send an empty message. | ||
| 181 | expect(sent.find((m) => m.id === 4)?.error).toMatchObject({ code: -32602 }); | ||
| 182 | }); | ||
| 183 | |||
| 184 | it("sends only changed context fields, and nothing before initialization", () => { | ||
| 185 | const { host, sent, setTheme } = setup(); | ||
| 186 | setTheme("light"); | ||
| 187 | host.updateHostContext(); | ||
| 188 | expect(sent).toHaveLength(0); | ||
| 189 | |||
| 190 | host.receive({ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: {} }); | ||
| 191 | host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized" }); | ||
| 192 | sent.length = 0; | ||
| 193 | setTheme("dark"); | ||
| 194 | host.updateHostContext(); | ||
| 195 | expect(sent).toEqual([ | ||
| 196 | { | ||
| 197 | jsonrpc: "2.0", | ||
| 198 | method: "ui/notifications/host-context-changed", | ||
| 199 | params: { theme: "dark", styles: context("dark").styles }, | ||
| 200 | }, | ||
| 201 | ]); | ||
| 202 | host.updateHostContext(); | ||
| 203 | expect(sent).toHaveLength(1); | ||
| 204 | }); | ||
| 205 | |||
| 206 | it("catches the app up on context that changed while it was initializing", () => { | ||
| 207 | const { host, sent, setTheme } = setup(); | ||
| 208 | host.receive({ jsonrpc: "2.0", id: 1, method: "ui/initialize", params: {} }); | ||
| 209 | setTheme("light"); | ||
| 210 | host.receive({ jsonrpc: "2.0", method: "ui/notifications/initialized" }); | ||
| 211 | expect(sent.at(-1)).toEqual({ | ||
| 212 | jsonrpc: "2.0", | ||
| 213 | method: "ui/notifications/host-context-changed", | ||
| 214 | params: { theme: "light", styles: context("light").styles }, | ||
| 215 | }); | ||
| 216 | }); | ||
| 217 | |||
| 218 | it("proxies tools/call and reports a refusal as a JSON-RPC error", async () => { | ||
| 219 | const { host, sent } = setup({ | ||
| 220 | callTool: async (input) => { | ||
| 221 | if (input.name === "delete_everything") | ||
| 222 | throw new McpAppHostRefusal("Declined by the user."); | ||
| 223 | return { content: [], structuredContent: { ok: true } }; | ||
| 224 | }, | ||
| 225 | }); | ||
| 226 | host.receive({ jsonrpc: "2.0", id: 7, method: "tools/call", params: { name: "refresh" } }); | ||
| 227 | host.receive({ | ||
| 228 | jsonrpc: "2.0", | ||
| 229 | id: 8, | ||
| 230 | method: "tools/call", | ||
| 231 | params: { name: "delete_everything", arguments: {} }, | ||
| 232 | }); | ||
| 233 | await flush(); | ||
| 234 | expect(sent).toContainEqual({ | ||
| 235 | jsonrpc: "2.0", | ||
| 236 | id: 7, | ||
| 237 | result: { content: [], structuredContent: { ok: true } }, | ||
| 238 | }); | ||
| 239 | expect(sent).toContainEqual({ | ||
| 240 | jsonrpc: "2.0", | ||
| 241 | id: 8, | ||
| 242 | error: { code: -32000, message: "Declined by the user." }, | ||
| 243 | }); | ||
| 244 | }); | ||
| 245 | |||
| 246 | it("rejects malformed requests and unsupported methods", () => { | ||
| 247 | const { host, sent } = setup(); | ||
| 248 | host.receive({ | ||
| 249 | jsonrpc: "2.0", | ||
| 250 | id: 1, | ||
| 251 | method: "ui/open-link", | ||
| 252 | params: { url: "javascript:x" }, | ||
| 253 | }); | ||
| 254 | host.receive({ jsonrpc: "2.0", id: 3, method: "sampling/createMessage", params: {} }); | ||
| 255 | host.receive({ jsonrpc: "2.0", id: 4, method: "ui/message", params: { role: "assistant" } }); | ||
| 256 | expect(sent.map((message) => (message.error as { code: number }).code)).toEqual([ | ||
| 257 | -32602, -32601, -32602, | ||
| 258 | ]); | ||
| 259 | }); | ||
| 260 | |||
| 261 | it("refuses requests past the in-flight limit and posts nothing after dispose", async () => { | ||
| 262 | let release: () => void = () => undefined; | ||
| 263 | const gate = new Promise<void>((resolve) => { | ||
| 264 | release = resolve; | ||
| 265 | }); | ||
| 266 | const { host, sent } = setup({ | ||
| 267 | callTool: async () => { | ||
| 268 | await gate; | ||
| 269 | return { content: [] }; | ||
| 270 | }, | ||
| 271 | }); | ||
| 272 | for (let id = 0; id < 17; id++) { | ||
| 273 | host.receive({ jsonrpc: "2.0", id, method: "tools/call", params: { name: "slow" } }); | ||
| 274 | } | ||
| 275 | expect(sent).toEqual([ | ||
| 276 | { jsonrpc: "2.0", id: 16, error: { code: -32000, message: "Too many requests in flight." } }, | ||
| 277 | ]); | ||
| 278 | host.dispose(); | ||
| 279 | release(); | ||
| 280 | await flush(); | ||
| 281 | expect(sent).toHaveLength(1); | ||
| 282 | }); | ||
| 283 | |||
| 284 | it("switches display mode only to one both sides offer, and tells the app", async () => { | ||
| 285 | const { host, sent } = setup(); | ||
| 286 | initialize(host, { availableDisplayModes: ["inline", "fullscreen"] }); | ||
| 287 | sent.length = 0; | ||
| 288 | host.receive({ | ||
| 289 | jsonrpc: "2.0", | ||
| 290 | id: 1, | ||
| 291 | method: "ui/request-display-mode", | ||
| 292 | params: { mode: "pip" }, | ||
| 293 | }); | ||
| 294 | host.receive({ | ||
| 295 | jsonrpc: "2.0", | ||
| 296 | id: 2, | ||
| 297 | method: "ui/request-display-mode", | ||
| 298 | params: { mode: "fullscreen" }, | ||
| 299 | }); | ||
| 300 | await flush(); | ||
| 301 | host.updateHostContext(); | ||
| 302 | expect(sent.find((m) => m.id === 1)?.result).toEqual({ mode: "inline" }); | ||
| 303 | expect(sent.find((m) => m.id === 2)?.result).toEqual({ mode: "fullscreen" }); | ||
| 304 | expect(sent.find((m) => m.method === "ui/notifications/host-context-changed")?.params).toEqual({ | ||
| 305 | displayMode: "fullscreen", | ||
| 306 | }); | ||
| 307 | |||
| 308 | // An app that declared only inline stays inline. | ||
| 309 | const declined = setup(); | ||
| 310 | initialize(declined.host, { availableDisplayModes: ["inline"] }); | ||
| 311 | declined.host.receive({ | ||
| 312 | jsonrpc: "2.0", | ||
| 313 | id: 3, | ||
| 314 | method: "ui/request-display-mode", | ||
| 315 | params: { mode: "fullscreen" }, | ||
| 316 | }); | ||
| 317 | await flush(); | ||
| 318 | expect(declined.sent.find((m) => m.id === 3)?.result).toEqual({ mode: "inline" }); | ||
| 319 | }); | ||
| 320 | |||
| 321 | it("forwards model context and download requests, and declares both", async () => { | ||
| 322 | const contexts: Array<unknown> = []; | ||
| 323 | const downloads: Array<unknown> = []; | ||
| 324 | const { host, sent } = setup({ | ||
| 325 | updateModelContext: async (update) => void contexts.push(update), | ||
| 326 | downloadFile: async (files) => { | ||
| 327 | downloads.push(files); | ||
| 328 | if (files.length > 1) throw new McpAppHostRefusal("Declined by the user."); | ||
| 329 | }, | ||
| 330 | }); | ||
| 331 | initialize(host); | ||
| 332 | const capabilities = ( | ||
| 333 | sent[0]?.result as { hostCapabilities: Record<string, unknown> } | undefined | ||
| 334 | )?.hostCapabilities; | ||
| 335 | expect(capabilities?.updateModelContext).toEqual({ text: {}, structuredContent: {} }); | ||
| 336 | expect(capabilities?.downloadFile).toEqual({}); | ||
| 337 | |||
| 338 | host.receive({ | ||
| 339 | jsonrpc: "2.0", | ||
| 340 | id: 1, | ||
| 341 | method: "ui/update-model-context", | ||
| 342 | params: { content: [{ type: "text", text: "2 overdue" }], structuredContent: { overdue: 2 } }, | ||
| 343 | }); | ||
| 344 | const embedded = { | ||
| 345 | type: "resource", | ||
| 346 | resource: { uri: "file:///report.csv", mimeType: "text/csv", text: "a,b" }, | ||
| 347 | }; | ||
| 348 | host.receive({ | ||
| 349 | jsonrpc: "2.0", | ||
| 350 | id: 2, | ||
| 351 | method: "ui/download-file", | ||
| 352 | params: { contents: [embedded] }, | ||
| 353 | }); | ||
| 354 | host.receive({ | ||
| 355 | jsonrpc: "2.0", | ||
| 356 | id: 3, | ||
| 357 | method: "ui/download-file", | ||
| 358 | params: { | ||
| 359 | contents: [embedded, { type: "resource_link", uri: "ui://todos/export", name: "x" }], | ||
| 360 | }, | ||
| 361 | }); | ||
| 362 | host.receive({ jsonrpc: "2.0", id: 4, method: "ui/download-file", params: { contents: [{}] } }); | ||
| 363 | await flush(); | ||
| 364 | expect(contexts).toEqual([ | ||
| 365 | { content: [{ type: "text", text: "2 overdue" }], structuredContent: { overdue: 2 } }, | ||
| 366 | ]); | ||
| 367 | expect(downloads[0]).toEqual([ | ||
| 368 | { | ||
| 369 | _tag: "embedded", | ||
| 370 | name: "report.csv", | ||
| 371 | mimeType: "text/csv", | ||
| 372 | bytes: new TextEncoder().encode("a,b"), | ||
| 373 | }, | ||
| 374 | ]); | ||
| 375 | expect(sent.find((m) => m.id === 2)?.result).toEqual({}); | ||
| 376 | // A refused download is reported in the result, as the draft defines. | ||
| 377 | expect(sent.find((m) => m.id === 3)?.result).toEqual({ isError: true }); | ||
| 378 | expect(sent.find((m) => m.id === 4)?.error).toMatchObject({ code: -32602 }); | ||
| 379 | }); | ||
| 380 | |||
| 381 | it("tears down after the app answers, or after the timeout, and passes on its own request", async () => { | ||
| 382 | vi.useFakeTimers(); | ||
| 383 | try { | ||
| 384 | let closeRequested = false; | ||
| 385 | const { host, sent } = setup({ onRequestTeardown: () => (closeRequested = true) }); | ||
| 386 | host.receive({ jsonrpc: "2.0", method: "ui/notifications/request-teardown" }); | ||
| 387 | expect(closeRequested).toBe(true); | ||
| 388 | |||
| 389 | // Before initialization there is nothing to ask. | ||
| 390 | await host.teardown(); | ||
| 391 | expect(sent.some((m) => m.method === "ui/resource-teardown")).toBe(false); | ||
| 392 | |||
| 393 | const answered = setup(); | ||
| 394 | initialize(answered.host); | ||
| 395 | let done = false; | ||
| 396 | void answered.host.teardown().then(() => (done = true)); | ||
| 397 | const request = answered.sent.find((m) => m.method === "ui/resource-teardown"); | ||
| 398 | expect(request).toBeDefined(); | ||
| 399 | answered.host.receive({ jsonrpc: "2.0", id: request?.id, result: {} }); | ||
| 400 | await vi.runAllTimersAsync(); | ||
| 401 | expect(done).toBe(true); | ||
| 402 | |||
| 403 | const silent = setup(); | ||
| 404 | initialize(silent.host); | ||
| 405 | let timedOut = false; | ||
| 406 | void silent.host.teardown().then(() => (timedOut = true)); | ||
| 407 | await vi.advanceTimersByTimeAsync(1999); | ||
| 408 | expect(timedOut).toBe(false); | ||
| 409 | await vi.advanceTimersByTimeAsync(1); | ||
| 410 | expect(timedOut).toBe(true); | ||
| 411 | // A torn-down host posts nothing more. | ||
| 412 | const before = silent.sent.length; | ||
| 413 | silent.host.updateHostContext(); | ||
| 414 | expect(silent.sent).toHaveLength(before); | ||
| 415 | } finally { | ||
| 416 | vi.useRealTimers(); | ||
| 417 | } | ||
| 418 | }); | ||
| 419 | }); | ||
packages/client-runtime/src/mcpApps/host.ts created+576| ... | @@ -0,0 +1,576 @@ | ||
| 1 | // @effect-diagnostics globalTimers:off - The bridge runs in the client, outside an Effect runtime. | ||
| 2 | import { MCP_APP_PROTOCOL_VERSION, type McpAppReference } from "@t3tools/shared/mcpApp"; | ||
| 3 | import * as Base64 from "effect/encoding/Base64"; | ||
| 4 | import * as Predicate from "effect/Predicate"; | ||
| 5 | import * as Result from "effect/Result"; | ||
| 6 | |||
| 7 | /** | ||
| 8 | * The host side of the MCP Apps bridge (spec 2026-01-26, plus the draft | ||
| 9 | * additions the current SDK sends: `ui/download-file` and | ||
| 10 | * `ui/notifications/request-teardown`): JSON-RPC 2.0 over postMessage between a | ||
| 11 | * client and one app document. It is transport-free so web (an iframe) and | ||
| 12 | * mobile (a WebView) share every protocol rule; each platform supplies how to | ||
| 13 | * post a message and how to reach the environment. | ||
| 14 | */ | ||
| 15 | |||
| 16 | const MAX_MESSAGE_BYTES = 256 * 1024; | ||
| 17 | /** Requests an app may have in flight at once; more are refused, not queued. */ | ||
| 18 | const MAX_PENDING_REQUESTS = 16; | ||
| 19 | /** How long the host waits for an app to answer `ui/resource-teardown`. */ | ||
| 20 | const TEARDOWN_TIMEOUT_MS = 2000; | ||
| 21 | const encoder = new TextEncoder(); | ||
| 22 | |||
| 23 | type JsonRpcId = string | number; | ||
| 24 | |||
| 25 | export type McpAppDisplayMode = "inline" | "fullscreen" | "pip"; | ||
| 26 | |||
| 27 | export interface McpAppHostContext { | ||
| 28 | readonly theme: "light" | "dark"; | ||
| 29 | readonly styles: { readonly variables: Readonly<Record<string, string>> }; | ||
| 30 | readonly displayMode: McpAppDisplayMode; | ||
| 31 | readonly availableDisplayModes: ReadonlyArray<McpAppDisplayMode>; | ||
| 32 | /** | ||
| 33 | * `maxHeight` when the host sizes the frame to the app's reported height, | ||
| 34 | * `height` when the frame is a fixed box the app must fit (spec | ||
| 35 | * "Container Dimensions"). | ||
| 36 | */ | ||
| 37 | readonly containerDimensions: | ||
| 38 | | { readonly width: number; readonly maxHeight: number } | ||
| 39 | | { readonly width: number; readonly height: number }; | ||
| 40 | readonly platform: "web" | "desktop" | "mobile"; | ||
| 41 | readonly locale?: string; | ||
| 42 | readonly timeZone?: string; | ||
| 43 | readonly userAgent?: string; | ||
| 44 | readonly deviceCapabilities?: { readonly touch?: boolean; readonly hover?: boolean }; | ||
| 45 | readonly safeAreaInsets?: { | ||
| 46 | readonly top: number; | ||
| 47 | readonly right: number; | ||
| 48 | readonly bottom: number; | ||
| 49 | readonly left: number; | ||
| 50 | }; | ||
| 51 | /** The tool call that created the app: the server's MCP `Tool` definition. */ | ||
| 52 | readonly toolInfo?: { readonly tool: unknown }; | ||
| 53 | } | ||
| 54 | |||
| 55 | /** A file an app asked the host to save (`ui/download-file`): embedded, or linked on its server. */ | ||
| 56 | export type McpAppDownload = | ||
| 57 | | { | ||
| 58 | readonly _tag: "embedded"; | ||
| 59 | readonly name: string; | ||
| 60 | readonly mimeType: string; | ||
| 61 | readonly bytes: Uint8Array; | ||
| 62 | } | ||
| 63 | | { | ||
| 64 | readonly _tag: "link"; | ||
| 65 | readonly name: string; | ||
| 66 | readonly uri: string; | ||
| 67 | readonly mimeType?: string; | ||
| 68 | }; | ||
| 69 | |||
| 70 | /** | ||
| 71 | * The bytes of an MCP resource's contents: `text` as UTF-8, `blob` decoded | ||
| 72 | * from base64. Undefined when it has neither or the base64 is malformed. | ||
| 73 | */ | ||
| 74 | export function mcpResourceBytes(content: unknown): Uint8Array | undefined { | ||
| 75 | if (!Predicate.isObject(content)) return undefined; | ||
| 76 | if (typeof content.text === "string") return encoder.encode(content.text); | ||
| 77 | if (typeof content.blob === "string") { | ||
| 78 | const decoded = Base64.decode(content.blob); | ||
| 79 | return Result.isSuccess(decoded) ? decoded.success : undefined; | ||
| 80 | } | ||
| 81 | return undefined; | ||
| 82 | } | ||
| 83 | |||
| 84 | export interface McpAppCallToolResult { | ||
| 85 | readonly content: ReadonlyArray<unknown>; | ||
| 86 | readonly structuredContent?: unknown; | ||
| 87 | readonly isError?: boolean | undefined; | ||
| 88 | readonly _meta?: unknown; | ||
| 89 | } | ||
| 90 | |||
| 91 | /** Thrown by host callbacks to refuse a request with a message the app can show. */ | ||
| 92 | export class McpAppHostRefusal extends Error {} | ||
| 93 | |||
| 94 | export interface McpAppHostOptions { | ||
| 95 | readonly app: McpAppReference; | ||
| 96 | readonly hostVersion: string; | ||
| 97 | /** Posts one JSON-RPC message to the app document. */ | ||
| 98 | readonly post: (message: unknown) => void; | ||
| 99 | readonly hostContext: () => McpAppHostContext; | ||
| 100 | |||
| 101 | readonly callTool: (input: { | ||
| 102 | readonly name: string; | ||
| 103 | readonly arguments: Record<string, unknown>; | ||
| 104 | }) => Promise<McpAppCallToolResult>; | ||
| 105 | readonly readResource: (input: { readonly uri: string }) => Promise<unknown>; | ||
| 106 | readonly openLink: (url: string) => Promise<void>; | ||
| 107 | readonly sendMessage: (text: string) => Promise<void>; | ||
| 108 | /** Replaces what this app tells the agent on its next turn; undefined content clears it. */ | ||
| 109 | readonly updateModelContext: (context: { | ||
| 110 | readonly content?: ReadonlyArray<unknown>; | ||
| 111 | readonly structuredContent?: Record<string, unknown>; | ||
| 112 | }) => Promise<void>; | ||
| 113 | /** | ||
| 114 | * Switches the app to a mode the host and the app both offer, returning the | ||
| 115 | * resulting mode. The host has already checked both lists. | ||
| 116 | */ | ||
| 117 | readonly requestDisplayMode: (mode: McpAppDisplayMode) => Promise<McpAppDisplayMode>; | ||
| 118 | readonly downloadFile: (files: ReadonlyArray<McpAppDownload>) => Promise<void>; | ||
| 119 | /** The app asked to be closed (`ui/notifications/request-teardown`). */ | ||
| 120 | readonly onRequestTeardown: () => void; | ||
| 121 | readonly onSizeChanged: (size: { readonly width?: number; readonly height?: number }) => void; | ||
| 122 | } | ||
| 123 | |||
| 124 | export interface McpAppHost { | ||
| 125 | /** Handles one message the app posted; the caller has already checked its source. */ | ||
| 126 | readonly receive: (data: unknown) => void; | ||
| 127 | /** | ||
| 128 | * Supplies the original tool call's arguments and result. They reach the | ||
| 129 | * app once it has initialized, whichever happens last, and only once. | ||
| 130 | */ | ||
| 131 | readonly setToolCall: (call: { | ||
| 132 | readonly arguments: unknown; | ||
| 133 | readonly result: McpAppCallToolResult | undefined; | ||
| 134 | }) => void; | ||
| 135 | /** Sends `host-context-changed` with the fields that differ from what the app last saw. */ | ||
| 136 | readonly updateHostContext: () => void; | ||
| 137 | /** | ||
| 138 | * Asks the app to wrap up before its document goes away (`ui/resource-teardown`), | ||
| 139 | * resolving once it answers or after a short timeout, then disposes the host. | ||
| 140 | */ | ||
| 141 | readonly teardown: () => Promise<void>; | ||
| 142 | readonly dispose: () => void; | ||
| 143 | } | ||
| 144 | |||
| 145 | function jsonBytes(value: unknown): number { | ||
| 146 | try { | ||
| 147 | return encoder.encode(JSON.stringify(value) ?? "").byteLength; | ||
| 148 | } catch { | ||
| 149 | return Number.POSITIVE_INFINITY; | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 153 | function isId(value: unknown): value is JsonRpcId { | ||
| 154 | return typeof value === "string" || (typeof value === "number" && Number.isFinite(value)); | ||
| 155 | } | ||
| 156 | |||
| 157 | /** | ||
| 158 | * A `CallToolResult` as the spec's schema accepts it. Providers can report | ||
| 159 | * absent fields as null (Codex sends `_meta: null`), which the MCP Apps SDK | ||
| 160 | * rejects, dropping the whole notification or response. | ||
| 161 | */ | ||
| 162 | function normalizeMcpAppToolResult(result: McpAppCallToolResult): McpAppCallToolResult { | ||
| 163 | return { | ||
| 164 | content: Array.isArray(result.content) ? result.content : [], | ||
| 165 | ...(Predicate.isObject(result.structuredContent) | ||
| 166 | ? { structuredContent: result.structuredContent } | ||
| 167 | : {}), | ||
| 168 | ...(result.isError === true ? { isError: true } : {}), | ||
| 169 | ...(Predicate.isObject(result._meta) ? { _meta: result._meta } : {}), | ||
| 170 | }; | ||
| 171 | } | ||
| 172 | |||
| 173 | const errorMessage = (error: unknown) => | ||
| 174 | error instanceof McpAppHostRefusal | ||
| 175 | ? error.message | ||
| 176 | : error instanceof Error && error.message.trim() !== "" | ||
| 177 | ? error.message | ||
| 178 | : "Request failed."; | ||
| 179 | |||
| 180 | export function makeMcpAppHost(options: McpAppHostOptions): McpAppHost { | ||
| 181 | let initialized = false; | ||
| 182 | let disposed = false; | ||
| 183 | let toolCall: | ||
| 184 | | { readonly arguments: unknown; readonly result: McpAppCallToolResult | undefined } | ||
| 185 | | undefined; | ||
| 186 | let toolCallSent = false; | ||
| 187 | let pending = 0; | ||
| 188 | let sentContext: McpAppHostContext | undefined; | ||
| 189 | /** Modes the app declared at initialize; undefined when it declared none. */ | ||
| 190 | let appDisplayModes: ReadonlyArray<string> | undefined; | ||
| 191 | let nextHostRequestId = 0; | ||
| 192 | const hostRequests = new Map<string, () => void>(); | ||
| 193 | |||
| 194 | const post = (message: unknown) => { | ||
| 195 | if (!disposed) options.post(message); | ||
| 196 | }; | ||
| 197 | const notify = (method: string, params: unknown) => post({ jsonrpc: "2.0", method, params }); | ||
| 198 | const respond = (id: JsonRpcId, result: unknown) => post({ jsonrpc: "2.0", id, result }); | ||
| 199 | const fail = (id: JsonRpcId, code: number, message: string) => | ||
| 200 | post({ jsonrpc: "2.0", id, error: { code, message } }); | ||
| 201 | |||
| 202 | const sendToolCall = () => { | ||
| 203 | if (!initialized || toolCall === undefined || toolCallSent) return; | ||
| 204 | toolCallSent = true; | ||
| 205 | notify("ui/notifications/tool-input", { | ||
| 206 | arguments: Predicate.isObject(toolCall.arguments) ? toolCall.arguments : {}, | ||
| 207 | }); | ||
| 208 | if (toolCall.result !== undefined) { | ||
| 209 | notify("ui/notifications/tool-result", normalizeMcpAppToolResult(toolCall.result)); | ||
| 210 | } | ||
| 211 | }; | ||
| 212 | |||
| 213 | const answer = (id: JsonRpcId, run: () => Promise<unknown>) => { | ||
| 214 | if (pending >= MAX_PENDING_REQUESTS) { | ||
| 215 | fail(id, -32000, "Too many requests in flight."); | ||
| 216 | return; | ||
| 217 | } | ||
| 218 | pending += 1; | ||
| 219 | void run().then( | ||
| 220 | (result) => { | ||
| 221 | pending -= 1; | ||
| 222 | respond(id, result); | ||
| 223 | }, | ||
| 224 | (error: unknown) => { | ||
| 225 | pending -= 1; | ||
| 226 | fail(id, -32000, errorMessage(error)); | ||
| 227 | }, | ||
| 228 | ); | ||
| 229 | }; | ||
| 230 | |||
| 231 | const handleRequest = (id: JsonRpcId, method: string, params: Record<PropertyKey, unknown>) => { | ||
| 232 | switch (method) { | ||
| 233 | case "ui/initialize": { | ||
| 234 | const requested = params.protocolVersion; | ||
| 235 | const declared = Predicate.isObject(params.appCapabilities) | ||
| 236 | ? params.appCapabilities.availableDisplayModes | ||
| 237 | : undefined; | ||
| 238 | appDisplayModes = Array.isArray(declared) | ||
| 239 | ? declared.filter((mode): mode is string => typeof mode === "string") | ||
| 240 | : undefined; | ||
| 241 | sentContext = options.hostContext(); | ||
| 242 | respond(id, { | ||
| 243 | protocolVersion: | ||
| 244 | requested === MCP_APP_PROTOCOL_VERSION ? requested : MCP_APP_PROTOCOL_VERSION, | ||
| 245 | hostInfo: { name: "t3-code", version: options.hostVersion }, | ||
| 246 | hostCapabilities: { | ||
| 247 | openLinks: {}, | ||
| 248 | serverTools: {}, | ||
| 249 | serverResources: {}, | ||
| 250 | logging: {}, | ||
| 251 | message: { text: {} }, | ||
| 252 | updateModelContext: { text: {}, structuredContent: {} }, | ||
| 253 | downloadFile: {}, | ||
| 254 | sandbox: { | ||
| 255 | ...(options.app.csp === undefined ? {} : { csp: options.app.csp }), | ||
| 256 | ...(options.app.permissions === undefined | ||
| 257 | ? {} | ||
| 258 | : { permissions: options.app.permissions }), | ||
| 259 | }, | ||
| 260 | }, | ||
| 261 | hostContext: sentContext, | ||
| 262 | }); | ||
| 263 | return; | ||
| 264 | } | ||
| 265 | case "ping": | ||
| 266 | respond(id, {}); | ||
| 267 | return; | ||
| 268 | case "tools/call": { | ||
| 269 | const name = params.name; | ||
| 270 | const args = params.arguments ?? {}; | ||
| 271 | if (typeof name !== "string" || name.trim() === "" || !Predicate.isObject(args)) { | ||
| 272 | fail(id, -32602, "tools/call needs a tool name and object arguments."); | ||
| 273 | return; | ||
| 274 | } | ||
| 275 | answer(id, () => | ||
| 276 | options | ||
| 277 | .callTool({ name, arguments: args as Record<string, unknown> }) | ||
| 278 | .then(normalizeMcpAppToolResult), | ||
| 279 | ); | ||
| 280 | return; | ||
| 281 | } | ||
| 282 | case "resources/read": { | ||
| 283 | const uri = params.uri; | ||
| 284 | if (typeof uri !== "string" || uri.trim() === "") { | ||
| 285 | fail(id, -32602, "resources/read needs a uri."); | ||
| 286 | return; | ||
| 287 | } | ||
| 288 | answer(id, () => options.readResource({ uri })); | ||
| 289 | return; | ||
| 290 | } | ||
| 291 | case "ui/open-link": { | ||
| 292 | const url = params.url; | ||
| 293 | if (typeof url !== "string" || !/^https?:\/\//i.test(url)) { | ||
| 294 | fail(id, -32602, "Only http(s) links can be opened."); | ||
| 295 | return; | ||
| 296 | } | ||
| 297 | answer(id, () => options.openLink(url).then(() => ({}))); | ||
| 298 | return; | ||
| 299 | } | ||
| 300 | case "ui/message": { | ||
| 301 | // The SDK sends an array of content blocks; the spec text shows one block. | ||
| 302 | const blocks = Array.isArray(params.content) ? params.content : [params.content]; | ||
| 303 | const texts = blocks.map((block) => | ||
| 304 | Predicate.isObject(block) && block.type === "text" && typeof block.text === "string" | ||
| 305 | ? block.text | ||
| 306 | : undefined, | ||
| 307 | ); | ||
| 308 | if (params.role !== "user" || texts.length === 0 || texts.includes(undefined)) { | ||
| 309 | fail(id, -32602, "Only text messages from the user role are supported."); | ||
| 310 | return; | ||
| 311 | } | ||
| 312 | const text = texts.join("\n").trim(); | ||
| 313 | if (text === "") { | ||
| 314 | fail(id, -32602, "ui/message needs non-empty text."); | ||
| 315 | return; | ||
| 316 | } | ||
| 317 | answer(id, () => options.sendMessage(text).then(() => ({}))); | ||
| 318 | return; | ||
| 319 | } | ||
| 320 | case "ui/request-display-mode": { | ||
| 321 | const current = options.hostContext().displayMode; | ||
| 322 | const mode = params.mode; | ||
| 323 | // A mode the host lacks, or the app did not declare, leaves the | ||
| 324 | // current one; the spec has the host answer with whichever applies. | ||
| 325 | const allowed = | ||
| 326 | typeof mode === "string" && | ||
| 327 | options.hostContext().availableDisplayModes.includes(mode as McpAppDisplayMode) && | ||
| 328 | (appDisplayModes === undefined || appDisplayModes.includes(mode)); | ||
| 329 | if (!allowed || mode === current) { | ||
| 330 | respond(id, { mode: current }); | ||
| 331 | return; | ||
| 332 | } | ||
| 333 | answer(id, () => | ||
| 334 | options | ||
| 335 | .requestDisplayMode(mode as McpAppDisplayMode) | ||
| 336 | .then((result) => ({ mode: result })), | ||
| 337 | ); | ||
| 338 | return; | ||
| 339 | } | ||
| 340 | case "ui/update-model-context": { | ||
| 341 | const content = params.content; | ||
| 342 | const structured = params.structuredContent; | ||
| 343 | if ( | ||
| 344 | (content !== undefined && !Array.isArray(content)) || | ||
| 345 | (structured !== undefined && !Predicate.isObject(structured)) | ||
| 346 | ) { | ||
| 347 | fail( | ||
| 348 | id, | ||
| 349 | -32602, | ||
| 350 | "Model context needs content blocks or an object of structured content.", | ||
| 351 | ); | ||
| 352 | return; | ||
| 353 | } | ||
| 354 | answer(id, () => | ||
| 355 | options | ||
| 356 | .updateModelContext({ | ||
| 357 | ...(content === undefined ? {} : { content }), | ||
| 358 | ...(structured === undefined | ||
| 359 | ? {} | ||
| 360 | : { structuredContent: structured as Record<string, unknown> }), | ||
| 361 | }) | ||
| 362 | .then(() => ({})), | ||
| 363 | ); | ||
| 364 | return; | ||
| 365 | } | ||
| 366 | case "ui/download-file": { | ||
| 367 | const files = readDownloads(params.contents); | ||
| 368 | if (files === undefined) { | ||
| 369 | fail(id, -32602, "ui/download-file needs embedded resources or resource links."); | ||
| 370 | return; | ||
| 371 | } | ||
| 372 | answer(id, () => | ||
| 373 | options.downloadFile(files).then( | ||
| 374 | () => ({}), | ||
| 375 | // The draft reports a refused or failed download in the result. | ||
| 376 | (error: unknown) => | ||
| 377 | error instanceof McpAppHostRefusal ? { isError: true } : Promise.reject(error), | ||
| 378 | ), | ||
| 379 | ); | ||
| 380 | return; | ||
| 381 | } | ||
| 382 | default: | ||
| 383 | fail(id, -32601, `Method not found: ${method}`); | ||
| 384 | } | ||
| 385 | }; | ||
| 386 | |||
| 387 | const handleNotification = (method: string, params: Record<PropertyKey, unknown>) => { | ||
| 388 | switch (method) { | ||
| 389 | case "ui/notifications/initialized": { | ||
| 390 | if (initialized) return; | ||
| 391 | initialized = true; | ||
| 392 | sendToolCall(); | ||
| 393 | // The theme or size may have changed since the initialize response. | ||
| 394 | hostHandle.updateHostContext(); | ||
| 395 | return; | ||
| 396 | } | ||
| 397 | case "ui/notifications/size-changed": { | ||
| 398 | const width = typeof params.width === "number" ? params.width : undefined; | ||
| 399 | const height = typeof params.height === "number" ? params.height : undefined; | ||
| 400 | if (width !== undefined || height !== undefined) { | ||
| 401 | options.onSizeChanged({ | ||
| 402 | ...(width === undefined ? {} : { width }), | ||
| 403 | ...(height === undefined ? {} : { height }), | ||
| 404 | }); | ||
| 405 | } | ||
| 406 | return; | ||
| 407 | } | ||
| 408 | case "ui/notifications/request-teardown": | ||
| 409 | options.onRequestTeardown(); | ||
| 410 | return; | ||
| 411 | default: | ||
| 412 | // notifications/message (logging) and unknown notifications are ignored. | ||
| 413 | } | ||
| 414 | }; | ||
| 415 | |||
| 416 | /** A response to a request the host sent the app. */ | ||
| 417 | const handleResponse = (id: JsonRpcId) => { | ||
| 418 | const settle = hostRequests.get(String(id)); | ||
| 419 | if (settle === undefined) return; | ||
| 420 | hostRequests.delete(String(id)); | ||
| 421 | settle(); | ||
| 422 | }; | ||
| 423 | |||
| 424 | const hostHandle: McpAppHost = { | ||
| 425 | receive: (data) => { | ||
| 426 | if (disposed || !Predicate.isObject(data) || data.jsonrpc !== "2.0") return; | ||
| 427 | if (jsonBytes(data) > MAX_MESSAGE_BYTES) { | ||
| 428 | if (isId(data.id) && typeof data.method === "string") { | ||
| 429 | fail(data.id, -32600, "Message too large."); | ||
| 430 | } | ||
| 431 | return; | ||
| 432 | } | ||
| 433 | const method = data.method; | ||
| 434 | if (typeof method !== "string") { | ||
| 435 | if (isId(data.id) && ("result" in data || "error" in data)) handleResponse(data.id); | ||
| 436 | return; | ||
| 437 | } | ||
| 438 | const params = Predicate.isObject(data.params) ? data.params : {}; | ||
| 439 | if (isId(data.id)) handleRequest(data.id, method, params); | ||
| 440 | else handleNotification(method, params); | ||
| 441 | }, | ||
| 442 | setToolCall: (call) => { | ||
| 443 | toolCall ??= call; | ||
| 444 | sendToolCall(); | ||
| 445 | }, | ||
| 446 | updateHostContext: () => { | ||
| 447 | // The spec forbids messages before the app finishes initializing; it | ||
| 448 | // reads the then-current context from the initialize response anyway. | ||
| 449 | if (!initialized || sentContext === undefined) return; | ||
| 450 | const next = options.hostContext(); | ||
| 451 | const changes: Record<string, unknown> = {}; | ||
| 452 | for (const key of Object.keys(next) as Array<keyof McpAppHostContext>) { | ||
| 453 | if (JSON.stringify(next[key]) !== JSON.stringify(sentContext[key])) { | ||
| 454 | changes[key] = next[key]; | ||
| 455 | } | ||
| 456 | } | ||
| 457 | sentContext = next; | ||
| 458 | if (Object.keys(changes).length > 0) { | ||
| 459 | notify("ui/notifications/host-context-changed", changes); | ||
| 460 | } | ||
| 461 | }, | ||
| 462 | teardown: () => | ||
| 463 | new Promise<void>((resolve) => { | ||
| 464 | let timer: ReturnType<typeof setTimeout> | undefined; | ||
| 465 | const finish = () => { | ||
| 466 | clearTimeout(timer); | ||
| 467 | disposed = true; | ||
| 468 | resolve(); | ||
| 469 | }; | ||
| 470 | // Before initialization the app has nothing to save and may not be | ||
| 471 | // listening; the spec only asks for teardown after it. | ||
| 472 | if (disposed || !initialized) { | ||
| 473 | finish(); | ||
| 474 | return; | ||
| 475 | } | ||
| 476 | const id = `t3-host-${nextHostRequestId++}`; | ||
| 477 | hostRequests.set(id, finish); | ||
| 478 | timer = setTimeout(() => { | ||
| 479 | hostRequests.delete(id); | ||
| 480 | finish(); | ||
| 481 | }, TEARDOWN_TIMEOUT_MS); | ||
| 482 | post({ jsonrpc: "2.0", id, method: "ui/resource-teardown", params: {} }); | ||
| 483 | }), | ||
| 484 | dispose: () => { | ||
| 485 | disposed = true; | ||
| 486 | }, | ||
| 487 | }; | ||
| 488 | return hostHandle; | ||
| 489 | } | ||
| 490 | |||
| 491 | const MAX_DOWNLOAD_NAME = 200; | ||
| 492 | |||
| 493 | /** The file name an app gave a download, or the last part of its URI. */ | ||
| 494 | function downloadName(uri: unknown, name: unknown): string { | ||
| 495 | const raw = | ||
| 496 | typeof name === "string" && name.trim() !== "" | ||
| 497 | ? name | ||
| 498 | : typeof uri === "string" | ||
| 499 | ? (uri.split(/[/?#]/).findLast(Boolean) ?? "download") | ||
| 500 | : "download"; | ||
| 501 | // Path separators, reserved characters, and control characters. | ||
| 502 | const safe = Array.from(raw, (char) => | ||
| 503 | /[\\/:*?"<>|]/.test(char) || char.charCodeAt(0) < 32 ? "-" : char, | ||
| 504 | ).join(""); | ||
| 505 | return safe.slice(0, MAX_DOWNLOAD_NAME) || "download"; | ||
| 506 | } | ||
| 507 | |||
| 508 | /** `ui/download-file` contents: MCP embedded resources and resource links. */ | ||
| 509 | function readDownloads(contents: unknown): ReadonlyArray<McpAppDownload> | undefined { | ||
| 510 | if (!Array.isArray(contents) || contents.length === 0) return undefined; | ||
| 511 | const files: Array<McpAppDownload> = []; | ||
| 512 | for (const entry of contents) { | ||
| 513 | if (!Predicate.isObject(entry)) return undefined; | ||
| 514 | if (entry.type === "resource" && Predicate.isObject(entry.resource)) { | ||
| 515 | const resource = entry.resource; | ||
| 516 | const bytes = mcpResourceBytes(resource); | ||
| 517 | if (bytes === undefined) return undefined; | ||
| 518 | files.push({ | ||
| 519 | _tag: "embedded", | ||
| 520 | name: downloadName(resource.uri, resource.name), | ||
| 521 | mimeType: | ||
| 522 | typeof resource.mimeType === "string" ? resource.mimeType : "application/octet-stream", | ||
| 523 | bytes, | ||
| 524 | }); | ||
| 525 | } else if (entry.type === "resource_link" && typeof entry.uri === "string") { | ||
| 526 | files.push({ | ||
| 527 | _tag: "link", | ||
| 528 | name: downloadName(entry.uri, entry.name), | ||
| 529 | uri: entry.uri, | ||
| 530 | ...(typeof entry.mimeType === "string" ? { mimeType: entry.mimeType } : {}), | ||
| 531 | }); | ||
| 532 | } else { | ||
| 533 | return undefined; | ||
| 534 | } | ||
| 535 | } | ||
| 536 | return files; | ||
| 537 | } | ||
| 538 | |||
| 539 | /** | ||
| 540 | * Maps T3's resolved theme variables to the spec's standardized style names, so | ||
| 541 | * apps written for any MCP Apps host pick up the thread's colors and fonts. | ||
| 542 | */ | ||
| 543 | export function mcpAppStyleVariables( | ||
| 544 | theme: Readonly<Record<string, string>>, | ||
| 545 | ): Record<string, string> { | ||
| 546 | const pick = (name: string) => theme[name]; | ||
| 547 | const pairs: ReadonlyArray<readonly [string, string | undefined]> = [ | ||
| 548 | ["--color-background-primary", pick("--background")], | ||
| 549 | ["--color-background-secondary", pick("--card")], | ||
| 550 | ["--color-background-tertiary", pick("--muted")], | ||
| 551 | ["--color-background-inverse", pick("--foreground")], | ||
| 552 | ["--color-background-info", pick("--info")], | ||
| 553 | ["--color-background-danger", pick("--destructive-surface")], | ||
| 554 | ["--color-background-warning", pick("--warning-surface")], | ||
| 555 | ["--color-text-primary", pick("--foreground")], | ||
| 556 | ["--color-text-secondary", pick("--muted-foreground")], | ||
| 557 | ["--color-text-tertiary", pick("--muted-foreground")], | ||
| 558 | ["--color-text-inverse", pick("--background")], | ||
| 559 | ["--color-text-info", pick("--info-foreground")], | ||
| 560 | ["--color-text-danger", pick("--destructive-foreground")], | ||
| 561 | ["--color-text-success", pick("--success-foreground")], | ||
| 562 | ["--color-text-warning", pick("--warning-foreground")], | ||
| 563 | ["--color-border-primary", pick("--border")], | ||
| 564 | ["--color-border-secondary", pick("--input")], | ||
| 565 | ["--color-border-danger", pick("--destructive")], | ||
| 566 | ["--color-ring-primary", pick("--ring")], | ||
| 567 | ["--font-sans", pick("--font-sans")], | ||
| 568 | ["--font-mono", pick("--font-mono")], | ||
| 569 | ["--border-radius-md", pick("--radius")], | ||
| 570 | ]; | ||
| 571 | const variables: Record<string, string> = {}; | ||
| 572 | for (const [name, value] of pairs) { | ||
| 573 | if (value !== undefined && value !== "") variables[name] = value; | ||
| 574 | } | ||
| 575 | return variables; | ||
| 576 | } | ||
packages/client-runtime/src/state/mcpApps.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | import { WS_METHODS } from "@t3tools/contracts"; | ||
| 2 | import type { Atom } from "effect/reactivity"; | ||
| 3 | |||
| 4 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | ||
| 5 | import { createEnvironmentRpcCommand } from "./runtime.ts"; | ||
| 6 | |||
| 7 | /** | ||
| 8 | * RPC commands an MCP App host makes on an app's behalf. Each client | ||
| 9 | * instantiates them with its own connection runtime (`mcpAppEnvironment` in | ||
| 10 | * web and mobile). | ||
| 11 | */ | ||
| 12 | export function createMcpAppEnvironmentAtoms<R, E>( | ||
| 13 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | ||
| 14 | ) { | ||
| 15 | return { | ||
| 16 | callTool: createEnvironmentRpcCommand(runtime, { | ||
| 17 | label: "environment-command:mcp-apps:call-tool", | ||
| 18 | tag: WS_METHODS.mcpAppsCallTool, | ||
| 19 | }), | ||
| 20 | toolInfo: createEnvironmentRpcCommand(runtime, { | ||
| 21 | label: "environment-command:mcp-apps:tool-info", | ||
| 22 | tag: WS_METHODS.mcpAppsToolInfo, | ||
| 23 | }), | ||
| 24 | readResource: createEnvironmentRpcCommand(runtime, { | ||
| 25 | label: "environment-command:mcp-apps:read-resource", | ||
| 26 | tag: WS_METHODS.mcpAppsReadResource, | ||
| 27 | }), | ||
| 28 | updateModelContext: createEnvironmentRpcCommand(runtime, { | ||
| 29 | label: "environment-command:mcp-apps:update-model-context", | ||
| 30 | tag: WS_METHODS.mcpAppsUpdateModelContext, | ||
| 31 | }), | ||
| 32 | }; | ||
| 33 | } | ||
packages/contracts/src/index.ts+1| ... | @@ -49,6 +49,7 @@ export * from "./filesystem.ts"; | ... | @@ -49,6 +49,7 @@ export * from "./filesystem.ts"; |
| 49 | export * from "./agentSessions.ts"; | 49 | export * from "./agentSessions.ts"; |
| 50 | export * from "./assets.ts"; | 50 | export * from "./assets.ts"; |
| 51 | export * from "./review.ts"; | 51 | export * from "./review.ts"; |
| 52 | export * from "./mcpApps.ts"; | ||
| 52 | export * from "./browserImport.ts"; | 53 | export * from "./browserImport.ts"; |
| 53 | export * from "./browserProfile.ts"; | 54 | export * from "./browserProfile.ts"; |
| 54 | export * from "./device.ts"; | 55 | export * from "./device.ts"; |
packages/contracts/src/mcpApps.ts created+114| ... | @@ -0,0 +1,114 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | |||
| 3 | import { ThreadId, TrimmedNonEmptyString, TurnItemId } from "./baseSchemas.ts"; | ||
| 4 | |||
| 5 | /** | ||
| 6 | * Requests an MCP App makes of its own MCP server, through the environment. | ||
| 7 | * Each names the tool call that produced the app; the environment resolves the | ||
| 8 | * app's server from that item, so an app can only reach the server it came from. | ||
| 9 | */ | ||
| 10 | const McpAppTarget = { | ||
| 11 | threadId: ThreadId, | ||
| 12 | itemId: TurnItemId, | ||
| 13 | }; | ||
| 14 | |||
| 15 | export const McpAppCallToolInput = Schema.Struct({ | ||
| 16 | ...McpAppTarget, | ||
| 17 | name: TrimmedNonEmptyString, | ||
| 18 | arguments: Schema.Record(Schema.String, Schema.Unknown), | ||
| 19 | }); | ||
| 20 | export type McpAppCallToolInput = typeof McpAppCallToolInput.Type; | ||
| 21 | |||
| 22 | /** MCP `CallToolResult`, passed through to the app unchanged. */ | ||
| 23 | export const McpAppCallToolResult = Schema.Struct({ | ||
| 24 | content: Schema.Array(Schema.Unknown), | ||
| 25 | structuredContent: Schema.optional(Schema.Unknown), | ||
| 26 | isError: Schema.optional(Schema.Boolean), | ||
| 27 | _meta: Schema.optional(Schema.Unknown), | ||
| 28 | }); | ||
| 29 | export type McpAppCallToolResult = typeof McpAppCallToolResult.Type; | ||
| 30 | |||
| 31 | export const McpAppToolInfoInput = Schema.Struct({ | ||
| 32 | ...McpAppTarget, | ||
| 33 | name: TrimmedNonEmptyString, | ||
| 34 | }); | ||
| 35 | export type McpAppToolInfoInput = typeof McpAppToolInfoInput.Type; | ||
| 36 | |||
| 37 | /** | ||
| 38 | * What a client needs to decide whether to ask before a call: whether the tool | ||
| 39 | * exists for apps and whether its server declares it read-only. | ||
| 40 | */ | ||
| 41 | export const McpAppToolInfo = Schema.Struct({ | ||
| 42 | callable: Schema.Boolean, | ||
| 43 | readOnly: Schema.Boolean, | ||
| 44 | title: Schema.optional(Schema.String), | ||
| 45 | /** The server's MCP `Tool` definition, which the host passes to the app as `toolInfo`. */ | ||
| 46 | tool: Schema.optional(Schema.Unknown), | ||
| 47 | }); | ||
| 48 | export type McpAppToolInfo = typeof McpAppToolInfo.Type; | ||
| 49 | |||
| 50 | export const McpAppReadResourceInput = Schema.Struct({ | ||
| 51 | ...McpAppTarget, | ||
| 52 | uri: TrimmedNonEmptyString, | ||
| 53 | }); | ||
| 54 | export type McpAppReadResourceInput = typeof McpAppReadResourceInput.Type; | ||
| 55 | |||
| 56 | /** MCP `ReadResourceResult`, passed through to the app unchanged. */ | ||
| 57 | export const McpAppReadResourceResult = Schema.Struct({ | ||
| 58 | contents: Schema.Array(Schema.Unknown), | ||
| 59 | }); | ||
| 60 | export type McpAppReadResourceResult = typeof McpAppReadResourceResult.Type; | ||
| 61 | |||
| 62 | /** | ||
| 63 | * What an app wants the agent to know on its next turn (`ui/update-model-context`). | ||
| 64 | * Each update replaces the app's previous one; no content clears it. | ||
| 65 | */ | ||
| 66 | export const McpAppUpdateModelContextInput = Schema.Struct({ | ||
| 67 | ...McpAppTarget, | ||
| 68 | /** | ||
| 69 | * The thread whose next turn gets the context: the one on screen, which is | ||
| 70 | * the app's own thread or a fork that shows it. | ||
| 71 | */ | ||
| 72 | conversationThreadId: ThreadId, | ||
| 73 | content: Schema.optional(Schema.Array(Schema.Unknown)), | ||
| 74 | structuredContent: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), | ||
| 75 | }); | ||
| 76 | export type McpAppUpdateModelContextInput = typeof McpAppUpdateModelContextInput.Type; | ||
| 77 | |||
| 78 | export const McpAppRequestErrorReason = Schema.Literals([ | ||
| 79 | "not-an-app", | ||
| 80 | "provider-unsupported", | ||
| 81 | "session-stopped", | ||
| 82 | "tool-not-callable", | ||
| 83 | "unsupported-content", | ||
| 84 | "request-failed", | ||
| 85 | ]); | ||
| 86 | export type McpAppRequestErrorReason = typeof McpAppRequestErrorReason.Type; | ||
| 87 | |||
| 88 | export class McpAppRequestError extends Schema.TaggedError<McpAppRequestError>()( | ||
| 89 | "McpAppRequestError", | ||
| 90 | { | ||
| 91 | threadId: ThreadId, | ||
| 92 | reason: McpAppRequestErrorReason, | ||
| 93 | cause: Schema.optional(Schema.Defect()), | ||
| 94 | }, | ||
| 95 | ) { | ||
| 96 | override get message(): string { | ||
| 97 | switch (this.reason) { | ||
| 98 | case "not-an-app": | ||
| 99 | return "This tool call has no MCP app."; | ||
| 100 | case "provider-unsupported": | ||
| 101 | return "This thread's provider cannot run MCP app requests."; | ||
| 102 | case "session-stopped": | ||
| 103 | // A forked thread shows its source's apps, so this names the thread | ||
| 104 | // that made the app rather than the one on screen. | ||
| 105 | return "The app's thread is not running. Send a message in the thread that created it to use the app again."; | ||
| 106 | case "tool-not-callable": | ||
| 107 | return "This app cannot call that tool."; | ||
| 108 | case "unsupported-content": | ||
| 109 | return "Only text and structured content are supported."; | ||
| 110 | case "request-failed": | ||
| 111 | return "The app's MCP server request failed."; | ||
| 112 | } | ||
| 113 | } | ||
| 114 | } | ||
packages/contracts/src/rpc.ts+43| ... | @@ -1,4 +1,14 @@ | ... | @@ -1,4 +1,14 @@ |
| 1 | import { OrchestrationDispatchCommandError } from "./orchestrationDispatch.ts"; | 1 | import { OrchestrationDispatchCommandError } from "./orchestrationDispatch.ts"; |
| 2 | import { | ||
| 3 | McpAppCallToolInput, | ||
| 4 | McpAppCallToolResult, | ||
| 5 | McpAppReadResourceInput, | ||
| 6 | McpAppReadResourceResult, | ||
| 7 | McpAppRequestError, | ||
| 8 | McpAppToolInfo, | ||
| 9 | McpAppToolInfoInput, | ||
| 10 | McpAppUpdateModelContextInput, | ||
| 11 | } from "./mcpApps.ts"; | ||
| 2 | import { | 12 | import { |
| 3 | ChatGptReconnectProfileInput, | 13 | ChatGptReconnectProfileInput, |
| 4 | ChatGptReconnectProfile, | 14 | ChatGptReconnectProfile, |
| ... | @@ -367,6 +377,12 @@ export const WS_METHODS = { | ... | @@ -367,6 +377,12 @@ export const WS_METHODS = { |
| 367 | attachmentsCreateUploadUrl: "attachments.createUploadUrl", | 377 | attachmentsCreateUploadUrl: "attachments.createUploadUrl", |
| 368 | attachmentsDelete: "attachments.delete", | 378 | attachmentsDelete: "attachments.delete", |
| 369 | 379 | ||
| 380 | // MCP Apps methods | ||
| 381 | mcpAppsCallTool: "mcpApps.callTool", | ||
| 382 | mcpAppsToolInfo: "mcpApps.toolInfo", | ||
| 383 | mcpAppsReadResource: "mcpApps.readResource", | ||
| 384 | mcpAppsUpdateModelContext: "mcpApps.updateModelContext", | ||
| 385 | |||
| 370 | // Provider methods | 386 | // Provider methods |
| 371 | providerUploadFeedback: "provider.uploadFeedback", | 387 | providerUploadFeedback: "provider.uploadFeedback", |
| 372 | providerAuthStart: "provider.auth.start", | 388 | providerAuthStart: "provider.auth.start", |
| ... | @@ -1232,6 +1248,29 @@ const WsAttachmentsDeleteRpc = Rpc.make(WS_METHODS.attachmentsDelete, { | ... | @@ -1232,6 +1248,29 @@ const WsAttachmentsDeleteRpc = Rpc.make(WS_METHODS.attachmentsDelete, { |
| 1232 | error: EnvironmentAuthorizationError, | 1248 | error: EnvironmentAuthorizationError, |
| 1233 | }); | 1249 | }); |
| 1234 | 1250 | ||
| 1251 | const WsMcpAppsCallToolRpc = Rpc.make(WS_METHODS.mcpAppsCallTool, { | ||
| 1252 | payload: McpAppCallToolInput, | ||
| 1253 | success: McpAppCallToolResult, | ||
| 1254 | error: Schema.Union([McpAppRequestError, EnvironmentAuthorizationError]), | ||
| 1255 | }); | ||
| 1256 | |||
| 1257 | const WsMcpAppsToolInfoRpc = Rpc.make(WS_METHODS.mcpAppsToolInfo, { | ||
| 1258 | payload: McpAppToolInfoInput, | ||
| 1259 | success: McpAppToolInfo, | ||
| 1260 | error: Schema.Union([McpAppRequestError, EnvironmentAuthorizationError]), | ||
| 1261 | }); | ||
| 1262 | |||
| 1263 | const WsMcpAppsUpdateModelContextRpc = Rpc.make(WS_METHODS.mcpAppsUpdateModelContext, { | ||
| 1264 | payload: McpAppUpdateModelContextInput, | ||
| 1265 | error: Schema.Union([McpAppRequestError, EnvironmentAuthorizationError]), | ||
| 1266 | }); | ||
| 1267 | |||
| 1268 | const WsMcpAppsReadResourceRpc = Rpc.make(WS_METHODS.mcpAppsReadResource, { | ||
| 1269 | payload: McpAppReadResourceInput, | ||
| 1270 | success: McpAppReadResourceResult, | ||
| 1271 | error: Schema.Union([McpAppRequestError, EnvironmentAuthorizationError]), | ||
| 1272 | }); | ||
| 1273 | |||
| 1235 | const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, { | 1274 | const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, { |
| 1236 | payload: ProviderUploadFeedbackInput, | 1275 | payload: ProviderUploadFeedbackInput, |
| 1237 | success: ProviderUploadFeedbackResult, | 1276 | success: ProviderUploadFeedbackResult, |
| ... | @@ -1856,6 +1895,10 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -1856,6 +1895,10 @@ export const WsRpcGroup = RpcGroup.make( |
| 1856 | WsAssetsPersistChatAttachmentsRpc, | 1895 | WsAssetsPersistChatAttachmentsRpc, |
| 1857 | WsAttachmentsCreateUploadUrlRpc, | 1896 | WsAttachmentsCreateUploadUrlRpc, |
| 1858 | WsAttachmentsDeleteRpc, | 1897 | WsAttachmentsDeleteRpc, |
| 1898 | WsMcpAppsCallToolRpc, | ||
| 1899 | WsMcpAppsToolInfoRpc, | ||
| 1900 | WsMcpAppsReadResourceRpc, | ||
| 1901 | WsMcpAppsUpdateModelContextRpc, | ||
| 1859 | WsProviderUploadFeedbackRpc, | 1902 | WsProviderUploadFeedbackRpc, |
| 1860 | WsSubscribeVcsStatusRpc, | 1903 | WsSubscribeVcsStatusRpc, |
| 1861 | WsSubscribeWorktreeSetupRpc, | 1904 | WsSubscribeWorktreeSetupRpc, |
packages/shared/package.json+4| ... | @@ -402,6 +402,10 @@ | ... | @@ -402,6 +402,10 @@ |
| 402 | "./codexAuthHandoff": { | 402 | "./codexAuthHandoff": { |
| 403 | "types": "./src/codexAuthHandoff.ts", | 403 | "types": "./src/codexAuthHandoff.ts", |
| 404 | "import": "./src/codexAuthHandoff.ts" | 404 | "import": "./src/codexAuthHandoff.ts" |
| 405 | }, | ||
| 406 | "./mcpApp": { | ||
| 407 | "types": "./src/mcpApp.ts", | ||
| 408 | "import": "./src/mcpApp.ts" | ||
| 405 | } | 409 | } |
| 406 | }, | 410 | }, |
| 407 | "scripts": { | 411 | "scripts": { |
packages/shared/src/mcpApp.test.ts created+150| ... | @@ -0,0 +1,150 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | injectMcpAppCsp, | ||
| 5 | mcpAppContentSecurityPolicy, | ||
| 6 | mcpAppToolCallableByApp, | ||
| 7 | readMcpAppCsp, | ||
| 8 | readMcpAppReference, | ||
| 9 | readMcpAppResourceUri, | ||
| 10 | } from "./mcpApp.ts"; | ||
| 11 | |||
| 12 | describe("mcpAppContentSecurityPolicy", () => { | ||
| 13 | it("blocks the network and T3's own origin when the app declares nothing", () => { | ||
| 14 | const policy = mcpAppContentSecurityPolicy(undefined); | ||
| 15 | expect(policy).toContain("default-src 'none'"); | ||
| 16 | expect(policy).toContain("connect-src 'none'"); | ||
| 17 | expect(policy).toContain("frame-src 'none'"); | ||
| 18 | expect(policy).not.toContain("'self'"); | ||
| 19 | }); | ||
| 20 | |||
| 21 | it("allows exactly the declared origins", () => { | ||
| 22 | const policy = mcpAppContentSecurityPolicy({ | ||
| 23 | connectDomains: ["https://api.weather.test"], | ||
| 24 | resourceDomains: ["https://*.cdn.test"], | ||
| 25 | }); | ||
| 26 | expect(policy).toContain("connect-src https://api.weather.test"); | ||
| 27 | expect(policy).toContain("script-src 'unsafe-inline' https://*.cdn.test"); | ||
| 28 | expect(policy).toContain("img-src data: blob: https://*.cdn.test"); | ||
| 29 | }); | ||
| 30 | }); | ||
| 31 | |||
| 32 | describe("injectMcpAppCsp", () => { | ||
| 33 | it("puts the policy after the doctype and ahead of every script", () => { | ||
| 34 | const html = "<!DOCTYPE html><html><head><script>x()</script></head></html>"; | ||
| 35 | const injected = injectMcpAppCsp(html, undefined); | ||
| 36 | expect(injected.startsWith('<!DOCTYPE html><meta http-equiv="Content-Security-Policy"')).toBe( | ||
| 37 | true, | ||
| 38 | ); | ||
| 39 | expect(injected.indexOf("connect-src 'none'")).toBeLessThan(injected.indexOf("<script>")); | ||
| 40 | expect(injectMcpAppCsp("<p>hi</p>", undefined).startsWith("<!doctype html><meta")).toBe(true); | ||
| 41 | }); | ||
| 42 | }); | ||
| 43 | |||
| 44 | describe("readMcpAppCsp", () => { | ||
| 45 | it("drops entries that could widen or break the policy", () => { | ||
| 46 | expect( | ||
| 47 | readMcpAppCsp({ | ||
| 48 | connectDomains: [ | ||
| 49 | "https://ok.test", | ||
| 50 | "*", | ||
| 51 | "'self'", | ||
| 52 | "https://evil.test; script-src *", | ||
| 53 | "http://localhost:3000", | ||
| 54 | "javascript:alert(1)", | ||
| 55 | ], | ||
| 56 | resourceDomains: "https://not-an-array.test", | ||
| 57 | }), | ||
| 58 | ).toEqual({ connectDomains: ["https://ok.test", "http://localhost:3000"] }); | ||
| 59 | expect(readMcpAppCsp({ connectDomains: ["*"] })).toBeUndefined(); | ||
| 60 | }); | ||
| 61 | }); | ||
| 62 | |||
| 63 | describe("readMcpAppReference", () => { | ||
| 64 | it("requires a ui:// resource and the owning server and tool", () => { | ||
| 65 | const reference = { | ||
| 66 | attachmentId: "thread-1-abc-html", | ||
| 67 | server: "weather", | ||
| 68 | tool: "get_weather", | ||
| 69 | resourceUri: "ui://weather/dashboard", | ||
| 70 | }; | ||
| 71 | expect(readMcpAppReference(reference)).toEqual(reference); | ||
| 72 | expect(readMcpAppReference({ ...reference, resourceUri: "https://x.test" })).toBeUndefined(); | ||
| 73 | expect(readMcpAppReference({ ...reference, server: "" })).toBeUndefined(); | ||
| 74 | }); | ||
| 75 | }); | ||
| 76 | |||
| 77 | describe("tool metadata", () => { | ||
| 78 | it("reads the resource URI from either spelling and honors app visibility", () => { | ||
| 79 | expect(readMcpAppResourceUri({ ui: { resourceUri: "ui://a/b" } })).toBe("ui://a/b"); | ||
| 80 | expect(readMcpAppResourceUri({ "ui/resourceUri": "ui://a/c" })).toBe("ui://a/c"); | ||
| 81 | expect(readMcpAppResourceUri({ ui: { resourceUri: "https://a" } })).toBeUndefined(); | ||
| 82 | expect(mcpAppToolCallableByApp(undefined)).toBe(true); | ||
| 83 | expect(mcpAppToolCallableByApp({ ui: { visibility: ["model"] } })).toBe(false); | ||
| 84 | expect(mcpAppToolCallableByApp({ ui: { visibility: ["model", "app"] } })).toBe(true); | ||
| 85 | }); | ||
| 86 | }); | ||
| 87 | |||
| 88 | describe("mcpAppFromToolItem", () => { | ||
| 89 | it("reads the app from both the stored output and its compact wire form", async () => { | ||
| 90 | const { compactDynamicToolOutput, mcpAppFromToolItem } = await import("./toolOutput.ts"); | ||
| 91 | const app = { | ||
| 92 | attachmentId: "thread-1-abc-html", | ||
| 93 | server: "weather", | ||
| 94 | tool: "get_weather", | ||
| 95 | resourceUri: "ui://weather/dashboard", | ||
| 96 | }; | ||
| 97 | const stored = { t3McpApp: app, result: { content: [] } }; | ||
| 98 | const toolName = "weather.get_weather"; | ||
| 99 | expect(mcpAppFromToolItem({ toolName, output: stored })).toEqual(app); | ||
| 100 | expect(mcpAppFromToolItem({ toolName, output: compactDynamicToolOutput(stored) })).toEqual(app); | ||
| 101 | }); | ||
| 102 | |||
| 103 | it("ignores a reference naming a server or tool other than the item's own", async () => { | ||
| 104 | const { mcpAppFromToolItem } = await import("./toolOutput.ts"); | ||
| 105 | // A tool result shaped like an app reference, claiming another server. | ||
| 106 | const forged = { | ||
| 107 | t3McpApp: { | ||
| 108 | attachmentId: "thread-1-abc-html", | ||
| 109 | server: "bank", | ||
| 110 | tool: "transfer", | ||
| 111 | resourceUri: "ui://bank/app", | ||
| 112 | }, | ||
| 113 | }; | ||
| 114 | expect(mcpAppFromToolItem({ toolName: "evil.lookup", output: forged })).toBeUndefined(); | ||
| 115 | expect(mcpAppFromToolItem({ toolName: "bank.transfer", output: forged })).toBeDefined(); | ||
| 116 | }); | ||
| 117 | |||
| 118 | it("keeps an app whose resource URI is longer than a name", async () => { | ||
| 119 | const { compactDynamicToolOutput } = await import("./toolOutput.ts"); | ||
| 120 | const resourceUri = `ui://weather/${"segment/".repeat(60)}dashboard`; | ||
| 121 | const output = compactDynamicToolOutput({ | ||
| 122 | t3McpApp: { | ||
| 123 | attachmentId: "thread-1-abc-html", | ||
| 124 | server: "weather", | ||
| 125 | tool: "get_weather", | ||
| 126 | resourceUri, | ||
| 127 | }, | ||
| 128 | }); | ||
| 129 | expect(output?.t3McpApp?.resourceUri).toBe(resourceUri); | ||
| 130 | }); | ||
| 131 | |||
| 132 | it("keeps the compact app reference within the wire budget", async () => { | ||
| 133 | const { compactDynamicToolOutput } = await import("./toolOutput.ts"); | ||
| 134 | const domains = Array.from( | ||
| 135 | { length: 32 }, | ||
| 136 | (_, index) => `https://${"a".repeat(200)}${index}.test`, | ||
| 137 | ); | ||
| 138 | const output = compactDynamicToolOutput({ | ||
| 139 | t3McpApp: { | ||
| 140 | attachmentId: "thread-1-abc-html", | ||
| 141 | server: "weather", | ||
| 142 | tool: "get_weather", | ||
| 143 | resourceUri: "ui://weather/dashboard", | ||
| 144 | csp: { connectDomains: domains, resourceDomains: domains }, | ||
| 145 | }, | ||
| 146 | }); | ||
| 147 | expect(new TextEncoder().encode(JSON.stringify(output)).byteLength).toBeLessThanOrEqual(8_192); | ||
| 148 | expect(output?.t3McpApp?.server).toBe("weather"); | ||
| 149 | }); | ||
| 150 | }); | ||
packages/shared/src/mcpApp.ts created+236| ... | @@ -0,0 +1,236 @@ | ||
| 1 | import * as Predicate from "effect/Predicate"; | ||
| 2 | |||
| 3 | /** | ||
| 4 | * MCP Apps (https://github.com/modelcontextprotocol/ext-apps, spec 2026-01-26): | ||
| 5 | * an MCP tool can name a `ui://` HTML resource that hosts render as an | ||
| 6 | * interactive view of its result. T3 snapshots the resource when the tool call | ||
| 7 | * completes, stores it as a thread attachment, and records an `McpAppReference` | ||
| 8 | * in the tool item's output; clients host it in an opaque-origin frame and | ||
| 9 | * speak the spec's JSON-RPC bridge with it. | ||
| 10 | */ | ||
| 11 | |||
| 12 | export const MCP_APP_PROTOCOL_VERSION = "2026-01-26"; | ||
| 13 | export const MCP_APP_MIME_TYPE = "text/html;profile=mcp-app"; | ||
| 14 | /** The MCP extension id a client declares to receive UI resources. */ | ||
| 15 | export const MCP_APP_EXTENSION_ID = "io.modelcontextprotocol/ui"; | ||
| 16 | export const MCP_APP_RESOURCE_SCHEME = "ui://"; | ||
| 17 | /** Where T3 records the app reference inside a tool item's output object. */ | ||
| 18 | export const MCP_APP_OUTPUT_KEY = "t3McpApp"; | ||
| 19 | |||
| 20 | const MCP_APP_MIN_HEIGHT = 80; | ||
| 21 | export const MCP_APP_DEFAULT_HEIGHT = 320; | ||
| 22 | export const MCP_APP_MAX_HEIGHT = 2000; | ||
| 23 | /** Largest app document T3 stores. */ | ||
| 24 | export const MCP_APP_MAX_HTML_BYTES = 5 * 1024 * 1024; | ||
| 25 | const MAX_DOMAINS = 32; | ||
| 26 | const MAX_DOMAIN_LENGTH = 256; | ||
| 27 | const MAX_NAME_LENGTH = 256; | ||
| 28 | // A `ui://` URI can carry a path and query; the compact wire budget still | ||
| 29 | // bounds the whole reference. | ||
| 30 | const MAX_RESOURCE_URI_LENGTH = 4096; | ||
| 31 | |||
| 32 | /** `_meta.ui.csp` on a UI resource: origins the app needs, by kind. */ | ||
| 33 | export interface McpAppCsp { | ||
| 34 | readonly connectDomains?: ReadonlyArray<string>; | ||
| 35 | readonly resourceDomains?: ReadonlyArray<string>; | ||
| 36 | readonly frameDomains?: ReadonlyArray<string>; | ||
| 37 | readonly baseUriDomains?: ReadonlyArray<string>; | ||
| 38 | } | ||
| 39 | |||
| 40 | /** `_meta.ui.permissions` on a UI resource. */ | ||
| 41 | export interface McpAppPermissions { | ||
| 42 | readonly camera?: Record<string, never>; | ||
| 43 | readonly microphone?: Record<string, never>; | ||
| 44 | readonly geolocation?: Record<string, never>; | ||
| 45 | readonly clipboardWrite?: Record<string, never>; | ||
| 46 | } | ||
| 47 | |||
| 48 | /** What a tool item's output carries so clients can host the app. */ | ||
| 49 | export interface McpAppReference { | ||
| 50 | readonly attachmentId: string; | ||
| 51 | /** The MCP server the tool belongs to, as the provider names it. */ | ||
| 52 | readonly server: string; | ||
| 53 | readonly tool: string; | ||
| 54 | readonly resourceUri: string; | ||
| 55 | readonly csp?: McpAppCsp; | ||
| 56 | readonly permissions?: McpAppPermissions; | ||
| 57 | readonly prefersBorder?: boolean; | ||
| 58 | } | ||
| 59 | |||
| 60 | // Only scheme-qualified origins, optionally with a leading `*.` subdomain | ||
| 61 | // wildcard, survive. Anything else (keywords, paths, quotes, whitespace) could | ||
| 62 | // widen or break the generated policy, so it is dropped. | ||
| 63 | const DOMAIN_PATTERN = /^(?:https?|wss?):\/\/(?:\*\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)*(?::\d{1,5})?$/i; | ||
| 64 | |||
| 65 | function readDomains(value: unknown): ReadonlyArray<string> | undefined { | ||
| 66 | if (!Array.isArray(value)) return undefined; | ||
| 67 | const domains = value | ||
| 68 | .filter( | ||
| 69 | (domain): domain is string => | ||
| 70 | typeof domain === "string" && | ||
| 71 | domain.length <= MAX_DOMAIN_LENGTH && | ||
| 72 | DOMAIN_PATTERN.test(domain), | ||
| 73 | ) | ||
| 74 | .slice(0, MAX_DOMAINS); | ||
| 75 | return domains.length === 0 ? undefined : domains; | ||
| 76 | } | ||
| 77 | |||
| 78 | /** The well-formed part of a resource's declared `_meta.ui.csp`. */ | ||
| 79 | export function readMcpAppCsp(value: unknown): McpAppCsp | undefined { | ||
| 80 | if (!Predicate.isObject(value)) return undefined; | ||
| 81 | const csp: { | ||
| 82 | connectDomains?: ReadonlyArray<string>; | ||
| 83 | resourceDomains?: ReadonlyArray<string>; | ||
| 84 | frameDomains?: ReadonlyArray<string>; | ||
| 85 | baseUriDomains?: ReadonlyArray<string>; | ||
| 86 | } = {}; | ||
| 87 | for (const key of [ | ||
| 88 | "connectDomains", | ||
| 89 | "resourceDomains", | ||
| 90 | "frameDomains", | ||
| 91 | "baseUriDomains", | ||
| 92 | ] as const) { | ||
| 93 | const domains = readDomains(value[key]); | ||
| 94 | if (domains !== undefined) csp[key] = domains; | ||
| 95 | } | ||
| 96 | return Object.keys(csp).length === 0 ? undefined : csp; | ||
| 97 | } | ||
| 98 | |||
| 99 | const PERMISSION_KEYS = ["camera", "microphone", "geolocation", "clipboardWrite"] as const; | ||
| 100 | |||
| 101 | export function readMcpAppPermissions(value: unknown): McpAppPermissions | undefined { | ||
| 102 | if (!Predicate.isObject(value)) return undefined; | ||
| 103 | const permissions: Partial<Record<(typeof PERMISSION_KEYS)[number], Record<string, never>>> = {}; | ||
| 104 | for (const key of PERMISSION_KEYS) { | ||
| 105 | if (Predicate.isObject(value[key])) permissions[key] = {}; | ||
| 106 | } | ||
| 107 | return Object.keys(permissions).length === 0 ? undefined : permissions; | ||
| 108 | } | ||
| 109 | |||
| 110 | const boundedName = (value: unknown) => | ||
| 111 | typeof value === "string" && value.trim() !== "" && value.length <= MAX_NAME_LENGTH | ||
| 112 | ? value | ||
| 113 | : undefined; | ||
| 114 | |||
| 115 | export function readMcpAppReference(value: unknown): McpAppReference | undefined { | ||
| 116 | if (!Predicate.isObject(value)) return undefined; | ||
| 117 | const attachmentId = boundedName(value.attachmentId); | ||
| 118 | const server = boundedName(value.server); | ||
| 119 | const tool = boundedName(value.tool); | ||
| 120 | const resourceUri = | ||
| 121 | typeof value.resourceUri === "string" && value.resourceUri.length <= MAX_RESOURCE_URI_LENGTH | ||
| 122 | ? value.resourceUri | ||
| 123 | : undefined; | ||
| 124 | if ( | ||
| 125 | attachmentId === undefined || | ||
| 126 | server === undefined || | ||
| 127 | tool === undefined || | ||
| 128 | resourceUri === undefined || | ||
| 129 | !resourceUri.startsWith(MCP_APP_RESOURCE_SCHEME) | ||
| 130 | ) { | ||
| 131 | return undefined; | ||
| 132 | } | ||
| 133 | const csp = readMcpAppCsp(value.csp); | ||
| 134 | const permissions = readMcpAppPermissions(value.permissions); | ||
| 135 | return { | ||
| 136 | attachmentId, | ||
| 137 | server, | ||
| 138 | tool, | ||
| 139 | resourceUri, | ||
| 140 | ...(csp === undefined ? {} : { csp }), | ||
| 141 | ...(permissions === undefined ? {} : { permissions }), | ||
| 142 | ...(typeof value.prefersBorder === "boolean" ? { prefersBorder: value.prefersBorder } : {}), | ||
| 143 | }; | ||
| 144 | } | ||
| 145 | |||
| 146 | export function mcpAppReferencesEqual(left: McpAppReference, right: McpAppReference): boolean { | ||
| 147 | return ( | ||
| 148 | left.attachmentId === right.attachmentId && | ||
| 149 | left.server === right.server && | ||
| 150 | left.tool === right.tool && | ||
| 151 | left.resourceUri === right.resourceUri | ||
| 152 | ); | ||
| 153 | } | ||
| 154 | |||
| 155 | /** The tool's `_meta.ui.resourceUri` (or the deprecated flat key), if it names a UI resource. */ | ||
| 156 | export function readMcpAppResourceUri(meta: unknown): string | undefined { | ||
| 157 | if (!Predicate.isObject(meta)) return undefined; | ||
| 158 | const nested = Predicate.isObject(meta.ui) ? meta.ui.resourceUri : undefined; | ||
| 159 | const uri = nested ?? meta["ui/resourceUri"]; | ||
| 160 | return typeof uri === "string" && uri.startsWith(MCP_APP_RESOURCE_SCHEME) ? uri : undefined; | ||
| 161 | } | ||
| 162 | |||
| 163 | /** | ||
| 164 | * Whether the tool's `_meta.ui.visibility` lets an app call it. The spec | ||
| 165 | * defaults to visible to both the model and apps. | ||
| 166 | */ | ||
| 167 | export function mcpAppToolCallableByApp(meta: unknown): boolean { | ||
| 168 | const visibility = | ||
| 169 | Predicate.isObject(meta) && Predicate.isObject(meta.ui) ? meta.ui.visibility : undefined; | ||
| 170 | return !Array.isArray(visibility) || visibility.includes("app"); | ||
| 171 | } | ||
| 172 | |||
| 173 | /** | ||
| 174 | * The Content-Security-Policy for an app document, per the spec's | ||
| 175 | * construction: undeclared origins stay blocked, and nothing loads from the | ||
| 176 | * host's own origin ('self' would reach T3's API). | ||
| 177 | */ | ||
| 178 | export function mcpAppContentSecurityPolicy(csp: McpAppCsp | undefined): string { | ||
| 179 | const resources = csp?.resourceDomains?.join(" ") ?? ""; | ||
| 180 | const list = (domains: ReadonlyArray<string> | undefined, fallback: string) => | ||
| 181 | domains === undefined || domains.length === 0 ? fallback : domains.join(" "); | ||
| 182 | return [ | ||
| 183 | "default-src 'none'", | ||
| 184 | `script-src 'unsafe-inline' ${resources}`.trim(), | ||
| 185 | `style-src 'unsafe-inline' ${resources}`.trim(), | ||
| 186 | `img-src data: blob: ${resources}`.trim(), | ||
| 187 | `font-src data: ${resources}`.trim(), | ||
| 188 | `media-src data: blob: ${resources}`.trim(), | ||
| 189 | `connect-src ${list(csp?.connectDomains, "'none'")}`, | ||
| 190 | `frame-src ${list(csp?.frameDomains, "'none'")}`, | ||
| 191 | "object-src 'none'", | ||
| 192 | `base-uri ${list(csp?.baseUriDomains, "'none'")}`, | ||
| 193 | "form-action 'none'", | ||
| 194 | ].join("; "); | ||
| 195 | } | ||
| 196 | |||
| 197 | const escapeAttribute = (value: string) => | ||
| 198 | value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;"); | ||
| 199 | |||
| 200 | /** | ||
| 201 | * Puts the app's policy at the top of its document as a CSP meta tag, ahead of | ||
| 202 | * any script. The asset route's `sandbox` header already makes the origin | ||
| 203 | * opaque; browsers enforce both policies, so the document can only tighten | ||
| 204 | * this one, never widen it. | ||
| 205 | */ | ||
| 206 | export function injectMcpAppCsp(html: string, csp: McpAppCsp | undefined): string { | ||
| 207 | const meta = `<meta http-equiv="Content-Security-Policy" content="${escapeAttribute( | ||
| 208 | mcpAppContentSecurityPolicy(csp), | ||
| 209 | )}">`; | ||
| 210 | // After a leading doctype, so the document stays in standards mode; the | ||
| 211 | // parser moves the tag into the head it opens. | ||
| 212 | const doctype = /^\uFEFF?\s*<!doctype[^>]*>/i.exec(html); | ||
| 213 | return doctype | ||
| 214 | ? `${doctype[0]}${meta}${html.slice(doctype[0].length)}` | ||
| 215 | : `<!doctype html>${meta}${html}`; | ||
| 216 | } | ||
| 217 | |||
| 218 | /** The iframe `allow` attribute for the permissions an app declared. */ | ||
| 219 | export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string { | ||
| 220 | const features: Array<string> = []; | ||
| 221 | if (permissions?.camera) features.push("camera"); | ||
| 222 | if (permissions?.microphone) features.push("microphone"); | ||
| 223 | if (permissions?.geolocation) features.push("geolocation"); | ||
| 224 | if (permissions?.clipboardWrite) features.push("clipboard-write"); | ||
| 225 | return features.join("; "); | ||
| 226 | } | ||
| 227 | |||
| 228 | export function clampMcpAppHeight(height: number): number { | ||
| 229 | if (!Number.isFinite(height)) return MCP_APP_DEFAULT_HEIGHT; | ||
| 230 | return Math.round(Math.min(MCP_APP_MAX_HEIGHT, Math.max(MCP_APP_MIN_HEIGHT, height))); | ||
| 231 | } | ||
| 232 | |||
| 233 | export function mcpAppFileName(reference: McpAppReference): string { | ||
| 234 | const name = reference.tool.replace(/[^\w.-]+/g, "-").slice(0, 80) || "app"; | ||
| 235 | return `${name}.html`; | ||
| 236 | } | ||
packages/shared/src/toolOutput.ts+31-1| ... | @@ -9,6 +9,7 @@ import { | ... | @@ -9,6 +9,7 @@ import { |
| 9 | readHtmlRenderReference, | 9 | readHtmlRenderReference, |
| 10 | type HtmlRenderReference, | 10 | type HtmlRenderReference, |
| 11 | } from "./htmlRender.ts"; | 11 | } from "./htmlRender.ts"; |
| 12 | import { MCP_APP_OUTPUT_KEY, readMcpAppReference, type McpAppReference } from "./mcpApp.ts"; | ||
| 12 | import { resolveT3McpToolId } from "./t3McpToolPresentation.ts"; | 13 | import { resolveT3McpToolId } from "./t3McpToolPresentation.ts"; |
| 13 | 14 | ||
| 14 | const MAX_PARSED_BYTES = 16_384; | 15 | const MAX_PARSED_BYTES = 16_384; |
| ... | @@ -39,6 +40,7 @@ interface CompactToolOutput { | ... | @@ -39,6 +40,7 @@ interface CompactToolOutput { |
| 39 | scheduledTaskId?: string; | 40 | scheduledTaskId?: string; |
| 40 | status?: "rolled_back"; | 41 | status?: "rolled_back"; |
| 41 | htmlRender?: HtmlRenderReference; | 42 | htmlRender?: HtmlRenderReference; |
| 43 | [MCP_APP_OUTPUT_KEY]?: McpAppReference; | ||
| 42 | thread?: { threadId: string }; | 44 | thread?: { threadId: string }; |
| 43 | threads?: Array<{ threadId?: string; status?: "rolled_back" }>; | 45 | threads?: Array<{ threadId?: string; status?: "rolled_back" }>; |
| 44 | } | 46 | } |
| ... | @@ -124,6 +126,9 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un | ... | @@ -124,6 +126,9 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un |
| 124 | if (data.status === "rolled_back") output.status = "rolled_back"; | 126 | if (data.status === "rolled_back") output.status = "rolled_back"; |
| 125 | const htmlRender = readHtmlRenderReference(data.htmlRender); | 127 | const htmlRender = readHtmlRenderReference(data.htmlRender); |
| 126 | if (htmlRender !== undefined) output.htmlRender = htmlRender; | 128 | if (htmlRender !== undefined) output.htmlRender = htmlRender; |
| 129 | const mcpApp = readMcpAppReference(data[MCP_APP_OUTPUT_KEY]); | ||
| 130 | // Kept under its stored key, so the compact wire output reads back the same way. | ||
| 131 | if (mcpApp !== undefined) output[MCP_APP_OUTPUT_KEY] = mcpApp; | ||
| 127 | const nestedThreadId = Predicate.isObject(data.thread) | 132 | const nestedThreadId = Predicate.isObject(data.thread) |
| 128 | ? boundedId(data.thread.threadId) | 133 | ? boundedId(data.thread.threadId) |
| 129 | : undefined; | 134 | : undefined; |
| ... | @@ -159,11 +164,21 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un | ... | @@ -159,11 +164,21 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un |
| 159 | } | 164 | } |
| 160 | } | 165 | } |
| 161 | } | 166 | } |
| 162 | if (encoder.encode(JSON.stringify(output)).byteLength > MAX_METADATA_BYTES) { | 167 | const oversized = () => encoder.encode(JSON.stringify(output)).byteLength > MAX_METADATA_BYTES; |
| 168 | if (oversized()) { | ||
| 163 | delete output.threads; | 169 | delete output.threads; |
| 164 | delete output.threadId; | 170 | delete output.threadId; |
| 165 | delete output.status; | 171 | delete output.status; |
| 166 | } | 172 | } |
| 173 | // An app's declared origins are the only unbounded-ish part of its | ||
| 174 | // reference; hosting works without them (the stored document carries its | ||
| 175 | // own policy), so they go before the app does. | ||
| 176 | const app = output[MCP_APP_OUTPUT_KEY]; | ||
| 177 | if (app?.csp !== undefined && oversized()) { | ||
| 178 | const { csp: _csp, ...rest } = app; | ||
| 179 | output[MCP_APP_OUTPUT_KEY] = rest; | ||
| 180 | } | ||
| 181 | if (oversized()) delete output[MCP_APP_OUTPUT_KEY]; | ||
| 167 | return Object.keys(output).length === 0 ? undefined : output; | 182 | return Object.keys(output).length === 0 ? undefined : output; |
| 168 | } | 183 | } |
| 169 | 184 | ||
| ... | @@ -177,6 +192,21 @@ export function htmlRenderFromToolItem(item: { | ... | @@ -177,6 +192,21 @@ export function htmlRenderFromToolItem(item: { |
| 177 | return output?.isError ? undefined : output?.htmlRender; | 192 | return output?.isError ? undefined : output?.htmlRender; |
| 178 | } | 193 | } |
| 179 | 194 | ||
| 195 | /** | ||
| 196 | * The MCP App a completed tool call carries, if any. The adapter that captured | ||
| 197 | * it put the reference in the output, beside the tool's own result. A tool's | ||
| 198 | * result can imitate that shape, so the reference only counts when it names | ||
| 199 | * the very server and tool the item records: a server can then only ever | ||
| 200 | * point at an app of its own. | ||
| 201 | */ | ||
| 202 | export function mcpAppFromToolItem(item: { | ||
| 203 | readonly toolName: string | null | undefined; | ||
| 204 | readonly output?: unknown; | ||
| 205 | }): McpAppReference | undefined { | ||
| 206 | const app = compactDynamicToolOutput(item.output)?.[MCP_APP_OUTPUT_KEY]; | ||
| 207 | return app !== undefined && item.toolName === `${app.server}.${app.tool}` ? app : undefined; | ||
| 208 | } | ||
| 209 | |||
| 180 | /** Some providers report completion even when command output describes a failure. */ | 210 | /** Some providers report completion even when command output describes a failure. */ |
| 181 | export function toolOutputIndicatesFailure(text: string): boolean { | 211 | export function toolOutputIndicatesFailure(text: string): boolean { |
| 182 | return ( | 212 | return ( |