@danwahl/antigravity-mcp 0.2.0 → 0.3.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/README.md CHANGED
@@ -47,13 +47,18 @@ Verify with `claude mcp list`.
47
47
 
48
48
  ## Tool: `cli`
49
49
 
50
- | Parameter | Type | Required | Description |
51
- |------------------|--------|----------|-------------|
52
- | `prompt` | string | yes | Task or question to send to Antigravity |
53
- | `cwd` | string | yes | Absolute path to the working directory (agy's workspace) |
54
- | `model` | string | no | Model name. Omit for agy's default. `agy models` lists the options. |
55
- | `conversationId` | string | no | Resume a previous conversation. Returned in the structured output of each call. Pass the same `cwd`, since conversations are workspace-scoped. |
56
- | `timeout` | number | no | Seconds before the run is killed. Default 120. |
50
+ | Parameter | Type | Required | Description |
51
+ |------------------|---------|----------|-------------|
52
+ | `prompt` | string | yes | Task or question to send to Antigravity |
53
+ | `cwd` | string | yes | Absolute path to the working directory (agy's workspace) |
54
+ | `model` | string | no | Model name, e.g. `gemini-3.1-pro-high`, `gemini-3.8-flash-medium`, `claude-sonnet-4-6`. Omit for agy's default. `agy models` lists the options. |
55
+ | `effort` | string | no | Reasoning effort: `low`, `medium`, or `high`. Omit for agy's default. |
56
+ | `sandbox` | boolean | no | Default `true`. Shell commands can read anywhere but write only under `/tmp`; agy's file edit tools are unaffected. Set `false` for builds, installs, git commits, or tests that write to the workspace. |
57
+ | `mode` | string | no | `plan` or `accept-edits`. In headless mode plan review is auto-approved, so `plan` shapes the workflow but does not block edits. |
58
+ | `agent` | string | no | Custom agent name, defined at `.agents/agents/<name>.md` in the workspace or `~/.gemini/config/agents/`. |
59
+ | `jsonSchema` | object | no | JSON Schema enforced on the final answer. The parsed object comes back in `structuredOutput`. |
60
+ | `conversationId` | string | no | Resume a previous conversation. Returned in the structured output of each call. Pass the same `cwd`, since conversations are workspace-scoped. |
61
+ | `timeout` | number | no | Seconds before the run is stopped. Default 120. On expiry the call returns an error; any partial response is in the structured output. |
57
62
 
58
63
  ### Structured output
59
64
 
@@ -70,7 +75,11 @@ Each call returns structured content alongside the text response:
70
75
  "thinkingTokens": 121,
71
76
  "cacheReadTokens": 0,
72
77
  "totalTokens": 12569
73
- }
78
+ },
79
+ "structuredOutput": null,
80
+ "durationSeconds": 2.6,
81
+ "numTurns": 1,
82
+ "deniedActions": []
74
83
  }
75
84
  ```
76
85
 
@@ -78,7 +87,15 @@ Each call returns structured content alongside the text response:
78
87
 
79
88
  ### What Antigravity can do
80
89
 
81
- `agy` runs with `--dangerously-skip-permissions`, giving it full tool access: read/write files, run shell commands, web search, and more. The `cwd` you specify is passed as `--add-dir` so it becomes the agent's workspace; the spawn working directory alone is not enough in headless mode. Persisted `settings.json` permission rules still apply.
90
+ `agy` runs with `--dangerously-skip-permissions`, giving it full tool access: read/write files, run shell commands, web search, and more. Headless agy cannot prompt for permission, so without this flag any tool needing approval is silently denied and the turn ends early. Deny rules in agy's own `settings.json` still apply; when one fires the call returns an error listing `deniedActions`.
91
+
92
+ The `cwd` you specify is passed as `--add-dir` so it becomes the agent's workspace; the spawn working directory alone is not enough in headless mode.
93
+
94
+ `--sandbox` is on by default as a check against the permission bypass. It restricts shell commands only: they can read anywhere but write only under `/tmp`. agy's file edit tools can still modify the workspace.
95
+
96
+ ### Timeouts and cancellation
97
+
98
+ The `timeout` is passed to agy as `--print-timeout`, so agy stops its own turn at the deadline and returns whatever it has. The server reports that as an error with the partial response in the structured output. If agy fails to exit within a few seconds after that, its process group is killed. Cancelling the MCP call (for example, interrupting Claude Code) kills the agy process group immediately.
82
99
 
83
100
  ### Errors
84
101
 
package/dist/index.js CHANGED
@@ -26,6 +26,9 @@ server.registerTool("cli", {
26
26
  "- Research and brainstorming: Antigravity has web search and a large context window, useful for exploring options or summarizing docs\n" +
27
27
  "- Large file analysis: processing files that would be expensive to handle directly\n" +
28
28
  "- Parallel workstreams: offloading independent subtasks while you continue other work\n\n" +
29
+ "Sandbox: on by default. Antigravity's file tools can still edit the workspace, but shell commands " +
30
+ "can only write under /tmp. Set `sandbox: false` for tasks whose shell commands must write to the " +
31
+ "workspace (builds, installs, git commits, test runs that produce files).\n\n" +
29
32
  "Conversation resumption: each response includes a `conversationId`. " +
30
33
  "Pass it back via the `conversationId` parameter (with the same `cwd`) to continue a conversation " +
31
34
  "without re-sending context — useful for multi-step tasks or follow-up questions.\n\n" +
@@ -42,7 +45,32 @@ server.registerTool("cli", {
42
45
  .string()
43
46
  .optional()
44
47
  .describe("Model to use. Omit to use Antigravity's default. " +
45
- "Run `agy models` to list available model names."),
48
+ "Examples: \"gemini-3.1-pro-high\", \"gemini-3.1-pro-low\", \"gemini-3.8-flash-medium\", " +
49
+ "\"claude-sonnet-4-6\", \"claude-opus-4-6-thinking\". Run `agy models` for the full list."),
50
+ effort: z
51
+ .enum(["low", "medium", "high"])
52
+ .optional()
53
+ .describe("Reasoning effort for the session. Omit to use Antigravity's default."),
54
+ sandbox: z
55
+ .boolean()
56
+ .optional()
57
+ .default(true)
58
+ .describe("Run shell commands in a sandbox where they can read anywhere but write only under /tmp. " +
59
+ "File edit tools are unaffected. Default: true."),
60
+ mode: z
61
+ .enum(["accept-edits", "plan"])
62
+ .optional()
63
+ .describe("Agent execution mode. \"plan\" drafts an implementation plan before acting; in headless mode " +
64
+ "the plan is auto-approved, so this shapes the workflow but does not prevent edits."),
65
+ agent: z
66
+ .string()
67
+ .optional()
68
+ .describe("Name of a custom agent to run, defined at .agents/agents/<name>.md in the workspace " +
69
+ "or ~/.gemini/config/agents/. Omit for the default agent."),
70
+ jsonSchema: z
71
+ .record(z.string(), z.unknown())
72
+ .optional()
73
+ .describe("JSON Schema to enforce on the final answer. The parsed object is returned in `structuredOutput`."),
46
74
  conversationId: z
47
75
  .string()
48
76
  .optional()
@@ -52,31 +80,40 @@ server.registerTool("cli", {
52
80
  .number()
53
81
  .optional()
54
82
  .default(120)
55
- .describe("Timeout in seconds. Default: 120. Increase for complex multi-step tasks."),
83
+ .describe("Timeout in seconds. Default: 120. Increase for complex multi-step tasks. " +
84
+ "On expiry the call returns an error with any partial response in the structured output."),
56
85
  },
57
86
  outputSchema: {
58
87
  conversationId: z.string().nullable().describe("Antigravity conversation ID"),
59
88
  response: z.string().describe("Antigravity's text response"),
60
89
  status: z.string().nullable().describe("Run status reported by agy (e.g. SUCCESS)"),
61
90
  usage: usageSchema.nullable().describe("Token usage for this turn"),
91
+ structuredOutput: z
92
+ .record(z.string(), z.unknown())
93
+ .nullable()
94
+ .describe("Parsed answer when `jsonSchema` was given"),
95
+ durationSeconds: z.number().nullable().describe("Wall-clock time of the run"),
96
+ numTurns: z.number().nullable().describe("Number of agent turns in the conversation"),
97
+ deniedActions: z.array(z.string()).describe("Tools agy was denied permission to use"),
62
98
  },
63
99
  annotations: {
64
100
  readOnlyHint: false,
65
101
  openWorldHint: true,
66
102
  },
67
- }, async ({ prompt, cwd, model, conversationId, timeout }) => {
103
+ }, async ({ prompt, cwd, model, effort, sandbox, mode, agent, jsonSchema, conversationId, timeout }, extra) => {
68
104
  const timeoutMs = (timeout ?? 120) * 1000;
69
- const result = await runAgy(prompt, cwd, model, timeoutMs, conversationId);
105
+ const result = await runAgy(prompt, cwd, timeoutMs, { model, effort, sandbox, mode, agent, jsonSchema, conversationId }, extra.signal);
106
+ const structuredContent = result.output;
70
107
  if (result.isError) {
71
108
  return {
72
109
  isError: true,
73
110
  content: [{ type: "text", text: result.errorMessage ?? "Unknown error" }],
74
- structuredContent: result.output,
111
+ structuredContent,
75
112
  };
76
113
  }
77
114
  return {
78
115
  content: [{ type: "text", text: result.output.response }],
79
- structuredContent: result.output,
116
+ structuredContent,
80
117
  };
81
118
  });
82
119
  async function runServer() {
package/dist/lib.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
- export function buildAgyArgs(prompt, cwd, model, conversationId) {
3
+ export function buildAgyArgs(prompt, cwd, opts = {}) {
4
4
  // The spawn cwd alone does not register a workspace in headless mode; without
5
5
  // --add-dir the agent is told it has no active workspace and works in its
6
6
  // own scratch directory.
@@ -10,34 +10,75 @@ export function buildAgyArgs(prompt, cwd, model, conversationId) {
10
10
  "--dangerously-skip-permissions",
11
11
  "--add-dir", cwd,
12
12
  ];
13
- if (model) {
14
- args.push("--model", model);
13
+ if (opts.sandbox ?? true) {
14
+ args.push("--sandbox");
15
15
  }
16
- if (conversationId) {
17
- args.push("--conversation", conversationId);
16
+ if (opts.model) {
17
+ args.push("--model", opts.model);
18
+ }
19
+ if (opts.effort) {
20
+ args.push("--effort", opts.effort);
21
+ }
22
+ if (opts.mode) {
23
+ args.push("--mode", opts.mode);
24
+ }
25
+ if (opts.agent) {
26
+ args.push("--agent", opts.agent);
27
+ }
28
+ if (opts.jsonSchema) {
29
+ args.push("--json-schema", JSON.stringify(opts.jsonSchema));
30
+ }
31
+ if (opts.conversationId) {
32
+ args.push("--conversation", opts.conversationId);
33
+ }
34
+ if (opts.timeoutMs) {
35
+ // Let agy wind down on its own at the deadline and return partial output;
36
+ // the caller's kill timer is only a backstop.
37
+ args.push("--print-timeout", `${Math.ceil(opts.timeoutMs / 1000)}s`);
18
38
  }
19
39
  return args;
20
40
  }
21
- const EMPTY_OUTPUT = { conversationId: null, response: "", status: null, usage: null };
41
+ export const EMPTY_OUTPUT = {
42
+ conversationId: null,
43
+ response: "",
44
+ status: null,
45
+ usage: null,
46
+ structuredOutput: null,
47
+ durationSeconds: null,
48
+ numTurns: null,
49
+ deniedActions: [],
50
+ };
22
51
  function numberOr(value, fallback) {
23
52
  return typeof value === "number" ? value : fallback;
24
53
  }
54
+ function numberOrNull(value) {
55
+ return typeof value === "number" ? value : null;
56
+ }
57
+ function isObject(value) {
58
+ return value !== null && typeof value === "object" && !Array.isArray(value);
59
+ }
25
60
  function parseUsage(value) {
26
- if (value === null || typeof value !== "object")
61
+ if (!isObject(value))
27
62
  return null;
28
- const u = value;
29
63
  return {
30
- inputTokens: numberOr(u.input_tokens, 0),
31
- outputTokens: numberOr(u.output_tokens, 0),
32
- thinkingTokens: numberOr(u.thinking_tokens, 0),
33
- cacheReadTokens: numberOr(u.cache_read_tokens, 0),
34
- totalTokens: numberOr(u.total_tokens, 0),
64
+ inputTokens: numberOr(value.input_tokens, 0),
65
+ outputTokens: numberOr(value.output_tokens, 0),
66
+ thinkingTokens: numberOr(value.thinking_tokens, 0),
67
+ cacheReadTokens: numberOr(value.cache_read_tokens, 0),
68
+ totalTokens: numberOr(value.total_tokens, 0),
35
69
  };
36
70
  }
71
+ function parseDeniedActions(value) {
72
+ if (!Array.isArray(value))
73
+ return [];
74
+ return value
75
+ .map((d) => (isObject(d) && typeof d.action === "string" ? d.action : null))
76
+ .filter((a) => a !== null);
77
+ }
37
78
  export function parseAgyOutput(stdout) {
38
79
  const trimmed = stdout.trim();
39
80
  if (!trimmed) {
40
- return EMPTY_OUTPUT;
81
+ return { ...EMPTY_OUTPUT };
41
82
  }
42
83
  let parsed;
43
84
  try {
@@ -46,18 +87,18 @@ export function parseAgyOutput(stdout) {
46
87
  catch {
47
88
  return { ...EMPTY_OUTPUT, response: trimmed };
48
89
  }
49
- if (parsed === null || typeof parsed !== "object") {
50
- return { ...EMPTY_OUTPUT, response: trimmed };
51
- }
52
- const obj = parsed;
53
- if (typeof obj.response !== "string") {
90
+ if (!isObject(parsed) || typeof parsed.response !== "string") {
54
91
  return { ...EMPTY_OUTPUT, response: trimmed };
55
92
  }
56
93
  return {
57
- conversationId: typeof obj.conversation_id === "string" ? obj.conversation_id : null,
58
- response: obj.response,
59
- status: typeof obj.status === "string" ? obj.status : null,
60
- usage: parseUsage(obj.usage),
94
+ conversationId: typeof parsed.conversation_id === "string" ? parsed.conversation_id : null,
95
+ response: parsed.response,
96
+ status: typeof parsed.status === "string" ? parsed.status : null,
97
+ usage: parseUsage(parsed.usage),
98
+ structuredOutput: isObject(parsed.structured_output) ? parsed.structured_output : null,
99
+ durationSeconds: numberOrNull(parsed.duration_seconds),
100
+ numTurns: numberOrNull(parsed.num_turns),
101
+ deniedActions: parseDeniedActions(parsed.denied_actions),
61
102
  };
62
103
  }
63
104
  // agy prints a structured `AGY_ERROR: {...}` line on stderr for model/API failures.
@@ -76,15 +117,31 @@ export function extractAgyError(stderr) {
76
117
  return json;
77
118
  }
78
119
  }
79
- function errorResult(errorMessage) {
80
- return { output: EMPTY_OUTPUT, isError: true, errorMessage };
120
+ // On --print-timeout expiry agy exits 0 with status SUCCESS and a stderr notice.
121
+ export function hitPrintTimeout(stderr) {
122
+ return /print timeout after .* with turn in progress/.test(stderr);
81
123
  }
82
- export function runAgy(prompt, cwd, model, timeoutMs, conversationId) {
124
+ function errorResult(errorMessage, output = { ...EMPTY_OUTPUT }) {
125
+ return { output, isError: true, errorMessage };
126
+ }
127
+ // Classify a completed (exit 0) run. Exposed for testing.
128
+ export function classifyRun(output, stderr, timeoutMs) {
129
+ if (hitPrintTimeout(stderr)) {
130
+ return errorResult(`agy timed out after ${timeoutMs / 1000}s; partial response returned in structured output`, output);
131
+ }
132
+ if (output.deniedActions.length > 0) {
133
+ return errorResult(`agy was denied permission for: ${output.deniedActions.join(", ")}. ` +
134
+ "Check permissions.deny rules in agy's settings.json.", output);
135
+ }
136
+ return { output, isError: false };
137
+ }
138
+ const KILL_GRACE_MS = 5000;
139
+ export function runAgy(prompt, cwd, timeoutMs, opts = {}, signal) {
83
140
  if (!existsSync(cwd)) {
84
141
  return Promise.resolve(errorResult(`Working directory does not exist: ${cwd}`));
85
142
  }
86
143
  return new Promise((resolve) => {
87
- const args = buildAgyArgs(prompt, cwd, model, conversationId);
144
+ const args = buildAgyArgs(prompt, cwd, { ...opts, timeoutMs });
88
145
  let child;
89
146
  try {
90
147
  child = spawn("agy", args, { cwd, env: process.env, detached: true });
@@ -111,20 +168,34 @@ export function runAgy(prompt, cwd, model, timeoutMs, conversationId) {
111
168
  // group already gone
112
169
  }
113
170
  };
171
+ const terminate = () => {
172
+ killGroup("SIGTERM");
173
+ setTimeout(() => killGroup("SIGKILL"), KILL_GRACE_MS).unref();
174
+ };
114
175
  const finish = (r) => {
115
176
  if (settled)
116
177
  return;
117
178
  settled = true;
118
179
  clearTimeout(timer);
180
+ signal?.removeEventListener("abort", onAbort);
119
181
  resolve(r);
120
182
  };
183
+ // Backstop: agy should exit on its own via --print-timeout, but if it doesn't
184
+ // (or grandchildren keep pipes open), kill the group and resolve without
185
+ // waiting for `close`.
121
186
  const timer = setTimeout(() => {
122
- killGroup("SIGTERM");
123
- setTimeout(() => killGroup("SIGKILL"), 5000).unref();
124
- // Resolve immediately — don't wait for `close`, which may never fire
125
- // if grandchildren keep stdio pipes open.
126
- finish(errorResult(`agy timed out after ${timeoutMs / 1000}s`));
127
- }, timeoutMs);
187
+ terminate();
188
+ finish(errorResult(`agy timed out after ${timeoutMs / 1000}s and was killed`));
189
+ }, timeoutMs + KILL_GRACE_MS);
190
+ const onAbort = () => {
191
+ terminate();
192
+ finish(errorResult("agy call was cancelled"));
193
+ };
194
+ if (signal?.aborted) {
195
+ onAbort();
196
+ return;
197
+ }
198
+ signal?.addEventListener("abort", onAbort, { once: true });
128
199
  child.stdout?.on("data", (chunk) => {
129
200
  stdout += chunk;
130
201
  });
@@ -138,14 +209,14 @@ export function runAgy(prompt, cwd, model, timeoutMs, conversationId) {
138
209
  ? "agy binary not found. Install Antigravity CLI and make sure `agy` is on PATH."
139
210
  : `Failed to spawn agy: ${err.message}`));
140
211
  });
141
- child.on("close", (code, signal) => {
212
+ child.on("close", (code, sig) => {
142
213
  if (code !== 0) {
143
- const how = code === null ? `signal ${signal}` : `code ${code}`;
214
+ const how = code === null ? `signal ${sig}` : `code ${code}`;
144
215
  const detail = extractAgyError(stderr) || stderr.trim() || stdout.trim() || how;
145
216
  finish(errorResult(`agy exited with ${how}: ${detail}`));
146
217
  return;
147
218
  }
148
- finish({ output: parseAgyOutput(stdout), isError: false });
219
+ finish(classifyRun(parseAgyOutput(stdout), stderr, timeoutMs));
149
220
  });
150
221
  });
151
222
  }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@danwahl/antigravity-mcp",
3
3
  "type": "module",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "description": "MCP server that exposes Antigravity CLI (agy) as a single tool for Claude Code",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/danwahl/antigravity-mcp.git"
9
+ "url": "git+https://github.com/danwahl/antigravity-mcp.git"
10
10
  },
11
11
  "keywords": [
12
12
  "mcp",
@@ -24,12 +24,13 @@
24
24
  "antigravity-mcp": "dist/index.js"
25
25
  },
26
26
  "scripts": {
27
- "build": "tsc && shx chmod +x dist/*.js",
27
+ "build": "rm -rf dist && tsc && shx chmod +x dist/*.js",
28
28
  "test": "node --import tsx/esm --test src/lib.test.ts",
29
29
  "prepublishOnly": "npm run build"
30
30
  },
31
31
  "files": [
32
- "dist"
32
+ "dist",
33
+ "!dist/*.test.js"
33
34
  ],
34
35
  "dependencies": {
35
36
  "@modelcontextprotocol/sdk": "^1.21.0",
package/dist/lib.test.js DELETED
@@ -1,145 +0,0 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { buildGeminiArgs, parseGeminiOutput, extractStructuredOutput, } from "./lib.ts";
4
- describe("buildGeminiArgs", () => {
5
- it("omits --model when not provided", () => {
6
- const args = buildGeminiArgs("hello", undefined);
7
- assert.ok(!args.includes("--model"));
8
- });
9
- it("includes --model when provided", () => {
10
- const args = buildGeminiArgs("hello", "gemini-2.5-pro");
11
- const idx = args.indexOf("--model");
12
- assert.ok(idx !== -1);
13
- assert.equal(args[idx + 1], "gemini-2.5-pro");
14
- });
15
- it("passes model string through unchanged", () => {
16
- const args = buildGeminiArgs("hello", "gemini-1.5-flash-001");
17
- assert.equal(args[args.indexOf("--model") + 1], "gemini-1.5-flash-001");
18
- });
19
- it("includes --resume when sessionId provided", () => {
20
- const args = buildGeminiArgs("x", undefined, "my-session-id");
21
- const idx = args.indexOf("--resume");
22
- assert.ok(idx !== -1);
23
- assert.equal(args[idx + 1], "my-session-id");
24
- });
25
- it("omits --resume when sessionId not provided", () => {
26
- const args = buildGeminiArgs("x", undefined);
27
- assert.ok(!args.includes("--resume"));
28
- });
29
- it("includes --approval-mode yolo", () => {
30
- const args = buildGeminiArgs("x", undefined);
31
- const idx = args.indexOf("--approval-mode");
32
- assert.ok(idx !== -1);
33
- assert.equal(args[idx + 1], "yolo");
34
- });
35
- it("includes --output-format json", () => {
36
- const args = buildGeminiArgs("x", undefined);
37
- const idx = args.indexOf("--output-format");
38
- assert.ok(idx !== -1);
39
- assert.equal(args[idx + 1], "json");
40
- });
41
- });
42
- describe("parseGeminiOutput", () => {
43
- it("parses valid JSON response", () => {
44
- const result = parseGeminiOutput(JSON.stringify({ response: "Hello, world!" }));
45
- assert.equal(result.response, "Hello, world!");
46
- assert.equal(result.sessionId, null);
47
- assert.equal(result.stats, undefined);
48
- });
49
- it("extracts session_id", () => {
50
- const result = parseGeminiOutput(JSON.stringify({ session_id: "abc-123", response: "ok" }));
51
- assert.equal(result.sessionId, "abc-123");
52
- });
53
- it("extracts stats field", () => {
54
- const stats = { models: {}, tools: {} };
55
- const result = parseGeminiOutput(JSON.stringify({ response: "ok", stats }));
56
- assert.deepEqual(result.stats, stats);
57
- });
58
- it("handles missing stats gracefully", () => {
59
- const result = parseGeminiOutput(JSON.stringify({ response: "ok" }));
60
- assert.equal(result.stats, undefined);
61
- });
62
- it("returns raw stdout when JSON parsing fails", () => {
63
- const raw = "this is not json";
64
- const result = parseGeminiOutput(raw);
65
- assert.equal(result.response, raw);
66
- assert.equal(result.sessionId, null);
67
- });
68
- it("returns raw stdout for JSON without response field", () => {
69
- const raw = JSON.stringify({ message: "unexpected shape" });
70
- const result = parseGeminiOutput(raw);
71
- assert.equal(result.response, raw);
72
- });
73
- it("handles empty stdout", () => {
74
- const result = parseGeminiOutput("");
75
- assert.equal(result.response, "");
76
- });
77
- it("trims surrounding whitespace before parsing", () => {
78
- const result = parseGeminiOutput(" " + JSON.stringify({ response: "trimmed" }) + "\n");
79
- assert.equal(result.response, "trimmed");
80
- });
81
- });
82
- describe("extractStructuredOutput", () => {
83
- it("passes through sessionId and response", () => {
84
- const output = { sessionId: "abc", response: "hello" };
85
- const result = extractStructuredOutput(output);
86
- assert.equal(result.sessionId, "abc");
87
- assert.equal(result.response, "hello");
88
- });
89
- it("returns empty records when no stats", () => {
90
- const output = { sessionId: null, response: "hi" };
91
- const result = extractStructuredOutput(output);
92
- assert.deepEqual(result.models, {});
93
- assert.deepEqual(result.tools, {});
94
- });
95
- it("extracts model token totals", () => {
96
- const output = {
97
- sessionId: null,
98
- response: "hi",
99
- stats: {
100
- models: {
101
- "gemini-2.5-pro": { tokens: { total: 100 } },
102
- "gemini-2.0-flash": { tokens: { total: 50 } },
103
- },
104
- },
105
- };
106
- assert.deepEqual(extractStructuredOutput(output).models, {
107
- "gemini-2.5-pro": 100,
108
- "gemini-2.0-flash": 50,
109
- });
110
- });
111
- it("omits models with no token data", () => {
112
- const output = {
113
- sessionId: null,
114
- response: "hi",
115
- stats: { models: { "gemini-2.5-pro": {} } },
116
- };
117
- assert.deepEqual(extractStructuredOutput(output).models, {});
118
- });
119
- it("extracts tool call counts from byName", () => {
120
- const output = {
121
- sessionId: null,
122
- response: "hi",
123
- stats: {
124
- tools: {
125
- byName: {
126
- list_directory: { count: 2 },
127
- web_fetch: { count: 1 },
128
- },
129
- },
130
- },
131
- };
132
- assert.deepEqual(extractStructuredOutput(output).tools, {
133
- list_directory: 2,
134
- web_fetch: 1,
135
- });
136
- });
137
- it("returns empty tools when no tool calls", () => {
138
- const output = {
139
- sessionId: null,
140
- response: "hi",
141
- stats: { tools: { totalCalls: 0, byName: {} } },
142
- };
143
- assert.deepEqual(extractStructuredOutput(output).tools, {});
144
- });
145
- });