1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Result from "effect/Result";
5import * as HostProcess from "@t3tools/shared/HostProcess";
6
7import {
8 makeAcpClientTerminals,
9 resolveEmbeddedTerminalContent,
10 type AcpClientTerminals,
11} from "./clientTerminals.ts";
12
13const withTerminals = <A, E>(use: (terminals: AcpClientTerminals) => Effect.Effect<A, E>) =>
14 Effect.gen(function* () {
15 const terminals = yield* makeAcpClientTerminals({
16 defaultCwd: process.cwd(),
17 });
18 return yield* use(terminals).pipe(Effect.ensuring(terminals.disposeAll));
19 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
20
21describe("AcpClientTerminals", () => {
22 it.effect("runs a command, buffers output, and reports the exit status", () =>
23 withTerminals((terminals) =>
24 Effect.gen(function* () {
25 const created = yield* terminals.create({
26 sessionId: "session",
27 command: process.execPath,
28 args: ["-e", "console.log('hello from acp'); process.exit(3);"],
29 });
30
31 const exit = yield* terminals.waitForExit({
32 sessionId: "session",
33 terminalId: created.terminalId,
34 });
35 expect(exit.exitCode).toBe(3);
36
37 const output = yield* terminals.output({
38 sessionId: "session",
39 terminalId: created.terminalId,
40 });
41 expect(output.output).toContain("hello from acp");
42 expect(output.truncated).toBe(false);
43 expect(output.exitStatus?.exitCode).toBe(3);
44 }),
45 ),
46 );
47
48 it.effect("resolves terminal environment from the requesting ACP session", () =>
49 Effect.gen(function* () {
50 const terminals = yield* makeAcpClientTerminals({
51 defaultCwd: process.cwd(),
52 environmentForSession: (sessionId) => ({
53 T3_TEST_ACP_SESSION_TOKEN: sessionId === "session-a" ? "token-a" : "token-b",
54 }),
55 });
56 yield* Effect.addFinalizer(() => terminals.disposeAll);
57
58 for (const [sessionId, expected] of [
59 ["session-a", "token-a"],
60 ["session-b", "token-b"],
61 ] as const) {
62 const created = yield* terminals.create({
63 sessionId,
64 command: process.execPath,
65 args: ["-e", "process.stdout.write(process.env.T3_TEST_ACP_SESSION_TOKEN ?? '')"],
66 });
67 yield* terminals.waitForExit({ sessionId, terminalId: created.terminalId });
68 const output = yield* terminals.output({ sessionId, terminalId: created.terminalId });
69 expect(output.output).toBe(expected);
70 }
71 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
72 );
73
74 it.effect("loads per-session progress without losing provider or command Node options", () =>
75 Effect.gen(function* () {
76 const environment = { NODE_OPTIONS: "--no-warnings", PROVIDER_SETTING: "keep" };
77 const preload = "--import=data:text/javascript,globalThis.progressPreloaded=true";
78 const terminals = yield* makeAcpClientTerminals({
79 defaultCwd: process.cwd(),
80 environment,
81 environmentForSession: (sessionId) => ({
82 NODE_OPTIONS: preload,
83 T3_SCRIPT_PROGRESS_TOKEN: sessionId,
84 }),
85 });
86 yield* Effect.addFinalizer(() => terminals.disposeAll);
87 const results = yield* Effect.all(
88 ["session-one", "session-two"].map((sessionId) =>
89 Effect.gen(function* () {
90 const created = yield* terminals.create({
91 sessionId,
92 command: process.execPath,
93 args: [
94 "-e",
95 "console.log(JSON.stringify({token:process.env.T3_SCRIPT_PROGRESS_TOKEN, options:process.env.NODE_OPTIONS, provider:process.env.PROVIDER_SETTING, loaded:globalThis.progressPreloaded}))",
96 ],
97 env: [
98 { name: "NODE_OPTIONS", value: "--trace-warnings" },
99 { name: "T3_SCRIPT_PROGRESS_TOKEN", value: "stale-inherited-token" },
100 ],
101 });
102 yield* terminals.waitForExit({ sessionId, terminalId: created.terminalId });
103 const output = yield* terminals.output({ sessionId, terminalId: created.terminalId });
104 return JSON.parse(output.output);
105 }),
106 ),
107 { concurrency: "unbounded" },
108 );
109 expect(results).toEqual(
110 ["session-one", "session-two"].map((token) => ({
111 token,
112 options: `${preload} --trace-warnings --no-warnings`,
113 provider: "keep",
114 loaded: true,
115 })),
116 );
117 expect(environment).toEqual({ NODE_OPTIONS: "--no-warnings", PROVIDER_SETTING: "keep" });
118 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
119 );
120
121 it.effect("runs Devin shell source with arguments, pipes, and session fallback variables", () =>
122 Effect.gen(function* () {
123 const terminals = yield* makeAcpClientTerminals({
124 defaultCwd: process.cwd(),
125 shellCommands: true,
126 environmentForSession: () => ({ T3_ACP_MCP_NODE: "mailbox-probe" }),
127 });
128 yield* Effect.addFinalizer(() => terminals.disposeAll);
129 // Same command-only shape as the failed production commands and live capture.
130 const terminal = yield* terminals.create({
131 sessionId: "devin",
132 command: 'printf "%s\\n" "$T3_ACP_MCP_NODE" | tr a-z A-Z',
133 });
134 const exit = yield* terminals.waitForExit({ sessionId: "devin", ...terminal });
135 const output = yield* terminals.output({ sessionId: "devin", ...terminal });
136 expect(exit.exitCode).toBe(0);
137 expect(output.output.trim()).toBe("MAILBOX-PROBE");
138 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
139 );
140
141 it.effect("truncates buffered output from the beginning at the byte limit", () =>
142 withTerminals((terminals) =>
143 Effect.gen(function* () {
144 const created = yield* terminals.create({
145 sessionId: "session",
146 command: process.execPath,
147 args: ["-e", "process.stdout.write('a'.repeat(64) + 'TAIL');"],
148 outputByteLimit: 16,
149 });
150 yield* terminals.waitForExit({ sessionId: "session", terminalId: created.terminalId });
151
152 const output = yield* terminals.output({
153 sessionId: "session",
154 terminalId: created.terminalId,
155 });
156 expect(output.truncated).toBe(true);
157 expect(output.output.endsWith("TAIL")).toBe(true);
158 expect(output.output.length).toBeLessThanOrEqual(16);
159 }),
160 ),
161 );
162
163 it.effect("kills long-running commands and rejects released terminal handles", () =>
164 withTerminals((terminals) =>
165 Effect.gen(function* () {
166 const created = yield* terminals.create({
167 sessionId: "session",
168 command: process.execPath,
169 args: ["-e", "setInterval(() => {}, 1000);"],
170 });
171 yield* terminals.kill({ sessionId: "session", terminalId: created.terminalId });
172 const exit = yield* terminals.waitForExit({
173 sessionId: "session",
174 terminalId: created.terminalId,
175 });
176 expect(exit.exitCode === 0 ? null : exit.exitCode).not.toBe(0);
177 // Windows has no signals: a killed process only reports an exit code.
178 if (yield* HostProcess.isWindows) {
179 expect(exit.signal).toBeNull();
180 } else {
181 expect(exit.signal).toBe("SIGTERM");
182 }
183
184 yield* terminals.release({ sessionId: "session", terminalId: created.terminalId });
185 const rejected = yield* Effect.flip(
186 terminals.output({ sessionId: "session", terminalId: created.terminalId }),
187 );
188 expect(rejected.message).toContain("unknown terminal ID");
189
190 // Embedded tool-call content still renders released terminals.
191 expect(terminals.readOutputSnapshot("session", created.terminalId)).toBeDefined();
192 }),
193 ),
194 );
195
196 it.effect("reserves terminal capacity across concurrent creates", () =>
197 withTerminals((terminals) =>
198 Effect.gen(function* () {
199 const results = yield* Effect.all(
200 Array.from({ length: 17 }, () =>
201 terminals
202 .create({
203 sessionId: "session",
204 command: process.execPath,
205 args: ["-e", "setInterval(() => {}, 1000);"],
206 })
207 .pipe(Effect.result),
208 ),
209 { concurrency: "unbounded" },
210 );
211
212 expect(results.filter(Result.isSuccess)).toHaveLength(16);
213 expect(results.filter(Result.isFailure)).toHaveLength(1);
214 }),
215 ),
216 );
217
218 it.effect("bounds retained snapshots after terminals are released", () =>
219 withTerminals((terminals) =>
220 Effect.gen(function* () {
221 const terminalIds: string[] = [];
222 for (let index = 0; index < 33; index += 1) {
223 const created = yield* terminals.create({
224 sessionId: "session",
225 command: process.execPath,
226 args: ["-e", `process.stdout.write('${index}');`],
227 });
228 yield* terminals.release({ sessionId: "session", terminalId: created.terminalId });
229 terminalIds.push(created.terminalId);
230 }
231
232 expect(terminals.readOutputSnapshot("session", terminalIds[0]!)).toBeUndefined();
233 expect(terminals.readOutputSnapshot("session", terminalIds.at(-1)!)).toBeDefined();
234 }),
235 ),
236 );
237
238 it.effect("bounds unreleased terminal handles without invalidating existing ids", () =>
239 withTerminals((terminals) =>
240 Effect.gen(function* () {
241 const terminalIds: string[] = [];
242 for (let index = 0; index < 64; index += 1) {
243 const created = yield* terminals.create({
244 sessionId: "session",
245 command: process.execPath,
246 args: ["-e", `process.stdout.write('${index}');`],
247 });
248 yield* terminals.waitForExit({
249 sessionId: "session",
250 terminalId: created.terminalId,
251 });
252 terminalIds.push(created.terminalId);
253 }
254
255 const rejected = yield* terminals
256 .create({
257 sessionId: "session",
258 command: process.execPath,
259 args: ["-e", ""],
260 })
261 .pipe(Effect.result);
262 expect(Result.isFailure(rejected)).toBe(true);
263 expect(terminals.readOutputSnapshot("session", terminalIds[0]!)).toBeDefined();
264 expect(terminals.readOutputSnapshot("session", terminalIds.at(-1)!)).toBeDefined();
265 }),
266 ),
267 );
268
269 it.effect("bounds aggregate output retained across unreleased terminals", () =>
270 withTerminals((terminals) =>
271 Effect.gen(function* () {
272 const terminalIds: string[] = [];
273 for (let index = 0; index < 3; index += 1) {
274 const created = yield* terminals.create({
275 sessionId: "session",
276 command: process.execPath,
277 args: ["-e", "process.stdout.write('x'.repeat(8 * 1024 * 1024));"],
278 outputByteLimit: 8 * 1024 * 1024,
279 });
280 yield* terminals.waitForExit({
281 sessionId: "session",
282 terminalId: created.terminalId,
283 });
284 terminalIds.push(created.terminalId);
285 }
286
287 const snapshots = terminalIds.map((terminalId) =>
288 terminals.readOutputSnapshot("session", terminalId)!,
289 );
290 const retainedBytes = snapshots.reduce(
291 (total, snapshot) => total + Buffer.byteLength(snapshot.output),
292 0,
293 );
294 expect(retainedBytes).toBeLessThanOrEqual(16 * 1024 * 1024);
295 expect(snapshots.some((snapshot) => snapshot.truncated)).toBe(true);
296 }),
297 ),
298 );
299
300 it.effect("rejects terminal handles from a different ACP session", () =>
301 withTerminals((terminals) =>
302 Effect.gen(function* () {
303 const created = yield* terminals.create({
304 sessionId: "owner-session",
305 command: process.execPath,
306 args: ["-e", "process.stdout.write('owned');"],
307 });
308 const foreignRequest = {
309 sessionId: "foreign-session",
310 terminalId: created.terminalId,
311 };
312 const results = yield* Effect.all(
313 [
314 terminals.output(foreignRequest),
315 terminals.waitForExit(foreignRequest),
316 terminals.kill(foreignRequest),
317 terminals.release(foreignRequest),
318 ].map((effect) => effect.pipe(Effect.result)),
319 { concurrency: "unbounded" },
320 );
321
322 expect(results.every(Result.isFailure)).toBe(true);
323 expect(terminals.readOutputSnapshot("foreign-session", created.terminalId)).toBeUndefined();
324 expect(terminals.readOutputSnapshot("owner-session", created.terminalId)).toBeDefined();
325 }),
326 ),
327 );
328});
329
330describe("resolveEmbeddedTerminalContent", () => {
331 it("rewrites terminal content into text from the buffered snapshot", () => {
332 const notification = {
333 sessionId: "session",
334 update: {
335 sessionUpdate: "tool_call_update" as const,
336 toolCallId: "call-1",
337 content: [
338 { type: "content" as const, content: { type: "text" as const, text: "before" } },
339 { type: "terminal" as const, terminalId: "t3-term-1" },
340 { type: "terminal" as const, terminalId: "t3-term-unknown" },
341 ],
342 },
343 };
344
345 const resolved = resolveEmbeddedTerminalContent(notification, (sessionId, terminalId) =>
346 sessionId === "session" && terminalId === "t3-term-1"
347 ? { output: "compiled 3 files", truncated: false, exitStatus: undefined }
348 : undefined,
349 );
350
351 expect(resolved.update).toMatchObject({
352 content: [
353 { type: "content", content: { type: "text", text: "before" } },
354 { type: "content", content: { type: "text", text: "compiled 3 files" } },
355 { type: "content", content: { type: "text", text: "[terminal t3-term-unknown]" } },
356 ],
357 });
358 });
359});