aws-runtime-bridge 1.7.50 → 1.8.0

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