@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.
- package/.fallowrc.json +11 -0
- package/.github/workflows/ci.yml +24 -0
- package/.github/workflows/release.yml +41 -0
- package/.oxlintignore +2 -0
- package/.oxlintrc.json +152 -0
- package/.release-please-manifest.json +3 -0
- package/assets/icon.svg +3 -0
- package/assets/logo.svg +62 -0
- package/bin/cli.ts +58 -0
- package/index.server.ts +9 -0
- package/license +21 -0
- package/package.json +45 -0
- package/paseo-plugin.json +8 -0
- package/pnpm-workspace.yaml +44 -0
- package/readme.md +292 -0
- package/release-please-config.json +13 -0
- package/server/agy-patch.test.ts +445 -0
- package/server/agy-patch.ts +445 -0
- package/server/auth.test.ts +51 -0
- package/server/auth.ts +122 -0
- package/server/command.test.ts +18 -0
- package/server/command.ts +92 -0
- package/server/connector.test.ts +1136 -0
- package/server/connector.ts +1386 -0
- package/server/history.test.ts +139 -0
- package/server/history.ts +157 -0
- package/server/mcp-proxy.test.ts +368 -0
- package/server/mcp-proxy.ts +375 -0
- package/server/provider.test.ts +11 -0
- package/server/provider.ts +37 -0
- package/server/sanitize.test.ts +174 -0
- package/server/sanitize.ts +286 -0
- package/server/skills.test.ts +121 -0
- package/server/skills.ts +183 -0
- package/server/steering.test.ts +405 -0
- package/server/steering.ts +199 -0
- package/tsconfig.json +14 -0
|
@@ -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
|
+
});
|