aws-runtime-bridge 1.7.50 → 1.8.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.
Files changed (65) hide show
  1. package/dist/adapter/AcodeSdkAdapter.d.ts +30 -3
  2. package/dist/adapter/AcodeSdkAdapter.d.ts.map +1 -1
  3. package/dist/adapter/AcodeSdkAdapter.js +461 -105
  4. package/dist/adapter/AcodeSdkAdapter.test.js +1282 -93
  5. package/dist/adapter/ClaudeSdkAdapter.test.js +31 -8
  6. package/dist/adapter/CodexSdkAdapter.d.ts.map +1 -1
  7. package/dist/adapter/CodexSdkAdapter.js +2 -0
  8. package/dist/adapter/OpencodeSdkAdapter.d.ts.map +1 -1
  9. package/dist/adapter/OpencodeSdkAdapter.js +2 -0
  10. package/dist/adapter/SdkProviderSpi.d.ts.map +1 -1
  11. package/dist/adapter/adapter.test.js +2 -2
  12. package/dist/adapter/idle-poll-loop.d.ts +4 -4
  13. package/dist/adapter/idle-poll-loop.d.ts.map +1 -1
  14. package/dist/adapter/idle-poll-loop.js +3 -3
  15. package/dist/adapter/idle-poll-loop.test.js +15 -1
  16. package/dist/adapter/types.d.ts +25 -6
  17. package/dist/adapter/types.d.ts.map +1 -1
  18. package/dist/adapter/types.js +226 -111
  19. package/dist/adapter/types.test.js +10 -3
  20. package/dist/routes/ai-sources.js +5 -1
  21. package/dist/routes/ai-sources.test.js +21 -0
  22. package/dist/routes/instance.d.ts.map +1 -1
  23. package/dist/routes/instance.js +6 -7
  24. package/dist/routes/runtime-binding.js +2 -2
  25. package/dist/routes/terminal.d.ts +22 -5
  26. package/dist/routes/terminal.d.ts.map +1 -1
  27. package/dist/routes/terminal.js +522 -170
  28. package/dist/routes/terminal.test.js +497 -208
  29. package/dist/services/mcp-launch-binding-queue.d.ts +1 -0
  30. package/dist/services/mcp-launch-binding-queue.d.ts.map +1 -1
  31. package/dist/services/mcp-launch-binding-queue.js +8 -2
  32. package/dist/services/mcp-launch-binding-queue.test.js +47 -4
  33. package/dist/services/session-output.d.ts +14 -6
  34. package/dist/services/session-output.d.ts.map +1 -1
  35. package/dist/services/session-output.js +29 -14
  36. package/dist/services/session-output.test.js +93 -39
  37. package/package/acode/dist/built-in-file-tools.d.ts +1 -1
  38. package/package/acode/dist/built-in-file-tools.d.ts.map +1 -1
  39. package/package/acode/dist/built-in-file-tools.js +388 -50
  40. package/package/acode/dist/runtime.d.ts +11 -3
  41. package/package/acode/dist/runtime.d.ts.map +1 -1
  42. package/package/acode/dist/runtime.js +902 -190
  43. package/package/acode/dist/types.d.ts +18 -5
  44. package/package/acode/dist/types.d.ts.map +1 -1
  45. package/package/aws-client-agent-mcp/dist/mcp-server.d.ts.map +1 -1
  46. package/package/aws-client-agent-mcp/dist/mcp-server.js +3 -2
  47. package/package/aws-client-agent-mcp/dist/mcp-server.js.map +1 -1
  48. package/package/aws-client-agent-mcp/dist/mcp-server.test.js +11 -9
  49. package/package/aws-client-agent-mcp/dist/mcp-server.test.js.map +1 -1
  50. package/package/aws-client-agent-mcp/dist/runtime-launch-binding.d.ts.map +1 -1
  51. package/package/aws-client-agent-mcp/dist/runtime-launch-binding.js +1 -0
  52. package/package/aws-client-agent-mcp/dist/runtime-launch-binding.js.map +1 -1
  53. package/package/aws-client-agent-mcp/dist/status-reporter.d.ts +2 -0
  54. package/package/aws-client-agent-mcp/dist/status-reporter.d.ts.map +1 -1
  55. package/package/aws-client-agent-mcp/dist/status-reporter.js +31 -3
  56. package/package/aws-client-agent-mcp/dist/status-reporter.js.map +1 -1
  57. package/package/aws-client-agent-mcp/dist/types.d.ts +1 -1
  58. package/package/aws-client-agent-mcp/dist/types.d.ts.map +1 -1
  59. package/package/aws-client-agent-mcp/dist/types.js.map +1 -1
  60. package/package/aws-client-agent-mcp/dist/websocket-client.d.ts.map +1 -1
  61. package/package/aws-client-agent-mcp/dist/websocket-client.js +2 -0
  62. package/package/aws-client-agent-mcp/dist/websocket-client.js.map +1 -1
  63. package/package/aws-client-agent-mcp/dist/websocket-client.test.js +43 -0
  64. package/package/aws-client-agent-mcp/dist/websocket-client.test.js.map +1 -1
  65. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
- import { EventEmitter } from 'node:events';
2
- import { describe, expect, it, vi } from 'vitest';
3
- import { AcodeSdkAdapter, isFailedAcodeTurnComplete } from './AcodeSdkAdapter.js';
1
+ import { EventEmitter } from "node:events";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { AcodeSdkAdapter, isFailedAcodeTurnComplete, } from "./AcodeSdkAdapter.js";
4
4
  class FailedTurnRuntime extends EventEmitter {
5
5
  constructor(config) {
6
6
  super();
@@ -8,14 +8,14 @@ class FailedTurnRuntime extends EventEmitter {
8
8
  }
9
9
  async start() { }
10
10
  async submit(_message) {
11
- this.emit('event', {
12
- type: 'error',
11
+ this.emit("event", {
12
+ type: "error",
13
13
  sessionId: this.config.sessionId,
14
14
  timestamp: new Date(0).toISOString(),
15
- data: { message: 'provider down' },
15
+ data: { message: "provider down" },
16
16
  });
17
- this.emit('event', {
18
- type: 'turn_complete',
17
+ this.emit("event", {
18
+ type: "turn_complete",
19
19
  sessionId: this.config.sessionId,
20
20
  timestamp: new Date(0).toISOString(),
21
21
  data: { failed: true, usage: { inputTokens: 1, outputTokens: 0 } },
@@ -31,11 +31,91 @@ class McpStatusRuntime extends EventEmitter {
31
31
  }
32
32
  async start() { }
33
33
  async submit(_message) {
34
- this.emit('event', {
35
- type: 'mcp_status',
34
+ this.emit("event", {
35
+ type: "mcp_status",
36
36
  sessionId: this.config.sessionId,
37
37
  timestamp: new Date(0).toISOString(),
38
- data: { action: 'call_tool', server: 'aws-mcp', tool: 'create_memory' },
38
+ data: {
39
+ action: "call_tool",
40
+ server: "aws-mcp",
41
+ tool: "create_memory",
42
+ toolUseId: "tool-call-1",
43
+ },
44
+ });
45
+ }
46
+ async abort() { }
47
+ async stop() { }
48
+ }
49
+ class BuiltinReadStatusRuntime extends EventEmitter {
50
+ constructor(config) {
51
+ super();
52
+ this.config = config;
53
+ }
54
+ async start() { }
55
+ async submit(_message) {
56
+ this.emit("event", {
57
+ type: "mcp_status",
58
+ sessionId: this.config.sessionId,
59
+ timestamp: new Date(0).toISOString(),
60
+ data: {
61
+ action: "call_tool",
62
+ server: "builtin",
63
+ tool: "read_file",
64
+ exposedName: "builtin/read_file",
65
+ toolUseId: "tool-read-1",
66
+ arguments: { path: "src/views/App.vue" },
67
+ },
68
+ });
69
+ }
70
+ async abort() { }
71
+ async stop() { }
72
+ }
73
+ class McpResultRuntime extends EventEmitter {
74
+ constructor(config) {
75
+ super();
76
+ this.config = config;
77
+ }
78
+ async start() { }
79
+ async submit(_message) {
80
+ this.emit("event", {
81
+ type: "mcp_status",
82
+ sessionId: this.config.sessionId,
83
+ timestamp: new Date(0).toISOString(),
84
+ data: {
85
+ action: "call_tool",
86
+ server: "aws-mcp",
87
+ tool: "create_memory",
88
+ toolUseId: "tool-call-1",
89
+ },
90
+ });
91
+ this.emit("event", {
92
+ type: "mcp_status",
93
+ sessionId: this.config.sessionId,
94
+ timestamp: new Date(0).toISOString(),
95
+ data: {
96
+ action: "tool_result",
97
+ server: "aws-mcp",
98
+ tool: "create_memory",
99
+ toolUseId: "tool-call-1",
100
+ result: '{"uri":"core://agent/profile"}',
101
+ },
102
+ });
103
+ }
104
+ async abort() { }
105
+ async stop() { }
106
+ }
107
+ class PollMessageStatusRuntime extends EventEmitter {
108
+ constructor(config) {
109
+ super();
110
+ this.config = config;
111
+ }
112
+ async start() { }
113
+ async submit(_message) {
114
+ this.emit("event", {
115
+ type: "mcp_status",
116
+ sessionId: this.config.sessionId,
117
+ timestamp: new Date(0).toISOString(),
118
+ data: { action: "call_tool", server: "aws-mcp", tool: "poll_message" },
39
119
  });
40
120
  }
41
121
  async abort() { }
@@ -48,11 +128,133 @@ class CommandStatusRuntime extends EventEmitter {
48
128
  }
49
129
  async start() { }
50
130
  async submit(_message) {
51
- this.emit('event', {
52
- type: 'command_status',
131
+ this.emit("event", {
132
+ type: "command_status",
133
+ sessionId: this.config.sessionId,
134
+ timestamp: new Date(0).toISOString(),
135
+ data: { action: "slash_command", command: "review" },
136
+ });
137
+ }
138
+ async abort() { }
139
+ async stop() { }
140
+ }
141
+ class AiRequestRuntime extends EventEmitter {
142
+ constructor(config) {
143
+ super();
144
+ this.config = config;
145
+ }
146
+ async start() { }
147
+ async submit(_message) {
148
+ this.emit("event", {
149
+ type: "ai_request",
150
+ sessionId: this.config.sessionId,
151
+ timestamp: new Date(0).toISOString(),
152
+ data: {
153
+ model: "test-model",
154
+ endpoint: "https://example.invalid/v1/chat/completions",
155
+ stream: true,
156
+ messageCount: 3,
157
+ toolCount: 2,
158
+ lastUserMessagePreview: "inspect status",
159
+ requestBody: '{"model":"test-model"}',
160
+ },
161
+ });
162
+ }
163
+ async abort() { }
164
+ async stop() { }
165
+ }
166
+ class ThinkingDeltaRuntime extends EventEmitter {
167
+ constructor(config) {
168
+ super();
169
+ this.config = config;
170
+ }
171
+ async start() { }
172
+ async submit(_message) {
173
+ this.emit("event", {
174
+ type: "thinking_delta",
175
+ sessionId: this.config.sessionId,
176
+ timestamp: new Date(0).toISOString(),
177
+ data: { text: "reasoning chunk" },
178
+ });
179
+ }
180
+ async abort() { }
181
+ async stop() { }
182
+ }
183
+ class AssistantDeltaRuntime extends EventEmitter {
184
+ constructor(config) {
185
+ super();
186
+ this.config = config;
187
+ }
188
+ async start() { }
189
+ async submit(_message) {
190
+ this.emit("event", {
191
+ type: "assistant_delta",
192
+ sessionId: this.config.sessionId,
193
+ timestamp: new Date(0).toISOString(),
194
+ data: { text: "answer chunk" },
195
+ });
196
+ }
197
+ async abort() { }
198
+ async stop() { }
199
+ }
200
+ class ResponseBodyTurnCompleteRuntime extends EventEmitter {
201
+ constructor(config) {
202
+ super();
203
+ this.config = config;
204
+ }
205
+ async start() { }
206
+ async submit(_message) {
207
+ this.emit("event", {
208
+ type: "turn_complete",
209
+ sessionId: this.config.sessionId,
210
+ timestamp: new Date(0).toISOString(),
211
+ data: {
212
+ usage: { inputTokens: 9, outputTokens: 3 },
213
+ responseBody: '{"choices":[{"message":{"content":"done"}}]}',
214
+ finishReason: "stop",
215
+ },
216
+ });
217
+ }
218
+ async abort() { }
219
+ async stop() { }
220
+ }
221
+ class ProviderUsageTurnCompleteRuntime extends EventEmitter {
222
+ constructor(config) {
223
+ super();
224
+ this.config = config;
225
+ }
226
+ async start() { }
227
+ async submit(_message) {
228
+ this.emit("event", {
229
+ type: "turn_complete",
230
+ sessionId: this.config.sessionId,
231
+ timestamp: new Date(0).toISOString(),
232
+ data: {
233
+ usage: { prompt_tokens: 31, completion_tokens: 58 },
234
+ },
235
+ });
236
+ }
237
+ async abort() { }
238
+ async stop() { }
239
+ }
240
+ class ThinkingThenAssistantDeltaRuntime extends EventEmitter {
241
+ constructor(config) {
242
+ super();
243
+ this.config = config;
244
+ }
245
+ async start() { }
246
+ async submit(_message) {
247
+ this.emit("event", {
248
+ type: "thinking_delta",
53
249
  sessionId: this.config.sessionId,
54
250
  timestamp: new Date(0).toISOString(),
55
- data: { action: 'slash_command', command: 'review' },
251
+ data: { text: "thinking chunk" },
252
+ });
253
+ this.emit("event", {
254
+ type: "assistant_delta",
255
+ sessionId: this.config.sessionId,
256
+ timestamp: new Date(1).toISOString(),
257
+ data: { text: "answer chunk" },
56
258
  });
57
259
  }
58
260
  async abort() { }
@@ -65,14 +267,14 @@ class McpThenSessionCompleteRuntime extends EventEmitter {
65
267
  }
66
268
  async start() { }
67
269
  async submit(_message) {
68
- this.emit('event', {
69
- type: 'mcp_status',
270
+ this.emit("event", {
271
+ type: "mcp_status",
70
272
  sessionId: this.config.sessionId,
71
273
  timestamp: new Date(0).toISOString(),
72
- data: { action: 'call_tool', server: 'aws-mcp', tool: 'send_private' },
274
+ data: { action: "call_tool", server: "aws-mcp", tool: "send_private" },
73
275
  });
74
- this.emit('event', {
75
- type: 'session_complete',
276
+ this.emit("event", {
277
+ type: "session_complete",
76
278
  sessionId: this.config.sessionId,
77
279
  timestamp: new Date(0).toISOString(),
78
280
  data: { exitCode: 0 },
@@ -104,8 +306,8 @@ class AutoPollRuntime extends EventEmitter {
104
306
  async submit(message) {
105
307
  AutoPollRuntime.submitMessages.push(message);
106
308
  if (AutoPollRuntime.submitMessages.length === 1) {
107
- this.emit('event', {
108
- type: 'turn_complete',
309
+ this.emit("event", {
310
+ type: "turn_complete",
109
311
  sessionId: this.config.sessionId,
110
312
  timestamp: new Date(0).toISOString(),
111
313
  data: { usage: { inputTokens: 1, outputTokens: 1 } },
@@ -116,158 +318,1145 @@ class AutoPollRuntime extends EventEmitter {
116
318
  async stop() { }
117
319
  }
118
320
  AutoPollRuntime.submitMessages = [];
119
- describe('AcodeSdkAdapter turn completion state', () => {
120
- it('keeps error status for failed turn_complete events', () => {
321
+ class NonPollingAutoPollRuntime extends EventEmitter {
322
+ constructor(config) {
323
+ super();
324
+ this.config = config;
325
+ }
326
+ async start() { }
327
+ async submit(message) {
328
+ NonPollingAutoPollRuntime.submitMessages.push(message);
329
+ this.emit("event", {
330
+ type: "assistant_delta",
331
+ sessionId: this.config.sessionId,
332
+ timestamp: new Date(0).toISOString(),
333
+ data: { text: "I cannot call poll_message." },
334
+ });
335
+ this.emit("event", {
336
+ type: "turn_complete",
337
+ sessionId: this.config.sessionId,
338
+ timestamp: new Date(0).toISOString(),
339
+ data: { usage: { inputTokens: 1, outputTokens: 1 } },
340
+ });
341
+ }
342
+ async abort() { }
343
+ async stop() { }
344
+ }
345
+ NonPollingAutoPollRuntime.submitMessages = [];
346
+ class PollingAutoPollRuntime extends EventEmitter {
347
+ constructor(config) {
348
+ super();
349
+ this.config = config;
350
+ }
351
+ async start() { }
352
+ async submit(message) {
353
+ PollingAutoPollRuntime.submitMessages.push(message);
354
+ this.emit("event", {
355
+ type: "mcp_status",
356
+ sessionId: this.config.sessionId,
357
+ timestamp: new Date(0).toISOString(),
358
+ data: { action: "call_tool", server: "aws-mcp", tool: "poll_message" },
359
+ });
360
+ this.emit("event", {
361
+ type: "turn_complete",
362
+ sessionId: this.config.sessionId,
363
+ timestamp: new Date(0).toISOString(),
364
+ data: { usage: { inputTokens: 1, outputTokens: 1 } },
365
+ });
366
+ }
367
+ async abort() { }
368
+ async stop() { }
369
+ }
370
+ PollingAutoPollRuntime.submitMessages = [];
371
+ class TerminalAfterTurnRuntime extends EventEmitter {
372
+ constructor(config) {
373
+ super();
374
+ this.config = config;
375
+ }
376
+ async start() { }
377
+ async submit(message) {
378
+ TerminalAfterTurnRuntime.submitMessages.push(message);
379
+ this.emit("event", {
380
+ type: "turn_complete",
381
+ sessionId: this.config.sessionId,
382
+ timestamp: new Date(0).toISOString(),
383
+ data: { usage: { inputTokens: 1, outputTokens: 1 } },
384
+ });
385
+ this.emit("event", {
386
+ type: "session_complete",
387
+ sessionId: this.config.sessionId,
388
+ timestamp: new Date(0).toISOString(),
389
+ data: { exitCode: 0 },
390
+ });
391
+ }
392
+ async abort() { }
393
+ async stop() { }
394
+ }
395
+ TerminalAfterTurnRuntime.submitMessages = [];
396
+ class FailingAutoPollRuntime extends EventEmitter {
397
+ constructor(config) {
398
+ super();
399
+ this.config = config;
400
+ }
401
+ async start() { }
402
+ async submit(message) {
403
+ FailingAutoPollRuntime.submitMessages.push(message);
404
+ this.emit("event", {
405
+ type: "error",
406
+ sessionId: this.config.sessionId,
407
+ timestamp: new Date(0).toISOString(),
408
+ data: { message: "mcp tool timeout" },
409
+ });
410
+ this.emit("event", {
411
+ type: "turn_complete",
412
+ sessionId: this.config.sessionId,
413
+ timestamp: new Date(0).toISOString(),
414
+ data: { failed: true, usage: { inputTokens: 1, outputTokens: 0 } },
415
+ });
416
+ }
417
+ async abort() { }
418
+ async stop() { }
419
+ }
420
+ FailingAutoPollRuntime.submitMessages = [];
421
+ class ResolvedWithoutTerminalEventRuntime extends EventEmitter {
422
+ constructor(config) {
423
+ super();
424
+ this.config = config;
425
+ }
426
+ async start() { }
427
+ async submit(message) {
428
+ ResolvedWithoutTerminalEventRuntime.submitMessages.push(message);
429
+ this.emit("event", {
430
+ type: "command_status",
431
+ sessionId: this.config.sessionId,
432
+ timestamp: new Date(0).toISOString(),
433
+ data: { action: "slash_command", command: "review" },
434
+ });
435
+ }
436
+ async abort() { }
437
+ async stop() { }
438
+ }
439
+ ResolvedWithoutTerminalEventRuntime.submitMessages = [];
440
+ class BareErrorRuntime extends EventEmitter {
441
+ constructor(config) {
442
+ super();
443
+ this.config = config;
444
+ }
445
+ async start() { }
446
+ async submit(message) {
447
+ BareErrorRuntime.submitMessages.push(message);
448
+ this.emit("event", {
449
+ type: "error",
450
+ sessionId: this.config.sessionId,
451
+ timestamp: new Date(0).toISOString(),
452
+ data: { message: "provider emitted bare error" },
453
+ });
454
+ }
455
+ async abort() { }
456
+ async stop() { }
457
+ }
458
+ BareErrorRuntime.submitMessages = [];
459
+ class RejectingSubmitRuntime extends EventEmitter {
460
+ constructor(_config) {
461
+ super();
462
+ }
463
+ async start() { }
464
+ async submit(message) {
465
+ RejectingSubmitRuntime.submitMessages.push(message);
466
+ throw new Error("submit rejected before turn_complete");
467
+ }
468
+ async abort() { }
469
+ async stop() { }
470
+ }
471
+ RejectingSubmitRuntime.submitMessages = [];
472
+ class StartFailureRuntime extends EventEmitter {
473
+ constructor(_config) {
474
+ super();
475
+ }
476
+ async start() {
477
+ throw new Error("runtime start failed");
478
+ }
479
+ async submit(_message) { }
480
+ async abort() { }
481
+ async stop() { }
482
+ }
483
+ class FailingPollThenSuccessfulUserRuntime extends EventEmitter {
484
+ constructor(config) {
485
+ super();
486
+ this.config = config;
487
+ }
488
+ async start() { }
489
+ async submit(message) {
490
+ FailingPollThenSuccessfulUserRuntime.submitMessages.push(message);
491
+ if (message.includes("poll_message")) {
492
+ this.emit("event", {
493
+ type: "error",
494
+ sessionId: this.config.sessionId,
495
+ timestamp: new Date(0).toISOString(),
496
+ data: { message: "poll failed once" },
497
+ });
498
+ this.emit("event", {
499
+ type: "turn_complete",
500
+ sessionId: this.config.sessionId,
501
+ timestamp: new Date(0).toISOString(),
502
+ data: { failed: true, usage: { inputTokens: 1, outputTokens: 0 } },
503
+ });
504
+ return;
505
+ }
506
+ this.emit("event", {
507
+ type: "turn_complete",
508
+ sessionId: this.config.sessionId,
509
+ timestamp: new Date(0).toISOString(),
510
+ data: { usage: { inputTokens: 1, outputTokens: 1 } },
511
+ });
512
+ this.emit("event", {
513
+ type: "session_complete",
514
+ sessionId: this.config.sessionId,
515
+ timestamp: new Date(0).toISOString(),
516
+ data: { exitCode: 0 },
517
+ });
518
+ }
519
+ async abort() { }
520
+ async stop() { }
521
+ }
522
+ FailingPollThenSuccessfulUserRuntime.submitMessages = [];
523
+ class StartupMcpStatusRuntime extends EventEmitter {
524
+ constructor(config) {
525
+ super();
526
+ this.config = config;
527
+ }
528
+ async start() {
529
+ // Mirrors the startup mcp_status event emitted by ACode's
530
+ // connectMcpServers before any user interaction. The R3 check in
531
+ // AcodeSdkAdapter validates the `tools` array (the tools the LLM
532
+ // can actually call) rather than the `servers` config keys, so
533
+ // the runtime must emit the tools form here to exercise that path.
534
+ this.emit("event", {
535
+ type: "mcp_status",
536
+ sessionId: this.config.sessionId,
537
+ timestamp: new Date(0).toISOString(),
538
+ data: {
539
+ tools: StartupMcpStatusRuntime.includeAwsMcp
540
+ ? [{ name: "aws-mcp__poll_message", source: "aws-mcp.poll_message" }]
541
+ : [{ name: "other-mcp__other_tool", source: "other-mcp.other_tool" }],
542
+ },
543
+ });
544
+ }
545
+ async submit(_message) { }
546
+ async abort() { }
547
+ async stop() { }
548
+ }
549
+ // When true (default) the emitted startup mcp_status event includes a
550
+ // tool named 'aws-mcp__poll_message', mirroring the happy-path runtime
551
+ // configuration. When false, the tools list intentionally omits
552
+ // poll_message so the R3 check is forced to take the warning path.
553
+ StartupMcpStatusRuntime.includeAwsMcp = true;
554
+ class DifferentlyNamedMcpRuntime extends EventEmitter {
555
+ // Reproduces the bug-report scenario: the user's MCP is configured
556
+ // under a non-default name ('aws-mcp-server') and the runtime
557
+ // reports that name in the legacy `servers` field, but the LLM-
558
+ // visible tools are still prefixed with the canonical 'aws-mcp__'.
559
+ // The R3 check must validate against the tools (not the server
560
+ // names) so this configuration is accepted as healthy.
561
+ constructor(config) {
562
+ super();
563
+ this.config = config;
564
+ }
565
+ async start() {
566
+ this.emit("event", {
567
+ type: "mcp_status",
568
+ sessionId: this.config.sessionId,
569
+ timestamp: new Date(0).toISOString(),
570
+ data: {
571
+ servers: ["aws-mcp-server"],
572
+ tools: [
573
+ { name: "aws-mcp__poll_message", source: "aws-mcp.poll_message" },
574
+ {
575
+ name: "aws-mcp__submit_response",
576
+ source: "aws-mcp.submit_response",
577
+ },
578
+ ],
579
+ },
580
+ });
581
+ }
582
+ async submit(_message) { }
583
+ async abort() { }
584
+ async stop() { }
585
+ }
586
+ describe("AcodeSdkAdapter turn completion state", () => {
587
+ it("keeps error status for failed turn_complete events", () => {
121
588
  expect(isFailedAcodeTurnComplete({ failed: true })).toBe(true);
122
589
  expect(isFailedAcodeTurnComplete({ failed: false })).toBe(false);
123
590
  expect(isFailedAcodeTurnComplete({ usage: { inputTokens: 1, outputTokens: 2 } })).toBe(false);
124
591
  });
125
- it('does not forward failed turn_complete into waiting_input status updates', async () => {
592
+ it("recovers failed turn_complete to waiting_input so the agent never stays stuck in error (R4)", async () => {
593
+ const adapter = new AcodeSdkAdapter();
594
+ adapter.setRuntimeConstructorForTest(FailedTurnRuntime);
595
+ const events = [];
596
+ const statuses = [];
597
+ adapter.on("event", (event) => events.push(event));
598
+ adapter.on("status-change", (_sessionId, status) => statuses.push(status));
599
+ await adapter.startSession("session-failed", {
600
+ command: "acode",
601
+ workingDirectory: process.cwd(),
602
+ autoAccept: true,
603
+ extraMcpServers: {},
604
+ envOverrides: {},
605
+ });
606
+ await adapter.sendMessage("session-failed", "fail please");
607
+ expect(events.map((event) => event.type)).toEqual(["error"]);
608
+ // R4: the session briefly enters 'error' but then recovers to 'waiting_input'
609
+ // so the agent can retry via the idle poll loop instead of being stuck forever.
610
+ expect(statuses).toEqual([
611
+ "starting",
612
+ "waiting_input",
613
+ "thinking",
614
+ "error",
615
+ "waiting_input",
616
+ ]);
617
+ expect(adapter.getSessionStatus("session-failed")).toBe("waiting_input");
618
+ });
619
+ it("forwards ACode turn_complete response metadata", async () => {
620
+ const adapter = new AcodeSdkAdapter();
621
+ adapter.setRuntimeConstructorForTest(ResponseBodyTurnCompleteRuntime);
622
+ const events = [];
623
+ adapter.on("event", (event) => events.push(event));
624
+ await adapter.startSession("session-response-body", {
625
+ command: "acode",
626
+ workingDirectory: process.cwd(),
627
+ autoAccept: true,
628
+ extraMcpServers: {},
629
+ envOverrides: {},
630
+ });
631
+ await adapter.sendMessage("session-response-body", "answer");
632
+ expect(events).toHaveLength(1);
633
+ expect(events[0]).toMatchObject({
634
+ type: "turn_complete",
635
+ data: {
636
+ usage: { inputTokens: 9, outputTokens: 3 },
637
+ responseBody: '{"choices":[{"message":{"content":"done"}}]}',
638
+ finishReason: "stop",
639
+ },
640
+ });
641
+ });
642
+ it("normalizes provider-native ACode usage fields for context display", async () => {
643
+ const adapter = new AcodeSdkAdapter();
644
+ adapter.setRuntimeConstructorForTest(ProviderUsageTurnCompleteRuntime);
645
+ const events = [];
646
+ const statuses = [];
647
+ adapter.on("event", (event) => events.push(event));
648
+ adapter.on("status-change", (_sessionId, status, actionInfo) => {
649
+ statuses.push({ status, usage: actionInfo?.usage });
650
+ });
651
+ await adapter.startSession("session-provider-usage", {
652
+ command: "acode",
653
+ workingDirectory: process.cwd(),
654
+ autoAccept: true,
655
+ extraMcpServers: {},
656
+ envOverrides: {},
657
+ });
658
+ await adapter.sendMessage("session-provider-usage", "answer");
659
+ expect(events).toHaveLength(1);
660
+ expect(events[0]).toMatchObject({
661
+ type: "turn_complete",
662
+ data: { usage: { inputTokens: 31, outputTokens: 58 } },
663
+ });
664
+ expect(statuses.at(-1)).toEqual({
665
+ status: "waiting_input",
666
+ usage: { inputTokens: 31, outputTokens: 58 },
667
+ });
668
+ });
669
+ it("maps ACode MCP status events to tool_using instead of thinking", async () => {
670
+ const adapter = new AcodeSdkAdapter();
671
+ adapter.setRuntimeConstructorForTest(McpStatusRuntime);
672
+ const events = [];
673
+ const statuses = [];
674
+ adapter.on("event", (event) => events.push(event));
675
+ adapter.on("status-change", (_sessionId, status, actionInfo) => {
676
+ statuses.push({
677
+ status,
678
+ actionType: actionInfo?.actionType,
679
+ actionDetail: actionInfo?.actionDetail,
680
+ });
681
+ });
682
+ await adapter.startSession("session-mcp", {
683
+ command: "acode",
684
+ workingDirectory: process.cwd(),
685
+ autoAccept: true,
686
+ extraMcpServers: {},
687
+ envOverrides: {},
688
+ });
689
+ await adapter.sendMessage("session-mcp", "call mcp");
690
+ expect(events.map((event) => event.type)).toEqual(["tool_use_start"]);
691
+ expect(events[0]?.data).toMatchObject({
692
+ actionType: "mcp",
693
+ actionLabel: "调用MCP",
694
+ actionDetail: "aws-mcp/create_memory",
695
+ actionId: "tool-call-1",
696
+ toolUseId: "tool-call-1",
697
+ });
698
+ expect(statuses.at(-2)).toEqual({
699
+ status: "tool_using",
700
+ actionType: "mcp",
701
+ actionDetail: "aws-mcp/create_memory",
702
+ });
703
+ expect(statuses.at(-1)).toEqual({
704
+ status: "waiting_input",
705
+ actionType: undefined,
706
+ actionDetail: undefined,
707
+ });
708
+ expect(adapter.getSessionStatus("session-mcp")).toBe("waiting_input");
709
+ });
710
+ it("preserves built-in file tool arguments in ACode provider events and statuses", async () => {
711
+ const adapter = new AcodeSdkAdapter();
712
+ adapter.setRuntimeConstructorForTest(BuiltinReadStatusRuntime);
713
+ const events = [];
714
+ const statuses = [];
715
+ adapter.on("event", (event) => events.push(event));
716
+ adapter.on("status-change", (_sessionId, status, actionInfo) => {
717
+ statuses.push({
718
+ status,
719
+ actionType: actionInfo?.actionType,
720
+ actionDetail: actionInfo?.actionDetail,
721
+ });
722
+ });
723
+ await adapter.startSession("session-builtin-read", {
724
+ command: "acode",
725
+ workingDirectory: process.cwd(),
726
+ autoAccept: true,
727
+ extraMcpServers: {},
728
+ envOverrides: {},
729
+ });
730
+ await adapter.sendMessage("session-builtin-read", "read file");
731
+ expect(events.map((event) => event.type)).toEqual(["tool_use_start"]);
732
+ expect(events[0]?.data).toMatchObject({
733
+ actionType: "read_file",
734
+ actionLabel: "读取文件",
735
+ actionDetail: "src/views/App.vue",
736
+ actionId: "tool-read-1",
737
+ toolUseId: "tool-read-1",
738
+ toolInput: { path: "src/views/App.vue" },
739
+ });
740
+ expect(statuses.at(-2)).toEqual({
741
+ status: "tool_using",
742
+ actionType: "read_file",
743
+ actionDetail: "src/views/App.vue",
744
+ });
745
+ });
746
+ it("clears active MCP tool status when ACode reports a tool result", async () => {
747
+ const adapter = new AcodeSdkAdapter();
748
+ adapter.setRuntimeConstructorForTest(McpResultRuntime);
749
+ const events = [];
750
+ const statuses = [];
751
+ adapter.on("event", (event) => events.push(event));
752
+ adapter.on("status-change", (_sessionId, status, actionInfo) => {
753
+ statuses.push({
754
+ status,
755
+ actionType: actionInfo?.actionType,
756
+ actionDetail: actionInfo?.actionDetail,
757
+ });
758
+ });
759
+ await adapter.startSession("session-mcp-result", {
760
+ command: "acode",
761
+ workingDirectory: process.cwd(),
762
+ autoAccept: true,
763
+ extraMcpServers: {},
764
+ envOverrides: {},
765
+ });
766
+ await adapter.sendMessage("session-mcp-result", "remember profile");
767
+ expect(events.map((event) => event.type)).toEqual([
768
+ "tool_use_start",
769
+ "tool_use_end",
770
+ ]);
771
+ expect(events[1]?.data).toMatchObject({
772
+ actionType: "mcp",
773
+ actionLabel: "调用MCP",
774
+ actionDetail: "aws-mcp/create_memory",
775
+ actionId: "tool-call-1",
776
+ toolUseId: "tool-call-1",
777
+ toolResult: '{"uri":"core://agent/profile"}',
778
+ });
779
+ expect(statuses.at(-2)).toEqual({
780
+ status: "thinking",
781
+ actionType: undefined,
782
+ actionDetail: undefined,
783
+ });
784
+ expect(statuses.at(-1)).toEqual({
785
+ status: "waiting_input",
786
+ actionType: undefined,
787
+ actionDetail: undefined,
788
+ });
789
+ expect(adapter.getSessionStatus("session-mcp-result")).toBe("waiting_input");
790
+ });
791
+ it("maps ACode poll_message MCP status events to waiting_input idle state", async () => {
792
+ const adapter = new AcodeSdkAdapter();
793
+ adapter.setRuntimeConstructorForTest(PollMessageStatusRuntime);
794
+ const events = [];
795
+ const statuses = [];
796
+ adapter.on("event", (event) => events.push(event));
797
+ adapter.on("status-change", (_sessionId, status, actionInfo) => {
798
+ statuses.push({
799
+ status,
800
+ actionType: actionInfo?.actionType,
801
+ actionDetail: actionInfo?.actionDetail,
802
+ });
803
+ });
804
+ await adapter.startSession("session-poll-message", {
805
+ command: "acode",
806
+ workingDirectory: process.cwd(),
807
+ autoAccept: true,
808
+ extraMcpServers: {},
809
+ envOverrides: {},
810
+ });
811
+ await adapter.sendMessage("session-poll-message", "listen");
812
+ expect(events.map((event) => event.type)).toEqual(["tool_use_start"]);
813
+ expect(events[0]?.data).toMatchObject({
814
+ actionType: "idle",
815
+ actionLabel: "监听消息",
816
+ actionDetail: "aws-mcp/poll_message",
817
+ });
818
+ expect(statuses.at(-1)).toEqual({
819
+ status: "waiting_input",
820
+ actionType: "idle",
821
+ actionDetail: "aws-mcp/poll_message",
822
+ });
823
+ expect(adapter.getSessionStatus("session-poll-message")).toBe("waiting_input");
824
+ });
825
+ it("forwards ACode command status as thinking without changing session status", async () => {
126
826
  const adapter = new AcodeSdkAdapter();
127
- adapter.setRuntimeConstructorForTest(FailedTurnRuntime);
827
+ adapter.setRuntimeConstructorForTest(CommandStatusRuntime);
128
828
  const events = [];
129
829
  const statuses = [];
130
- adapter.on('event', (event) => events.push(event));
131
- adapter.on('status-change', (_sessionId, status) => statuses.push(status));
132
- await adapter.startSession('session-failed', {
133
- command: 'acode',
830
+ adapter.on("event", (event) => events.push(event));
831
+ adapter.on("status-change", (_sessionId, status) => statuses.push(status));
832
+ await adapter.startSession("session-command-status", {
833
+ command: "acode",
134
834
  workingDirectory: process.cwd(),
135
835
  autoAccept: true,
136
836
  extraMcpServers: {},
137
837
  envOverrides: {},
138
838
  });
139
- await adapter.sendMessage('session-failed', 'fail please');
140
- expect(events.map((event) => event.type)).toEqual(['error']);
141
- expect(statuses).toEqual(['starting', 'waiting_input', 'thinking', 'error']);
142
- expect(adapter.getSessionStatus('session-failed')).toBe('error');
839
+ await adapter.sendMessage("session-command-status", "/review src");
840
+ expect(events).toHaveLength(1);
841
+ expect(events[0]?.type).toBe("thinking");
842
+ expect(String(events[0]?.data.text)).toContain("slash_command");
843
+ expect(statuses).toEqual([
844
+ "starting",
845
+ "waiting_input",
846
+ "thinking",
847
+ "waiting_input",
848
+ ]);
849
+ expect(adapter.getSessionStatus("session-command-status")).toBe("waiting_input");
143
850
  });
144
- it('maps ACode MCP status events to tool_using instead of thinking', async () => {
851
+ it("forwards ACode AI request summaries as provider events", async () => {
145
852
  const adapter = new AcodeSdkAdapter();
146
- adapter.setRuntimeConstructorForTest(McpStatusRuntime);
853
+ adapter.setRuntimeConstructorForTest(AiRequestRuntime);
147
854
  const events = [];
148
- const statuses = [];
149
- adapter.on('event', (event) => events.push(event));
150
- adapter.on('status-change', (_sessionId, status, actionInfo) => {
151
- statuses.push({ status, actionType: actionInfo?.actionType, actionDetail: actionInfo?.actionDetail });
855
+ adapter.on("event", (event) => events.push(event));
856
+ await adapter.startSession("session-ai-request", {
857
+ command: "acode",
858
+ workingDirectory: process.cwd(),
859
+ autoAccept: true,
860
+ extraMcpServers: {},
861
+ envOverrides: {},
152
862
  });
153
- await adapter.startSession('session-mcp', {
154
- command: 'acode',
863
+ await adapter.sendMessage("session-ai-request", "inspect status");
864
+ expect(events).toHaveLength(1);
865
+ expect(events[0]).toMatchObject({
866
+ type: "ai_request",
867
+ data: {
868
+ model: "test-model",
869
+ endpoint: "https://example.invalid/v1/chat/completions",
870
+ stream: true,
871
+ messageCount: 3,
872
+ toolCount: 2,
873
+ lastUserMessagePreview: "inspect status",
874
+ requestBody: '{"model":"test-model"}',
875
+ },
876
+ });
877
+ });
878
+ it("forwards ACode thinking deltas as realtime thinking provider events", async () => {
879
+ const adapter = new AcodeSdkAdapter();
880
+ adapter.setRuntimeConstructorForTest(ThinkingDeltaRuntime);
881
+ const events = [];
882
+ adapter.on("event", (event) => events.push(event));
883
+ await adapter.startSession("session-thinking-delta", {
884
+ command: "acode",
155
885
  workingDirectory: process.cwd(),
156
886
  autoAccept: true,
157
887
  extraMcpServers: {},
158
888
  envOverrides: {},
159
889
  });
160
- await adapter.sendMessage('session-mcp', 'call mcp');
161
- expect(events.map((event) => event.type)).toEqual(['tool_use_start']);
162
- expect(events[0]?.data).toMatchObject({
163
- actionType: 'mcp',
164
- actionLabel: 'Calling MCP',
165
- actionDetail: 'aws-mcp/create_memory',
890
+ await adapter.sendMessage("session-thinking-delta", "think aloud");
891
+ expect(events).toHaveLength(1);
892
+ expect(events[0]).toMatchObject({
893
+ type: "thinking",
894
+ data: { text: "reasoning chunk" },
166
895
  });
167
- expect(statuses.at(-1)).toEqual({
168
- status: 'tool_using',
169
- actionType: 'mcp',
170
- actionDetail: 'aws-mcp/create_memory',
896
+ });
897
+ it("forwards ACode assistant deltas as realtime text provider events", async () => {
898
+ const adapter = new AcodeSdkAdapter();
899
+ adapter.setRuntimeConstructorForTest(AssistantDeltaRuntime);
900
+ const events = [];
901
+ adapter.on("event", (event) => events.push(event));
902
+ await adapter.startSession("session-assistant-delta", {
903
+ command: "acode",
904
+ workingDirectory: process.cwd(),
905
+ autoAccept: true,
906
+ extraMcpServers: {},
907
+ envOverrides: {},
908
+ });
909
+ await adapter.sendMessage("session-assistant-delta", "answer now");
910
+ expect(events).toHaveLength(1);
911
+ expect(events[0]).toMatchObject({
912
+ type: "text_delta",
913
+ data: { text: "answer chunk" },
171
914
  });
172
- expect(adapter.getSessionStatus('session-mcp')).toBe('tool_using');
173
915
  });
174
- it('forwards ACode command status as thinking without changing session status', async () => {
916
+ it("preserves realtime thinking then answer delta order", async () => {
175
917
  const adapter = new AcodeSdkAdapter();
176
- adapter.setRuntimeConstructorForTest(CommandStatusRuntime);
918
+ adapter.setRuntimeConstructorForTest(ThinkingThenAssistantDeltaRuntime);
177
919
  const events = [];
920
+ adapter.on("event", (event) => events.push(event));
921
+ await adapter.startSession("session-thinking-answer-order", {
922
+ command: "acode",
923
+ workingDirectory: process.cwd(),
924
+ autoAccept: true,
925
+ extraMcpServers: {},
926
+ envOverrides: {},
927
+ });
928
+ await adapter.sendMessage("session-thinking-answer-order", "think then answer");
929
+ expect(events.map((event) => event.type)).toEqual([
930
+ "thinking",
931
+ "text_delta",
932
+ ]);
933
+ expect(events.map((event) => event.data.text)).toEqual([
934
+ "thinking chunk",
935
+ "answer chunk",
936
+ ]);
937
+ });
938
+ it("recovers to waiting_input when ACode submit resolves without a terminal turn event", async () => {
939
+ ResolvedWithoutTerminalEventRuntime.submitMessages = [];
940
+ const adapter = new AcodeSdkAdapter();
941
+ adapter.setRuntimeConstructorForTest(ResolvedWithoutTerminalEventRuntime);
178
942
  const statuses = [];
179
- adapter.on('event', (event) => events.push(event));
180
- adapter.on('status-change', (_sessionId, status) => statuses.push(status));
181
- await adapter.startSession('session-command-status', {
182
- command: 'acode',
943
+ adapter.on("status-change", (_sessionId, status) => statuses.push(status));
944
+ await adapter.startSession("session-resolved-no-terminal", {
945
+ command: "acode",
183
946
  workingDirectory: process.cwd(),
184
947
  autoAccept: true,
185
948
  extraMcpServers: {},
186
949
  envOverrides: {},
187
950
  });
188
- await adapter.sendMessage('session-command-status', '/review src');
189
- expect(events).toHaveLength(1);
190
- expect(events[0]?.type).toBe('thinking');
191
- expect(String(events[0]?.data.text)).toContain('slash_command');
192
- expect(statuses).toEqual(['starting', 'waiting_input', 'thinking']);
193
- expect(adapter.getSessionStatus('session-command-status')).toBe('thinking');
951
+ await adapter.sendMessage("session-resolved-no-terminal", "/review src");
952
+ expect(ResolvedWithoutTerminalEventRuntime.submitMessages).toEqual([
953
+ "/review src",
954
+ ]);
955
+ expect(statuses).toEqual([
956
+ "starting",
957
+ "waiting_input",
958
+ "thinking",
959
+ "waiting_input",
960
+ ]);
961
+ expect(adapter.getSessionStatus("session-resolved-no-terminal")).toBe("waiting_input");
194
962
  });
195
- it('forwards session_complete status after MCP calls so UI can clear active tool state', async () => {
963
+ it("forwards session_complete status after MCP calls so UI can clear active tool state", async () => {
196
964
  const adapter = new AcodeSdkAdapter();
197
965
  adapter.setRuntimeConstructorForTest(McpThenSessionCompleteRuntime);
198
966
  const events = [];
199
967
  const statuses = [];
200
- adapter.on('event', (event) => events.push(event));
201
- adapter.on('status-change', (_sessionId, status, actionInfo) => {
202
- statuses.push({ status, actionType: actionInfo?.actionType, actionDetail: actionInfo?.actionDetail });
968
+ adapter.on("event", (event) => events.push(event));
969
+ adapter.on("status-change", (_sessionId, status, actionInfo) => {
970
+ statuses.push({
971
+ status,
972
+ actionType: actionInfo?.actionType,
973
+ actionDetail: actionInfo?.actionDetail,
974
+ });
203
975
  });
204
- await adapter.startSession('session-mcp-complete', {
205
- command: 'acode',
976
+ await adapter.startSession("session-mcp-complete", {
977
+ command: "acode",
206
978
  workingDirectory: process.cwd(),
207
979
  autoAccept: true,
208
980
  extraMcpServers: {},
209
981
  envOverrides: {},
210
982
  });
211
- await adapter.sendMessage('session-mcp-complete', 'send private');
212
- expect(events.map((event) => event.type)).toEqual(['tool_use_start', 'session_complete']);
983
+ await adapter.sendMessage("session-mcp-complete", "send private");
984
+ expect(events.map((event) => event.type)).toEqual([
985
+ "tool_use_start",
986
+ "session_complete",
987
+ ]);
213
988
  expect(statuses.at(-2)).toEqual({
214
- status: 'tool_using',
215
- actionType: 'mcp',
216
- actionDetail: 'aws-mcp/send_private',
989
+ status: "tool_using",
990
+ actionType: "mcp",
991
+ actionDetail: "aws-mcp/send_private",
217
992
  });
218
993
  expect(statuses.at(-1)).toEqual({
219
- status: 'completed',
994
+ status: "completed",
220
995
  actionType: undefined,
221
996
  actionDetail: undefined,
222
997
  });
223
- expect(adapter.getSessionStatus('session-mcp-complete')).toBe('completed');
998
+ expect(adapter.getSessionStatus("session-mcp-complete")).toBe("completed");
224
999
  });
225
- it('does not block launch completion while the initial prompt runs in the background', async () => {
1000
+ it("does not block launch completion while the initial prompt runs in the background", async () => {
226
1001
  DeferredInitialPromptRuntime.submitCalls = 0;
227
1002
  DeferredInitialPromptRuntime.receivedInitialPrompt = undefined;
228
1003
  const adapter = new AcodeSdkAdapter();
229
1004
  adapter.setRuntimeConstructorForTest(DeferredInitialPromptRuntime);
230
1005
  const statuses = [];
231
- adapter.on('status-change', (_sessionId, status) => statuses.push(status));
232
- await adapter.startSession('session-initial-prompt', {
233
- command: 'acode',
1006
+ adapter.on("status-change", (_sessionId, status) => statuses.push(status));
1007
+ await adapter.startSession("session-initial-prompt", {
1008
+ command: "acode",
234
1009
  workingDirectory: process.cwd(),
235
1010
  autoAccept: true,
236
- initialPrompt: 'start working loop',
1011
+ initialPrompt: "start working loop",
237
1012
  extraMcpServers: {},
238
1013
  envOverrides: {},
239
1014
  });
240
1015
  expect(DeferredInitialPromptRuntime.receivedInitialPrompt).toBeUndefined();
241
1016
  expect(DeferredInitialPromptRuntime.submitCalls).toBe(1);
242
- expect(statuses).toEqual(['starting', 'waiting_input', 'thinking']);
243
- expect(adapter.getSessionStatus('session-initial-prompt')).toBe('thinking');
1017
+ expect(statuses).toEqual(["starting", "waiting_input", "thinking"]);
1018
+ expect(adapter.getSessionStatus("session-initial-prompt")).toBe("thinking");
244
1019
  });
245
- it('continues the message loop by auto-submitting poll_message after a completed turn', async () => {
1020
+ it("continues the message loop by auto-submitting poll_message after a completed turn", async () => {
246
1021
  vi.useFakeTimers();
247
1022
  AutoPollRuntime.submitMessages = [];
248
1023
  const adapter = new AcodeSdkAdapter();
249
1024
  adapter.setRuntimeConstructorForTest(AutoPollRuntime);
250
1025
  const statuses = [];
251
- adapter.on('status-change', (_sessionId, status) => statuses.push(status));
1026
+ adapter.on("status-change", (_sessionId, status) => statuses.push(status));
1027
+ try {
1028
+ await adapter.startSession("session-auto-poll", {
1029
+ command: "acode",
1030
+ workingDirectory: process.cwd(),
1031
+ autoAccept: true,
1032
+ extraMcpServers: {},
1033
+ envOverrides: {},
1034
+ });
1035
+ await adapter.sendMessage("session-auto-poll", "handle current work");
1036
+ await vi.advanceTimersByTimeAsync(250);
1037
+ expect(AutoPollRuntime.submitMessages).toHaveLength(2);
1038
+ expect(AutoPollRuntime.submitMessages[0]).toBe("handle current work");
1039
+ expect(AutoPollRuntime.submitMessages[1]).toContain("poll_message");
1040
+ expect(statuses).toEqual([
1041
+ "starting",
1042
+ "waiting_input",
1043
+ "thinking",
1044
+ "waiting_input",
1045
+ "thinking",
1046
+ "waiting_input",
1047
+ ]);
1048
+ expect(adapter.getSessionStatus("session-auto-poll")).toBe("waiting_input");
1049
+ }
1050
+ finally {
1051
+ vi.useRealTimers();
1052
+ adapter.cleanup();
1053
+ }
1054
+ });
1055
+ it("stops re-arming auto-poll when the model answers without calling poll_message", async () => {
1056
+ vi.useFakeTimers();
1057
+ NonPollingAutoPollRuntime.submitMessages = [];
1058
+ const adapter = new AcodeSdkAdapter();
1059
+ adapter.setRuntimeConstructorForTest(NonPollingAutoPollRuntime);
1060
+ try {
1061
+ await adapter.startSession("session-non-polling-auto-poll", {
1062
+ command: "acode",
1063
+ workingDirectory: process.cwd(),
1064
+ autoAccept: true,
1065
+ extraMcpServers: {},
1066
+ envOverrides: {},
1067
+ });
1068
+ await vi.advanceTimersByTimeAsync(250);
1069
+ expect(NonPollingAutoPollRuntime.submitMessages).toHaveLength(1);
1070
+ expect(NonPollingAutoPollRuntime.submitMessages[0]).toContain("poll_message");
1071
+ await vi.advanceTimersByTimeAsync(5_000);
1072
+ expect(NonPollingAutoPollRuntime.submitMessages).toHaveLength(1);
1073
+ expect(adapter.getSessionStatus("session-non-polling-auto-poll")).toBe("waiting_input");
1074
+ }
1075
+ finally {
1076
+ vi.useRealTimers();
1077
+ adapter.cleanup();
1078
+ }
1079
+ });
1080
+ it("keeps re-arming auto-poll when the model actually calls poll_message", async () => {
1081
+ vi.useFakeTimers();
1082
+ PollingAutoPollRuntime.submitMessages = [];
1083
+ const adapter = new AcodeSdkAdapter();
1084
+ adapter.setRuntimeConstructorForTest(PollingAutoPollRuntime);
1085
+ try {
1086
+ await adapter.startSession("session-polling-auto-poll", {
1087
+ command: "acode",
1088
+ workingDirectory: process.cwd(),
1089
+ autoAccept: true,
1090
+ extraMcpServers: {},
1091
+ envOverrides: {},
1092
+ });
1093
+ await vi.advanceTimersByTimeAsync(250);
1094
+ expect(PollingAutoPollRuntime.submitMessages).toHaveLength(1);
1095
+ await vi.advanceTimersByTimeAsync(250);
1096
+ expect(PollingAutoPollRuntime.submitMessages).toHaveLength(2);
1097
+ }
1098
+ finally {
1099
+ vi.useRealTimers();
1100
+ adapter.cleanup();
1101
+ }
1102
+ });
1103
+ it("keeps re-arming auto-poll when a poll turn completes without assistant text", async () => {
1104
+ vi.useFakeTimers();
1105
+ AutoPollRuntime.submitMessages = [];
1106
+ const adapter = new AcodeSdkAdapter();
1107
+ adapter.setRuntimeConstructorForTest(AutoPollRuntime);
252
1108
  try {
253
- await adapter.startSession('session-auto-poll', {
254
- command: 'acode',
1109
+ await adapter.startSession("session-empty-auto-poll-turn", {
1110
+ command: "acode",
255
1111
  workingDirectory: process.cwd(),
256
1112
  autoAccept: true,
257
1113
  extraMcpServers: {},
258
1114
  envOverrides: {},
259
1115
  });
260
- await adapter.sendMessage('session-auto-poll', 'handle current work');
1116
+ await vi.advanceTimersByTimeAsync(250);
1117
+ expect(AutoPollRuntime.submitMessages).toHaveLength(1);
261
1118
  await vi.advanceTimersByTimeAsync(250);
262
1119
  expect(AutoPollRuntime.submitMessages).toHaveLength(2);
263
- expect(AutoPollRuntime.submitMessages[0]).toBe('handle current work');
264
- expect(AutoPollRuntime.submitMessages[1]).toContain('poll_message');
265
- expect(statuses).toEqual(['starting', 'waiting_input', 'thinking', 'waiting_input', 'thinking']);
266
- expect(adapter.getSessionStatus('session-auto-poll')).toBe('thinking');
1120
+ expect(AutoPollRuntime.submitMessages[1]).toContain("poll_message");
1121
+ }
1122
+ finally {
1123
+ vi.useRealTimers();
1124
+ adapter.cleanup();
1125
+ }
1126
+ });
1127
+ it("cancels a pending auto-poll when a user message arrives", async () => {
1128
+ vi.useFakeTimers();
1129
+ AutoPollRuntime.submitMessages = [];
1130
+ const adapter = new AcodeSdkAdapter();
1131
+ adapter.setRuntimeConstructorForTest(AutoPollRuntime);
1132
+ try {
1133
+ await adapter.startSession("session-cancel-auto-poll", {
1134
+ command: "acode",
1135
+ workingDirectory: process.cwd(),
1136
+ autoAccept: true,
1137
+ extraMcpServers: {},
1138
+ envOverrides: {},
1139
+ });
1140
+ await adapter.sendMessage("session-cancel-auto-poll", "handle current work");
1141
+ await adapter.sendMessage("session-cancel-auto-poll", "user follow up");
1142
+ await vi.advanceTimersByTimeAsync(250);
1143
+ expect(AutoPollRuntime.submitMessages).toEqual([
1144
+ "handle current work",
1145
+ "user follow up",
1146
+ expect.stringContaining("poll_message"),
1147
+ ]);
1148
+ expect(adapter.getSessionStatus("session-cancel-auto-poll")).toBe("waiting_input");
1149
+ }
1150
+ finally {
1151
+ vi.useRealTimers();
1152
+ adapter.cleanup();
1153
+ }
1154
+ });
1155
+ it("does not schedule auto-poll after terminal session status", async () => {
1156
+ vi.useFakeTimers();
1157
+ TerminalAfterTurnRuntime.submitMessages = [];
1158
+ const adapter = new AcodeSdkAdapter();
1159
+ adapter.setRuntimeConstructorForTest(TerminalAfterTurnRuntime);
1160
+ try {
1161
+ await adapter.startSession("session-terminal-auto-poll", {
1162
+ command: "acode",
1163
+ workingDirectory: process.cwd(),
1164
+ autoAccept: true,
1165
+ extraMcpServers: {},
1166
+ envOverrides: {},
1167
+ });
1168
+ await adapter.sendMessage("session-terminal-auto-poll", "complete");
1169
+ await vi.advanceTimersByTimeAsync(250);
1170
+ expect(TerminalAfterTurnRuntime.submitMessages).toEqual(["complete"]);
1171
+ expect(adapter.getSessionStatus("session-terminal-auto-poll")).toBe("completed");
1172
+ }
1173
+ finally {
1174
+ vi.useRealTimers();
1175
+ adapter.cleanup();
1176
+ }
1177
+ });
1178
+ it("arms auto-poll when session is started without an initial prompt (R1)", async () => {
1179
+ vi.useFakeTimers();
1180
+ AutoPollRuntime.submitMessages = [];
1181
+ const adapter = new AcodeSdkAdapter();
1182
+ adapter.setRuntimeConstructorForTest(AutoPollRuntime);
1183
+ const statuses = [];
1184
+ adapter.on("status-change", (_sessionId, status) => statuses.push(status));
1185
+ try {
1186
+ // No initialPrompt: previously the session sat at 'waiting_input'
1187
+ // forever because no auto-poll timer was armed and no setIdleCommands
1188
+ // was called yet. R1 fix arms the auto-poll on startSession instead.
1189
+ await adapter.startSession("session-r1-no-initial", {
1190
+ command: "acode",
1191
+ workingDirectory: process.cwd(),
1192
+ autoAccept: true,
1193
+ extraMcpServers: {},
1194
+ envOverrides: {},
1195
+ });
1196
+ await vi.advanceTimersByTimeAsync(250);
1197
+ // Status sequence: starting -> waiting_input (startSession) ->
1198
+ // thinking (auto-poll fires) -> waiting_input (turn_complete success
1199
+ // re-arms the loop). Empty turns are allowed to retry because only a
1200
+ // plain-text assistant answer indicates the model ignored poll_message.
1201
+ expect(AutoPollRuntime.submitMessages).toHaveLength(1);
1202
+ expect(AutoPollRuntime.submitMessages[0]).toContain("poll_message");
1203
+ expect(statuses).toEqual([
1204
+ "starting",
1205
+ "waiting_input",
1206
+ "thinking",
1207
+ "waiting_input",
1208
+ ]);
1209
+ expect(adapter.getSessionStatus("session-r1-no-initial")).toBe("waiting_input");
1210
+ }
1211
+ finally {
1212
+ vi.useRealTimers();
1213
+ adapter.cleanup();
1214
+ }
1215
+ });
1216
+ it("recovers a polling-driven session to waiting_input after a failed turn and re-arms with a longer backoff (R2)", async () => {
1217
+ vi.useFakeTimers();
1218
+ FailingAutoPollRuntime.submitMessages = [];
1219
+ const adapter = new AcodeSdkAdapter();
1220
+ adapter.setRuntimeConstructorForTest(FailingAutoPollRuntime);
1221
+ const statuses = [];
1222
+ adapter.on("status-change", (_sessionId, status) => statuses.push(status));
1223
+ try {
1224
+ await adapter.startSession("session-r2-recover-failed", {
1225
+ command: "acode",
1226
+ workingDirectory: process.cwd(),
1227
+ autoAccept: true,
1228
+ extraMcpServers: {},
1229
+ envOverrides: {},
1230
+ });
1231
+ // R1 arms the auto-poll; advance the normal 250ms delay to fire it.
1232
+ await vi.advanceTimersByTimeAsync(250);
1233
+ expect(FailingAutoPollRuntime.submitMessages).toHaveLength(1);
1234
+ // R2 fix runs synchronously during the failing submit: the
1235
+ // 'error' event sets status to 'error', then the 'turn_complete'
1236
+ // (failed) event triggers the recovery path which flips status
1237
+ // back to 'waiting_input' and schedules a 2s delayed re-arm.
1238
+ expect(adapter.getSessionStatus("session-r2-recover-failed")).toBe("waiting_input");
1239
+ expect(statuses).toEqual([
1240
+ "starting",
1241
+ "waiting_input",
1242
+ "thinking",
1243
+ "error",
1244
+ "waiting_input",
1245
+ ]);
1246
+ expect(statuses.at(-1)).toBe("waiting_input");
1247
+ // 250ms after recovery is too short for the 2s backoff.
1248
+ await vi.advanceTimersByTimeAsync(250);
1249
+ expect(FailingAutoPollRuntime.submitMessages).toHaveLength(1);
1250
+ // After the 2s backoff elapses, the recovery timer fires and submits
1251
+ // a new poll prompt, restoring the message loop.
1252
+ await vi.advanceTimersByTimeAsync(2000);
1253
+ expect(FailingAutoPollRuntime.submitMessages.length).toBeGreaterThanOrEqual(2);
1254
+ expect(FailingAutoPollRuntime.submitMessages[1]).toContain("poll_message");
1255
+ }
1256
+ finally {
1257
+ vi.useRealTimers();
1258
+ adapter.cleanup();
1259
+ }
1260
+ });
1261
+ it("recovers from a bare ACode error event so auto-poll does not get stuck terminal", async () => {
1262
+ vi.useFakeTimers();
1263
+ BareErrorRuntime.submitMessages = [];
1264
+ const adapter = new AcodeSdkAdapter();
1265
+ adapter.setRuntimeConstructorForTest(BareErrorRuntime);
1266
+ const statuses = [];
1267
+ adapter.on("status-change", (_sessionId, status) => statuses.push(status));
1268
+ try {
1269
+ await adapter.startSession("session-bare-error", {
1270
+ command: "acode",
1271
+ workingDirectory: process.cwd(),
1272
+ autoAccept: true,
1273
+ extraMcpServers: {},
1274
+ envOverrides: {},
1275
+ });
1276
+ await vi.advanceTimersByTimeAsync(250);
1277
+ expect(BareErrorRuntime.submitMessages).toHaveLength(1);
1278
+ expect(adapter.getSessionStatus("session-bare-error")).toBe("waiting_input");
1279
+ expect(statuses).toEqual([
1280
+ "starting",
1281
+ "waiting_input",
1282
+ "thinking",
1283
+ "error",
1284
+ "waiting_input",
1285
+ ]);
1286
+ await vi.advanceTimersByTimeAsync(1999);
1287
+ expect(BareErrorRuntime.submitMessages).toHaveLength(1);
1288
+ await vi.advanceTimersByTimeAsync(1);
1289
+ expect(BareErrorRuntime.submitMessages).toHaveLength(2);
1290
+ expect(BareErrorRuntime.submitMessages[1]).toContain("poll_message");
1291
+ }
1292
+ finally {
1293
+ vi.useRealTimers();
1294
+ adapter.cleanup();
1295
+ }
1296
+ });
1297
+ it("recovers from direct runtime submit rejection during user turns", async () => {
1298
+ vi.useFakeTimers();
1299
+ RejectingSubmitRuntime.submitMessages = [];
1300
+ const adapter = new AcodeSdkAdapter();
1301
+ adapter.setRuntimeConstructorForTest(RejectingSubmitRuntime);
1302
+ const statuses = [];
1303
+ adapter.on("status-change", (_sessionId, status) => statuses.push(status));
1304
+ try {
1305
+ await adapter.startSession("session-user-submit-reject", {
1306
+ command: "acode",
1307
+ workingDirectory: process.cwd(),
1308
+ autoAccept: true,
1309
+ extraMcpServers: {},
1310
+ envOverrides: {},
1311
+ });
1312
+ await adapter.sendMessage("session-user-submit-reject", "user work");
1313
+ expect(RejectingSubmitRuntime.submitMessages).toEqual(["user work"]);
1314
+ expect(adapter.getSessionStatus("session-user-submit-reject")).toBe("waiting_input");
1315
+ expect(statuses).toEqual([
1316
+ "starting",
1317
+ "waiting_input",
1318
+ "thinking",
1319
+ "error",
1320
+ "waiting_input",
1321
+ ]);
1322
+ await vi.advanceTimersByTimeAsync(2000);
1323
+ expect(RejectingSubmitRuntime.submitMessages[1]).toContain("poll_message");
1324
+ }
1325
+ finally {
1326
+ vi.useRealTimers();
1327
+ adapter.cleanup();
1328
+ }
1329
+ });
1330
+ it("does not let stale failed-turn recovery poll after a user follow-up arrives", async () => {
1331
+ vi.useFakeTimers();
1332
+ FailingPollThenSuccessfulUserRuntime.submitMessages = [];
1333
+ const adapter = new AcodeSdkAdapter();
1334
+ adapter.setRuntimeConstructorForTest(FailingPollThenSuccessfulUserRuntime);
1335
+ try {
1336
+ await adapter.startSession("session-stale-recovery-user", {
1337
+ command: "acode",
1338
+ workingDirectory: process.cwd(),
1339
+ autoAccept: true,
1340
+ extraMcpServers: {},
1341
+ envOverrides: {},
1342
+ });
1343
+ await vi.advanceTimersByTimeAsync(250);
1344
+ expect(FailingPollThenSuccessfulUserRuntime.submitMessages).toHaveLength(1);
1345
+ await adapter.sendMessage("session-stale-recovery-user", "human follow up");
1346
+ await vi.advanceTimersByTimeAsync(2000);
1347
+ expect(FailingPollThenSuccessfulUserRuntime.submitMessages).toEqual([
1348
+ expect.stringContaining("poll_message"),
1349
+ "human follow up",
1350
+ ]);
1351
+ expect(adapter.getSessionStatus("session-stale-recovery-user")).toBe("completed");
267
1352
  }
268
1353
  finally {
269
1354
  vi.useRealTimers();
270
1355
  adapter.cleanup();
271
1356
  }
272
1357
  });
1358
+ it("cleans up sessions when ACode runtime fails during startup", async () => {
1359
+ const adapter = new AcodeSdkAdapter();
1360
+ adapter.setRuntimeConstructorForTest(StartFailureRuntime);
1361
+ const statuses = [];
1362
+ adapter.on("status-change", (_sessionId, status) => statuses.push(status));
1363
+ await expect(adapter.startSession("session-start-failure", {
1364
+ command: "acode",
1365
+ workingDirectory: process.cwd(),
1366
+ autoAccept: true,
1367
+ extraMcpServers: {},
1368
+ envOverrides: {},
1369
+ })).rejects.toThrow("runtime start failed");
1370
+ expect(adapter.hasSession("session-start-failure")).toBe(false);
1371
+ expect(adapter.getSessionStatus("session-start-failure")).toBeUndefined();
1372
+ expect(statuses).toEqual(["starting", "error"]);
1373
+ });
1374
+ it("logs a warning but does not emit a fatal error event when poll_message is missing (R3)", async () => {
1375
+ StartupMcpStatusRuntime.includeAwsMcp = false;
1376
+ const adapter = new AcodeSdkAdapter();
1377
+ adapter.setRuntimeConstructorForTest(StartupMcpStatusRuntime);
1378
+ const events = [];
1379
+ const consoleWarn = vi
1380
+ .spyOn(console, "warn")
1381
+ .mockImplementation(() => undefined);
1382
+ adapter.on("event", (event) => events.push(event));
1383
+ try {
1384
+ await adapter.startSession("session-r3-no-poll-message", {
1385
+ command: "acode",
1386
+ workingDirectory: process.cwd(),
1387
+ autoAccept: true,
1388
+ extraMcpServers: {},
1389
+ envOverrides: {},
1390
+ });
1391
+ // R3 fix: a missing poll_message tool is a configuration smell
1392
+ // (the LLM will fail when it tries to call it), but it must NOT
1393
+ // be promoted to a session-level 'error' event. Doing so would
1394
+ // lock the dashboard timeline into a permanent '发生错误' state
1395
+ // even though the LLM itself never reports a real error.
1396
+ expect(events.find((event) => event.type === "error")).toBeUndefined();
1397
+ expect(consoleWarn).toHaveBeenCalled();
1398
+ const warnText = String(consoleWarn.mock.calls[0]?.[0] ?? "");
1399
+ expect(warnText).toContain("poll_message");
1400
+ }
1401
+ finally {
1402
+ consoleWarn.mockRestore();
1403
+ adapter.cleanup();
1404
+ }
1405
+ });
1406
+ it("does not log a warning or emit an error event when poll_message is exposed (R3 negative)", async () => {
1407
+ StartupMcpStatusRuntime.includeAwsMcp = true;
1408
+ const adapter = new AcodeSdkAdapter();
1409
+ adapter.setRuntimeConstructorForTest(StartupMcpStatusRuntime);
1410
+ const events = [];
1411
+ const consoleWarn = vi
1412
+ .spyOn(console, "warn")
1413
+ .mockImplementation(() => undefined);
1414
+ adapter.on("event", (event) => events.push(event));
1415
+ try {
1416
+ await adapter.startSession("session-r3-has-poll-message", {
1417
+ command: "acode",
1418
+ workingDirectory: process.cwd(),
1419
+ autoAccept: true,
1420
+ extraMcpServers: {},
1421
+ envOverrides: {},
1422
+ });
1423
+ expect(events.find((event) => event.type === "error")).toBeUndefined();
1424
+ expect(consoleWarn).not.toHaveBeenCalled();
1425
+ }
1426
+ finally {
1427
+ consoleWarn.mockRestore();
1428
+ adapter.cleanup();
1429
+ }
1430
+ });
1431
+ it("does not log a warning when the MCP server is named differently but still exposes poll_message (R3 user-scenario)", async () => {
1432
+ // Reproduces the production scenario from the bug report: the user
1433
+ // configures the AWS MCP under a non-default name (e.g. 'aws-mcp-server'
1434
+ // or 'agent-mcp'), but aws-mcp's tools are still exposed to the LLM
1435
+ // under names like 'aws-mcp__poll_message'. The old R3 check matched
1436
+ // the literal string 'aws-mcp' against the user-configured server
1437
+ // names, which produced a false-positive 'error' event and locked
1438
+ // the dashboard into a permanent '发生错误' state.
1439
+ const adapter = new AcodeSdkAdapter();
1440
+ adapter.setRuntimeConstructorForTest(DifferentlyNamedMcpRuntime);
1441
+ const events = [];
1442
+ const consoleWarn = vi
1443
+ .spyOn(console, "warn")
1444
+ .mockImplementation(() => undefined);
1445
+ adapter.on("event", (event) => events.push(event));
1446
+ try {
1447
+ await adapter.startSession("session-r3-differently-named-mcp", {
1448
+ command: "acode",
1449
+ workingDirectory: process.cwd(),
1450
+ autoAccept: true,
1451
+ extraMcpServers: {},
1452
+ envOverrides: {},
1453
+ });
1454
+ expect(events.find((event) => event.type === "error")).toBeUndefined();
1455
+ expect(consoleWarn).not.toHaveBeenCalled();
1456
+ }
1457
+ finally {
1458
+ consoleWarn.mockRestore();
1459
+ adapter.cleanup();
1460
+ }
1461
+ });
273
1462
  });