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
@@ -4,19 +4,35 @@
4
4
  * 主流程:直接加载随 aws-runtime-bridge 内置发布的 ACode headless runtime,
5
5
  * 不依赖外部 CLI 安装;将 ACode 的结构化事件映射为 runtime-bridge 统一事件流。
6
6
  */
7
- import { EventEmitter } from 'node:events';
8
- import path from 'node:path';
9
- import { pathToFileURL } from 'node:url';
10
- import { v4 as uuidv4 } from 'uuid';
11
- import { getAcodeConfigFile, getAcodeSkillsDir, getRuntimeHomeDir } from '../config.js';
12
- import { getReleasedAcodeEntryPath } from '../services/acode-package.js';
7
+ import { EventEmitter } from "node:events";
8
+ import path from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+ import { v4 as uuidv4 } from "uuid";
11
+ import { getAcodeConfigFile, getAcodeSkillsDir, getRuntimeHomeDir, } from "../config.js";
12
+ import { getReleasedAcodeEntryPath } from "../services/acode-package.js";
13
+ import { IdlePollLoop } from "./idle-poll-loop.js";
14
+ import { getCanonicalMcpDetail, getToolActionInfo, } from "./types.js";
13
15
  const ACODE_AUTO_POLL_PROMPT = [
14
- 'Continue the AgentsWork message loop now.',
15
- 'You MUST call the exact MCP tool named aws-mcp__poll_message as your next action and wait for the next assigned message or task.',
16
- 'Do not answer in plain text before calling aws-mcp__poll_message.',
17
- 'After handling any received work, reply through the appropriate aws-mcp messaging tool, then continue listening again.',
18
- ].join(' ');
16
+ "Continue the AgentsWork message loop now.",
17
+ "You MUST call the exact MCP tool named aws-mcp__poll_message as your next action and wait for the next assigned message or task.",
18
+ "Do not answer in plain text before calling aws-mcp__poll_message.",
19
+ "After handling any received work, reply through the appropriate aws-mcp messaging tool, then continue listening again.",
20
+ ].join(" ");
19
21
  const ACODE_AUTO_POLL_DELAY_MS = 250;
22
+ // Backoff used when a polling-driven turn fails (e.g., 60s MCP tool timeout,
23
+ // model 5xx, provider unreachable). Larger than ACODE_AUTO_POLL_DELAY_MS to
24
+ // avoid busy-looping when the same failure recurs on every retry.
25
+ const ACODE_AUTO_POLL_FAILURE_DELAY_MS = 2000;
26
+ const ACODE_MESSAGE_POLLING_IDLE_PROMPT = "系统提醒:当前工作已完成或运行时已进入空闲状态。请立即调用 poll_message 工具阻塞等待新消息和流程线任务提醒;收到流程任务提醒后调用 my_task 查看已由后台静默抢占的任务详情并直接处理;完成任务后调用 submit_task_result 提交结果,然后继续 poll_message。";
27
+ function isMessagePollingIdleCommand(command) {
28
+ const normalized = command.trim();
29
+ if (!normalized)
30
+ return false;
31
+ const lower = normalized.toLowerCase();
32
+ return (lower.includes("poll_message") ||
33
+ lower.includes("get_message") ||
34
+ normalized.includes("取消息"));
35
+ }
20
36
  function getAcodePackageEntryPath() {
21
37
  return path.resolve(getReleasedAcodeEntryPath());
22
38
  }
@@ -24,15 +40,26 @@ export function toAcodeEsmImportSpecifier(modulePath) {
24
40
  return pathToFileURL(path.resolve(modulePath)).href;
25
41
  }
26
42
  function toUsage(value) {
27
- if (!value || typeof value !== 'object') {
43
+ if (!value || typeof value !== "object") {
28
44
  return undefined;
29
45
  }
30
46
  const usage = value;
47
+ const inputTokens = toFiniteTokenCount(usage.inputTokens, usage.input_tokens, usage.promptTokens, usage.prompt_tokens);
48
+ const outputTokens = toFiniteTokenCount(usage.outputTokens, usage.output_tokens, usage.completionTokens, usage.completion_tokens);
31
49
  return {
32
- inputTokens: Number(usage.inputTokens) || 0,
33
- outputTokens: Number(usage.outputTokens) || 0,
50
+ inputTokens,
51
+ outputTokens,
34
52
  };
35
53
  }
54
+ function toFiniteTokenCount(...values) {
55
+ for (const value of values) {
56
+ const count = Number(value);
57
+ if (Number.isFinite(count) && count > 0) {
58
+ return count;
59
+ }
60
+ }
61
+ return 0;
62
+ }
36
63
  /**
37
64
  * Check whether an ACode turn_complete event represents a failed turn.
38
65
  * Main flow: only explicit failed=true prevents error status from being reset to waiting_input.
@@ -40,18 +67,34 @@ function toUsage(value) {
40
67
  export function isFailedAcodeTurnComplete(data) {
41
68
  return data.failed === true;
42
69
  }
70
+ function toAcodeMcpActionDetail(actionDetail, server, tool) {
71
+ if (!actionDetail) {
72
+ return tool ? `${server}/${tool}` : server;
73
+ }
74
+ const prefix = `${server}__`;
75
+ if (actionDetail.startsWith(prefix)) {
76
+ return getCanonicalMcpDetail(`${server}__${actionDetail.slice(prefix.length)}`);
77
+ }
78
+ return getCanonicalMcpDetail(actionDetail.replace("/", "__"));
79
+ }
80
+ function isAcodeSemanticToolAction(actionType) {
81
+ return (actionType === "read_file" ||
82
+ actionType === "edit_file" ||
83
+ actionType === "write_file" ||
84
+ actionType === "bash");
85
+ }
43
86
  export class AcodeSdkAdapter extends EventEmitter {
44
87
  constructor() {
45
88
  super(...arguments);
46
- this.providerId = 'acode';
47
- this.displayName = 'ACode';
89
+ this.providerId = "acode";
90
+ this.displayName = "ACode";
48
91
  this.sessions = new Map();
49
92
  }
50
93
  async startSession(sessionId, config) {
51
94
  const runtimeHome = getRuntimeHomeDir();
52
95
  const session = {
53
96
  sessionId,
54
- status: 'starting',
97
+ status: "starting",
55
98
  messages: [],
56
99
  createdAt: new Date().toISOString(),
57
100
  totalUsage: { inputTokens: 0, outputTokens: 0 },
@@ -62,33 +105,58 @@ export class AcodeSdkAdapter extends EventEmitter {
62
105
  runtime: await this.createRuntime(sessionId, config, runtimeHome),
63
106
  autoPollActive: false,
64
107
  });
65
- this.emit('status-change', sessionId, 'starting');
108
+ this.emit("status-change", sessionId, "starting");
66
109
  const entry = this.sessions.get(sessionId);
67
110
  if (!entry) {
68
- throw new Error('ACode session was not initialized');
111
+ throw new Error("ACode session was not initialized");
112
+ }
113
+ try {
114
+ await entry.runtime.start();
115
+ session.status = "waiting_input";
116
+ this.emit("status-change", sessionId, "waiting_input");
117
+ if (config.initialPrompt?.trim()) {
118
+ this.startInitialPromptTurn(sessionId, config.initialPrompt);
119
+ }
120
+ else {
121
+ // R1 fix: when no initial prompt is provided, explicitly arm the
122
+ // auto-poll loop. Without this, the session sits at 'waiting_input'
123
+ // forever if setIdleCommands hasn't been called yet (or is configured
124
+ // with a non-polling command). setIdleCommands will cancel this timer
125
+ // and replace it with the configured idle loop when invoked.
126
+ this.armIdleLoopOrAutoPoll(sessionId);
127
+ }
69
128
  }
70
- await entry.runtime.start();
71
- session.status = 'waiting_input';
72
- this.emit('status-change', sessionId, 'waiting_input');
73
- if (config.initialPrompt?.trim()) {
74
- this.startInitialPromptTurn(sessionId, config.initialPrompt);
129
+ catch (error) {
130
+ session.status = "error";
131
+ this.emitRuntimeErrorEvent(sessionId, error);
132
+ this.emit("status-change", sessionId, "error");
133
+ this.sessions.delete(sessionId);
134
+ throw error;
75
135
  }
76
136
  }
77
137
  async sendMessage(sessionId, message) {
78
138
  const entry = this.requireSession(sessionId);
79
139
  this.cancelScheduledAutoPoll(entry);
140
+ entry.failedTurnRecoveryScheduled = false;
141
+ entry.idlePollLoop?.stop();
80
142
  const userMessage = {
81
143
  id: uuidv4(),
82
144
  sessionId,
83
- role: 'user',
145
+ role: "user",
84
146
  content: message,
85
147
  timestamp: new Date().toISOString(),
86
148
  };
87
149
  entry.adapterSession.messages.push(userMessage);
88
- this.emit('conversation-message', sessionId, userMessage);
89
- entry.adapterSession.status = 'thinking';
90
- this.emit('status-change', sessionId, 'thinking');
91
- await entry.runtime.submit(message);
150
+ this.emit("conversation-message", sessionId, userMessage);
151
+ entry.adapterSession.status = "thinking";
152
+ this.emit("status-change", sessionId, "thinking");
153
+ try {
154
+ await entry.runtime.submit(message);
155
+ this.recoverResolvedTurnWithoutTerminalEvent(sessionId);
156
+ }
157
+ catch (error) {
158
+ this.emitRuntimeError(sessionId, error, { recover: true });
159
+ }
92
160
  }
93
161
  async sendConfirmation(_sessionId, _accept) { }
94
162
  async abortCurrentTurn(sessionId) {
@@ -100,10 +168,12 @@ export class AcodeSdkAdapter extends EventEmitter {
100
168
  return;
101
169
  }
102
170
  this.cancelScheduledAutoPoll(entry);
171
+ entry.failedTurnRecoveryScheduled = false;
172
+ entry.idlePollLoop?.stop();
103
173
  await entry.runtime.stop();
104
- entry.adapterSession.status = 'terminated';
174
+ entry.adapterSession.status = "terminated";
105
175
  this.sessions.delete(sessionId);
106
- this.emit('status-change', sessionId, 'terminated');
176
+ this.emit("status-change", sessionId, "terminated");
107
177
  }
108
178
  getConversation(sessionId) {
109
179
  return this.sessions.get(sessionId)?.adapterSession.messages ?? [];
@@ -122,6 +192,23 @@ export class AcodeSdkAdapter extends EventEmitter {
122
192
  void this.terminateSession(sessionId);
123
193
  }
124
194
  }
195
+ setIdleCommands(sessionId, commands) {
196
+ const entry = this.sessions.get(sessionId);
197
+ if (!entry)
198
+ return;
199
+ entry.idleCommands = commands;
200
+ this.cancelScheduledAutoPoll(entry);
201
+ if (commands.idleInputCommand &&
202
+ isMessagePollingIdleCommand(commands.idleInputCommand)) {
203
+ const loop = this.getIdlePollLoop(entry);
204
+ loop.start();
205
+ return;
206
+ }
207
+ if (entry.adapterSession.status === "waiting_input" &&
208
+ (commands.idleInputCommand || commands.nonInputCommand)) {
209
+ this.getIdlePollLoop(entry).start();
210
+ }
211
+ }
125
212
  /** Injects a runtime constructor for adapter tests without loading the packaged ACode module. */
126
213
  setRuntimeConstructorForTest(runtimeConstructor) {
127
214
  this.runtimeConstructor = runtimeConstructor;
@@ -134,10 +221,12 @@ export class AcodeSdkAdapter extends EventEmitter {
134
221
  configPath: config.mcpConfigPath || getAcodeConfigFile(runtimeHome),
135
222
  skillsDir: getAcodeSkillsDir(runtimeHome),
136
223
  model: config.model,
137
- systemPrompt: typeof config.systemPrompt === 'string' ? config.systemPrompt : undefined,
224
+ systemPrompt: typeof config.systemPrompt === "string"
225
+ ? config.systemPrompt
226
+ : undefined,
138
227
  env: config.envOverrides,
139
228
  });
140
- runtime.on('event', (event) => this.forwardAcodeEvent(event));
229
+ runtime.on("event", (event) => this.forwardAcodeEvent(event));
141
230
  return runtime;
142
231
  }
143
232
  async loadRuntimeConstructor() {
@@ -145,7 +234,7 @@ export class AcodeSdkAdapter extends EventEmitter {
145
234
  return this.runtimeConstructor;
146
235
  }
147
236
  const modulePath = getAcodePackageEntryPath();
148
- const module = await import(toAcodeEsmImportSpecifier(modulePath));
237
+ const module = (await import(toAcodeEsmImportSpecifier(modulePath)));
149
238
  if (!module.ACodeRuntime) {
150
239
  throw new Error(`ACode runtime export not found: ${modulePath}`);
151
240
  }
@@ -155,7 +244,7 @@ export class AcodeSdkAdapter extends EventEmitter {
155
244
  requireSession(sessionId) {
156
245
  const entry = this.sessions.get(sessionId);
157
246
  if (!entry) {
158
- throw new Error('ACode session not found');
247
+ throw new Error("ACode session not found");
159
248
  }
160
249
  return entry;
161
250
  }
@@ -169,22 +258,25 @@ export class AcodeSdkAdapter extends EventEmitter {
169
258
  if (!entry) {
170
259
  return;
171
260
  }
172
- entry.adapterSession.status = 'thinking';
173
- this.emit('status-change', sessionId, 'thinking');
174
- void entry.runtime.submit(initialPrompt).catch((error) => {
261
+ entry.adapterSession.status = "thinking";
262
+ this.emit("status-change", sessionId, "thinking");
263
+ void entry.runtime
264
+ .submit(initialPrompt)
265
+ .then(() => {
266
+ this.recoverResolvedTurnWithoutTerminalEvent(sessionId);
267
+ })
268
+ .catch((error) => {
175
269
  const currentEntry = this.sessions.get(sessionId);
176
270
  if (!currentEntry) {
177
271
  return;
178
272
  }
179
- currentEntry.adapterSession.status = 'error';
180
- const timestamp = new Date().toISOString();
181
- this.emit('event', {
182
- type: 'error',
183
- sessionId,
184
- timestamp,
185
- data: { text: error instanceof Error ? error.message : String(error) },
186
- });
187
- this.emit('status-change', sessionId, 'error');
273
+ // R4 fix: a failed initial prompt turn used to leave the session
274
+ // permanently in 'error' because autoPollActive was false at this
275
+ // point (only set by submitAutoPollPrompt), so the R2 recovery in
276
+ // forwardAcodeEvent skipped it, and no idle loop was armed.
277
+ // Now we always recover to 'waiting_input' and arm the idle loop
278
+ // so the agent can retry via poll_message.
279
+ this.emitRuntimeError(sessionId, error, { recover: true });
188
280
  });
189
281
  }
190
282
  forwardAcodeEvent(event) {
@@ -192,107 +284,308 @@ export class AcodeSdkAdapter extends EventEmitter {
192
284
  if (!entry) {
193
285
  return;
194
286
  }
195
- if (event.type === 'assistant_delta') {
196
- const text = typeof event.data.text === 'string' ? event.data.text : '';
287
+ if (event.type === "assistant_delta") {
288
+ const text = typeof event.data.text === "string" ? event.data.text : "";
289
+ if (entry.autoPollActive) {
290
+ entry.autoPollObservedAssistantText = true;
291
+ }
197
292
  entry.adapterSession.messages.push({
198
293
  id: uuidv4(),
199
294
  sessionId: event.sessionId,
200
- role: 'assistant',
295
+ role: "assistant",
201
296
  content: text,
202
297
  timestamp: event.timestamp,
203
298
  });
204
- this.emitProviderEvent(event, 'text_delta', { text });
299
+ this.emitProviderEvent(event, "text_delta", { text });
300
+ return;
301
+ }
302
+ if (event.type === "thinking_delta") {
303
+ const text = typeof event.data.text === "string" ? event.data.text : "";
304
+ this.emitProviderEvent(event, "thinking", { text });
305
+ return;
306
+ }
307
+ if (event.type === "ai_request") {
308
+ this.emitProviderEvent(event, "ai_request", {
309
+ model: typeof event.data.model === "string" ? event.data.model : undefined,
310
+ endpoint: typeof event.data.endpoint === "string"
311
+ ? event.data.endpoint
312
+ : undefined,
313
+ stream: typeof event.data.stream === "boolean"
314
+ ? event.data.stream
315
+ : undefined,
316
+ messageCount: typeof event.data.messageCount === "number"
317
+ ? event.data.messageCount
318
+ : undefined,
319
+ toolCount: typeof event.data.toolCount === "number"
320
+ ? event.data.toolCount
321
+ : undefined,
322
+ lastUserMessagePreview: typeof event.data.lastUserMessagePreview === "string"
323
+ ? event.data.lastUserMessagePreview
324
+ : undefined,
325
+ requestBody: typeof event.data.requestBody === "string"
326
+ ? event.data.requestBody
327
+ : undefined,
328
+ });
205
329
  return;
206
330
  }
207
- if (event.type === 'turn_complete') {
331
+ if (event.type === "turn_complete") {
208
332
  const usage = toUsage(event.data.usage);
209
333
  if (usage) {
210
334
  entry.adapterSession.totalUsage.inputTokens += usage.inputTokens;
211
335
  entry.adapterSession.totalUsage.outputTokens += usage.outputTokens;
212
336
  }
213
337
  if (isFailedAcodeTurnComplete(event.data)) {
338
+ // R2 fix: a failed polling-driven turn (e.g., 60s MCP tool timeout,
339
+ // model 5xx, provider unreachable) used to leave the session parked
340
+ // in 'error' or 'thinking' forever, so the agent stopped calling
341
+ // poll_message permanently. Recover to 'waiting_input' and re-arm
342
+ // the auto-poll with a longer backoff so a transient failure
343
+ // doesn't break the message loop.
344
+ //
345
+ // R4 fix: previously this only recovered when autoPollActive was
346
+ // true (i.e. during a submitAutoPollPrompt-driven turn). When the
347
+ // initial prompt turn failed, autoPollActive was false and the
348
+ // session got permanently stuck in 'error'. Now we always recover
349
+ // for non-terminal sessions so the idle loop can retry.
350
+ this.recoverTurnFailure(event.sessionId, entry);
351
+ return;
352
+ }
353
+ this.emitProviderEvent(event, "turn_complete", {
354
+ usage: entry.adapterSession.totalUsage,
355
+ responseBody: typeof event.data.responseBody === "string"
356
+ ? event.data.responseBody
357
+ : undefined,
358
+ finishReason: typeof event.data.finishReason === "string"
359
+ ? event.data.finishReason
360
+ : undefined,
361
+ });
362
+ entry.adapterSession.status = "waiting_input";
363
+ this.emit("status-change", event.sessionId, "waiting_input", {
364
+ usage: entry.adapterSession.totalUsage,
365
+ });
366
+ if (entry.autoPollActive &&
367
+ entry.autoPollObservedAssistantText &&
368
+ !entry.autoPollObservedPollingTool) {
369
+ entry.stopAutoPollAfterCurrentTurn = true;
370
+ console.warn(`[${this.displayName}] Auto-poll turn produced assistant text without calling poll_message; stopping auto-poll rearm for session ${event.sessionId}`);
214
371
  return;
215
372
  }
216
- this.emitProviderEvent(event, 'turn_complete', { usage: entry.adapterSession.totalUsage });
217
- entry.adapterSession.status = 'waiting_input';
218
- this.emit('status-change', event.sessionId, 'waiting_input', { usage: entry.adapterSession.totalUsage });
219
- this.scheduleAutoPoll(event.sessionId);
373
+ this.armIdleLoopOrAutoPoll(event.sessionId);
220
374
  return;
221
375
  }
222
- if (event.type === 'session_complete') {
223
- entry.adapterSession.status = 'completed';
224
- this.emitProviderEvent(event, 'session_complete', { exitCode: Number(event.data.exitCode) || 0 });
225
- this.emit('status-change', event.sessionId, 'completed');
376
+ if (event.type === "session_complete") {
377
+ this.cancelScheduledAutoPoll(entry);
378
+ entry.failedTurnRecoveryScheduled = false;
379
+ entry.idlePollLoop?.stop();
380
+ entry.adapterSession.status = "completed";
381
+ this.emitProviderEvent(event, "session_complete", {
382
+ exitCode: Number(event.data.exitCode) || 0,
383
+ });
384
+ this.emit("status-change", event.sessionId, "completed");
226
385
  return;
227
386
  }
228
- if (event.type === 'error') {
229
- entry.adapterSession.status = 'error';
230
- this.emitProviderEvent(event, 'error', { text: String(event.data.message || 'ACode runtime error') });
231
- this.emit('status-change', event.sessionId, 'error');
387
+ if (event.type === "error") {
388
+ entry.adapterSession.status = "error";
389
+ this.emitProviderEvent(event, "error", {
390
+ text: String(event.data.message || "ACode runtime error"),
391
+ });
392
+ this.emit("status-change", event.sessionId, "error");
393
+ if (!entry.autoPollActive) {
394
+ this.recoverTurnFailure(event.sessionId, entry);
395
+ }
232
396
  return;
233
397
  }
234
- if (event.type === 'mcp_status') {
235
- const server = typeof event.data.server === 'string' ? event.data.server : 'mcp';
236
- const tool = typeof event.data.tool === 'string' ? event.data.tool : '';
237
- const actionDetail = tool ? `${server}/${tool}` : server;
238
- entry.adapterSession.status = 'tool_using';
239
- this.emitProviderEvent(event, 'tool_use_start', {
398
+ if (event.type === "mcp_status") {
399
+ // R3 fix: the ACode runtime emits a startup mcp_status event with no
400
+ // `action` field but containing a `tools` array describing what the
401
+ // LLM can actually call. Validate that a poll_message tool is exposed,
402
+ // otherwise the message loop cannot continue.
403
+ //
404
+ // The previous version of this check matched the literal string
405
+ // 'aws-mcp' against the user-configured MCP server keys in the
406
+ // `servers` field, which produced false positives when the operator
407
+ // named the entry differently (e.g. 'aws-mcp-server' or 'agent-mcp')
408
+ // while still exposing aws-mcp's tools to the LLM. Worse, on a
409
+ // mismatch it emitted a generic `type: 'error'` ProviderEvent, which
410
+ // the rest of the bridge promotes to a fatal session-level 'error'
411
+ // status that the dashboard cannot recover from — the LLM keeps
412
+ // working but the timeline header gets stuck on "发生错误" forever.
413
+ //
414
+ // The fix below validates against the actual `tools` list (the same
415
+ // tools the LLM sees) and on a missing poll_message it only logs a
416
+ // warning. The runtime will naturally emit a real 'error' event the
417
+ // first time the LLM tries to call a missing tool, which the existing
418
+ // error handling already routes correctly without permanently locking
419
+ // the session status.
420
+ if (!event.data.action && Array.isArray(event.data.tools)) {
421
+ const tools = event.data.tools;
422
+ const hasPollMessage = tools.some((tool) => typeof tool?.name === "string" &&
423
+ tool.name.includes("poll_message"));
424
+ if (!hasPollMessage) {
425
+ const message = "ACode runtime is not exposing a poll_message tool. The LLM cannot call poll_message. " +
426
+ "Please configure an MCP server that provides poll_message (e.g. aws-mcp) in the dashboard panel for this runtime.";
427
+ console.warn(`[${this.displayName}] ${message}`);
428
+ }
429
+ return;
430
+ }
431
+ const server = typeof event.data.server === "string" ? event.data.server : "mcp";
432
+ const tool = typeof event.data.tool === "string" ? event.data.tool : "";
433
+ const toolName = server && tool ? `${server}__${tool}` : tool || server;
434
+ const toolInput = event.data.arguments && typeof event.data.arguments === "object" && !Array.isArray(event.data.arguments)
435
+ ? event.data.arguments
436
+ : undefined;
437
+ const actionInfo = getToolActionInfo(toolName, toolInput);
438
+ if (entry.autoPollActive && actionInfo.actionType === "idle") {
439
+ entry.autoPollObservedPollingTool = true;
440
+ }
441
+ const actionDetail = isAcodeSemanticToolAction(actionInfo.actionType)
442
+ ? actionInfo.actionDetail || toAcodeMcpActionDetail(undefined, server, tool)
443
+ : toAcodeMcpActionDetail(actionInfo.actionDetail, server, tool);
444
+ const toolUseId = typeof event.data.toolUseId === "string"
445
+ ? event.data.toolUseId
446
+ : undefined;
447
+ const eventData = {
240
448
  toolName: actionDetail,
241
- actionType: 'mcp',
242
- actionLabel: 'Calling MCP',
243
- actionDetail,
244
- });
245
- this.emit('status-change', event.sessionId, 'tool_using', {
246
- actionType: 'mcp',
247
- actionLabel: 'Calling MCP',
449
+ actionType: actionInfo.actionType,
450
+ actionLabel: actionInfo.actionLabel,
248
451
  actionDetail,
249
- });
452
+ ...(toolInput ? { toolInput } : {}),
453
+ ...(toolUseId ? { toolUseId, actionId: toolUseId } : {}),
454
+ };
455
+ if (event.data.action === "tool_result") {
456
+ this.emitProviderEvent(event, "tool_use_end", {
457
+ ...eventData,
458
+ toolResult: typeof event.data.result === "string"
459
+ ? event.data.result
460
+ : JSON.stringify(event.data.result ?? ""),
461
+ });
462
+ entry.adapterSession.status = "thinking";
463
+ this.emit("status-change", event.sessionId, "thinking");
464
+ return;
465
+ }
466
+ const status = actionInfo.actionType === "idle" ? "waiting_input" : "tool_using";
467
+ entry.adapterSession.status = status;
468
+ this.emitProviderEvent(event, "tool_use_start", eventData);
469
+ this.emit("status-change", event.sessionId, status, eventData);
250
470
  return;
251
471
  }
252
- this.emitProviderEvent(event, 'thinking', {
472
+ this.emitProviderEvent(event, "thinking", {
253
473
  text: JSON.stringify(event.data),
254
474
  });
255
475
  }
256
476
  emitProviderEvent(event, type, data) {
257
- this.emit('event', {
477
+ this.emit("event", {
258
478
  type,
259
479
  sessionId: event.sessionId,
260
480
  timestamp: event.timestamp,
261
481
  data,
262
482
  });
263
483
  }
484
+ /**
485
+ * After a completed turn, prefer the configured IdlePollLoop (set via setIdleCommands).
486
+ * Fall back to the legacy auto-poll mechanism when no idle commands are configured.
487
+ */
488
+ armIdleLoopOrAutoPoll(sessionId) {
489
+ const entry = this.sessions.get(sessionId);
490
+ if (!entry || this.isTerminalStatus(entry.adapterSession.status)) {
491
+ return;
492
+ }
493
+ if (entry.idleCommands?.idleInputCommand) {
494
+ entry.idlePollLoop?.start();
495
+ return;
496
+ }
497
+ this.scheduleAutoPoll(sessionId);
498
+ }
499
+ getIdlePollLoop(entry) {
500
+ if (entry.idlePollLoop)
501
+ return entry.idlePollLoop;
502
+ const sessionId = entry.adapterSession.sessionId;
503
+ entry.idlePollLoop = new IdlePollLoop({
504
+ getStatus: () => this.sessions.get(sessionId)?.adapterSession.status,
505
+ getCommand: () => this.sessions.get(sessionId)?.idleCommands?.idleInputCommand,
506
+ isPollingCommand: (command) => isMessagePollingIdleCommand(command),
507
+ dispatchPollingPrompt: () => this.submitAutoPollPrompt(sessionId, ACODE_MESSAGE_POLLING_IDLE_PROMPT),
508
+ dispatchRegularIdleCommand: (command) => this.submitAutoPollPrompt(sessionId, command),
509
+ });
510
+ return entry.idlePollLoop;
511
+ }
512
+ submitAutoPollPrompt(sessionId, prompt) {
513
+ const entry = this.sessions.get(sessionId);
514
+ if (!entry || this.isTerminalStatus(entry.adapterSession.status))
515
+ return;
516
+ entry.autoPollActive = true;
517
+ entry.autoPollObservedPollingTool = false;
518
+ entry.autoPollObservedAssistantText = false;
519
+ entry.stopAutoPollAfterCurrentTurn = false;
520
+ entry.adapterSession.status = "thinking";
521
+ this.emit("status-change", sessionId, "thinking");
522
+ void entry.runtime
523
+ .submit(prompt)
524
+ .catch((error) => {
525
+ this.emitRuntimeError(sessionId, error);
526
+ })
527
+ .finally(() => {
528
+ const latestEntry = this.sessions.get(sessionId);
529
+ if (!latestEntry)
530
+ return;
531
+ latestEntry.autoPollActive = false;
532
+ // If the R2 recovery path already scheduled a delayed re-arm for a
533
+ // failed polling-driven turn, skip armIdleLoopOrAutoPoll so we do
534
+ // not double-arm (immediate fire via IdlePollLoop AND a 2s timer).
535
+ // The recovery timer is the source of truth here: the flag is just
536
+ // a defensive marker; the timer itself is what matters.
537
+ if (latestEntry.failedTurnRecoveryScheduled ||
538
+ latestEntry.autoPollTimer) {
539
+ latestEntry.failedTurnRecoveryScheduled = false;
540
+ return;
541
+ }
542
+ if (latestEntry.stopAutoPollAfterCurrentTurn) {
543
+ latestEntry.stopAutoPollAfterCurrentTurn = false;
544
+ latestEntry.autoPollObservedPollingTool = false;
545
+ latestEntry.autoPollObservedAssistantText = false;
546
+ return;
547
+ }
548
+ if (latestEntry.adapterSession.status === "error") {
549
+ this.recoverTurnFailure(sessionId, latestEntry);
550
+ return;
551
+ }
552
+ if (latestEntry.adapterSession.status === "thinking" ||
553
+ latestEntry.adapterSession.status === "tool_using") {
554
+ this.recoverResolvedTurnWithoutTerminalEvent(sessionId);
555
+ return;
556
+ }
557
+ if (latestEntry.adapterSession.status === "waiting_input") {
558
+ this.armIdleLoopOrAutoPoll(sessionId);
559
+ }
560
+ });
561
+ }
264
562
  /**
265
563
  * Continue ACode's headless agent loop after a completed turn.
266
564
  * Main flow: debounce duplicate scheduling -> mark busy -> submit an internal
267
565
  * poll instruction -> let normal ACode events publish MCP/tool/turn states.
566
+ *
567
+ * @param delayMs Optional override for the default 250ms backoff. The R2
568
+ * recovery path uses ACODE_AUTO_POLL_FAILURE_DELAY_MS (2s) to avoid
569
+ * busy-looping when the same failure recurs on every retry.
268
570
  */
269
- scheduleAutoPoll(sessionId) {
571
+ scheduleAutoPoll(sessionId, delayMs = ACODE_AUTO_POLL_DELAY_MS) {
270
572
  const entry = this.sessions.get(sessionId);
271
- if (!entry || entry.autoPollActive || entry.autoPollTimer || this.isTerminalStatus(entry.adapterSession.status)) {
573
+ if (!entry ||
574
+ entry.autoPollActive ||
575
+ entry.autoPollTimer ||
576
+ this.isTerminalStatus(entry.adapterSession.status)) {
272
577
  return;
273
578
  }
274
579
  entry.autoPollTimer = setTimeout(() => {
275
580
  const currentEntry = this.sessions.get(sessionId);
276
- if (!currentEntry || currentEntry.autoPollActive || this.isTerminalStatus(currentEntry.adapterSession.status)) {
581
+ if (!currentEntry ||
582
+ currentEntry.autoPollActive ||
583
+ this.isTerminalStatus(currentEntry.adapterSession.status)) {
277
584
  return;
278
585
  }
279
586
  currentEntry.autoPollTimer = undefined;
280
- currentEntry.autoPollActive = true;
281
- currentEntry.adapterSession.status = 'thinking';
282
- this.emit('status-change', sessionId, 'thinking');
283
- void currentEntry.runtime.submit(ACODE_AUTO_POLL_PROMPT).catch((error) => {
284
- this.emitRuntimeError(sessionId, error);
285
- }).finally(() => {
286
- const latestEntry = this.sessions.get(sessionId);
287
- if (!latestEntry) {
288
- return;
289
- }
290
- latestEntry.autoPollActive = false;
291
- if (latestEntry.adapterSession.status === 'waiting_input') {
292
- this.scheduleAutoPoll(sessionId);
293
- }
294
- });
295
- }, ACODE_AUTO_POLL_DELAY_MS);
587
+ this.submitAutoPollPrompt(sessionId, ACODE_AUTO_POLL_PROMPT);
588
+ }, delayMs);
296
589
  }
297
590
  cancelScheduledAutoPoll(entry) {
298
591
  if (!entry.autoPollTimer) {
@@ -302,21 +595,84 @@ export class AcodeSdkAdapter extends EventEmitter {
302
595
  entry.autoPollTimer = undefined;
303
596
  }
304
597
  isTerminalStatus(status) {
305
- return status === 'completed' || status === 'terminated' || status === 'error';
598
+ return status === "completed" || status === "terminated";
306
599
  }
307
- emitRuntimeError(sessionId, error) {
600
+ emitRuntimeError(sessionId, error, options) {
308
601
  const currentEntry = this.sessions.get(sessionId);
309
602
  if (!currentEntry) {
310
603
  return;
311
604
  }
312
- currentEntry.adapterSession.status = 'error';
605
+ currentEntry.adapterSession.status = "error";
606
+ this.emitRuntimeErrorEvent(sessionId, error);
607
+ this.emit("status-change", sessionId, "error");
608
+ if (options?.recover) {
609
+ this.recoverTurnFailure(sessionId, currentEntry);
610
+ }
611
+ }
612
+ emitRuntimeErrorEvent(sessionId, error) {
313
613
  const timestamp = new Date().toISOString();
314
- this.emit('event', {
315
- type: 'error',
614
+ this.emit("event", {
615
+ type: "error",
316
616
  sessionId,
317
617
  timestamp,
318
618
  data: { text: error instanceof Error ? error.message : String(error) },
319
619
  });
320
- this.emit('status-change', sessionId, 'error');
620
+ }
621
+ recoverTurnFailure(sessionId, entry) {
622
+ if (this.isTerminalStatus(entry.adapterSession.status)) {
623
+ return;
624
+ }
625
+ if (entry.autoPollTimer || entry.failedTurnRecoveryScheduled) {
626
+ if (entry.adapterSession.status !== "waiting_input") {
627
+ entry.adapterSession.status = "waiting_input";
628
+ this.emit("status-change", sessionId, "waiting_input");
629
+ }
630
+ return;
631
+ }
632
+ this.cancelScheduledAutoPoll(entry);
633
+ entry.adapterSession.status = "waiting_input";
634
+ this.emit("status-change", sessionId, "waiting_input");
635
+ entry.failedTurnRecoveryScheduled = true;
636
+ entry.autoPollTimer = setTimeout(() => {
637
+ const currentEntry = this.sessions.get(sessionId);
638
+ if (!currentEntry ||
639
+ currentEntry.autoPollActive ||
640
+ this.isTerminalStatus(currentEntry.adapterSession.status)) {
641
+ if (currentEntry) {
642
+ currentEntry.failedTurnRecoveryScheduled = false;
643
+ }
644
+ return;
645
+ }
646
+ currentEntry.autoPollTimer = undefined;
647
+ currentEntry.failedTurnRecoveryScheduled = false;
648
+ if (currentEntry.idleCommands?.idleInputCommand) {
649
+ this.getIdlePollLoop(currentEntry).start();
650
+ }
651
+ else {
652
+ this.submitAutoPollPrompt(sessionId, ACODE_AUTO_POLL_PROMPT);
653
+ }
654
+ }, ACODE_AUTO_POLL_FAILURE_DELAY_MS);
655
+ }
656
+ /**
657
+ * Recover turns where runtime.submit() resolved but no terminal turn event arrived.
658
+ *
659
+ * Main flow: ACode should normally emit turn_complete/session_complete/error before
660
+ * submit resolves. Some edge paths can emit only intermediate events (for example
661
+ * command_status or tool_result), then resolve. At that point there is no active
662
+ * turn anymore, so keeping the adapter at thinking/tool_using makes the dashboard
663
+ * look permanently stuck. Recover to waiting_input and arm the listening loop.
664
+ */
665
+ recoverResolvedTurnWithoutTerminalEvent(sessionId) {
666
+ const entry = this.sessions.get(sessionId);
667
+ if (!entry || this.isTerminalStatus(entry.adapterSession.status)) {
668
+ return;
669
+ }
670
+ if (entry.adapterSession.status !== "thinking" &&
671
+ entry.adapterSession.status !== "tool_using") {
672
+ return;
673
+ }
674
+ entry.adapterSession.status = "waiting_input";
675
+ this.emit("status-change", sessionId, "waiting_input");
676
+ this.armIdleLoopOrAutoPoll(sessionId);
321
677
  }
322
678
  }