gogcli-mcp-gmail 2.8.0 → 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 },
@@ -135,7 +383,7 @@ describe('bulk action tools (archive, mark_read, mark_unread, trash)', () => {
135
383
 
136
384
  // gog 0.25.0 — --thread is archive-only
137
385
  it('gog_gmail_archive passes --thread to archive whole threads by id', async () => {
138
- await handlers.get('gog_gmail_archive')!({ messageIds: ['t1', 't2'], thread: true });
386
+ await harness.callTool('gog_gmail_archive', { messageIds: ['t1', 't2'], thread: true });
139
387
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
140
388
  ['gmail', 'archive', 't1', 't2', '--thread'],
141
389
  { account: undefined },
@@ -143,7 +391,7 @@ describe('bulk action tools (archive, mark_read, mark_unread, trash)', () => {
143
391
  });
144
392
 
145
393
  it('other bulk tools do not expose a thread param', async () => {
146
- await handlers.get('gog_gmail_trash')!({ messageIds: ['m1'], thread: true });
394
+ await harness.callTool('gog_gmail_trash', { messageIds: ['m1'], thread: true });
147
395
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
148
396
  ['gmail', 'trash', 'm1'],
149
397
  { account: undefined },
@@ -153,7 +401,7 @@ describe('bulk action tools (archive, mark_read, mark_unread, trash)', () => {
153
401
 
154
402
  describe('gog_gmail_message_modify', () => {
155
403
  it('calls runOrDiagnose with messageId and label changes', async () => {
156
- await handlers.get('gog_gmail_message_modify')!({
404
+ await harness.callTool('gog_gmail_message_modify', {
157
405
  messageId: 'm1',
158
406
  add: 'STARRED,IMPORTANT',
159
407
  remove: 'INBOX',
@@ -165,7 +413,7 @@ describe('gog_gmail_message_modify', () => {
165
413
  });
166
414
 
167
415
  it('omits flags when not provided', async () => {
168
- await handlers.get('gog_gmail_message_modify')!({ messageId: 'm1' });
416
+ await harness.callTool('gog_gmail_message_modify', { messageId: 'm1' });
169
417
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
170
418
  ['gmail', 'messages', 'modify', 'm1'],
171
419
  { account: undefined },
@@ -175,17 +423,33 @@ describe('gog_gmail_message_modify', () => {
175
423
 
176
424
  describe('gog_gmail_batch_delete', () => {
177
425
  it('calls runOrDiagnose with messageIds as positional args', async () => {
178
- await handlers.get('gog_gmail_batch_delete')!({ messageIds: ['m1', 'm2', 'm3'] });
426
+ await harness.callTool('gog_gmail_batch_delete', { messageIds: ['m1', 'm2', 'm3'] });
179
427
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
180
428
  ['gmail', 'batch', 'delete', 'm1', 'm2', 'm3'],
181
429
  { account: undefined },
182
430
  );
183
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
+ });
184
448
  });
185
449
 
186
450
  describe('gog_gmail_batch_modify', () => {
187
451
  it('calls runOrDiagnose with messageIds and label flags', async () => {
188
- await handlers.get('gog_gmail_batch_modify')!({
452
+ await harness.callTool('gog_gmail_batch_modify', {
189
453
  messageIds: ['m1', 'm2'],
190
454
  add: 'STARRED',
191
455
  remove: 'INBOX',
@@ -197,7 +461,7 @@ describe('gog_gmail_batch_modify', () => {
197
461
  });
198
462
 
199
463
  it('omits label flags when not provided', async () => {
200
- await handlers.get('gog_gmail_batch_modify')!({ messageIds: ['m1'] });
464
+ await harness.callTool('gog_gmail_batch_modify', { messageIds: ['m1'] });
201
465
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
202
466
  ['gmail', 'batch', 'modify', 'm1'],
203
467
  { account: undefined },
@@ -207,7 +471,7 @@ describe('gog_gmail_batch_modify', () => {
207
471
 
208
472
  describe('gog_gmail_thread_get', () => {
209
473
  it('calls runOrDiagnose with threadId', async () => {
210
- await handlers.get('gog_gmail_thread_get')!({ threadId: 't1' });
474
+ await harness.callTool('gog_gmail_thread_get', { threadId: 't1' });
211
475
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
212
476
  ['gmail', 'thread', 'get', 't1'],
213
477
  { account: undefined },
@@ -215,7 +479,7 @@ describe('gog_gmail_thread_get', () => {
215
479
  });
216
480
 
217
481
  it('passes all flags', async () => {
218
- await handlers.get('gog_gmail_thread_get')!({
482
+ await harness.callTool('gog_gmail_thread_get', {
219
483
  threadId: 't1',
220
484
  download: true,
221
485
  full: true,
@@ -229,7 +493,7 @@ describe('gog_gmail_thread_get', () => {
229
493
  });
230
494
 
231
495
  it('omits boolean flags when false', async () => {
232
- await handlers.get('gog_gmail_thread_get')!({
496
+ await harness.callTool('gog_gmail_thread_get', {
233
497
  threadId: 't1',
234
498
  download: false,
235
499
  full: false,
@@ -254,14 +518,14 @@ describe('gog_gmail_thread_get', () => {
254
518
  });
255
519
 
256
520
  it('does not transform the output when no paging params are given', async () => {
257
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText(THREAD));
258
- 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' });
259
523
  expect(result.content[0].text).toBe(THREAD);
260
524
  });
261
525
 
262
526
  it('latestN returns only the last N messages', async () => {
263
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText(THREAD));
264
- 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 });
265
529
  // latestN is wrapper-side; no CLI flag is added
266
530
  expect(vi.mocked(lib.runOrDiagnose).mock.calls[0]![0]).toEqual(['gmail', 'thread', 'get', 't1']);
267
531
  const parsed = JSON.parse(result.content[0].text);
@@ -269,8 +533,8 @@ describe('gog_gmail_thread_get', () => {
269
533
  });
270
534
 
271
535
  it('snippetsOnly returns per-message headers and snippet without bodies', async () => {
272
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText(THREAD));
273
- 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 });
274
538
  const parsed = JSON.parse(result.content[0].text);
275
539
  expect(parsed.thread.messages).toHaveLength(3);
276
540
  const m1 = parsed.thread.messages[0];
@@ -282,35 +546,35 @@ describe('gog_gmail_thread_get', () => {
282
546
  });
283
547
 
284
548
  it('combines latestN and snippetsOnly', async () => {
285
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText(THREAD));
286
- 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 });
287
551
  const parsed = JSON.parse(result.content[0].text);
288
552
  expect(parsed.thread.messages).toHaveLength(1);
289
553
  expect(parsed.thread.messages[0].id).toBe('m3');
290
554
  });
291
555
 
292
556
  it('returns the raw result when the payload is not JSON', async () => {
293
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('not json'));
294
- 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 });
295
559
  expect(result.content[0].text).toBe('not json');
296
560
  });
297
561
 
298
562
  it('returns the raw result when there is no messages array', async () => {
299
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('{"thread":{}}'));
300
- 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 });
301
565
  expect(result.content[0].text).toBe('{"thread":{}}');
302
566
  });
303
567
 
304
568
  it('returns the raw result when there is no thread object', async () => {
305
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('{}'));
306
- 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 });
307
571
  expect(result.content[0].text).toBe('{}');
308
572
  });
309
573
  });
310
574
 
311
575
  describe('gog_gmail_thread_modify', () => {
312
576
  it('calls runOrDiagnose with threadId and label flags', async () => {
313
- await handlers.get('gog_gmail_thread_modify')!({
577
+ await harness.callTool('gog_gmail_thread_modify', {
314
578
  threadId: 't1',
315
579
  add: 'IMPORTANT',
316
580
  remove: 'INBOX',
@@ -322,7 +586,7 @@ describe('gog_gmail_thread_modify', () => {
322
586
  });
323
587
 
324
588
  it('omits label flags when not provided', async () => {
325
- await handlers.get('gog_gmail_thread_modify')!({ threadId: 't1' });
589
+ await harness.callTool('gog_gmail_thread_modify', { threadId: 't1' });
326
590
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
327
591
  ['gmail', 'thread', 'modify', 't1'],
328
592
  { account: undefined },
@@ -332,7 +596,7 @@ describe('gog_gmail_thread_modify', () => {
332
596
 
333
597
  describe('gog_gmail_thread_attachments', () => {
334
598
  it('calls runOrDiagnose with threadId', async () => {
335
- await handlers.get('gog_gmail_thread_attachments')!({ threadId: 't1' });
599
+ await harness.callTool('gog_gmail_thread_attachments', { threadId: 't1' });
336
600
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
337
601
  ['gmail', 'thread', 'attachments', 't1'],
338
602
  { account: undefined },
@@ -340,7 +604,7 @@ describe('gog_gmail_thread_attachments', () => {
340
604
  });
341
605
 
342
606
  it('passes --download and --out-dir when provided', async () => {
343
- await handlers.get('gog_gmail_thread_attachments')!({
607
+ await harness.callTool('gog_gmail_thread_attachments', {
344
608
  threadId: 't1',
345
609
  download: true,
346
610
  outDir: '/tmp/atts',
@@ -354,7 +618,7 @@ describe('gog_gmail_thread_attachments', () => {
354
618
 
355
619
  describe('gog_gmail_labels_list', () => {
356
620
  it('calls runOrDiagnose with no args', async () => {
357
- await handlers.get('gog_gmail_labels_list')!({});
621
+ await harness.callTool('gog_gmail_labels_list', {});
358
622
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
359
623
  ['gmail', 'labels', 'list'],
360
624
  { account: undefined },
@@ -362,7 +626,7 @@ describe('gog_gmail_labels_list', () => {
362
626
  });
363
627
 
364
628
  it('forwards account', async () => {
365
- await handlers.get('gog_gmail_labels_list')!({ account: 'a@b.com' });
629
+ await harness.callTool('gog_gmail_labels_list', { account: 'a@b.com' });
366
630
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
367
631
  ['gmail', 'labels', 'list'],
368
632
  { account: 'a@b.com' },
@@ -372,7 +636,7 @@ describe('gog_gmail_labels_list', () => {
372
636
 
373
637
  describe('gog_gmail_labels_get', () => {
374
638
  it('calls runOrDiagnose with labelIdOrName', async () => {
375
- await handlers.get('gog_gmail_labels_get')!({ labelIdOrName: 'INBOX' });
639
+ await harness.callTool('gog_gmail_labels_get', { labelIdOrName: 'INBOX' });
376
640
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
377
641
  ['gmail', 'labels', 'get', 'INBOX'],
378
642
  { account: undefined },
@@ -382,7 +646,7 @@ describe('gog_gmail_labels_get', () => {
382
646
 
383
647
  describe('gog_gmail_labels_create', () => {
384
648
  it('calls runOrDiagnose with name', async () => {
385
- await handlers.get('gog_gmail_labels_create')!({ name: 'Newsletter' });
649
+ await harness.callTool('gog_gmail_labels_create', { name: 'Newsletter' });
386
650
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
387
651
  ['gmail', 'labels', 'create', 'Newsletter'],
388
652
  { account: undefined },
@@ -392,7 +656,7 @@ describe('gog_gmail_labels_create', () => {
392
656
 
393
657
  describe('gog_gmail_labels_rename', () => {
394
658
  it('calls runOrDiagnose with old and new names', async () => {
395
- await handlers.get('gog_gmail_labels_rename')!({ labelIdOrName: 'Old', newName: 'New' });
659
+ await harness.callTool('gog_gmail_labels_rename', { labelIdOrName: 'Old', newName: 'New' });
396
660
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
397
661
  ['gmail', 'labels', 'rename', 'Old', 'New'],
398
662
  { account: undefined },
@@ -402,9 +666,9 @@ describe('gog_gmail_labels_rename', () => {
402
666
 
403
667
  describe('gog_gmail_labels_delete', () => {
404
668
  it('calls runOrDiagnose with labelIdOrName', async () => {
405
- await handlers.get('gog_gmail_labels_delete')!({ labelIdOrName: 'Trash-Me' });
669
+ await harness.callTool('gog_gmail_labels_delete', { labelIdOrName: 'Trash-Me' });
406
670
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
407
- ['gmail', 'labels', 'delete', 'Trash-Me'],
671
+ ['gmail', 'labels', 'delete', 'Trash-Me', '--force'],
408
672
  { account: undefined },
409
673
  );
410
674
  });
@@ -412,7 +676,7 @@ describe('gog_gmail_labels_delete', () => {
412
676
 
413
677
  describe('gog_gmail_labels_modify', () => {
414
678
  it('calls runOrDiagnose with threadIds and label flags', async () => {
415
- await handlers.get('gog_gmail_labels_modify')!({
679
+ await harness.callTool('gog_gmail_labels_modify', {
416
680
  threadIds: ['t1', 't2'],
417
681
  add: 'Newsletter',
418
682
  remove: 'INBOX',
@@ -424,7 +688,7 @@ describe('gog_gmail_labels_modify', () => {
424
688
  });
425
689
 
426
690
  it('omits label flags when not provided', async () => {
427
- await handlers.get('gog_gmail_labels_modify')!({ threadIds: ['t1'] });
691
+ await harness.callTool('gog_gmail_labels_modify', { threadIds: ['t1'] });
428
692
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
429
693
  ['gmail', 'labels', 'modify', 't1'],
430
694
  { account: undefined },
@@ -434,7 +698,7 @@ describe('gog_gmail_labels_modify', () => {
434
698
 
435
699
  describe('gog_gmail_drafts_list', () => {
436
700
  it('calls runOrDiagnose with no flags', async () => {
437
- await handlers.get('gog_gmail_drafts_list')!({});
701
+ await harness.callTool('gog_gmail_drafts_list', {});
438
702
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
439
703
  ['gmail', 'drafts', 'list'],
440
704
  { account: undefined },
@@ -442,7 +706,7 @@ describe('gog_gmail_drafts_list', () => {
442
706
  });
443
707
 
444
708
  it('passes pagination flags', async () => {
445
- 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 });
446
710
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
447
711
  ['gmail', 'drafts', 'list', '--max=50', '--page=tok', '--all'],
448
712
  { account: undefined },
@@ -452,7 +716,7 @@ describe('gog_gmail_drafts_list', () => {
452
716
 
453
717
  describe('gog_gmail_drafts_get', () => {
454
718
  it('calls runOrDiagnose with draftId', async () => {
455
- await handlers.get('gog_gmail_drafts_get')!({ draftId: 'd1' });
719
+ await harness.callTool('gog_gmail_drafts_get', { draftId: 'd1' });
456
720
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
457
721
  ['gmail', 'drafts', 'get', 'd1'],
458
722
  { account: undefined },
@@ -460,7 +724,7 @@ describe('gog_gmail_drafts_get', () => {
460
724
  });
461
725
 
462
726
  it('passes --download when true', async () => {
463
- await handlers.get('gog_gmail_drafts_get')!({ draftId: 'd1', download: true });
727
+ await harness.callTool('gog_gmail_drafts_get', { draftId: 'd1', download: true });
464
728
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
465
729
  ['gmail', 'drafts', 'get', 'd1', '--download'],
466
730
  { account: undefined },
@@ -470,7 +734,7 @@ describe('gog_gmail_drafts_get', () => {
470
734
 
471
735
  describe('gog_gmail_drafts_create', () => {
472
736
  it('calls runOrDiagnose with minimal required flags', async () => {
473
- await handlers.get('gog_gmail_drafts_create')!({
737
+ await harness.callTool('gog_gmail_drafts_create', {
474
738
  subject: 'Hi',
475
739
  body: 'Hello',
476
740
  });
@@ -481,7 +745,7 @@ describe('gog_gmail_drafts_create', () => {
481
745
  });
482
746
 
483
747
  it('passes all flags including attachments', async () => {
484
- await handlers.get('gog_gmail_drafts_create')!({
748
+ await harness.callTool('gog_gmail_drafts_create', {
485
749
  to: 'a@b.com,c@d.com',
486
750
  cc: 'cc@x.com',
487
751
  bcc: 'bcc@x.com',
@@ -514,8 +778,33 @@ describe('gog_gmail_drafts_create', () => {
514
778
  );
515
779
  });
516
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
+
517
806
  it('skips recipient flags when omitRecipients is true, even if to/cc/bcc are supplied', async () => {
518
- await handlers.get('gog_gmail_drafts_create')!({
807
+ await harness.callTool('gog_gmail_drafts_create', {
519
808
  to: 'a@b.com', cc: 'cc@x.com', bcc: 'bcc@x.com',
520
809
  subject: 'Hi', body: 'Hello', omitRecipients: true,
521
810
  });
@@ -527,9 +816,9 @@ describe('gog_gmail_drafts_create', () => {
527
816
 
528
817
  it('returnFull re-fetches and returns the full stored draft', async () => {
529
818
  vi.mocked(lib.runOrDiagnose)
530
- .mockResolvedValueOnce(toText('{"draftId":"d9","message":{"id":"m9"}}'))
531
- .mockResolvedValueOnce(toText('{"id":"d9","message":{"subject":"Hi","body":"Hello"}}'));
532
- 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', {
533
822
  subject: 'Hi', body: 'Hello', returnFull: true,
534
823
  });
535
824
  expect(lib.runOrDiagnose).toHaveBeenNthCalledWith(1,
@@ -541,22 +830,22 @@ describe('gog_gmail_drafts_create', () => {
541
830
 
542
831
  it('returnFull does not push --return-full to the CLI', async () => {
543
832
  vi.mocked(lib.runOrDiagnose)
544
- .mockResolvedValueOnce(toText('{"draftId":"d9"}'))
545
- .mockResolvedValueOnce(toText('{}'));
546
- 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 });
547
836
  expect(vi.mocked(lib.runOrDiagnose).mock.calls[0]![0]).not.toContain('--return-full');
548
837
  });
549
838
 
550
839
  it('returnFull returns the write result when output is not parseable JSON', async () => {
551
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('not json'));
552
- 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 });
553
842
  expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
554
843
  expect(result.content[0].text).toBe('not json');
555
844
  });
556
845
 
557
846
  it('returnFull returns the write result when no draftId is present', async () => {
558
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('{"message":{"id":"m9"}}'));
559
- 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 });
560
849
  expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
561
850
  expect(result.content[0].text).toBe('{"message":{"id":"m9"}}');
562
851
  });
@@ -564,7 +853,7 @@ describe('gog_gmail_drafts_create', () => {
564
853
 
565
854
  describe('gmail draft reply threading (native --thread-id)', () => {
566
855
  it('passes replyToThreadId straight through as --thread-id on create (no thread fetch)', async () => {
567
- await handlers.get('gog_gmail_drafts_create')!({
856
+ await harness.callTool('gog_gmail_drafts_create', {
568
857
  subject: 'Re: roof', body: 'Sounds good', replyToThreadId: '19dffe06f9668b28', account: 'me@x.com',
569
858
  });
570
859
  // gog resolves the thread's latest-message headers itself — no extra fetch.
@@ -576,7 +865,7 @@ describe('gmail draft reply threading (native --thread-id)', () => {
576
865
  });
577
866
 
578
867
  it('passes replyToThreadId as --thread-id on update', async () => {
579
- await handlers.get('gog_gmail_drafts_update')!({
868
+ await harness.callTool('gog_gmail_drafts_update', {
580
869
  draftId: 'd1', subject: 'S', body: 'B', replyToThreadId: 't1',
581
870
  });
582
871
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
@@ -586,7 +875,7 @@ describe('gmail draft reply threading (native --thread-id)', () => {
586
875
  });
587
876
 
588
877
  it('replyToMessageId wins when both ids are supplied (no --thread-id)', async () => {
589
- await handlers.get('gog_gmail_drafts_create')!({
878
+ await harness.callTool('gog_gmail_drafts_create', {
590
879
  subject: 'S', body: 'B', replyToMessageId: 'mExplicit', replyToThreadId: 't1',
591
880
  });
592
881
  expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
@@ -599,7 +888,7 @@ describe('gmail draft reply threading (native --thread-id)', () => {
599
888
 
600
889
  describe('gog_gmail_drafts_update', () => {
601
890
  it('calls runOrDiagnose with draftId and updated fields', async () => {
602
- await handlers.get('gog_gmail_drafts_update')!({
891
+ await harness.callTool('gog_gmail_drafts_update', {
603
892
  draftId: 'd1',
604
893
  subject: 'New subject',
605
894
  body: 'New body',
@@ -611,7 +900,7 @@ describe('gog_gmail_drafts_update', () => {
611
900
  });
612
901
 
613
902
  it('passes attachments as repeatable flags', async () => {
614
- await handlers.get('gog_gmail_drafts_update')!({
903
+ await harness.callTool('gog_gmail_drafts_update', {
615
904
  draftId: 'd1',
616
905
  subject: 'S',
617
906
  body: 'B',
@@ -624,7 +913,7 @@ describe('gog_gmail_drafts_update', () => {
624
913
  });
625
914
 
626
915
  it('skips recipient flags when omitRecipients is true', async () => {
627
- await handlers.get('gog_gmail_drafts_update')!({
916
+ await harness.callTool('gog_gmail_drafts_update', {
628
917
  draftId: 'd1', to: 'a@b.com', subject: 'S', body: 'B', omitRecipients: true,
629
918
  });
630
919
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
@@ -634,7 +923,7 @@ describe('gog_gmail_drafts_update', () => {
634
923
  });
635
924
 
636
925
  it('passes --clear-attachments when clearAttachments is true', async () => {
637
- await handlers.get('gog_gmail_drafts_update')!({
926
+ await harness.callTool('gog_gmail_drafts_update', {
638
927
  draftId: 'd1', subject: 'S', body: 'B', clearAttachments: true,
639
928
  });
640
929
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
@@ -645,9 +934,9 @@ describe('gog_gmail_drafts_update', () => {
645
934
 
646
935
  it('returnFull re-fetches the draft by its known id', async () => {
647
936
  vi.mocked(lib.runOrDiagnose)
648
- .mockResolvedValueOnce(toText('{"draftId":"d1"}'))
649
- .mockResolvedValueOnce(toText('{"id":"d1","message":{"subject":"S"}}'));
650
- 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', {
651
940
  draftId: 'd1', subject: 'S', body: 'B', returnFull: true,
652
941
  });
653
942
  expect(lib.runOrDiagnose).toHaveBeenNthCalledWith(2,
@@ -656,8 +945,8 @@ describe('gog_gmail_drafts_update', () => {
656
945
  });
657
946
 
658
947
  it('returnFull surfaces a failed update instead of re-fetching a stale draft', async () => {
659
- vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(toText('Error: update failed'));
660
- 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', {
661
950
  draftId: 'd1', subject: 'S', body: 'B', returnFull: true,
662
951
  });
663
952
  // write failed (non-JSON) → no re-fetch; the error is surfaced
@@ -668,7 +957,7 @@ describe('gog_gmail_drafts_update', () => {
668
957
 
669
958
  describe('gog_gmail_drafts_delete', () => {
670
959
  it('calls runOrDiagnose with draftId', async () => {
671
- await handlers.get('gog_gmail_drafts_delete')!({ draftId: 'd1' });
960
+ await harness.callTool('gog_gmail_drafts_delete', { draftId: 'd1' });
672
961
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
673
962
  ['gmail', 'drafts', 'delete', 'd1'],
674
963
  { account: undefined },
@@ -676,7 +965,7 @@ describe('gog_gmail_drafts_delete', () => {
676
965
  });
677
966
 
678
967
  it('appends --force when force is true', async () => {
679
- await handlers.get('gog_gmail_drafts_delete')!({ draftId: 'd1', force: true });
968
+ await harness.callTool('gog_gmail_drafts_delete', { draftId: 'd1', force: true });
680
969
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
681
970
  ['gmail', 'drafts', 'delete', 'd1', '--force'],
682
971
  { account: undefined },
@@ -684,7 +973,7 @@ describe('gog_gmail_drafts_delete', () => {
684
973
  });
685
974
 
686
975
  it('omits --force when force is false', async () => {
687
- await handlers.get('gog_gmail_drafts_delete')!({ draftId: 'd1', force: false });
976
+ await harness.callTool('gog_gmail_drafts_delete', { draftId: 'd1', force: false });
688
977
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
689
978
  ['gmail', 'drafts', 'delete', 'd1'],
690
979
  { account: undefined },
@@ -694,7 +983,7 @@ describe('gog_gmail_drafts_delete', () => {
694
983
 
695
984
  describe('gog_gmail_drafts_send', () => {
696
985
  it('calls runOrDiagnose with draftId', async () => {
697
- await handlers.get('gog_gmail_drafts_send')!({ draftId: 'd1' });
986
+ await harness.callTool('gog_gmail_drafts_send', { draftId: 'd1' });
698
987
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
699
988
  ['gmail', 'drafts', 'send', 'd1'],
700
989
  { account: undefined },
@@ -704,7 +993,7 @@ describe('gog_gmail_drafts_send', () => {
704
993
 
705
994
  describe('gog_gmail_forward', () => {
706
995
  it('calls runOrDiagnose with messageId and required --to', async () => {
707
- 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' });
708
997
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
709
998
  ['gmail', 'forward', 'm1', '--to=a@b.com'],
710
999
  { account: undefined },
@@ -712,7 +1001,7 @@ describe('gog_gmail_forward', () => {
712
1001
  });
713
1002
 
714
1003
  it('passes all forward flags', async () => {
715
- await handlers.get('gog_gmail_forward')!({
1004
+ await harness.callTool('gog_gmail_forward', {
716
1005
  messageId: 'm1',
717
1006
  to: 'a@b.com',
718
1007
  cc: 'cc@x.com',
@@ -736,7 +1025,7 @@ describe('gog_gmail_forward', () => {
736
1025
  });
737
1026
 
738
1027
  it('omits --skip-attachments when false', async () => {
739
- 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 });
740
1029
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
741
1030
  ['gmail', 'forward', 'm1', '--to=a@b.com'],
742
1031
  { account: undefined },
@@ -744,9 +1033,99 @@ describe('gog_gmail_forward', () => {
744
1033
  });
745
1034
  });
746
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
+
747
1126
  describe('gog_gmail_autoreply', () => {
748
1127
  it('calls runOrDiagnose with query and --body', async () => {
749
- await handlers.get('gog_gmail_autoreply')!({ query: 'is:unread', body: 'Thanks' });
1128
+ await harness.callTool('gog_gmail_autoreply', { query: 'is:unread', body: 'Thanks' });
750
1129
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
751
1130
  ['gmail', 'autoreply', 'is:unread', '--body=Thanks'],
752
1131
  { account: undefined },
@@ -754,7 +1133,7 @@ describe('gog_gmail_autoreply', () => {
754
1133
  });
755
1134
 
756
1135
  it('passes all autoreply flags', async () => {
757
- await handlers.get('gog_gmail_autoreply')!({
1136
+ await harness.callTool('gog_gmail_autoreply', {
758
1137
  query: 'is:unread',
759
1138
  max: 50,
760
1139
  subject: 'Re: out of office',
@@ -788,7 +1167,7 @@ describe('gog_gmail_autoreply', () => {
788
1167
  });
789
1168
 
790
1169
  it('omits boolean flags when false', async () => {
791
- await handlers.get('gog_gmail_autoreply')!({
1170
+ await harness.callTool('gog_gmail_autoreply', {
792
1171
  query: 'is:unread',
793
1172
  body: 'Thanks',
794
1173
  archive: false,
@@ -803,7 +1182,7 @@ describe('gog_gmail_autoreply', () => {
803
1182
  });
804
1183
 
805
1184
  it('supports HTML-only body (no plain --body)', async () => {
806
- 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>' });
807
1186
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
808
1187
  ['gmail', 'autoreply', 'is:unread', '--body-html=<p>Hi</p>'],
809
1188
  { account: undefined },
@@ -813,7 +1192,7 @@ describe('gog_gmail_autoreply', () => {
813
1192
 
814
1193
  describe('gog_gmail_messages_search', () => {
815
1194
  it('calls runOrDiagnose with just the query', async () => {
816
- await handlers.get('gog_gmail_messages_search')!({ query: 'from:alice' });
1195
+ await harness.callTool('gog_gmail_messages_search', { query: 'from:alice' });
817
1196
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
818
1197
  ['gmail', 'messages', 'search', 'from:alice'],
819
1198
  { account: undefined },
@@ -821,7 +1200,7 @@ describe('gog_gmail_messages_search', () => {
821
1200
  });
822
1201
 
823
1202
  it('passes all flags when provided', async () => {
824
- await handlers.get('gog_gmail_messages_search')!({
1203
+ await harness.callTool('gog_gmail_messages_search', {
825
1204
  query: 'is:unread',
826
1205
  max: 10,
827
1206
  page: 'tok',
@@ -838,7 +1217,7 @@ describe('gog_gmail_messages_search', () => {
838
1217
  });
839
1218
 
840
1219
  it('omits flags when false/absent', async () => {
841
- 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 });
842
1221
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
843
1222
  ['gmail', 'messages', 'search', 'x'],
844
1223
  { account: undefined },
@@ -848,7 +1227,7 @@ describe('gog_gmail_messages_search', () => {
848
1227
 
849
1228
  describe('gog_gmail_labels_style', () => {
850
1229
  it('calls runOrDiagnose with just the label', async () => {
851
- await handlers.get('gog_gmail_labels_style')!({ labelIdOrName: 'Work' });
1230
+ await harness.callTool('gog_gmail_labels_style', { labelIdOrName: 'Work' });
852
1231
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
853
1232
  ['gmail', 'labels', 'style', 'Work'],
854
1233
  { account: undefined },
@@ -856,7 +1235,7 @@ describe('gog_gmail_labels_style', () => {
856
1235
  });
857
1236
 
858
1237
  it('passes all style flags when provided', async () => {
859
- await handlers.get('gog_gmail_labels_style')!({
1238
+ await harness.callTool('gog_gmail_labels_style', {
860
1239
  labelIdOrName: 'Work',
861
1240
  backgroundColor: '#000000',
862
1241
  textColor: '#ffffff',
@@ -872,7 +1251,7 @@ describe('gog_gmail_labels_style', () => {
872
1251
 
873
1252
  describe('gog_gmail_vacation_get', () => {
874
1253
  it('calls runOrDiagnose', async () => {
875
- await handlers.get('gog_gmail_vacation_get')!({ account: 'me@x.com' });
1254
+ await harness.callTool('gog_gmail_vacation_get', { account: 'me@x.com' });
876
1255
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
877
1256
  ['gmail', 'settings', 'vacation', 'get'],
878
1257
  { account: 'me@x.com' },
@@ -882,7 +1261,7 @@ describe('gog_gmail_vacation_get', () => {
882
1261
 
883
1262
  describe('gog_gmail_vacation_update', () => {
884
1263
  it('calls runOrDiagnose with no flags', async () => {
885
- await handlers.get('gog_gmail_vacation_update')!({});
1264
+ await harness.callTool('gog_gmail_vacation_update', {});
886
1265
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
887
1266
  ['gmail', 'settings', 'vacation', 'update'],
888
1267
  { account: undefined },
@@ -890,7 +1269,7 @@ describe('gog_gmail_vacation_update', () => {
890
1269
  });
891
1270
 
892
1271
  it('enables with subject/body/start/end and scoping', async () => {
893
- await handlers.get('gog_gmail_vacation_update')!({
1272
+ await harness.callTool('gog_gmail_vacation_update', {
894
1273
  enable: true,
895
1274
  subject: 'Away',
896
1275
  body: '<p>OOO</p>',
@@ -906,7 +1285,7 @@ describe('gog_gmail_vacation_update', () => {
906
1285
  });
907
1286
 
908
1287
  it('disables the responder', async () => {
909
- 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 });
910
1289
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
911
1290
  ['gmail', 'settings', 'vacation', 'update', '--disable'],
912
1291
  { account: undefined },
@@ -916,7 +1295,7 @@ describe('gog_gmail_vacation_update', () => {
916
1295
 
917
1296
  describe('gog_gmail_filters_list', () => {
918
1297
  it('calls runOrDiagnose', async () => {
919
- await handlers.get('gog_gmail_filters_list')!({});
1298
+ await harness.callTool('gog_gmail_filters_list', {});
920
1299
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
921
1300
  ['gmail', 'settings', 'filters', 'list'],
922
1301
  { account: undefined },
@@ -926,7 +1305,7 @@ describe('gog_gmail_filters_list', () => {
926
1305
 
927
1306
  describe('gog_gmail_filters_get', () => {
928
1307
  it('calls runOrDiagnose with the filter ID', async () => {
929
- await handlers.get('gog_gmail_filters_get')!({ filterId: 'f1' });
1308
+ await harness.callTool('gog_gmail_filters_get', { filterId: 'f1' });
930
1309
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
931
1310
  ['gmail', 'settings', 'filters', 'get', 'f1'],
932
1311
  { account: undefined },
@@ -936,7 +1315,7 @@ describe('gog_gmail_filters_get', () => {
936
1315
 
937
1316
  describe('gog_gmail_filters_create', () => {
938
1317
  it('calls runOrDiagnose with no flags', async () => {
939
- await handlers.get('gog_gmail_filters_create')!({});
1318
+ await harness.callTool('gog_gmail_filters_create', {});
940
1319
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
941
1320
  ['gmail', 'settings', 'filters', 'create'],
942
1321
  { account: undefined },
@@ -944,7 +1323,7 @@ describe('gog_gmail_filters_create', () => {
944
1323
  });
945
1324
 
946
1325
  it('passes all criteria and actions when provided', async () => {
947
- await handlers.get('gog_gmail_filters_create')!({
1326
+ await harness.callTool('gog_gmail_filters_create', {
948
1327
  from: 'alice@x.com',
949
1328
  to: 'me@x.com',
950
1329
  subject: 'Report',
@@ -961,13 +1340,13 @@ describe('gog_gmail_filters_create', () => {
961
1340
  forward: 'fwd@x.com',
962
1341
  });
963
1342
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
964
- ['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'],
965
1344
  { account: undefined },
966
1345
  );
967
1346
  });
968
1347
 
969
1348
  it('omits boolean flags when false', async () => {
970
- await handlers.get('gog_gmail_filters_create')!({
1349
+ await harness.callTool('gog_gmail_filters_create', {
971
1350
  from: 'a@x.com',
972
1351
  hasAttachment: false,
973
1352
  archive: false,
@@ -986,9 +1365,9 @@ describe('gog_gmail_filters_create', () => {
986
1365
 
987
1366
  describe('gog_gmail_filters_delete', () => {
988
1367
  it('calls runOrDiagnose with the filter ID', async () => {
989
- await handlers.get('gog_gmail_filters_delete')!({ filterId: 'f1' });
1368
+ await harness.callTool('gog_gmail_filters_delete', { filterId: 'f1' });
990
1369
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
991
- ['gmail', 'settings', 'filters', 'delete', 'f1'],
1370
+ ['gmail', 'settings', 'filters', 'delete', 'f1', '--force'],
992
1371
  { account: undefined },
993
1372
  );
994
1373
  });
@@ -996,7 +1375,7 @@ describe('gog_gmail_filters_delete', () => {
996
1375
 
997
1376
  describe('gog_gmail_sendas_list', () => {
998
1377
  it('calls runOrDiagnose', async () => {
999
- await handlers.get('gog_gmail_sendas_list')!({});
1378
+ await harness.callTool('gog_gmail_sendas_list', {});
1000
1379
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1001
1380
  ['gmail', 'settings', 'sendas', 'list'],
1002
1381
  { account: undefined },
@@ -1006,7 +1385,7 @@ describe('gog_gmail_sendas_list', () => {
1006
1385
 
1007
1386
  describe('gog_gmail_sendas_get', () => {
1008
1387
  it('calls runOrDiagnose with the email', async () => {
1009
- await handlers.get('gog_gmail_sendas_get')!({ email: 'alias@x.com' });
1388
+ await harness.callTool('gog_gmail_sendas_get', { email: 'alias@x.com' });
1010
1389
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1011
1390
  ['gmail', 'settings', 'sendas', 'get', 'alias@x.com'],
1012
1391
  { account: undefined },
@@ -1016,7 +1395,7 @@ describe('gog_gmail_sendas_get', () => {
1016
1395
 
1017
1396
  describe('gog_gmail_sendas_create', () => {
1018
1397
  it('calls runOrDiagnose with just the email', async () => {
1019
- await handlers.get('gog_gmail_sendas_create')!({ email: 'alias@x.com' });
1398
+ await harness.callTool('gog_gmail_sendas_create', { email: 'alias@x.com' });
1020
1399
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1021
1400
  ['gmail', 'settings', 'sendas', 'create', 'alias@x.com'],
1022
1401
  { account: undefined },
@@ -1024,7 +1403,7 @@ describe('gog_gmail_sendas_create', () => {
1024
1403
  });
1025
1404
 
1026
1405
  it('passes all flags when provided', async () => {
1027
- await handlers.get('gog_gmail_sendas_create')!({
1406
+ await harness.callTool('gog_gmail_sendas_create', {
1028
1407
  email: 'alias@x.com',
1029
1408
  displayName: 'Alias',
1030
1409
  replyTo: 'reply@x.com',
@@ -1038,7 +1417,7 @@ describe('gog_gmail_sendas_create', () => {
1038
1417
  });
1039
1418
 
1040
1419
  it('omits treatAsAlias when false', async () => {
1041
- 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 });
1042
1421
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1043
1422
  ['gmail', 'settings', 'sendas', 'create', 'alias@x.com'],
1044
1423
  { account: undefined },
@@ -1048,7 +1427,7 @@ describe('gog_gmail_sendas_create', () => {
1048
1427
 
1049
1428
  describe('gog_gmail_sendas_update', () => {
1050
1429
  it('calls runOrDiagnose with just the email', async () => {
1051
- await handlers.get('gog_gmail_sendas_update')!({ email: 'alias@x.com' });
1430
+ await harness.callTool('gog_gmail_sendas_update', { email: 'alias@x.com' });
1052
1431
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1053
1432
  ['gmail', 'settings', 'sendas', 'update', 'alias@x.com'],
1054
1433
  { account: undefined },
@@ -1056,7 +1435,7 @@ describe('gog_gmail_sendas_update', () => {
1056
1435
  });
1057
1436
 
1058
1437
  it('passes all flags when provided', async () => {
1059
- await handlers.get('gog_gmail_sendas_update')!({
1438
+ await harness.callTool('gog_gmail_sendas_update', {
1060
1439
  email: 'alias@x.com',
1061
1440
  displayName: 'Alias',
1062
1441
  replyTo: 'reply@x.com',
@@ -1071,7 +1450,7 @@ describe('gog_gmail_sendas_update', () => {
1071
1450
  });
1072
1451
 
1073
1452
  it('omits boolean flags when false', async () => {
1074
- 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 });
1075
1454
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1076
1455
  ['gmail', 'settings', 'sendas', 'update', 'alias@x.com'],
1077
1456
  { account: undefined },
@@ -1081,9 +1460,9 @@ describe('gog_gmail_sendas_update', () => {
1081
1460
 
1082
1461
  describe('gog_gmail_sendas_delete', () => {
1083
1462
  it('calls runOrDiagnose with the email', async () => {
1084
- await handlers.get('gog_gmail_sendas_delete')!({ email: 'alias@x.com' });
1463
+ await harness.callTool('gog_gmail_sendas_delete', { email: 'alias@x.com' });
1085
1464
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1086
- ['gmail', 'settings', 'sendas', 'delete', 'alias@x.com'],
1465
+ ['gmail', 'settings', 'sendas', 'delete', 'alias@x.com', '--force'],
1087
1466
  { account: undefined },
1088
1467
  );
1089
1468
  });
@@ -1091,10 +1470,214 @@ describe('gog_gmail_sendas_delete', () => {
1091
1470
 
1092
1471
  describe('gog_gmail_sendas_verify', () => {
1093
1472
  it('calls runOrDiagnose with the email', async () => {
1094
- await handlers.get('gog_gmail_sendas_verify')!({ email: 'alias@x.com' });
1473
+ await harness.callTool('gog_gmail_sendas_verify', { email: 'alias@x.com' });
1095
1474
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1096
1475
  ['gmail', 'settings', 'sendas', 'verify', 'alias@x.com'],
1097
1476
  { account: undefined },
1098
1477
  );
1099
1478
  });
1100
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
+ });