Skip to content
Source
Sign inSign up

FxAdapterV2.test.ts

372 lines12.8 KB

1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { describe, it } from "@effect/vitest";
4import {
5 MessageId,
6 NodeId,
7 ProjectId,
8 ProviderInstanceId,
9 ProviderSessionId,
10 RunAttemptId,
11 RunId,
12 type RuntimeMode,
13 ThreadId,
14} from "@t3tools/contracts";
15import { ACP_PROTOCOL } from "@t3tools/provider-acp/server/adapter";
16import { FxSettings } from "@t3tools/provider-fx/settings";
17import { FX_DRIVER_KIND, makeFxAdapterV2 } from "@t3tools/provider-fx/testing";
18import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
19import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
20import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter";
21import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
22import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
23import * as DateTime from "effect/DateTime";
24import * as Effect from "effect/Effect";
25import * as FileSystem from "effect/FileSystem";
26import * as Layer from "effect/Layer";
27import * as Path from "effect/Path";
28import * as Schema from "effect/Schema";
29import * as Stream from "effect/Stream";
30import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
31​
32import {
33 decodeAcpReplayTranscript,
34 makeAcpReplayCompletenessAssertion,
35 makeAcpReplayRuntime,
36} from "./AcpAdapterV2.testkit.ts";
37​
38const testLayer = Layer.mergeAll(
39 NodeServices.layer,
40 NodeCrypto.layer,
41 IdAllocator.layer,
42 McpProviderSessions.layer,
43 TestProviderHost.layer().pipe(
44 Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
45 ),
46);
47​
48const SESSION_ID = "fx-session-01";
49const ENABLED_FX_SETTINGS = Schema.decodeSync(FxSettings)({ enabled: true });
50/** The model `fx status --json` reports. */
51const FX_DEFAULT_MODEL = "anthropic/claude-opus-5.5";
52const NAMED_MODEL = "anthropic/claude-sonnet-5.5";
53const CUSTOM_MODEL = "openai/gpt-6.1-sol";
54const CODEX_MODEL = "gpt-6.1-sol";
55const FX_0_0_13_MODES = ["code", "ask"];
56/** fx `main` after vercel-labs/fx#1189. */
57const FX_MAIN_MODES = ["auto", "ask", "full-access"];
58​
59type Frame = Record<string, unknown>;
60const outbound = (method: string, params: unknown = "<any>"): Frame => ({
61 type: "expect_outbound",
62 label: method,
63 frame: { kind: "request", method, params },
64});
65const answer = (method: string, result: unknown): Frame => ({
66 type: "emit_inbound",
67 label: `${method}.result`,
68 frame: { kind: "response", method, result },
69});
70​
71const select = (
72 id: string,
73 category: string,
74 currentValue: string,
75 values: ReadonlyArray<string>,
76) => ({
77 id,
78 name: id,
79 category,
80 type: "select",
81 currentValue,
82 options: values.map((value) => ({ value, name: value })),
83});
84​
85/**
86 * fx's session config as recorded from 0.0.13 (fixtures/simple), models
87 * trimmed. fx lists its backend `provider` before `model`, both with
88 * category `model`.
89 */
90const fxConfig = (input: {
91 readonly model: string;
92 readonly modes: ReadonlyArray<string>;
93 readonly mode?: string;
94 /** The session's own model list, on a backend other than the gateway. */
95 readonly models?: ReadonlyArray<string>;
96}) => [
97 select("provider", "model", "gateway", ["gateway", "codex", "grok"]),
98 select(
99 "model",
100 "model",
101 input.model,
102 input.models ?? [NAMED_MODEL, FX_DEFAULT_MODEL, "openai/gpt-5.6-sol"],
103 ),
104 select("mode", "mode", input.mode ?? "ask", input.modes),
105 select("effort", "thought_level", "xhigh", ["auto", "low", "medium", "high", "xhigh", "max"]),
106];
107​
108/**
109 * fx's reply to `session/new` or `session/load`. fx 0.0.13 reports `ask`
110 * while it enforces its saved default until a client writes the mode.
111 */
112const fxSession = (input: {
113 readonly model: string;
114 readonly modes: ReadonlyArray<string>;
115 readonly models?: ReadonlyArray<string>;
116}) => ({
117 sessionId: SESSION_ID,
118 configOptions: fxConfig(input),
119 modes: { currentModeId: "ask", availableModes: input.modes.map((id) => ({ id, name: id })) },
120});
121​
122const initialize: ReadonlyArray<Frame> = [
123 outbound("initialize"),
124 answer("initialize", {
125 protocolVersion: 1,
126 agentCapabilities: {
127 loadSession: true,
128 promptCapabilities: { image: true, audio: false, embeddedContext: true },
129 },
130 agentInfo: { name: "fx", title: "fx", version: "0.0.13" },
131 authMethods: [],
132 }),
133];
134​
135/** A config write and fx's reply, which lists the session's config after it. */
136const setConfig = (
137 configId: string,
138 value: string,
139 configAfter: ReadonlyArray<unknown>,
140): ReadonlyArray<Frame> => [
141 outbound("session/set_config_option", { sessionId: SESSION_ID, configId, value }),
142 answer("session/set_config_option", { configOptions: configAfter }),
143];
144​
145/**
146 * Opens an fx session against a scripted ACP agent, which fails on any
147 * frame the script does not expect. Every scripted frame must be used.
148 */
149const openFxSession = Effect.fn("openFxSession")(function* (input: {
150 readonly scenario: string;
151 readonly frames: ReadonlyArray<Frame>;
152 readonly runtimeMode: RuntimeMode;
153 readonly model?: string;
154 /** Loads this fx session instead of creating one. */
155 readonly loadSessionId?: string;
156 /** Then starts a turn on this model. */
157 readonly turnModel?: string;
158}) {
159 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
160 const fileSystem = yield* FileSystem.FileSystem;
161 const path = yield* Path.Path;
162 const replayDir = yield* fileSystem.makeTempDirectoryScoped({
163 prefix: `t3-fx-${input.scenario}-`,
164 });
165 const statusPath = path.join(replayDir, "status.json");
166 const transcript = yield* decodeAcpReplayTranscript(
167 {
168 provider: FX_DRIVER_KIND,
169 protocol: ACP_PROTOCOL,
170 version: "1",
171 scenario: input.scenario,
172 entries: input.frames as never,
173 },
174 FX_DRIVER_KIND,
175 );
176 const instanceId = ProviderInstanceId.make(`fx-${input.scenario}`);
177 const adapter = yield* makeFxAdapterV2({
178 instanceId,
179 settings: ENABLED_FX_SETTINGS,
180 environment: {},
181 selfInvocation: yield* resolveSelfInvocation(),
182 defaultModel: Effect.succeed(FX_DEFAULT_MODEL),
183 makeRuntime: makeAcpReplayRuntime({
184 transcript,
185 statusPath,
186 scriptPath: yield* path.fromFileUrl(
187 new URL("../../../scripts/acp-replay-agent.ts", import.meta.url),
188 ),
189 childProcessSpawner,
190 fileSystem,
191 }),
192 });
193 const threadId = ThreadId.make(`thread-fx-${input.scenario}`);
194 // The thread's effort matches the session's, so no effort write is scripted.
195 const selectionOn = (model: string) => ({
196 instanceId,
197 model,
198 options: [{ id: "effort", value: "xhigh" }],
199 });
200 const modelSelection = selectionOn(input.model ?? "default");
201 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
202 runtimeMode: input.runtimeMode,
203 interactionMode: "default",
204 cwd: replayDir,
205 });
206 yield* Effect.gen(function* () {
207 const session = yield* adapter.openSession({
208 threadId,
209 providerSessionId: ProviderSessionId.make(`provider-session-fx-${input.scenario}`),
210 modelSelection,
211 runtimePolicy,
212 ...(input.loadSessionId === undefined ? {} : { initialNativeThreadId: input.loadSessionId }),
213 });
214 if (input.turnModel === undefined) return;
215 const providerThread = yield* session.ensureThread({ threadId, modelSelection, runtimePolicy });
216 const now = yield* DateTime.now;
217 const turnSelection = selectionOn(input.turnModel);
218 yield* session.startTurn({
219 appThread: {
220 createdBy: "user",
221 creationSource: "web",
222 id: threadId,
223 projectId: ProjectId.make(`project:${threadId}`),
224 title: "fx adapter test",
225 providerInstanceId: instanceId,
226 modelSelection: turnSelection,
227 runtimeMode: runtimePolicy.runtimeMode,
228 interactionMode: "default",
229 branch: null,
230 worktreePath: null,
231 activeProviderThreadId: providerThread.id,
232 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
233 forkedFrom: null,
234 createdAt: now,
235 updatedAt: now,
236 archivedAt: null,
237 settledOverride: null,
238 settledAt: null,
239 lastVisitedAt: null,
240 deletedAt: null,
241 },
242 threadId,
243 runId: RunId.make(`run:${threadId}:1`),
244 runOrdinal: 1,
245 providerTurnOrdinal: 1,
246 attemptId: RunAttemptId.make(`attempt:${threadId}:1`),
247 rootNodeId: NodeId.make(`node:${threadId}:1`),
248 providerThread,
249 message: {
250 createdBy: "user",
251 creationSource: "web",
252 messageId: MessageId.make(`message:${threadId}:1`),
253 text: "Say hello",
254 attachments: [],
255 },
256 modelSelection: turnSelection,
257 runtimePolicy,
258 });
259 yield* session.events.pipe(
260 Stream.takeUntil((event) => event.type === "turn.terminal"),
261 Stream.runDrain,
262 );
263 }).pipe(Effect.scoped);
264 yield* makeAcpReplayCompletenessAssertion(fileSystem, statusPath, transcript);
265});
266​
267describe("FxAdapterV2", () => {
268 it.effect("runs Auto as fx's `code` mode on 0.0.13 and as `auto` where fx advertises it", () =>
269 Effect.forEach(
270 [
271 { scenario: "auto-0-0-13", modes: FX_0_0_13_MODES, written: "code" },
272 { scenario: "auto-main", modes: FX_MAIN_MODES, written: "auto" },
273 ],
274 ({ scenario, modes, written }) =>
275 openFxSession({
276 scenario,
277 runtimeMode: "auto",
278 frames: [
279 ...initialize,
280 outbound("session/new", { cwd: "<workspace>", mcpServers: "<any>" }),
281 answer("session/new", fxSession({ model: FX_DEFAULT_MODEL, modes })),
282 ...setConfig(
283 "mode",
284 written,
285 fxConfig({ model: FX_DEFAULT_MODEL, modes, mode: written }),
286 ),
287 ],
288 }),
289 { discard: true },
290 ).pipe(Effect.provide(testLayer), Effect.scoped),
291 );
292​
293 it.effect("puts a loaded session that ran a named model back on fx's default for Default", () =>
294 openFxSession({
295 scenario: "default-model",
296 runtimeMode: "approval-required",
297 model: "default",
298 loadSessionId: SESSION_ID,
299 frames: [
300 ...initialize,
301 outbound("session/load"),
302 answer("session/load", fxSession({ model: NAMED_MODEL, modes: FX_0_0_13_MODES })),
303 // Config id `model`, never fx's `provider`, and never T3's "default".
304 ...setConfig(
305 "model",
306 FX_DEFAULT_MODEL,
307 fxConfig({ model: FX_DEFAULT_MODEL, modes: FX_0_0_13_MODES }),
308 ),
309 // Written on load even though fx reports ask.
310 ...setConfig("mode", "ask", fxConfig({ model: FX_DEFAULT_MODEL, modes: FX_0_0_13_MODES })),
311 ],
312 }).pipe(Effect.provide(testLayer), Effect.scoped),
313 );
314​
315 // `fx status` names the gateway default, but this loaded session runs on
316 // Codex, whose catalog lacks it; fx would refuse the write and the thread
317 // could not start.
318 it.effect("keeps the session's model when its backend lacks fx's default", () =>
319 openFxSession({
320 scenario: "default-other-backend",
321 runtimeMode: "approval-required",
322 model: "default",
323 loadSessionId: SESSION_ID,
324 frames: [
325 ...initialize,
326 outbound("session/load"),
327 answer(
328 "session/load",
329 fxSession({ model: CODEX_MODEL, models: [CODEX_MODEL], modes: FX_0_0_13_MODES }),
330 ),
331 ...setConfig(
332 "mode",
333 "ask",
334 fxConfig({ model: CODEX_MODEL, models: [CODEX_MODEL], modes: FX_0_0_13_MODES }),
335 ),
336 ],
337 }).pipe(Effect.provide(testLayer), Effect.scoped),
338 );
339​
340 // fx 0.0.13's gateway takes any model id (src/acp/server.zig
341 // handleSetConfigOption) and lists the current one even when its catalog
342 // does not, so a custom model reaches fx instead of running on another,
343 // and switching back afterwards leaves it again.
344 it.effect("sends a custom model fx does not list, and leaves it for Default", () =>
345 openFxSession({
346 scenario: "custom-model",
347 runtimeMode: "approval-required",
348 model: CUSTOM_MODEL,
349 turnModel: "default",
350 frames: [
351 ...initialize,
352 outbound("session/new"),
353 answer("session/new", fxSession({ model: FX_DEFAULT_MODEL, modes: FX_0_0_13_MODES })),
354 ...setConfig(
355 "model",
356 CUSTOM_MODEL,
357 fxConfig({ model: CUSTOM_MODEL, modes: FX_0_0_13_MODES }),
358 ),
359 ...setConfig("mode", "ask", fxConfig({ model: CUSTOM_MODEL, modes: FX_0_0_13_MODES })),
360 // Back on Default, which the session started on: fx runs the custom
361 // model now, so the write must go out again.
362 ...setConfig(
363 "model",
364 FX_DEFAULT_MODEL,
365 fxConfig({ model: FX_DEFAULT_MODEL, modes: FX_0_0_13_MODES }),
366 ),
367 outbound("session/prompt"),
368 answer("session/prompt", { stopReason: "end_turn" }),
369 ],
370 }).pipe(Effect.provide(testLayer), Effect.scoped),
371 );
372});