| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Result from "effect/Result"; |
| 5 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 6 | |
| 7 | import { |
| 8 | makeAcpClientTerminals, |
| 9 | resolveEmbeddedTerminalContent, |
| 10 | type AcpClientTerminals, |
| 11 | } from "./clientTerminals.ts"; |
| 12 | |
| 13 | const 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 | |
| 21 | describe("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 | |
| 330 | describe("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 | }); |