gogcli-mcp-gmail 2.21.1 → 2.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -8
- package/SKILL.md +55 -6
- package/dist/index.js +1183 -49
- package/manifest.json +13 -5
- package/package.json +1 -1
- package/src/tools/gmail-extra.ts +2296 -45
- package/tests/tools/attachment-index-resolve.test.ts +76 -0
- package/tests/tools/draft-fork-signature.test.ts +67 -0
- package/tests/tools/draft-fork.test.ts +995 -0
- package/tests/tools/draft-write-vs-refetch.test.ts +81 -0
- package/tests/tools/gmail-extra.test.ts +1645 -48
|
@@ -96,7 +96,7 @@ describe('gog_gmail_attachment', () => {
|
|
|
96
96
|
stubGog({ meta: PDF_LIST, download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 99723, contentBase64: PDF_B64 } });
|
|
97
97
|
const res = await call({});
|
|
98
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']);
|
|
99
|
+
expect(dlArgs()).toEqual(['gmail', 'attachment', 'm1', 'a1', '--use-indexed-attachment-ids=false', '--inline', '--inline-max-bytes=3145728', '--out=/tmp/gog-attachments/m1/attachment', '--name=attachment']);
|
|
100
100
|
expect(gotGet()).toBe(true);
|
|
101
101
|
const payload = JSON.parse(textOf(res));
|
|
102
102
|
expect(payload).toMatchObject({
|
|
@@ -135,14 +135,14 @@ describe('gog_gmail_attachment', () => {
|
|
|
135
135
|
stubGog({ download: { path: '/tmp/gog-attachments/m1/report.pdf', bytes: 12, contentBase64: PDF_B64 } });
|
|
136
136
|
await call({ name: 'report.pdf' });
|
|
137
137
|
expect(gotGet()).toBe(false);
|
|
138
|
-
expect(dlArgs()).toEqual(['gmail', 'attachment', 'm1', 'a1', '--inline', '--out=/tmp/gog-attachments/m1/report.pdf', '--name=report.pdf']);
|
|
138
|
+
expect(dlArgs()).toEqual(['gmail', 'attachment', 'm1', 'a1', '--use-indexed-attachment-ids=false', '--inline', '--inline-max-bytes=3145728', '--out=/tmp/gog-attachments/m1/report.pdf', '--name=report.pdf']);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
141
|
it('a named non-image on the connector skips --inline (headed straight to Drive)', async () => {
|
|
142
142
|
stubGog({ download: { path: '/tmp/gog-attachments/m1/report.pdf', bytes: 12 }, drive: { file: { id: 'F9' } } });
|
|
143
143
|
await asConnector(() => call({ name: 'report.pdf' }));
|
|
144
144
|
expect(gotGet()).toBe(false);
|
|
145
|
-
expect(dlArgs()).toEqual(['gmail', 'attachment', 'm1', 'a1', '--out=/tmp/gog-attachments/m1/report.pdf', '--name=report.pdf']);
|
|
145
|
+
expect(dlArgs()).toEqual(['gmail', 'attachment', 'm1', 'a1', '--use-indexed-attachment-ids=false', '--inline-max-bytes=3145728', '--out=/tmp/gog-attachments/m1/report.pdf', '--name=report.pdf']);
|
|
146
146
|
});
|
|
147
147
|
|
|
148
148
|
it('resolves the real filename by size and sanitizes path separators (no traversal)', async () => {
|
|
@@ -235,7 +235,7 @@ describe('gog_gmail_attachment', () => {
|
|
|
235
235
|
it('deliver=drive skips --inline and uploads, honoring driveFolder and name', async () => {
|
|
236
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
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']);
|
|
238
|
+
expect(dlArgs()).toEqual(['gmail', 'attachment', 'm1', 'a1', '--use-indexed-attachment-ids=false', '--inline-max-bytes=3145728', '--out=/tmp/gog-attachments/m1/renamed.png', '--name=renamed.png']);
|
|
239
239
|
expect(lib.run).toHaveBeenCalledWith(
|
|
240
240
|
['drive', 'upload', '/tmp/gog-attachments/m1/renamed.png', '--json', '--parent=DIR9', '--name=renamed.png'], { account: 'me@x.com' });
|
|
241
241
|
expect(JSON.parse(textOf(res))).toMatchObject({ deliveredVia: 'drive', id: 'F2' });
|
|
@@ -268,14 +268,14 @@ describe('gog_gmail_attachment', () => {
|
|
|
268
268
|
it('honors a caller out on stdio', async () => {
|
|
269
269
|
stubGog({ download: { path: '/home/me/x.png', bytes: 24, contentBase64: PNG_B64 } });
|
|
270
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']);
|
|
271
|
+
expect(dlArgs()).toEqual(['gmail', 'attachment', 'm1', 'a1', '--use-indexed-attachment-ids=false', '--inline', '--inline-max-bytes=3145728', '--out=/home/me/x.png', '--name=x.png']);
|
|
272
272
|
});
|
|
273
273
|
|
|
274
274
|
it('ignores a caller out on the connector and notes it', async () => {
|
|
275
275
|
stubGog({ download: { path: '/tmp/gog-attachments/m1/report.pdf', bytes: 12 }, drive: { file: { id: 'F3' } } });
|
|
276
276
|
const res = await asConnector(() => call({ out: '/home/claude/report.pdf', name: 'report.pdf' }));
|
|
277
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']);
|
|
278
|
+
expect(dlArgs()).toEqual(['gmail', 'attachment', 'm1', 'a1', '--use-indexed-attachment-ids=false', '--inline-max-bytes=3145728', '--out=/tmp/gog-attachments/m1/report.pdf', '--name=report.pdf']);
|
|
279
279
|
expect(textOf(res)).toContain('`out` was ignored');
|
|
280
280
|
});
|
|
281
281
|
|
|
@@ -473,7 +473,7 @@ describe('gog_gmail_thread_get', () => {
|
|
|
473
473
|
it('calls runOrDiagnose with threadId', async () => {
|
|
474
474
|
await harness.callTool('gog_gmail_thread_get', { threadId: 't1' });
|
|
475
475
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
476
|
-
['gmail', 'thread', 'get', 't1'],
|
|
476
|
+
['gmail', 'thread', 'get', 't1', '--use-indexed-attachment-ids=false'],
|
|
477
477
|
{ account: undefined },
|
|
478
478
|
);
|
|
479
479
|
});
|
|
@@ -487,7 +487,7 @@ describe('gog_gmail_thread_get', () => {
|
|
|
487
487
|
outDir: '/tmp/atts',
|
|
488
488
|
});
|
|
489
489
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
490
|
-
['gmail', 'thread', 'get', 't1', '--download', '--full', '--sanitize-content', '--out-dir=/tmp/atts'],
|
|
490
|
+
['gmail', 'thread', 'get', 't1', '--download', '--full', '--sanitize-content', '--out-dir=/tmp/atts', '--use-indexed-attachment-ids=false'],
|
|
491
491
|
{ account: undefined },
|
|
492
492
|
);
|
|
493
493
|
});
|
|
@@ -500,7 +500,7 @@ describe('gog_gmail_thread_get', () => {
|
|
|
500
500
|
sanitizeContent: false,
|
|
501
501
|
});
|
|
502
502
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
503
|
-
['gmail', 'thread', 'get', 't1'],
|
|
503
|
+
['gmail', 'thread', 'get', 't1', '--use-indexed-attachment-ids=false'],
|
|
504
504
|
{ account: undefined },
|
|
505
505
|
);
|
|
506
506
|
});
|
|
@@ -527,7 +527,7 @@ describe('gog_gmail_thread_get', () => {
|
|
|
527
527
|
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(THREAD));
|
|
528
528
|
const result = await harness.callTool('gog_gmail_thread_get', { threadId: 't1', latestN: 2 });
|
|
529
529
|
// latestN is wrapper-side; no CLI flag is added
|
|
530
|
-
expect(vi.mocked(lib.runOrDiagnose).mock.calls[0]![0]).toEqual(['gmail', 'thread', 'get', 't1']);
|
|
530
|
+
expect(vi.mocked(lib.runOrDiagnose).mock.calls[0]![0]).toEqual(['gmail', 'thread', 'get', 't1', '--use-indexed-attachment-ids=false']);
|
|
531
531
|
const parsed = JSON.parse(result.content[0].text);
|
|
532
532
|
expect(parsed.thread.messages.map((m: { id: string }) => m.id)).toEqual(['m2', 'm3']);
|
|
533
533
|
});
|
|
@@ -598,7 +598,7 @@ describe('gog_gmail_thread_attachments', () => {
|
|
|
598
598
|
it('calls runOrDiagnose with threadId', async () => {
|
|
599
599
|
await harness.callTool('gog_gmail_thread_attachments', { threadId: 't1' });
|
|
600
600
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
601
|
-
['gmail', 'thread', 'attachments', 't1'],
|
|
601
|
+
['gmail', 'thread', 'attachments', 't1', '--use-indexed-attachment-ids=false'],
|
|
602
602
|
{ account: undefined },
|
|
603
603
|
);
|
|
604
604
|
});
|
|
@@ -610,7 +610,7 @@ describe('gog_gmail_thread_attachments', () => {
|
|
|
610
610
|
outDir: '/tmp/atts',
|
|
611
611
|
});
|
|
612
612
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
613
|
-
['gmail', 'thread', 'attachments', 't1', '--download', '--out-dir=/tmp/atts'],
|
|
613
|
+
['gmail', 'thread', 'attachments', 't1', '--download', '--out-dir=/tmp/atts', '--use-indexed-attachment-ids=false'],
|
|
614
614
|
{ account: undefined },
|
|
615
615
|
);
|
|
616
616
|
});
|
|
@@ -714,11 +714,509 @@ describe('gog_gmail_drafts_list', () => {
|
|
|
714
714
|
});
|
|
715
715
|
});
|
|
716
716
|
|
|
717
|
+
// ===========================================================================
|
|
718
|
+
// REQUIREMENT 2 — the listing must say where each draft came from and whether
|
|
719
|
+
// sending it would start a NEW conversation, and TIER 0 MUST COST NOTHING.
|
|
720
|
+
//
|
|
721
|
+
// Hazard B (N+1) is enforced here by assertion, not by intention: the argv has
|
|
722
|
+
// to stay byte-identical to today's and `run` must never be touched. A 20-draft
|
|
723
|
+
// listing that quietly became 20 gog spawns on the one shared Fly machine is the
|
|
724
|
+
// regression these tests exist to make impossible.
|
|
725
|
+
// ===========================================================================
|
|
726
|
+
describe('gog_gmail_drafts_list — tier 0 origin and threading', () => {
|
|
727
|
+
const LIST = JSON.stringify({
|
|
728
|
+
drafts: [
|
|
729
|
+
// API-created reply: sits inside the co-parent's thread.
|
|
730
|
+
{ id: 'r4303011157206680397', messageId: '19f856becba0661d', threadId: '19f856b0000thread' },
|
|
731
|
+
// The Apple Mail replacement: non-API id, roots its own thread.
|
|
732
|
+
{ id: 's:14092347734530621658', messageId: '19fe8a673d1e5f21', threadId: '19fe8a673d1e5f21' },
|
|
733
|
+
// Draft ids can be NEGATIVE and still be plain API drafts.
|
|
734
|
+
{ id: 'r-457330811034304502', messageId: 'aaa', threadId: 'bbb' },
|
|
735
|
+
],
|
|
736
|
+
nextPageToken: 'next-tok',
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('adds origin and rootsOwnThread without spending a single extra gog call', async () => {
|
|
740
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(LIST));
|
|
741
|
+
const result = await harness.callTool('gog_gmail_drafts_list', {});
|
|
742
|
+
|
|
743
|
+
// Hazard B: exactly one invocation, and the argv is what it always was.
|
|
744
|
+
expect(vi.mocked(lib.runOrDiagnose).mock.calls).toHaveLength(1);
|
|
745
|
+
expect(vi.mocked(lib.runOrDiagnose).mock.calls[0]![0]).toEqual(['gmail', 'drafts', 'list']);
|
|
746
|
+
expect(lib.run).not.toHaveBeenCalled();
|
|
747
|
+
|
|
748
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
749
|
+
expect(parsed.drafts.map((d: { id: string; origin: string; rootsOwnThread: boolean }) => [d.id, d.origin, d.rootsOwnThread])).toEqual([
|
|
750
|
+
['r4303011157206680397', 'api', false],
|
|
751
|
+
['s:14092347734530621658', 'non-api', true],
|
|
752
|
+
['r-457330811034304502', 'api', false],
|
|
753
|
+
]);
|
|
754
|
+
// Additive only: gog's own fields survive untouched.
|
|
755
|
+
expect(parsed.drafts[0].messageId).toBe('19f856becba0661d');
|
|
756
|
+
expect(parsed.nextPageToken).toBe('next-tok');
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('never labels a draft apple-mail from the listing alone', async () => {
|
|
760
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(LIST));
|
|
761
|
+
const result = await harness.callTool('gog_gmail_drafts_list', {});
|
|
762
|
+
// Hazard A: an `s:` prefix means IMAP/sync — Thunderbird and Outlook produce
|
|
763
|
+
// it too. Claiming "apple-mail" here would be a free false positive.
|
|
764
|
+
expect(JSON.parse(result.content[0].text).drafts[1].origin).toBe('non-api');
|
|
765
|
+
expect(result.content[0].text).not.toContain('apple-mail');
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it('explains rootsOwnThread as the consequence the caller cares about', async () => {
|
|
769
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(LIST));
|
|
770
|
+
const parsed = JSON.parse((await harness.callTool('gog_gmail_drafts_list', {})).content[0].text);
|
|
771
|
+
// Emitted ONCE for the whole result and selected by the per-row boolean —
|
|
772
|
+
// it is one of exactly two constants, so a copy per row was ~300 chars of
|
|
773
|
+
// duplicated text carrying no extra information.
|
|
774
|
+
expect(parsed.threadingNotes.rootsOwnThread).toContain('NEW conversation');
|
|
775
|
+
expect(parsed.threadingNotes.inThread).toContain('existing thread');
|
|
776
|
+
expect(parsed.drafts[1].rootsOwnThread).toBe(true);
|
|
777
|
+
expect(parsed.drafts[0].rootsOwnThread).toBe(false);
|
|
778
|
+
// The `non-api` != Apple caveat and the measured coin-flip figure ride along once.
|
|
779
|
+
expect(parsed.originNote).toContain('non-api');
|
|
780
|
+
expect(parsed.originNote).toContain('0.50');
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it('passes non-JSON output through untouched', async () => {
|
|
784
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult('No drafts'));
|
|
785
|
+
const result = await harness.callTool('gog_gmail_drafts_list', {});
|
|
786
|
+
expect(result.content[0].text).toBe('No drafts');
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it('passes JSON without a drafts array through untouched', async () => {
|
|
790
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult('{"error":"nope"}'));
|
|
791
|
+
const result = await harness.callTool('gog_gmail_drafts_list', {});
|
|
792
|
+
expect(result.content[0].text).toBe('{"error":"nope"}');
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it('passes a non-text result through untouched', async () => {
|
|
796
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce({ content: [{ type: 'image', data: 'AAAA', mimeType: 'image/png' }] });
|
|
797
|
+
const result = await harness.callTool('gog_gmail_drafts_list', {});
|
|
798
|
+
expect(result.content[0].type).toBe('image');
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
describe('gog_gmail_drafts_list — tier 1 enrich', () => {
|
|
803
|
+
const LIST = JSON.stringify({
|
|
804
|
+
drafts: [
|
|
805
|
+
{ id: 'r1', messageId: 'm1', threadId: 't1' },
|
|
806
|
+
{ id: 's:2', messageId: 'm2', threadId: 'm2' },
|
|
807
|
+
],
|
|
808
|
+
nextPageToken: '',
|
|
809
|
+
});
|
|
810
|
+
const SEARCH = JSON.stringify({
|
|
811
|
+
messages: [
|
|
812
|
+
{ id: 'm1', threadId: 't1', from: 'Chris <chris@x.com>', subject: 'Re: August schedule', internalDateIso: '2026-08-09T10:00:00-04:00' },
|
|
813
|
+
],
|
|
814
|
+
nextPageToken: '',
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it('is off by default — no enrichment fields and no second call', async () => {
|
|
818
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(LIST));
|
|
819
|
+
const parsed = JSON.parse((await harness.callTool('gog_gmail_drafts_list', {})).content[0].text);
|
|
820
|
+
expect(lib.run).not.toHaveBeenCalled();
|
|
821
|
+
expect(parsed.enrichment).toBeUndefined();
|
|
822
|
+
expect(parsed.drafts[0].subject).toBeUndefined();
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it('spends exactly one extra gog call and joins on messageId', async () => {
|
|
826
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(LIST));
|
|
827
|
+
vi.mocked(lib.run).mockResolvedValueOnce(SEARCH);
|
|
828
|
+
const parsed = JSON.parse((await harness.callTool('gog_gmail_drafts_list', { enrich: true })).content[0].text);
|
|
829
|
+
|
|
830
|
+
expect(vi.mocked(lib.run).mock.calls).toHaveLength(1);
|
|
831
|
+
expect(vi.mocked(lib.run).mock.calls[0]![0]).toEqual([
|
|
832
|
+
'gmail', 'messages', 'search', 'in:drafts', '--max=20',
|
|
833
|
+
'--include-attachments=false', '--use-indexed-attachment-ids=false',
|
|
834
|
+
]);
|
|
835
|
+
expect(parsed.drafts[0]).toMatchObject({
|
|
836
|
+
id: 'r1', origin: 'api', subject: 'Re: August schedule', from: 'Chris <chris@x.com>', internalDateIso: '2026-08-09T10:00:00-04:00',
|
|
837
|
+
});
|
|
838
|
+
// The unjoined draft keeps its tier-0 fields and gains nothing else.
|
|
839
|
+
expect(parsed.drafts[1].subject).toBeUndefined();
|
|
840
|
+
expect(parsed.drafts[1].origin).toBe('non-api');
|
|
841
|
+
expect(parsed.enrichment).toMatchObject({ requested: true, applied: true, extraGogCalls: 1, matched: 1, unmatched: 1 });
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it('mirrors max and --all onto the enrichment search', async () => {
|
|
845
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(LIST));
|
|
846
|
+
vi.mocked(lib.run).mockResolvedValueOnce(SEARCH);
|
|
847
|
+
await harness.callTool('gog_gmail_drafts_list', { enrich: true, max: 50, all: true, account: 'a@b.com' });
|
|
848
|
+
expect(vi.mocked(lib.run).mock.calls[0]![0]).toEqual([
|
|
849
|
+
'gmail', 'messages', 'search', 'in:drafts', '--max=50', '--all',
|
|
850
|
+
'--include-attachments=false', '--use-indexed-attachment-ids=false',
|
|
851
|
+
]);
|
|
852
|
+
expect(vi.mocked(lib.run).mock.calls[0]![1]).toEqual({ account: 'a@b.com' });
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it('degrades to tier 0 instead of erroring when the search fails', async () => {
|
|
856
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(LIST));
|
|
857
|
+
vi.mocked(lib.run).mockRejectedValueOnce(new Error('gog exploded'));
|
|
858
|
+
const result = await harness.callTool('gog_gmail_drafts_list', { enrich: true });
|
|
859
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
860
|
+
expect(result.isError).toBeFalsy();
|
|
861
|
+
expect(parsed.drafts[0].origin).toBe('api'); // tier 0 survives
|
|
862
|
+
expect(parsed.enrichment).toMatchObject({ requested: true, applied: false, extraGogCalls: 1 });
|
|
863
|
+
expect(parsed.enrichment.reason).toContain('gog exploded');
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it('degrades to tier 0 when the search output is not JSON at all', async () => {
|
|
867
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(LIST));
|
|
868
|
+
vi.mocked(lib.run).mockResolvedValueOnce('not json at all');
|
|
869
|
+
const parsed = JSON.parse((await harness.callTool('gog_gmail_drafts_list', { enrich: true })).content[0].text);
|
|
870
|
+
expect(parsed.enrichment.applied).toBe(false);
|
|
871
|
+
expect(parsed.drafts[0].rootsOwnThread).toBe(false);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it('degrades to tier 0 when the search output is JSON without a messages array', async () => {
|
|
875
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(LIST));
|
|
876
|
+
vi.mocked(lib.run).mockResolvedValueOnce('{"error":"quota"}');
|
|
877
|
+
const parsed = JSON.parse((await harness.callTool('gog_gmail_drafts_list', { enrich: true })).content[0].text);
|
|
878
|
+
expect(parsed.enrichment.applied).toBe(false);
|
|
879
|
+
expect(parsed.enrichment.reason).toContain('no messages array');
|
|
880
|
+
expect(parsed.drafts[1].origin).toBe('non-api');
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it('skips messages with no id when joining', async () => {
|
|
884
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(LIST));
|
|
885
|
+
vi.mocked(lib.run).mockResolvedValueOnce(JSON.stringify({ messages: [{ subject: 'orphan' }] }));
|
|
886
|
+
const parsed = JSON.parse((await harness.callTool('gog_gmail_drafts_list', { enrich: true })).content[0].text);
|
|
887
|
+
expect(parsed.enrichment).toMatchObject({ applied: true, matched: 0, unmatched: 2 });
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it('does not spend the enrichment call when the listing is unparseable', async () => {
|
|
891
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult('No drafts'));
|
|
892
|
+
const result = await harness.callTool('gog_gmail_drafts_list', { enrich: true });
|
|
893
|
+
expect(lib.run).not.toHaveBeenCalled();
|
|
894
|
+
expect(result.content[0].text).toBe('No drafts');
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
it('tolerates a draft entry with no id', async () => {
|
|
898
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(JSON.stringify({ drafts: [{ messageId: 'm9', threadId: 'm9' }] })));
|
|
899
|
+
const parsed = JSON.parse((await harness.callTool('gog_gmail_drafts_list', {})).content[0].text);
|
|
900
|
+
expect(parsed.drafts[0].origin).toBe('api');
|
|
901
|
+
expect(parsed.drafts[0].rootsOwnThread).toBe(true);
|
|
902
|
+
});
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
// ===========================================================================
|
|
906
|
+
// REQUIREMENT 3 — gog_gmail_drafts_diff.
|
|
907
|
+
//
|
|
908
|
+
// Two named drafts, two gog spawns, never a scan. It answers the question the
|
|
909
|
+
// owner actually has in front of a fork: WHAT diverged, WHAT threading was
|
|
910
|
+
// lost, and — separately and conservatively — whether one plausibly replaced
|
|
911
|
+
// the other.
|
|
912
|
+
// ===========================================================================
|
|
913
|
+
describe('gog_gmail_drafts_diff', () => {
|
|
914
|
+
const b64 = (s: string) => Buffer.from(s, 'utf8').toString('base64url');
|
|
915
|
+
|
|
916
|
+
function draftGetJson(o: {
|
|
917
|
+
draftId: string; messageId: string; threadId: string; internalDate: string;
|
|
918
|
+
headers: Array<{ name: string; value: string }>; body: string;
|
|
919
|
+
}): string {
|
|
920
|
+
return JSON.stringify({
|
|
921
|
+
draft: {
|
|
922
|
+
id: o.draftId,
|
|
923
|
+
message: {
|
|
924
|
+
id: o.messageId,
|
|
925
|
+
threadId: o.threadId,
|
|
926
|
+
internalDate: o.internalDate,
|
|
927
|
+
payload: { mimeType: 'text/plain', headers: o.headers, body: { data: b64(o.body) } },
|
|
928
|
+
},
|
|
929
|
+
},
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// The observed case: an API-created reply threaded onto the co-parent's
|
|
934
|
+
// message, and the Apple Mail replacement that dropped a paragraph, added a
|
|
935
|
+
// sentence, and landed on its own thread.
|
|
936
|
+
const ORIGINAL = draftGetJson({
|
|
937
|
+
draftId: 'r4303011157206680397',
|
|
938
|
+
messageId: '19f856becba0661d',
|
|
939
|
+
threadId: '19f856b0000thread',
|
|
940
|
+
internalDate: '1000',
|
|
941
|
+
headers: [
|
|
942
|
+
{ name: 'From', value: 'Chris Hall <chris@x.com>' },
|
|
943
|
+
{ name: 'To', value: 'coparent@y.com' },
|
|
944
|
+
{ name: 'Cc', value: 'coordinator@pc.com' },
|
|
945
|
+
{ name: 'Subject', value: 'Re: August schedule' },
|
|
946
|
+
{ name: 'Message-Id', value: '<orig@mail.gmail.com>' },
|
|
947
|
+
{ name: 'In-Reply-To', value: '<coparent@mail.gmail.com>' },
|
|
948
|
+
{ name: 'References', value: '<coparent@mail.gmail.com>' },
|
|
949
|
+
{ name: 'MIME-Version', value: '1.0' },
|
|
950
|
+
],
|
|
951
|
+
body: 'Thanks for the note.\nI can do the 14th.\nPickup at six.\nTHE PARAGRAPH ONLY GMAIL HAS.\nBest, Chris',
|
|
952
|
+
});
|
|
953
|
+
const APPLE_FORK = draftGetJson({
|
|
954
|
+
draftId: 's:14092347734530621658',
|
|
955
|
+
messageId: '19fe8a673d1e5f21',
|
|
956
|
+
threadId: '19fe8a673d1e5f21',
|
|
957
|
+
internalDate: '2000',
|
|
958
|
+
headers: [
|
|
959
|
+
{ name: 'From', value: 'chris@x.com' },
|
|
960
|
+
{ name: 'To', value: 'coparent@y.com' },
|
|
961
|
+
{ name: 'Subject', value: 'Re: August schedule' },
|
|
962
|
+
{ name: 'Message-Id', value: '<8F3C1B0A-1111-2222-3333-AABBCCDDEEFF@gmail.com>' },
|
|
963
|
+
{ name: 'Mime-Version', value: '1.0 (1.0)' },
|
|
964
|
+
{ name: 'X-Universally-Unique-Identifier', value: '8F3C1B0A-1111-2222-3333-AABBCCDDEEFF' },
|
|
965
|
+
{ name: 'X-Apple-Notify-Thread', value: 'yes' },
|
|
966
|
+
],
|
|
967
|
+
body: 'Thanks for the note.\nI can do the 14th.\nPickup at six.\nBest, Chris\nTHE SENTENCE ONLY APPLE HAS.',
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
function stub(map: Record<string, string>): void {
|
|
971
|
+
vi.mocked(lib.run).mockImplementation(async (args) => {
|
|
972
|
+
const id = (args as string[])[3]!;
|
|
973
|
+
const payload = map[id];
|
|
974
|
+
if (payload === undefined) throw new Error(`Google API error (404 notFound): ${id}`);
|
|
975
|
+
return payload;
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const call = (extra: Record<string, unknown> = {}) => harness.callTool('gog_gmail_drafts_diff', {
|
|
980
|
+
draftIdA: 'r4303011157206680397', draftIdB: 's:14092347734530621658', ...extra,
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
it('costs exactly two gog calls, one per named draft', async () => {
|
|
984
|
+
stub({ 'r4303011157206680397': ORIGINAL, 's:14092347734530621658': APPLE_FORK });
|
|
985
|
+
await call();
|
|
986
|
+
expect(vi.mocked(lib.run).mock.calls).toHaveLength(2);
|
|
987
|
+
expect(vi.mocked(lib.run).mock.calls[0]![0]).toEqual(['gmail', 'drafts', 'get', 'r4303011157206680397', '--use-indexed-attachment-ids=false']);
|
|
988
|
+
expect(vi.mocked(lib.run).mock.calls[1]![0]).toEqual(['gmail', 'drafts', 'get', 's:14092347734530621658', '--use-indexed-attachment-ids=false']);
|
|
989
|
+
expect(lib.runOrDiagnose).not.toHaveBeenCalled();
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it('shows what each copy alone would lose', async () => {
|
|
993
|
+
stub({ 'r4303011157206680397': ORIGINAL, 's:14092347734530621658': APPLE_FORK });
|
|
994
|
+
const parsed = JSON.parse((await call()).content[0].text);
|
|
995
|
+
expect(parsed.bodyDiff.onlyInA).toEqual(['THE PARAGRAPH ONLY GMAIL HAS.']);
|
|
996
|
+
expect(parsed.bodyDiff.onlyInB).toEqual(['THE SENTENCE ONLY APPLE HAS.']);
|
|
997
|
+
expect(parsed.bodyDiff.sharedLineCount).toBe(4);
|
|
998
|
+
expect(parsed.bodyDiff.neitherIsSuperset).toBe(true);
|
|
999
|
+
expect(parsed.bodyDiff.note).toContain('NEITHER');
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it('names the threading that would be lost by sending the fork', async () => {
|
|
1003
|
+
stub({ 'r4303011157206680397': ORIGINAL, 's:14092347734530621658': APPLE_FORK });
|
|
1004
|
+
const parsed = JSON.parse((await call()).content[0].text);
|
|
1005
|
+
expect(parsed.drafts.a).toMatchObject({ origin: 'api', rootsOwnThread: false, inReplyTo: '<coparent@mail.gmail.com>' });
|
|
1006
|
+
expect(parsed.drafts.b).toMatchObject({ origin: 'non-api', rootsOwnThread: true });
|
|
1007
|
+
expect(parsed.drafts.b.inReplyTo).toBeUndefined();
|
|
1008
|
+
expect(parsed.drafts.b.appleIdentitySignals).toEqual([
|
|
1009
|
+
'X-Universally-Unique-Identifier: 8F3C1B0A-1111-2222-3333-AABBCCDDEEFF',
|
|
1010
|
+
'X-Apple-Notify-Thread: yes',
|
|
1011
|
+
]);
|
|
1012
|
+
expect(parsed.threadingDifferences.join(' ')).toContain('different threadIds');
|
|
1013
|
+
expect(parsed.threadingDifferences.join(' ')).toContain('reply headers');
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it('confirms the pairing only with an Apple identity header plus real lineage', async () => {
|
|
1017
|
+
stub({ 'r4303011157206680397': ORIGINAL, 's:14092347734530621658': APPLE_FORK });
|
|
1018
|
+
const parsed = JSON.parse((await call()).content[0].text);
|
|
1019
|
+
expect(parsed.forkPairing.verdict).toBe('confirmed');
|
|
1020
|
+
expect(parsed.forkPairing.tier).toBe(2);
|
|
1021
|
+
expect(parsed.forkPairing.originalDraftId).toBe('r4303011157206680397');
|
|
1022
|
+
expect(parsed.forkPairing.candidateDraftId).toBe('s:14092347734530621658');
|
|
1023
|
+
expect(parsed.forkPairing.evidence.join(' ')).toContain('body line similarity');
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
// ---- HAZARD A: the test that matters most. ----
|
|
1027
|
+
it('returns "none" for two unrelated drafts that share every cheap signal', async () => {
|
|
1028
|
+
// Both non-API, both rooting their own thread, both Apple-authored, same
|
|
1029
|
+
// From, same subject, minutes apart — the live mailbox really does hold
|
|
1030
|
+
// deliberate [VERSION A]/[VERSION B] pairs like this. Only LINEAGE is
|
|
1031
|
+
// missing, and without it the answer must be "unrelated".
|
|
1032
|
+
const common = {
|
|
1033
|
+
threadId: 'self', internalDate: '1000',
|
|
1034
|
+
headers: [
|
|
1035
|
+
{ name: 'From', value: 'chris@x.com' },
|
|
1036
|
+
{ name: 'Subject', value: 'Re: August schedule' },
|
|
1037
|
+
{ name: 'X-Universally-Unique-Identifier', value: 'AAAA-1111' },
|
|
1038
|
+
],
|
|
1039
|
+
};
|
|
1040
|
+
const A = draftGetJson({ ...common, draftId: 's:aaa', messageId: 'self', body: '[VERSION A] I would prefer the 14th and a six oclock pickup.' });
|
|
1041
|
+
const B = draftGetJson({
|
|
1042
|
+
...common, draftId: 's:bbb', messageId: 'self2', internalDate: '1180',
|
|
1043
|
+
headers: [...common.headers, { name: 'X-Apple-Notify-Thread', value: 'yes' }],
|
|
1044
|
+
body: '[VERSION B] Let us keep the current arrangement through September.',
|
|
1045
|
+
});
|
|
1046
|
+
stub({ 's:aaa': A, 's:bbb': B });
|
|
1047
|
+
const result = await harness.callTool('gog_gmail_drafts_diff', { draftIdA: 's:aaa', draftIdB: 's:bbb' });
|
|
1048
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
1049
|
+
expect(parsed.forkPairing.verdict).toBe('none');
|
|
1050
|
+
expect(parsed.forkPairing.missing.join(' ')).toContain('no lineage signal');
|
|
1051
|
+
// Nothing in the payload may read as an assertion that one replaced the other.
|
|
1052
|
+
expect(result.content[0].text).not.toContain('replaced draft');
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
// The default shape of a co-parenting mailbox: two drafts replying into the
|
|
1056
|
+
// SAME co-parent message, about different things. Shared root, Apple
|
|
1057
|
+
// headers, newer, same From — everything but a link from one to the other.
|
|
1058
|
+
it('returns "none" for two independent replies to the same co-parent message', async () => {
|
|
1059
|
+
const handoff = draftGetJson({
|
|
1060
|
+
draftId: 'r4303011157206680397', messageId: 'm1', threadId: 't1', internalDate: '1000',
|
|
1061
|
+
headers: [
|
|
1062
|
+
{ name: 'From', value: 'Chris Hall <chris@x.com>' },
|
|
1063
|
+
{ name: 'Message-Id', value: '<orig@mail.gmail.com>' },
|
|
1064
|
+
{ name: 'In-Reply-To', value: '<coparent-2026-05-01@mail.gmail.com>' },
|
|
1065
|
+
{ name: 'References', value: '<coparent-2026-05-01@mail.gmail.com>' },
|
|
1066
|
+
],
|
|
1067
|
+
body: 'Confirming the July handoff at six on the 14th.\nI will bring the booster seat.',
|
|
1068
|
+
});
|
|
1069
|
+
const orthodontist = draftGetJson({
|
|
1070
|
+
draftId: 's:14092347734530621658', messageId: 'm2', threadId: 't1', internalDate: '2000',
|
|
1071
|
+
headers: [
|
|
1072
|
+
{ name: 'From', value: 'chris@x.com' },
|
|
1073
|
+
{ name: 'Message-Id', value: '<9F3A@gmail.com>' },
|
|
1074
|
+
{ name: 'In-Reply-To', value: '<coparent-2026-05-01@mail.gmail.com>' },
|
|
1075
|
+
{ name: 'References', value: '<coparent-2026-05-01@mail.gmail.com>' },
|
|
1076
|
+
{ name: 'X-Universally-Unique-Identifier', value: '9F3A' },
|
|
1077
|
+
],
|
|
1078
|
+
body: 'The orthodontist invoice came to 240 dollars.\nI am splitting it per the parenting plan.',
|
|
1079
|
+
});
|
|
1080
|
+
stub({ 'r4303011157206680397': handoff, 's:14092347734530621658': orthodontist });
|
|
1081
|
+
const result = await call();
|
|
1082
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
1083
|
+
expect(parsed.forkPairing.verdict).not.toBe('confirmed');
|
|
1084
|
+
expect(result.content[0].text).not.toContain('replaced draft');
|
|
1085
|
+
expect(parsed.forkPairing.evidence.join(' ')).toContain('CORROBORATING ONLY');
|
|
1086
|
+
expect(parsed.forkPairing.missing.join(' ')).toContain('no lineage signal');
|
|
1087
|
+
expect(parsed.forkPairing.bodyAgreement.similarity).toBe(0);
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
// Apple quotes the original on reply, so two unrelated replies into one
|
|
1091
|
+
// thread share a big identical block. It must not read as agreement.
|
|
1092
|
+
it('ignores the quoted block shared by two unrelated replies', async () => {
|
|
1093
|
+
const quote = Array.from({ length: 20 }, (_, i) => `> quoted line ${i}`).join('\n');
|
|
1094
|
+
const attribution = 'On 1 May 2026, at 09:14, Co Parent <co@x.com> wrote:';
|
|
1095
|
+
const mk = (id: string, msgId: string, date: string, body: string, extra: Array<{ name: string; value: string }>) => draftGetJson({
|
|
1096
|
+
draftId: id, messageId: msgId, threadId: msgId, internalDate: date,
|
|
1097
|
+
headers: [{ name: 'From', value: 'chris@x.com' }, ...extra],
|
|
1098
|
+
body: `${body}\n${attribution}\n${quote}`,
|
|
1099
|
+
});
|
|
1100
|
+
stub({
|
|
1101
|
+
r1: mk('r1', 'm1', '1000', 'Tuition is due on the 5th.\nI paid the deposit already.', []),
|
|
1102
|
+
's:2': mk('s:2', 'm2', '2000', 'The passport renewal needs both signatures.\nI booked the 3rd.', [{ name: 'X-Apple-Notify-Thread', value: 'yes' }]),
|
|
1103
|
+
});
|
|
1104
|
+
const parsed = JSON.parse((await harness.callTool('gog_gmail_drafts_diff', { draftIdA: 'r1', draftIdB: 's:2' })).content[0].text);
|
|
1105
|
+
expect(parsed.forkPairing.verdict).toBe('none');
|
|
1106
|
+
expect(parsed.forkPairing.bodyAgreement.similarity).toBe(0);
|
|
1107
|
+
expect(parsed.forkPairing.bodyAgreement.quotedLinesIgnored).toEqual({ original: 21, candidate: 21 });
|
|
1108
|
+
// The whole-body diff still shows the quoted block as shared — that is a
|
|
1109
|
+
// different question (what would be lost), and it stays honest.
|
|
1110
|
+
expect(parsed.bodyDiff.sharedLineCount).toBe(21);
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
it('downgrades to "candidate" when lineage exists but the candidate is not newer', async () => {
|
|
1114
|
+
const older = draftGetJson({
|
|
1115
|
+
draftId: 's:old', messageId: 'x1', threadId: 'x1', internalDate: '500',
|
|
1116
|
+
headers: [{ name: 'From', value: 'chris@x.com' }, { name: 'X-Apple-Notify-Thread', value: 'yes' }],
|
|
1117
|
+
body: 'Thanks for the note.\nI can do the 14th.\nPickup at six.\nBest, Chris',
|
|
1118
|
+
});
|
|
1119
|
+
stub({ 'r4303011157206680397': ORIGINAL, 's:old': older });
|
|
1120
|
+
const parsed = JSON.parse((await harness.callTool('gog_gmail_drafts_diff', { draftIdA: 'r4303011157206680397', draftIdB: 's:old' })).content[0].text);
|
|
1121
|
+
// Draft B is older, so it is treated as the ORIGINAL and A as the candidate.
|
|
1122
|
+
expect(parsed.forkPairing.originalDraftId).toBe('s:old');
|
|
1123
|
+
expect(parsed.forkPairing.candidateDraftId).toBe('r4303011157206680397');
|
|
1124
|
+
expect(parsed.forkPairing.verdict).toBe('candidate');
|
|
1125
|
+
expect(parsed.forkPairing.note).toContain('Unconfirmed');
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
it('reports identical bodies and no threading difference', async () => {
|
|
1129
|
+
const same = (id: string) => draftGetJson({
|
|
1130
|
+
draftId: id, messageId: 'same', threadId: 'thr', internalDate: '1000',
|
|
1131
|
+
headers: [{ name: 'From', value: 'chris@x.com' }, { name: 'In-Reply-To', value: '<z@y>' }],
|
|
1132
|
+
body: 'one\ntwo',
|
|
1133
|
+
});
|
|
1134
|
+
stub({ 'r1': same('r1'), 'r2': same('r2') });
|
|
1135
|
+
const parsed = JSON.parse((await harness.callTool('gog_gmail_drafts_diff', { draftIdA: 'r1', draftIdB: 'r2' })).content[0].text);
|
|
1136
|
+
expect(parsed.bodyDiff.note).toContain('identical');
|
|
1137
|
+
expect(parsed.threadingDifferences).toEqual(['No threading difference: the two drafts share a threadId and agree on whether they carry reply headers.']);
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
it('names a one-sided superset in each direction', async () => {
|
|
1141
|
+
const short = draftGetJson({ draftId: 'r1', messageId: 'm', threadId: 'm', internalDate: '1', headers: [], body: 'one\ntwo' });
|
|
1142
|
+
const long = draftGetJson({ draftId: 'r2', messageId: 'm', threadId: 'm', internalDate: '2', headers: [], body: 'one\ntwo\nthree' });
|
|
1143
|
+
stub({ 'r1': short, 'r2': long });
|
|
1144
|
+
expect(JSON.parse((await harness.callTool('gog_gmail_drafts_diff', { draftIdA: 'r1', draftIdB: 'r2' })).content[0].text).bodyDiff.note)
|
|
1145
|
+
.toContain('Draft B is a superset');
|
|
1146
|
+
stub({ 'r3': long, 'r4': short });
|
|
1147
|
+
expect(JSON.parse((await harness.callTool('gog_gmail_drafts_diff', { draftIdA: 'r3', draftIdB: 'r4' })).content[0].text).bodyDiff.note)
|
|
1148
|
+
.toContain('Draft A is a superset');
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
it('makes no containment claim when one draft’s body could not be read', async () => {
|
|
1152
|
+
const empty = JSON.stringify({
|
|
1153
|
+
draft: { id: 'r1', message: { id: 'm1', threadId: 'm1', internalDate: '1', payload: { mimeType: 'application/octet-stream', headers: [], body: {} } } },
|
|
1154
|
+
});
|
|
1155
|
+
const full = draftGetJson({ draftId: 'r2', messageId: 'm2', threadId: 'm2', internalDate: '2', headers: [], body: 'one\ntwo' });
|
|
1156
|
+
stub({ r1: empty, r2: full });
|
|
1157
|
+
const parsed = JSON.parse((await harness.callTool('gog_gmail_drafts_diff', { draftIdA: 'r1', draftIdB: 'r2' })).content[0].text);
|
|
1158
|
+
expect(parsed.bodyDiff.comparability).toBe('a-unreadable');
|
|
1159
|
+
expect(parsed.bodyDiff.supersetClaim).toBe('not-assessed');
|
|
1160
|
+
expect(parsed.bodyDiff.neitherIsSuperset).toBeNull();
|
|
1161
|
+
expect(parsed.bodyDiff.note).not.toMatch(/superset/i);
|
|
1162
|
+
expect(parsed.drafts.a.bodyLineCount).toBe(0);
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
it('caps the reported diff lines and says so', async () => {
|
|
1166
|
+
const many = (n: number, tag: string) => Array.from({ length: n }, (_, i) => `${tag}-${i}`).join('\n');
|
|
1167
|
+
stub({
|
|
1168
|
+
'r1': draftGetJson({ draftId: 'r1', messageId: 'm', threadId: 'm', internalDate: '1', headers: [], body: many(5, 'a') }),
|
|
1169
|
+
'r2': draftGetJson({ draftId: 'r2', messageId: 'm', threadId: 'm', internalDate: '2', headers: [], body: many(5, 'b') }),
|
|
1170
|
+
});
|
|
1171
|
+
const parsed = JSON.parse((await harness.callTool('gog_gmail_drafts_diff', { draftIdA: 'r1', draftIdB: 'r2', maxDiffLines: 2 })).content[0].text);
|
|
1172
|
+
expect(parsed.bodyDiff.onlyInA).toHaveLength(2);
|
|
1173
|
+
expect(parsed.bodyDiff.onlyInB).toHaveLength(2);
|
|
1174
|
+
expect(parsed.bodyDiff.truncated).toBe(true);
|
|
1175
|
+
expect(parsed.bodyDiff.note).toContain('truncated');
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
it('reports threading differences when one side has no threadId, in both directions', async () => {
|
|
1179
|
+
const noThread = JSON.stringify({
|
|
1180
|
+
draft: { id: 'x', message: { id: 'm1', internalDate: '1', payload: { mimeType: 'text/plain', headers: [], body: { data: b64('x') } } } },
|
|
1181
|
+
});
|
|
1182
|
+
const threaded = draftGetJson({
|
|
1183
|
+
draftId: 'r2', messageId: 'm2', threadId: 'thr', internalDate: '2',
|
|
1184
|
+
headers: [{ name: 'References', value: '<z@y>' }], body: 'y',
|
|
1185
|
+
});
|
|
1186
|
+
// A has no threadId and no reply headers; B has both.
|
|
1187
|
+
stub({ r1: noThread, r2: threaded });
|
|
1188
|
+
const first = JSON.parse((await harness.callTool('gog_gmail_drafts_diff', { draftIdA: 'r1', draftIdB: 'r2' })).content[0].text);
|
|
1189
|
+
expect(first.threadingDifferences[0]).toContain('((none) vs thr)');
|
|
1190
|
+
expect(first.threadingDifferences[1]).toContain('Draft r2 carries reply headers');
|
|
1191
|
+
expect(first.threadingDifferences[1]).toContain('draft r1 does not');
|
|
1192
|
+
// Mirror image: now it is B that has no threadId.
|
|
1193
|
+
stub({ r3: threaded, r4: noThread });
|
|
1194
|
+
const second = JSON.parse((await harness.callTool('gog_gmail_drafts_diff', { draftIdA: 'r3', draftIdB: 'r4' })).content[0].text);
|
|
1195
|
+
expect(second.threadingDifferences[0]).toContain('(thr vs (none))');
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
it('diagnoses a draft id that no longer resolves, spending only the calls it made', async () => {
|
|
1199
|
+
stub({ 'r4303011157206680397': ORIGINAL });
|
|
1200
|
+
const result = await call();
|
|
1201
|
+
expect(vi.mocked(lib.run).mock.calls).toHaveLength(2);
|
|
1202
|
+
expect(lib.diagnose).toHaveBeenCalled();
|
|
1203
|
+
expect(String(vi.mocked(lib.diagnose).mock.calls[0]![0])).toContain('s:14092347734530621658');
|
|
1204
|
+
expect(result.content[0].text).toBe('diagnosed');
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
it('errors clearly when either draft payload is unreadable', async () => {
|
|
1208
|
+
stub({ 'r4303011157206680397': 'not json', 's:14092347734530621658': APPLE_FORK });
|
|
1209
|
+
expect((await call()).content[0].text).toContain('r4303011157206680397');
|
|
1210
|
+
stub({ 'r4303011157206680397': ORIGINAL, 's:14092347734530621658': '{"draft":{}}' });
|
|
1211
|
+
expect((await call()).content[0].text).toContain('s:14092347734530621658');
|
|
1212
|
+
});
|
|
1213
|
+
});
|
|
1214
|
+
|
|
717
1215
|
describe('gog_gmail_drafts_get', () => {
|
|
718
1216
|
it('calls runOrDiagnose with draftId', async () => {
|
|
719
1217
|
await harness.callTool('gog_gmail_drafts_get', { draftId: 'd1' });
|
|
720
1218
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
721
|
-
['gmail', 'drafts', 'get', 'd1'],
|
|
1219
|
+
['gmail', 'drafts', 'get', 'd1', '--use-indexed-attachment-ids=false'],
|
|
722
1220
|
{ account: undefined },
|
|
723
1221
|
);
|
|
724
1222
|
});
|
|
@@ -726,7 +1224,7 @@ describe('gog_gmail_drafts_get', () => {
|
|
|
726
1224
|
it('passes --download when true', async () => {
|
|
727
1225
|
await harness.callTool('gog_gmail_drafts_get', { draftId: 'd1', download: true });
|
|
728
1226
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
729
|
-
['gmail', 'drafts', 'get', 'd1', '--download'],
|
|
1227
|
+
['gmail', 'drafts', 'get', 'd1', '--download', '--use-indexed-attachment-ids=false'],
|
|
730
1228
|
{ account: undefined },
|
|
731
1229
|
);
|
|
732
1230
|
});
|
|
@@ -739,7 +1237,7 @@ describe('gog_gmail_drafts_create', () => {
|
|
|
739
1237
|
body: 'Hello',
|
|
740
1238
|
});
|
|
741
1239
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
742
|
-
['gmail', 'drafts', 'create', '--subject=Hi', '--body=Hello'],
|
|
1240
|
+
['gmail', 'drafts', 'create', '--subject=Hi', '--body=Hello', '--auto-from-addressed-alias=false'],
|
|
743
1241
|
{ account: undefined },
|
|
744
1242
|
);
|
|
745
1243
|
});
|
|
@@ -772,7 +1270,7 @@ describe('gog_gmail_drafts_create', () => {
|
|
|
772
1270
|
'--quote',
|
|
773
1271
|
'--attach=/tmp/a.pdf',
|
|
774
1272
|
'--attach=/tmp/b.pdf',
|
|
775
|
-
'--from=me@x.com',
|
|
1273
|
+
'--from=me@x.com', '--auto-from-addressed-alias=false'
|
|
776
1274
|
],
|
|
777
1275
|
{ account: undefined },
|
|
778
1276
|
);
|
|
@@ -785,7 +1283,7 @@ describe('gog_gmail_drafts_create', () => {
|
|
|
785
1283
|
bodyHtmlFile: '/tmp/body.html',
|
|
786
1284
|
});
|
|
787
1285
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
788
|
-
['gmail', 'drafts', 'create', '--subject=Hi', '--body=Hello', '--body-html-file=/tmp/body.html'],
|
|
1286
|
+
['gmail', 'drafts', 'create', '--subject=Hi', '--body=Hello', '--body-html-file=/tmp/body.html', '--auto-from-addressed-alias=false'],
|
|
789
1287
|
{ account: undefined },
|
|
790
1288
|
);
|
|
791
1289
|
});
|
|
@@ -798,7 +1296,7 @@ describe('gog_gmail_drafts_create', () => {
|
|
|
798
1296
|
replyAll: true,
|
|
799
1297
|
});
|
|
800
1298
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
801
|
-
['gmail', 'drafts', 'create', '--subject=Re: Hi', '--body=Hello all', '--thread-id=t1', '--reply-all'],
|
|
1299
|
+
['gmail', 'drafts', 'create', '--subject=Re: Hi', '--body=Hello all', '--thread-id=t1', '--reply-all', '--auto-from-addressed-alias=false'],
|
|
802
1300
|
{ account: undefined },
|
|
803
1301
|
);
|
|
804
1302
|
});
|
|
@@ -809,7 +1307,7 @@ describe('gog_gmail_drafts_create', () => {
|
|
|
809
1307
|
subject: 'Hi', body: 'Hello', omitRecipients: true,
|
|
810
1308
|
});
|
|
811
1309
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
812
|
-
['gmail', 'drafts', 'create', '--subject=Hi', '--body=Hello'],
|
|
1310
|
+
['gmail', 'drafts', 'create', '--subject=Hi', '--body=Hello', '--auto-from-addressed-alias=false'],
|
|
813
1311
|
{ account: undefined },
|
|
814
1312
|
);
|
|
815
1313
|
});
|
|
@@ -822,9 +1320,9 @@ describe('gog_gmail_drafts_create', () => {
|
|
|
822
1320
|
subject: 'Hi', body: 'Hello', returnFull: true,
|
|
823
1321
|
});
|
|
824
1322
|
expect(lib.runOrDiagnose).toHaveBeenNthCalledWith(1,
|
|
825
|
-
['gmail', 'drafts', 'create', '--subject=Hi', '--body=Hello'], { account: undefined });
|
|
1323
|
+
['gmail', 'drafts', 'create', '--subject=Hi', '--body=Hello', '--auto-from-addressed-alias=false'], { account: undefined });
|
|
826
1324
|
expect(lib.runOrDiagnose).toHaveBeenNthCalledWith(2,
|
|
827
|
-
['gmail', 'drafts', 'get', 'd9'], { account: undefined });
|
|
1325
|
+
['gmail', 'drafts', 'get', 'd9', '--use-indexed-attachment-ids=false'], { account: undefined });
|
|
828
1326
|
expect(result.content[0].text).toContain('"subject":"Hi"');
|
|
829
1327
|
});
|
|
830
1328
|
|
|
@@ -859,7 +1357,7 @@ describe('gmail draft reply threading (native --thread-id)', () => {
|
|
|
859
1357
|
// gog resolves the thread's latest-message headers itself — no extra fetch.
|
|
860
1358
|
expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
|
|
861
1359
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
862
|
-
['gmail', 'drafts', 'create', '--subject=Re: roof', '--body=Sounds good', '--thread-id=19dffe06f9668b28'],
|
|
1360
|
+
['gmail', 'drafts', 'create', '--subject=Re: roof', '--body=Sounds good', '--thread-id=19dffe06f9668b28', '--auto-from-addressed-alias=false'],
|
|
863
1361
|
{ account: 'me@x.com' },
|
|
864
1362
|
);
|
|
865
1363
|
});
|
|
@@ -869,7 +1367,7 @@ describe('gmail draft reply threading (native --thread-id)', () => {
|
|
|
869
1367
|
draftId: 'd1', subject: 'S', body: 'B', replyToThreadId: 't1',
|
|
870
1368
|
});
|
|
871
1369
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
872
|
-
['gmail', 'drafts', 'update', 'd1', '--subject=S', '--body=B', '--thread-id=t1'],
|
|
1370
|
+
['gmail', 'drafts', 'update', 'd1', '--subject=S', '--body=B', '--thread-id=t1', '--auto-from-addressed-alias=false'],
|
|
873
1371
|
{ account: undefined },
|
|
874
1372
|
);
|
|
875
1373
|
});
|
|
@@ -880,7 +1378,7 @@ describe('gmail draft reply threading (native --thread-id)', () => {
|
|
|
880
1378
|
});
|
|
881
1379
|
expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
|
|
882
1380
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
883
|
-
['gmail', 'drafts', 'create', '--subject=S', '--body=B', '--reply-to-message-id=mExplicit'],
|
|
1381
|
+
['gmail', 'drafts', 'create', '--subject=S', '--body=B', '--reply-to-message-id=mExplicit', '--auto-from-addressed-alias=false'],
|
|
884
1382
|
{ account: undefined },
|
|
885
1383
|
);
|
|
886
1384
|
});
|
|
@@ -894,7 +1392,7 @@ describe('gog_gmail_drafts_update', () => {
|
|
|
894
1392
|
body: 'New body',
|
|
895
1393
|
});
|
|
896
1394
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
897
|
-
['gmail', 'drafts', 'update', 'd1', '--subject=New subject', '--body=New body'],
|
|
1395
|
+
['gmail', 'drafts', 'update', 'd1', '--subject=New subject', '--body=New body', '--auto-from-addressed-alias=false'],
|
|
898
1396
|
{ account: undefined },
|
|
899
1397
|
);
|
|
900
1398
|
});
|
|
@@ -907,7 +1405,7 @@ describe('gog_gmail_drafts_update', () => {
|
|
|
907
1405
|
attach: ['/tmp/x.pdf'],
|
|
908
1406
|
});
|
|
909
1407
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
910
|
-
['gmail', 'drafts', 'update', 'd1', '--subject=S', '--body=B', '--attach=/tmp/x.pdf'],
|
|
1408
|
+
['gmail', 'drafts', 'update', 'd1', '--subject=S', '--body=B', '--attach=/tmp/x.pdf', '--auto-from-addressed-alias=false'],
|
|
911
1409
|
{ account: undefined },
|
|
912
1410
|
);
|
|
913
1411
|
});
|
|
@@ -917,7 +1415,7 @@ describe('gog_gmail_drafts_update', () => {
|
|
|
917
1415
|
draftId: 'd1', to: 'a@b.com', subject: 'S', body: 'B', omitRecipients: true,
|
|
918
1416
|
});
|
|
919
1417
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
920
|
-
['gmail', 'drafts', 'update', 'd1', '--subject=S', '--body=B'],
|
|
1418
|
+
['gmail', 'drafts', 'update', 'd1', '--subject=S', '--body=B', '--auto-from-addressed-alias=false'],
|
|
921
1419
|
{ account: undefined },
|
|
922
1420
|
);
|
|
923
1421
|
});
|
|
@@ -927,7 +1425,7 @@ describe('gog_gmail_drafts_update', () => {
|
|
|
927
1425
|
draftId: 'd1', subject: 'S', body: 'B', clearAttachments: true,
|
|
928
1426
|
});
|
|
929
1427
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
930
|
-
['gmail', 'drafts', 'update', 'd1', '--subject=S', '--body=B', '--clear-attachments'],
|
|
1428
|
+
['gmail', 'drafts', 'update', 'd1', '--subject=S', '--body=B', '--auto-from-addressed-alias=false', '--clear-attachments'],
|
|
931
1429
|
{ account: undefined },
|
|
932
1430
|
);
|
|
933
1431
|
});
|
|
@@ -937,7 +1435,7 @@ describe('gog_gmail_drafts_update', () => {
|
|
|
937
1435
|
draftId: 'd1', subject: 'S', body: 'B', clearReplyContext: true,
|
|
938
1436
|
});
|
|
939
1437
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
940
|
-
['gmail', 'drafts', 'update', 'd1', '--subject=S', '--body=B', '--clear-reply-context'],
|
|
1438
|
+
['gmail', 'drafts', 'update', 'd1', '--subject=S', '--body=B', '--auto-from-addressed-alias=false', '--clear-reply-context'],
|
|
941
1439
|
{ account: undefined },
|
|
942
1440
|
);
|
|
943
1441
|
});
|
|
@@ -961,7 +1459,7 @@ describe('gog_gmail_drafts_update', () => {
|
|
|
961
1459
|
});
|
|
962
1460
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
963
1461
|
['gmail', 'drafts', 'update', 'd1', '--subject=S', '--body=B',
|
|
964
|
-
'--clear-attachments', '--clear-reply-context'],
|
|
1462
|
+
'--auto-from-addressed-alias=false', '--clear-attachments', '--clear-reply-context'],
|
|
965
1463
|
{ account: undefined },
|
|
966
1464
|
);
|
|
967
1465
|
});
|
|
@@ -974,7 +1472,7 @@ describe('gog_gmail_drafts_update', () => {
|
|
|
974
1472
|
draftId: 'd1', subject: 'S', body: 'B', returnFull: true,
|
|
975
1473
|
});
|
|
976
1474
|
expect(lib.runOrDiagnose).toHaveBeenNthCalledWith(2,
|
|
977
|
-
['gmail', 'drafts', 'get', 'd1'], { account: undefined });
|
|
1475
|
+
['gmail', 'drafts', 'get', 'd1', '--use-indexed-attachment-ids=false'], { account: undefined });
|
|
978
1476
|
expect(result.content[0].text).toContain('"subject":"S"');
|
|
979
1477
|
});
|
|
980
1478
|
|
|
@@ -1071,7 +1569,7 @@ describe('gog_gmail_reply', () => {
|
|
|
1071
1569
|
it('calls runOrDiagnose with messageId and --body', async () => {
|
|
1072
1570
|
await harness.callTool('gog_gmail_reply', { messageId: 'm1', body: 'Thanks' });
|
|
1073
1571
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1074
|
-
['gmail', 'reply', 'm1', '--body=Thanks'],
|
|
1572
|
+
['gmail', 'reply', 'm1', '--body=Thanks', '--auto-from-addressed-alias=false'],
|
|
1075
1573
|
{ account: undefined },
|
|
1076
1574
|
);
|
|
1077
1575
|
});
|
|
@@ -1111,7 +1609,7 @@ describe('gog_gmail_reply', () => {
|
|
|
1111
1609
|
'--from=me@x.com',
|
|
1112
1610
|
'--signature',
|
|
1113
1611
|
'--signature-from=alias@x.com',
|
|
1114
|
-
'--signature-file=/tmp/sig.txt',
|
|
1612
|
+
'--signature-file=/tmp/sig.txt', '--auto-from-addressed-alias=false'
|
|
1115
1613
|
],
|
|
1116
1614
|
{ account: 'me@gmail.com' },
|
|
1117
1615
|
);
|
|
@@ -1120,7 +1618,7 @@ describe('gog_gmail_reply', () => {
|
|
|
1120
1618
|
it('omits --no-quote and --signature when false', async () => {
|
|
1121
1619
|
await harness.callTool('gog_gmail_reply', { messageId: 'm1', body: 'Hi', noQuote: false, signature: false });
|
|
1122
1620
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1123
|
-
['gmail', 'reply', 'm1', '--body=Hi'],
|
|
1621
|
+
['gmail', 'reply', 'm1', '--body=Hi', '--auto-from-addressed-alias=false'],
|
|
1124
1622
|
{ account: undefined },
|
|
1125
1623
|
);
|
|
1126
1624
|
});
|
|
@@ -1130,7 +1628,7 @@ describe('gog_gmail_reply_all', () => {
|
|
|
1130
1628
|
it('uses the reply-all subcommand', async () => {
|
|
1131
1629
|
await harness.callTool('gog_gmail_reply_all', { messageId: 'm1', body: 'Thanks all' });
|
|
1132
1630
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1133
|
-
['gmail', 'reply-all', 'm1', '--body=Thanks all'],
|
|
1631
|
+
['gmail', 'reply-all', 'm1', '--body=Thanks all', '--auto-from-addressed-alias=false'],
|
|
1134
1632
|
{ account: undefined },
|
|
1135
1633
|
);
|
|
1136
1634
|
});
|
|
@@ -1150,7 +1648,7 @@ describe('gog_gmail_reply_all', () => {
|
|
|
1150
1648
|
'--cc=x@y.com',
|
|
1151
1649
|
'--cc=z@y.com',
|
|
1152
1650
|
'--remove=drop@y.com',
|
|
1153
|
-
'--signature-file=/tmp/sig.html',
|
|
1651
|
+
'--signature-file=/tmp/sig.html', '--auto-from-addressed-alias=false'
|
|
1154
1652
|
],
|
|
1155
1653
|
{ account: undefined },
|
|
1156
1654
|
);
|
|
@@ -1228,7 +1726,7 @@ describe('gog_gmail_messages_search', () => {
|
|
|
1228
1726
|
it('calls runOrDiagnose with just the query', async () => {
|
|
1229
1727
|
await harness.callTool('gog_gmail_messages_search', { query: 'from:alice' });
|
|
1230
1728
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1231
|
-
['gmail', 'messages', 'search', 'from:alice'],
|
|
1729
|
+
['gmail', 'messages', 'search', 'from:alice', '--include-attachments=false', '--use-indexed-attachment-ids=false'],
|
|
1232
1730
|
{ account: undefined },
|
|
1233
1731
|
);
|
|
1234
1732
|
});
|
|
@@ -1245,7 +1743,7 @@ describe('gog_gmail_messages_search', () => {
|
|
|
1245
1743
|
account: 'me@x.com',
|
|
1246
1744
|
});
|
|
1247
1745
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1248
|
-
['gmail', 'messages', 'search', 'is:unread', '--max=10', '--page=tok', '--all', '--include-body', '--full', '--body-format=html'],
|
|
1746
|
+
['gmail', 'messages', 'search', 'is:unread', '--max=10', '--page=tok', '--all', '--include-body', '--full', '--body-format=html', '--include-attachments=false', '--use-indexed-attachment-ids=false'],
|
|
1249
1747
|
{ account: 'me@x.com' },
|
|
1250
1748
|
);
|
|
1251
1749
|
});
|
|
@@ -1253,7 +1751,7 @@ describe('gog_gmail_messages_search', () => {
|
|
|
1253
1751
|
it('omits flags when false/absent', async () => {
|
|
1254
1752
|
await harness.callTool('gog_gmail_messages_search', { query: 'x', all: false, includeBody: false, full: false });
|
|
1255
1753
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1256
|
-
['gmail', 'messages', 'search', 'x'],
|
|
1754
|
+
['gmail', 'messages', 'search', 'x', '--include-attachments=false', '--use-indexed-attachment-ids=false'],
|
|
1257
1755
|
{ account: undefined },
|
|
1258
1756
|
);
|
|
1259
1757
|
});
|
|
@@ -1550,7 +2048,7 @@ describe('large payloads route to file args', () => {
|
|
|
1550
2048
|
it('keeps a body at exactly the threshold inline', async () => {
|
|
1551
2049
|
const atLimit = 'x'.repeat(lib.PAYLOAD_INLINE_MAX);
|
|
1552
2050
|
await harness.callTool('gog_gmail_drafts_create', { subject: 'S', body: atLimit });
|
|
1553
|
-
expect(args()).toEqual(['gmail', 'drafts', 'create', '--subject=S', `--body=${atLimit}
|
|
2051
|
+
expect(args()).toEqual(['gmail', 'drafts', 'create', '--subject=S', `--body=${atLimit}`, '--auto-from-addressed-alias=false']);
|
|
1554
2052
|
});
|
|
1555
2053
|
|
|
1556
2054
|
it('measures bytes, not characters, so a multibyte body crosses earlier', async () => {
|
|
@@ -1568,7 +2066,7 @@ describe('large payloads route to file args', () => {
|
|
|
1568
2066
|
expect(args()).toEqual([
|
|
1569
2067
|
'gmail', 'drafts', 'create',
|
|
1570
2068
|
'--subject=S',
|
|
1571
|
-
{ kind: 'file', flag: 'body-file', contents: big, ext: undefined },
|
|
2069
|
+
{ kind: 'file', flag: 'body-file', contents: big, ext: undefined }, '--auto-from-addressed-alias=false'
|
|
1572
2070
|
]);
|
|
1573
2071
|
});
|
|
1574
2072
|
|
|
@@ -1579,7 +2077,7 @@ describe('large payloads route to file args', () => {
|
|
|
1579
2077
|
'gmail', 'drafts', 'create',
|
|
1580
2078
|
'--subject=S',
|
|
1581
2079
|
'--body=plain',
|
|
1582
|
-
{ kind: 'file', flag: 'body-html-file', contents: bigHtml, ext: 'html' },
|
|
2080
|
+
{ kind: 'file', flag: 'body-html-file', contents: bigHtml, ext: 'html' }, '--auto-from-addressed-alias=false'
|
|
1583
2081
|
]);
|
|
1584
2082
|
});
|
|
1585
2083
|
|
|
@@ -1588,7 +2086,7 @@ describe('large payloads route to file args', () => {
|
|
|
1588
2086
|
expect(args()).toEqual([
|
|
1589
2087
|
'gmail', 'drafts', 'update', 'd1',
|
|
1590
2088
|
'--subject=S',
|
|
1591
|
-
{ kind: 'file', flag: 'body-file', contents: big, ext: undefined },
|
|
2089
|
+
{ kind: 'file', flag: 'body-file', contents: big, ext: undefined }, '--auto-from-addressed-alias=false'
|
|
1592
2090
|
]);
|
|
1593
2091
|
});
|
|
1594
2092
|
|
|
@@ -1599,7 +2097,7 @@ describe('large payloads route to file args', () => {
|
|
|
1599
2097
|
expect(args()).toEqual([
|
|
1600
2098
|
'gmail', 'drafts', 'create',
|
|
1601
2099
|
'--subject=S',
|
|
1602
|
-
{ kind: 'file', flag: 'body-file', contents: big, ext: undefined },
|
|
2100
|
+
{ kind: 'file', flag: 'body-file', contents: big, ext: undefined }, '--auto-from-addressed-alias=false'
|
|
1603
2101
|
]);
|
|
1604
2102
|
});
|
|
1605
2103
|
|
|
@@ -1612,7 +2110,7 @@ describe('large payloads route to file args', () => {
|
|
|
1612
2110
|
'--subject=S',
|
|
1613
2111
|
{ kind: 'file', flag: 'body-file', contents: big, ext: undefined },
|
|
1614
2112
|
'--thread-id=t1',
|
|
1615
|
-
'--attach=/tmp/a.pdf',
|
|
2113
|
+
'--attach=/tmp/a.pdf', '--auto-from-addressed-alias=false'
|
|
1616
2114
|
]);
|
|
1617
2115
|
});
|
|
1618
2116
|
|
|
@@ -1629,7 +2127,7 @@ describe('large payloads route to file args', () => {
|
|
|
1629
2127
|
expect(args()).toEqual([
|
|
1630
2128
|
'gmail', 'reply', 'm1',
|
|
1631
2129
|
{ kind: 'file', flag: 'body-file', contents: big, ext: undefined },
|
|
1632
|
-
{ kind: 'file', flag: 'body-html-file', contents: bigHtml, ext: 'html' },
|
|
2130
|
+
{ kind: 'file', flag: 'body-html-file', contents: bigHtml, ext: 'html' }, '--auto-from-addressed-alias=false'
|
|
1633
2131
|
]);
|
|
1634
2132
|
});
|
|
1635
2133
|
|
|
@@ -1638,7 +2136,7 @@ describe('large payloads route to file args', () => {
|
|
|
1638
2136
|
expect(args()).toEqual([
|
|
1639
2137
|
'gmail', 'reply-all', 'm1',
|
|
1640
2138
|
{ kind: 'file', flag: 'body-file', contents: big, ext: undefined },
|
|
1641
|
-
'--signature',
|
|
2139
|
+
'--signature', '--auto-from-addressed-alias=false'
|
|
1642
2140
|
]);
|
|
1643
2141
|
});
|
|
1644
2142
|
|
|
@@ -1710,8 +2208,1107 @@ describe('inline/file param conflicts are rejected before gog runs', () => {
|
|
|
1710
2208
|
it('bodyHtmlFile alone still passes through as --body-html-file', async () => {
|
|
1711
2209
|
await harness.callTool('gog_gmail_reply', { messageId: 'm1', body: 'Hi', bodyHtmlFile: '/tmp/b.html' });
|
|
1712
2210
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1713
|
-
['gmail', 'reply', 'm1', '--body=Hi', '--body-html-file=/tmp/b.html'],
|
|
2211
|
+
['gmail', 'reply', 'm1', '--body=Hi', '--body-html-file=/tmp/b.html', '--auto-from-addressed-alias=false'],
|
|
2212
|
+
{ account: undefined },
|
|
2213
|
+
);
|
|
2214
|
+
});
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2217
|
+
// ---------------------------------------------------------------------------
|
|
2218
|
+
// gog 0.35.0 capabilities (MIN_GOG_VERSION floor).
|
|
2219
|
+
//
|
|
2220
|
+
// Every flag below is env-bound in gog, so an ambient GOG_GMAIL_* var on the
|
|
2221
|
+
// host silently changes the SHAPE of the output (or, for the attachment
|
|
2222
|
+
// download, makes gog reject the caller's own argument). runner.ts strips only
|
|
2223
|
+
// *_TOKEN/*_SECRET/*_API_KEY/*_PRIVATE_KEY, and on the remote runner the child
|
|
2224
|
+
// env belongs to a backend this wrapper does not control — so the tests below
|
|
2225
|
+
// assert the flag is PINNED on every call, not merely pushed when true.
|
|
2226
|
+
// ---------------------------------------------------------------------------
|
|
2227
|
+
|
|
2228
|
+
describe('gog 0.35.0 — indexed attachment ids (gog_gmail_attachment)', () => {
|
|
2229
|
+
const PDF_B64 = 'JVBERi0xLjUKJVBFRgo=';
|
|
2230
|
+
const INDEXED_LIST = {
|
|
2231
|
+
attachments: [
|
|
2232
|
+
{ filename: 'cover.png', mimeType: 'image/png', attachmentIndex: 0, size: 11 },
|
|
2233
|
+
{ filename: 'Guest_Copy.pdf', mimeType: 'application/pdf', attachmentIndex: 1, size: 99723 },
|
|
2234
|
+
],
|
|
2235
|
+
};
|
|
2236
|
+
|
|
2237
|
+
function stub(opts: { meta?: unknown; metaError?: Error; download?: unknown; downloadError?: unknown }): void {
|
|
2238
|
+
vi.mocked(lib.run).mockImplementation(async (args) => {
|
|
2239
|
+
const a = args as string[];
|
|
2240
|
+
if (a[0] === 'gmail' && a[1] === 'get') {
|
|
2241
|
+
if (opts.metaError) throw opts.metaError;
|
|
2242
|
+
return JSON.stringify(opts.meta ?? { attachments: [] });
|
|
2243
|
+
}
|
|
2244
|
+
if (a[0] === 'gmail' && a[1] === 'attachment') {
|
|
2245
|
+
if (opts.downloadError) throw opts.downloadError;
|
|
2246
|
+
return JSON.stringify(opts.download ?? {});
|
|
2247
|
+
}
|
|
2248
|
+
return '{}';
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
const runArgs = () => vi.mocked(lib.run).mock.calls.map((c) => c[0] as string[]);
|
|
2252
|
+
const dlArgs = () => runArgs().find((a) => a[1] === 'attachment')!;
|
|
2253
|
+
const textOf = (res: { content: unknown[] }) => (res.content[0] as { text: string }).text;
|
|
2254
|
+
|
|
2255
|
+
it('resolves the real filename BEFORE the download and never runs the size heuristic', async () => {
|
|
2256
|
+
stub({ meta: INDEXED_LIST, download: { path: '/tmp/gog-attachments/m1/Guest_Copy.pdf', bytes: 99723, contentBase64: PDF_B64 } });
|
|
2257
|
+
const res = await harness.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 1 });
|
|
2258
|
+
// exactly one metadata read, and it happens BEFORE the download.
|
|
2259
|
+
expect(runArgs().map((a) => a.slice(0, 2))).toEqual([['gmail', 'get'], ['gmail', 'attachment']]);
|
|
2260
|
+
expect(runArgs()[0]).toEqual(['gmail', 'get', 'm1', '--use-indexed-attachment-ids']);
|
|
2261
|
+
// the index rides in the positional slot, and gog is told to read it as one.
|
|
2262
|
+
expect(dlArgs()).toEqual([
|
|
2263
|
+
'gmail', 'attachment', 'm1', '1', '--use-indexed-attachment-ids', '--inline', '--inline-max-bytes=3145728',
|
|
2264
|
+
'--out=/tmp/gog-attachments/m1/Guest_Copy.pdf', '--name=Guest_Copy.pdf',
|
|
2265
|
+
]);
|
|
2266
|
+
expect(JSON.parse(textOf(res))).toMatchObject({ fileName: 'Guest_Copy.pdf', mimeType: 'application/pdf' });
|
|
2267
|
+
});
|
|
2268
|
+
|
|
2269
|
+
it('pins the mode OFF on the legacy attachmentId path', async () => {
|
|
2270
|
+
stub({ download: { path: '/tmp/gog-attachments/m1/x.pdf', bytes: 12, contentBase64: PDF_B64 } });
|
|
2271
|
+
await harness.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentId: 'OPAQUE1', name: 'x.pdf' });
|
|
2272
|
+
expect(dlArgs()).toEqual([
|
|
2273
|
+
'gmail', 'attachment', 'm1', 'OPAQUE1', '--use-indexed-attachment-ids=false', '--inline', '--inline-max-bytes=3145728',
|
|
2274
|
+
'--out=/tmp/gog-attachments/m1/x.pdf', '--name=x.pdf',
|
|
2275
|
+
]);
|
|
2276
|
+
});
|
|
2277
|
+
|
|
2278
|
+
it('rejects both attachmentId and attachmentIndex', async () => {
|
|
2279
|
+
const res = await harness.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentId: 'a1', attachmentIndex: 0 });
|
|
2280
|
+
expect(res.isError).toBe(true);
|
|
2281
|
+
expect(textOf(res)).toContain('exactly one');
|
|
2282
|
+
expect(lib.run).not.toHaveBeenCalled();
|
|
2283
|
+
});
|
|
2284
|
+
|
|
2285
|
+
it('rejects neither attachmentId nor attachmentIndex', async () => {
|
|
2286
|
+
const res = await harness.callTool('gog_gmail_attachment', { messageId: 'm1' });
|
|
2287
|
+
expect(res.isError).toBe(true);
|
|
2288
|
+
expect(textOf(res)).toContain('exactly one');
|
|
2289
|
+
expect(lib.run).not.toHaveBeenCalled();
|
|
2290
|
+
});
|
|
2291
|
+
|
|
2292
|
+
it('a caller-supplied name skips the index lookup', async () => {
|
|
2293
|
+
stub({ meta: INDEXED_LIST, download: { path: '/tmp/gog-attachments/m1/mine.pdf', bytes: 5, contentBase64: PDF_B64 } });
|
|
2294
|
+
await harness.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 1, name: 'mine.pdf' });
|
|
2295
|
+
expect(runArgs().some((a) => a[1] === 'get')).toBe(false);
|
|
2296
|
+
});
|
|
2297
|
+
|
|
2298
|
+
it('survives an index lookup failure without falling back to the size heuristic', async () => {
|
|
2299
|
+
stub({ metaError: new Error('get failed'), download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 12, contentBase64: PDF_B64 } });
|
|
2300
|
+
const res = await harness.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 0 });
|
|
2301
|
+
// only the (failed) pre-download lookup — resolveBySize must not run after it.
|
|
2302
|
+
expect(runArgs().filter((a) => a[1] === 'get')).toHaveLength(1);
|
|
2303
|
+
expect(JSON.parse(textOf(res))).toMatchObject({ fileName: 'attachment.pdf', mimeType: 'application/pdf' });
|
|
2304
|
+
});
|
|
2305
|
+
|
|
2306
|
+
it('tolerates a message whose listing carries no attachments array', async () => {
|
|
2307
|
+
stub({ meta: {}, download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 12, contentBase64: PDF_B64 } });
|
|
2308
|
+
const res = await harness.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 3 });
|
|
2309
|
+
expect(JSON.parse(textOf(res))).toMatchObject({ fileName: 'attachment.pdf' });
|
|
2310
|
+
});
|
|
2311
|
+
|
|
2312
|
+
it('uses the filename/mimeType gog itself reports on an --inline download', async () => {
|
|
2313
|
+
// gog >= 0.34 returns the part metadata alongside the bytes; prefer it over
|
|
2314
|
+
// any wrapper-side guess.
|
|
2315
|
+
stub({ meta: { attachments: [{}] }, download: {
|
|
2316
|
+
path: '/tmp/gog-attachments/m1/attachment', bytes: 12, contentBase64: PDF_B64,
|
|
2317
|
+
filename: 'From_Gog.pdf', mimeType: 'application/pdf',
|
|
2318
|
+
} });
|
|
2319
|
+
const res = await harness.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 0 });
|
|
2320
|
+
expect(JSON.parse(textOf(res))).toMatchObject({ fileName: 'From_Gog.pdf', mimeType: 'application/pdf' });
|
|
2321
|
+
});
|
|
2322
|
+
|
|
2323
|
+
it('prefers a caller name over the one gog reports', async () => {
|
|
2324
|
+
stub({ download: {
|
|
2325
|
+
path: '/tmp/gog-attachments/m1/mine.pdf', bytes: 12, contentBase64: PDF_B64,
|
|
2326
|
+
filename: 'From_Gog.pdf', mimeType: 'application/pdf',
|
|
2327
|
+
} });
|
|
2328
|
+
const res = await harness.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 0, name: 'mine.pdf' });
|
|
2329
|
+
expect(JSON.parse(textOf(res))).toMatchObject({ fileName: 'mine.pdf', mimeType: 'application/pdf' });
|
|
2330
|
+
});
|
|
2331
|
+
|
|
2332
|
+
it('passes inlineMaxBytes through as --inline-max-bytes', async () => {
|
|
2333
|
+
stub({ meta: INDEXED_LIST, download: { path: '/tmp/gog-attachments/m1/cover.png', bytes: 11 } });
|
|
2334
|
+
await harness.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 0, inlineMaxBytes: 1048576 });
|
|
2335
|
+
expect(dlArgs()).toEqual([
|
|
2336
|
+
'gmail', 'attachment', 'm1', '0', '--use-indexed-attachment-ids', '--inline', '--inline-max-bytes=1048576',
|
|
2337
|
+
'--out=/tmp/gog-attachments/m1/cover.png', '--name=cover.png',
|
|
2338
|
+
]);
|
|
2339
|
+
});
|
|
2340
|
+
|
|
2341
|
+
it('does not mangle an indexed failure message while redacting (no bare-digit substitution)', async () => {
|
|
2342
|
+
stub({ downloadError: new Error('Command failed: gog gmail attachment m1 0\nquota exceeded: 1000 requests') });
|
|
2343
|
+
await harness.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 0 });
|
|
2344
|
+
const passed = (vi.mocked(lib.diagnose).mock.calls[0]![0] as Error).message;
|
|
2345
|
+
expect(passed).toBe('quota exceeded: 1000 requests');
|
|
2346
|
+
});
|
|
2347
|
+
});
|
|
2348
|
+
|
|
2349
|
+
describe('gog 0.35.0 — indexed ids are pinned on every listing that emits attachments', () => {
|
|
2350
|
+
const args = () => vi.mocked(lib.runOrDiagnose).mock.calls[0]![0] as string[];
|
|
2351
|
+
|
|
2352
|
+
it('gog_gmail_thread_get pins the mode off by default and on when asked', async () => {
|
|
2353
|
+
await harness.callTool('gog_gmail_thread_get', { threadId: 't1' });
|
|
2354
|
+
expect(args()).toEqual(['gmail', 'thread', 'get', 't1', '--use-indexed-attachment-ids=false']);
|
|
2355
|
+
vi.clearAllMocks();
|
|
2356
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
|
|
2357
|
+
await harness.callTool('gog_gmail_thread_get', { threadId: 't1', useIndexedAttachmentIds: true });
|
|
2358
|
+
expect(args()).toEqual(['gmail', 'thread', 'get', 't1', '--use-indexed-attachment-ids']);
|
|
2359
|
+
});
|
|
2360
|
+
|
|
2361
|
+
it('gog_gmail_thread_attachments pins the mode — a flat list needs per-message indexes', async () => {
|
|
2362
|
+
await harness.callTool('gog_gmail_thread_attachments', { threadId: 't1' });
|
|
2363
|
+
expect(args()).toEqual(['gmail', 'thread', 'attachments', 't1', '--use-indexed-attachment-ids=false']);
|
|
2364
|
+
vi.clearAllMocks();
|
|
2365
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
|
|
2366
|
+
await harness.callTool('gog_gmail_thread_attachments', { threadId: 't1', useIndexedAttachmentIds: true, download: true });
|
|
2367
|
+
expect(args()).toEqual(['gmail', 'thread', 'attachments', 't1', '--download', '--use-indexed-attachment-ids']);
|
|
2368
|
+
});
|
|
2369
|
+
|
|
2370
|
+
it('gog_gmail_drafts_get pins the mode', async () => {
|
|
2371
|
+
await harness.callTool('gog_gmail_drafts_get', { draftId: 'd1' });
|
|
2372
|
+
expect(args()).toEqual(['gmail', 'drafts', 'get', 'd1', '--use-indexed-attachment-ids=false']);
|
|
2373
|
+
vi.clearAllMocks();
|
|
2374
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
|
|
2375
|
+
await harness.callTool('gog_gmail_drafts_get', { draftId: 'd1', useIndexedAttachmentIds: true, download: true });
|
|
2376
|
+
expect(args()).toEqual(['gmail', 'drafts', 'get', 'd1', '--download', '--use-indexed-attachment-ids']);
|
|
2377
|
+
});
|
|
2378
|
+
|
|
2379
|
+
it('gog_gmail_messages_search pins BOTH attachment-shaping flags', async () => {
|
|
2380
|
+
await harness.callTool('gog_gmail_messages_search', { query: 'x' });
|
|
2381
|
+
expect(args()).toEqual([
|
|
2382
|
+
'gmail', 'messages', 'search', 'x', '--include-attachments=false', '--use-indexed-attachment-ids=false',
|
|
2383
|
+
]);
|
|
2384
|
+
vi.clearAllMocks();
|
|
2385
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
|
|
2386
|
+
await harness.callTool('gog_gmail_messages_search', { query: 'x', includeAttachments: true, useIndexedAttachmentIds: true });
|
|
2387
|
+
expect(args()).toEqual([
|
|
2388
|
+
'gmail', 'messages', 'search', 'x', '--include-attachments', '--use-indexed-attachment-ids',
|
|
2389
|
+
]);
|
|
2390
|
+
});
|
|
2391
|
+
});
|
|
2392
|
+
|
|
2393
|
+
describe('gog 0.35.0 — --auto-from-addressed-alias is pinned on every send-shaped write', () => {
|
|
2394
|
+
const args = () => vi.mocked(lib.runOrDiagnose).mock.calls[0]![0] as string[];
|
|
2395
|
+
|
|
2396
|
+
it('gog_gmail_drafts_create pins it off, and sets it when asked', async () => {
|
|
2397
|
+
await harness.callTool('gog_gmail_drafts_create', { subject: 'S', body: 'B' });
|
|
2398
|
+
expect(args()).toEqual(['gmail', 'drafts', 'create', '--subject=S', '--body=B', '--auto-from-addressed-alias=false']);
|
|
2399
|
+
vi.clearAllMocks();
|
|
2400
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
|
|
2401
|
+
await harness.callTool('gog_gmail_drafts_create', { subject: 'S', body: 'B', autoFromAddressedAlias: true });
|
|
2402
|
+
expect(args()).toEqual(['gmail', 'drafts', 'create', '--subject=S', '--body=B', '--auto-from-addressed-alias']);
|
|
2403
|
+
});
|
|
2404
|
+
|
|
2405
|
+
it('gog_gmail_drafts_update pins it', async () => {
|
|
2406
|
+
await harness.callTool('gog_gmail_drafts_update', { draftId: 'd1', subject: 'S', body: 'B', autoFromAddressedAlias: true });
|
|
2407
|
+
expect(args()).toEqual(['gmail', 'drafts', 'update', 'd1', '--subject=S', '--body=B', '--auto-from-addressed-alias']);
|
|
2408
|
+
});
|
|
2409
|
+
|
|
2410
|
+
it('gog_gmail_reply and gog_gmail_reply_all pin it', async () => {
|
|
2411
|
+
await harness.callTool('gog_gmail_reply', { messageId: 'm1', body: 'Hi' });
|
|
2412
|
+
expect(args()).toEqual(['gmail', 'reply', 'm1', '--body=Hi', '--auto-from-addressed-alias=false']);
|
|
2413
|
+
vi.clearAllMocks();
|
|
2414
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
|
|
2415
|
+
await harness.callTool('gog_gmail_reply_all', { messageId: 'm1', body: 'Hi', autoFromAddressedAlias: true });
|
|
2416
|
+
expect(args()).toEqual(['gmail', 'reply-all', 'm1', '--body=Hi', '--auto-from-addressed-alias']);
|
|
2417
|
+
});
|
|
2418
|
+
});
|
|
2419
|
+
|
|
2420
|
+
describe('gog 0.35.0 — gog_gmail_import', () => {
|
|
2421
|
+
const args = () => vi.mocked(lib.runOrDiagnose).mock.calls[0]![0] as string[];
|
|
2422
|
+
|
|
2423
|
+
it('imports a file with no options', async () => {
|
|
2424
|
+
await harness.callTool('gog_gmail_import', { file: '/tmp/msg.eml' });
|
|
2425
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(['gmail', 'import', '/tmp/msg.eml'], { account: undefined });
|
|
2426
|
+
});
|
|
2427
|
+
|
|
2428
|
+
it('passes every option, repeating --label', async () => {
|
|
2429
|
+
await harness.callTool('gog_gmail_import', {
|
|
2430
|
+
file: '/srv/exports/archived.eml',
|
|
2431
|
+
labels: ['INBOX', 'Archive/2026'],
|
|
2432
|
+
internalDateSource: 'receivedTime',
|
|
2433
|
+
neverMarkSpam: true,
|
|
2434
|
+
processForCalendar: true,
|
|
2435
|
+
account: 'me@x.com',
|
|
2436
|
+
});
|
|
2437
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
2438
|
+
['gmail', 'import', '/srv/exports/archived.eml', '--label=INBOX', '--label=Archive/2026', '--internal-date-source=receivedTime', '--never-mark-spam', '--process-for-calendar'],
|
|
2439
|
+
{ account: 'me@x.com' },
|
|
2440
|
+
);
|
|
2441
|
+
});
|
|
2442
|
+
|
|
2443
|
+
it('omits flags that are false or absent', async () => {
|
|
2444
|
+
await harness.callTool('gog_gmail_import', { file: '/tmp/m.eml', neverMarkSpam: false, processForCalendar: false });
|
|
2445
|
+
expect(args()).toEqual(['gmail', 'import', '/tmp/m.eml']);
|
|
2446
|
+
});
|
|
2447
|
+
|
|
2448
|
+
it('rejects an internalDateSource outside gog\'s enum', async () => {
|
|
2449
|
+
const res = await harness.callTool('gog_gmail_import', { file: '/tmp/m.eml', internalDateSource: 'yesterday' });
|
|
2450
|
+
expect(res.isError).toBe(true);
|
|
2451
|
+
});
|
|
2452
|
+
|
|
2453
|
+
it('never appends --force — gog gates no confirmation on import', async () => {
|
|
2454
|
+
await harness.callTool('gog_gmail_import', { file: '/tmp/m.eml' });
|
|
2455
|
+
expect(args()).not.toContain('--force');
|
|
2456
|
+
});
|
|
2457
|
+
});
|
|
2458
|
+
|
|
2459
|
+
// gog reads a "-" path argument with io.ReadAll(stdinReader(ctx)) — os.Stdin,
|
|
2460
|
+
// unaffected by --no-input (gmail_import.go:102, gmail_body_input.go:45 at
|
|
2461
|
+
// upstream-v0.35.0). runner.ts's spawnGog spawns with default stdio and never
|
|
2462
|
+
// writes to or ends child.stdin, so gog blocks forever on a read that never
|
|
2463
|
+
// EOFs: `spawn('gog', ['gmail','import','-','--dry-run','--json','--no-input'])`
|
|
2464
|
+
// was still running with no output after 5s. Under run() that burns the whole
|
|
2465
|
+
// 30s timeout and returns "gog timed out after 30s". No description may offer
|
|
2466
|
+
// it as a usable option.
|
|
2467
|
+
describe('server-side file params never advertise stdin as usable', () => {
|
|
2468
|
+
const STDIN_PARAMS: Array<[tool: string, param: string]> = [
|
|
2469
|
+
['gog_gmail_import', 'file'],
|
|
2470
|
+
['gog_gmail_drafts_create', 'bodyHtmlFile'],
|
|
2471
|
+
['gog_gmail_drafts_update', 'bodyHtmlFile'],
|
|
2472
|
+
['gog_gmail_reply', 'bodyHtmlFile'],
|
|
2473
|
+
['gog_gmail_reply_all', 'bodyHtmlFile'],
|
|
2474
|
+
];
|
|
2475
|
+
|
|
2476
|
+
async function paramDescriptions(): Promise<Map<string, Record<string, { description?: string }>>> {
|
|
2477
|
+
const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
|
|
2478
|
+
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
|
2479
|
+
const configs = new Map<string, Record<string, { description?: string }>>();
|
|
2480
|
+
vi.spyOn(server, 'registerTool').mockImplementation((name, config) => {
|
|
2481
|
+
configs.set(name, (config as { inputSchema: Record<string, { description?: string }> }).inputSchema);
|
|
2482
|
+
return undefined as never;
|
|
2483
|
+
});
|
|
2484
|
+
registerExtraGmailTools(server);
|
|
2485
|
+
return configs;
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
it.each(STDIN_PARAMS)('%s.%s warns that stdin hangs instead of offering it', async (tool, param) => {
|
|
2489
|
+
const schema = (await paramDescriptions()).get(tool);
|
|
2490
|
+
const desc = schema?.[param]?.description ?? '';
|
|
2491
|
+
expect(desc).not.toBe('');
|
|
2492
|
+
expect(desc).not.toMatch(/(?:or|use)\s+"?-"?\s+(?:for|to read)/i);
|
|
2493
|
+
expect(desc).toMatch(/stdin/i);
|
|
2494
|
+
expect(desc).toMatch(/hang|never writes/i);
|
|
2495
|
+
});
|
|
2496
|
+
});
|
|
2497
|
+
|
|
2498
|
+
// Every env-bound gog flag this tool depends on is pinned on the call, because
|
|
2499
|
+
// the child env on the remote runner belongs to a backend we do not control.
|
|
2500
|
+
// --inline-max-bytes is declared env:"GOG_GMAIL_INLINE_MAX_BYTES"
|
|
2501
|
+
// (gmail_attachment.go:27 at upstream-v0.35.0), so an ambient value would
|
|
2502
|
+
// silently decide whether contentBase64 comes back at all.
|
|
2503
|
+
describe('gog_gmail_attachment pins --inline-max-bytes', () => {
|
|
2504
|
+
const args = () => vi.mocked(lib.run).mock.calls[0]![0] as string[];
|
|
2505
|
+
|
|
2506
|
+
it('pins gog\'s default when the caller supplies nothing', async () => {
|
|
2507
|
+
vi.mocked(lib.run).mockResolvedValue(JSON.stringify({ bytes: 10, contentBase64: 'AAAA', mimeType: 'image/png', filename: 'a.png' }));
|
|
2508
|
+
await harness.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentId: 'a1', deliver: 'inline' });
|
|
2509
|
+
expect(args()).toContain('--inline-max-bytes=3145728');
|
|
2510
|
+
});
|
|
2511
|
+
|
|
2512
|
+
it('pins the caller\'s value when supplied', async () => {
|
|
2513
|
+
vi.mocked(lib.run).mockResolvedValue(JSON.stringify({ bytes: 10, contentBase64: 'AAAA', mimeType: 'image/png', filename: 'a.png' }));
|
|
2514
|
+
await harness.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentId: 'a1', deliver: 'inline', inlineMaxBytes: 99 });
|
|
2515
|
+
expect(args()).toContain('--inline-max-bytes=99');
|
|
2516
|
+
});
|
|
2517
|
+
});
|
|
2518
|
+
|
|
2519
|
+
// ===========================================================================
|
|
2520
|
+
// REQUIREMENT 4 — VERIFY (and repair) THREADING ON AN UPDATE.
|
|
2521
|
+
//
|
|
2522
|
+
// gog already does the repair: `--thread-id` on `gmail drafts update` sets
|
|
2523
|
+
// replyToThreadID, so buildDraftMessage RESOLVES In-Reply-To/References from
|
|
2524
|
+
// the thread's latest non-draft message, and Users.Drafts.Update keeps the
|
|
2525
|
+
// draft id (internal/cmd/gmail_drafts.go, upstream-v0.35.0). What was missing
|
|
2526
|
+
// is the VERIFICATION: gog reports inReplyTo/references/replyContextSource in
|
|
2527
|
+
// its own ack, and nothing was reading them back to the caller.
|
|
2528
|
+
//
|
|
2529
|
+
// The silent failure this catches: the thread branch of fetchReplyInfo has no
|
|
2530
|
+
// "target has no Message-ID header" guard (the message branch does), so a
|
|
2531
|
+
// thread whose latest message lacks a Message-Id resolves NO lineage — and
|
|
2532
|
+
// because an explicit reply target suppresses the carry-forward branch, the
|
|
2533
|
+
// draft is MOVED to the new thread and ends up with NO reply headers at all.
|
|
2534
|
+
// ===========================================================================
|
|
2535
|
+
describe('gog_gmail_drafts_update — threading verification', () => {
|
|
2536
|
+
const ACK = (over: Record<string, unknown> = {}): string => JSON.stringify({
|
|
2537
|
+
draftId: 'r4303011157206680397',
|
|
2538
|
+
threadId: '19f856becba0661d',
|
|
2539
|
+
inReplyTo: '<CAO@mail.gmail.com>',
|
|
2540
|
+
references: '<CAO@mail.gmail.com>',
|
|
2541
|
+
replyContextSource: 'caller',
|
|
2542
|
+
...over,
|
|
2543
|
+
});
|
|
2544
|
+
|
|
2545
|
+
it('adopts a draft onto a thread in one call, keeps its id, and reports the effective headers', async () => {
|
|
2546
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(ACK()));
|
|
2547
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
2548
|
+
draftId: 'r4303011157206680397',
|
|
2549
|
+
subject: 'Re: pickup schedule',
|
|
2550
|
+
body: 'merged text',
|
|
2551
|
+
replyToThreadId: '19f856becba0661d',
|
|
2552
|
+
});
|
|
2553
|
+
|
|
2554
|
+
// One gog invocation: gog resolves the thread's reply headers server-side.
|
|
2555
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
|
|
2556
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
2557
|
+
['gmail', 'drafts', 'update', 'r4303011157206680397', '--subject=Re: pickup schedule',
|
|
2558
|
+
'--body=merged text', '--thread-id=19f856becba0661d', '--auto-from-addressed-alias=false'],
|
|
1714
2559
|
{ account: undefined },
|
|
1715
2560
|
);
|
|
2561
|
+
|
|
2562
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
2563
|
+
// The id survives the adoption — that is the whole point of updating in place.
|
|
2564
|
+
expect(parsed.draftId).toBe('r4303011157206680397');
|
|
2565
|
+
expect(parsed.threadingVerification).toMatchObject({
|
|
2566
|
+
requested: 'set',
|
|
2567
|
+
via: 'replyToThreadId',
|
|
2568
|
+
target: '19f856becba0661d',
|
|
2569
|
+
ok: true,
|
|
2570
|
+
effective: {
|
|
2571
|
+
threadId: '19f856becba0661d',
|
|
2572
|
+
inReplyTo: '<CAO@mail.gmail.com>',
|
|
2573
|
+
references: '<CAO@mail.gmail.com>',
|
|
2574
|
+
replyContextSource: 'caller',
|
|
2575
|
+
},
|
|
2576
|
+
});
|
|
2577
|
+
// Every update rewrites the body — gog requires --body. Say so.
|
|
2578
|
+
expect(parsed.threadingVerification.note).toMatch(/body/i);
|
|
2579
|
+
});
|
|
2580
|
+
|
|
2581
|
+
it('WARNS when the re-thread moved the draft but produced no reply headers', async () => {
|
|
2582
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(
|
|
2583
|
+
ACK({ inReplyTo: null, references: null, replyContextSource: null }),
|
|
2584
|
+
));
|
|
2585
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
2586
|
+
draftId: 'r4303011157206680397', subject: 'S', body: 'B', replyToThreadId: '19f856becba0661d',
|
|
2587
|
+
});
|
|
2588
|
+
const v = JSON.parse(result.content[0].text).threadingVerification;
|
|
2589
|
+
expect(v.ok).toBe(false);
|
|
2590
|
+
expect(v.note).toMatch(/WARNING/);
|
|
2591
|
+
expect(v.note).toMatch(/not arrive as a reply/i);
|
|
2592
|
+
// The dangerous half: it DID move threads, so it is not a no-op to ignore.
|
|
2593
|
+
expect(v.note).toContain('19f856becba0661d');
|
|
2594
|
+
});
|
|
2595
|
+
|
|
2596
|
+
it('anchors to a message id when both reply targets are given, and says which it used', async () => {
|
|
2597
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(ACK()));
|
|
2598
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
2599
|
+
draftId: 'd1', subject: 'S', body: 'B', replyToMessageId: 'mExplicit', replyToThreadId: 't1',
|
|
2600
|
+
});
|
|
2601
|
+
const args = vi.mocked(lib.runOrDiagnose).mock.calls[0]![0] as string[];
|
|
2602
|
+
expect(args).toContain('--reply-to-message-id=mExplicit');
|
|
2603
|
+
expect(args.some((a) => a.startsWith('--thread-id'))).toBe(false);
|
|
2604
|
+
expect(JSON.parse(result.content[0].text).threadingVerification).toMatchObject({
|
|
2605
|
+
requested: 'set', via: 'replyToMessageId', target: 'mExplicit', ok: true,
|
|
2606
|
+
});
|
|
2607
|
+
});
|
|
2608
|
+
|
|
2609
|
+
it('confirms clearReplyContext actually dropped the lineage, and that the threadId stayed', async () => {
|
|
2610
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(
|
|
2611
|
+
ACK({ inReplyTo: null, references: null, replyContextSource: null }),
|
|
2612
|
+
));
|
|
2613
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
2614
|
+
draftId: 'd1', subject: 'S', body: 'B', clearReplyContext: true,
|
|
2615
|
+
});
|
|
2616
|
+
const v = JSON.parse(result.content[0].text).threadingVerification;
|
|
2617
|
+
expect(v).toMatchObject({ requested: 'clear', ok: true });
|
|
2618
|
+
expect(v.note).not.toMatch(/WARNING/);
|
|
2619
|
+
expect(v.effective.threadId).toBe('19f856becba0661d');
|
|
2620
|
+
});
|
|
2621
|
+
|
|
2622
|
+
it('WARNS when clearReplyContext left reply headers in place', async () => {
|
|
2623
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(ACK()));
|
|
2624
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
2625
|
+
draftId: 'd1', subject: 'S', body: 'B', clearReplyContext: true,
|
|
2626
|
+
});
|
|
2627
|
+
const v = JSON.parse(result.content[0].text).threadingVerification;
|
|
2628
|
+
expect(v.ok).toBe(false);
|
|
2629
|
+
expect(v.note).toMatch(/WARNING/);
|
|
2630
|
+
expect(v.note).toContain('<CAO@mail.gmail.com>');
|
|
2631
|
+
});
|
|
2632
|
+
|
|
2633
|
+
it('adds nothing at all when no reply-context change was requested', async () => {
|
|
2634
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(ACK()));
|
|
2635
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
2636
|
+
draftId: 'd1', subject: 'S', body: 'B',
|
|
2637
|
+
});
|
|
2638
|
+
// Byte-identical passthrough: an update that changes no threading must not
|
|
2639
|
+
// acquire a new output shape.
|
|
2640
|
+
expect(result.content[0].text).toBe(ACK());
|
|
2641
|
+
});
|
|
2642
|
+
|
|
2643
|
+
it('carries the verification onto the returnFull re-fetch', async () => {
|
|
2644
|
+
vi.mocked(lib.runOrDiagnose)
|
|
2645
|
+
.mockResolvedValueOnce(rawTextResult(ACK()))
|
|
2646
|
+
.mockResolvedValueOnce(rawTextResult('{"draft":{"id":"r4303011157206680397"}}'));
|
|
2647
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
2648
|
+
draftId: 'r4303011157206680397', subject: 'S', body: 'B', replyToThreadId: '19f856becba0661d', returnFull: true,
|
|
2649
|
+
});
|
|
2650
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledTimes(2);
|
|
2651
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
2652
|
+
expect(parsed.draft.id).toBe('r4303011157206680397');
|
|
2653
|
+
expect(parsed.threadingVerification.ok).toBe(true);
|
|
2654
|
+
});
|
|
2655
|
+
|
|
2656
|
+
it('degrades to a prose note when the final result is not a JSON object', async () => {
|
|
2657
|
+
vi.mocked(lib.runOrDiagnose)
|
|
2658
|
+
.mockResolvedValueOnce(rawTextResult(ACK()))
|
|
2659
|
+
.mockResolvedValueOnce(rawTextResult('draft_id\tr4303011157206680397'));
|
|
2660
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
2661
|
+
draftId: 'r4303011157206680397', subject: 'S', body: 'B', replyToThreadId: '19f856becba0661d', returnFull: true,
|
|
2662
|
+
});
|
|
2663
|
+
expect(result.content[0].text).toMatch(/threadingVerification/);
|
|
2664
|
+
expect(result.content[1].text).toBe('draft_id\tr4303011157206680397');
|
|
2665
|
+
});
|
|
2666
|
+
|
|
2667
|
+
it('labels an unreported threadId rather than interpolating undefined', async () => {
|
|
2668
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(JSON.stringify({
|
|
2669
|
+
draftId: 'd1', inReplyTo: null, references: null, replyContextSource: null,
|
|
2670
|
+
})));
|
|
2671
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
2672
|
+
draftId: 'd1', subject: 'S', body: 'B', replyToThreadId: 't1',
|
|
2673
|
+
});
|
|
2674
|
+
const v = JSON.parse(result.content[0].text).threadingVerification;
|
|
2675
|
+
expect(v.effective.threadId).toBeUndefined();
|
|
2676
|
+
expect(v.note).toContain('(none reported)');
|
|
2677
|
+
expect(v.note).not.toContain('undefined');
|
|
2678
|
+
});
|
|
2679
|
+
|
|
2680
|
+
it('says nothing when the write itself failed (no JSON ack to verify against)', async () => {
|
|
2681
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(errorResult('Error: usage: --subject required'));
|
|
2682
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
2683
|
+
draftId: 'd1', subject: 'S', body: 'B', replyToThreadId: 't1',
|
|
2684
|
+
});
|
|
2685
|
+
expect(result.content[0].text).toContain('usage: --subject required');
|
|
2686
|
+
expect(result.content[0].text).not.toContain('threadingVerification');
|
|
2687
|
+
});
|
|
2688
|
+
});
|
|
2689
|
+
|
|
2690
|
+
// ===========================================================================
|
|
2691
|
+
// REQUIREMENT 1 — a 404 on a draft id is a FORK REPORT, not a bare notFound.
|
|
2692
|
+
//
|
|
2693
|
+
// A draft created here and then edited in a mail client is not updated in
|
|
2694
|
+
// place: the client writes a NEW draft and abandons the original, so the
|
|
2695
|
+
// original id stops resolving and `gog gmail drafts update` returns
|
|
2696
|
+
// `Google API error (404 notFound)` with nothing to say it was replaced.
|
|
2697
|
+
//
|
|
2698
|
+
// HAZARD A governs the shape of the answer: with the original unfetchable
|
|
2699
|
+
// there is nothing left to establish LINEAGE against, so this report may never
|
|
2700
|
+
// name a replacement. It lists what exists and hands the caller the tool that
|
|
2701
|
+
// can decide (gog_gmail_drafts_diff, on a named pair).
|
|
2702
|
+
// HAZARD B governs its cost: at most 2 extra gog invocations, constant, and
|
|
2703
|
+
// only on a call that has ALREADY failed.
|
|
2704
|
+
// ===========================================================================
|
|
2705
|
+
describe('gog_gmail_drafts_update — DRAFT_FORKED on 404', () => {
|
|
2706
|
+
const NOT_FOUND = 'Error: Google API error (404 notFound): Requested entity was not found.';
|
|
2707
|
+
const LIST = JSON.stringify({
|
|
2708
|
+
drafts: [
|
|
2709
|
+
{ id: 's:14092347734530621658', messageId: '19fe8a673d1e5f21', threadId: '19fe8a673d1e5f21' },
|
|
2710
|
+
{ id: 'r-457330811034304502', messageId: 'aaa', threadId: 'bbb' },
|
|
2711
|
+
// gog declares every field `omitempty`, so a draft can arrive with none
|
|
2712
|
+
// of them. It must still be listed, not crash the report.
|
|
2713
|
+
{},
|
|
2714
|
+
],
|
|
2715
|
+
});
|
|
2716
|
+
const SEARCH = JSON.stringify({
|
|
2717
|
+
messages: [
|
|
2718
|
+
{ id: '19fe8a673d1e5f21', subject: 'Re: pickup schedule', from: 'me@x.com', internalDateIso: '2026-08-09T10:00:00Z' },
|
|
2719
|
+
{ id: 'aaa', subject: 'Unrelated note to the plumber', from: 'me@x.com', internalDateIso: '2026-08-01T09:00:00Z' },
|
|
2720
|
+
{ subject: 'a search hit with no id joins to nothing' },
|
|
2721
|
+
],
|
|
2722
|
+
});
|
|
2723
|
+
|
|
2724
|
+
function stub404(): void {
|
|
2725
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(errorResult(NOT_FOUND));
|
|
2726
|
+
vi.mocked(lib.run).mockImplementation(async (args) => {
|
|
2727
|
+
const argv = args as string[];
|
|
2728
|
+
if (argv[1] === 'drafts' && argv[2] === 'list') return LIST;
|
|
2729
|
+
if (argv[1] === 'messages' && argv[2] === 'search') return SEARCH;
|
|
2730
|
+
throw new Error(`unexpected gog call: ${argv.join(' ')}`);
|
|
2731
|
+
});
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
it('explains the 404 as a possible client-side fork and keeps gog\'s own error', async () => {
|
|
2735
|
+
stub404();
|
|
2736
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
2737
|
+
draftId: 'r4303011157206680397', subject: 'S', body: 'B',
|
|
2738
|
+
});
|
|
2739
|
+
expect(result.isError).toBe(true);
|
|
2740
|
+
const text = result.content[0].text as string;
|
|
2741
|
+
expect(text).toContain('DRAFT_FORKED');
|
|
2742
|
+
expect(text).toContain('r4303011157206680397');
|
|
2743
|
+
// gog's own words survive verbatim — the diagnosis is added, never swapped in.
|
|
2744
|
+
expect(text).toContain('Google API error (404 notFound)');
|
|
2745
|
+
const parsed = JSON.parse(text.slice(text.indexOf('{')));
|
|
2746
|
+
expect(parsed.code).toBe('DRAFT_FORKED');
|
|
2747
|
+
expect(parsed.currentDrafts.map((d: { id?: string; origin: string; subject?: string }) => [d.id, d.origin, d.subject])).toEqual([
|
|
2748
|
+
['s:14092347734530621658', 'non-api', 'Re: pickup schedule'],
|
|
2749
|
+
['r-457330811034304502', 'api', 'Unrelated note to the plumber'],
|
|
2750
|
+
// An id-less draft is reported as `api` — the ONLY thing `non-api` may
|
|
2751
|
+
// ever mean is a literal `s:` prefix, so an absent id must not claim one.
|
|
2752
|
+
[undefined, 'api', undefined],
|
|
2753
|
+
]);
|
|
2754
|
+
expect(parsed.nextSteps.join(' ')).toContain('gog_gmail_drafts_diff');
|
|
2755
|
+
});
|
|
2756
|
+
|
|
2757
|
+
it('names NO replacement, even when a newer non-api draft is sitting right there', async () => {
|
|
2758
|
+
stub404();
|
|
2759
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
2760
|
+
draftId: 'r4303011157206680397', subject: 'S', body: 'B',
|
|
2761
|
+
});
|
|
2762
|
+
const text = result.content[0].text as string;
|
|
2763
|
+
const parsed = JSON.parse(text.slice(text.indexOf('{')));
|
|
2764
|
+
// Hazard A: the 404'd draft cannot be fetched, so no lineage signal can
|
|
2765
|
+
// exist and no pairing verdict is possible. Not "candidate" — none.
|
|
2766
|
+
expect(parsed.forkClaim).toBeNull();
|
|
2767
|
+
expect(text).not.toContain('confirmed');
|
|
2768
|
+
expect(text).not.toContain('apple-mail');
|
|
2769
|
+
for (const d of parsed.currentDrafts) expect(d).not.toHaveProperty('verdict');
|
|
2770
|
+
// The list is ordering, not evidence, and it says so.
|
|
2771
|
+
expect(parsed.forkClaimNote).toMatch(/ordering is presentation, not evidence/i);
|
|
2772
|
+
// And the 404 has innocent explanations too.
|
|
2773
|
+
expect(parsed.otherExplanations.join(' ')).toMatch(/deleted|sent/i);
|
|
2774
|
+
});
|
|
2775
|
+
|
|
2776
|
+
it('spends at most two extra gog invocations, both constant in the number of drafts', async () => {
|
|
2777
|
+
stub404();
|
|
2778
|
+
await harness.callTool('gog_gmail_drafts_update', { draftId: 'r43', subject: 'S', body: 'B' });
|
|
2779
|
+
expect(vi.mocked(lib.run).mock.calls.map((c) => (c[0] as string[]).slice(0, 3))).toEqual([
|
|
2780
|
+
['gmail', 'drafts', 'list'],
|
|
2781
|
+
['gmail', 'messages', 'search'],
|
|
2782
|
+
]);
|
|
2783
|
+
expect(vi.mocked(lib.run)).toHaveBeenCalledTimes(2);
|
|
2784
|
+
});
|
|
2785
|
+
|
|
2786
|
+
it('leaves a non-404 failure completely alone, and spends nothing', async () => {
|
|
2787
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(errorResult('Error: Google API error (403 forbidden): insufficient scope'));
|
|
2788
|
+
const result = await harness.callTool('gog_gmail_drafts_update', { draftId: 'd1', subject: 'S', body: 'B' });
|
|
2789
|
+
expect(result.content[0].text).toBe('Error: Google API error (403 forbidden): insufficient scope');
|
|
2790
|
+
expect(lib.run).not.toHaveBeenCalled();
|
|
2791
|
+
});
|
|
2792
|
+
|
|
2793
|
+
it('does not fire on a SUCCESSFUL result that merely mentions 404', async () => {
|
|
2794
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult('{"draftId":"d1","message":{"snippet":"the 404 not found page"}}'));
|
|
2795
|
+
const result = await harness.callTool('gog_gmail_drafts_update', { draftId: 'd1', subject: 'S', body: 'B' });
|
|
2796
|
+
expect(result.content[0].text).toContain('the 404 not found page');
|
|
2797
|
+
expect(result.content[0].text).not.toContain('DRAFT_FORKED');
|
|
2798
|
+
expect(lib.run).not.toHaveBeenCalled();
|
|
2799
|
+
});
|
|
2800
|
+
|
|
2801
|
+
it('still reports the fork explanation when the draft listing itself fails', async () => {
|
|
2802
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(errorResult(NOT_FOUND));
|
|
2803
|
+
vi.mocked(lib.run).mockRejectedValue(new Error('gog timed out after 30s'));
|
|
2804
|
+
const result = await harness.callTool('gog_gmail_drafts_update', { draftId: 'd1', subject: 'S', body: 'B' });
|
|
2805
|
+
const text = result.content[0].text as string;
|
|
2806
|
+
expect(text).toContain('DRAFT_FORKED');
|
|
2807
|
+
const parsed = JSON.parse(text.slice(text.indexOf('{')));
|
|
2808
|
+
expect(parsed.currentDrafts).toBeUndefined();
|
|
2809
|
+
expect(parsed.currentDraftsUnavailable).toContain('gog timed out');
|
|
2810
|
+
expect(vi.mocked(lib.run)).toHaveBeenCalledTimes(1);
|
|
2811
|
+
});
|
|
2812
|
+
|
|
2813
|
+
it('keeps the free tier-0 fields when only the enrichment search fails', async () => {
|
|
2814
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(errorResult(NOT_FOUND));
|
|
2815
|
+
vi.mocked(lib.run).mockImplementation(async (args) => {
|
|
2816
|
+
if ((args as string[])[2] === 'list') return LIST;
|
|
2817
|
+
throw new Error('search exploded');
|
|
2818
|
+
});
|
|
2819
|
+
const result = await harness.callTool('gog_gmail_drafts_update', { draftId: 'd1', subject: 'S', body: 'B' });
|
|
2820
|
+
const text = result.content[0].text as string;
|
|
2821
|
+
const parsed = JSON.parse(text.slice(text.indexOf('{')));
|
|
2822
|
+
expect(parsed.currentDrafts).toHaveLength(3);
|
|
2823
|
+
expect(parsed.currentDrafts[0].origin).toBe('non-api');
|
|
2824
|
+
expect(parsed.currentDrafts[0].subject).toBeUndefined();
|
|
2825
|
+
expect(parsed.enrichmentNote).toContain('search exploded');
|
|
2826
|
+
});
|
|
2827
|
+
|
|
2828
|
+
it('treats a listing without a drafts array as unavailable rather than empty', async () => {
|
|
2829
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(errorResult(NOT_FOUND));
|
|
2830
|
+
// Parses as JSON, but carries no drafts array — a different failure from
|
|
2831
|
+
// gog's plain-text "No drafts", and it must not read as "you have none".
|
|
2832
|
+
vi.mocked(lib.run).mockResolvedValue('{"nextPageToken":"tok"}');
|
|
2833
|
+
const result = await harness.callTool('gog_gmail_drafts_update', { draftId: 'd1', subject: 'S', body: 'B' });
|
|
2834
|
+
const parsed = JSON.parse((result.content[0].text as string).slice((result.content[0].text as string).indexOf('{')));
|
|
2835
|
+
expect(parsed.currentDraftsUnavailable).toContain('no drafts array');
|
|
2836
|
+
});
|
|
2837
|
+
|
|
2838
|
+
it('fires for gog_gmail_drafts_send too — the draft you meant to send is gone', async () => {
|
|
2839
|
+
stub404();
|
|
2840
|
+
const result = await harness.callTool('gog_gmail_drafts_send', { draftId: 'r4303011157206680397' });
|
|
2841
|
+
expect(result.isError).toBe(true);
|
|
2842
|
+
const text = result.content[0].text as string;
|
|
2843
|
+
expect(text).toContain('DRAFT_FORKED');
|
|
2844
|
+
expect(text).toContain('gog_gmail_drafts_send');
|
|
2845
|
+
expect(vi.mocked(lib.run)).toHaveBeenCalledTimes(2);
|
|
2846
|
+
});
|
|
2847
|
+
|
|
2848
|
+
// `gmail drafts update` resolves THREE Google entities and gog renders all
|
|
2849
|
+
// three 404s identically: the draft (Users.Drafts.Get/Update), the thread
|
|
2850
|
+
// behind --thread-id (Users.Threads.Get) and the message behind
|
|
2851
|
+
// --reply-to-message-id (Users.Messages.Get). Claiming DRAFT_FORKED on a
|
|
2852
|
+
// stale THREAD id — while listing that same draft id under currentDrafts —
|
|
2853
|
+
// sends the caller hunting for a replacement draft that does not exist.
|
|
2854
|
+
it('does not call it a fork when the draft is still listed: the 404 came from the reply target', async () => {
|
|
2855
|
+
stub404();
|
|
2856
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
2857
|
+
draftId: 'r-457330811034304502', subject: 'S', body: 'B',
|
|
2858
|
+
replyToThreadId: 'THREAD-THAT-DOES-NOT-EXIST',
|
|
2859
|
+
});
|
|
2860
|
+
expect(result.isError).toBe(true);
|
|
2861
|
+
const text = result.content[0].text as string;
|
|
2862
|
+
expect(text).not.toContain('DRAFT_FORKED');
|
|
2863
|
+
const parsed = JSON.parse(text.slice(text.indexOf('{')));
|
|
2864
|
+
expect(parsed.code).toBe('GOOGLE_404_NOT_THE_DRAFT');
|
|
2865
|
+
expect(parsed.replyTarget).toEqual({ via: 'replyToThreadId', target: 'THREAD-THAT-DOES-NOT-EXIST' });
|
|
2866
|
+
// The payload may not contradict itself: it lists the draft AND says it is gone.
|
|
2867
|
+
expect(parsed.currentDrafts.map((d: { id?: string }) => d.id)).toContain('r-457330811034304502');
|
|
2868
|
+
expect(parsed.whatHappened).not.toMatch(/no longer has a draft|has no draft under this id/i);
|
|
2869
|
+
expect(parsed.whatHappened).toMatch(/still exists|still listed/i);
|
|
2870
|
+
expect(parsed.nextSteps.join(' ')).toMatch(/thread id/i);
|
|
2871
|
+
// gog's own words survive, and the cost cap is unchanged.
|
|
2872
|
+
expect(text).toContain('Google API error (404 notFound)');
|
|
2873
|
+
expect(vi.mocked(lib.run)).toHaveBeenCalledTimes(2);
|
|
2874
|
+
});
|
|
2875
|
+
|
|
2876
|
+
it('says the same for a send whose 404 cannot be about the draft id either', async () => {
|
|
2877
|
+
stub404();
|
|
2878
|
+
const result = await harness.callTool('gog_gmail_drafts_send', { draftId: 's:14092347734530621658' });
|
|
2879
|
+
const text = result.content[0].text as string;
|
|
2880
|
+
expect(text).not.toContain('DRAFT_FORKED');
|
|
2881
|
+
const parsed = JSON.parse(text.slice(text.indexOf('{')));
|
|
2882
|
+
expect(parsed.code).toBe('GOOGLE_404_NOT_THE_DRAFT');
|
|
2883
|
+
expect(parsed.replyTarget).toBeNull();
|
|
2884
|
+
expect(parsed.raceNote).toMatch(/AFTER the failure/i);
|
|
2885
|
+
});
|
|
2886
|
+
|
|
2887
|
+
it('recognises a 404 that gog reported without a reason word', async () => {
|
|
2888
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(errorResult('Error: Google API error (404): Requested entity was not found.'));
|
|
2889
|
+
vi.mocked(lib.run).mockResolvedValue(LIST);
|
|
2890
|
+
const result = await harness.callTool('gog_gmail_drafts_send', { draftId: 'd1' });
|
|
2891
|
+
expect(result.content[0].text).toContain('DRAFT_FORKED');
|
|
2892
|
+
});
|
|
2893
|
+
});
|
|
2894
|
+
|
|
2895
|
+
// ===========================================================================
|
|
2896
|
+
// REQUIREMENT 5 — DO NOT LET AN ADOPTION SILENTLY DROP THE OTHER COPY'S TEXT.
|
|
2897
|
+
//
|
|
2898
|
+
// `draftComposeInput.validate()` (gmail_drafts.go:321) hard-requires a body on
|
|
2899
|
+
// every update: "required: --body, --body-file, --body-html, or
|
|
2900
|
+
// --body-html-file". There is no header-only edit, so re-threading a mail
|
|
2901
|
+
// client's replacement back onto the original conversation ALWAYS rewrites the
|
|
2902
|
+
// whole body — the exact operation that drops the paragraph living only in the
|
|
2903
|
+
// sibling copy. In the observed case neither copy was a superset.
|
|
2904
|
+
//
|
|
2905
|
+
// COST (hazard B): the check is opt-in and costs exactly ONE extra gog
|
|
2906
|
+
// invocation, on a NAMED sibling. It never scans, and with the param absent it
|
|
2907
|
+
// spends nothing and changes no argv.
|
|
2908
|
+
//
|
|
2909
|
+
// CLAIMS (hazard A): it compares two bodies. It never says one draft replaced
|
|
2910
|
+
// the other — that verdict needs gog_gmail_drafts_diff.
|
|
2911
|
+
// ===========================================================================
|
|
2912
|
+
describe('gog_gmail_drafts_update — content-loss check', () => {
|
|
2913
|
+
const b64 = (s: string) => Buffer.from(s, 'utf8').toString('base64url');
|
|
2914
|
+
const siblingGet = (body: string): string => JSON.stringify({
|
|
2915
|
+
draft: { id: 's:14092347734530621658', message: { id: 'm2', threadId: 'm2', payload: { mimeType: 'text/plain', body: { data: b64(body) } } } },
|
|
2916
|
+
});
|
|
2917
|
+
const ACK = '{"draftId":"r4303011157206680397","threadId":"19f856becba0661d","inReplyTo":"<orig@mail.gmail.com>"}';
|
|
2918
|
+
|
|
2919
|
+
// The observed divergence: the mail-client copy kept a paragraph the merged
|
|
2920
|
+
// body forgot.
|
|
2921
|
+
const SIBLING_BODY = 'Thanks for the note.\nI can do the 14th.\nTHE PARAGRAPH ONLY APPLE HAS.\nBest, Chris';
|
|
2922
|
+
|
|
2923
|
+
it('spends nothing and changes no argv when no sibling is named', async () => {
|
|
2924
|
+
await harness.callTool('gog_gmail_drafts_update', { draftId: 'd1', subject: 'S', body: 'B' });
|
|
2925
|
+
expect(lib.run).not.toHaveBeenCalled();
|
|
2926
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
|
|
2927
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
2928
|
+
['gmail', 'drafts', 'update', 'd1', '--subject=S', '--body=B', '--auto-from-addressed-alias=false'],
|
|
2929
|
+
{ account: undefined },
|
|
2930
|
+
);
|
|
2931
|
+
});
|
|
2932
|
+
|
|
2933
|
+
it('costs exactly one extra invocation, and reads the sibling BEFORE writing', async () => {
|
|
2934
|
+
vi.mocked(lib.run).mockResolvedValue(siblingGet(SIBLING_BODY));
|
|
2935
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(ACK));
|
|
2936
|
+
await harness.callTool('gog_gmail_drafts_update', {
|
|
2937
|
+
draftId: 'r4303011157206680397', subject: 'S', body: SIBLING_BODY,
|
|
2938
|
+
forkSiblingDraftId: 's:14092347734530621658',
|
|
2939
|
+
});
|
|
2940
|
+
expect(lib.run).toHaveBeenCalledTimes(1);
|
|
2941
|
+
expect(lib.run).toHaveBeenCalledWith(
|
|
2942
|
+
['gmail', 'drafts', 'get', 's:14092347734530621658', '--use-indexed-attachment-ids=false'],
|
|
2943
|
+
{ account: undefined },
|
|
2944
|
+
);
|
|
2945
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
|
|
2946
|
+
// Reading after the write would be a report on damage already done.
|
|
2947
|
+
expect(vi.mocked(lib.run).mock.invocationCallOrder[0])
|
|
2948
|
+
.toBeLessThan(vi.mocked(lib.runOrDiagnose).mock.invocationCallOrder[0]!);
|
|
2949
|
+
});
|
|
2950
|
+
|
|
2951
|
+
it('writes, and reports the check, when the new body keeps every sibling line', async () => {
|
|
2952
|
+
vi.mocked(lib.run).mockResolvedValue(siblingGet(SIBLING_BODY));
|
|
2953
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(ACK));
|
|
2954
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
2955
|
+
draftId: 'r4303011157206680397', subject: 'S', body: `${SIBLING_BODY}\nplus a line the Gmail copy added`,
|
|
2956
|
+
forkSiblingDraftId: 's:14092347734530621658', replyToThreadId: '19f856becba0661d',
|
|
2957
|
+
});
|
|
2958
|
+
expect(result.isError).toBeFalsy();
|
|
2959
|
+
const parsed = JSON.parse(result.content[0].text as string);
|
|
2960
|
+
expect(parsed.draftId).toBe('r4303011157206680397');
|
|
2961
|
+
expect(parsed.contentLossCheck).toMatchObject({
|
|
2962
|
+
siblingDraftId: 's:14092347734530621658', status: 'clean', linesOnlyInSibling: [],
|
|
2963
|
+
});
|
|
2964
|
+
// The adoption still reports what it actually threaded.
|
|
2965
|
+
expect(parsed.threadingVerification.ok).toBe(true);
|
|
2966
|
+
});
|
|
2967
|
+
|
|
2968
|
+
it('REFUSES the write when the body would drop a line the sibling holds — and writes nothing', async () => {
|
|
2969
|
+
vi.mocked(lib.run).mockResolvedValue(siblingGet(SIBLING_BODY));
|
|
2970
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
2971
|
+
draftId: 'r4303011157206680397', subject: 'S',
|
|
2972
|
+
body: 'Thanks for the note.\nI can do the 14th.\nBest, Chris',
|
|
2973
|
+
forkSiblingDraftId: 's:14092347734530621658', replyToThreadId: '19f856becba0661d',
|
|
2974
|
+
});
|
|
2975
|
+
expect(result.isError).toBe(true);
|
|
2976
|
+
// THE point: the draft is untouched.
|
|
2977
|
+
expect(lib.runOrDiagnose).not.toHaveBeenCalled();
|
|
2978
|
+
const text = result.content[0].text as string;
|
|
2979
|
+
expect(text).toContain('DRAFT_CONTENT_LOSS');
|
|
2980
|
+
expect(text).toContain('THE PARAGRAPH ONLY APPLE HAS.');
|
|
2981
|
+
expect(text).toMatch(/nothing was written/i);
|
|
2982
|
+
const parsed = JSON.parse(text.slice(text.indexOf('{')));
|
|
2983
|
+
expect(parsed.contentLossCheck.status).toBe('would-lose');
|
|
2984
|
+
expect(parsed.contentLossCheck.linesOnlyInSiblingCount).toBe(1);
|
|
2985
|
+
});
|
|
2986
|
+
|
|
2987
|
+
it('writes anyway, with the loss spelled out, when acceptContentLoss is set', async () => {
|
|
2988
|
+
vi.mocked(lib.run).mockResolvedValue(siblingGet(SIBLING_BODY));
|
|
2989
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(ACK));
|
|
2990
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
2991
|
+
draftId: 'r4303011157206680397', subject: 'S', body: 'Thanks for the note.\nBest, Chris',
|
|
2992
|
+
forkSiblingDraftId: 's:14092347734530621658', acceptContentLoss: true,
|
|
2993
|
+
});
|
|
2994
|
+
expect(result.isError).toBeFalsy();
|
|
2995
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledTimes(1);
|
|
2996
|
+
const check = JSON.parse(result.content[0].text as string).contentLossCheck;
|
|
2997
|
+
expect(check.status).toBe('would-lose');
|
|
2998
|
+
expect(check.acknowledged).toBe(true);
|
|
2999
|
+
expect(check.linesOnlyInSibling).toContain('THE PARAGRAPH ONLY APPLE HAS.');
|
|
3000
|
+
});
|
|
3001
|
+
|
|
3002
|
+
it('refuses when the sibling cannot be fetched — an unrun check is not a passed check', async () => {
|
|
3003
|
+
vi.mocked(lib.run).mockRejectedValue(new Error('Google API error (404 notFound): Requested entity was not found.'));
|
|
3004
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
3005
|
+
draftId: 'd1', subject: 'S', body: 'B', forkSiblingDraftId: 's:gone',
|
|
3006
|
+
});
|
|
3007
|
+
expect(result.isError).toBe(true);
|
|
3008
|
+
expect(lib.runOrDiagnose).not.toHaveBeenCalled();
|
|
3009
|
+
const text = result.content[0].text as string;
|
|
3010
|
+
expect(text).toContain('DRAFT_CONTENT_LOSS_UNCHECKED');
|
|
3011
|
+
expect(text).toContain('404 notFound');
|
|
3012
|
+
});
|
|
3013
|
+
|
|
3014
|
+
it('refuses when the sibling fetch returned something with no readable body', async () => {
|
|
3015
|
+
vi.mocked(lib.run).mockResolvedValue('not json at all');
|
|
3016
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
3017
|
+
draftId: 'd1', subject: 'S', body: 'B', forkSiblingDraftId: 's:weird',
|
|
3018
|
+
});
|
|
3019
|
+
expect(result.content[0].text).toContain('DRAFT_CONTENT_LOSS_UNCHECKED');
|
|
3020
|
+
expect(lib.runOrDiagnose).not.toHaveBeenCalled();
|
|
3021
|
+
});
|
|
3022
|
+
|
|
3023
|
+
it('refuses when the sibling parsed but carried no body text', async () => {
|
|
3024
|
+
vi.mocked(lib.run).mockResolvedValue(JSON.stringify({ draft: { message: { payload: { mimeType: 'text/plain' } } } }));
|
|
3025
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
3026
|
+
draftId: 'd1', subject: 'S', body: 'B', forkSiblingDraftId: 's:empty',
|
|
3027
|
+
});
|
|
3028
|
+
expect(result.content[0].text).toContain('DRAFT_CONTENT_LOSS_UNCHECKED');
|
|
3029
|
+
expect(lib.runOrDiagnose).not.toHaveBeenCalled();
|
|
3030
|
+
});
|
|
3031
|
+
|
|
3032
|
+
it('lets acceptContentLoss override an unrunnable check too', async () => {
|
|
3033
|
+
vi.mocked(lib.run).mockRejectedValue(new Error('boom'));
|
|
3034
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(ACK));
|
|
3035
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
3036
|
+
draftId: 'd1', subject: 'S', body: 'B', forkSiblingDraftId: 's:gone', acceptContentLoss: true,
|
|
3037
|
+
});
|
|
3038
|
+
expect(result.isError).toBeFalsy();
|
|
3039
|
+
expect(JSON.parse(result.content[0].text as string).contentLossCheck.status).toBe('unchecked');
|
|
3040
|
+
});
|
|
3041
|
+
|
|
3042
|
+
it('degrades to a prose note when the write result is not a JSON object', async () => {
|
|
3043
|
+
vi.mocked(lib.run).mockResolvedValue(siblingGet('kept'));
|
|
3044
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult('Draft updated.'));
|
|
3045
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
3046
|
+
draftId: 'd1', subject: 'S', body: 'kept', forkSiblingDraftId: 's:1',
|
|
3047
|
+
});
|
|
3048
|
+
// The note is prepended; gog's own output is kept verbatim beneath it.
|
|
3049
|
+
expect(result.content[0].text).toContain('contentLossCheck:');
|
|
3050
|
+
expect(result.content[1].text).toBe('Draft updated.');
|
|
3051
|
+
});
|
|
3052
|
+
|
|
3053
|
+
// HAZARD A. The caller names the sibling; this server does not decide the
|
|
3054
|
+
// pair. Two unrelated drafts produce a total-divergence report, which is the
|
|
3055
|
+
// same shape a real fork produces — so the answer must never read as a
|
|
3056
|
+
// pairing verdict, and must point at the one tool that can issue one.
|
|
3057
|
+
it('never claims the sibling is a fork, however the bodies compare', async () => {
|
|
3058
|
+
vi.mocked(lib.run).mockResolvedValue(siblingGet('dentist appointment friday\ninsurance card is in the drawer'));
|
|
3059
|
+
const result = await harness.callTool('gog_gmail_drafts_update', {
|
|
3060
|
+
draftId: 'r4303011157206680397', subject: 'Re: August schedule', body: 'Entirely unrelated invoice text.',
|
|
3061
|
+
forkSiblingDraftId: 's:unrelated',
|
|
3062
|
+
});
|
|
3063
|
+
const text = result.content[0].text as string;
|
|
3064
|
+
const parsed = JSON.parse(text.slice(text.indexOf('{')));
|
|
3065
|
+
expect(parsed.contentLossCheck.forkClaim).toBeNull();
|
|
3066
|
+
expect(parsed.contentLossCheck.forkClaimNote).toContain('gog_gmail_drafts_diff');
|
|
3067
|
+
expect(text).not.toMatch(/\bconfirmed\b/);
|
|
3068
|
+
expect(text).not.toMatch(/replaced draft|is a fork of/i);
|
|
3069
|
+
});
|
|
3070
|
+
|
|
3071
|
+
it('is not offered on drafts_create — there is no earlier body to lose', async () => {
|
|
3072
|
+
const { tools } = await harness.client.listTools();
|
|
3073
|
+
const create = tools.find((t) => t.name === 'gog_gmail_drafts_create');
|
|
3074
|
+
const update = tools.find((t) => t.name === 'gog_gmail_drafts_update');
|
|
3075
|
+
expect(Object.keys(create!.inputSchema.properties ?? {})).not.toContain('forkSiblingDraftId');
|
|
3076
|
+
expect(Object.keys(update!.inputSchema.properties ?? {})).toContain('forkSiblingDraftId');
|
|
3077
|
+
});
|
|
3078
|
+
});
|
|
3079
|
+
|
|
3080
|
+
// ===========================================================================
|
|
3081
|
+
// CLAIMS CORRECTNESS — three things the reports asserted on evidence they did
|
|
3082
|
+
// not have. Each of these is a sentence a caller acts on: "the update WAS
|
|
3083
|
+
// written", "draft X no longer resolves", "these two drafts are the same
|
|
3084
|
+
// message". Getting any of them wrong costs real text in a legal-adjacent
|
|
3085
|
+
// correspondence, so each is pinned to the evidence that actually exists.
|
|
3086
|
+
// ===========================================================================
|
|
3087
|
+
|
|
3088
|
+
// HAZARD A, through the real RPC path: two genuinely unrelated Apple Mail
|
|
3089
|
+
// drafts — different subjects, different threadIds, no shared reply root — that
|
|
3090
|
+
// agree on nothing but `Hi Jennifer,` / `Thanks,` / `Chris` /
|
|
3091
|
+
// `Sent from my iPhone`. That is 4 lines and 43 characters of pure apparatus,
|
|
3092
|
+
// and it used to clear all three lineage minimums and come back `confirmed`.
|
|
3093
|
+
describe('gog_gmail_drafts_diff — boilerplate is never lineage', () => {
|
|
3094
|
+
const b64 = (s: string) => Buffer.from(s, 'utf8').toString('base64url');
|
|
3095
|
+
const appleDraft = (o: { draftId: string; messageId: string; subject: string; date: string; sentence: string }) =>
|
|
3096
|
+
JSON.stringify({
|
|
3097
|
+
draft: {
|
|
3098
|
+
id: o.draftId,
|
|
3099
|
+
message: {
|
|
3100
|
+
id: o.messageId, threadId: o.messageId, internalDate: o.date,
|
|
3101
|
+
payload: {
|
|
3102
|
+
mimeType: 'text/plain',
|
|
3103
|
+
headers: [
|
|
3104
|
+
{ name: 'From', value: 'Chris Hall <chris@x.com>' },
|
|
3105
|
+
{ name: 'Subject', value: o.subject },
|
|
3106
|
+
{ name: 'Message-Id', value: `<${o.messageId}@apple.com>` },
|
|
3107
|
+
{ name: 'X-Universally-Unique-Identifier', value: o.messageId.toUpperCase() },
|
|
3108
|
+
],
|
|
3109
|
+
body: { data: b64(`Hi Jennifer,\n\n${o.sentence}\n\nThanks,\nChris\n\nSent from my iPhone`) },
|
|
3110
|
+
},
|
|
3111
|
+
},
|
|
3112
|
+
},
|
|
3113
|
+
});
|
|
3114
|
+
|
|
3115
|
+
it('does not pair two unrelated notes that share only the greeting and the signature', async () => {
|
|
3116
|
+
const A = appleDraft({ draftId: 'rOLD', messageId: 'aaa1', subject: 'Tuesday pickup', date: '1000', sentence: 'Tuesday pickup at 5 works for me.' });
|
|
3117
|
+
const B = appleDraft({ draftId: 's:NEW', messageId: 'bbb2', subject: 'Orthodontist invoice', date: '2000', sentence: 'I paid the orthodontist invoice today.' });
|
|
3118
|
+
vi.mocked(lib.run).mockImplementation(async (args) => ({ rOLD: A, 's:NEW': B }[(args as string[])[3]!]!));
|
|
3119
|
+
const result = await harness.callTool('gog_gmail_drafts_diff', { draftIdA: 'rOLD', draftIdB: 's:NEW' });
|
|
3120
|
+
const text = result.content[0].text as string;
|
|
3121
|
+
const parsed = JSON.parse(text);
|
|
3122
|
+
expect(parsed.forkPairing.verdict).toBe('none');
|
|
3123
|
+
expect(parsed.forkPairing.bodyAgreement.meetsThreshold).toBe(false);
|
|
3124
|
+
expect(parsed.forkPairing.bodyAgreement.sharedAuthoredLines).toBe(0);
|
|
3125
|
+
expect(parsed.forkPairing.bodyAgreement.sharedAuthoredChars).toBe(0);
|
|
3126
|
+
expect(parsed.forkPairing.bodyAgreement.boilerplateLinesIgnored).toEqual({ original: 4, candidate: 4 });
|
|
3127
|
+
expect(text).not.toContain('replaced draft');
|
|
3128
|
+
expect(text).not.toContain('All four signals are present');
|
|
3129
|
+
// The divergence report is a DIFFERENT question and stays honest: the
|
|
3130
|
+
// apparatus really is text both copies hold.
|
|
3131
|
+
expect(parsed.bodyDiff.sharedLineCount).toBe(4);
|
|
3132
|
+
});
|
|
3133
|
+
});
|
|
3134
|
+
|
|
3135
|
+
describe('gog_gmail_drafts_diff — truncation reports its magnitude, and the cap is validated', () => {
|
|
3136
|
+
const b64 = (s: string) => Buffer.from(s, 'utf8').toString('base64url');
|
|
3137
|
+
const body = (tag: string, n: number) => JSON.stringify({
|
|
3138
|
+
draft: { id: tag, message: { id: tag, threadId: tag, internalDate: '1', payload: { mimeType: 'text/plain', headers: [], body: { data: b64(Array.from({ length: n }, (_, i) => `${tag}-${i}`).join('\n')) } } } },
|
|
3139
|
+
});
|
|
3140
|
+
|
|
3141
|
+
beforeEach(() => {
|
|
3142
|
+
vi.mocked(lib.run).mockImplementation(async (args) => body((args as string[])[3]!, 500));
|
|
3143
|
+
});
|
|
3144
|
+
|
|
3145
|
+
it('says how many lines each side actually held, not just that it truncated', async () => {
|
|
3146
|
+
const parsed = JSON.parse((await harness.callTool('gog_gmail_drafts_diff', { draftIdA: 'a', draftIdB: 'b' })).content[0].text);
|
|
3147
|
+
expect(parsed.bodyDiff.onlyInA).toHaveLength(200);
|
|
3148
|
+
expect(parsed.bodyDiff.truncated).toBe(true);
|
|
3149
|
+
// Without these the caller cannot tell 200-of-201 from 200-of-500, and the
|
|
3150
|
+
// whole point of the diff is deciding what to merge before an overwrite.
|
|
3151
|
+
expect(parsed.bodyDiff.onlyInACount).toBe(500);
|
|
3152
|
+
expect(parsed.bodyDiff.onlyInBCount).toBe(500);
|
|
3153
|
+
expect(parsed.bodyDiff.note).toContain('500');
|
|
3154
|
+
});
|
|
3155
|
+
|
|
3156
|
+
it('rejects maxDiffLines 0 and negatives instead of printing an empty divergence report', async () => {
|
|
3157
|
+
const zero = await harness.callTool('gog_gmail_drafts_diff', { draftIdA: 'a', draftIdB: 'b', maxDiffLines: 0 });
|
|
3158
|
+
expect(zero.isError).toBe(true);
|
|
3159
|
+
const negative = await harness.callTool('gog_gmail_drafts_diff', { draftIdA: 'a', draftIdB: 'b', maxDiffLines: -1 });
|
|
3160
|
+
expect(negative.isError).toBe(true);
|
|
3161
|
+
const fractional = await harness.callTool('gog_gmail_drafts_diff', { draftIdA: 'a', draftIdB: 'b', maxDiffLines: 1.5 });
|
|
3162
|
+
expect(fractional.isError).toBe(true);
|
|
3163
|
+
});
|
|
3164
|
+
});
|
|
3165
|
+
|
|
3166
|
+
describe('gog_gmail_drafts_list — the threading note is emitted once, not per row', () => {
|
|
3167
|
+
const rows = (n: number) => JSON.stringify({
|
|
3168
|
+
drafts: Array.from({ length: n }, (_, i) => ({ id: `r${i}`, messageId: `m${i}`, threadId: i % 2 === 0 ? `m${i}` : `t${i}` })),
|
|
3169
|
+
});
|
|
3170
|
+
|
|
3171
|
+
it('carries both note variants at the top level and none on the rows', async () => {
|
|
3172
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult(rows(20)));
|
|
3173
|
+
const text = (await harness.callTool('gog_gmail_drafts_list', {})).content[0].text as string;
|
|
3174
|
+
const parsed = JSON.parse(text);
|
|
3175
|
+
for (const d of parsed.drafts) expect(d).not.toHaveProperty('threadingNote');
|
|
3176
|
+
expect(parsed.threadingNotes.rootsOwnThread).toContain('NEW conversation');
|
|
3177
|
+
expect(parsed.threadingNotes.inThread).toContain('existing thread');
|
|
3178
|
+
// The selector stays on the row, so the note still resolves per draft.
|
|
3179
|
+
expect(parsed.drafts[0].rootsOwnThread).toBe(true);
|
|
3180
|
+
expect(parsed.drafts[1].rootsOwnThread).toBe(false);
|
|
3181
|
+
// ~300 chars x 20 rows of a constant is a per-call token cost that carries
|
|
3182
|
+
// no information. Still exactly one gog spawn — hazard B was never the
|
|
3183
|
+
// issue here.
|
|
3184
|
+
expect(text.length).toBeLessThan(3000);
|
|
3185
|
+
expect(lib.run).not.toHaveBeenCalled();
|
|
3186
|
+
});
|
|
3187
|
+
});
|
|
3188
|
+
|
|
3189
|
+
// A caller who believes "the update WAS written" may delete or overwrite the
|
|
3190
|
+
// sibling that now holds the only copy of the lines the check just listed.
|
|
3191
|
+
describe('gog_gmail_drafts_update — acceptContentLoss reports the write that ACTUALLY happened', () => {
|
|
3192
|
+
const b64 = (s: string) => Buffer.from(s, 'utf8').toString('base64url');
|
|
3193
|
+
const SIBLING = JSON.stringify({
|
|
3194
|
+
draft: { id: 's:sib', message: { id: 'm2', threadId: 'm2', payload: { mimeType: 'text/plain', body: { data: b64('kept line\nTHE PARAGRAPH ONLY THE SIBLING HAS.') } } } },
|
|
3195
|
+
});
|
|
3196
|
+
const call = () => harness.callTool('gog_gmail_drafts_update', {
|
|
3197
|
+
draftId: 'r43', subject: 'S', body: 'kept line', forkSiblingDraftId: 's:sib', acceptContentLoss: true,
|
|
3198
|
+
});
|
|
3199
|
+
|
|
3200
|
+
it('says the write was ATTEMPTED AND FAILED when gog returned a plain error', async () => {
|
|
3201
|
+
vi.mocked(lib.run).mockResolvedValue(SIBLING);
|
|
3202
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(errorResult('Error: gog: permission denied'));
|
|
3203
|
+
const result = await call();
|
|
3204
|
+
expect(result.isError).toBe(true);
|
|
3205
|
+
const text = result.content.map((c: { text?: string }) => c.text).join('\n');
|
|
3206
|
+
expect(text).not.toContain('WAS written');
|
|
3207
|
+
expect(text).toMatch(/FAILED/);
|
|
3208
|
+
expect(text).toMatch(/nothing was saved/i);
|
|
3209
|
+
});
|
|
3210
|
+
|
|
3211
|
+
it('does not claim a write on the 404 path, where it also says the draft is gone', async () => {
|
|
3212
|
+
vi.mocked(lib.run).mockImplementation(async (args) => {
|
|
3213
|
+
const a = args as string[];
|
|
3214
|
+
if (a[2] === 'get') return SIBLING;
|
|
3215
|
+
if (a[2] === 'list') return JSON.stringify({ drafts: [{ id: 's:sib', messageId: 'm2', threadId: 'm2' }] });
|
|
3216
|
+
return JSON.stringify({ messages: [] });
|
|
3217
|
+
});
|
|
3218
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(errorResult('Error: Google API error (404 notFound): Requested entity was not found.'));
|
|
3219
|
+
const result = await call();
|
|
3220
|
+
// A result that says the draft no longer exists AND that the update was
|
|
3221
|
+
// written is self-contradictory, and the caller acts on the second half.
|
|
3222
|
+
const text = result.content.map((c: { text?: string }) => c.text).join('\n');
|
|
3223
|
+
expect(text).toContain('DRAFT_FORKED');
|
|
3224
|
+
expect(text).not.toContain('WAS written');
|
|
3225
|
+
expect(text).toMatch(/FAILED/);
|
|
3226
|
+
expect(text).toMatch(/NOTHING WAS SAVED/i);
|
|
3227
|
+
});
|
|
3228
|
+
|
|
3229
|
+
it('still says the update WAS written when the write succeeded', async () => {
|
|
3230
|
+
vi.mocked(lib.run).mockResolvedValue(SIBLING);
|
|
3231
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(rawTextResult('{"draftId":"r43"}'));
|
|
3232
|
+
const result = await call();
|
|
3233
|
+
expect(result.isError).toBeFalsy();
|
|
3234
|
+
const parsed = JSON.parse(result.content[0].text as string);
|
|
3235
|
+
expect(parsed.contentLossCheck.written).toBe(true);
|
|
3236
|
+
expect(parsed.contentLossCheck.acknowledged).toBe(true);
|
|
3237
|
+
expect(parsed.contentLossCheck.note).toContain('WAS written');
|
|
3238
|
+
});
|
|
3239
|
+
});
|
|
3240
|
+
|
|
3241
|
+
// `not listed` is not `does not exist`. The fork report's listing is capped at
|
|
3242
|
+
// 20 by construction (it is a failure path and must not grow with the mailbox),
|
|
3243
|
+
// so on a mailbox with more drafts than that, absence of evidence was becoming
|
|
3244
|
+
// the fork story by default — and sending the caller hunting for a replacement
|
|
3245
|
+
// that does not exist is exactly the cost this report was built to avoid.
|
|
3246
|
+
describe('gog_gmail_drafts_update — DRAFT_FORKED states what its listing can and cannot show', () => {
|
|
3247
|
+
const NOT_FOUND = 'Error: Google API error (404 notFound): Requested entity was not found.';
|
|
3248
|
+
const fullWindow = JSON.stringify({
|
|
3249
|
+
drafts: Array.from({ length: 20 }, (_, i) => ({ id: `r${i}`, messageId: `m${i}`, threadId: `t${i}` })),
|
|
3250
|
+
});
|
|
3251
|
+
const shortWindow = JSON.stringify({ drafts: [{ id: 'r1', messageId: 'm1', threadId: 't1' }] });
|
|
3252
|
+
|
|
3253
|
+
function stub(list: string): void {
|
|
3254
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(errorResult(NOT_FOUND));
|
|
3255
|
+
vi.mocked(lib.run).mockImplementation(async (args) => {
|
|
3256
|
+
const a = args as string[];
|
|
3257
|
+
if (a[2] === 'list') return list;
|
|
3258
|
+
return JSON.stringify({ messages: [] });
|
|
3259
|
+
});
|
|
3260
|
+
}
|
|
3261
|
+
const parse = (result: { content: Array<{ text?: string }> }) => {
|
|
3262
|
+
const text = result.content[0].text as string;
|
|
3263
|
+
return { text, parsed: JSON.parse(text.slice(text.indexOf('{'))) };
|
|
3264
|
+
};
|
|
3265
|
+
|
|
3266
|
+
it('does not assert the draft is gone when the 20-draft window came back FULL', async () => {
|
|
3267
|
+
stub(fullWindow);
|
|
3268
|
+
const { text, parsed } = parse(await harness.callTool('gog_gmail_drafts_update', {
|
|
3269
|
+
draftId: 'rSTILL_EXISTS_BUT_RANK_25', subject: 'S', body: 'B',
|
|
3270
|
+
}));
|
|
3271
|
+
expect(text).not.toMatch(/no longer resolves/);
|
|
3272
|
+
expect(parsed.listingEvidence.basis).toBe('capped-listing');
|
|
3273
|
+
expect(parsed.listingEvidence.windowSize).toBe(20);
|
|
3274
|
+
expect(parsed.listingEvidence.draftsListed).toBe(20);
|
|
3275
|
+
expect(parsed.listingEvidence.note).toMatch(/not evidence|does not establish/i);
|
|
3276
|
+
expect(parsed.listingEvidence.note).toContain('gog_gmail_drafts_list');
|
|
3277
|
+
});
|
|
3278
|
+
|
|
3279
|
+
it('does assert it when the listing came back SHORT of the window, so it covered the folder', async () => {
|
|
3280
|
+
stub(shortWindow);
|
|
3281
|
+
const { text, parsed } = parse(await harness.callTool('gog_gmail_drafts_update', { draftId: 'rGONE', subject: 'S', body: 'B' }));
|
|
3282
|
+
expect(text).toMatch(/no longer resolves/);
|
|
3283
|
+
expect(parsed.listingEvidence.basis).toBe('complete-listing');
|
|
3284
|
+
expect(parsed.listingEvidence.draftsListed).toBe(1);
|
|
3285
|
+
});
|
|
3286
|
+
|
|
3287
|
+
it('claims nothing about the draft when the listing itself failed', async () => {
|
|
3288
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValueOnce(errorResult(NOT_FOUND));
|
|
3289
|
+
vi.mocked(lib.run).mockRejectedValue(new Error('gog timed out after 30s'));
|
|
3290
|
+
const { text, parsed } = parse(await harness.callTool('gog_gmail_drafts_update', { draftId: 'rUNKNOWN', subject: 'S', body: 'B' }));
|
|
3291
|
+
expect(text).not.toMatch(/no longer resolves/);
|
|
3292
|
+
expect(parsed.listingEvidence.basis).toBe('listing-unavailable');
|
|
3293
|
+
expect(parsed.listingEvidence.note).toMatch(/failed/i);
|
|
3294
|
+
});
|
|
3295
|
+
|
|
3296
|
+
// The branch that CANNOT confirm the fork story was also the one whose
|
|
3297
|
+
// explanations omitted the leading alternative the caller literally handed it.
|
|
3298
|
+
it('carries the reply target and names it as an explanation', async () => {
|
|
3299
|
+
stub(fullWindow);
|
|
3300
|
+
const { parsed } = parse(await harness.callTool('gog_gmail_drafts_update', {
|
|
3301
|
+
draftId: 'rUNSEEN', subject: 'S', body: 'B', replyToThreadId: 'STALE-THREAD',
|
|
3302
|
+
}));
|
|
3303
|
+
expect(parsed.replyTarget).toEqual({ via: 'replyToThreadId', target: 'STALE-THREAD' });
|
|
3304
|
+
expect(parsed.otherExplanations.join(' ')).toMatch(/replyToThreadId=STALE-THREAD/);
|
|
3305
|
+
expect(parsed.otherExplanations.join(' ')).toMatch(/not the draft|reply target/i);
|
|
3306
|
+
});
|
|
3307
|
+
|
|
3308
|
+
it('omits the reply-target explanation when the call named no target', async () => {
|
|
3309
|
+
stub(shortWindow);
|
|
3310
|
+
const { parsed } = parse(await harness.callTool('gog_gmail_drafts_send', { draftId: 'rGONE' }));
|
|
3311
|
+
expect(parsed.replyTarget).toBeNull();
|
|
3312
|
+
expect(parsed.otherExplanations.join(' ')).not.toMatch(/reply target/i);
|
|
1716
3313
|
});
|
|
1717
3314
|
});
|