tmux-team 2.2.0 → 3.0.0-alpha.2
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 +42 -193
- package/package.json +3 -2
- package/skills/README.md +101 -0
- package/skills/claude/team.md +46 -0
- package/skills/codex/SKILL.md +46 -0
- package/src/cli.ts +19 -5
- package/src/commands/config.ts +2 -44
- package/src/commands/help.ts +1 -2
- package/src/commands/install-skill.ts +148 -0
- package/src/commands/talk.test.ts +458 -63
- package/src/commands/talk.ts +158 -69
- package/src/config.test.ts +0 -1
- package/src/config.ts +0 -1
- package/src/identity.ts +89 -0
- package/src/types.ts +2 -2
- package/src/version.ts +1 -1
- package/src/pm/commands.test.ts +0 -1462
- package/src/pm/commands.ts +0 -1011
- package/src/pm/manager.test.ts +0 -377
- package/src/pm/manager.ts +0 -146
- package/src/pm/permissions.test.ts +0 -444
- package/src/pm/permissions.ts +0 -293
- package/src/pm/storage/adapter.ts +0 -57
- package/src/pm/storage/fs.test.ts +0 -512
- package/src/pm/storage/fs.ts +0 -290
- package/src/pm/storage/github.ts +0 -842
- package/src/pm/types.ts +0 -91
|
@@ -64,7 +64,6 @@ function createDefaultConfig(): ResolvedConfig {
|
|
|
64
64
|
pollInterval: 0.1, // Fast polling for tests
|
|
65
65
|
captureLines: 100,
|
|
66
66
|
preambleEvery: 3,
|
|
67
|
-
hideOrphanTasks: false,
|
|
68
67
|
},
|
|
69
68
|
agents: {},
|
|
70
69
|
paneRegistry: {
|
|
@@ -222,7 +221,6 @@ describe('buildMessage (via cmdTalk)', () => {
|
|
|
222
221
|
pollInterval: 0.1,
|
|
223
222
|
captureLines: 100,
|
|
224
223
|
preambleEvery: 3,
|
|
225
|
-
hideOrphanTasks: false,
|
|
226
224
|
},
|
|
227
225
|
};
|
|
228
226
|
|
|
@@ -259,7 +257,6 @@ describe('buildMessage (via cmdTalk)', () => {
|
|
|
259
257
|
pollInterval: 0.1,
|
|
260
258
|
captureLines: 100,
|
|
261
259
|
preambleEvery: 1,
|
|
262
|
-
hideOrphanTasks: false,
|
|
263
260
|
},
|
|
264
261
|
};
|
|
265
262
|
|
|
@@ -283,7 +280,6 @@ describe('buildMessage (via cmdTalk)', () => {
|
|
|
283
280
|
pollInterval: 0.1,
|
|
284
281
|
captureLines: 100,
|
|
285
282
|
preambleEvery: 0,
|
|
286
|
-
hideOrphanTasks: false,
|
|
287
283
|
},
|
|
288
284
|
};
|
|
289
285
|
|
|
@@ -447,6 +443,12 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
447
443
|
}
|
|
448
444
|
});
|
|
449
445
|
|
|
446
|
+
// Helper: generate mock capture output with proper marker structure
|
|
447
|
+
// The end marker must appear TWICE: once in instruction, once from "agent"
|
|
448
|
+
function mockCompleteResponse(nonce: string, response: string): string {
|
|
449
|
+
return `{tmux-team-start:${nonce}}\nHello\n\n[IMPORTANT: When your response is complete, print exactly: {tmux-team-end:${nonce}}]\n${response}\n{tmux-team-end:${nonce}}`;
|
|
450
|
+
}
|
|
451
|
+
|
|
450
452
|
it('appends nonce instruction to message', async () => {
|
|
451
453
|
const tmux = createMockTmux();
|
|
452
454
|
// Set up capture to return the nonce marker immediately
|
|
@@ -454,10 +456,10 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
454
456
|
tmux.capture = () => {
|
|
455
457
|
captureCount++;
|
|
456
458
|
if (captureCount === 1) return ''; // Baseline
|
|
457
|
-
// Return marker on second capture
|
|
459
|
+
// Return marker on second capture - must include instruction AND agent's end marker
|
|
458
460
|
const sent = tmux.sends[0]?.message || '';
|
|
459
461
|
const match = sent.match(/\{tmux-team-end:([a-f0-9]+)\}/);
|
|
460
|
-
return match ?
|
|
462
|
+
return match ? mockCompleteResponse(match[1], 'Response here') : '';
|
|
461
463
|
};
|
|
462
464
|
|
|
463
465
|
const ctx = createContext({
|
|
@@ -470,7 +472,6 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
470
472
|
pollInterval: 0.01,
|
|
471
473
|
captureLines: 100,
|
|
472
474
|
preambleEvery: 3,
|
|
473
|
-
hideOrphanTasks: false,
|
|
474
475
|
},
|
|
475
476
|
},
|
|
476
477
|
});
|
|
@@ -497,7 +498,7 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
497
498
|
const sent = tmux.sends[0]?.message || '';
|
|
498
499
|
const match = sent.match(/\{tmux-team-end:([a-f0-9]+)\}/);
|
|
499
500
|
if (match) {
|
|
500
|
-
return
|
|
501
|
+
return mockCompleteResponse(match[1], 'Agent response here');
|
|
501
502
|
}
|
|
502
503
|
return 'baseline content';
|
|
503
504
|
};
|
|
@@ -513,7 +514,6 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
513
514
|
pollInterval: 0.01,
|
|
514
515
|
captureLines: 100,
|
|
515
516
|
preambleEvery: 3,
|
|
516
|
-
hideOrphanTasks: false,
|
|
517
517
|
},
|
|
518
518
|
},
|
|
519
519
|
});
|
|
@@ -544,7 +544,6 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
544
544
|
pollInterval: 0.02,
|
|
545
545
|
captureLines: 100,
|
|
546
546
|
preambleEvery: 3,
|
|
547
|
-
hideOrphanTasks: false,
|
|
548
547
|
},
|
|
549
548
|
},
|
|
550
549
|
});
|
|
@@ -563,23 +562,24 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
563
562
|
expect(output.error).toContain('Timed out');
|
|
564
563
|
});
|
|
565
564
|
|
|
566
|
-
it('isolates response
|
|
565
|
+
it('isolates response using start/end markers in scrollback', async () => {
|
|
567
566
|
const tmux = createMockTmux();
|
|
568
567
|
const ui = createMockUI();
|
|
569
568
|
|
|
570
|
-
|
|
571
|
-
const baseline = 'Previous conversation\nOld content here';
|
|
569
|
+
const oldContent = 'Previous conversation\nOld content here';
|
|
572
570
|
|
|
573
571
|
tmux.capture = () => {
|
|
574
|
-
|
|
575
|
-
if (captureCount === 1) return baseline;
|
|
576
|
-
// Second capture includes baseline + new content + marker
|
|
572
|
+
// Simulate scrollback with old content, then our instruction (with end marker), response, and agent's end marker
|
|
577
573
|
const sent = tmux.sends[0]?.message || '';
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
|
|
574
|
+
const startMatch = sent.match(/\{tmux-team-start:([a-f0-9]+)\}/);
|
|
575
|
+
const endMatch = sent.match(/\{tmux-team-end:([a-f0-9]+)\}/);
|
|
576
|
+
if (startMatch && endMatch) {
|
|
577
|
+
// Start and end markers should have the same nonce
|
|
578
|
+
expect(startMatch[1]).toBe(endMatch[1]);
|
|
579
|
+
// Must include end marker TWICE: once in instruction, once from "agent"
|
|
580
|
+
return `${oldContent}\n\n{tmux-team-start:${startMatch[1]}}\nMessage content here\n\n[IMPORTANT: print {tmux-team-end:${endMatch[1]}}]\nNew response content\n\n{tmux-team-end:${endMatch[1]}}`;
|
|
581
581
|
}
|
|
582
|
-
return
|
|
582
|
+
return oldContent;
|
|
583
583
|
};
|
|
584
584
|
|
|
585
585
|
const ctx = createContext({
|
|
@@ -593,7 +593,6 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
593
593
|
pollInterval: 0.01,
|
|
594
594
|
captureLines: 100,
|
|
595
595
|
preambleEvery: 3,
|
|
596
|
-
hideOrphanTasks: false,
|
|
597
596
|
},
|
|
598
597
|
},
|
|
599
598
|
});
|
|
@@ -602,8 +601,11 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
602
601
|
|
|
603
602
|
const output = ui.jsonOutput[0] as Record<string, unknown>;
|
|
604
603
|
expect(output.status).toBe('completed');
|
|
605
|
-
// Response should NOT include
|
|
606
|
-
expect(output.response).
|
|
604
|
+
// Response should NOT include old content before start marker
|
|
605
|
+
expect(output.response).not.toContain('Previous conversation');
|
|
606
|
+
expect(output.response).not.toContain('Old content here');
|
|
607
|
+
// Response should contain the actual response content
|
|
608
|
+
expect(output.response).toContain('New response content');
|
|
607
609
|
});
|
|
608
610
|
|
|
609
611
|
it('clears active request on completion', async () => {
|
|
@@ -615,7 +617,7 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
615
617
|
if (captureCount === 1) return '';
|
|
616
618
|
const sent = tmux.sends[0]?.message || '';
|
|
617
619
|
const match = sent.match(/\{tmux-team-end:([a-f0-9]+)\}/);
|
|
618
|
-
return match ?
|
|
620
|
+
return match ? mockCompleteResponse(match[1], 'Done') : '';
|
|
619
621
|
};
|
|
620
622
|
|
|
621
623
|
const paths = createTestPaths(testDir);
|
|
@@ -629,7 +631,6 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
629
631
|
pollInterval: 0.01,
|
|
630
632
|
captureLines: 100,
|
|
631
633
|
preambleEvery: 3,
|
|
632
|
-
hideOrphanTasks: false,
|
|
633
634
|
},
|
|
634
635
|
},
|
|
635
636
|
});
|
|
@@ -658,7 +659,6 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
658
659
|
pollInterval: 0.01,
|
|
659
660
|
captureLines: 100,
|
|
660
661
|
preambleEvery: 3,
|
|
661
|
-
hideOrphanTasks: false,
|
|
662
662
|
},
|
|
663
663
|
},
|
|
664
664
|
});
|
|
@@ -680,22 +680,22 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
680
680
|
// Create mock tmux that returns markers for each agent after a delay
|
|
681
681
|
const tmux = createMockTmux();
|
|
682
682
|
let captureCount = 0;
|
|
683
|
-
const
|
|
683
|
+
const noncesByPane: Record<string, string> = {};
|
|
684
684
|
|
|
685
|
-
// Mock send to capture the
|
|
685
|
+
// Mock send to capture the nonce for each pane
|
|
686
686
|
tmux.send = (pane: string, msg: string) => {
|
|
687
687
|
const match = msg.match(/\{tmux-team-end:([a-f0-9]+)\}/);
|
|
688
688
|
if (match) {
|
|
689
|
-
|
|
689
|
+
noncesByPane[pane] = match[1];
|
|
690
690
|
}
|
|
691
691
|
};
|
|
692
692
|
|
|
693
|
-
// Mock capture to return
|
|
693
|
+
// Mock capture to return complete response after first poll
|
|
694
694
|
tmux.capture = (pane: string) => {
|
|
695
695
|
captureCount++;
|
|
696
|
-
// Return
|
|
697
|
-
if (captureCount > 3 &&
|
|
698
|
-
return
|
|
696
|
+
// Return complete response on second capture for each pane
|
|
697
|
+
if (captureCount > 3 && noncesByPane[pane]) {
|
|
698
|
+
return mockCompleteResponse(noncesByPane[pane], 'Response from agent');
|
|
699
699
|
}
|
|
700
700
|
return 'working...';
|
|
701
701
|
};
|
|
@@ -713,7 +713,6 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
713
713
|
pollInterval: 0.05,
|
|
714
714
|
captureLines: 100,
|
|
715
715
|
preambleEvery: 3,
|
|
716
|
-
hideOrphanTasks: false,
|
|
717
716
|
},
|
|
718
717
|
paneRegistry: {
|
|
719
718
|
codex: { pane: '10.1' },
|
|
@@ -730,19 +729,19 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
730
729
|
|
|
731
730
|
it('handles partial timeout in wait mode with all target', async () => {
|
|
732
731
|
const tmux = createMockTmux();
|
|
733
|
-
const
|
|
732
|
+
const noncesByPane: Record<string, string> = {};
|
|
734
733
|
|
|
735
734
|
tmux.send = (pane: string, msg: string) => {
|
|
736
735
|
const match = msg.match(/\{tmux-team-end:([a-f0-9]+)\}/);
|
|
737
736
|
if (match) {
|
|
738
|
-
|
|
737
|
+
noncesByPane[pane] = match[1];
|
|
739
738
|
}
|
|
740
739
|
};
|
|
741
740
|
|
|
742
741
|
// Only pane 10.1 responds, 10.2 times out
|
|
743
742
|
tmux.capture = (pane: string) => {
|
|
744
|
-
if (pane === '10.1' &&
|
|
745
|
-
return
|
|
743
|
+
if (pane === '10.1' && noncesByPane[pane]) {
|
|
744
|
+
return mockCompleteResponse(noncesByPane[pane], 'Response from codex');
|
|
746
745
|
}
|
|
747
746
|
return 'still working...';
|
|
748
747
|
};
|
|
@@ -760,7 +759,6 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
760
759
|
pollInterval: 0.02,
|
|
761
760
|
captureLines: 100,
|
|
762
761
|
preambleEvery: 3,
|
|
763
|
-
hideOrphanTasks: false,
|
|
764
762
|
},
|
|
765
763
|
paneRegistry: {
|
|
766
764
|
codex: { pane: '10.1' },
|
|
@@ -798,11 +796,11 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
798
796
|
}
|
|
799
797
|
};
|
|
800
798
|
|
|
801
|
-
// Return
|
|
799
|
+
// Return complete response immediately
|
|
802
800
|
tmux.capture = (pane: string) => {
|
|
803
801
|
const idx = pane === '10.1' ? 0 : 1;
|
|
804
802
|
if (nonces[idx]) {
|
|
805
|
-
return
|
|
803
|
+
return mockCompleteResponse(nonces[idx], 'Response');
|
|
806
804
|
}
|
|
807
805
|
return '';
|
|
808
806
|
};
|
|
@@ -818,7 +816,6 @@ describe('cmdTalk - --wait mode', () => {
|
|
|
818
816
|
pollInterval: 0.02,
|
|
819
817
|
captureLines: 100,
|
|
820
818
|
preambleEvery: 3,
|
|
821
|
-
hideOrphanTasks: false,
|
|
822
819
|
},
|
|
823
820
|
paneRegistry: {
|
|
824
821
|
codex: { pane: '10.1' },
|
|
@@ -853,25 +850,33 @@ describe('cmdTalk - nonce collision handling', () => {
|
|
|
853
850
|
const ui = createMockUI();
|
|
854
851
|
|
|
855
852
|
let captureCount = 0;
|
|
856
|
-
const
|
|
853
|
+
const oldStartMarker = '{tmux-team-start:0000}';
|
|
854
|
+
const oldEndMarker = '{tmux-team-end:0000}'; // Old marker from previous request
|
|
857
855
|
|
|
858
856
|
tmux.capture = () => {
|
|
859
857
|
captureCount++;
|
|
858
|
+
// Scrollback includes OLD markers from a previous request
|
|
860
859
|
if (captureCount === 1) {
|
|
861
|
-
|
|
862
|
-
return `Old response ${oldMarker}`;
|
|
860
|
+
return `${oldStartMarker}\nOld question\nOld response\n${oldEndMarker}`;
|
|
863
861
|
}
|
|
864
|
-
// New capture still has old
|
|
862
|
+
// New capture still has old markers but new request markers not complete yet
|
|
865
863
|
if (captureCount === 2) {
|
|
866
|
-
|
|
864
|
+
const sent = tmux.sends[0]?.message || '';
|
|
865
|
+
const startMatch = sent.match(/\{tmux-team-start:([a-f0-9]+)\}/);
|
|
866
|
+
if (startMatch) {
|
|
867
|
+
return `${oldStartMarker}\nOld question\nOld response\n${oldEndMarker}\n\n{tmux-team-start:${startMatch[1]}}\nNew question asked`;
|
|
868
|
+
}
|
|
869
|
+
return `${oldStartMarker}\nOld question\nOld response\n${oldEndMarker}`;
|
|
867
870
|
}
|
|
868
|
-
// Finally, new marker appears
|
|
871
|
+
// Finally, new end marker appears - must have TWO occurrences of new end marker
|
|
869
872
|
const sent = tmux.sends[0]?.message || '';
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
|
|
873
|
+
const startMatch = sent.match(/\{tmux-team-start:([a-f0-9]+)\}/);
|
|
874
|
+
const endMatch = sent.match(/\{tmux-team-end:([a-f0-9]+)\}/);
|
|
875
|
+
if (startMatch && endMatch) {
|
|
876
|
+
// Old markers in scrollback + new instruction (with end marker) + response + agent's end marker
|
|
877
|
+
return `${oldStartMarker}\nOld question\nOld response\n${oldEndMarker}\n\n{tmux-team-start:${startMatch[1]}}\nNew question asked\n\n[IMPORTANT: print {tmux-team-end:${endMatch[1]}}]\nNew response\n\n{tmux-team-end:${endMatch[1]}}`;
|
|
873
878
|
}
|
|
874
|
-
return
|
|
879
|
+
return `${oldStartMarker}\nOld question\nOld response\n${oldEndMarker}`;
|
|
875
880
|
};
|
|
876
881
|
|
|
877
882
|
const ctx = createContext({
|
|
@@ -885,7 +890,6 @@ describe('cmdTalk - nonce collision handling', () => {
|
|
|
885
890
|
pollInterval: 0.01,
|
|
886
891
|
captureLines: 100,
|
|
887
892
|
preambleEvery: 3,
|
|
888
|
-
hideOrphanTasks: false,
|
|
889
893
|
},
|
|
890
894
|
},
|
|
891
895
|
});
|
|
@@ -894,8 +898,10 @@ describe('cmdTalk - nonce collision handling', () => {
|
|
|
894
898
|
|
|
895
899
|
const output = ui.jsonOutput[0] as Record<string, unknown>;
|
|
896
900
|
expect(output.status).toBe('completed');
|
|
897
|
-
// Response should be from after the new
|
|
901
|
+
// Response should be from after the new start marker, not triggered by old markers
|
|
898
902
|
expect(output.response as string).not.toContain('Old response');
|
|
903
|
+
expect(output.response as string).not.toContain('Old question');
|
|
904
|
+
expect(output.response as string).toContain('New response');
|
|
899
905
|
});
|
|
900
906
|
});
|
|
901
907
|
|
|
@@ -916,13 +922,14 @@ describe('cmdTalk - JSON output contract', () => {
|
|
|
916
922
|
const tmux = createMockTmux();
|
|
917
923
|
const ui = createMockUI();
|
|
918
924
|
|
|
919
|
-
let captureCount = 0;
|
|
920
925
|
tmux.capture = () => {
|
|
921
|
-
captureCount++;
|
|
922
|
-
if (captureCount === 1) return '';
|
|
923
926
|
const sent = tmux.sends[0]?.message || '';
|
|
924
|
-
const
|
|
925
|
-
|
|
927
|
+
const endMatch = sent.match(/\{tmux-team-end:([a-f0-9]+)\}/);
|
|
928
|
+
if (endMatch) {
|
|
929
|
+
// Must have TWO end markers: one in instruction, one from "agent"
|
|
930
|
+
return `{tmux-team-start:${endMatch[1]}}\nMessage\n\n[IMPORTANT: print {tmux-team-end:${endMatch[1]}}]\nResponse\n\n{tmux-team-end:${endMatch[1]}}`;
|
|
931
|
+
}
|
|
932
|
+
return '';
|
|
926
933
|
};
|
|
927
934
|
|
|
928
935
|
const ctx = createContext({
|
|
@@ -936,7 +943,6 @@ describe('cmdTalk - JSON output contract', () => {
|
|
|
936
943
|
pollInterval: 0.01,
|
|
937
944
|
captureLines: 100,
|
|
938
945
|
preambleEvery: 3,
|
|
939
|
-
hideOrphanTasks: false,
|
|
940
946
|
},
|
|
941
947
|
},
|
|
942
948
|
});
|
|
@@ -949,7 +955,8 @@ describe('cmdTalk - JSON output contract', () => {
|
|
|
949
955
|
expect(output).toHaveProperty('status', 'completed');
|
|
950
956
|
expect(output).toHaveProperty('requestId');
|
|
951
957
|
expect(output).toHaveProperty('nonce');
|
|
952
|
-
expect(output).toHaveProperty('
|
|
958
|
+
expect(output).toHaveProperty('startMarker');
|
|
959
|
+
expect(output).toHaveProperty('endMarker');
|
|
953
960
|
expect(output).toHaveProperty('response');
|
|
954
961
|
});
|
|
955
962
|
|
|
@@ -969,7 +976,6 @@ describe('cmdTalk - JSON output contract', () => {
|
|
|
969
976
|
pollInterval: 0.01,
|
|
970
977
|
captureLines: 100,
|
|
971
978
|
preambleEvery: 3,
|
|
972
|
-
hideOrphanTasks: false,
|
|
973
979
|
},
|
|
974
980
|
},
|
|
975
981
|
});
|
|
@@ -987,6 +993,395 @@ describe('cmdTalk - JSON output contract', () => {
|
|
|
987
993
|
expect(output).toHaveProperty('error');
|
|
988
994
|
expect(output).toHaveProperty('requestId');
|
|
989
995
|
expect(output).toHaveProperty('nonce');
|
|
990
|
-
expect(output).toHaveProperty('
|
|
996
|
+
expect(output).toHaveProperty('startMarker');
|
|
997
|
+
expect(output).toHaveProperty('endMarker');
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
it('captures partialResponse on timeout when agent started responding', async () => {
|
|
1001
|
+
const tmux = createMockTmux();
|
|
1002
|
+
const ui = createMockUI();
|
|
1003
|
+
|
|
1004
|
+
// Simulate agent started responding but didn't finish (no end marker)
|
|
1005
|
+
tmux.capture = () =>
|
|
1006
|
+
'{tmux-team-start:abcd}\nHello\n\n[IMPORTANT: When your response is complete, print exactly: {tmux-team-end:abcd}]\nThis is partial content\nStill writing...';
|
|
1007
|
+
|
|
1008
|
+
const ctx = createContext({
|
|
1009
|
+
tmux,
|
|
1010
|
+
ui,
|
|
1011
|
+
paths: createTestPaths(testDir),
|
|
1012
|
+
flags: { wait: true, json: true, timeout: 0.05 },
|
|
1013
|
+
config: {
|
|
1014
|
+
defaults: {
|
|
1015
|
+
timeout: 0.05,
|
|
1016
|
+
pollInterval: 0.01,
|
|
1017
|
+
captureLines: 100,
|
|
1018
|
+
preambleEvery: 3,
|
|
1019
|
+
},
|
|
1020
|
+
},
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
try {
|
|
1024
|
+
await cmdTalk(ctx, 'claude', 'Hello');
|
|
1025
|
+
} catch {
|
|
1026
|
+
// Expected timeout
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const output = ui.jsonOutput[0] as Record<string, unknown>;
|
|
1030
|
+
expect(output).toHaveProperty('status', 'timeout');
|
|
1031
|
+
expect(output).toHaveProperty('partialResponse');
|
|
1032
|
+
expect(output.partialResponse).toContain('This is partial content');
|
|
1033
|
+
expect(output.partialResponse).toContain('Still writing...');
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it('returns null partialResponse when nothing captured', async () => {
|
|
1037
|
+
const tmux = createMockTmux();
|
|
1038
|
+
const ui = createMockUI();
|
|
1039
|
+
|
|
1040
|
+
// Nothing meaningful in the capture
|
|
1041
|
+
tmux.capture = () => 'random scrollback content';
|
|
1042
|
+
|
|
1043
|
+
const ctx = createContext({
|
|
1044
|
+
tmux,
|
|
1045
|
+
ui,
|
|
1046
|
+
paths: createTestPaths(testDir),
|
|
1047
|
+
flags: { wait: true, json: true, timeout: 0.05 },
|
|
1048
|
+
config: {
|
|
1049
|
+
defaults: {
|
|
1050
|
+
timeout: 0.05,
|
|
1051
|
+
pollInterval: 0.01,
|
|
1052
|
+
captureLines: 100,
|
|
1053
|
+
preambleEvery: 3,
|
|
1054
|
+
},
|
|
1055
|
+
},
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
try {
|
|
1059
|
+
await cmdTalk(ctx, 'claude', 'Hello');
|
|
1060
|
+
} catch {
|
|
1061
|
+
// Expected timeout
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const output = ui.jsonOutput[0] as Record<string, unknown>;
|
|
1065
|
+
expect(output).toHaveProperty('status', 'timeout');
|
|
1066
|
+
expect(output.partialResponse).toBeNull();
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
it('captures partialResponse in broadcast timeout', async () => {
|
|
1070
|
+
const tmux = createMockTmux();
|
|
1071
|
+
const ui = createMockUI();
|
|
1072
|
+
const markersByPane: Record<string, string> = {};
|
|
1073
|
+
|
|
1074
|
+
tmux.send = (pane: string, msg: string) => {
|
|
1075
|
+
const match = msg.match(/\{tmux-team-end:([a-f0-9]+)\}/);
|
|
1076
|
+
if (match) markersByPane[pane] = match[1];
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
// codex completes, gemini times out with partial response
|
|
1080
|
+
tmux.capture = (pane: string) => {
|
|
1081
|
+
if (pane === '10.1') {
|
|
1082
|
+
const nonce = markersByPane['10.1'];
|
|
1083
|
+
return `{tmux-team-start:${nonce}}\nMsg\n\n[IMPORTANT: print {tmux-team-end:${nonce}}]\nResponse\n{tmux-team-end:${nonce}}`;
|
|
1084
|
+
}
|
|
1085
|
+
// gemini has partial response
|
|
1086
|
+
const nonce = markersByPane['10.2'];
|
|
1087
|
+
return `{tmux-team-start:${nonce}}\nMsg\n\n[IMPORTANT: print {tmux-team-end:${nonce}}]\nPartial gemini output...`;
|
|
1088
|
+
};
|
|
1089
|
+
|
|
1090
|
+
const paths = createTestPaths(testDir);
|
|
1091
|
+
const ctx = createContext({
|
|
1092
|
+
ui,
|
|
1093
|
+
tmux,
|
|
1094
|
+
paths,
|
|
1095
|
+
flags: { wait: true, timeout: 0.1, json: true },
|
|
1096
|
+
config: {
|
|
1097
|
+
defaults: {
|
|
1098
|
+
timeout: 0.1,
|
|
1099
|
+
pollInterval: 0.02,
|
|
1100
|
+
captureLines: 100,
|
|
1101
|
+
preambleEvery: 3,
|
|
1102
|
+
},
|
|
1103
|
+
paneRegistry: {
|
|
1104
|
+
codex: { pane: '10.1' },
|
|
1105
|
+
gemini: { pane: '10.2' },
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
try {
|
|
1111
|
+
await cmdTalk(ctx, 'all', 'Hello');
|
|
1112
|
+
} catch {
|
|
1113
|
+
// Expected timeout exit
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const result = ui.jsonOutput[0] as {
|
|
1117
|
+
results: Array<{
|
|
1118
|
+
agent: string;
|
|
1119
|
+
status: string;
|
|
1120
|
+
response?: string;
|
|
1121
|
+
partialResponse?: string;
|
|
1122
|
+
}>;
|
|
1123
|
+
};
|
|
1124
|
+
const codexResult = result.results.find((r) => r.agent === 'codex');
|
|
1125
|
+
const geminiResult = result.results.find((r) => r.agent === 'gemini');
|
|
1126
|
+
|
|
1127
|
+
expect(codexResult?.status).toBe('completed');
|
|
1128
|
+
expect(codexResult?.response).toContain('Response');
|
|
1129
|
+
|
|
1130
|
+
expect(geminiResult?.status).toBe('timeout');
|
|
1131
|
+
expect(geminiResult?.partialResponse).toContain('Partial gemini output');
|
|
1132
|
+
});
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
// ─────────────────────────────────────────────────────────────
|
|
1136
|
+
// Start/End Marker Tests - comprehensive coverage for the new marker system
|
|
1137
|
+
// ─────────────────────────────────────────────────────────────
|
|
1138
|
+
|
|
1139
|
+
describe('cmdTalk - start/end marker extraction', () => {
|
|
1140
|
+
let testDir: string;
|
|
1141
|
+
|
|
1142
|
+
beforeEach(() => {
|
|
1143
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'talk-test-'));
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
afterEach(() => {
|
|
1147
|
+
if (fs.existsSync(testDir)) {
|
|
1148
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
1149
|
+
}
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
// Helper: generate mock capture output with proper marker structure
|
|
1153
|
+
// The end marker must appear TWICE: once in instruction, once from "agent"
|
|
1154
|
+
function mockResponse(nonce: string, response: string): string {
|
|
1155
|
+
return `{tmux-team-start:${nonce}}\nContent\n\n[IMPORTANT: print {tmux-team-end:${nonce}}]\n${response}\n{tmux-team-end:${nonce}}`;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
it('includes both start and end markers in sent message', async () => {
|
|
1159
|
+
const tmux = createMockTmux();
|
|
1160
|
+
const ui = createMockUI();
|
|
1161
|
+
|
|
1162
|
+
// Return complete response immediately
|
|
1163
|
+
tmux.capture = () => {
|
|
1164
|
+
const sent = tmux.sends[0]?.message || '';
|
|
1165
|
+
const endMatch = sent.match(/\{tmux-team-end:([a-f0-9]+)\}/);
|
|
1166
|
+
if (endMatch) {
|
|
1167
|
+
return mockResponse(endMatch[1], 'Response');
|
|
1168
|
+
}
|
|
1169
|
+
return '';
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
const ctx = createContext({
|
|
1173
|
+
tmux,
|
|
1174
|
+
ui,
|
|
1175
|
+
paths: createTestPaths(testDir),
|
|
1176
|
+
flags: { wait: true, json: true, timeout: 5 },
|
|
1177
|
+
config: { defaults: { timeout: 5, pollInterval: 0.01, captureLines: 100, preambleEvery: 3 } },
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
await cmdTalk(ctx, 'claude', 'Test message');
|
|
1181
|
+
|
|
1182
|
+
const sent = tmux.sends[0].message;
|
|
1183
|
+
expect(sent).toMatch(/\{tmux-team-start:[a-f0-9]+\}/);
|
|
1184
|
+
expect(sent).toMatch(/\{tmux-team-end:[a-f0-9]+\}/);
|
|
1185
|
+
|
|
1186
|
+
// Both markers should have same nonce
|
|
1187
|
+
const startMatch = sent.match(/\{tmux-team-start:([a-f0-9]+)\}/);
|
|
1188
|
+
const endMatch = sent.match(/\{tmux-team-end:([a-f0-9]+)\}/);
|
|
1189
|
+
expect(startMatch?.[1]).toBe(endMatch?.[1]);
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
it('extracts only content between start and end markers', async () => {
|
|
1193
|
+
const tmux = createMockTmux();
|
|
1194
|
+
const ui = createMockUI();
|
|
1195
|
+
|
|
1196
|
+
tmux.capture = () => {
|
|
1197
|
+
const sent = tmux.sends[0]?.message || '';
|
|
1198
|
+
const endMatch = sent.match(/\{tmux-team-end:([a-f0-9]+)\}/);
|
|
1199
|
+
if (endMatch) {
|
|
1200
|
+
// Simulate scrollback with content before start marker, then proper instruction + response
|
|
1201
|
+
return `Old garbage\nMore old stuff\n{tmux-team-start:${endMatch[1]}}\nThe original message\n\n[IMPORTANT: print {tmux-team-end:${endMatch[1]}}]\nThis is the actual response\n\n{tmux-team-end:${endMatch[1]}}\nContent after marker`;
|
|
1202
|
+
}
|
|
1203
|
+
return 'Old garbage\nMore old stuff';
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
const ctx = createContext({
|
|
1207
|
+
tmux,
|
|
1208
|
+
ui,
|
|
1209
|
+
paths: createTestPaths(testDir),
|
|
1210
|
+
flags: { wait: true, json: true, timeout: 5 },
|
|
1211
|
+
config: { defaults: { timeout: 5, pollInterval: 0.01, captureLines: 100, preambleEvery: 3 } },
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
await cmdTalk(ctx, 'claude', 'Test');
|
|
1215
|
+
|
|
1216
|
+
const output = ui.jsonOutput[0] as Record<string, unknown>;
|
|
1217
|
+
expect(output.status).toBe('completed');
|
|
1218
|
+
expect(output.response).toContain('actual response');
|
|
1219
|
+
expect(output.response).not.toContain('Old garbage');
|
|
1220
|
+
expect(output.response).not.toContain('Content after marker');
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
it('handles multiline responses correctly', async () => {
|
|
1224
|
+
const tmux = createMockTmux();
|
|
1225
|
+
const ui = createMockUI();
|
|
1226
|
+
|
|
1227
|
+
const multilineResponse = `Line 1 of response
|
|
1228
|
+
Line 2 of response
|
|
1229
|
+
Line 3 with special chars: <>&"'
|
|
1230
|
+
Line 4 final`;
|
|
1231
|
+
|
|
1232
|
+
tmux.capture = () => {
|
|
1233
|
+
const sent = tmux.sends[0]?.message || '';
|
|
1234
|
+
const endMatch = sent.match(/\{tmux-team-end:([a-f0-9]+)\}/);
|
|
1235
|
+
if (endMatch) {
|
|
1236
|
+
return `{tmux-team-start:${endMatch[1]}}\nMessage\n\n[IMPORTANT: print {tmux-team-end:${endMatch[1]}}]\n${multilineResponse}\n\n{tmux-team-end:${endMatch[1]}}`;
|
|
1237
|
+
}
|
|
1238
|
+
return '';
|
|
1239
|
+
};
|
|
1240
|
+
|
|
1241
|
+
const ctx = createContext({
|
|
1242
|
+
tmux,
|
|
1243
|
+
ui,
|
|
1244
|
+
paths: createTestPaths(testDir),
|
|
1245
|
+
flags: { wait: true, json: true, timeout: 5 },
|
|
1246
|
+
config: { defaults: { timeout: 5, pollInterval: 0.01, captureLines: 100, preambleEvery: 3 } },
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
await cmdTalk(ctx, 'claude', 'Test');
|
|
1250
|
+
|
|
1251
|
+
const output = ui.jsonOutput[0] as Record<string, unknown>;
|
|
1252
|
+
expect(output.response).toContain('Line 1 of response');
|
|
1253
|
+
expect(output.response).toContain('Line 4 final');
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
it('handles empty response between markers', async () => {
|
|
1257
|
+
const tmux = createMockTmux();
|
|
1258
|
+
const ui = createMockUI();
|
|
1259
|
+
|
|
1260
|
+
tmux.capture = () => {
|
|
1261
|
+
const sent = tmux.sends[0]?.message || '';
|
|
1262
|
+
const endMatch = sent.match(/\{tmux-team-end:([a-f0-9]+)\}/);
|
|
1263
|
+
if (endMatch) {
|
|
1264
|
+
// Agent printed end marker immediately with no content
|
|
1265
|
+
return `{tmux-team-start:${endMatch[1]}}\nMessage here\n\n[IMPORTANT: print {tmux-team-end:${endMatch[1]}}]\n{tmux-team-end:${endMatch[1]}}`;
|
|
1266
|
+
}
|
|
1267
|
+
return '';
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
const ctx = createContext({
|
|
1271
|
+
tmux,
|
|
1272
|
+
ui,
|
|
1273
|
+
paths: createTestPaths(testDir),
|
|
1274
|
+
flags: { wait: true, json: true, timeout: 5 },
|
|
1275
|
+
config: { defaults: { timeout: 5, pollInterval: 0.01, captureLines: 100, preambleEvery: 3 } },
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
await cmdTalk(ctx, 'claude', 'Test');
|
|
1279
|
+
|
|
1280
|
+
const output = ui.jsonOutput[0] as Record<string, unknown>;
|
|
1281
|
+
expect(output.status).toBe('completed');
|
|
1282
|
+
// Response should be the message content (trimmed)
|
|
1283
|
+
expect(typeof output.response).toBe('string');
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
it('correctly handles start marker on same line as content', async () => {
|
|
1287
|
+
const tmux = createMockTmux();
|
|
1288
|
+
const ui = createMockUI();
|
|
1289
|
+
|
|
1290
|
+
tmux.capture = () => {
|
|
1291
|
+
const sent = tmux.sends[0]?.message || '';
|
|
1292
|
+
const endMatch = sent.match(/\{tmux-team-end:([a-f0-9]+)\}/);
|
|
1293
|
+
if (endMatch) {
|
|
1294
|
+
// Start marker followed by newline, then instruction and content
|
|
1295
|
+
return `Old stuff\n{tmux-team-start:${endMatch[1]}}\nMessage\n\n[IMPORTANT: print {tmux-team-end:${endMatch[1]}}]\nActual response content\n{tmux-team-end:${endMatch[1]}}`;
|
|
1296
|
+
}
|
|
1297
|
+
return 'Old stuff';
|
|
1298
|
+
};
|
|
1299
|
+
|
|
1300
|
+
const ctx = createContext({
|
|
1301
|
+
tmux,
|
|
1302
|
+
ui,
|
|
1303
|
+
paths: createTestPaths(testDir),
|
|
1304
|
+
flags: { wait: true, json: true, timeout: 5 },
|
|
1305
|
+
config: { defaults: { timeout: 5, pollInterval: 0.01, captureLines: 100, preambleEvery: 3 } },
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
await cmdTalk(ctx, 'claude', 'Test');
|
|
1309
|
+
|
|
1310
|
+
const output = ui.jsonOutput[0] as Record<string, unknown>;
|
|
1311
|
+
expect(output.response).not.toContain('Old stuff');
|
|
1312
|
+
expect(output.response).toContain('Actual response content');
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
it('uses lastIndexOf for start marker to handle multiple occurrences', async () => {
|
|
1316
|
+
const tmux = createMockTmux();
|
|
1317
|
+
const ui = createMockUI();
|
|
1318
|
+
|
|
1319
|
+
let captureCount = 0;
|
|
1320
|
+
tmux.capture = () => {
|
|
1321
|
+
captureCount++;
|
|
1322
|
+
const sent = tmux.sends[0]?.message || '';
|
|
1323
|
+
const startMatch = sent.match(/\{tmux-team-start:([a-f0-9]+)\}/);
|
|
1324
|
+
const endMatch = sent.match(/\{tmux-team-end:([a-f0-9]+)\}/);
|
|
1325
|
+
if (startMatch && endMatch) {
|
|
1326
|
+
if (captureCount < 3) {
|
|
1327
|
+
// First few captures: old markers in history, new start marker sent but not end yet
|
|
1328
|
+
// (only ONE end marker in instruction = still waiting)
|
|
1329
|
+
return `{tmux-team-start:old1}\nOld message\nOld response\n{tmux-team-end:old1}\n\n{tmux-team-start:${startMatch[1]}}\nNew message\n[When done: {tmux-team-end:${endMatch[1]}}]`;
|
|
1330
|
+
}
|
|
1331
|
+
// Finally, new end marker appears (TWO occurrences = complete)
|
|
1332
|
+
return `{tmux-team-start:old1}\nOld message\nOld response\n{tmux-team-end:old1}\n\n{tmux-team-start:${startMatch[1]}}\nNew message\n[When done: {tmux-team-end:${endMatch[1]}}]\n\nNew actual response\n\n{tmux-team-end:${endMatch[1]}}`;
|
|
1333
|
+
}
|
|
1334
|
+
return '';
|
|
1335
|
+
};
|
|
1336
|
+
|
|
1337
|
+
const ctx = createContext({
|
|
1338
|
+
tmux,
|
|
1339
|
+
ui,
|
|
1340
|
+
paths: createTestPaths(testDir),
|
|
1341
|
+
flags: { wait: true, json: true, timeout: 5 },
|
|
1342
|
+
config: { defaults: { timeout: 5, pollInterval: 0.01, captureLines: 100, preambleEvery: 3 } },
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
await cmdTalk(ctx, 'claude', 'Test');
|
|
1346
|
+
|
|
1347
|
+
const output = ui.jsonOutput[0] as Record<string, unknown>;
|
|
1348
|
+
expect(output.status).toBe('completed');
|
|
1349
|
+
// Should get response from the NEW start marker, not the old one
|
|
1350
|
+
expect(output.response).toContain('New actual response');
|
|
1351
|
+
expect(output.response).not.toContain('Old response');
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
it('handles large scrollback with markers at edges', async () => {
|
|
1355
|
+
const tmux = createMockTmux();
|
|
1356
|
+
const ui = createMockUI();
|
|
1357
|
+
|
|
1358
|
+
// Simulate 100+ lines of scrollback
|
|
1359
|
+
const lotsOfContent = Array.from({ length: 150 }, (_, i) => `Line ${i}`).join('\n');
|
|
1360
|
+
|
|
1361
|
+
tmux.capture = () => {
|
|
1362
|
+
const sent = tmux.sends[0]?.message || '';
|
|
1363
|
+
const startMatch = sent.match(/\{tmux-team-start:([a-f0-9]+)\}/);
|
|
1364
|
+
const endMatch = sent.match(/\{tmux-team-end:([a-f0-9]+)\}/);
|
|
1365
|
+
if (startMatch && endMatch) {
|
|
1366
|
+
// TWO end markers: one in instruction, one from "agent" response
|
|
1367
|
+
return `${lotsOfContent}\n{tmux-team-start:${startMatch[1]}}\nMessage\n[When done: {tmux-team-end:${endMatch[1]}}]\n\nThe actual response\n\n{tmux-team-end:${endMatch[1]}}`;
|
|
1368
|
+
}
|
|
1369
|
+
return lotsOfContent;
|
|
1370
|
+
};
|
|
1371
|
+
|
|
1372
|
+
const ctx = createContext({
|
|
1373
|
+
tmux,
|
|
1374
|
+
ui,
|
|
1375
|
+
paths: createTestPaths(testDir),
|
|
1376
|
+
flags: { wait: true, json: true, timeout: 5 },
|
|
1377
|
+
config: { defaults: { timeout: 5, pollInterval: 0.01, captureLines: 200, preambleEvery: 3 } },
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
await cmdTalk(ctx, 'claude', 'Test');
|
|
1381
|
+
|
|
1382
|
+
const output = ui.jsonOutput[0] as Record<string, unknown>;
|
|
1383
|
+
expect(output.status).toBe('completed');
|
|
1384
|
+
expect(output.response).toContain('actual response');
|
|
1385
|
+
expect(output.response).not.toContain('Line 0');
|
|
991
1386
|
});
|
|
992
1387
|
});
|