1import {
2 ProviderSessionId,
3 ProviderThreadId,
4 ThreadId,
5 TurnItemId,
6 type McpAppRequestError,
7 type OrchestrationV2ProviderThread,
8 type OrchestrationV2TurnItem,
9} from "@t3tools/contracts";
10import { assert, describe, it } from "@effect/vitest";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15
16import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
17import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
18import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts";
19import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
20import * as McpAppModelContext from "./McpAppModelContext.ts";
21import * as McpAppRequests from "./McpAppRequests.ts";
22
23const threadId = ThreadId.make("thread-app");
24const itemId = TurnItemId.make("item-app");
25const providerThreadId = ProviderThreadId.make("provider-thread-app");
26const providerSessionId = ProviderSessionId.make("provider-session-app");
27const forkId = ThreadId.make("thread-fork");
28const unrelatedId = ThreadId.make("thread-unrelated");
29// A chain of forks deeper than any fixed walk limit: deep-70 → … → deep-1 → app.
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
66/** Each app's stored context, keyed by item, as the real store keeps it. */
67const storedContext = new Map<string, string>();
68
69function 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
132const reason = <A, R>(effect: Effect.Effect<A, McpAppRequestError, R>) =>
133 effect.pipe(
134 Effect.flip,
135 Effect.map((error) => error.reason),
136 );
137
138describe("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});