gogcli-mcp-gmail 2.7.1 → 2.18.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.
@@ -1,32 +1,37 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { registerExtraGmailTools } from '../../src/tools/gmail-extra.js';
3
3
  import * as lib from '../../../gogcli-mcp/src/lib.js';
4
- import { setupHandlers, toText, type ToolHandler } from '../../../gogcli-mcp/tests/helpers/test-harness.js';
4
+ import { createTestHarness, type TestHarness } from '@chrischall/mcp-utils/test';
5
+ import { rawTextResult, errorResult } from '@chrischall/mcp-utils';
5
6
 
6
7
  vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
7
8
  const actual = await importOriginal<typeof lib>();
8
9
  return {
9
10
  ...actual,
11
+ run: vi.fn(),
10
12
  runOrDiagnose: vi.fn(),
13
+ diagnose: vi.fn(),
11
14
  };
12
15
  });
13
16
 
14
- let handlers: Map<string, ToolHandler>;
17
+ let harness: TestHarness;
15
18
 
16
- beforeEach(() => {
19
+ beforeEach(async () => {
17
20
  vi.clearAllMocks();
18
- vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
19
- handlers = setupHandlers(registerExtraGmailTools);
21
+ vi.mocked(lib.run).mockResolvedValue('{}');
22
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
23
+ vi.mocked(lib.diagnose).mockResolvedValue(errorResult('diagnosed'));
24
+ harness = await createTestHarness(registerExtraGmailTools);
20
25
  });
21
26
 
22
27
  describe('gog_gmail_raw', () => {
23
28
  it('calls runOrDiagnose with messageId', async () => {
24
- await handlers.get('gog_gmail_raw')!({ messageId: 'm1' });
29
+ await harness.callTool('gog_gmail_raw', { messageId: 'm1' });
25
30
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['gmail', 'raw', 'm1'], { account: undefined });
26
31
  });
27
32
 
28
33
  it('passes --format and --pretty when provided', async () => {
29
- await handlers.get('gog_gmail_raw')!({ messageId: 'm1', format: 'metadata', pretty: true });
34
+ await harness.callTool('gog_gmail_raw', { messageId: 'm1', format: 'metadata', pretty: true });
30
35
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
31
36
  ['gmail', 'raw', 'm1', '--format=metadata', '--pretty'],
32
37
  { account: undefined },
@@ -34,42 +39,285 @@ describe('gog_gmail_raw', () => {
34
39
  });
35
40
 
36
41
  it('omits --pretty when false', async () => {
37
- await handlers.get('gog_gmail_raw')!({ messageId: 'm1', pretty: false });
42
+ await harness.callTool('gog_gmail_raw', { messageId: 'm1', pretty: false });
38
43
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['gmail', 'raw', 'm1'], { account: undefined });
39
44
  });
40
45
  });
41
46
 
42
47
  describe('gog_gmail_attachment', () => {
43
- it('calls runOrDiagnose with messageId and attachmentId', async () => {
44
- await handlers.get('gog_gmail_attachment')!({ messageId: 'm1', attachmentId: 'a1' });
45
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(
46
- ['gmail', 'attachment', 'm1', 'a1'],
47
- { account: undefined },
48
- );
48
+ // base64 whose first 16 chars decode to the given ASCII/binary prefix.
49
+ const PNG_B64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB'; // "\x89PNG\r\n\x1a\n..."
50
+ const PDF_B64 = 'JVBERi0xLjUKJVBFRgo='; // "%PDF-1.5\n%PEF\n"
51
+ const OCTET_B64 = 'AAAAAAAAAAAAAAAA'; // decodes to NUL bytes — no magic match
52
+
53
+ // Route the mocked `run` by subcommand: the metadata lookup (`gmail get`), the
54
+ // download (`gmail attachment`), and the Drive upload (`drive upload`).
55
+ function stubGog(opts: {
56
+ meta?: unknown;
57
+ metaError?: Error;
58
+ download?: unknown;
59
+ drive?: unknown;
60
+ downloadError?: unknown;
61
+ }): void {
62
+ vi.mocked(lib.run).mockImplementation(async (args) => {
63
+ const a = args as string[];
64
+ if (a[0] === 'gmail' && a[1] === 'get') {
65
+ if (opts.metaError) throw opts.metaError;
66
+ return JSON.stringify(opts.meta ?? { attachments: [] });
67
+ }
68
+ if (a[0] === 'gmail' && a[1] === 'attachment') {
69
+ if (opts.downloadError) throw opts.downloadError;
70
+ return JSON.stringify(opts.download ?? {});
71
+ }
72
+ if (a[0] === 'drive' && a[1] === 'upload') return JSON.stringify(opts.drive ?? { file: {} });
73
+ return '{}';
74
+ });
75
+ }
76
+
77
+ // A dummy executor store — its mere presence makes runExecutor.getStore()
78
+ // truthy, which is how the handler detects the remote connector transport.
79
+ const REMOTE = { executor: async () => '{}' };
80
+ const asConnector = <T>(fn: () => Promise<T>): Promise<T> => lib.runExecutor.run(REMOTE, fn);
81
+
82
+ // The part metadata (`gmail get` `.attachments[]`) is matched by SIZE — Gmail's
83
+ // attachmentId isn't stable across calls — so every list entry carries a `size`
84
+ // that the download's `bytes` must equal for the filename/MIME to resolve.
85
+ const PDF_LIST = { attachments: [{ filename: 'Guest_Copy.pdf', mimeType: 'application/pdf', size: 99723 }] };
86
+ const PNG_LIST = { attachments: [{ filename: 'photo.png', mimeType: 'image/png', size: 24 }] };
87
+
88
+ const call = (args: Record<string, unknown>) =>
89
+ harness.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentId: 'a1', ...args });
90
+ const textOf = (res: Awaited<ReturnType<typeof call>>) => (res.content[0] as { text: string }).text;
91
+ const gotGet = () => vi.mocked(lib.run).mock.calls.some((c) => (c[0] as string[])[1] === 'get');
92
+ const dlArgs = () => vi.mocked(lib.run).mock.calls.find((c) => (c[0] as string[])[1] === 'attachment')![0] as string[];
93
+
94
+ it('the repro: a no-name PDF on stdio comes back as a readable file path, named correctly', async () => {
95
+ // download writes to a provisional temp path; the real name resolves by size.
96
+ stubGog({ meta: PDF_LIST, download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 99723, contentBase64: PDF_B64 } });
97
+ const res = await call({});
98
+ // download to the temp path first, then the metadata read to resolve the name.
99
+ expect(dlArgs()).toEqual(['gmail', 'attachment', 'm1', 'a1', '--inline', '--out=/tmp/gog-attachments/m1/attachment', '--name=attachment']);
100
+ expect(gotGet()).toBe(true);
101
+ const payload = JSON.parse(textOf(res));
102
+ expect(payload).toMatchObject({
103
+ delivery: 'file', path: '/tmp/gog-attachments/m1/attachment', fileName: 'Guest_Copy.pdf', mimeType: 'application/pdf', bytes: 99723,
104
+ });
105
+ // never an embedded-resource blob on auto (the claude.ai host rejects those for PDF).
106
+ expect(res.content.some((c) => c.type === 'resource')).toBe(false);
49
107
  });
50
108
 
51
- it('passes --out and --name when provided', async () => {
52
- await handlers.get('gog_gmail_attachment')!({
53
- messageId: 'm1',
54
- attachmentId: 'a1',
55
- out: '/tmp/file.pdf',
56
- name: 'report.pdf',
109
+ it('the repro on the connector: the same PDF is delivered via Drive with the resolved name', async () => {
110
+ stubGog({
111
+ meta: PDF_LIST,
112
+ download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 99723, contentBase64: PDF_B64 },
113
+ drive: { file: { id: 'F1', name: 'Guest_Copy.pdf', webViewLink: 'https://drive.google.com/file/d/F1/view' } },
57
114
  });
58
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(
59
- ['gmail', 'attachment', 'm1', 'a1', '--out=/tmp/file.pdf', '--name=report.pdf'],
60
- { account: undefined },
61
- );
115
+ const res = await asConnector(() => call({}));
116
+ // uploads the downloaded temp file, but names the Drive copy with the resolved filename.
117
+ expect(lib.run).toHaveBeenCalledWith(
118
+ ['drive', 'upload', '/tmp/gog-attachments/m1/attachment', '--json', '--name=Guest_Copy.pdf'], { account: undefined });
119
+ expect(JSON.parse(textOf(res))).toMatchObject({ deliveredVia: 'drive', id: 'F1' });
120
+ });
121
+
122
+ it('an image renders inline (image block), on stdio and connector alike', async () => {
123
+ stubGog({ meta: PNG_LIST, download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 24, contentBase64: PNG_B64 } });
124
+ const local = await call({});
125
+ expect(local.content[1]).toEqual({ type: 'image', data: PNG_B64, mimeType: 'image/png' });
126
+ expect(textOf(local)).toContain('photo.png');
127
+ vi.clearAllMocks();
128
+ stubGog({ meta: PNG_LIST, download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 24, contentBase64: PNG_B64 } });
129
+ const remote = await asConnector(() => call({}));
130
+ expect(remote.content[1]).toEqual({ type: 'image', data: PNG_B64, mimeType: 'image/png' });
131
+ expect(lib.run).not.toHaveBeenCalledWith(expect.arrayContaining(['drive', 'upload']), expect.anything());
132
+ });
133
+
134
+ it('a caller-supplied name skips the metadata lookup and names the file directly', async () => {
135
+ stubGog({ download: { path: '/tmp/gog-attachments/m1/report.pdf', bytes: 12, contentBase64: PDF_B64 } });
136
+ await call({ name: 'report.pdf' });
137
+ expect(gotGet()).toBe(false);
138
+ expect(dlArgs()).toEqual(['gmail', 'attachment', 'm1', 'a1', '--inline', '--out=/tmp/gog-attachments/m1/report.pdf', '--name=report.pdf']);
139
+ });
140
+
141
+ it('a named non-image on the connector skips --inline (headed straight to Drive)', async () => {
142
+ stubGog({ download: { path: '/tmp/gog-attachments/m1/report.pdf', bytes: 12 }, drive: { file: { id: 'F9' } } });
143
+ await asConnector(() => call({ name: 'report.pdf' }));
144
+ expect(gotGet()).toBe(false);
145
+ expect(dlArgs()).toEqual(['gmail', 'attachment', 'm1', 'a1', '--out=/tmp/gog-attachments/m1/report.pdf', '--name=report.pdf']);
146
+ });
147
+
148
+ it('resolves the real filename by size and sanitizes path separators (no traversal)', async () => {
149
+ stubGog({
150
+ meta: { attachments: [{ filename: '../../etc/evil.pdf', mimeType: 'application/pdf', size: 10 }] },
151
+ download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 10, contentBase64: PDF_B64 },
152
+ });
153
+ const res = await call({});
154
+ const fileName = JSON.parse(textOf(res)).fileName as string;
155
+ expect(fileName).not.toMatch(/[/\\]/); // single safe segment, no traversal
156
+ expect(fileName).toContain('evil.pdf');
157
+ });
158
+
159
+ it('derives an extension from the MIME type when the part has no filename (never *.bin)', async () => {
160
+ stubGog({
161
+ meta: { attachments: [{ mimeType: 'application/pdf', size: 10 }] },
162
+ download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 10, contentBase64: PDF_B64 },
163
+ });
164
+ const res = await call({});
165
+ expect(JSON.parse(textOf(res)).fileName).toBe('attachment.pdf');
166
+ });
167
+
168
+ it('falls back to a magic-byte sniff when the size is ambiguous (repeated)', async () => {
169
+ stubGog({
170
+ meta: { attachments: [
171
+ { filename: 'a.pdf', mimeType: 'application/pdf', size: 10 },
172
+ { filename: 'b.pdf', mimeType: 'application/pdf', size: 10 },
173
+ ] },
174
+ download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 10, contentBase64: PDF_B64 },
175
+ });
176
+ const res = await call({});
177
+ // two parts share the size → no unique match → sniff + derived name.
178
+ expect(JSON.parse(textOf(res))).toMatchObject({ fileName: 'attachment.pdf', mimeType: 'application/pdf' });
179
+ });
180
+
181
+ it('survives a metadata-lookup failure and still delivers, sniffing the MIME', async () => {
182
+ stubGog({ metaError: new Error('get failed'), download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 24, contentBase64: PNG_B64 } });
183
+ const res = await call({});
184
+ // resolveBySize catches the failure → sniff → image/png.
185
+ expect(res.content[1]).toEqual({ type: 'image', data: PNG_B64, mimeType: 'image/png' });
186
+ });
187
+
188
+ it('summarizes with "? bytes" when the download reports no size (skips the size lookup)', async () => {
189
+ stubGog({ download: { path: '/tmp/gog-attachments/m1/x.png', contentBase64: PNG_B64 }, meta: PNG_LIST });
190
+ const res = await call({ name: 'x.png' });
191
+ expect(gotGet()).toBe(false); // no bytes → no size match needed; name given anyway
192
+ expect(res.content[1]).toEqual({ type: 'image', data: PNG_B64, mimeType: 'image/png' });
193
+ expect(textOf(res)).toContain('? bytes');
194
+ });
195
+
196
+ it('skips the size lookup entirely when the download reports no bytes and no name', async () => {
197
+ stubGog({ download: { path: '/tmp/gog-attachments/m1/attachment', contentBase64: OCTET_B64 } });
198
+ const res = await call({});
199
+ // info.bytes undefined → resolveBySize short-circuits (no `gmail get`).
200
+ expect(gotGet()).toBe(false);
201
+ expect(JSON.parse(textOf(res))).toMatchObject({ delivery: 'file', fileName: 'attachment', mimeType: 'application/octet-stream' });
202
+ });
203
+
204
+ it('falls back to application/octet-stream when the message has no attachments array', async () => {
205
+ // meta with no `attachments` key exercises the `?? []` guard in resolveBySize.
206
+ stubGog({ meta: {}, download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 12, contentBase64: OCTET_B64 } });
207
+ const res = await call({});
208
+ expect(JSON.parse(textOf(res))).toMatchObject({ delivery: 'file', fileName: 'attachment', mimeType: 'application/octet-stream' });
209
+ });
210
+
211
+ it('deliver=inline returns a native image block for an image', async () => {
212
+ stubGog({ meta: PNG_LIST, download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 24, contentBase64: PNG_B64 } });
213
+ const res = await call({ deliver: 'inline' });
214
+ expect(res.content[1]).toEqual({ type: 'image', data: PNG_B64, mimeType: 'image/png' });
215
+ });
216
+
217
+ it('deliver=inline forces an embedded resource blob for a non-image', async () => {
218
+ stubGog({ download: { path: '/tmp/gog-attachments/m1/doc.pdf', bytes: 12, contentBase64: PDF_B64 } });
219
+ const res = await call({ deliver: 'inline', name: 'doc.pdf' });
220
+ expect(res.content[1]).toEqual({
221
+ type: 'resource',
222
+ resource: { uri: 'gmail-attachment://m1/doc.pdf', mimeType: 'application/pdf', blob: PDF_B64 },
223
+ });
224
+ });
225
+
226
+ it('deliver=inline errors when the attachment is too large (no reason field)', async () => {
227
+ // no `path` either → exercises the `info.path ?? outPath` fallback.
228
+ stubGog({ download: { bytes: 9_000_000 } });
229
+ const res = await call({ deliver: 'inline', name: 'big.pdf' });
230
+ expect(res.isError).toBe(true);
231
+ expect(textOf(res)).toContain('too large');
232
+ expect(lib.run).not.toHaveBeenCalledWith(expect.arrayContaining(['drive', 'upload']), expect.anything());
233
+ });
234
+
235
+ it('deliver=drive skips --inline and uploads, honoring driveFolder and name', async () => {
236
+ stubGog({ download: { path: '/tmp/gog-attachments/m1/renamed.png', bytes: 24 }, drive: { file: { id: 'F2', webViewLink: 'https://drive.google.com/file/d/F2/view' } } });
237
+ const res = await call({ deliver: 'drive', driveFolder: 'DIR9', name: 'renamed.png', account: 'me@x.com' });
238
+ expect(dlArgs()).toEqual(['gmail', 'attachment', 'm1', 'a1', '--out=/tmp/gog-attachments/m1/renamed.png', '--name=renamed.png']);
239
+ expect(lib.run).toHaveBeenCalledWith(
240
+ ['drive', 'upload', '/tmp/gog-attachments/m1/renamed.png', '--json', '--parent=DIR9', '--name=renamed.png'], { account: 'me@x.com' });
241
+ expect(JSON.parse(textOf(res))).toMatchObject({ deliveredVia: 'drive', id: 'F2' });
242
+ });
243
+
244
+ it('deliver=off returns a structured record with the size-resolved filename + mime', async () => {
245
+ stubGog({ meta: PDF_LIST, download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 99723, cached: true } });
246
+ const res = await call({ deliver: 'off' });
247
+ expect(JSON.parse(textOf(res))).toMatchObject({
248
+ delivery: 'file', path: '/tmp/gog-attachments/m1/attachment', fileName: 'Guest_Copy.pdf', mimeType: 'application/pdf', bytes: 99723, cached: true,
249
+ });
250
+ });
251
+
252
+ it('deliver=off on the connector still surfaces the ignored-out note', async () => {
253
+ stubGog({ meta: PDF_LIST, download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 99723 } });
254
+ const res = await asConnector(() => call({ deliver: 'off', out: '/home/claude/x.pdf' }));
255
+ expect(res.content[0]).toMatchObject({ type: 'text', text: expect.stringContaining('`out` was ignored') });
256
+ // the structured record still follows the note.
257
+ expect(JSON.parse((res.content[1] as { text: string }).text)).toMatchObject({ delivery: 'file', fileName: 'Guest_Copy.pdf' });
258
+ });
259
+
260
+ it('still reports drive delivery when the upload output lacks a file envelope', async () => {
261
+ stubGog({ meta: PDF_LIST, download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 99723 }, drive: {} });
262
+ const res = await asConnector(() => call({}));
263
+ const payload = JSON.parse(textOf(res));
264
+ expect(payload).toMatchObject({ deliveredVia: 'drive' });
265
+ expect(payload.id).toBeUndefined();
266
+ });
267
+
268
+ it('honors a caller out on stdio', async () => {
269
+ stubGog({ download: { path: '/home/me/x.png', bytes: 24, contentBase64: PNG_B64 } });
270
+ await call({ out: '/home/me/x.png', name: 'x.png' });
271
+ expect(dlArgs()).toEqual(['gmail', 'attachment', 'm1', 'a1', '--inline', '--out=/home/me/x.png', '--name=x.png']);
272
+ });
273
+
274
+ it('ignores a caller out on the connector and notes it', async () => {
275
+ stubGog({ download: { path: '/tmp/gog-attachments/m1/report.pdf', bytes: 12 }, drive: { file: { id: 'F3' } } });
276
+ const res = await asConnector(() => call({ out: '/home/claude/report.pdf', name: 'report.pdf' }));
277
+ // download used the temp path, NOT the caller's /home/claude path.
278
+ expect(dlArgs()).toEqual(['gmail', 'attachment', 'm1', 'a1', '--out=/tmp/gog-attachments/m1/report.pdf', '--name=report.pdf']);
279
+ expect(textOf(res)).toContain('`out` was ignored');
280
+ });
281
+
282
+ it('a caller name that sanitizes to empty falls back to "attachment"', async () => {
283
+ stubGog({ download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 10 } });
284
+ await call({ name: '...' }); // only dots → sanitizes to '' → 'attachment'
285
+ expect(dlArgs()).toEqual(expect.arrayContaining(['--name=attachment', '--out=/tmp/gog-attachments/m1/attachment']));
286
+ });
287
+
288
+ it('wraps a download failure without leaking the command line or the attachment token', async () => {
289
+ stubGog({ downloadError: new Error('Command failed: gog gmail attachment m1 a1 --out=/home/claude/x.pdf\nmkdir /home/claude: permission denied') });
290
+ const res = await call({});
291
+ expect(lib.diagnose).toHaveBeenCalled();
292
+ const passed = (vi.mocked(lib.diagnose).mock.calls[0][0] as Error).message;
293
+ expect(passed).not.toContain('Command failed');
294
+ expect(passed).not.toContain('a1');
295
+ expect(passed).not.toContain('m1');
296
+ expect(passed).toContain('permission denied');
297
+ expect(res.isError).toBe(true);
298
+ });
299
+
300
+ it('wraps a non-Error rejection', async () => {
301
+ stubGog({ downloadError: 'weird string failure' });
302
+ await call({});
303
+ expect((vi.mocked(lib.diagnose).mock.calls[0][0] as Error).message).toBe('weird string failure');
304
+ });
305
+
306
+ it('falls back to a generic message when the error is nothing but the command echo', async () => {
307
+ stubGog({ downloadError: new Error('Command failed: gog gmail attachment m1 a1\n') });
308
+ await call({});
309
+ expect((vi.mocked(lib.diagnose).mock.calls[0][0] as Error).message).toBe('the download failed on the server');
62
310
  });
63
311
  });
64
312
 
65
313
  describe('gog_gmail_url', () => {
66
314
  it('calls runOrDiagnose with a single threadId', async () => {
67
- await handlers.get('gog_gmail_url')!({ threadIds: ['t1'] });
315
+ await harness.callTool('gog_gmail_url', { threadIds: ['t1'] });
68
316
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['gmail', 'url', 't1'], { account: undefined });
69
317
  });
70
318
 
71
319
  it('calls runOrDiagnose with multiple threadIds', async () => {
72
- await handlers.get('gog_gmail_url')!({ threadIds: ['t1', 't2', 't3'] });
320
+ await harness.callTool('gog_gmail_url', { threadIds: ['t1', 't2', 't3'] });
73
321
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
74
322
  ['gmail', 'url', 't1', 't2', 't3'],
75
323
  { account: undefined },
@@ -79,12 +327,12 @@ describe('gog_gmail_url', () => {
79
327
 
80
328
  describe('gog_gmail_history', () => {
81
329
  it('calls runOrDiagnose with no flags', async () => {
82
- await handlers.get('gog_gmail_history')!({});
330
+ await harness.callTool('gog_gmail_history', {});
83
331
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['gmail', 'history'], { account: undefined });
84
332
  });
85
333
 
86
334
  it('passes all history flags', async () => {
87
- await handlers.get('gog_gmail_history')!({ since: '12345', max: 50, page: 'tok', all: true });
335
+ await harness.callTool('gog_gmail_history', { since: '12345', max: 50, page: 'tok', all: true });
88
336
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
89
337
  ['gmail', 'history', '--since=12345', '--max=50', '--page=tok', '--all'],
90
338
  { account: undefined },
@@ -92,7 +340,7 @@ describe('gog_gmail_history', () => {
92
340
  });
93
341
 
94
342
  it('omits --all when false', async () => {
95
- await handlers.get('gog_gmail_history')!({ all: false });
343
+ await harness.callTool('gog_gmail_history', { all: false });
96
344
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['gmail', 'history'], { account: undefined });
97
345
  });
98
346
  });
@@ -108,7 +356,7 @@ describe('bulk action tools (archive, mark_read, mark_unread, trash)', () => {
108
356
  for (const { tool, cmd } of bulkTools) {
109
357
  describe(tool, () => {
110
358
  it('passes messageIds as positional args', async () => {
111
- await handlers.get(tool)!({ messageIds: ['m1', 'm2'] });
359
+ await harness.callTool(tool, { messageIds: ['m1', 'm2'] });
112
360
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
113
361
  ['gmail', cmd, 'm1', 'm2'],
114
362
  { account: undefined },
@@ -116,7 +364,7 @@ describe('bulk action tools (archive, mark_read, mark_unread, trash)', () => {
116
364
  });
117
365
 
118
366
  it('passes --query and --max', async () => {
119
- await handlers.get(tool)!({ query: 'is:unread older_than:7d', max: 50 });
367
+ await harness.callTool(tool, { query: 'is:unread older_than:7d', max: 50 });
120
368
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
121
369
  ['gmail', cmd, '--query=is:unread older_than:7d', '--max=50'],
122
370
  { account: undefined },
@@ -124,7 +372,7 @@ describe('bulk action tools (archive, mark_read, mark_unread, trash)', () => {
124
372
  });
125
373
 
126
374
  it('passes both positional ids and flags together', async () => {
127
- await handlers.get(tool)!({ messageIds: ['m1'], max: 10 });
375
+ await harness.callTool(tool, { messageIds: ['m1'], max: 10 });
128
376
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
129
377
  ['gmail', cmd, 'm1', '--max=10'],
130
378
  { account: undefined },
@@ -132,11 +380,28 @@ describe('bulk action tools (archive, mark_read, mark_unread, trash)', () => {
132
380
  });
133
381
  });
134
382
  }
383
+
384
+ // gog 0.25.0 — --thread is archive-only
385
+ it('gog_gmail_archive passes --thread to archive whole threads by id', async () => {
386
+ await harness.callTool('gog_gmail_archive', { messageIds: ['t1', 't2'], thread: true });
387
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
388
+ ['gmail', 'archive', 't1', 't2', '--thread'],
389
+ { account: undefined },
390
+ );
391
+ });
392
+
393
+ it('other bulk tools do not expose a thread param', async () => {
394
+ await harness.callTool('gog_gmail_trash', { messageIds: ['m1'], thread: true });
395
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
396
+ ['gmail', 'trash', 'm1'],
397
+ { account: undefined },
398
+ );
399
+ });
135
400
  });
136
401
 
137
402
  describe('gog_gmail_message_modify', () => {
138
403
  it('calls runOrDiagnose with messageId and label changes', async () => {
139
- await handlers.get('gog_gmail_message_modify')!({
404
+ await harness.callTool('gog_gmail_message_modify', {
140
405
  messageId: 'm1',
141
406
  add: 'STARRED,IMPORTANT',
142
407
  remove: 'INBOX',
@@ -148,7 +413,7 @@ describe('gog_gmail_message_modify', () => {
148
413
  });
149
414
 
150
415
  it('omits flags when not provided', async () => {
151
- await handlers.get('gog_gmail_message_modify')!({ messageId: 'm1' });
416
+ await harness.callTool('gog_gmail_message_modify', { messageId: 'm1' });
152
417
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
153
418
  ['gmail', 'messages', 'modify', 'm1'],
154
419
  { account: undefined },
@@ -158,17 +423,33 @@ describe('gog_gmail_message_modify', () => {
158
423
 
159
424
  describe('gog_gmail_batch_delete', () => {
160
425
  it('calls runOrDiagnose with messageIds as positional args', async () => {
161
- await handlers.get('gog_gmail_batch_delete')!({ messageIds: ['m1', 'm2', 'm3'] });
426
+ await harness.callTool('gog_gmail_batch_delete', { messageIds: ['m1', 'm2', 'm3'] });
162
427
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
163
428
  ['gmail', 'batch', 'delete', 'm1', 'm2', 'm3'],
164
429
  { account: undefined },
165
430
  );
166
431
  });
432
+
433
+ it('appends --force when force is true', async () => {
434
+ await harness.callTool('gog_gmail_batch_delete', { messageIds: ['m1'], force: true });
435
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
436
+ ['gmail', 'batch', 'delete', 'm1', '--force'],
437
+ { account: undefined },
438
+ );
439
+ });
440
+
441
+ it('omits --force when force is false', async () => {
442
+ await harness.callTool('gog_gmail_batch_delete', { messageIds: ['m1'], force: false });
443
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
444
+ ['gmail', 'batch', 'delete', 'm1'],
445
+ { account: undefined },
446
+ );
447
+ });
167
448
  });
168
449
 
169
450
  describe('gog_gmail_batch_modify', () => {
170
451
  it('calls runOrDiagnose with messageIds and label flags', async () => {
171
- await handlers.get('gog_gmail_batch_modify')!({
452
+ await harness.callTool('gog_gmail_batch_modify', {
172
453
  messageIds: ['m1', 'm2'],
173
454
  add: 'STARRED',
174
455
  remove: 'INBOX',
@@ -180,7 +461,7 @@ describe('gog_gmail_batch_modify', () => {
180
461
  });
181
462
 
182
463
  it('omits label flags when not provided', async () => {
183
- await handlers.get('gog_gmail_batch_modify')!({ messageIds: ['m1'] });
464
+ await harness.callTool('gog_gmail_batch_modify', { messageIds: ['m1'] });
184
465
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
185
466
  ['gmail', 'batch', 'modify', 'm1'],
186
467
  { account: undefined },
@@ -190,7 +471,7 @@ describe('gog_gmail_batch_modify', () => {
190
471
 
191
472
  describe('gog_gmail_thread_get', () => {
192
473
  it('calls runOrDiagnose with threadId', async () => {
193
- await handlers.get('gog_gmail_thread_get')!({ threadId: 't1' });
474
+ await harness.callTool('gog_gmail_thread_get', { threadId: 't1' });
194
475
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
195
476
  ['gmail', 'thread', 'get', 't1'],
196
477
  { account: undefined },
@@ -198,7 +479,7 @@ describe('gog_gmail_thread_get', () => {
198
479
  });
199
480
 
200
481
  it('passes all flags', async () => {
201
- await handlers.get('gog_gmail_thread_get')!({
482
+ await harness.callTool('gog_gmail_thread_get', {
202
483
  threadId: 't1',
203
484
  download: true,
204
485
  full: true,
@@ -212,7 +493,7 @@ describe('gog_gmail_thread_get', () => {
212
493
  });
213
494
 
214
495
  it('omits boolean flags when false', async () => {
215
- await handlers.get('gog_gmail_thread_get')!({
496
+ await harness.callTool('gog_gmail_thread_get', {
216
497
  threadId: 't1',
217
498
  download: false,
218
499
  full: false,
@@ -237,14 +518,14 @@ describe('gog_gmail_thread_get', () => {
237
518
  });
238
519
 
239
520
  it('does not transform the output when no paging params are given', async () => {
240
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText(THREAD));
241
- const result = await handlers.get('gog_gmail_thread_get')!({ threadId: 't1' });
521
+ vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(THREAD));
522
+ const result = await harness.callTool('gog_gmail_thread_get', { threadId: 't1' });
242
523
  expect(result.content[0].text).toBe(THREAD);
243
524
  });
244
525
 
245
526
  it('latestN returns only the last N messages', async () => {
246
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText(THREAD));
247
- const result = await handlers.get('gog_gmail_thread_get')!({ threadId: 't1', latestN: 2 });
527
+ vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(THREAD));
528
+ const result = await harness.callTool('gog_gmail_thread_get', { threadId: 't1', latestN: 2 });
248
529
  // latestN is wrapper-side; no CLI flag is added
249
530
  expect(vi.mocked(lib.runOrDiagnose).mock.calls[0]![0]).toEqual(['gmail', 'thread', 'get', 't1']);
250
531
  const parsed = JSON.parse(result.content[0].text);
@@ -252,8 +533,8 @@ describe('gog_gmail_thread_get', () => {
252
533
  });
253
534
 
254
535
  it('snippetsOnly returns per-message headers and snippet without bodies', async () => {
255
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText(THREAD));
256
- const result = await handlers.get('gog_gmail_thread_get')!({ threadId: 't1', snippetsOnly: true });
536
+ vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(THREAD));
537
+ const result = await harness.callTool('gog_gmail_thread_get', { threadId: 't1', snippetsOnly: true });
257
538
  const parsed = JSON.parse(result.content[0].text);
258
539
  expect(parsed.thread.messages).toHaveLength(3);
259
540
  const m1 = parsed.thread.messages[0];
@@ -265,35 +546,35 @@ describe('gog_gmail_thread_get', () => {
265
546
  });
266
547
 
267
548
  it('combines latestN and snippetsOnly', async () => {
268
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText(THREAD));
269
- const result = await handlers.get('gog_gmail_thread_get')!({ threadId: 't1', latestN: 1, snippetsOnly: true });
549
+ vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(THREAD));
550
+ const result = await harness.callTool('gog_gmail_thread_get', { threadId: 't1', latestN: 1, snippetsOnly: true });
270
551
  const parsed = JSON.parse(result.content[0].text);
271
552
  expect(parsed.thread.messages).toHaveLength(1);
272
553
  expect(parsed.thread.messages[0].id).toBe('m3');
273
554
  });
274
555
 
275
556
  it('returns the raw result when the payload is not JSON', async () => {
276
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('not json'));
277
- const result = await handlers.get('gog_gmail_thread_get')!({ threadId: 't1', latestN: 2 });
557
+ vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult('not json'));
558
+ const result = await harness.callTool('gog_gmail_thread_get', { threadId: 't1', latestN: 2 });
278
559
  expect(result.content[0].text).toBe('not json');
279
560
  });
280
561
 
281
562
  it('returns the raw result when there is no messages array', async () => {
282
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('{"thread":{}}'));
283
- const result = await handlers.get('gog_gmail_thread_get')!({ threadId: 't1', snippetsOnly: true });
563
+ vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult('{"thread":{}}'));
564
+ const result = await harness.callTool('gog_gmail_thread_get', { threadId: 't1', snippetsOnly: true });
284
565
  expect(result.content[0].text).toBe('{"thread":{}}');
285
566
  });
286
567
 
287
568
  it('returns the raw result when there is no thread object', async () => {
288
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('{}'));
289
- const result = await handlers.get('gog_gmail_thread_get')!({ threadId: 't1', latestN: 1 });
569
+ vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult('{}'));
570
+ const result = await harness.callTool('gog_gmail_thread_get', { threadId: 't1', latestN: 1 });
290
571
  expect(result.content[0].text).toBe('{}');
291
572
  });
292
573
  });
293
574
 
294
575
  describe('gog_gmail_thread_modify', () => {
295
576
  it('calls runOrDiagnose with threadId and label flags', async () => {
296
- await handlers.get('gog_gmail_thread_modify')!({
577
+ await harness.callTool('gog_gmail_thread_modify', {
297
578
  threadId: 't1',
298
579
  add: 'IMPORTANT',
299
580
  remove: 'INBOX',
@@ -305,7 +586,7 @@ describe('gog_gmail_thread_modify', () => {
305
586
  });
306
587
 
307
588
  it('omits label flags when not provided', async () => {
308
- await handlers.get('gog_gmail_thread_modify')!({ threadId: 't1' });
589
+ await harness.callTool('gog_gmail_thread_modify', { threadId: 't1' });
309
590
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
310
591
  ['gmail', 'thread', 'modify', 't1'],
311
592
  { account: undefined },
@@ -315,7 +596,7 @@ describe('gog_gmail_thread_modify', () => {
315
596
 
316
597
  describe('gog_gmail_thread_attachments', () => {
317
598
  it('calls runOrDiagnose with threadId', async () => {
318
- await handlers.get('gog_gmail_thread_attachments')!({ threadId: 't1' });
599
+ await harness.callTool('gog_gmail_thread_attachments', { threadId: 't1' });
319
600
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
320
601
  ['gmail', 'thread', 'attachments', 't1'],
321
602
  { account: undefined },
@@ -323,7 +604,7 @@ describe('gog_gmail_thread_attachments', () => {
323
604
  });
324
605
 
325
606
  it('passes --download and --out-dir when provided', async () => {
326
- await handlers.get('gog_gmail_thread_attachments')!({
607
+ await harness.callTool('gog_gmail_thread_attachments', {
327
608
  threadId: 't1',
328
609
  download: true,
329
610
  outDir: '/tmp/atts',
@@ -337,7 +618,7 @@ describe('gog_gmail_thread_attachments', () => {
337
618
 
338
619
  describe('gog_gmail_labels_list', () => {
339
620
  it('calls runOrDiagnose with no args', async () => {
340
- await handlers.get('gog_gmail_labels_list')!({});
621
+ await harness.callTool('gog_gmail_labels_list', {});
341
622
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
342
623
  ['gmail', 'labels', 'list'],
343
624
  { account: undefined },
@@ -345,7 +626,7 @@ describe('gog_gmail_labels_list', () => {
345
626
  });
346
627
 
347
628
  it('forwards account', async () => {
348
- await handlers.get('gog_gmail_labels_list')!({ account: 'a@b.com' });
629
+ await harness.callTool('gog_gmail_labels_list', { account: 'a@b.com' });
349
630
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
350
631
  ['gmail', 'labels', 'list'],
351
632
  { account: 'a@b.com' },
@@ -355,7 +636,7 @@ describe('gog_gmail_labels_list', () => {
355
636
 
356
637
  describe('gog_gmail_labels_get', () => {
357
638
  it('calls runOrDiagnose with labelIdOrName', async () => {
358
- await handlers.get('gog_gmail_labels_get')!({ labelIdOrName: 'INBOX' });
639
+ await harness.callTool('gog_gmail_labels_get', { labelIdOrName: 'INBOX' });
359
640
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
360
641
  ['gmail', 'labels', 'get', 'INBOX'],
361
642
  { account: undefined },
@@ -365,7 +646,7 @@ describe('gog_gmail_labels_get', () => {
365
646
 
366
647
  describe('gog_gmail_labels_create', () => {
367
648
  it('calls runOrDiagnose with name', async () => {
368
- await handlers.get('gog_gmail_labels_create')!({ name: 'Newsletter' });
649
+ await harness.callTool('gog_gmail_labels_create', { name: 'Newsletter' });
369
650
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
370
651
  ['gmail', 'labels', 'create', 'Newsletter'],
371
652
  { account: undefined },
@@ -375,7 +656,7 @@ describe('gog_gmail_labels_create', () => {
375
656
 
376
657
  describe('gog_gmail_labels_rename', () => {
377
658
  it('calls runOrDiagnose with old and new names', async () => {
378
- await handlers.get('gog_gmail_labels_rename')!({ labelIdOrName: 'Old', newName: 'New' });
659
+ await harness.callTool('gog_gmail_labels_rename', { labelIdOrName: 'Old', newName: 'New' });
379
660
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
380
661
  ['gmail', 'labels', 'rename', 'Old', 'New'],
381
662
  { account: undefined },
@@ -385,9 +666,9 @@ describe('gog_gmail_labels_rename', () => {
385
666
 
386
667
  describe('gog_gmail_labels_delete', () => {
387
668
  it('calls runOrDiagnose with labelIdOrName', async () => {
388
- await handlers.get('gog_gmail_labels_delete')!({ labelIdOrName: 'Trash-Me' });
669
+ await harness.callTool('gog_gmail_labels_delete', { labelIdOrName: 'Trash-Me' });
389
670
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
390
- ['gmail', 'labels', 'delete', 'Trash-Me'],
671
+ ['gmail', 'labels', 'delete', 'Trash-Me', '--force'],
391
672
  { account: undefined },
392
673
  );
393
674
  });
@@ -395,7 +676,7 @@ describe('gog_gmail_labels_delete', () => {
395
676
 
396
677
  describe('gog_gmail_labels_modify', () => {
397
678
  it('calls runOrDiagnose with threadIds and label flags', async () => {
398
- await handlers.get('gog_gmail_labels_modify')!({
679
+ await harness.callTool('gog_gmail_labels_modify', {
399
680
  threadIds: ['t1', 't2'],
400
681
  add: 'Newsletter',
401
682
  remove: 'INBOX',
@@ -407,7 +688,7 @@ describe('gog_gmail_labels_modify', () => {
407
688
  });
408
689
 
409
690
  it('omits label flags when not provided', async () => {
410
- await handlers.get('gog_gmail_labels_modify')!({ threadIds: ['t1'] });
691
+ await harness.callTool('gog_gmail_labels_modify', { threadIds: ['t1'] });
411
692
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
412
693
  ['gmail', 'labels', 'modify', 't1'],
413
694
  { account: undefined },
@@ -417,7 +698,7 @@ describe('gog_gmail_labels_modify', () => {
417
698
 
418
699
  describe('gog_gmail_drafts_list', () => {
419
700
  it('calls runOrDiagnose with no flags', async () => {
420
- await handlers.get('gog_gmail_drafts_list')!({});
701
+ await harness.callTool('gog_gmail_drafts_list', {});
421
702
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
422
703
  ['gmail', 'drafts', 'list'],
423
704
  { account: undefined },
@@ -425,7 +706,7 @@ describe('gog_gmail_drafts_list', () => {
425
706
  });
426
707
 
427
708
  it('passes pagination flags', async () => {
428
- await handlers.get('gog_gmail_drafts_list')!({ max: 50, page: 'tok', all: true });
709
+ await harness.callTool('gog_gmail_drafts_list', { max: 50, page: 'tok', all: true });
429
710
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
430
711
  ['gmail', 'drafts', 'list', '--max=50', '--page=tok', '--all'],
431
712
  { account: undefined },
@@ -435,7 +716,7 @@ describe('gog_gmail_drafts_list', () => {
435
716
 
436
717
  describe('gog_gmail_drafts_get', () => {
437
718
  it('calls runOrDiagnose with draftId', async () => {
438
- await handlers.get('gog_gmail_drafts_get')!({ draftId: 'd1' });
719
+ await harness.callTool('gog_gmail_drafts_get', { draftId: 'd1' });
439
720
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
440
721
  ['gmail', 'drafts', 'get', 'd1'],
441
722
  { account: undefined },
@@ -443,7 +724,7 @@ describe('gog_gmail_drafts_get', () => {
443
724
  });
444
725
 
445
726
  it('passes --download when true', async () => {
446
- await handlers.get('gog_gmail_drafts_get')!({ draftId: 'd1', download: true });
727
+ await harness.callTool('gog_gmail_drafts_get', { draftId: 'd1', download: true });
447
728
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
448
729
  ['gmail', 'drafts', 'get', 'd1', '--download'],
449
730
  { account: undefined },
@@ -453,7 +734,7 @@ describe('gog_gmail_drafts_get', () => {
453
734
 
454
735
  describe('gog_gmail_drafts_create', () => {
455
736
  it('calls runOrDiagnose with minimal required flags', async () => {
456
- await handlers.get('gog_gmail_drafts_create')!({
737
+ await harness.callTool('gog_gmail_drafts_create', {
457
738
  subject: 'Hi',
458
739
  body: 'Hello',
459
740
  });
@@ -464,7 +745,7 @@ describe('gog_gmail_drafts_create', () => {
464
745
  });
465
746
 
466
747
  it('passes all flags including attachments', async () => {
467
- await handlers.get('gog_gmail_drafts_create')!({
748
+ await harness.callTool('gog_gmail_drafts_create', {
468
749
  to: 'a@b.com,c@d.com',
469
750
  cc: 'cc@x.com',
470
751
  bcc: 'bcc@x.com',
@@ -497,8 +778,33 @@ describe('gog_gmail_drafts_create', () => {
497
778
  );
498
779
  });
499
780
 
781
+ it('passes --body-html-file when bodyHtmlFile is supplied', async () => {
782
+ await harness.callTool('gog_gmail_drafts_create', {
783
+ subject: 'Hi',
784
+ body: 'Hello',
785
+ bodyHtmlFile: '/tmp/body.html',
786
+ });
787
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
788
+ ['gmail', 'drafts', 'create', '--subject=Hi', '--body=Hello', '--body-html-file=/tmp/body.html'],
789
+ { account: undefined },
790
+ );
791
+ });
792
+
793
+ it('passes --reply-all when replyAll is set', async () => {
794
+ await harness.callTool('gog_gmail_drafts_create', {
795
+ subject: 'Re: Hi',
796
+ body: 'Hello all',
797
+ replyToThreadId: 't1',
798
+ replyAll: true,
799
+ });
800
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
801
+ ['gmail', 'drafts', 'create', '--subject=Re: Hi', '--body=Hello all', '--thread-id=t1', '--reply-all'],
802
+ { account: undefined },
803
+ );
804
+ });
805
+
500
806
  it('skips recipient flags when omitRecipients is true, even if to/cc/bcc are supplied', async () => {
501
- await handlers.get('gog_gmail_drafts_create')!({
807
+ await harness.callTool('gog_gmail_drafts_create', {
502
808
  to: 'a@b.com', cc: 'cc@x.com', bcc: 'bcc@x.com',
503
809
  subject: 'Hi', body: 'Hello', omitRecipients: true,
504
810
  });
@@ -510,9 +816,9 @@ describe('gog_gmail_drafts_create', () => {
510
816
 
511
817
  it('returnFull re-fetches and returns the full stored draft', async () => {
512
818
  vi.mocked(lib.runOrDiagnose)
513
- .mockResolvedValueOnce(toText('{"draftId":"d9","message":{"id":"m9"}}'))
514
- .mockResolvedValueOnce(toText('{"id":"d9","message":{"subject":"Hi","body":"Hello"}}'));
515
- const result = await handlers.get('gog_gmail_drafts_create')!({
819
+ .mockResolvedValueOnce(rawTextResult('{"draftId":"d9","message":{"id":"m9"}}'))
820
+ .mockResolvedValueOnce(rawTextResult('{"id":"d9","message":{"subject":"Hi","body":"Hello"}}'));
821
+ const result = await harness.callTool('gog_gmail_drafts_create', {
516
822
  subject: 'Hi', body: 'Hello', returnFull: true,
517
823
  });
518
824
  expect(lib.runOrDiagnose).toHaveBeenNthCalledWith(1,
@@ -524,22 +830,22 @@ describe('gog_gmail_drafts_create', () => {
524
830
 
525
831
  it('returnFull does not push --return-full to the CLI', async () => {
526
832
  vi.mocked(lib.runOrDiagnose)
527
- .mockResolvedValueOnce(toText('{"draftId":"d9"}'))
528
- .mockResolvedValueOnce(toText('{}'));
529
- await handlers.get('gog_gmail_drafts_create')!({ subject: 'Hi', body: 'Hello', returnFull: true });
833
+ .mockResolvedValueOnce(rawTextResult('{"draftId":"d9"}'))
834
+ .mockResolvedValueOnce(rawTextResult('{}'));
835
+ await harness.callTool('gog_gmail_drafts_create', { subject: 'Hi', body: 'Hello', returnFull: true });
530
836
  expect(vi.mocked(lib.runOrDiagnose).mock.calls[0]![0]).not.toContain('--return-full');
531
837
  });
532
838
 
533
839
  it('returnFull returns the write result when output is not parseable JSON', async () => {
534
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('not json'));
535
- const result = await handlers.get('gog_gmail_drafts_create')!({ subject: 'Hi', body: 'Hello', returnFull: true });
840
+ vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult('not json'));
841
+ const result = await harness.callTool('gog_gmail_drafts_create', { subject: 'Hi', body: 'Hello', returnFull: true });
536
842
  expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
537
843
  expect(result.content[0].text).toBe('not json');
538
844
  });
539
845
 
540
846
  it('returnFull returns the write result when no draftId is present', async () => {
541
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('{"message":{"id":"m9"}}'));
542
- const result = await handlers.get('gog_gmail_drafts_create')!({ subject: 'Hi', body: 'Hello', returnFull: true });
847
+ vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult('{"message":{"id":"m9"}}'));
848
+ const result = await harness.callTool('gog_gmail_drafts_create', { subject: 'Hi', body: 'Hello', returnFull: true });
543
849
  expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
544
850
  expect(result.content[0].text).toBe('{"message":{"id":"m9"}}');
545
851
  });
@@ -547,7 +853,7 @@ describe('gog_gmail_drafts_create', () => {
547
853
 
548
854
  describe('gmail draft reply threading (native --thread-id)', () => {
549
855
  it('passes replyToThreadId straight through as --thread-id on create (no thread fetch)', async () => {
550
- await handlers.get('gog_gmail_drafts_create')!({
856
+ await harness.callTool('gog_gmail_drafts_create', {
551
857
  subject: 'Re: roof', body: 'Sounds good', replyToThreadId: '19dffe06f9668b28', account: 'me@x.com',
552
858
  });
553
859
  // gog resolves the thread's latest-message headers itself — no extra fetch.
@@ -559,7 +865,7 @@ describe('gmail draft reply threading (native --thread-id)', () => {
559
865
  });
560
866
 
561
867
  it('passes replyToThreadId as --thread-id on update', async () => {
562
- await handlers.get('gog_gmail_drafts_update')!({
868
+ await harness.callTool('gog_gmail_drafts_update', {
563
869
  draftId: 'd1', subject: 'S', body: 'B', replyToThreadId: 't1',
564
870
  });
565
871
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
@@ -569,7 +875,7 @@ describe('gmail draft reply threading (native --thread-id)', () => {
569
875
  });
570
876
 
571
877
  it('replyToMessageId wins when both ids are supplied (no --thread-id)', async () => {
572
- await handlers.get('gog_gmail_drafts_create')!({
878
+ await harness.callTool('gog_gmail_drafts_create', {
573
879
  subject: 'S', body: 'B', replyToMessageId: 'mExplicit', replyToThreadId: 't1',
574
880
  });
575
881
  expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
@@ -582,7 +888,7 @@ describe('gmail draft reply threading (native --thread-id)', () => {
582
888
 
583
889
  describe('gog_gmail_drafts_update', () => {
584
890
  it('calls runOrDiagnose with draftId and updated fields', async () => {
585
- await handlers.get('gog_gmail_drafts_update')!({
891
+ await harness.callTool('gog_gmail_drafts_update', {
586
892
  draftId: 'd1',
587
893
  subject: 'New subject',
588
894
  body: 'New body',
@@ -594,7 +900,7 @@ describe('gog_gmail_drafts_update', () => {
594
900
  });
595
901
 
596
902
  it('passes attachments as repeatable flags', async () => {
597
- await handlers.get('gog_gmail_drafts_update')!({
903
+ await harness.callTool('gog_gmail_drafts_update', {
598
904
  draftId: 'd1',
599
905
  subject: 'S',
600
906
  body: 'B',
@@ -607,7 +913,7 @@ describe('gog_gmail_drafts_update', () => {
607
913
  });
608
914
 
609
915
  it('skips recipient flags when omitRecipients is true', async () => {
610
- await handlers.get('gog_gmail_drafts_update')!({
916
+ await harness.callTool('gog_gmail_drafts_update', {
611
917
  draftId: 'd1', to: 'a@b.com', subject: 'S', body: 'B', omitRecipients: true,
612
918
  });
613
919
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
@@ -617,7 +923,7 @@ describe('gog_gmail_drafts_update', () => {
617
923
  });
618
924
 
619
925
  it('passes --clear-attachments when clearAttachments is true', async () => {
620
- await handlers.get('gog_gmail_drafts_update')!({
926
+ await harness.callTool('gog_gmail_drafts_update', {
621
927
  draftId: 'd1', subject: 'S', body: 'B', clearAttachments: true,
622
928
  });
623
929
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
@@ -628,9 +934,9 @@ describe('gog_gmail_drafts_update', () => {
628
934
 
629
935
  it('returnFull re-fetches the draft by its known id', async () => {
630
936
  vi.mocked(lib.runOrDiagnose)
631
- .mockResolvedValueOnce(toText('{"draftId":"d1"}'))
632
- .mockResolvedValueOnce(toText('{"id":"d1","message":{"subject":"S"}}'));
633
- const result = await handlers.get('gog_gmail_drafts_update')!({
937
+ .mockResolvedValueOnce(rawTextResult('{"draftId":"d1"}'))
938
+ .mockResolvedValueOnce(rawTextResult('{"id":"d1","message":{"subject":"S"}}'));
939
+ const result = await harness.callTool('gog_gmail_drafts_update', {
634
940
  draftId: 'd1', subject: 'S', body: 'B', returnFull: true,
635
941
  });
636
942
  expect(lib.runOrDiagnose).toHaveBeenNthCalledWith(2,
@@ -639,8 +945,8 @@ describe('gog_gmail_drafts_update', () => {
639
945
  });
640
946
 
641
947
  it('returnFull surfaces a failed update instead of re-fetching a stale draft', async () => {
642
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('Error: update failed'));
643
- const result = await handlers.get('gog_gmail_drafts_update')!({
948
+ vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult('Error: update failed'));
949
+ const result = await harness.callTool('gog_gmail_drafts_update', {
644
950
  draftId: 'd1', subject: 'S', body: 'B', returnFull: true,
645
951
  });
646
952
  // write failed (non-JSON) → no re-fetch; the error is surfaced
@@ -651,7 +957,7 @@ describe('gog_gmail_drafts_update', () => {
651
957
 
652
958
  describe('gog_gmail_drafts_delete', () => {
653
959
  it('calls runOrDiagnose with draftId', async () => {
654
- await handlers.get('gog_gmail_drafts_delete')!({ draftId: 'd1' });
960
+ await harness.callTool('gog_gmail_drafts_delete', { draftId: 'd1' });
655
961
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
656
962
  ['gmail', 'drafts', 'delete', 'd1'],
657
963
  { account: undefined },
@@ -659,7 +965,7 @@ describe('gog_gmail_drafts_delete', () => {
659
965
  });
660
966
 
661
967
  it('appends --force when force is true', async () => {
662
- await handlers.get('gog_gmail_drafts_delete')!({ draftId: 'd1', force: true });
968
+ await harness.callTool('gog_gmail_drafts_delete', { draftId: 'd1', force: true });
663
969
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
664
970
  ['gmail', 'drafts', 'delete', 'd1', '--force'],
665
971
  { account: undefined },
@@ -667,7 +973,7 @@ describe('gog_gmail_drafts_delete', () => {
667
973
  });
668
974
 
669
975
  it('omits --force when force is false', async () => {
670
- await handlers.get('gog_gmail_drafts_delete')!({ draftId: 'd1', force: false });
976
+ await harness.callTool('gog_gmail_drafts_delete', { draftId: 'd1', force: false });
671
977
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
672
978
  ['gmail', 'drafts', 'delete', 'd1'],
673
979
  { account: undefined },
@@ -677,7 +983,7 @@ describe('gog_gmail_drafts_delete', () => {
677
983
 
678
984
  describe('gog_gmail_drafts_send', () => {
679
985
  it('calls runOrDiagnose with draftId', async () => {
680
- await handlers.get('gog_gmail_drafts_send')!({ draftId: 'd1' });
986
+ await harness.callTool('gog_gmail_drafts_send', { draftId: 'd1' });
681
987
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
682
988
  ['gmail', 'drafts', 'send', 'd1'],
683
989
  { account: undefined },
@@ -687,7 +993,7 @@ describe('gog_gmail_drafts_send', () => {
687
993
 
688
994
  describe('gog_gmail_forward', () => {
689
995
  it('calls runOrDiagnose with messageId and required --to', async () => {
690
- await handlers.get('gog_gmail_forward')!({ messageId: 'm1', to: 'a@b.com' });
996
+ await harness.callTool('gog_gmail_forward', { messageId: 'm1', to: 'a@b.com' });
691
997
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
692
998
  ['gmail', 'forward', 'm1', '--to=a@b.com'],
693
999
  { account: undefined },
@@ -695,7 +1001,7 @@ describe('gog_gmail_forward', () => {
695
1001
  });
696
1002
 
697
1003
  it('passes all forward flags', async () => {
698
- await handlers.get('gog_gmail_forward')!({
1004
+ await harness.callTool('gog_gmail_forward', {
699
1005
  messageId: 'm1',
700
1006
  to: 'a@b.com',
701
1007
  cc: 'cc@x.com',
@@ -719,7 +1025,7 @@ describe('gog_gmail_forward', () => {
719
1025
  });
720
1026
 
721
1027
  it('omits --skip-attachments when false', async () => {
722
- await handlers.get('gog_gmail_forward')!({ messageId: 'm1', to: 'a@b.com', skipAttachments: false });
1028
+ await harness.callTool('gog_gmail_forward', { messageId: 'm1', to: 'a@b.com', skipAttachments: false });
723
1029
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
724
1030
  ['gmail', 'forward', 'm1', '--to=a@b.com'],
725
1031
  { account: undefined },
@@ -727,9 +1033,99 @@ describe('gog_gmail_forward', () => {
727
1033
  });
728
1034
  });
729
1035
 
1036
+ describe('gog_gmail_reply', () => {
1037
+ it('calls runOrDiagnose with messageId and --body', async () => {
1038
+ await harness.callTool('gog_gmail_reply', { messageId: 'm1', body: 'Thanks' });
1039
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1040
+ ['gmail', 'reply', 'm1', '--body=Thanks'],
1041
+ { account: undefined },
1042
+ );
1043
+ });
1044
+
1045
+ it('passes all reply flags including repeatable recipients', async () => {
1046
+ await harness.callTool('gog_gmail_reply', {
1047
+ messageId: 'm1',
1048
+ body: 'Hi',
1049
+ bodyHtml: '<p>Hi</p>',
1050
+ to: ['a@b.com', 'c@d.com'],
1051
+ cc: ['cc@x.com'],
1052
+ bcc: ['bcc@x.com'],
1053
+ remove: ['old@x.com'],
1054
+ subject: 'New subject',
1055
+ noQuote: true,
1056
+ attach: ['/tmp/a.pdf', '/tmp/b.pdf'],
1057
+ from: 'me@x.com',
1058
+ signature: true,
1059
+ signatureFrom: 'alias@x.com',
1060
+ signatureFile: '/tmp/sig.txt',
1061
+ account: 'me@gmail.com',
1062
+ });
1063
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1064
+ [
1065
+ 'gmail', 'reply', 'm1',
1066
+ '--body=Hi',
1067
+ '--body-html=<p>Hi</p>',
1068
+ '--to=a@b.com',
1069
+ '--to=c@d.com',
1070
+ '--cc=cc@x.com',
1071
+ '--bcc=bcc@x.com',
1072
+ '--remove=old@x.com',
1073
+ '--subject=New subject',
1074
+ '--no-quote',
1075
+ '--attach=/tmp/a.pdf',
1076
+ '--attach=/tmp/b.pdf',
1077
+ '--from=me@x.com',
1078
+ '--signature',
1079
+ '--signature-from=alias@x.com',
1080
+ '--signature-file=/tmp/sig.txt',
1081
+ ],
1082
+ { account: 'me@gmail.com' },
1083
+ );
1084
+ });
1085
+
1086
+ it('omits --no-quote and --signature when false', async () => {
1087
+ await harness.callTool('gog_gmail_reply', { messageId: 'm1', body: 'Hi', noQuote: false, signature: false });
1088
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1089
+ ['gmail', 'reply', 'm1', '--body=Hi'],
1090
+ { account: undefined },
1091
+ );
1092
+ });
1093
+ });
1094
+
1095
+ describe('gog_gmail_reply_all', () => {
1096
+ it('uses the reply-all subcommand', async () => {
1097
+ await harness.callTool('gog_gmail_reply_all', { messageId: 'm1', body: 'Thanks all' });
1098
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1099
+ ['gmail', 'reply-all', 'm1', '--body=Thanks all'],
1100
+ { account: undefined },
1101
+ );
1102
+ });
1103
+
1104
+ it('passes repeatable recipient and signature flags', async () => {
1105
+ await harness.callTool('gog_gmail_reply_all', {
1106
+ messageId: 'm1',
1107
+ bodyHtml: '<p>Hi</p>',
1108
+ cc: ['x@y.com', 'z@y.com'],
1109
+ remove: ['drop@y.com'],
1110
+ signatureFile: '/tmp/sig.html',
1111
+ });
1112
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1113
+ [
1114
+ 'gmail', 'reply-all', 'm1',
1115
+ '--body-html=<p>Hi</p>',
1116
+ '--cc=x@y.com',
1117
+ '--cc=z@y.com',
1118
+ '--remove=drop@y.com',
1119
+ '--signature-file=/tmp/sig.html',
1120
+ ],
1121
+ { account: undefined },
1122
+ );
1123
+ });
1124
+ });
1125
+
730
1126
  describe('gog_gmail_autoreply', () => {
731
1127
  it('calls runOrDiagnose with query and --body', async () => {
732
- await handlers.get('gog_gmail_autoreply')!({ query: 'is:unread', body: 'Thanks' });
1128
+ await harness.callTool('gog_gmail_autoreply', { query: 'is:unread', body: 'Thanks' });
733
1129
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
734
1130
  ['gmail', 'autoreply', 'is:unread', '--body=Thanks'],
735
1131
  { account: undefined },
@@ -737,7 +1133,7 @@ describe('gog_gmail_autoreply', () => {
737
1133
  });
738
1134
 
739
1135
  it('passes all autoreply flags', async () => {
740
- await handlers.get('gog_gmail_autoreply')!({
1136
+ await harness.callTool('gog_gmail_autoreply', {
741
1137
  query: 'is:unread',
742
1138
  max: 50,
743
1139
  subject: 'Re: out of office',
@@ -771,7 +1167,7 @@ describe('gog_gmail_autoreply', () => {
771
1167
  });
772
1168
 
773
1169
  it('omits boolean flags when false', async () => {
774
- await handlers.get('gog_gmail_autoreply')!({
1170
+ await harness.callTool('gog_gmail_autoreply', {
775
1171
  query: 'is:unread',
776
1172
  body: 'Thanks',
777
1173
  archive: false,
@@ -786,7 +1182,7 @@ describe('gog_gmail_autoreply', () => {
786
1182
  });
787
1183
 
788
1184
  it('supports HTML-only body (no plain --body)', async () => {
789
- await handlers.get('gog_gmail_autoreply')!({ query: 'is:unread', bodyHtml: '<p>Hi</p>' });
1185
+ await harness.callTool('gog_gmail_autoreply', { query: 'is:unread', bodyHtml: '<p>Hi</p>' });
790
1186
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
791
1187
  ['gmail', 'autoreply', 'is:unread', '--body-html=<p>Hi</p>'],
792
1188
  { account: undefined },
@@ -796,7 +1192,7 @@ describe('gog_gmail_autoreply', () => {
796
1192
 
797
1193
  describe('gog_gmail_messages_search', () => {
798
1194
  it('calls runOrDiagnose with just the query', async () => {
799
- await handlers.get('gog_gmail_messages_search')!({ query: 'from:alice' });
1195
+ await harness.callTool('gog_gmail_messages_search', { query: 'from:alice' });
800
1196
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
801
1197
  ['gmail', 'messages', 'search', 'from:alice'],
802
1198
  { account: undefined },
@@ -804,7 +1200,7 @@ describe('gog_gmail_messages_search', () => {
804
1200
  });
805
1201
 
806
1202
  it('passes all flags when provided', async () => {
807
- await handlers.get('gog_gmail_messages_search')!({
1203
+ await harness.callTool('gog_gmail_messages_search', {
808
1204
  query: 'is:unread',
809
1205
  max: 10,
810
1206
  page: 'tok',
@@ -821,7 +1217,7 @@ describe('gog_gmail_messages_search', () => {
821
1217
  });
822
1218
 
823
1219
  it('omits flags when false/absent', async () => {
824
- await handlers.get('gog_gmail_messages_search')!({ query: 'x', all: false, includeBody: false, full: false });
1220
+ await harness.callTool('gog_gmail_messages_search', { query: 'x', all: false, includeBody: false, full: false });
825
1221
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
826
1222
  ['gmail', 'messages', 'search', 'x'],
827
1223
  { account: undefined },
@@ -831,7 +1227,7 @@ describe('gog_gmail_messages_search', () => {
831
1227
 
832
1228
  describe('gog_gmail_labels_style', () => {
833
1229
  it('calls runOrDiagnose with just the label', async () => {
834
- await handlers.get('gog_gmail_labels_style')!({ labelIdOrName: 'Work' });
1230
+ await harness.callTool('gog_gmail_labels_style', { labelIdOrName: 'Work' });
835
1231
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
836
1232
  ['gmail', 'labels', 'style', 'Work'],
837
1233
  { account: undefined },
@@ -839,7 +1235,7 @@ describe('gog_gmail_labels_style', () => {
839
1235
  });
840
1236
 
841
1237
  it('passes all style flags when provided', async () => {
842
- await handlers.get('gog_gmail_labels_style')!({
1238
+ await harness.callTool('gog_gmail_labels_style', {
843
1239
  labelIdOrName: 'Work',
844
1240
  backgroundColor: '#000000',
845
1241
  textColor: '#ffffff',
@@ -855,7 +1251,7 @@ describe('gog_gmail_labels_style', () => {
855
1251
 
856
1252
  describe('gog_gmail_vacation_get', () => {
857
1253
  it('calls runOrDiagnose', async () => {
858
- await handlers.get('gog_gmail_vacation_get')!({ account: 'me@x.com' });
1254
+ await harness.callTool('gog_gmail_vacation_get', { account: 'me@x.com' });
859
1255
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
860
1256
  ['gmail', 'settings', 'vacation', 'get'],
861
1257
  { account: 'me@x.com' },
@@ -865,7 +1261,7 @@ describe('gog_gmail_vacation_get', () => {
865
1261
 
866
1262
  describe('gog_gmail_vacation_update', () => {
867
1263
  it('calls runOrDiagnose with no flags', async () => {
868
- await handlers.get('gog_gmail_vacation_update')!({});
1264
+ await harness.callTool('gog_gmail_vacation_update', {});
869
1265
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
870
1266
  ['gmail', 'settings', 'vacation', 'update'],
871
1267
  { account: undefined },
@@ -873,7 +1269,7 @@ describe('gog_gmail_vacation_update', () => {
873
1269
  });
874
1270
 
875
1271
  it('enables with subject/body/start/end and scoping', async () => {
876
- await handlers.get('gog_gmail_vacation_update')!({
1272
+ await harness.callTool('gog_gmail_vacation_update', {
877
1273
  enable: true,
878
1274
  subject: 'Away',
879
1275
  body: '<p>OOO</p>',
@@ -889,7 +1285,7 @@ describe('gog_gmail_vacation_update', () => {
889
1285
  });
890
1286
 
891
1287
  it('disables the responder', async () => {
892
- await handlers.get('gog_gmail_vacation_update')!({ disable: true, enable: false, contactsOnly: false, domainOnly: false });
1288
+ await harness.callTool('gog_gmail_vacation_update', { disable: true, enable: false, contactsOnly: false, domainOnly: false });
893
1289
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
894
1290
  ['gmail', 'settings', 'vacation', 'update', '--disable'],
895
1291
  { account: undefined },
@@ -899,7 +1295,7 @@ describe('gog_gmail_vacation_update', () => {
899
1295
 
900
1296
  describe('gog_gmail_filters_list', () => {
901
1297
  it('calls runOrDiagnose', async () => {
902
- await handlers.get('gog_gmail_filters_list')!({});
1298
+ await harness.callTool('gog_gmail_filters_list', {});
903
1299
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
904
1300
  ['gmail', 'settings', 'filters', 'list'],
905
1301
  { account: undefined },
@@ -909,7 +1305,7 @@ describe('gog_gmail_filters_list', () => {
909
1305
 
910
1306
  describe('gog_gmail_filters_get', () => {
911
1307
  it('calls runOrDiagnose with the filter ID', async () => {
912
- await handlers.get('gog_gmail_filters_get')!({ filterId: 'f1' });
1308
+ await harness.callTool('gog_gmail_filters_get', { filterId: 'f1' });
913
1309
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
914
1310
  ['gmail', 'settings', 'filters', 'get', 'f1'],
915
1311
  { account: undefined },
@@ -919,7 +1315,7 @@ describe('gog_gmail_filters_get', () => {
919
1315
 
920
1316
  describe('gog_gmail_filters_create', () => {
921
1317
  it('calls runOrDiagnose with no flags', async () => {
922
- await handlers.get('gog_gmail_filters_create')!({});
1318
+ await harness.callTool('gog_gmail_filters_create', {});
923
1319
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
924
1320
  ['gmail', 'settings', 'filters', 'create'],
925
1321
  { account: undefined },
@@ -927,7 +1323,7 @@ describe('gog_gmail_filters_create', () => {
927
1323
  });
928
1324
 
929
1325
  it('passes all criteria and actions when provided', async () => {
930
- await handlers.get('gog_gmail_filters_create')!({
1326
+ await harness.callTool('gog_gmail_filters_create', {
931
1327
  from: 'alice@x.com',
932
1328
  to: 'me@x.com',
933
1329
  subject: 'Report',
@@ -944,13 +1340,13 @@ describe('gog_gmail_filters_create', () => {
944
1340
  forward: 'fwd@x.com',
945
1341
  });
946
1342
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
947
- ['gmail', 'settings', 'filters', 'create', '--from=alice@x.com', '--to=me@x.com', '--subject=Report', '--query=has:attachment', '--has-attachment', '--add-label=Reports', '--remove-label=INBOX', '--archive', '--mark-read', '--star', '--important', '--trash', '--never-spam', '--forward=fwd@x.com'],
1343
+ ['gmail', 'settings', 'filters', 'create', '--from=alice@x.com', '--to=me@x.com', '--subject=Report', '--query=has:attachment', '--has-attachment', '--add-label=Reports', '--remove-label=INBOX', '--archive', '--mark-read', '--star', '--important', '--trash', '--never-spam', '--forward=fwd@x.com', '--force'],
948
1344
  { account: undefined },
949
1345
  );
950
1346
  });
951
1347
 
952
1348
  it('omits boolean flags when false', async () => {
953
- await handlers.get('gog_gmail_filters_create')!({
1349
+ await harness.callTool('gog_gmail_filters_create', {
954
1350
  from: 'a@x.com',
955
1351
  hasAttachment: false,
956
1352
  archive: false,
@@ -969,9 +1365,9 @@ describe('gog_gmail_filters_create', () => {
969
1365
 
970
1366
  describe('gog_gmail_filters_delete', () => {
971
1367
  it('calls runOrDiagnose with the filter ID', async () => {
972
- await handlers.get('gog_gmail_filters_delete')!({ filterId: 'f1' });
1368
+ await harness.callTool('gog_gmail_filters_delete', { filterId: 'f1' });
973
1369
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
974
- ['gmail', 'settings', 'filters', 'delete', 'f1'],
1370
+ ['gmail', 'settings', 'filters', 'delete', 'f1', '--force'],
975
1371
  { account: undefined },
976
1372
  );
977
1373
  });
@@ -979,7 +1375,7 @@ describe('gog_gmail_filters_delete', () => {
979
1375
 
980
1376
  describe('gog_gmail_sendas_list', () => {
981
1377
  it('calls runOrDiagnose', async () => {
982
- await handlers.get('gog_gmail_sendas_list')!({});
1378
+ await harness.callTool('gog_gmail_sendas_list', {});
983
1379
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
984
1380
  ['gmail', 'settings', 'sendas', 'list'],
985
1381
  { account: undefined },
@@ -989,7 +1385,7 @@ describe('gog_gmail_sendas_list', () => {
989
1385
 
990
1386
  describe('gog_gmail_sendas_get', () => {
991
1387
  it('calls runOrDiagnose with the email', async () => {
992
- await handlers.get('gog_gmail_sendas_get')!({ email: 'alias@x.com' });
1388
+ await harness.callTool('gog_gmail_sendas_get', { email: 'alias@x.com' });
993
1389
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
994
1390
  ['gmail', 'settings', 'sendas', 'get', 'alias@x.com'],
995
1391
  { account: undefined },
@@ -999,7 +1395,7 @@ describe('gog_gmail_sendas_get', () => {
999
1395
 
1000
1396
  describe('gog_gmail_sendas_create', () => {
1001
1397
  it('calls runOrDiagnose with just the email', async () => {
1002
- await handlers.get('gog_gmail_sendas_create')!({ email: 'alias@x.com' });
1398
+ await harness.callTool('gog_gmail_sendas_create', { email: 'alias@x.com' });
1003
1399
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1004
1400
  ['gmail', 'settings', 'sendas', 'create', 'alias@x.com'],
1005
1401
  { account: undefined },
@@ -1007,7 +1403,7 @@ describe('gog_gmail_sendas_create', () => {
1007
1403
  });
1008
1404
 
1009
1405
  it('passes all flags when provided', async () => {
1010
- await handlers.get('gog_gmail_sendas_create')!({
1406
+ await harness.callTool('gog_gmail_sendas_create', {
1011
1407
  email: 'alias@x.com',
1012
1408
  displayName: 'Alias',
1013
1409
  replyTo: 'reply@x.com',
@@ -1021,7 +1417,7 @@ describe('gog_gmail_sendas_create', () => {
1021
1417
  });
1022
1418
 
1023
1419
  it('omits treatAsAlias when false', async () => {
1024
- await handlers.get('gog_gmail_sendas_create')!({ email: 'alias@x.com', treatAsAlias: false });
1420
+ await harness.callTool('gog_gmail_sendas_create', { email: 'alias@x.com', treatAsAlias: false });
1025
1421
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1026
1422
  ['gmail', 'settings', 'sendas', 'create', 'alias@x.com'],
1027
1423
  { account: undefined },
@@ -1031,7 +1427,7 @@ describe('gog_gmail_sendas_create', () => {
1031
1427
 
1032
1428
  describe('gog_gmail_sendas_update', () => {
1033
1429
  it('calls runOrDiagnose with just the email', async () => {
1034
- await handlers.get('gog_gmail_sendas_update')!({ email: 'alias@x.com' });
1430
+ await harness.callTool('gog_gmail_sendas_update', { email: 'alias@x.com' });
1035
1431
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1036
1432
  ['gmail', 'settings', 'sendas', 'update', 'alias@x.com'],
1037
1433
  { account: undefined },
@@ -1039,7 +1435,7 @@ describe('gog_gmail_sendas_update', () => {
1039
1435
  });
1040
1436
 
1041
1437
  it('passes all flags when provided', async () => {
1042
- await handlers.get('gog_gmail_sendas_update')!({
1438
+ await harness.callTool('gog_gmail_sendas_update', {
1043
1439
  email: 'alias@x.com',
1044
1440
  displayName: 'Alias',
1045
1441
  replyTo: 'reply@x.com',
@@ -1054,7 +1450,7 @@ describe('gog_gmail_sendas_update', () => {
1054
1450
  });
1055
1451
 
1056
1452
  it('omits boolean flags when false', async () => {
1057
- await handlers.get('gog_gmail_sendas_update')!({ email: 'alias@x.com', treatAsAlias: false, makeDefault: false });
1453
+ await harness.callTool('gog_gmail_sendas_update', { email: 'alias@x.com', treatAsAlias: false, makeDefault: false });
1058
1454
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1059
1455
  ['gmail', 'settings', 'sendas', 'update', 'alias@x.com'],
1060
1456
  { account: undefined },
@@ -1064,9 +1460,9 @@ describe('gog_gmail_sendas_update', () => {
1064
1460
 
1065
1461
  describe('gog_gmail_sendas_delete', () => {
1066
1462
  it('calls runOrDiagnose with the email', async () => {
1067
- await handlers.get('gog_gmail_sendas_delete')!({ email: 'alias@x.com' });
1463
+ await harness.callTool('gog_gmail_sendas_delete', { email: 'alias@x.com' });
1068
1464
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1069
- ['gmail', 'settings', 'sendas', 'delete', 'alias@x.com'],
1465
+ ['gmail', 'settings', 'sendas', 'delete', 'alias@x.com', '--force'],
1070
1466
  { account: undefined },
1071
1467
  );
1072
1468
  });
@@ -1074,10 +1470,214 @@ describe('gog_gmail_sendas_delete', () => {
1074
1470
 
1075
1471
  describe('gog_gmail_sendas_verify', () => {
1076
1472
  it('calls runOrDiagnose with the email', async () => {
1077
- await handlers.get('gog_gmail_sendas_verify')!({ email: 'alias@x.com' });
1473
+ await harness.callTool('gog_gmail_sendas_verify', { email: 'alias@x.com' });
1078
1474
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1079
1475
  ['gmail', 'settings', 'sendas', 'verify', 'alias@x.com'],
1080
1476
  { account: undefined },
1081
1477
  );
1082
1478
  });
1083
1479
  });
1480
+
1481
+ // resultText degradations: a non-text tool result (never produced by
1482
+ // runOrDiagnose today, but allowed by the MCP result shape) is passed
1483
+ // through untouched instead of being post-processed.
1484
+ describe('non-text result passthrough', () => {
1485
+ it('gog_gmail_thread_get returns a non-text result untouched when trimming', async () => {
1486
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue({ content: [] });
1487
+ const result = await harness.callTool('gog_gmail_thread_get', { threadId: 't1', latestN: 1 });
1488
+ expect(result.content).toEqual([]);
1489
+ });
1490
+
1491
+ it('returnFull surfaces a non-text write result without re-fetching', async () => {
1492
+ vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce({ content: [] });
1493
+ const result = await harness.callTool('gog_gmail_drafts_update', {
1494
+ draftId: 'd1', subject: 'S', body: 'B', returnFull: true,
1495
+ });
1496
+ expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
1497
+ expect(result.content).toEqual([]);
1498
+ });
1499
+ });
1500
+
1501
+ // Large message bodies cannot travel in argv: the hosted Fly runner rejects any
1502
+ // single arg over its cap and the Linux kernel caps MAX_ARG_STRLEN at 128 KiB.
1503
+ // payloadArg swaps an oversize value for a GogFileArg that the executor
1504
+ // materializes as a temp file. These tests pin the boundary behavior at the
1505
+ // tool surface: small bodies stay inline, large ones become file args, and the
1506
+ // rest of the flag set is unaffected either way.
1507
+ describe('large payloads route to file args', () => {
1508
+ const big = 'x'.repeat(lib.PAYLOAD_INLINE_MAX + 1);
1509
+ const bigHtml = `<table>${'<tr><td>cell</td></tr>'.repeat(600)}</table>`;
1510
+
1511
+ // Pull the args array out of the single runOrDiagnose call under test.
1512
+ function args(): lib.GogArg[] {
1513
+ return vi.mocked(lib.runOrDiagnose).mock.calls[0]![0];
1514
+ }
1515
+
1516
+ it('keeps a body at exactly the threshold inline', async () => {
1517
+ const atLimit = 'x'.repeat(lib.PAYLOAD_INLINE_MAX);
1518
+ await harness.callTool('gog_gmail_drafts_create', { subject: 'S', body: atLimit });
1519
+ expect(args()).toEqual(['gmail', 'drafts', 'create', '--subject=S', `--body=${atLimit}`]);
1520
+ });
1521
+
1522
+ it('measures bytes, not characters, so a multibyte body crosses earlier', async () => {
1523
+ // Each emoji is 2 UTF-16 units but 4 UTF-8 bytes, so this sits comfortably
1524
+ // under the threshold by .length yet well over it by byte count.
1525
+ const emoji = '😀'.repeat(1500);
1526
+ expect(emoji.length).toBeLessThan(lib.PAYLOAD_INLINE_MAX);
1527
+ expect(Buffer.byteLength(emoji, 'utf8')).toBeGreaterThan(lib.PAYLOAD_INLINE_MAX);
1528
+ await harness.callTool('gog_gmail_drafts_create', { subject: 'S', body: emoji });
1529
+ expect(args()[4]).toEqual({ kind: 'file', flag: 'body-file', contents: emoji, ext: undefined });
1530
+ });
1531
+
1532
+ it('gog_gmail_drafts_create routes a large body to --body-file', async () => {
1533
+ await harness.callTool('gog_gmail_drafts_create', { subject: 'S', body: big });
1534
+ expect(args()).toEqual([
1535
+ 'gmail', 'drafts', 'create',
1536
+ '--subject=S',
1537
+ { kind: 'file', flag: 'body-file', contents: big, ext: undefined },
1538
+ ]);
1539
+ });
1540
+
1541
+ it('gog_gmail_drafts_create routes a large bodyHtml to --body-html-file with an html ext', async () => {
1542
+ expect(Buffer.byteLength(bigHtml)).toBeGreaterThan(12_000);
1543
+ await harness.callTool('gog_gmail_drafts_create', { subject: 'S', body: 'plain', bodyHtml: bigHtml });
1544
+ expect(args()).toEqual([
1545
+ 'gmail', 'drafts', 'create',
1546
+ '--subject=S',
1547
+ '--body=plain',
1548
+ { kind: 'file', flag: 'body-html-file', contents: bigHtml, ext: 'html' },
1549
+ ]);
1550
+ });
1551
+
1552
+ it('gog_gmail_drafts_update routes a large body to --body-file', async () => {
1553
+ await harness.callTool('gog_gmail_drafts_update', { draftId: 'd1', subject: 'S', body: big });
1554
+ expect(args()).toEqual([
1555
+ 'gmail', 'drafts', 'update', 'd1',
1556
+ '--subject=S',
1557
+ { kind: 'file', flag: 'body-file', contents: big, ext: undefined },
1558
+ ]);
1559
+ });
1560
+
1561
+ it('omitRecipients still suppresses to/cc/bcc alongside a large body', async () => {
1562
+ await harness.callTool('gog_gmail_drafts_create', {
1563
+ subject: 'S', body: big, to: 'a@b.com', cc: 'c@d.com', bcc: 'e@f.com', omitRecipients: true,
1564
+ });
1565
+ expect(args()).toEqual([
1566
+ 'gmail', 'drafts', 'create',
1567
+ '--subject=S',
1568
+ { kind: 'file', flag: 'body-file', contents: big, ext: undefined },
1569
+ ]);
1570
+ });
1571
+
1572
+ it('threading and attachments still apply alongside a large body', async () => {
1573
+ await harness.callTool('gog_gmail_drafts_create', {
1574
+ subject: 'S', body: big, replyToThreadId: 't1', attach: ['/tmp/a.pdf'],
1575
+ });
1576
+ expect(args()).toEqual([
1577
+ 'gmail', 'drafts', 'create',
1578
+ '--subject=S',
1579
+ { kind: 'file', flag: 'body-file', contents: big, ext: undefined },
1580
+ '--thread-id=t1',
1581
+ '--attach=/tmp/a.pdf',
1582
+ ]);
1583
+ });
1584
+
1585
+ it('replyToMessageId still wins over replyToThreadId alongside a large body', async () => {
1586
+ await harness.callTool('gog_gmail_drafts_create', {
1587
+ subject: 'S', body: big, replyToMessageId: 'm1', replyToThreadId: 't1',
1588
+ });
1589
+ expect(args()).toContain('--reply-to-message-id=m1');
1590
+ expect(args()).not.toContain('--thread-id=t1');
1591
+ });
1592
+
1593
+ it('gog_gmail_reply routes a large body and bodyHtml to file args', async () => {
1594
+ await harness.callTool('gog_gmail_reply', { messageId: 'm1', body: big, bodyHtml: bigHtml });
1595
+ expect(args()).toEqual([
1596
+ 'gmail', 'reply', 'm1',
1597
+ { kind: 'file', flag: 'body-file', contents: big, ext: undefined },
1598
+ { kind: 'file', flag: 'body-html-file', contents: bigHtml, ext: 'html' },
1599
+ ]);
1600
+ });
1601
+
1602
+ it('gog_gmail_reply_all routes a large body to --body-file, leaving the signature boolean a bare flag', async () => {
1603
+ await harness.callTool('gog_gmail_reply_all', { messageId: 'm1', body: big, signature: true });
1604
+ expect(args()).toEqual([
1605
+ 'gmail', 'reply-all', 'm1',
1606
+ { kind: 'file', flag: 'body-file', contents: big, ext: undefined },
1607
+ '--signature',
1608
+ ]);
1609
+ });
1610
+
1611
+ it('gog_gmail_forward routes a large note to --note-file', async () => {
1612
+ await harness.callTool('gog_gmail_forward', { messageId: 'm1', to: 'a@b.com', note: big });
1613
+ expect(args()).toEqual([
1614
+ 'gmail', 'forward', 'm1', '--to=a@b.com',
1615
+ { kind: 'file', flag: 'note-file', contents: big, ext: undefined },
1616
+ ]);
1617
+ });
1618
+
1619
+ it('gog_gmail_autoreply routes a large body to --body-file but keeps bodyHtml inline', async () => {
1620
+ // gog 0.34.1 gives `gmail autoreply` a --body-file but no --body-html-file.
1621
+ await harness.callTool('gog_gmail_autoreply', { query: 'is:unread', body: big, bodyHtml: '<p>Hi</p>' });
1622
+ expect(args()).toEqual([
1623
+ 'gmail', 'autoreply', 'is:unread',
1624
+ { kind: 'file', flag: 'body-file', contents: big, ext: undefined },
1625
+ '--body-html=<p>Hi</p>',
1626
+ ]);
1627
+ });
1628
+
1629
+ it('gog_gmail_vacation_update keeps a large body inline (gog has no --body-file there)', async () => {
1630
+ await harness.callTool('gog_gmail_vacation_update', { enable: true, body: big });
1631
+ expect(args()).toEqual(['gmail', 'settings', 'vacation', 'update', '--enable', `--body=${big}`]);
1632
+ });
1633
+ });
1634
+
1635
+ // gog hard-errors when an inline flag and its --*-file twin are both present
1636
+ // ("use only one of --body-html or --body-html-file"). The tools reject the
1637
+ // combination up front so the caller sees which PARAMS collided.
1638
+ describe('inline/file param conflicts are rejected before gog runs', () => {
1639
+ it('gog_gmail_drafts_create rejects bodyHtml plus bodyHtmlFile', async () => {
1640
+ const res = await harness.callTool('gog_gmail_drafts_create', {
1641
+ subject: 'S', body: 'B', bodyHtml: '<p>Hi</p>', bodyHtmlFile: '/tmp/b.html',
1642
+ });
1643
+ expect(res.isError).toBe(true);
1644
+ expect((res.content[0] as { text: string }).text).toContain('bodyHtml and bodyHtmlFile are mutually exclusive');
1645
+ expect(lib.runOrDiagnose).not.toHaveBeenCalled();
1646
+ });
1647
+
1648
+ it('gog_gmail_drafts_update rejects bodyHtml plus bodyHtmlFile', async () => {
1649
+ const res = await harness.callTool('gog_gmail_drafts_update', {
1650
+ draftId: 'd1', subject: 'S', body: 'B', bodyHtml: '<p>Hi</p>', bodyHtmlFile: '/tmp/b.html',
1651
+ });
1652
+ expect(res.isError).toBe(true);
1653
+ expect((res.content[0] as { text: string }).text).toContain('mutually exclusive');
1654
+ expect(lib.runOrDiagnose).not.toHaveBeenCalled();
1655
+ });
1656
+
1657
+ it('gog_gmail_reply rejects bodyHtml plus bodyHtmlFile', async () => {
1658
+ const res = await harness.callTool('gog_gmail_reply', {
1659
+ messageId: 'm1', bodyHtml: '<p>Hi</p>', bodyHtmlFile: '/tmp/b.html',
1660
+ });
1661
+ expect(res.isError).toBe(true);
1662
+ expect((res.content[0] as { text: string }).text).toContain('bodyHtml and bodyHtmlFile are mutually exclusive');
1663
+ expect(lib.runOrDiagnose).not.toHaveBeenCalled();
1664
+ });
1665
+
1666
+ it('an empty-string bodyHtml still counts as supplied and conflicts', async () => {
1667
+ // Guards the `!== undefined` check against a falsy-but-present value
1668
+ // sliding through to gog, which rejects the pair regardless of content.
1669
+ const res = await harness.callTool('gog_gmail_reply', {
1670
+ messageId: 'm1', body: 'B', bodyHtml: '', bodyHtmlFile: '/tmp/b.html',
1671
+ });
1672
+ expect(res.isError).toBe(true);
1673
+ expect(lib.runOrDiagnose).not.toHaveBeenCalled();
1674
+ });
1675
+
1676
+ it('bodyHtmlFile alone still passes through as --body-html-file', async () => {
1677
+ await harness.callTool('gog_gmail_reply', { messageId: 'm1', body: 'Hi', bodyHtmlFile: '/tmp/b.html' });
1678
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1679
+ ['gmail', 'reply', 'm1', '--body=Hi', '--body-html-file=/tmp/b.html'],
1680
+ { account: undefined },
1681
+ );
1682
+ });
1683
+ });