Skip to content
Source
Sign inSign up

McpAppRequests.test.ts

288 lines9.7 KB

1import {
2 ProviderDriverKind,
3 ProviderSessionId,
4 ProviderThreadId,
5 ThreadId,
6 TurnItemId,
7 type McpAppRequestError,
8 type OrchestrationV2ProviderThread,
9 type OrchestrationV2TurnItem,
10} from "@t3tools/contracts";
11import { assert, describe, it } from "@effect/vitest";
12import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16​
17import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
18import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
19import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts";
20import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
21import * as McpAppModelContext from "./McpAppModelContext.ts";
22import * as McpAppRequests from "./McpAppRequests.ts";
23​
24const threadId = ThreadId.make("thread-app");
25const itemId = TurnItemId.make("item-app");
26const providerThreadId = ProviderThreadId.make("provider-thread-app");
27const providerSessionId = ProviderSessionId.make("provider-session-app");
28const forkId = ThreadId.make("thread-fork");
29const unrelatedId = ThreadId.make("thread-unrelated");
30const deepForkId = (depth: number) => ThreadId.make(`thread-deep-${depth}`);
31const 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​
38const 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​
59const 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​
66const storedContext = new Map<string, string>();
67​
68function makeLayer(input: {
69 readonly item: OrchestrationV2TurnItem | null;
70 readonly mcpApps?: ProviderAdapter.ProviderAdapterV2McpApps;
71 readonly live?: boolean;
72}) {
73 return McpAppRequests.layer.pipe(
74 Layer.provide(
75 Layer.mergeAll(
76 Layer.mock(McpAppModelContext.McpAppModelContext)({
77 set: (entry) =>
78 Effect.sync(() => {
79 const key = `${entry.threadId}/${entry.itemId}`;
80 if (entry.text.trim() === "") storedContext.delete(key);
81 else storedContext.set(key, entry.text);
82 }),
83 }),
84 Layer.mock(Orchestrator.OrchestratorV2)({
85 getTurnItem: () => Effect.succeed(input.item),
86 }),
87 Layer.mock(ThreadManagementService.ThreadManagementService)({
88 getThreadRecords: (id: ThreadId) =>
89 Effect.succeed({
90 thread: {
91 lineage:
92 deepParent(id) !== null
93 ? {
94 parentThreadId: deepParent(id),
95 relationshipToParent: "fork",
96 rootThreadId: threadId,
97 }
98 : id === forkId
99 ? {
100 parentThreadId: threadId,
101 relationshipToParent: "fork",
102 rootThreadId: threadId,
103 }
104 : { parentThreadId: null, relationshipToParent: null, rootThreadId: id },
105 },
106 providerThreads: [
107 { id: providerThreadId, providerSessionId } as OrchestrationV2ProviderThread,
108 ],
109 } as never),
110 }),
111 Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({
112 get: () =>
113 Effect.succeed(
114 input.live === false
115 ? Option.none()
116 : Option.some(
117 (input.mcpApps === undefined
118 ? {}
119 : {
120 mcpApps: input.mcpApps,
121 }) as ProviderAdapter.ProviderAdapterV2SessionRuntime,
122 ),
123 ),
124 }),
125 ),
126 ),
127 );
129​
130const reason = <A, R>(effect: Effect.Effect<A, McpAppRequestError, R>) =>
131 effect.pipe(
132 Effect.flip,
133 Effect.map((error) => error.reason),
134 );
135​
136describe("McpAppRequests", () => {
137 const calls: Array<{ server: string; tool: string }> = [];
138 const mcpApps: ProviderAdapter.ProviderAdapterV2McpApps = {
139 listTools: () =>
140 Effect.succeed([
141 { name: "refresh", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } },
142 {
143 name: "model_only",
144 inputSchema: { type: "object" },
145 _meta: { ui: { visibility: ["model"] } },
146 },
147 ]),
148 callTool: (input) =>
149 Effect.sync(() => {
150 calls.push({ server: input.server, tool: input.tool });
151 return { content: [{ type: "text", text: "ok" }] };
152 }),
153 readResource: () => Effect.succeed({ contents: [] }),
154 };
155​
156 it.effect("calls tools on the app's own server and reports their read-only hint", () =>
157 Effect.gen(function* () {
158 const requests = yield* McpAppRequests.McpAppRequests;
159 const result = yield* requests.callTool({
160 threadId,
161 itemId,
162 name: "refresh",
163 arguments: {},
164 });
165 assert.deepEqual(result, { content: [{ type: "text", text: "ok" }] });
166 assert.deepEqual(calls.at(-1), { server: "weather", tool: "refresh" });
167 assert.deepEqual(yield* requests.toolInfo({ threadId, itemId, name: "refresh" }), {
168 callable: true,
169 readOnly: true,
170 tool: {
171 name: "refresh",
172 inputSchema: { type: "object" },
173 annotations: { readOnlyHint: true },
174 },
175 });
176 }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))),
177 );
178​
179 it.effect("refuses tools hidden from apps or missing from the server", () =>
180 Effect.gen(function* () {
181 const requests = yield* McpAppRequests.McpAppRequests;
182 const before = calls.length;
183 for (const name of ["model_only", "unknown"]) {
184 assert.equal(
185 yield* reason(requests.callTool({ threadId, itemId, name, arguments: {} })),
186 "tool-not-callable",
187 );
188 }
189 assert.equal(calls.length, before);
190 }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))),
191 );
192​
193 it.effect("reports a provider whose process has stopped as a stopped session", () =>
194 Effect.gen(function* () {
195 const requests = yield* McpAppRequests.McpAppRequests;
196 assert.equal(
197 yield* reason(requests.readResource({ threadId, itemId, uri: "ui://weather/app.html" })),
198 "session-stopped",
199 );
200 }).pipe(
201 Effect.provide(
202 makeLayer({
203 item: appItem({ t3McpApp: app }),
204 mcpApps: {
205 ...mcpApps,
206 readResource: () =>
207 Effect.fail(
208 new ProviderAdapter.ProviderAdapterSessionStoppedError({
209 driver: ProviderDriverKind.make("claudeAgent"),
210 }),
211 ),
212 },
213 }),
214 ),
215 ),
216 );
217​
218 it.effect("keeps each app's latest model context, without a live session", () =>
219 Effect.gen(function* () {
220 const requests = yield* McpAppRequests.McpAppRequests;
221 const own = { threadId, itemId, conversationThreadId: threadId };
222 yield* requests.updateModelContext({
223 ...own,
224 content: [{ type: "text", text: " indented value " }],
225 });
226 assert.equal(storedContext.get(`${threadId}/${itemId}`), " indented value ");
227 yield* requests.updateModelContext({
228 ...own,
229 content: [{ type: "text", text: "Filtered to overdue" }],
230 structuredContent: { filter: "overdue" },
231 });
232 assert.equal(
233 storedContext.get(`${threadId}/${itemId}`),
234 'Filtered to overdue\n{"filter":"overdue"}',
235 );
236 assert.equal(
237 yield* reason(
238 requests.updateModelContext({ ...own, content: [{ type: "image", data: "x" }] }),
239 ),
240 "unsupported-content",
241 );
242 assert.equal(
243 yield* reason(
244 requests.updateModelContext({
245 ...own,
246 content: [{ type: "text", text: "表".repeat(6000) }],
247 }),
248 ),
249 "request-failed",
250 );
251 yield* requests.updateModelContext(own);
252 assert.isFalse(storedContext.has(`${threadId}/${itemId}`));
253 }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), live: false }))),
254 );
255​
256 it.effect("stores a fork's model context under the fork, and refuses unrelated threads", () =>
257 Effect.gen(function* () {
258 const requests = yield* McpAppRequests.McpAppRequests;
259 const content = [{ type: "text", text: "Seen from the fork" }];
260 yield* requests.updateModelContext({
261 threadId,
262 itemId,
263 conversationThreadId: forkId,
264 content,
265 });
266 assert.equal(storedContext.get(`${forkId}/${itemId}`), "Seen from the fork");
267 assert.isFalse(storedContext.has(`${threadId}/${itemId}`));
268 assert.equal(
269 yield* reason(
270 requests.updateModelContext({
271 threadId,
272 itemId,
273 conversationThreadId: unrelatedId,
274 content,
275 }),
276 ),
277 "not-an-app",
278 );
279 yield* requests.updateModelContext({
280 threadId,
281 itemId,
282 conversationThreadId: deepForkId(70),
283 content,
284 });
285 assert.isTrue(storedContext.has(`${deepForkId(70)}/${itemId}`));
286 }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), live: false }))),
287 );
288});