agent-gear 1.1.5 → 1.1.7
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/dist/acp/bridge.d.ts +83 -3
- package/dist/acp/bridge.d.ts.map +1 -1
- package/dist/acp/bridge.js +386 -62
- package/dist/acp/bridge.js.map +1 -1
- package/dist/acp/bridge.test.js +131 -8
- package/dist/acp/bridge.test.js.map +1 -1
- package/dist/acp/spawner.js +4 -4
- package/dist/acp/spawner.js.map +1 -1
- package/dist/acp/sync-bridge.d.ts +6 -1
- package/dist/acp/sync-bridge.d.ts.map +1 -1
- package/dist/acp/sync-bridge.js +23 -3
- package/dist/acp/sync-bridge.js.map +1 -1
- package/dist/commands/mcp-proxy.d.ts +10 -0
- package/dist/commands/mcp-proxy.d.ts.map +1 -1
- package/dist/commands/mcp-proxy.js +78 -24
- package/dist/commands/mcp-proxy.js.map +1 -1
- package/dist/socketio-client.d.ts.map +1 -1
- package/dist/socketio-client.js +164 -4
- package/dist/socketio-client.js.map +1 -1
- package/package.json +2 -2
- package/src/acp/bridge.test.ts +160 -8
- package/src/acp/bridge.ts +479 -63
- package/src/acp/spawner.ts +4 -4
- package/src/acp/sync-bridge.ts +27 -3
- package/src/commands/mcp-proxy.ts +91 -28
- package/src/socketio-client.ts +197 -5
- package/tests/acp-bridge.test.ts +369 -31
- package/tests/acp-spawner.test.ts +28 -1
- package/tests/acp-sync-bridge.test.ts +39 -1
- package/tests/acp-wire-elicitation.test.ts +158 -0
- package/tests/acp-wire-mcp.test.ts +114 -0
- package/tests/commands/mcp-proxy-dispatcher.test.ts +117 -0
- package/tests/fixtures/fake-acp-agent.mjs +122 -0
- package/tests/socketio-client.test.ts +211 -0
package/tests/acp-bridge.test.ts
CHANGED
|
@@ -9,7 +9,9 @@ function buildMockConn() {
|
|
|
9
9
|
cancel: vi.fn<[unknown], Promise<void>>().mockResolvedValue(undefined),
|
|
10
10
|
closeSession: vi.fn<[unknown], Promise<object>>().mockResolvedValue({}),
|
|
11
11
|
setSessionMode: vi.fn<[unknown], Promise<object>>().mockResolvedValue({}),
|
|
12
|
-
|
|
12
|
+
setSessionConfigOption: vi.fn<[unknown], Promise<object>>().mockResolvedValue({}),
|
|
13
|
+
authenticate: vi.fn<[unknown], Promise<object>>().mockResolvedValue({}),
|
|
14
|
+
logout: vi.fn<[unknown], Promise<object>>().mockResolvedValue({}),
|
|
13
15
|
};
|
|
14
16
|
}
|
|
15
17
|
|
|
@@ -18,9 +20,13 @@ let mockConnInstance = buildMockConn();
|
|
|
18
20
|
vi.mock('@agentclientprotocol/sdk', () => ({
|
|
19
21
|
ndJsonStream: vi.fn(() => ({})),
|
|
20
22
|
ClientSideConnection: vi.fn(function () { return mockConnInstance; }),
|
|
23
|
+
CreateElicitationRequest: {
|
|
24
|
+
isForm: (v: { mode?: string }) => v.mode === 'form',
|
|
25
|
+
isUrl: (v: { mode?: string }) => v.mode === 'url',
|
|
26
|
+
},
|
|
21
27
|
}));
|
|
22
28
|
|
|
23
|
-
import { AcpSessionBridge } from '../../src/acp/bridge.js';
|
|
29
|
+
import { AcpSessionBridge, extractConfigOptions } from '../../src/acp/bridge.js';
|
|
24
30
|
|
|
25
31
|
class MockAcpSpawner {
|
|
26
32
|
start = vi.fn();
|
|
@@ -74,8 +80,13 @@ describe('AcpSessionBridge', () => {
|
|
|
74
80
|
expect(mockSpawner.start).toHaveBeenCalledOnce();
|
|
75
81
|
expect(mc().initialize).toHaveBeenCalledWith({
|
|
76
82
|
protocolVersion: 1,
|
|
77
|
-
clientCapabilities: {
|
|
78
|
-
|
|
83
|
+
clientCapabilities: {
|
|
84
|
+
session: {
|
|
85
|
+
configOptions: { boolean: {} },
|
|
86
|
+
},
|
|
87
|
+
elicitation: { form: {}, url: {} },
|
|
88
|
+
},
|
|
89
|
+
clientInfo: { name: 'agent-gear', version: '1.1.5' },
|
|
79
90
|
});
|
|
80
91
|
expect(mc().newSession).toHaveBeenCalledWith({
|
|
81
92
|
cwd: '/home/user',
|
|
@@ -121,10 +132,16 @@ describe('AcpSessionBridge', () => {
|
|
|
121
132
|
it('should include currentModel in acp:initialized when available', async () => {
|
|
122
133
|
mockConnInstance.newSession = vi.fn().mockResolvedValue({
|
|
123
134
|
sessionId: 'acp-sess-abc',
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
135
|
+
configOptions: [
|
|
136
|
+
{
|
|
137
|
+
id: 'model',
|
|
138
|
+
name: 'Model',
|
|
139
|
+
category: 'model',
|
|
140
|
+
type: 'select',
|
|
141
|
+
currentValue: 'gpt-4',
|
|
142
|
+
options: [{ value: 'gpt-4', name: 'GPT-4' }],
|
|
143
|
+
},
|
|
144
|
+
],
|
|
128
145
|
});
|
|
129
146
|
const bridge = createBridge();
|
|
130
147
|
const emitSpy = vi.fn();
|
|
@@ -141,13 +158,19 @@ describe('AcpSessionBridge', () => {
|
|
|
141
158
|
it('should include availableModels and currentModelId in acp:initialized', async () => {
|
|
142
159
|
mockConnInstance.newSession = vi.fn().mockResolvedValue({
|
|
143
160
|
sessionId: 'acp-sess-abc',
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
161
|
+
configOptions: [
|
|
162
|
+
{
|
|
163
|
+
id: 'model',
|
|
164
|
+
name: 'Model',
|
|
165
|
+
category: 'model',
|
|
166
|
+
type: 'select',
|
|
167
|
+
currentValue: 'claude-3',
|
|
168
|
+
options: [
|
|
169
|
+
{ value: 'gpt-4', name: 'GPT-4' },
|
|
170
|
+
{ value: 'claude-3', name: 'Claude 3' },
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
],
|
|
151
174
|
});
|
|
152
175
|
const bridge = createBridge();
|
|
153
176
|
const emitSpy = vi.fn();
|
|
@@ -165,6 +188,22 @@ describe('AcpSessionBridge', () => {
|
|
|
165
188
|
}));
|
|
166
189
|
});
|
|
167
190
|
|
|
191
|
+
it('should map auth method without type field to kind agent', async () => {
|
|
192
|
+
mc().initialize.mockResolvedValue({
|
|
193
|
+
protocolVersion: 1,
|
|
194
|
+
authMethods: [{ id: 'oauth', description: 'Login with agent' }],
|
|
195
|
+
});
|
|
196
|
+
const bridge = createBridge();
|
|
197
|
+
|
|
198
|
+
bridge.start();
|
|
199
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
200
|
+
|
|
201
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:auth:required', {
|
|
202
|
+
sessionId: 'sess-1',
|
|
203
|
+
authMethods: [{ id: 'oauth', kind: 'agent', description: null, url: null }],
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
168
207
|
it('should emit acp:stopped on initialize failure', async () => {
|
|
169
208
|
mc().initialize.mockRejectedValue(new Error('init failed'));
|
|
170
209
|
const bridge = createBridge();
|
|
@@ -201,6 +240,7 @@ describe('AcpSessionBridge', () => {
|
|
|
201
240
|
expect(mc().resumeSession).toHaveBeenCalledWith({
|
|
202
241
|
sessionId: 'existing-sess-id',
|
|
203
242
|
cwd: '/home/user',
|
|
243
|
+
mcpServers: [],
|
|
204
244
|
});
|
|
205
245
|
expect(emitSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
206
246
|
sessionId: 'sess-1',
|
|
@@ -491,6 +531,119 @@ describe('AcpSessionBridge', () => {
|
|
|
491
531
|
});
|
|
492
532
|
});
|
|
493
533
|
|
|
534
|
+
describe('unstable_createElicitation', () => {
|
|
535
|
+
it('form 模式:emit acp:elicitation 并在响应后 resolve accept + content', async () => {
|
|
536
|
+
const bridge = createBridge();
|
|
537
|
+
const requestedSchema = {
|
|
538
|
+
type: 'object',
|
|
539
|
+
properties: { name: { type: 'string' } },
|
|
540
|
+
required: ['name'],
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const promise = bridge.unstable_createElicitation({
|
|
544
|
+
mode: 'form',
|
|
545
|
+
message: 'Need your name',
|
|
546
|
+
sessionId: 'acp-sess-abc',
|
|
547
|
+
toolCallId: 'tc-1',
|
|
548
|
+
requestedSchema,
|
|
549
|
+
} as never);
|
|
550
|
+
|
|
551
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:elicitation', expect.objectContaining({
|
|
552
|
+
sessionId: 'sess-1',
|
|
553
|
+
mode: 'form',
|
|
554
|
+
message: 'Need your name',
|
|
555
|
+
toolCallId: 'tc-1',
|
|
556
|
+
requestedSchema,
|
|
557
|
+
}));
|
|
558
|
+
|
|
559
|
+
const emitCall = mockSocket.emit.mock.calls.find((c: unknown[]) => c[0] === 'acp:elicitation');
|
|
560
|
+
const requestId = (emitCall![1] as { requestId: string }).requestId;
|
|
561
|
+
expect(requestId).toMatch(/^elic_/);
|
|
562
|
+
|
|
563
|
+
bridge.handleElicitationResponse(requestId, 'accept', { name: 'ash' });
|
|
564
|
+
|
|
565
|
+
const result = await promise;
|
|
566
|
+
expect(result).toEqual({ action: 'accept', content: { name: 'ash' } });
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('url 模式:透传 elicitationId 与 url', async () => {
|
|
570
|
+
const bridge = createBridge();
|
|
571
|
+
|
|
572
|
+
const promise = bridge.unstable_createElicitation({
|
|
573
|
+
mode: 'url',
|
|
574
|
+
message: 'Authorize access',
|
|
575
|
+
sessionId: 'acp-sess-abc',
|
|
576
|
+
elicitationId: 'elic-agent-1',
|
|
577
|
+
url: 'https://example.com/auth',
|
|
578
|
+
} as never);
|
|
579
|
+
|
|
580
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:elicitation', expect.objectContaining({
|
|
581
|
+
sessionId: 'sess-1',
|
|
582
|
+
mode: 'url',
|
|
583
|
+
message: 'Authorize access',
|
|
584
|
+
elicitationId: 'elic-agent-1',
|
|
585
|
+
url: 'https://example.com/auth',
|
|
586
|
+
}));
|
|
587
|
+
|
|
588
|
+
const emitCall = mockSocket.emit.mock.calls.find((c: unknown[]) => c[0] === 'acp:elicitation');
|
|
589
|
+
const requestId = (emitCall![1] as { requestId: string }).requestId;
|
|
590
|
+
bridge.handleElicitationResponse(requestId, 'decline');
|
|
591
|
+
|
|
592
|
+
const result = await promise;
|
|
593
|
+
expect(result).toEqual({ action: 'decline' });
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it('custom/未知模式:立即返回 cancel 且不 emit socket 事件', async () => {
|
|
597
|
+
const bridge = createBridge();
|
|
598
|
+
|
|
599
|
+
const result = await bridge.unstable_createElicitation({
|
|
600
|
+
mode: '_vendor_custom',
|
|
601
|
+
message: 'custom request',
|
|
602
|
+
sessionId: 'acp-sess-abc',
|
|
603
|
+
} as never);
|
|
604
|
+
|
|
605
|
+
expect(result).toEqual({ action: 'cancel' });
|
|
606
|
+
expect(mockSocket.emit).not.toHaveBeenCalledWith('acp:elicitation', expect.anything());
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('65s 超时自动 resolve cancel', async () => {
|
|
610
|
+
const bridge = createBridge();
|
|
611
|
+
|
|
612
|
+
const promise = bridge.unstable_createElicitation({
|
|
613
|
+
mode: 'form',
|
|
614
|
+
message: 'm',
|
|
615
|
+
sessionId: 'acp-sess-abc',
|
|
616
|
+
requestedSchema: { type: 'object' },
|
|
617
|
+
} as never);
|
|
618
|
+
|
|
619
|
+
await vi.advanceTimersByTimeAsync(65_000);
|
|
620
|
+
|
|
621
|
+
const result = await promise;
|
|
622
|
+
expect(result).toEqual({ action: 'cancel' });
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('handleElicitationResponse 未知 requestId 打 warn 不抛错', () => {
|
|
626
|
+
const bridge = createBridge();
|
|
627
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
628
|
+
|
|
629
|
+
expect(() => bridge.handleElicitationResponse('elic_unknown', 'cancel')).not.toThrow();
|
|
630
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('elic_unknown'));
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
describe('unstable_completeElicitation', () => {
|
|
635
|
+
it('emit acp:elicitation:complete', async () => {
|
|
636
|
+
const bridge = createBridge();
|
|
637
|
+
|
|
638
|
+
await bridge.unstable_completeElicitation({ elicitationId: 'elic-agent-1' } as never);
|
|
639
|
+
|
|
640
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:elicitation:complete', {
|
|
641
|
+
sessionId: 'sess-1',
|
|
642
|
+
elicitationId: 'elic-agent-1',
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
|
|
494
647
|
describe('setMode', () => {
|
|
495
648
|
it('should call connection.setSessionMode', async () => {
|
|
496
649
|
const bridge = createBridge();
|
|
@@ -511,51 +664,236 @@ describe('AcpSessionBridge', () => {
|
|
|
511
664
|
await expect(bridge.setMode('mode-1')).resolves.toBeUndefined();
|
|
512
665
|
expect(mc().setSessionMode).not.toHaveBeenCalled();
|
|
513
666
|
});
|
|
667
|
+
|
|
668
|
+
it('should use setSessionConfigOption when mode config option exists', async () => {
|
|
669
|
+
mockConnInstance.newSession = vi.fn().mockResolvedValue({
|
|
670
|
+
sessionId: 'acp-sess-abc',
|
|
671
|
+
configOptions: [
|
|
672
|
+
{
|
|
673
|
+
id: 'mode',
|
|
674
|
+
name: 'Mode',
|
|
675
|
+
category: 'mode',
|
|
676
|
+
type: 'select',
|
|
677
|
+
currentValue: 'code',
|
|
678
|
+
options: [{ value: 'code', name: 'Code' }, { value: 'plan', name: 'Plan' }],
|
|
679
|
+
},
|
|
680
|
+
],
|
|
681
|
+
});
|
|
682
|
+
const bridge = createBridge();
|
|
683
|
+
bridge.start();
|
|
684
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
685
|
+
|
|
686
|
+
await bridge.setMode('plan');
|
|
687
|
+
|
|
688
|
+
expect(mc().setSessionConfigOption).toHaveBeenCalledWith({
|
|
689
|
+
sessionId: 'acp-sess-abc',
|
|
690
|
+
configId: 'mode',
|
|
691
|
+
value: 'plan',
|
|
692
|
+
});
|
|
693
|
+
});
|
|
514
694
|
});
|
|
515
695
|
|
|
516
696
|
describe('setModel', () => {
|
|
517
|
-
it('should call
|
|
697
|
+
it('should call setSessionConfigOption when model config option exists', async () => {
|
|
698
|
+
mockConnInstance.newSession = vi.fn().mockResolvedValue({
|
|
699
|
+
sessionId: 'acp-sess-abc',
|
|
700
|
+
configOptions: [
|
|
701
|
+
{
|
|
702
|
+
id: 'model',
|
|
703
|
+
name: 'Model',
|
|
704
|
+
category: 'model',
|
|
705
|
+
type: 'select',
|
|
706
|
+
currentValue: 'gpt-4',
|
|
707
|
+
options: [{ value: 'gpt-4', name: 'GPT-4' }, { value: 'claude-3', name: 'Claude 3' }],
|
|
708
|
+
},
|
|
709
|
+
],
|
|
710
|
+
});
|
|
518
711
|
const bridge = createBridge();
|
|
519
712
|
bridge.start();
|
|
520
713
|
await vi.advanceTimersByTimeAsync(0);
|
|
521
714
|
|
|
522
|
-
await bridge.setModel('
|
|
715
|
+
await bridge.setModel('claude-3');
|
|
523
716
|
|
|
524
|
-
expect(mc().
|
|
717
|
+
expect(mc().setSessionConfigOption).toHaveBeenCalledWith({
|
|
525
718
|
sessionId: 'acp-sess-abc',
|
|
526
|
-
|
|
719
|
+
configId: 'model',
|
|
720
|
+
value: 'claude-3',
|
|
527
721
|
});
|
|
528
722
|
});
|
|
529
723
|
|
|
724
|
+
it('should throw when initialized but no model config option', async () => {
|
|
725
|
+
const bridge = createBridge();
|
|
726
|
+
bridge.start();
|
|
727
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
728
|
+
|
|
729
|
+
await expect(bridge.setModel('model-1')).rejects.toThrow(
|
|
730
|
+
'Model switching is not supported',
|
|
731
|
+
);
|
|
732
|
+
expect(mc().setSessionConfigOption).not.toHaveBeenCalled();
|
|
733
|
+
});
|
|
734
|
+
|
|
530
735
|
it('should not throw when not initialized', async () => {
|
|
531
736
|
const bridge = createBridge();
|
|
532
737
|
|
|
533
738
|
await expect(bridge.setModel('model-1')).resolves.toBeUndefined();
|
|
534
|
-
expect(mc().unstable_setSessionModel).not.toHaveBeenCalled();
|
|
535
739
|
});
|
|
536
740
|
});
|
|
537
741
|
|
|
538
|
-
describe('
|
|
539
|
-
it('should
|
|
742
|
+
describe('setConfigOption', () => {
|
|
743
|
+
it('should call connection.setSessionConfigOption with boolean value', async () => {
|
|
540
744
|
const bridge = createBridge();
|
|
745
|
+
bridge.start();
|
|
746
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
747
|
+
|
|
748
|
+
await bridge.setConfigOption('opt-1', true);
|
|
541
749
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
750
|
+
expect(mc().setSessionConfigOption).toHaveBeenCalledWith({
|
|
751
|
+
sessionId: 'acp-sess-abc',
|
|
752
|
+
configId: 'opt-1',
|
|
753
|
+
value: true,
|
|
754
|
+
type: 'boolean',
|
|
755
|
+
});
|
|
545
756
|
});
|
|
546
757
|
|
|
547
|
-
it('should
|
|
758
|
+
it('should call connection.setSessionConfigOption with select value', async () => {
|
|
548
759
|
const bridge = createBridge();
|
|
760
|
+
bridge.start();
|
|
761
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
549
762
|
|
|
550
|
-
|
|
551
|
-
|
|
763
|
+
await bridge.setConfigOption('opt-2', 'val-1');
|
|
764
|
+
|
|
765
|
+
expect(mc().setSessionConfigOption).toHaveBeenCalledWith({
|
|
766
|
+
sessionId: 'acp-sess-abc',
|
|
767
|
+
configId: 'opt-2',
|
|
768
|
+
value: 'val-1',
|
|
769
|
+
});
|
|
552
770
|
});
|
|
553
771
|
|
|
554
|
-
it('should
|
|
772
|
+
it('should update snapshot and emit acp:config-options from set_config_option response', async () => {
|
|
773
|
+
mockConnInstance.setSessionConfigOption = vi.fn().mockResolvedValue({
|
|
774
|
+
configOptions: [
|
|
775
|
+
{
|
|
776
|
+
id: 'flags',
|
|
777
|
+
name: 'Flags',
|
|
778
|
+
category: null,
|
|
779
|
+
type: 'boolean',
|
|
780
|
+
currentValue: true,
|
|
781
|
+
},
|
|
782
|
+
],
|
|
783
|
+
});
|
|
555
784
|
const bridge = createBridge();
|
|
785
|
+
bridge.start();
|
|
786
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
556
787
|
|
|
557
|
-
|
|
558
|
-
|
|
788
|
+
await bridge.setConfigOption('flags', 'true');
|
|
789
|
+
|
|
790
|
+
expect(bridge.getConfigOptions()).toEqual([
|
|
791
|
+
{
|
|
792
|
+
id: 'flags',
|
|
793
|
+
name: 'Flags',
|
|
794
|
+
description: null,
|
|
795
|
+
category: null,
|
|
796
|
+
type: 'boolean',
|
|
797
|
+
currentValue: 'true',
|
|
798
|
+
options: [],
|
|
799
|
+
},
|
|
800
|
+
]);
|
|
801
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:config-options', {
|
|
802
|
+
sessionId: 'sess-1',
|
|
803
|
+
configOptions: bridge.getConfigOptions(),
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it('should not emit acp:config-options when response has no configOptions', async () => {
|
|
808
|
+
mockConnInstance.setSessionConfigOption = vi.fn().mockResolvedValue({});
|
|
809
|
+
const bridge = createBridge();
|
|
810
|
+
bridge.start();
|
|
811
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
812
|
+
|
|
813
|
+
await bridge.setConfigOption('opt-2', 'val-1');
|
|
814
|
+
|
|
815
|
+
expect(mockSocket.emit).not.toHaveBeenCalledWith('acp:config-options', expect.anything());
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
describe('extractConfigOptions', () => {
|
|
820
|
+
it('should return empty array when configOptions is null or undefined', () => {
|
|
821
|
+
expect(extractConfigOptions(null)).toEqual([]);
|
|
822
|
+
expect(extractConfigOptions(undefined)).toEqual([]);
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it('should flatten grouped select options with group name prefix', () => {
|
|
826
|
+
const result = extractConfigOptions([
|
|
827
|
+
{
|
|
828
|
+
id: 'model',
|
|
829
|
+
name: 'Model',
|
|
830
|
+
category: 'model',
|
|
831
|
+
type: 'select',
|
|
832
|
+
currentValue: 'gpt-4',
|
|
833
|
+
options: [
|
|
834
|
+
{
|
|
835
|
+
group: 'openai',
|
|
836
|
+
name: 'OpenAI',
|
|
837
|
+
options: [
|
|
838
|
+
{ value: 'gpt-4', name: 'GPT-4' },
|
|
839
|
+
{ value: 'gpt-4o', name: 'GPT-4o' },
|
|
840
|
+
],
|
|
841
|
+
},
|
|
842
|
+
{
|
|
843
|
+
group: 'anthropic',
|
|
844
|
+
name: 'Anthropic',
|
|
845
|
+
options: [{ value: 'claude-3', name: 'Claude 3' }],
|
|
846
|
+
},
|
|
847
|
+
],
|
|
848
|
+
},
|
|
849
|
+
]);
|
|
850
|
+
|
|
851
|
+
expect(result[0].options).toEqual([
|
|
852
|
+
{ value: 'gpt-4', name: 'OpenAI/GPT-4' },
|
|
853
|
+
{ value: 'gpt-4o', name: 'OpenAI/GPT-4o' },
|
|
854
|
+
{ value: 'claude-3', name: 'Anthropic/Claude 3' },
|
|
855
|
+
]);
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('should map select and boolean config options', () => {
|
|
859
|
+
const result = extractConfigOptions([
|
|
860
|
+
{
|
|
861
|
+
id: 'model',
|
|
862
|
+
name: 'Model',
|
|
863
|
+
category: 'model',
|
|
864
|
+
type: 'select',
|
|
865
|
+
currentValue: 'gpt-4',
|
|
866
|
+
options: [{ value: 'gpt-4', name: 'GPT-4' }],
|
|
867
|
+
},
|
|
868
|
+
{
|
|
869
|
+
id: 'flags',
|
|
870
|
+
name: 'Flags',
|
|
871
|
+
category: null,
|
|
872
|
+
type: 'boolean',
|
|
873
|
+
currentValue: true,
|
|
874
|
+
},
|
|
875
|
+
]);
|
|
876
|
+
|
|
877
|
+
expect(result).toEqual([
|
|
878
|
+
{
|
|
879
|
+
id: 'model',
|
|
880
|
+
name: 'Model',
|
|
881
|
+
description: null,
|
|
882
|
+
category: 'model',
|
|
883
|
+
type: 'select',
|
|
884
|
+
currentValue: 'gpt-4',
|
|
885
|
+
options: [{ value: 'gpt-4', name: 'GPT-4' }],
|
|
886
|
+
},
|
|
887
|
+
{
|
|
888
|
+
id: 'flags',
|
|
889
|
+
name: 'Flags',
|
|
890
|
+
description: null,
|
|
891
|
+
category: null,
|
|
892
|
+
type: 'boolean',
|
|
893
|
+
currentValue: 'true',
|
|
894
|
+
options: [],
|
|
895
|
+
},
|
|
896
|
+
]);
|
|
559
897
|
});
|
|
560
898
|
});
|
|
561
899
|
});
|
|
@@ -68,7 +68,34 @@ describe('AcpSpawner', () => {
|
|
|
68
68
|
spawner.start();
|
|
69
69
|
const stderr = mockProcess.stderr as Readable;
|
|
70
70
|
stderr.emit('data', Buffer.from('some warning\n'));
|
|
71
|
-
expect(consoleErrorSpy).toHaveBeenCalledWith('ACP stderr: some warning\n');
|
|
71
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith('ACP stderr (session=sess-1): some warning\n');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should log resolved command, args and cwd on start', () => {
|
|
75
|
+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
76
|
+
spawner.start();
|
|
77
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
78
|
+
'Starting ACP server: session=sess-1 cmd=/usr/bin/opencode args=["acp"] cwd=/tmp/project',
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should log exit code, signal and spawn params on process exit', () => {
|
|
83
|
+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
84
|
+
spawner.start();
|
|
85
|
+
mockProcess.emit('exit', 1, null);
|
|
86
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
87
|
+
'ACP process exited: session=sess-1 code=1 signal=null cmd=/usr/bin/opencode args=["acp"]',
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should log process error with session and command', () => {
|
|
92
|
+
spawner.start();
|
|
93
|
+
const error = new Error('spawn opencode ENOENT');
|
|
94
|
+
mockProcess.emit('error', error);
|
|
95
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
96
|
+
'ACP process error (session=sess-1, cmd=/usr/bin/opencode):',
|
|
97
|
+
error,
|
|
98
|
+
);
|
|
72
99
|
});
|
|
73
100
|
|
|
74
101
|
it('should resolve closed on process exit', async () => {
|
|
@@ -4,8 +4,9 @@ function buildMockConn() {
|
|
|
4
4
|
return {
|
|
5
5
|
initialize: vi.fn<[unknown], Promise<{ protocolVersion: number }>>().mockResolvedValue({ protocolVersion: 1 }),
|
|
6
6
|
newSession: vi.fn<[unknown], Promise<{ sessionId: string }>>().mockResolvedValue({ sessionId: 'sync-sess-1' }),
|
|
7
|
-
listSessions: vi.fn<[unknown], Promise<{ sessions: Array<{ sessionId: string; title: string | null; cwd: string; updatedAt: string | null }
|
|
7
|
+
listSessions: vi.fn<[unknown], Promise<{ sessions: Array<{ sessionId: string; title: string | null; cwd: string; updatedAt: string | null }>; nextCursor?: string | null }>>()
|
|
8
8
|
.mockResolvedValue({ sessions: [{ sessionId: 'hist-1', title: 'Session 1', cwd: '/home/user', updatedAt: '2026-01-01T00:00:00Z' }] }),
|
|
9
|
+
deleteSession: vi.fn<[unknown], Promise<object>>().mockResolvedValue({}),
|
|
9
10
|
loadSession: vi.fn<[unknown], Promise<object>>().mockResolvedValue({}),
|
|
10
11
|
cancel: vi.fn<[unknown], Promise<void>>().mockResolvedValue(undefined),
|
|
11
12
|
closeSession: vi.fn<[unknown], Promise<object>>().mockResolvedValue({}),
|
|
@@ -128,6 +129,43 @@ describe('AcpSyncBridge', () => {
|
|
|
128
129
|
|
|
129
130
|
expect(result.sessions).toEqual([]);
|
|
130
131
|
});
|
|
132
|
+
|
|
133
|
+
it('should throw when not connected', async () => {
|
|
134
|
+
const bridge = createBridge(new MockAcpSpawner());
|
|
135
|
+
await expect(bridge.listSessions('/home/user')).rejects.toThrow('ACP connection not established');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should pass cursor to ACP and return nextCursor', async () => {
|
|
139
|
+
mc().listSessions.mockResolvedValue({
|
|
140
|
+
sessions: [{ sessionId: 'hist-2', title: 'Session 2', cwd: '/home/user', updatedAt: null }],
|
|
141
|
+
nextCursor: 'cursor-2',
|
|
142
|
+
});
|
|
143
|
+
const mockSpawner = new MockAcpSpawner();
|
|
144
|
+
const bridge = createBridge(mockSpawner);
|
|
145
|
+
await bridge.connect();
|
|
146
|
+
|
|
147
|
+
const result = await bridge.listSessions('/home/user', 'cursor-1');
|
|
148
|
+
|
|
149
|
+
expect(mc().listSessions).toHaveBeenCalledWith({ cwd: '/home/user', cursor: 'cursor-1' });
|
|
150
|
+
expect(result.nextCursor).toBe('cursor-2');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('deleteSession', () => {
|
|
155
|
+
it('should call ACP deleteSession with the given session ID', async () => {
|
|
156
|
+
const mockSpawner = new MockAcpSpawner();
|
|
157
|
+
const bridge = createBridge(mockSpawner);
|
|
158
|
+
await bridge.connect();
|
|
159
|
+
|
|
160
|
+
await bridge.deleteSession('hist-1');
|
|
161
|
+
|
|
162
|
+
expect(mc().deleteSession).toHaveBeenCalledWith({ sessionId: 'hist-1' });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should throw when not connected', async () => {
|
|
166
|
+
const bridge = createBridge(new MockAcpSpawner());
|
|
167
|
+
await expect(bridge.deleteSession('hist-1')).rejects.toThrow();
|
|
168
|
+
});
|
|
131
169
|
});
|
|
132
170
|
|
|
133
171
|
describe('loadSession', () => {
|