@simonepri/refined-antigravity-acp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1136 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { PassThrough } from "node:stream";
4
+ import readline from "node:readline";
5
+ import type { ChildProcess } from "node:child_process";
6
+ import {
7
+ ChildSlot,
8
+ createAcpReadableStream,
9
+ createAcpWritableStream,
10
+ createSystemPromptState,
11
+ DEFAULT_HANG_GRACE_MS,
12
+ DEFAULT_HANG_INACTIVITY_MS,
13
+ filterConfigOptions,
14
+ HangDetector,
15
+ parseStepUpdate,
16
+ parseTrajectoryStateUpdate,
17
+ processInboundMessage,
18
+ processOutboundMessage,
19
+ RECYCLE_CONFIG_PREFIX,
20
+ RECYCLE_INIT_ID,
21
+ RECYCLE_LOAD_ID,
22
+ RECYCLE_MODE_ID,
23
+ rewriteMcpServers,
24
+ } from "./connector.js";
25
+ import { StreamSanitizer } from "./sanitize.js";
26
+ import type { AcpStreamMessage } from "@getpaseo/plugin/server/acp";
27
+
28
+ describe("filterConfigOptions", () => {
29
+ it("removes option with id: mode from configOptions", () => {
30
+ const result = {
31
+ configOptions: [
32
+ { id: "model", name: "Model", category: "model", type: "select" },
33
+ { id: "mode", name: "Session Mode", category: "mode", type: "select" },
34
+ { id: "other_setting", name: "Other", type: "boolean" },
35
+ ],
36
+ };
37
+
38
+ filterConfigOptions(result);
39
+
40
+ expect(result.configOptions).toEqual([
41
+ { id: "model", name: "Model", category: "model", type: "select" },
42
+ { id: "other_setting", name: "Other", type: "boolean" },
43
+ ]);
44
+ });
45
+
46
+ it("removes option with category: mode even if id differs", () => {
47
+ const result = {
48
+ configOptions: [
49
+ { id: "custom_mode", name: "Mode Selector", category: "mode", type: "select" },
50
+ { id: "model", name: "Model", category: "model", type: "select" },
51
+ ],
52
+ };
53
+
54
+ filterConfigOptions(result);
55
+
56
+ expect(result.configOptions).toEqual([
57
+ { id: "model", name: "Model", category: "model", type: "select" },
58
+ ]);
59
+ });
60
+
61
+ it("handles null, undefined, or missing configOptions gracefully", () => {
62
+ expect(() => filterConfigOptions(null)).not.toThrow();
63
+ expect(() => filterConfigOptions(undefined)).not.toThrow();
64
+ expect(() => filterConfigOptions({})).not.toThrow();
65
+ expect(() => filterConfigOptions({ configOptions: "invalid" })).not.toThrow();
66
+ });
67
+ });
68
+
69
+ describe("rewriteMcpServers", () => {
70
+ it("normalizes object headers to array and leaves the transport type alone", async () => {
71
+ const servers = [
72
+ {
73
+ name: "test-server",
74
+ type: "http",
75
+ url: "http://127.0.0.1:8080/mcp",
76
+ headers: { Authorization: "Bearer xyz", "X-Custom": "val" },
77
+ },
78
+ ];
79
+
80
+ await rewriteMcpServers(servers);
81
+
82
+ // agy supports http natively; rewriting it to sse only misdescribed the endpoint.
83
+ expect(servers[0].type).toBe("http");
84
+ expect(servers[0].headers).toEqual([
85
+ { name: "Authorization", value: "Bearer xyz" },
86
+ { name: "X-Custom", value: "val" },
87
+ ]);
88
+ });
89
+
90
+ it("routes server.url through the proxy pool when provided", async () => {
91
+ const pool = {
92
+ rewriteUrl: async (url: string) =>
93
+ `http://127.0.0.1:9999${new URL(url).pathname}${new URL(url).search}`,
94
+ };
95
+ const servers = [
96
+ {
97
+ name: "paseo",
98
+ type: "http",
99
+ url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=a1",
100
+ },
101
+ ];
102
+
103
+ await rewriteMcpServers(servers, pool as any);
104
+
105
+ expect(servers[0].type).toBe("http");
106
+ expect(servers[0].url).toBe("http://127.0.0.1:9999/mcp/agents?callerAgentId=a1");
107
+ });
108
+
109
+ it("propagates a proxy failure instead of leaving the URL un-normalized", async () => {
110
+ const pool = {
111
+ rewriteUrl: async () => {
112
+ throw new Error("MCP proxy for http://127.0.0.1:6767 is not running");
113
+ },
114
+ };
115
+ const servers = [{ name: "paseo", type: "http", url: "http://127.0.0.1:6767/mcp/agents" }];
116
+
117
+ await expect(rewriteMcpServers(servers, pool as any)).rejects.toThrow(/not running/);
118
+ expect(servers[0].url).toBe("http://127.0.0.1:6767/mcp/agents");
119
+ });
120
+
121
+ it("normalizes array headers with key property to name property", async () => {
122
+ const servers = [
123
+ {
124
+ name: "test-array",
125
+ type: "http",
126
+ url: "http://127.0.0.1:8080/mcp",
127
+ headers: [{ key: "Authorization", value: "Bearer token" }],
128
+ },
129
+ ];
130
+
131
+ await rewriteMcpServers(servers);
132
+
133
+ expect(servers[0].headers).toEqual([{ name: "Authorization", value: "Bearer token" }]);
134
+ });
135
+ });
136
+
137
+ describe("processOutboundMessage", () => {
138
+ it("rewrites mcpServers and maps modeId in session/new", async () => {
139
+ const msg: AcpStreamMessage = {
140
+ jsonrpc: "2.0",
141
+ id: 1,
142
+ method: "session/new",
143
+ params: {
144
+ cwd: "/test",
145
+ modeId: "accept-edits",
146
+ mcpServers: [
147
+ {
148
+ name: "local",
149
+ type: "http",
150
+ url: "http://localhost:3000/sse",
151
+ },
152
+ ],
153
+ },
154
+ } as unknown as AcpStreamMessage;
155
+
156
+ const out = (await processOutboundMessage(msg)) as unknown as {
157
+ params: { modeId: string; mcpServers: Array<{ type: string }> };
158
+ };
159
+
160
+ expect(out.params.modeId).toBe("auto_edit");
161
+ expect(out.params.mcpServers[0].type).toBe("http");
162
+ });
163
+
164
+ it("maps dangerously-skip-permissions to yolo and plan to default", async () => {
165
+ const msg1 = {
166
+ jsonrpc: "2.0",
167
+ id: 2,
168
+ method: "session/set_mode",
169
+ params: { modeId: "dangerously-skip-permissions" },
170
+ } as unknown as AcpStreamMessage;
171
+ const out1 = (await processOutboundMessage(msg1)) as unknown as { params: { modeId: string } };
172
+ expect(out1.params.modeId).toBe("yolo");
173
+
174
+ const msg2 = {
175
+ jsonrpc: "2.0",
176
+ id: 3,
177
+ method: "session/set_mode",
178
+ params: { modeId: "plan" },
179
+ } as unknown as AcpStreamMessage;
180
+ const out2 = (await processOutboundMessage(msg2)) as unknown as { params: { modeId: string } };
181
+ expect(out2.params.modeId).toBe("default");
182
+ });
183
+ });
184
+
185
+ describe("processInboundMessage", () => {
186
+ it("filters mode configOptions from response", () => {
187
+ const msg: AcpStreamMessage = {
188
+ jsonrpc: "2.0",
189
+ id: 1,
190
+ result: {
191
+ sessionId: "sess_1",
192
+ configOptions: [{ id: "mode" }, { id: "model" }],
193
+ },
194
+ } as unknown as AcpStreamMessage;
195
+
196
+ const [out] = processInboundMessage(msg) as unknown as Array<{
197
+ result: { configOptions: unknown[] };
198
+ }>;
199
+ expect(out.result.configOptions).toEqual([{ id: "model" }]);
200
+ });
201
+
202
+ it("sanitizes assistant text updates", () => {
203
+ const msg: AcpStreamMessage = {
204
+ jsonrpc: "2.0",
205
+ method: "session/update",
206
+ params: {
207
+ update: {
208
+ content: { text: "Hello <system_message>hidden</system_message> world" },
209
+ },
210
+ },
211
+ } as unknown as AcpStreamMessage;
212
+
213
+ const [out] = processInboundMessage(msg) as unknown as Array<{
214
+ params: { update: { content: { text: string } } };
215
+ }>;
216
+ expect(out.params.update.content.text).toBe("Hello world");
217
+ });
218
+
219
+ it("emits buffered look-ahead text at the turn boundary instead of dropping it", () => {
220
+ const sanitizer = new StreamSanitizer();
221
+ const state = { lastSessionId: null as string | null };
222
+
223
+ // A trailing "<" is held back in case it opens a harness tag.
224
+ const chunk = {
225
+ jsonrpc: "2.0",
226
+ method: "session/update",
227
+ params: {
228
+ sessionId: "sess_1",
229
+ update: { sessionUpdate: "agent_message_chunk", content: { text: "keep 5 <" } },
230
+ },
231
+ } as unknown as AcpStreamMessage;
232
+ const streamed = processInboundMessage(chunk, sanitizer, state) as unknown as Array<{
233
+ params: { update: { content: { text: string } } };
234
+ }>;
235
+ expect(streamed[0].params.update.content.text).toBe("keep 5 ");
236
+
237
+ // The turn ends without the tag ever arriving, so the held text is real output.
238
+ const response = { jsonrpc: "2.0", id: 7, result: { ok: true } } as unknown as AcpStreamMessage;
239
+ const flushed = processInboundMessage(response, sanitizer, state) as unknown as Array<{
240
+ params?: { sessionId?: string; update?: { content?: { text?: string } } };
241
+ result?: unknown;
242
+ }>;
243
+ expect(flushed).toHaveLength(2);
244
+ expect(flushed[0].params?.sessionId).toBe("sess_1");
245
+ expect(flushed[0].params?.update?.content?.text).toBe("<");
246
+ expect(flushed[1].result).toEqual({ ok: true });
247
+ });
248
+ });
249
+
250
+ describe("system context injection", () => {
251
+ it("injects system context and formatting guidance on first prompt of session/new", async () => {
252
+ const promptState = createSystemPromptState();
253
+
254
+ const newMsg = {
255
+ jsonrpc: "2.0",
256
+ id: 10,
257
+ method: "session/new",
258
+ params: {
259
+ cwd: "/workspace",
260
+ _meta: {
261
+ _paseo: {
262
+ systemPrompt: "You are an expert DevOps engineer.",
263
+ },
264
+ },
265
+ },
266
+ } as unknown as AcpStreamMessage;
267
+ await processOutboundMessage(newMsg, undefined, promptState);
268
+
269
+ const resMsg = {
270
+ jsonrpc: "2.0",
271
+ id: 10,
272
+ result: {
273
+ sessionId: "native-sess-1",
274
+ },
275
+ } as unknown as AcpStreamMessage;
276
+ processInboundMessage(resMsg, undefined, undefined, promptState);
277
+
278
+ const promptMsg1 = {
279
+ jsonrpc: "2.0",
280
+ id: 11,
281
+ method: "session/prompt",
282
+ params: {
283
+ sessionId: "native-sess-1",
284
+ prompt: [{ type: "text", text: "Diagnose high CPU usage" }],
285
+ },
286
+ } as unknown as AcpStreamMessage;
287
+ const outPrompt1 = (await processOutboundMessage(
288
+ promptMsg1,
289
+ undefined,
290
+ promptState,
291
+ )) as unknown as {
292
+ params: { prompt: Array<{ type: string; text: string }> };
293
+ };
294
+
295
+ expect(outPrompt1.params.prompt[0].text).toContain(
296
+ "[System Context]:\nYou are an expert DevOps engineer.",
297
+ );
298
+ expect(outPrompt1.params.prompt[0].text).toContain("[Formatting Guidance]:");
299
+ expect(outPrompt1.params.prompt[0].text).toContain("Diagnose high CPU usage");
300
+ expect(outPrompt1.params.prompt[0].text.endsWith("\n\nDiagnose high CPU usage")).toBe(true);
301
+
302
+ const promptMsg2 = {
303
+ jsonrpc: "2.0",
304
+ id: 12,
305
+ method: "session/prompt",
306
+ params: {
307
+ sessionId: "native-sess-1",
308
+ prompt: [{ type: "text", text: "Check memory too" }],
309
+ },
310
+ } as unknown as AcpStreamMessage;
311
+ const outPrompt2 = (await processOutboundMessage(
312
+ promptMsg2,
313
+ undefined,
314
+ promptState,
315
+ )) as unknown as {
316
+ params: { prompt: Array<{ type: string; text: string }> };
317
+ };
318
+
319
+ expect(outPrompt2.params.prompt[0].text).toBe("Check memory too");
320
+ expect(outPrompt2.params.prompt[0].text).not.toContain("[System Context]");
321
+ expect(outPrompt2.params.prompt[0].text).not.toContain("[Formatting Guidance]");
322
+ });
323
+
324
+ it("does not inject system context on session/load", async () => {
325
+ const promptState = createSystemPromptState();
326
+
327
+ const loadMsg = {
328
+ jsonrpc: "2.0",
329
+ id: 20,
330
+ method: "session/load",
331
+ params: {
332
+ sessionId: "resumed-sess-1",
333
+ _meta: {
334
+ _paseo: {
335
+ systemPrompt: "Should be ignored on load",
336
+ },
337
+ },
338
+ },
339
+ } as unknown as AcpStreamMessage;
340
+ await processOutboundMessage(loadMsg, undefined, promptState);
341
+
342
+ const resMsg = {
343
+ jsonrpc: "2.0",
344
+ id: 20,
345
+ result: {
346
+ sessionId: "resumed-sess-1",
347
+ },
348
+ } as unknown as AcpStreamMessage;
349
+ processInboundMessage(resMsg, undefined, undefined, promptState);
350
+
351
+ const promptMsg = {
352
+ jsonrpc: "2.0",
353
+ id: 21,
354
+ method: "session/prompt",
355
+ params: {
356
+ sessionId: "resumed-sess-1",
357
+ prompt: [{ type: "text", text: "Continue the previous task" }],
358
+ },
359
+ } as unknown as AcpStreamMessage;
360
+ const outPrompt = (await processOutboundMessage(
361
+ promptMsg,
362
+ undefined,
363
+ promptState,
364
+ )) as unknown as {
365
+ params: { prompt: Array<{ type: string; text: string }> };
366
+ };
367
+
368
+ expect(outPrompt.params.prompt[0].text).toBe("Continue the previous task");
369
+ expect(outPrompt.params.prompt[0].text).not.toContain("[System Context]");
370
+ expect(outPrompt.params.prompt[0].text).not.toContain("[Formatting Guidance]");
371
+ });
372
+
373
+ it("injects formatting guidance even when session/new provides no system prompt", async () => {
374
+ const promptState = createSystemPromptState();
375
+
376
+ const newMsg = {
377
+ jsonrpc: "2.0",
378
+ id: 30,
379
+ method: "session/new",
380
+ params: { cwd: "/workspace" },
381
+ } as unknown as AcpStreamMessage;
382
+ await processOutboundMessage(newMsg, undefined, promptState);
383
+
384
+ const resMsg = {
385
+ jsonrpc: "2.0",
386
+ id: 30,
387
+ result: { sessionId: "native-sess-no-prompt" },
388
+ } as unknown as AcpStreamMessage;
389
+ processInboundMessage(resMsg, undefined, undefined, promptState);
390
+
391
+ const promptMsg = {
392
+ jsonrpc: "2.0",
393
+ id: 31,
394
+ method: "session/prompt",
395
+ params: {
396
+ sessionId: "native-sess-no-prompt",
397
+ prompt: [{ type: "text", text: "Hello there" }],
398
+ },
399
+ } as unknown as AcpStreamMessage;
400
+ const outPrompt = (await processOutboundMessage(
401
+ promptMsg,
402
+ undefined,
403
+ promptState,
404
+ )) as unknown as {
405
+ params: { prompt: Array<{ type: string; text: string }> };
406
+ };
407
+
408
+ expect(outPrompt.params.prompt[0].text).not.toContain("[System Context]");
409
+ expect(outPrompt.params.prompt[0].text).toContain("[Formatting Guidance]:");
410
+ expect(outPrompt.params.prompt[0].text.endsWith("\n\nHello there")).toBe(true);
411
+ });
412
+ });
413
+
414
+ describe("parseTrajectoryStateUpdate", () => {
415
+ it("parses exact RAW WS MSG line", () => {
416
+ const line =
417
+ 'RAW WS MSG: {"trajectoryStateUpdate":{"trajectoryId":"sess_1","state":"STATE_WAITING_FOR_TASKS"}}';
418
+ expect(parseTrajectoryStateUpdate(line)).toEqual({
419
+ trajectoryId: "sess_1",
420
+ state: "STATE_WAITING_FOR_TASKS",
421
+ });
422
+ });
423
+
424
+ it("parses line with log prefix and extra fields", () => {
425
+ const line =
426
+ '2026-09-22 10:00:00 INFO RAW WS MSG: {"trajectoryStateUpdate":{"trajectoryId":"sess_2","state":"STATE_RUNNING"},"other":123}';
427
+ expect(parseTrajectoryStateUpdate(line)).toEqual({
428
+ trajectoryId: "sess_2",
429
+ state: "STATE_RUNNING",
430
+ });
431
+ });
432
+
433
+ it("parses line without RAW WS MSG marker but with trajectoryStateUpdate", () => {
434
+ const line = '{"trajectoryStateUpdate":{"trajectoryId":"sess_3","state":"STATE_FULLY_IDLE"}}';
435
+ expect(parseTrajectoryStateUpdate(line)).toEqual({
436
+ trajectoryId: "sess_3",
437
+ state: "STATE_FULLY_IDLE",
438
+ });
439
+ });
440
+
441
+ it("returns null for non-JSON or unrelated stderr lines", () => {
442
+ expect(parseTrajectoryStateUpdate("Starting Antigravity ACP server...")).toBeNull();
443
+ expect(parseTrajectoryStateUpdate('RAW WS MSG: {"otherField": true}')).toBeNull();
444
+ expect(parseTrajectoryStateUpdate("RAW WS MSG: {invalid json")).toBeNull();
445
+ });
446
+
447
+ it("returns null if trajectoryId or state is missing or invalid", () => {
448
+ expect(
449
+ parseTrajectoryStateUpdate('RAW WS MSG: {"trajectoryStateUpdate":{"trajectoryId":123}}'),
450
+ ).toBeNull();
451
+ expect(
452
+ parseTrajectoryStateUpdate(
453
+ 'RAW WS MSG: {"trajectoryStateUpdate":{"trajectoryId":"s1","state":456}}',
454
+ ),
455
+ ).toBeNull();
456
+ });
457
+ });
458
+
459
+ describe("parseStepUpdate", () => {
460
+ it("extracts trajectoryId, state, target, and source from RAW WS MSG", () => {
461
+ const line =
462
+ 'I0922 13:54:15.822065 8416960960 local_connection.py:521] RAW WS MSG: {"stepUpdate":{"cascadeId":"s1","trajectoryId":"s1","stepIndex":1867,"state":"STATE_DONE","source":"SOURCE_MODEL","target":"TARGET_USER","text":""}}';
463
+ expect(parseStepUpdate(line)).toEqual({
464
+ trajectoryId: "s1",
465
+ state: "STATE_DONE",
466
+ target: "TARGET_USER",
467
+ source: "SOURCE_MODEL",
468
+ });
469
+ });
470
+
471
+ it("handles environment tool calls", () => {
472
+ const line =
473
+ 'RAW WS MSG: {"stepUpdate":{"trajectoryId":"s1","state":"STATE_ACTIVE","target":"TARGET_ENVIRONMENT","source":"SOURCE_MODEL"}}';
474
+ expect(parseStepUpdate(line)).toEqual({
475
+ trajectoryId: "s1",
476
+ state: "STATE_ACTIVE",
477
+ target: "TARGET_ENVIRONMENT",
478
+ source: "SOURCE_MODEL",
479
+ });
480
+ });
481
+
482
+ it("returns null for non-stepUpdate lines", () => {
483
+ expect(parseStepUpdate("regular log line")).toBeNull();
484
+ expect(parseStepUpdate('RAW WS MSG: {"usageUpdate":{}}')).toBeNull();
485
+ });
486
+ });
487
+
488
+ describe("HangDetector", () => {
489
+ beforeEach(() => {
490
+ vi.useFakeTimers();
491
+ });
492
+
493
+ afterEach(() => {
494
+ vi.restoreAllMocks();
495
+ vi.useRealTimers();
496
+ });
497
+
498
+ it("defaults to 5000ms grace and 30,000ms inactivity", () => {
499
+ const detector = new HangDetector();
500
+ expect(detector.graceMs).toBe(DEFAULT_HANG_GRACE_MS);
501
+ expect(detector.inactivityMs).toBe(DEFAULT_HANG_INACTIVITY_MS);
502
+ detector.dispose();
503
+ });
504
+
505
+ it("stepUpdate targeting TARGET_USER with STATE_DONE triggers grace timer and declares hang on expiration", () => {
506
+ const onHangDeclared = vi.fn();
507
+ const detector = new HangDetector({ graceMs: 1000, onHangDeclared });
508
+
509
+ detector.processOutbound({
510
+ jsonrpc: "2.0",
511
+ id: 201,
512
+ method: "session/prompt",
513
+ params: { sessionId: "sess_user_turn" },
514
+ } as unknown as AcpStreamMessage);
515
+
516
+ detector.processStderrLine(
517
+ 'RAW WS MSG: {"stepUpdate":{"trajectoryId":"sess_user_turn","state":"STATE_DONE","target":"TARGET_USER","source":"SOURCE_MODEL"}}',
518
+ );
519
+
520
+ expect(onHangDeclared).not.toHaveBeenCalled();
521
+
522
+ vi.advanceTimersByTime(999);
523
+ expect(onHangDeclared).not.toHaveBeenCalled();
524
+
525
+ vi.advanceTimersByTime(1);
526
+ expect(onHangDeclared).toHaveBeenCalledTimes(1);
527
+ expect(onHangDeclared).toHaveBeenCalledWith("sess_user_turn", 201);
528
+ expect(detector.isHung("sess_user_turn")).toBe(true);
529
+
530
+ detector.dispose();
531
+ });
532
+
533
+ it("subsequent TARGET_ENVIRONMENT step clears pending turn-completion grace timer", () => {
534
+ const onHangDeclared = vi.fn();
535
+ const detector = new HangDetector({ graceMs: 1000, onHangDeclared });
536
+
537
+ detector.processOutbound({
538
+ jsonrpc: "2.0",
539
+ id: 202,
540
+ method: "session/prompt",
541
+ params: { sessionId: "sess_env_followup" },
542
+ } as unknown as AcpStreamMessage);
543
+
544
+ // Agent finishes user message chunk
545
+ detector.processStderrLine(
546
+ 'RAW WS MSG: {"stepUpdate":{"trajectoryId":"sess_env_followup","state":"STATE_DONE","target":"TARGET_USER","source":"SOURCE_MODEL"}}',
547
+ );
548
+
549
+ vi.advanceTimersByTime(500);
550
+
551
+ // Agent continues by starting environment tool call
552
+ detector.processStderrLine(
553
+ 'RAW WS MSG: {"stepUpdate":{"trajectoryId":"sess_env_followup","state":"STATE_ACTIVE","target":"TARGET_ENVIRONMENT","source":"SOURCE_MODEL"}}',
554
+ );
555
+
556
+ // Advance past grace timer
557
+ vi.advanceTimersByTime(1000);
558
+ expect(onHangDeclared).not.toHaveBeenCalled();
559
+ expect(detector.isHung("sess_env_followup")).toBe(false);
560
+
561
+ detector.dispose();
562
+ });
563
+
564
+ it("STATE_WAITING_FOR_TASKS triggers grace timer and calls onHang on expiration", () => {
565
+ const onHangDeclared = vi.fn();
566
+ const detector = new HangDetector({ graceMs: 1000, onHangDeclared });
567
+
568
+ detector.processOutbound({
569
+ jsonrpc: "2.0",
570
+ id: 101,
571
+ method: "session/prompt",
572
+ params: { sessionId: "sess_hang" },
573
+ } as unknown as AcpStreamMessage);
574
+
575
+ detector.processStderrLine(
576
+ 'RAW WS MSG: {"trajectoryStateUpdate":{"trajectoryId":"sess_hang","state":"STATE_WAITING_FOR_TASKS"}}',
577
+ );
578
+
579
+ expect(detector.lastStates.get("sess_hang")).toBe("STATE_WAITING_FOR_TASKS");
580
+ expect(onHangDeclared).not.toHaveBeenCalled();
581
+
582
+ vi.advanceTimersByTime(999);
583
+ expect(onHangDeclared).not.toHaveBeenCalled();
584
+
585
+ vi.advanceTimersByTime(1);
586
+ expect(onHangDeclared).toHaveBeenCalledTimes(1);
587
+ expect(onHangDeclared).toHaveBeenCalledWith("sess_hang", 101);
588
+ expect(detector.isHung("sess_hang")).toBe(true);
589
+
590
+ detector.dispose();
591
+ });
592
+
593
+ it("Early response before grace timer clears the timer and does NOT trigger onHang", () => {
594
+ const onHangDeclared = vi.fn();
595
+ const detector = new HangDetector({ graceMs: 1000, onHangDeclared });
596
+
597
+ detector.processOutbound({
598
+ jsonrpc: "2.0",
599
+ id: 102,
600
+ method: "session/prompt",
601
+ params: { sessionId: "sess_ok" },
602
+ } as unknown as AcpStreamMessage);
603
+
604
+ detector.processStderrLine(
605
+ 'RAW WS MSG: {"trajectoryStateUpdate":{"trajectoryId":"sess_ok","state":"STATE_WAITING_FOR_TASKS"}}',
606
+ );
607
+
608
+ vi.advanceTimersByTime(500);
609
+
610
+ // Prompt finishes before grace timer expires
611
+ detector.processInbound({
612
+ jsonrpc: "2.0",
613
+ id: 102,
614
+ result: { ok: true },
615
+ } as unknown as AcpStreamMessage);
616
+
617
+ expect(detector.pendingPrompts.has(102)).toBe(false);
618
+
619
+ vi.advanceTimersByTime(1000);
620
+ expect(onHangDeclared).not.toHaveBeenCalled();
621
+ expect(detector.isHung("sess_ok")).toBe(false);
622
+
623
+ detector.dispose();
624
+ });
625
+
626
+ it("Early error response before grace timer clears the timer and does NOT trigger onHang", () => {
627
+ const onHangDeclared = vi.fn();
628
+ const detector = new HangDetector({ graceMs: 1000, onHangDeclared });
629
+
630
+ detector.processOutbound({
631
+ jsonrpc: "2.0",
632
+ id: 103,
633
+ method: "session/prompt",
634
+ params: { sessionId: "sess_err" },
635
+ } as unknown as AcpStreamMessage);
636
+
637
+ detector.processStderrLine(
638
+ 'RAW WS MSG: {"trajectoryStateUpdate":{"trajectoryId":"sess_err","state":"STATE_WAITING_FOR_TASKS"}}',
639
+ );
640
+
641
+ vi.advanceTimersByTime(300);
642
+
643
+ // Inbound error response
644
+ detector.processInbound({
645
+ jsonrpc: "2.0",
646
+ id: 103,
647
+ error: { code: -32000, message: "Cancelled" },
648
+ } as unknown as AcpStreamMessage);
649
+
650
+ expect(detector.pendingPrompts.has(103)).toBe(false);
651
+
652
+ vi.advanceTimersByTime(1000);
653
+ expect(onHangDeclared).not.toHaveBeenCalled();
654
+
655
+ detector.dispose();
656
+ });
657
+
658
+ it("session/update refreshes lastActivityAt", () => {
659
+ const onHangDeclared = vi.fn();
660
+ const detector = new HangDetector({ inactivityMs: 5000, onHangDeclared });
661
+
662
+ detector.processOutbound({
663
+ jsonrpc: "2.0",
664
+ id: 104,
665
+ method: "session/prompt",
666
+ params: { sessionId: "sess_active" },
667
+ } as unknown as AcpStreamMessage);
668
+
669
+ const initialActivity = detector.pendingPrompts.get(104)?.lastActivityAt;
670
+
671
+ vi.advanceTimersByTime(3000);
672
+
673
+ detector.processInbound({
674
+ jsonrpc: "2.0",
675
+ method: "session/update",
676
+ params: {
677
+ sessionId: "sess_active",
678
+ update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "chunk" } },
679
+ },
680
+ } as unknown as AcpStreamMessage);
681
+
682
+ const refreshedActivity = detector.pendingPrompts.get(104)?.lastActivityAt;
683
+ expect(refreshedActivity).toBe((initialActivity ?? 0) + 3000);
684
+
685
+ // At 6000ms total, initial 5000ms window has passed, but inactivity timer was refreshed at 3000ms
686
+ vi.advanceTimersByTime(3000);
687
+ expect(onHangDeclared).not.toHaveBeenCalled();
688
+
689
+ // At 8000ms total (5000ms from last update), inactivity fires
690
+ vi.advanceTimersByTime(2000);
691
+ expect(onHangDeclared).toHaveBeenCalledTimes(1);
692
+ expect(onHangDeclared).toHaveBeenCalledWith("sess_active", 104);
693
+
694
+ detector.dispose();
695
+ });
696
+
697
+ it("Inactivity backstop fires if no updates arrive", () => {
698
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
699
+ const onHangDeclared = vi.fn();
700
+ const detector = new HangDetector({ inactivityMs: 2000, onHangDeclared });
701
+
702
+ detector.processOutbound({
703
+ jsonrpc: "2.0",
704
+ id: 105,
705
+ method: "session/prompt",
706
+ params: { sessionId: "sess_quiet" },
707
+ } as unknown as AcpStreamMessage);
708
+
709
+ vi.advanceTimersByTime(1999);
710
+ expect(onHangDeclared).not.toHaveBeenCalled();
711
+
712
+ vi.advanceTimersByTime(1);
713
+ expect(onHangDeclared).toHaveBeenCalledTimes(1);
714
+ expect(onHangDeclared).toHaveBeenCalledWith("sess_quiet", 105);
715
+ expect(detector.isHung("sess_quiet")).toBe(true);
716
+
717
+ expect(consoleSpy).toHaveBeenCalledWith(
718
+ "[paseo-antigravity] Inactivity backstop fired for session sess_quiet, prompt 105",
719
+ );
720
+
721
+ detector.dispose();
722
+ });
723
+
724
+ it("cancels grace timer if state transitions away from STATE_WAITING_FOR_TASKS", () => {
725
+ const onHangDeclared = vi.fn();
726
+ const detector = new HangDetector({ graceMs: 1000, onHangDeclared });
727
+
728
+ detector.processOutbound({
729
+ jsonrpc: "2.0",
730
+ id: 106,
731
+ method: "session/prompt",
732
+ params: { sessionId: "sess_resume" },
733
+ } as unknown as AcpStreamMessage);
734
+
735
+ detector.processStderrLine(
736
+ 'RAW WS MSG: {"trajectoryStateUpdate":{"trajectoryId":"sess_resume","state":"STATE_WAITING_FOR_TASKS"}}',
737
+ );
738
+
739
+ vi.advanceTimersByTime(500);
740
+
741
+ // Harness resumes running
742
+ detector.processStderrLine(
743
+ 'RAW WS MSG: {"trajectoryStateUpdate":{"trajectoryId":"sess_resume","state":"STATE_RUNNING"}}',
744
+ );
745
+
746
+ vi.advanceTimersByTime(1000);
747
+ expect(onHangDeclared).not.toHaveBeenCalled();
748
+ expect(detector.isHung("sess_resume")).toBe(false);
749
+
750
+ detector.dispose();
751
+ });
752
+
753
+ it("dispose cancels pending timers", () => {
754
+ const onHangDeclared = vi.fn();
755
+ const detector = new HangDetector({ graceMs: 1000, inactivityMs: 2000, onHangDeclared });
756
+
757
+ detector.processOutbound({
758
+ jsonrpc: "2.0",
759
+ id: 107,
760
+ method: "session/prompt",
761
+ params: { sessionId: "sess_dispose" },
762
+ } as unknown as AcpStreamMessage);
763
+
764
+ detector.processStderrLine(
765
+ 'RAW WS MSG: {"trajectoryStateUpdate":{"trajectoryId":"sess_dispose","state":"STATE_WAITING_FOR_TASKS"}}',
766
+ );
767
+
768
+ detector.dispose();
769
+
770
+ vi.advanceTimersByTime(5000);
771
+ expect(onHangDeclared).not.toHaveBeenCalled();
772
+ expect(detector.pendingPrompts.size).toBe(0);
773
+ });
774
+ });
775
+
776
+ class MockChildProcess extends EventEmitter {
777
+ stdout = new PassThrough();
778
+ stderr = new PassThrough();
779
+ stdin = new PassThrough();
780
+ killed = false;
781
+ killSignals: string[] = [];
782
+
783
+ kill(signal: NodeJS.Signals | number = "SIGTERM"): boolean {
784
+ this.killed = true;
785
+ this.killSignals.push(String(signal));
786
+ this.emit("close");
787
+ return true;
788
+ }
789
+ }
790
+
791
+ describe("ChildSlot & Process Recycling", () => {
792
+ beforeEach(() => {
793
+ vi.useFakeTimers();
794
+ });
795
+
796
+ afterEach(() => {
797
+ vi.restoreAllMocks();
798
+ vi.useRealTimers();
799
+ });
800
+
801
+ it("emits synthetic end_turn on hang declaration and suppresses late real response", async () => {
802
+ const child1 = new MockChildProcess();
803
+ const slot = new ChildSlot({
804
+ cmd: "test-cmd",
805
+ args: [],
806
+ initialChild: child1 as unknown as ChildProcess,
807
+ options: { graceMs: 1000 },
808
+ });
809
+
810
+ const receivedChunks: AcpStreamMessage[] = [];
811
+ const readable = createAcpReadableStream(slot);
812
+ const reader = readable.getReader();
813
+
814
+ // Start reading in background
815
+ const readPromise = (async () => {
816
+ while (true) {
817
+ const { value, done } = await reader.read();
818
+ if (done || !value) break;
819
+ receivedChunks.push(value);
820
+ }
821
+ })();
822
+
823
+ // 1. Send outbound prompt
824
+ slot.hangDetector.processOutbound({
825
+ jsonrpc: "2.0",
826
+ id: 201,
827
+ method: "session/prompt",
828
+ params: { sessionId: "sess_hang_test" },
829
+ } as unknown as AcpStreamMessage);
830
+
831
+ // 2. Trajectory state update indicates STATE_WAITING_FOR_TASKS
832
+ slot.hangDetector.processStderrLine(
833
+ 'RAW WS MSG: {"trajectoryStateUpdate":{"trajectoryId":"sess_hang_test","state":"STATE_WAITING_FOR_TASKS"}}',
834
+ );
835
+
836
+ // 3. Fast forward time to trigger grace timer hang declaration
837
+ await vi.advanceTimersByTimeAsync(1000);
838
+
839
+ // Verify synthetic end_turn was enqueued
840
+ await vi.waitFor(() => expect(receivedChunks.length).toBe(1));
841
+ expect(receivedChunks[0]).toEqual({
842
+ jsonrpc: "2.0",
843
+ id: 201,
844
+ result: { stopReason: "end_turn" },
845
+ });
846
+
847
+ // Session must be marked as needing recycle
848
+ const session = slot.sessionCache.sessions.get("sess_hang_test");
849
+ expect(session?.needsRecycle).toBe(true);
850
+
851
+ // 4. Late real response arrives from child
852
+ slot.handleStdoutLine(
853
+ JSON.stringify({
854
+ jsonrpc: "2.0",
855
+ id: 201,
856
+ result: { stopReason: "end_turn", extra: "late_response" },
857
+ }),
858
+ );
859
+
860
+ // Late response must be dropped, not emitted to reader
861
+ expect(receivedChunks.length).toBe(1);
862
+
863
+ slot.close();
864
+ await readPromise;
865
+ });
866
+
867
+ it("transparently recycles child process, swallows session/update history replay, and preserves stream", async () => {
868
+ const child1 = new MockChildProcess();
869
+ const child2 = new MockChildProcess();
870
+
871
+ const spawnMock = vi.fn().mockImplementation(() => child2 as unknown as ChildProcess);
872
+
873
+ const slot = new ChildSlot({
874
+ cmd: "test-cmd",
875
+ args: [],
876
+ initialChild: child1 as unknown as ChildProcess,
877
+ options: {
878
+ spawnProcess: spawnMock,
879
+ recycleTimeoutMs: 5000,
880
+ },
881
+ });
882
+
883
+ const readable = createAcpReadableStream(slot);
884
+ const writable = createAcpWritableStream(slot);
885
+ const writer = writable.getWriter();
886
+ const reader = readable.getReader();
887
+ const receivedChunks: AcpStreamMessage[] = [];
888
+
889
+ const readPromise = (async () => {
890
+ while (true) {
891
+ const { value, done } = await reader.read();
892
+ if (done || !value) break;
893
+ receivedChunks.push(value);
894
+ }
895
+ })();
896
+
897
+ // 1. Initialize session and cache metadata on child1
898
+ await writer.write({
899
+ jsonrpc: "2.0",
900
+ id: "init-1",
901
+ method: "initialize",
902
+ params: { protocolVersion: 1, clientInfo: { name: "test-client", version: "1.0" } },
903
+ } as unknown as AcpStreamMessage);
904
+
905
+ await writer.write({
906
+ jsonrpc: "2.0",
907
+ id: "new-1",
908
+ method: "session/new",
909
+ params: {
910
+ cwd: "/workspace/project",
911
+ mcpServers: [{ name: "srv1", url: "http://localhost:8080" }],
912
+ _meta: { testKey: "testVal" },
913
+ },
914
+ } as unknown as AcpStreamMessage);
915
+
916
+ // Child1 responds to session/new with sessionId
917
+ slot.handleStdoutLine(
918
+ JSON.stringify({
919
+ jsonrpc: "2.0",
920
+ id: "new-1",
921
+ result: { sessionId: "sess_recycle_123" },
922
+ }),
923
+ );
924
+
925
+ await writer.write({
926
+ jsonrpc: "2.0",
927
+ id: "mode-1",
928
+ method: "session/set_mode",
929
+ params: { sessionId: "sess_recycle_123", modeId: "auto_edit" },
930
+ } as unknown as AcpStreamMessage);
931
+
932
+ await writer.write({
933
+ jsonrpc: "2.0",
934
+ id: "cfg-1",
935
+ method: "session/set_config_option",
936
+ params: { sessionId: "sess_recycle_123", configId: "model", value: "claude-3-5-sonnet" },
937
+ } as unknown as AcpStreamMessage);
938
+
939
+ // Verify session metadata is cached
940
+ const cached = slot.sessionCache.sessions.get("sess_recycle_123");
941
+ expect(cached?.cwd).toBe("/workspace/project");
942
+ expect(cached?.lastModeId).toBe("auto_edit");
943
+ expect(cached?.lastConfigOptions.get("model")?.value).toBe("claude-3-5-sonnet");
944
+
945
+ // 2. Turn 1 hangs on prompt 301
946
+ await writer.write({
947
+ jsonrpc: "2.0",
948
+ id: 301,
949
+ method: "session/prompt",
950
+ params: { sessionId: "sess_recycle_123", prompt: [{ type: "text", text: "do work" }] },
951
+ } as unknown as AcpStreamMessage);
952
+
953
+ slot.handleHangDeclared("sess_recycle_123", 301);
954
+
955
+ // Reader receives synthetic end_turn
956
+ await vi.waitFor(() => expect(receivedChunks.length).toBe(2)); // session/new response + synthetic end_turn
957
+ expect(receivedChunks[1]).toEqual({
958
+ jsonrpc: "2.0",
959
+ id: 301,
960
+ result: { stopReason: "end_turn" },
961
+ });
962
+ expect(cached?.needsRecycle).toBe(true);
963
+
964
+ // 3. Turn 2 prompt arrives: triggers transparent process recycle
965
+ // Setup child2 stdin monitor to respond to recycle sequence
966
+ const child2Lines: string[] = [];
967
+ const child2Rl = readline.createInterface({ input: child2.stdin, crlfDelay: Infinity });
968
+
969
+ child2Rl.on("line", (line) => {
970
+ child2Lines.push(line);
971
+ const req = JSON.parse(line);
972
+
973
+ if (req.id === RECYCLE_INIT_ID) {
974
+ // Reply to initialize
975
+ child2.stdout.write(
976
+ JSON.stringify({ jsonrpc: "2.0", id: RECYCLE_INIT_ID, result: {} }) + "\n",
977
+ );
978
+ } else if (req.id === RECYCLE_LOAD_ID) {
979
+ // Before replying to session/load, emit 50 session/update history replay notifications!
980
+ for (let i = 0; i < 50; i++) {
981
+ child2.stdout.write(
982
+ JSON.stringify({
983
+ jsonrpc: "2.0",
984
+ method: "session/update",
985
+ params: {
986
+ sessionId: "sess_recycle_123",
987
+ update: {
988
+ sessionUpdate: "agent_message_chunk",
989
+ content: { type: "text", text: `hist ${i}` },
990
+ },
991
+ },
992
+ }) + "\n",
993
+ );
994
+ }
995
+ // Then reply to session/load
996
+ child2.stdout.write(
997
+ JSON.stringify({ jsonrpc: "2.0", id: RECYCLE_LOAD_ID, result: {} }) + "\n",
998
+ );
999
+ } else if (req.id === RECYCLE_MODE_ID) {
1000
+ child2.stdout.write(
1001
+ JSON.stringify({ jsonrpc: "2.0", id: RECYCLE_MODE_ID, result: {} }) + "\n",
1002
+ );
1003
+ } else if (req.id === `${RECYCLE_CONFIG_PREFIX}model`) {
1004
+ child2.stdout.write(
1005
+ JSON.stringify({ jsonrpc: "2.0", id: `${RECYCLE_CONFIG_PREFIX}model`, result: {} }) +
1006
+ "\n",
1007
+ );
1008
+ }
1009
+ });
1010
+
1011
+ // Write next outbound prompt
1012
+ const writeTurn2Promise = writer.write({
1013
+ jsonrpc: "2.0",
1014
+ id: 302,
1015
+ method: "session/prompt",
1016
+ params: {
1017
+ sessionId: "sess_recycle_123",
1018
+ prompt: [{ type: "text", text: "second question" }],
1019
+ },
1020
+ } as unknown as AcpStreamMessage);
1021
+
1022
+ // Give microtasks / I/O a moment to process
1023
+ await vi.waitFor(() => expect(child1.killed).toBe(true));
1024
+ expect(child1.killSignals).toContain("SIGKILL");
1025
+ expect(spawnMock).toHaveBeenCalledTimes(1);
1026
+
1027
+ await writeTurn2Promise;
1028
+
1029
+ // Verify all 50 history session/update notifications were dropped / swallowed!
1030
+ const historyUpdatesReceived = receivedChunks.filter(
1031
+ (c) => (c as { method?: string }).method === "session/update",
1032
+ );
1033
+ expect(historyUpdatesReceived.length).toBe(0);
1034
+
1035
+ // Verify child2 received init, load (with cwd & mcpServers), mode, config, and then prompt 302
1036
+ const parsedRequests = child2Lines.map((l) => JSON.parse(l));
1037
+ expect(parsedRequests[0].id).toBe(RECYCLE_INIT_ID);
1038
+ expect(parsedRequests[1].id).toBe(RECYCLE_LOAD_ID);
1039
+ expect(parsedRequests[1].params.cwd).toBe("/workspace/project");
1040
+ expect(parsedRequests[2].id).toBe(RECYCLE_MODE_ID);
1041
+ expect(parsedRequests[2].params.modeId).toBe("auto_edit");
1042
+ expect(parsedRequests[3].id).toBe(`${RECYCLE_CONFIG_PREFIX}model`);
1043
+ expect(parsedRequests[3].params.value).toBe("claude-3-5-sonnet");
1044
+ expect(parsedRequests[4].id).toBe(302);
1045
+
1046
+ // Child2 replies to prompt 302
1047
+ child2.stdout.write(
1048
+ JSON.stringify({
1049
+ jsonrpc: "2.0",
1050
+ id: 302,
1051
+ result: { stopReason: "end_turn" },
1052
+ }) + "\n",
1053
+ );
1054
+
1055
+ await vi.waitFor(() => expect(receivedChunks.length).toBe(3));
1056
+ expect(receivedChunks[2]).toEqual({
1057
+ jsonrpc: "2.0",
1058
+ id: 302,
1059
+ result: { stopReason: "end_turn" },
1060
+ });
1061
+
1062
+ // Stream remains open and active
1063
+ expect(slot.state.isClosed).toBe(false);
1064
+
1065
+ child2Rl.close();
1066
+ slot.close();
1067
+ await readPromise;
1068
+ });
1069
+
1070
+ it("closes streams and throws on write if process recycle fails", async () => {
1071
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
1072
+ const child1 = new MockChildProcess();
1073
+
1074
+ const spawnMock = vi.fn().mockRejectedValue(new Error("Spawn failure"));
1075
+
1076
+ const slot = new ChildSlot({
1077
+ cmd: "test-cmd",
1078
+ args: [],
1079
+ initialChild: child1 as unknown as ChildProcess,
1080
+ options: { spawnProcess: spawnMock },
1081
+ });
1082
+
1083
+ const readable = createAcpReadableStream(slot);
1084
+ const writable = createAcpWritableStream(slot);
1085
+ const writer = writable.getWriter();
1086
+ const reader = readable.getReader();
1087
+
1088
+ // Mark session as needing recycle
1089
+ const session = slot.sessionCache.sessions.get("sess_fail") ?? {
1090
+ sessionId: "sess_fail",
1091
+ lastConfigOptions: new Map(),
1092
+ needsRecycle: true,
1093
+ };
1094
+ slot.sessionCache.sessions.set("sess_fail", session);
1095
+
1096
+ let streamError: unknown = null;
1097
+ reader.read().catch((err) => {
1098
+ streamError = err;
1099
+ });
1100
+
1101
+ await expect(
1102
+ writer.write({
1103
+ jsonrpc: "2.0",
1104
+ id: 401,
1105
+ method: "session/prompt",
1106
+ params: { sessionId: "sess_fail" },
1107
+ } as unknown as AcpStreamMessage),
1108
+ ).rejects.toThrow("Spawn failure");
1109
+
1110
+ expect(consoleSpy).toHaveBeenCalledWith(
1111
+ "[paseo-antigravity] Process recycle failed:",
1112
+ expect.any(Error),
1113
+ );
1114
+ expect(slot.state.isClosed).toBe(true);
1115
+
1116
+ await vi.waitFor(() => expect(streamError).toBeTruthy());
1117
+ });
1118
+
1119
+ it("does not close readable stream on old child exit when isRecycling is true", () => {
1120
+ const child1 = new MockChildProcess();
1121
+ const slot = new ChildSlot({
1122
+ cmd: "test-cmd",
1123
+ args: [],
1124
+ initialChild: child1 as unknown as ChildProcess,
1125
+ });
1126
+
1127
+ slot.isRecycling = true;
1128
+ child1.emit("close");
1129
+
1130
+ expect(slot.state.isClosed).toBe(false);
1131
+
1132
+ slot.isRecycling = false;
1133
+ child1.emit("close");
1134
+ expect(slot.state.isClosed).toBe(true);
1135
+ });
1136
+ });