@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,375 @@
1
+ import fs from "node:fs";
2
+ import http from "node:http";
3
+ import https from "node:https";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ /**
8
+ * Protocol versions the target MCP server understands. Paseo's daemon embeds
9
+ * `@modelcontextprotocol/sdk`, whose streamable-HTTP transport rejects any request
10
+ * carrying an `MCP-Protocol-Version` header outside this list with HTTP 400.
11
+ */
12
+ export const DEFAULT_SUPPORTED_PROTOCOL_VERSIONS = [
13
+ "2025-11-25",
14
+ "2025-06-18",
15
+ "2025-03-26",
16
+ "2024-11-05",
17
+ "2024-10-07",
18
+ ] as const;
19
+
20
+ /** Headers that are meaningful only for a single hop and must not be forwarded. */
21
+ const HOP_BY_HOP_HEADERS = [
22
+ "connection",
23
+ "keep-alive",
24
+ "proxy-authenticate",
25
+ "proxy-authorization",
26
+ "te",
27
+ "trailer",
28
+ "transfer-encoding",
29
+ "upgrade",
30
+ ];
31
+
32
+ export interface McpProxyOptions {
33
+ /** Versions the upstream accepts. Anything else is rewritten to `fallbackProtocolVersion`. */
34
+ supportedProtocolVersions?: readonly string[];
35
+ /** Version substituted when the client asks for one the upstream does not support. */
36
+ fallbackProtocolVersion?: string;
37
+ /**
38
+ * File used to remember the listening port across restarts. Antigravity persists the
39
+ * MCP server URL into its per-session SQLite database, so a session resumed after a
40
+ * daemon restart still points at whatever port we used when it was created.
41
+ */
42
+ portFile?: string | null;
43
+ }
44
+
45
+ function defaultPortFile(): string {
46
+ const base =
47
+ process.env.PASEO_PLUGIN_DATA_DIR ?? path.join(os.homedir(), ".paseo", "plugin-data");
48
+ return path.join(base, "antigravity", "mcp-proxy-ports.json");
49
+ }
50
+
51
+ function readPortFile(file: string): Record<string, number> {
52
+ try {
53
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf-8"));
54
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
55
+ return Object.fromEntries(
56
+ Object.entries(parsed as Record<string, unknown>).filter(
57
+ ([, v]) => typeof v === "number" && Number.isInteger(v) && v > 0 && v < 65536,
58
+ ) as Array<[string, number]>,
59
+ );
60
+ } catch {
61
+ return {};
62
+ }
63
+ }
64
+
65
+ function writePortFile(file: string, origin: string, port: number): void {
66
+ try {
67
+ fs.mkdirSync(path.dirname(file), { recursive: true });
68
+ fs.writeFileSync(file, JSON.stringify({ ...readPortFile(file), [origin]: port }, null, 2));
69
+ } catch {
70
+ // A missing port file only costs us port stability, never correctness.
71
+ }
72
+ }
73
+
74
+ function sendJsonRpcError(res: http.ServerResponse, statusCode: number, message: string): void {
75
+ if (res.headersSent) return;
76
+ const payload = JSON.stringify({
77
+ jsonrpc: "2.0",
78
+ error: {
79
+ code: -32603,
80
+ message,
81
+ },
82
+ id: null,
83
+ });
84
+ res.writeHead(statusCode, {
85
+ "Content-Type": "application/json",
86
+ "Content-Length": Buffer.byteLength(payload),
87
+ });
88
+ res.end(payload);
89
+ }
90
+
91
+ interface JsonRpcDescription {
92
+ isNotification: boolean;
93
+ method: string | null;
94
+ }
95
+
96
+ function describeJsonRpc(body: Buffer): JsonRpcDescription {
97
+ if (body.length === 0) return { isNotification: false, method: null };
98
+ try {
99
+ const parsed: unknown = JSON.parse(body.toString("utf-8"));
100
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
101
+ return { isNotification: false, method: null };
102
+ }
103
+ const { method, id } = parsed as Record<string, unknown>;
104
+ const methodName = typeof method === "string" ? method : null;
105
+ return {
106
+ isNotification: methodName !== null && id === undefined,
107
+ method: methodName,
108
+ };
109
+ } catch {
110
+ return { isNotification: false, method: null };
111
+ }
112
+ }
113
+
114
+ function formatNotificationAbsorbLog(statusCode: number, method: string | null): string {
115
+ const methodLabel = method ? '"' + method + '"' : "unknown";
116
+ const base =
117
+ "[paseo-antigravity][mcp-proxy] Absorbed upstream " +
118
+ statusCode +
119
+ " on notification " +
120
+ methodLabel +
121
+ "; replied 202 so go-sdk does not tear the session down";
122
+ if (method === "notifications/cancelled") {
123
+ return base;
124
+ }
125
+ return base + " (indicates proxy normalization bug; investigate)";
126
+ }
127
+
128
+ function normalizeAccept(rawAccept: string | string[] | undefined): string {
129
+ const accept = Array.isArray(rawAccept) ? rawAccept.join(", ") : rawAccept;
130
+ if (!accept || !accept.includes("application/json") || !accept.includes("text/event-stream")) {
131
+ return "application/json, text/event-stream";
132
+ }
133
+ return accept;
134
+ }
135
+
136
+ /**
137
+ * A loopback reverse proxy for a single upstream MCP origin.
138
+ *
139
+ * Google's `agy_acp_server` ships an MCP client that advertises protocol version
140
+ * `2026-07-28`, which Paseo's daemon rejects with HTTP 400 on every request after
141
+ * `initialize`. This proxy normalizes that header down to a version the upstream
142
+ * accepts, turning a hard transport failure into ordinary version negotiation.
143
+ *
144
+ * It is origin-bound and forwards the request path and query verbatim, so
145
+ * `?callerAgentId=...` survives and the client's OAuth discovery probes
146
+ * (`/.well-known/oauth-protected-resource/...`) reach the real upstream path
147
+ * instead of being collapsed onto the MCP endpoint.
148
+ */
149
+ export class McpProxy {
150
+ private server: http.Server | null = null;
151
+ private port: number | null = null;
152
+ private readonly origin: URL;
153
+ private readonly supportedProtocolVersions: ReadonlySet<string>;
154
+ private readonly fallbackProtocolVersion: string;
155
+ private readonly portFile: string | null;
156
+
157
+ constructor(origin: string, options: McpProxyOptions = {}) {
158
+ this.origin = new URL(origin);
159
+ this.supportedProtocolVersions = new Set(
160
+ options.supportedProtocolVersions ?? DEFAULT_SUPPORTED_PROTOCOL_VERSIONS,
161
+ );
162
+ this.fallbackProtocolVersion =
163
+ options.fallbackProtocolVersion ?? DEFAULT_SUPPORTED_PROTOCOL_VERSIONS[0];
164
+ this.portFile = options.portFile === undefined ? defaultPortFile() : options.portFile;
165
+ }
166
+
167
+ private buildForwardHeaders(
168
+ req: http.IncomingMessage,
169
+ bodyLength: number,
170
+ ): http.OutgoingHttpHeaders {
171
+ const headers: http.OutgoingHttpHeaders = { ...req.headers };
172
+ delete headers.host;
173
+ for (const name of HOP_BY_HOP_HEADERS) delete headers[name];
174
+
175
+ const version = req.headers["mcp-protocol-version"];
176
+ if (typeof version === "string" && !this.supportedProtocolVersions.has(version)) {
177
+ headers["mcp-protocol-version"] = this.fallbackProtocolVersion;
178
+ }
179
+
180
+ headers.accept = normalizeAccept(req.headers.accept);
181
+
182
+ if (bodyLength > 0) {
183
+ if (!headers["content-type"]) {
184
+ headers["content-type"] = "application/json";
185
+ }
186
+ headers["content-length"] = bodyLength;
187
+ }
188
+
189
+ return headers;
190
+ }
191
+
192
+ private handleProxyResponse(
193
+ res: http.ServerResponse,
194
+ proxyRes: http.IncomingMessage,
195
+ rpcDesc: JsonRpcDescription,
196
+ ): void {
197
+ const statusCode = proxyRes.statusCode ?? 502;
198
+ if (rpcDesc.isNotification && statusCode >= 400 && statusCode < 500) {
199
+ console.error(formatNotificationAbsorbLog(statusCode, rpcDesc.method));
200
+ proxyRes.resume();
201
+ res.writeHead(202, { "Content-Length": "0" });
202
+ res.end();
203
+ return;
204
+ }
205
+
206
+ res.writeHead(statusCode, proxyRes.headers);
207
+ // MCP responses are often `text/event-stream`; without an explicit flush the
208
+ // headers can sit in Node's buffer until the first chunk, stalling the client.
209
+ res.flushHeaders();
210
+ proxyRes.pipe(res);
211
+ }
212
+
213
+ private forwardRequest(req: http.IncomingMessage, res: http.ServerResponse, body: Buffer): void {
214
+ const isTls = this.origin.protocol === "https:";
215
+ const transport = isTls ? https : http;
216
+ const rpcDesc = describeJsonRpc(body);
217
+
218
+ const proxyReq = transport.request(
219
+ {
220
+ protocol: this.origin.protocol,
221
+ hostname: this.origin.hostname,
222
+ port: this.origin.port || (isTls ? 443 : 80),
223
+ method: req.method,
224
+ // Verbatim path + query. The upstream owns its own routing.
225
+ path: req.url,
226
+ headers: this.buildForwardHeaders(req, body.length),
227
+ },
228
+ (proxyRes) => this.handleProxyResponse(res, proxyRes, rpcDesc),
229
+ );
230
+
231
+ proxyReq.on("error", (err) => {
232
+ sendJsonRpcError(res, 502, `MCP proxy error: ${err.message}`);
233
+ });
234
+ // Long-lived SSE streams must not be reaped by the default socket timeout.
235
+ proxyReq.setTimeout(0);
236
+ res.on("close", () => proxyReq.destroy());
237
+
238
+ proxyReq.end(body);
239
+ }
240
+
241
+ /**
242
+ * Buffering the request body in memory is safe and necessary because MCP JSON-RPC
243
+ * messages are small (typically <1MB) and buffering allows us to inspect for
244
+ * notifications (missing `id`) before forwarding or deciding whether to absorb 4xx
245
+ * responses, while also enabling re-streaming the body to upstream.
246
+ */
247
+ private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
248
+ const chunks: Buffer[] = [];
249
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
250
+ req.on("error", (err) => {
251
+ sendJsonRpcError(res, 400, `MCP proxy request error: ${err.message}`);
252
+ });
253
+ req.on("end", () => {
254
+ this.forwardRequest(req, res, Buffer.concat(chunks));
255
+ });
256
+ }
257
+
258
+ private listen(port: number): Promise<number> {
259
+ return new Promise((resolve, reject) => {
260
+ const server = http.createServer((req, res) => this.handleRequest(req, res));
261
+ server.keepAliveTimeout = 0;
262
+ server.headersTimeout = 0;
263
+ server.requestTimeout = 0;
264
+ server.once("error", reject);
265
+ server.listen(port, "127.0.0.1", () => {
266
+ const addr = server.address();
267
+ if (addr && typeof addr === "object") {
268
+ server.removeListener("error", reject);
269
+ this.server = server;
270
+ this.port = addr.port;
271
+ resolve(addr.port);
272
+ } else {
273
+ reject(new Error("MCP proxy failed to resolve a listening port"));
274
+ }
275
+ });
276
+ });
277
+ }
278
+
279
+ /**
280
+ * Starts the proxy on 127.0.0.1, preferring the port used on the previous run so
281
+ * that URLs Antigravity persisted for existing sessions keep resolving.
282
+ */
283
+ async start(): Promise<number> {
284
+ if (this.port !== null) return this.port;
285
+
286
+ const remembered = this.portFile ? readPortFile(this.portFile)[this.origin.origin] : undefined;
287
+ if (remembered !== undefined) {
288
+ try {
289
+ return await this.listen(remembered);
290
+ } catch {
291
+ // Port taken by something else; fall through to an ephemeral one.
292
+ }
293
+ }
294
+
295
+ const port = await this.listen(0);
296
+ if (this.portFile) writePortFile(this.portFile, this.origin.origin, port);
297
+ return port;
298
+ }
299
+
300
+ getPort(): number | null {
301
+ return this.port;
302
+ }
303
+
304
+ /**
305
+ * Rewrites an upstream MCP URL to route through this proxy, preserving path and query.
306
+ *
307
+ * Throws when the proxy is not listening. Returning the original URL here would
308
+ * silently hand Antigravity a direct upstream connection with no header
309
+ * normalization, which is exactly the failure this class exists to prevent.
310
+ */
311
+ rewriteUrl(originalUrl: string): string {
312
+ if (this.port === null) {
313
+ throw new Error(
314
+ `MCP proxy for ${this.origin.origin} is not running; refusing to expose an un-normalized MCP URL`,
315
+ );
316
+ }
317
+ const target = new URL(originalUrl);
318
+ return `http://127.0.0.1:${this.port}${target.pathname}${target.search}`;
319
+ }
320
+
321
+ async stop(): Promise<void> {
322
+ if (!this.server) return;
323
+ const server = this.server;
324
+ this.server = null;
325
+ this.port = null;
326
+ await new Promise<void>((resolve) => server.close(() => resolve()));
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Process-wide pool of proxies keyed by upstream origin.
332
+ *
333
+ * Proxies deliberately outlive individual agent connections: Antigravity writes the
334
+ * rewritten URL into its session database, so tearing a proxy down when one stream
335
+ * ends would leave every resumed session pointing at a dead port.
336
+ */
337
+ export class McpProxyPool {
338
+ private readonly proxies = new Map<string, Promise<McpProxy>>();
339
+ private readonly options: McpProxyOptions;
340
+
341
+ constructor(options: McpProxyOptions = {}) {
342
+ this.options = options;
343
+ }
344
+
345
+ async rewriteUrl(originalUrl: string): Promise<string> {
346
+ const origin = new URL(originalUrl).origin;
347
+ let pending = this.proxies.get(origin);
348
+ if (!pending) {
349
+ pending = (async () => {
350
+ const proxy = new McpProxy(origin, this.options);
351
+ await proxy.start();
352
+ return proxy;
353
+ })();
354
+ this.proxies.set(origin, pending);
355
+ pending.catch(() => this.proxies.delete(origin));
356
+ }
357
+ return (await pending).rewriteUrl(originalUrl);
358
+ }
359
+
360
+ async stopAll(): Promise<void> {
361
+ const pending = Array.from(this.proxies.values());
362
+ this.proxies.clear();
363
+ await Promise.all(
364
+ pending.map((p) =>
365
+ p.then(
366
+ (proxy) => proxy.stop(),
367
+ () => undefined,
368
+ ),
369
+ ),
370
+ );
371
+ }
372
+ }
373
+
374
+ /** Shared pool. One proxy per upstream origin for the lifetime of the daemon process. */
375
+ export const sharedMcpProxyPool = new McpProxyPool();
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createAntigravityProvider } from "./provider.js";
3
+
4
+ describe("createAntigravityProvider", () => {
5
+ it("initializes provider registration with correct metadata", () => {
6
+ const provider = createAntigravityProvider();
7
+ expect(provider.id).toBe("antigravity");
8
+ expect(provider.label).toBe("Antigravity");
9
+ expect(typeof provider.connect).toBe("function");
10
+ });
11
+ });
@@ -0,0 +1,37 @@
1
+ import { runAcpProvider } from "@getpaseo/plugin/server/acp";
2
+ import type {
3
+ ProviderConnectRequest,
4
+ ProviderConnection,
5
+ ProviderRegistration,
6
+ } from "@getpaseo/plugin/server/provider";
7
+ import { createAntigravityConnector } from "./connector.js";
8
+ import { SteeringConnection } from "./steering.js";
9
+
10
+ /**
11
+ * Creates the Antigravity provider registration for Paseo.
12
+ *
13
+ * Uses `createAntigravityConnector` to launch and stream to the local `agy_acp_server`,
14
+ * filtering redundant mode controls, discovering and advertising skills, and expanding
15
+ * slash commands. Wraps the established connection with `SteeringConnection` to support
16
+ * mid-turn steering (`prompt.steer`).
17
+ */
18
+ export function createAntigravityProvider(): ProviderRegistration {
19
+ const baseProvider = runAcpProvider({
20
+ id: "antigravity",
21
+ label: "Antigravity",
22
+ description: "Connect Paseo to Google Antigravity via ACP",
23
+ icon: "assets/icon.svg",
24
+ connector: createAntigravityConnector(),
25
+ acpOptions: {
26
+ waitForInitialCommands: true,
27
+ },
28
+ });
29
+
30
+ return {
31
+ ...baseProvider,
32
+ async connect(request: ProviderConnectRequest): Promise<ProviderConnection> {
33
+ const connection = await baseProvider.connect(request);
34
+ return new SteeringConnection(connection, request);
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,174 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ formatSteeringPrompt,
4
+ formatSystemContext,
5
+ sanitizeAssistantText,
6
+ StreamSanitizer,
7
+ stripSteeringPrefix,
8
+ stripSystemContext,
9
+ } from "./sanitize.js";
10
+
11
+ describe("sanitize", () => {
12
+ it("normalizes <kbd> tags into backtick code spans", () => {
13
+ const raw = "Press <kbd>Cmd</kbd> + <kbd>C</kbd> to copy.";
14
+ expect(sanitizeAssistantText(raw)).toBe("Press `Cmd` + `C` to copy.");
15
+ });
16
+
17
+ it("normalizes <u>, <mark>, and <sup> tags", () => {
18
+ const raw = "This is <u>important</u>, <mark>highlighted</mark>, and x<sup>2</sup>.";
19
+ expect(sanitizeAssistantText(raw)).toBe("This is *important*, **highlighted**, and x^(2).");
20
+ });
21
+
22
+ it("converts common LaTeX math escapes into clean Unicode characters", () => {
23
+ const raw = "State A $\\to$ State B, where x $\\le$ 10 and y $\\ge$ 5 (x $\\neq$ y).";
24
+ expect(sanitizeAssistantText(raw)).toBe("State A → State B, where x ≤ 10 and y ≥ 5 (x ≠ y).");
25
+ });
26
+
27
+ it("strips leaked <system_message> blocks", () => {
28
+ const raw = `<SYSTEM_MESSAGE>
29
+ The subagent 81628bf1 has reported success.
30
+ </SYSTEM_MESSAGE>
31
+
32
+ We have received the first completion.`;
33
+ expect(sanitizeAssistantText(raw).trim()).toBe("We have received the first completion.");
34
+ });
35
+
36
+ it("strips preamble with <SYSTEM_MESSAGE>", () => {
37
+ const raw = `The following is a <SYSTEM_MESSAGE> not actually sent by the user. It is provided by the system as important information to pay attention to.
38
+
39
+ <SYSTEM_MESSAGE>
40
+ [Message] task completed
41
+ </SYSTEM_MESSAGE>Done with task.`;
42
+ expect(sanitizeAssistantText(raw).trim()).toBe("Done with task.");
43
+ });
44
+
45
+ it("strips leaked <system_notification>, <context>, and <messaging>", () => {
46
+ const raw = `<system_notification>Task exited 0</system_notification><context>Context info</context>Here is your result.`;
47
+ expect(sanitizeAssistantText(raw).trim()).toBe("Here is your result.");
48
+ });
49
+
50
+ it("strips <task_notification> blocks and streaming unclosed tags", () => {
51
+ const raw = `<task_notification>
52
+ Task 528dc461-72a0-4c97-a5d5-d24b66eb6d6c/task-957 ended with status: EXITED.
53
+ Exit code: 0
54
+ Execution time: 42.66 seconds.
55
+ Tail output:
56
+ Running tests
57
+ </task_notification>`;
58
+ expect(sanitizeAssistantText(raw)).toBe("");
59
+
60
+ const streaming = `<task_notification>
61
+ Task task-957 ended with status: EXITED.`;
62
+ expect(sanitizeAssistantText(streaming)).toBe("");
63
+ });
64
+
65
+ it("strips cancellation error messages emitted during turn abort / steering", () => {
66
+ expect(sanitizeAssistantText("The request was cancelled by the client.")).toBe("");
67
+ expect(sanitizeAssistantText("The request was canceled by the client")).toBe("");
68
+ expect(sanitizeAssistantText("context canceled.")).toBe("");
69
+ expect(sanitizeAssistantText("context cancelled")).toBe("");
70
+ });
71
+
72
+ it("strips leaked [Mid-turn update]: prefixes from assistant output", () => {
73
+ const raw = "[Mid-turn update]: I am stopping that action and proceeding with tests.";
74
+ expect(sanitizeAssistantText(raw)).toBe("I am stopping that action and proceeding with tests.");
75
+ });
76
+
77
+ it("strips <RECEIVE_NOTIFICATIONS_DIRECTLY> blocks and directives", () => {
78
+ const raw = `The diagnostic check was launched in the background to inspect the provider environment.
79
+
80
+ <RECEIVE_NOTIFICATIONS_DIRECTLY>
81
+ Wait for the notification when the diagnostic check completes.
82
+ </RECEIVE_NOTIFICATIONS_DIRECTLY>`;
83
+ expect(sanitizeAssistantText(raw).trim()).toBe(
84
+ "The diagnostic check was launched in the background to inspect the provider environment.",
85
+ );
86
+ });
87
+
88
+ it("formats system context with system prompt and formatting guidance", () => {
89
+ const context = formatSystemContext("Act as an expert SRE.");
90
+ expect(context).toContain("[System Context]:\nAct as an expert SRE.");
91
+ expect(context).toContain("[Formatting Guidance]:");
92
+ expect(context).toContain("Math: The UI does not render LaTeX");
93
+ expect(context).toContain("Mermaid: Always wrap both node labels AND edge labels");
94
+ });
95
+
96
+ it("marks the steering replacement prompt as a mid-turn interruption", () => {
97
+ // Paseo answers a steer by cancelling the turn and re-dispatching the text. The
98
+ // marker is what tells the model the new text supersedes the instruction in flight.
99
+ expect(formatSteeringPrompt("STOP!")).toBe("[Mid-turn update]: STOP!");
100
+ expect(formatSteeringPrompt(" why port 3000? ")).toBe("[Mid-turn update]: why port 3000?");
101
+ });
102
+
103
+ it("round-trips the steering marker back out of text that is about to be displayed", () => {
104
+ const sent = formatSteeringPrompt("use vitest");
105
+ expect(stripSteeringPrefix(sent).trim()).toBe("use vitest");
106
+ expect(sanitizeAssistantText(`Sure.\n${sent}`)).toBe("Sure.use vitest");
107
+ });
108
+ describe("stripSystemContext", () => {
109
+ it("strips formatted system context with custom prompt and formatting guidance", () => {
110
+ const context = formatSystemContext("Act as an expert SRE.");
111
+ const fullPrompt = `${context}\n\nDeploy the application to staging.`;
112
+ expect(stripSystemContext(fullPrompt)).toBe("Deploy the application to staging.");
113
+ });
114
+
115
+ it("strips formatting guidance when no system prompt was provided", () => {
116
+ const context = formatSystemContext(undefined);
117
+ const fullPrompt = `${context}\n\nWhat is the status?`;
118
+ expect(stripSystemContext(fullPrompt)).toBe("What is the status?");
119
+ });
120
+
121
+ it("leaves regular user prompt untouched", () => {
122
+ const raw = "How do I configure nginx?";
123
+ expect(stripSystemContext(raw)).toBe("How do I configure nginx?");
124
+ });
125
+
126
+ it("strips entire string when text contains only system context and formatting guidance", () => {
127
+ const context = formatSystemContext("Act as an expert SRE.");
128
+ expect(stripSystemContext(context)).toBe("");
129
+ });
130
+ });
131
+
132
+ describe("StreamSanitizer", () => {
133
+ it("preserves spaces between normal streamed chunks without trimming", () => {
134
+ const sanitizer = new StreamSanitizer();
135
+ expect(sanitizer.process("Hello ")).toBe("Hello ");
136
+ expect(sanitizer.process("world! ")).toBe("world! ");
137
+ expect(sanitizer.process("How are you?")).toBe("How are you?");
138
+ });
139
+
140
+ it("suppresses <task_notification> split across multiple streaming chunks", () => {
141
+ const sanitizer = new StreamSanitizer();
142
+ expect(sanitizer.process("Before test.\n<task_")).toBe("Before test.\n");
143
+ expect(sanitizer.process("notification>\nTask 123 ended.\nExit code: 0\n")).toBe("");
144
+ expect(sanitizer.process("Tail output:\nRunning tests...\n</task_")).toBe("");
145
+ expect(sanitizer.process("notification>\nAfter test.")).toBe("\nAfter test.");
146
+ });
147
+
148
+ it("suppresses unclosed <task_notification> streaming to the end of turn", () => {
149
+ const sanitizer = new StreamSanitizer();
150
+ expect(sanitizer.process("Start\n<task_notification>\nTask 456 running...")).toBe("Start\n");
151
+ expect(sanitizer.process("\nMore task output...")).toBe("");
152
+ expect(sanitizer.flush()).toBe("");
153
+ });
154
+
155
+ it("does not suppress or break mathematical < or HTML-like templates", () => {
156
+ const sanitizer = new StreamSanitizer();
157
+ expect(sanitizer.process("if a < b and c > d: ")).toBe("if a < b and c > d: ");
158
+ expect(sanitizer.process("std::vector<int> numbers;")).toBe("std::vector<int> numbers;");
159
+ expect(sanitizer.flush()).toBe("");
160
+ });
161
+
162
+ it("suppresses preamble <SYSTEM_MESSAGE> banners across streaming chunks", () => {
163
+ const sanitizer = new StreamSanitizer();
164
+ expect(
165
+ sanitizer.process(
166
+ "OK.\nThe following is a <SYSTEM_MESSAGE> not actually sent by the user.\n<SYSTEM_MESSAGE>",
167
+ ),
168
+ ).toBe("OK.\n");
169
+ expect(sanitizer.process("[Message] Internal note\n</SYSTEM_MESSAGE>\nReady!")).toBe(
170
+ "\nReady!",
171
+ );
172
+ });
173
+ });
174
+ });