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.
@@ -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
  });