gogcli-mcp-gmail 2.22.0 → 2.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -6
- package/SKILL.md +32 -4
- package/dist/index.js +989 -30
- package/manifest.json +8 -4
- package/package.json +1 -1
- package/src/tools/gmail-extra.ts +2114 -21
- package/tests/tools/draft-diff-arithmetic.test.ts +57 -0
- package/tests/tools/draft-fork-signature.test.ts +67 -0
- package/tests/tools/draft-fork.test.ts +995 -0
- package/tests/tools/draft-write-vs-refetch.test.ts +118 -0
- package/tests/tools/gmail-extra.test.ts +1295 -0
|
@@ -714,6 +714,504 @@ 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' });
|
|
@@ -2017,3 +2515,800 @@ describe('gog_gmail_attachment pins --inline-max-bytes', () => {
|
|
|
2017
2515
|
expect(args()).toContain('--inline-max-bytes=99');
|
|
2018
2516
|
});
|
|
2019
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'],
|
|
2559
|
+
{ account: undefined },
|
|
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);
|
|
3313
|
+
});
|
|
3314
|
+
});
|