| 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 * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 18 | import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; |
| 19 | import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; |
| 20 | import * as McpAppModelContext from "./McpAppModelContext.ts"; |
| 21 | import * as McpAppRequests from "./McpAppRequests.ts"; |
| 22 | |
| 23 | const threadId = ThreadId.make("thread-app"); |
| 24 | const itemId = TurnItemId.make("item-app"); |
| 25 | const providerThreadId = ProviderThreadId.make("provider-thread-app"); |
| 26 | const providerSessionId = ProviderSessionId.make("provider-session-app"); |
| 27 | const forkId = ThreadId.make("thread-fork"); |
| 28 | const unrelatedId = ThreadId.make("thread-unrelated"); |
| 29 | // A chain of forks deeper than any fixed walk limit: deep-70 → … → deep-1 → app. |
| 30 | const deepForkId = (depth: number) => ThreadId.make(`thread-deep-${depth}`); |
| 31 | const deepParent = (id: ThreadId): ThreadId | null => { |
| 32 | const match = /^thread-deep-(\d+)$/.exec(id); |
| 33 | if (match === null) return null; |
| 34 | const depth = Number(match[1]); |
| 35 | return depth === 1 ? threadId : deepForkId(depth - 1); |
| 36 | }; |
| 37 | |
| 38 | const appItem = (output: unknown): OrchestrationV2TurnItem => ({ |
| 39 | id: itemId, |
| 40 | threadId, |
| 41 | runId: null, |
| 42 | nodeId: null, |
| 43 | providerThreadId, |
| 44 | providerTurnId: null, |
| 45 | nativeItemRef: null, |
| 46 | parentItemId: null, |
| 47 | ordinal: 0, |
| 48 | status: "completed", |
| 49 | title: null, |
| 50 | startedAt: null, |
| 51 | completedAt: null, |
| 52 | updatedAt: DateTime.makeUnsafe(0), |
| 53 | type: "dynamic_tool", |
| 54 | toolName: "weather.get_weather", |
| 55 | input: {}, |
| 56 | output, |
| 57 | }); |
| 58 | |
| 59 | const app = { |
| 60 | attachmentId: "thread-app-00000000-0000-0000-0000-000000000000-html", |
| 61 | server: "weather", |
| 62 | tool: "get_weather", |
| 63 | resourceUri: "ui://weather/dashboard", |
| 64 | }; |
| 65 | |
| 66 | /** Each app's stored context, keyed by item, as the real store keeps it. */ |
| 67 | const storedContext = new Map<string, string>(); |
| 68 | |
| 69 | function makeLayer(input: { |
| 70 | readonly item: OrchestrationV2TurnItem | null; |
| 71 | readonly mcpApps?: ProviderAdapter.ProviderAdapterV2McpApps; |
| 72 | readonly live?: boolean; |
| 73 | }) { |
| 74 | return McpAppRequests.layer.pipe( |
| 75 | Layer.provide( |
| 76 | Layer.mergeAll( |
| 77 | Layer.mock(McpAppModelContext.McpAppModelContext)({ |
| 78 | set: (entry) => |
| 79 | Effect.sync(() => { |
| 80 | const key = `${entry.threadId}/${entry.itemId}`; |
| 81 | if (entry.text.trim() === "") storedContext.delete(key); |
| 82 | else storedContext.set(key, entry.text); |
| 83 | }), |
| 84 | }), |
| 85 | Layer.mock(Orchestrator.OrchestratorV2)({ |
| 86 | getTurnItem: () => Effect.succeed(input.item), |
| 87 | }), |
| 88 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| 89 | getThreadRecords: (id: ThreadId) => |
| 90 | Effect.succeed({ |
| 91 | // A fork of the app's thread, and an unrelated thread. |
| 92 | thread: { |
| 93 | lineage: |
| 94 | deepParent(id) !== null |
| 95 | ? { |
| 96 | parentThreadId: deepParent(id), |
| 97 | relationshipToParent: "fork", |
| 98 | rootThreadId: threadId, |
| 99 | } |
| 100 | : id === forkId |
| 101 | ? { |
| 102 | parentThreadId: threadId, |
| 103 | relationshipToParent: "fork", |
| 104 | rootThreadId: threadId, |
| 105 | } |
| 106 | : { parentThreadId: null, relationshipToParent: null, rootThreadId: id }, |
| 107 | }, |
| 108 | providerThreads: [ |
| 109 | { id: providerThreadId, providerSessionId } as OrchestrationV2ProviderThread, |
| 110 | ], |
| 111 | } as never), |
| 112 | }), |
| 113 | Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ |
| 114 | get: () => |
| 115 | Effect.succeed( |
| 116 | input.live === false |
| 117 | ? Option.none() |
| 118 | : Option.some( |
| 119 | (input.mcpApps === undefined |
| 120 | ? {} |
| 121 | : { |
| 122 | mcpApps: input.mcpApps, |
| 123 | }) as ProviderAdapter.ProviderAdapterV2SessionRuntime, |
| 124 | ), |
| 125 | ), |
| 126 | }), |
| 127 | ), |
| 128 | ), |
| 129 | ); |
| 130 | } |
| 131 | |
| 132 | const reason = <A, R>(effect: Effect.Effect<A, McpAppRequestError, R>) => |
| 133 | effect.pipe( |
| 134 | Effect.flip, |
| 135 | Effect.map((error) => error.reason), |
| 136 | ); |
| 137 | |
| 138 | describe("McpAppRequests", () => { |
| 139 | const calls: Array<{ server: string; tool: string }> = []; |
| 140 | const mcpApps: ProviderAdapter.ProviderAdapterV2McpApps = { |
| 141 | listTools: () => |
| 142 | Effect.succeed([ |
| 143 | { name: "refresh", annotations: { readOnlyHint: true } }, |
| 144 | { name: "model_only", _meta: { ui: { visibility: ["model"] } } }, |
| 145 | ]), |
| 146 | callTool: (input) => |
| 147 | Effect.sync(() => { |
| 148 | calls.push({ server: input.server, tool: input.tool }); |
| 149 | return { content: [{ type: "text", text: "ok" }] }; |
| 150 | }), |
| 151 | readResource: () => Effect.succeed({ contents: [] }), |
| 152 | }; |
| 153 | |
| 154 | it.effect("calls tools on the app's own server and reports their read-only hint", () => |
| 155 | Effect.gen(function* () { |
| 156 | const requests = yield* McpAppRequests.McpAppRequests; |
| 157 | const result = yield* requests.callTool({ |
| 158 | threadId, |
| 159 | itemId, |
| 160 | name: "refresh", |
| 161 | arguments: {}, |
| 162 | }); |
| 163 | assert.deepEqual(result, { content: [{ type: "text", text: "ok" }] }); |
| 164 | assert.deepEqual(calls.at(-1), { server: "weather", tool: "refresh" }); |
| 165 | assert.deepEqual(yield* requests.toolInfo({ threadId, itemId, name: "refresh" }), { |
| 166 | callable: true, |
| 167 | readOnly: true, |
| 168 | tool: { name: "refresh", annotations: { readOnlyHint: true } }, |
| 169 | }); |
| 170 | }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))), |
| 171 | ); |
| 172 | |
| 173 | it.effect("refuses tools hidden from apps or missing from the server", () => |
| 174 | Effect.gen(function* () { |
| 175 | const requests = yield* McpAppRequests.McpAppRequests; |
| 176 | const before = calls.length; |
| 177 | for (const name of ["model_only", "unknown"]) { |
| 178 | assert.equal( |
| 179 | yield* reason(requests.callTool({ threadId, itemId, name, arguments: {} })), |
| 180 | "tool-not-callable", |
| 181 | ); |
| 182 | } |
| 183 | assert.equal(calls.length, before); |
| 184 | }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))), |
| 185 | ); |
| 186 | |
| 187 | it.effect("keeps each app's latest model context, without a live session", () => |
| 188 | Effect.gen(function* () { |
| 189 | const requests = yield* McpAppRequests.McpAppRequests; |
| 190 | const own = { threadId, itemId, conversationThreadId: threadId }; |
| 191 | // The app's text is stored as sent, whitespace included. |
| 192 | yield* requests.updateModelContext({ |
| 193 | ...own, |
| 194 | content: [{ type: "text", text: " indented value " }], |
| 195 | }); |
| 196 | assert.equal(storedContext.get(`${threadId}/${itemId}`), " indented value "); |
| 197 | // An update replaces the app's context rather than adding to it. |
| 198 | yield* requests.updateModelContext({ |
| 199 | ...own, |
| 200 | content: [{ type: "text", text: "Filtered to overdue" }], |
| 201 | structuredContent: { filter: "overdue" }, |
| 202 | }); |
| 203 | assert.equal( |
| 204 | storedContext.get(`${threadId}/${itemId}`), |
| 205 | 'Filtered to overdue\n{"filter":"overdue"}', |
| 206 | ); |
| 207 | assert.equal( |
| 208 | yield* reason( |
| 209 | requests.updateModelContext({ ...own, content: [{ type: "image", data: "x" }] }), |
| 210 | ), |
| 211 | "unsupported-content", |
| 212 | ); |
| 213 | // The cap is in UTF-8 bytes: 6,000 three-byte characters exceed 16 KiB. |
| 214 | assert.equal( |
| 215 | yield* reason( |
| 216 | requests.updateModelContext({ |
| 217 | ...own, |
| 218 | content: [{ type: "text", text: "่กจ".repeat(6000) }], |
| 219 | }), |
| 220 | ), |
| 221 | "request-failed", |
| 222 | ); |
| 223 | yield* requests.updateModelContext(own); |
| 224 | assert.isFalse(storedContext.has(`${threadId}/${itemId}`)); |
| 225 | }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), live: false }))), |
| 226 | ); |
| 227 | |
| 228 | it.effect("stores a fork's model context under the fork, and refuses unrelated threads", () => |
| 229 | Effect.gen(function* () { |
| 230 | const requests = yield* McpAppRequests.McpAppRequests; |
| 231 | const content = [{ type: "text", text: "Seen from the fork" }]; |
| 232 | yield* requests.updateModelContext({ |
| 233 | threadId, |
| 234 | itemId, |
| 235 | conversationThreadId: forkId, |
| 236 | content, |
| 237 | }); |
| 238 | assert.equal(storedContext.get(`${forkId}/${itemId}`), "Seen from the fork"); |
| 239 | assert.isFalse(storedContext.has(`${threadId}/${itemId}`)); |
| 240 | assert.equal( |
| 241 | yield* reason( |
| 242 | requests.updateModelContext({ |
| 243 | threadId, |
| 244 | itemId, |
| 245 | conversationThreadId: unrelatedId, |
| 246 | content, |
| 247 | }), |
| 248 | ), |
| 249 | "not-an-app", |
| 250 | ); |
| 251 | // However many forks deep, a descendant still counts. |
| 252 | yield* requests.updateModelContext({ |
| 253 | threadId, |
| 254 | itemId, |
| 255 | conversationThreadId: deepForkId(70), |
| 256 | content, |
| 257 | }); |
| 258 | assert.isTrue(storedContext.has(`${deepForkId(70)}/${itemId}`)); |
| 259 | }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), live: false }))), |
| 260 | ); |
| 261 | |
| 262 | it.effect("reports why a request cannot run", () => |
| 263 | Effect.gen(function* () { |
| 264 | const run = (layer: ReturnType<typeof makeLayer>) => |
| 265 | Effect.gen(function* () { |
| 266 | const requests = yield* McpAppRequests.McpAppRequests; |
| 267 | return yield* reason(requests.readResource({ threadId, itemId, uri: "ui://weather/x" })); |
| 268 | }).pipe(Effect.provide(layer)); |
| 269 | assert.equal( |
| 270 | yield* run(makeLayer({ item: appItem({ result: "plain" }), mcpApps })), |
| 271 | "not-an-app", |
| 272 | ); |
| 273 | assert.equal( |
| 274 | yield* run(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps, live: false })), |
| 275 | "session-stopped", |
| 276 | ); |
| 277 | assert.equal( |
| 278 | yield* run(makeLayer({ item: appItem({ t3McpApp: app }) })), |
| 279 | "provider-unsupported", |
| 280 | ); |
| 281 | }), |
| 282 | ); |
| 283 | }); |