appback-remoteagent 0.16.0 → 0.17.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.
package/README.md CHANGED
@@ -123,6 +123,9 @@ Current command surface implemented in `src/bot.ts`:
123
123
  | `/batch done` | Alias for `/batch send` |
124
124
  | `/batch cancel` | Discards the current batch |
125
125
  | `/batch status` | Shows current batch state |
126
+ | `/queue` | Lists instructions waiting behind the active session work |
127
+ | `/queue remove <id>` | Removes one waiting instruction by its `Q001`-style id |
128
+ | `/queue del` | Removes the most recently queued instruction |
126
129
 
127
130
  Multi-bot polling is tiered by recent activity and active provider work. See [docs/BOT_POLLING_POLICY.md](./docs/BOT_POLLING_POLICY.md).
128
131
 
@@ -18,7 +18,7 @@ export class CodexAdapter {
18
18
  const args = request.sessionId
19
19
  ? this.buildResumeArgs(request, outputPath, sandboxMode)
20
20
  : this.buildExecArgs(request, outputPath, sandboxMode);
21
- const { stdout, stderr, code, timedOut } = await this.runCodex(args, request.cwd, request.remoteSessionId, request.publicSessionId, request.message);
21
+ const { stdout, stderr, code, timedOut } = await this.runCodex(args, request.cwd, request.remoteSessionId, request.publicSessionId, request.message, request.onProgress);
22
22
  try {
23
23
  const sessionId = this.extractThreadId(stdout) ?? request.sessionId;
24
24
  const output = await this.readOutput(outputPath) || this.extractAgentMessage(stdout);
@@ -102,12 +102,31 @@ export class CodexAdapter {
102
102
  args.push("--dangerously-bypass-approvals-and-sandbox");
103
103
  }
104
104
  }
105
- runCodex(args, cwd, remoteSessionId, publicSessionId, input) {
105
+ runCodex(args, cwd, remoteSessionId, publicSessionId, input, onProgress) {
106
106
  return spawnWithPlatformShell(this.codexBin, args, cwd, this.currentTimeoutMs(), input, remoteSessionId, {
107
107
  REMOTEAGENT_SESSION_ID: remoteSessionId,
108
108
  REMOTEAGENT_PUBLIC_SESSION_ID: publicSessionId ?? "",
109
109
  REMOTEAGENT_WORKSPACE: cwd,
110
- });
110
+ }, (line) => this.handleProgressLine(line, onProgress));
111
+ }
112
+ async handleProgressLine(line, onProgress) {
113
+ if (!onProgress || !line.startsWith("{")) {
114
+ return;
115
+ }
116
+ let text;
117
+ try {
118
+ const event = JSON.parse(line);
119
+ text = event.type === "item.completed" && event.item?.type === "agent_message"
120
+ ? event.item.text?.trim()
121
+ : undefined;
122
+ }
123
+ catch {
124
+ // Ignore non-JSON stdout and let the normal final-response parser handle it.
125
+ return;
126
+ }
127
+ if (text && /^REPORT:progress(?:\r?\n|$)/i.test(text)) {
128
+ await onProgress(text);
129
+ }
111
130
  }
112
131
  extractThreadId(stdout) {
113
132
  for (const line of stdout.split(/\r?\n/)) {
@@ -2,7 +2,7 @@ import process from "node:process";
2
2
  import { execFile, spawn } from "node:child_process";
3
3
  import { buildProviderEnv } from "./runtime-env.js";
4
4
  const activeCommands = new Map();
5
- export function spawnWithPlatformShell(bin, args, cwd, timeoutMs, input, executionKey, extraEnv) {
5
+ export function spawnWithPlatformShell(bin, args, cwd, timeoutMs, input, executionKey, extraEnv, onStdoutLine) {
6
6
  return new Promise((resolve, reject) => {
7
7
  const command = process.platform === "win32"
8
8
  ? spawn("cmd.exe", ["/d", "/c", "call", bin, ...args], {
@@ -19,13 +19,29 @@ export function spawnWithPlatformShell(bin, args, cwd, timeoutMs, input, executi
19
19
  }
20
20
  let stdout = "";
21
21
  let stderr = "";
22
+ let stdoutRemainder = "";
23
+ let stdoutCallbackTail = Promise.resolve();
22
24
  let timedOut = false;
23
25
  const timer = setTimeout(() => {
24
26
  timedOut = true;
25
27
  terminateProcessTree(command);
26
28
  }, timeoutMs);
27
29
  command.stdout.on("data", (chunk) => {
28
- stdout += chunk.toString();
30
+ const text = chunk.toString();
31
+ stdout += text;
32
+ if (!onStdoutLine) {
33
+ return;
34
+ }
35
+ stdoutRemainder += text;
36
+ const lines = stdoutRemainder.split(/\r?\n/);
37
+ stdoutRemainder = lines.pop() ?? "";
38
+ for (const line of lines) {
39
+ stdoutCallbackTail = stdoutCallbackTail
40
+ .then(() => onStdoutLine(line))
41
+ .catch((error) => {
42
+ console.warn(`[provider-stream] stdout callback failed: ${error instanceof Error ? error.message : String(error)}`);
43
+ });
44
+ }
29
45
  });
30
46
  command.stderr.on("data", (chunk) => {
31
47
  stderr += chunk.toString();
@@ -41,11 +57,19 @@ export function spawnWithPlatformShell(bin, args, cwd, timeoutMs, input, executi
41
57
  command.stdin.write(input);
42
58
  }
43
59
  command.stdin.end();
44
- command.on("close", (code) => {
60
+ command.on("close", async (code) => {
45
61
  clearTimeout(timer);
46
62
  if (executionKey && activeCommands.get(executionKey) === command) {
47
63
  activeCommands.delete(executionKey);
48
64
  }
65
+ if (onStdoutLine && stdoutRemainder) {
66
+ stdoutCallbackTail = stdoutCallbackTail
67
+ .then(() => onStdoutLine(stdoutRemainder))
68
+ .catch((error) => {
69
+ console.warn(`[provider-stream] stdout callback failed: ${error instanceof Error ? error.message : String(error)}`);
70
+ });
71
+ }
72
+ await stdoutCallbackTail;
49
73
  resolve({ stdout, stderr, code, timedOut });
50
74
  });
51
75
  });
package/dist/bot.js CHANGED
@@ -24,6 +24,7 @@ const HELP_TEXT = [
24
24
  "/attach codex <thread_id>",
25
25
  "/attach claude <session_id>",
26
26
  "/model [name]",
27
+ "/queue [remove <id>|del]",
27
28
  "/stop",
28
29
  "/sandbox codex <read-only|workspace-write|danger-full-access>",
29
30
  "/status",
@@ -80,6 +81,7 @@ const RECOGNIZED_COMMANDS = new Set([
80
81
  "batch",
81
82
  "attach",
82
83
  "model",
84
+ "queue",
83
85
  "stop",
84
86
  "sandbox",
85
87
  "status",
@@ -100,6 +102,8 @@ const TELEGRAM_STALE_UPDATE_GRACE_SECONDS = 10;
100
102
  const TELEGRAM_PROCESS_STARTED_AT_SECONDS = Math.floor(Date.now() / 1000);
101
103
  const workLoopTails = new Map();
102
104
  const workLoopGenerations = new Map();
105
+ const queuedWorkLoops = new Map();
106
+ let nextQueuedWorkSequence = 1;
103
107
  const REPORT_CONTINUE_PROMPT = [
104
108
  "Continue the same task now.",
105
109
  "Do more concrete work before replying again.",
@@ -225,6 +229,18 @@ export function createBot(token, bridge, botManagement, botInfo) {
225
229
  throw error;
226
230
  }
227
231
  };
232
+ const removeQueuedInstruction = async (botId, chatId, selector) => {
233
+ const mapping = await bridge.status(botId, chatId);
234
+ const activeKey = workLoopKey(botId, chatId, mapping?.session.sessionId);
235
+ const removed = removeQueuedWorkLoop(activeKey, selector);
236
+ if (!removed) {
237
+ const target = selector ? normalizeQueueId(selector) : "the latest queued instruction";
238
+ return `Queued instruction was not found: ${target}`;
239
+ }
240
+ await bridge.logSystem(botId, chatId, `Removed queued instruction ${removed.id} for ${activeKey}.`);
241
+ return `Removed queued instruction ${removed.id} from ${removed.publicSessionId ?? "this session"}.\n`
242
+ + `Remaining queued instructions: ${listQueuedWorkLoops(activeKey).length}`;
243
+ };
228
244
  const runPlanDocumentReinforcement = async (ctx, count) => {
229
245
  if (!ctx.chat) {
230
246
  throw new Error("Telegram chat context is missing.");
@@ -453,17 +469,39 @@ ${bridge.formatStatus(mapping)}`);
453
469
  const mapping = await bridge.setModel(botId, chatId, model);
454
470
  await reply(ctx, `Set ${mapping.session.mode} model to ${model}.\n\n${bridge.formatStatus(mapping)}`);
455
471
  });
472
+ bot.command("queue", async (ctx) => {
473
+ const botId = getBotId();
474
+ const chatId = String(ctx.chat.id);
475
+ const mapping = await bridge.status(botId, chatId);
476
+ const activeKey = workLoopKey(botId, chatId, mapping?.session.sessionId);
477
+ const { args, rest } = parseCommand(ctx.message?.text, 2);
478
+ const action = args[0]?.toLowerCase();
479
+ const selector = args[1];
480
+ if (rest?.trim() || (action && action !== "list" && action !== "remove" && action !== "rm" && action !== "del")) {
481
+ await reply(ctx, "Usage: `/queue`, `/queue remove <id>`, or `/queue del`", { parse_mode: "Markdown" });
482
+ return;
483
+ }
484
+ if (!action || action === "list") {
485
+ await reply(ctx, formatQueuedWorkLoops(activeKey, mapping?.session.publicId));
486
+ return;
487
+ }
488
+ if ((action === "remove" || action === "rm") && !selector) {
489
+ await reply(ctx, "Usage: `/queue remove <id>`", { parse_mode: "Markdown" });
490
+ return;
491
+ }
492
+ await reply(ctx, await removeQueuedInstruction(botId, chatId, selector));
493
+ });
456
494
  bot.command("stop", async (ctx) => {
457
495
  const botId = getBotId();
458
496
  const chatId = String(ctx.chat.id);
459
497
  const mapping = await bridge.status(botId, chatId);
460
498
  const sessionId = mapping?.session.sessionId;
461
499
  autoContinue.requestStop(botId, chatId, sessionId);
462
- cancelQueuedWorkLoops(botId, chatId, sessionId);
500
+ const queuedWorkCount = cancelQueuedWorkLoops(botId, chatId, sessionId);
463
501
  const pendingBatch = messageBatcher.cancelPending(botId, chatId);
464
502
  const manualBatch = messageBatcher.cancelManual(botId, chatId);
465
503
  if (!autoContinue.beginStop(botId, chatId, sessionId)) {
466
- const batchCount = pendingBatch.count + manualBatch.count;
504
+ const batchCount = queuedWorkCount + pendingBatch.count + manualBatch.count;
467
505
  if (batchCount > 0) {
468
506
  await bridge.logSystem(botId, chatId, `Duplicate stop discarded ${batchCount} queued message(s).`);
469
507
  }
@@ -472,7 +510,7 @@ ${bridge.formatStatus(mapping)}`);
472
510
  try {
473
511
  const result = await bridge.stopActiveRun(botId, chatId);
474
512
  await bridge.logSystem(botId, chatId, "Stop requested for auto-continue.");
475
- const batchCount = pendingBatch.count + manualBatch.count;
513
+ const batchCount = queuedWorkCount + pendingBatch.count + manualBatch.count;
476
514
  await reply(ctx, result.stopped
477
515
  ? `Stop requested. Active work for ${result.sessionPublicId ?? "this session"} was interrupted, further automatic continuation will stop, and ${batchCount} queued message(s) were discarded.`
478
516
  : `Stop requested. No active provider process was running, but further automatic continuation will stop, and ${batchCount} queued message(s) were discarded.`);
@@ -866,6 +904,28 @@ ${bridge.formatStatus(mapping)}`);
866
904
  await bridge.reset(botId, chatId);
867
905
  await reply(ctx, "Cleared all pairings for this chat.");
868
906
  });
907
+ bot.on("callback_query:data", async (ctx) => {
908
+ const match = /^remoteagent:queue:(remove:(Q\d+)|del)$/i.exec(ctx.callbackQuery.data);
909
+ if (!match) {
910
+ return;
911
+ }
912
+ const callbackChat = ctx.callbackQuery.message?.chat;
913
+ if (!callbackChat) {
914
+ await callTelegramApi(token, "answerCallbackQuery", {
915
+ callback_query_id: ctx.callbackQuery.id,
916
+ text: "This queue action is no longer available.",
917
+ });
918
+ return;
919
+ }
920
+ const botId = getBotId();
921
+ const chatId = String(callbackChat.id);
922
+ const result = await removeQueuedInstruction(botId, chatId, match[2]);
923
+ await callTelegramApi(token, "answerCallbackQuery", {
924
+ callback_query_id: ctx.callbackQuery.id,
925
+ text: result.split("\n", 1)[0],
926
+ });
927
+ await sendTelegramMessage(token, callbackChat.id, result);
928
+ });
869
929
  bot.on("message", async (ctx) => {
870
930
  const botId = getBotId();
871
931
  const chatId = String(ctx.chat.id);
@@ -1163,14 +1223,17 @@ async function runWithPendingAnimation(botToken, chatId, task) {
1163
1223
  pulseTyping();
1164
1224
  try {
1165
1225
  const helpers = {
1166
- reportProgress: async (chunks, parseMode) => {
1226
+ reportProgress: async (chunks, parseMode, messageOptions) => {
1167
1227
  const normalized = await normalizeTelegramDelivery(chunks);
1168
1228
  const progressChunks = flattenChunks(normalized.chunks, 3900);
1169
1229
  if (progressChunks.length === 0 && normalized.documents.length === 0) {
1170
1230
  return;
1171
1231
  }
1172
1232
  const rendered = formatProviderTelegramChunks(progressChunks, parseMode);
1173
- const extra = rendered.parseMode ? { parse_mode: rendered.parseMode } : undefined;
1233
+ const extra = {
1234
+ ...messageOptions,
1235
+ ...(rendered.parseMode ? { parse_mode: rendered.parseMode } : {}),
1236
+ };
1174
1237
  for (const chunk of rendered.chunks) {
1175
1238
  await sendTelegramMessage(botToken, chatId, chunk, extra).catch((error) => {
1176
1239
  console.warn(`[telegram-progress-delivery] chat=${chatId} dropped progress message: ${formatTelegramDeliveryError(error)}`);
@@ -1228,12 +1291,50 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1228
1291
  .catch(() => undefined)
1229
1292
  .then(() => gate);
1230
1293
  workLoopTails.set(activeKey, currentTail);
1294
+ let queuedEntry;
1231
1295
  if (previousTail) {
1232
- await bridge.logSystem(botId, chatId, `Queued overlapping Telegram work loop for ${activeKey}.`);
1233
- await helpers.reportProgress([`Queued instruction for ${currentSession?.session.publicId ?? "this session"}. It will run after the active work finishes.`]);
1234
- await previousTail.catch(() => undefined);
1235
- if ((workLoopGenerations.get(activeKey) ?? 0) !== generation || autoContinue.isStopRequested(botId, chatId, sessionId)) {
1236
- await bridge.logSystem(botId, chatId, `Discarded queued Telegram work loop for ${activeKey}.`);
1296
+ queuedEntry = registerQueuedWorkLoop({
1297
+ activeKey,
1298
+ botId,
1299
+ chatId,
1300
+ sessionId,
1301
+ publicSessionId: currentSession?.session.publicId,
1302
+ message,
1303
+ release,
1304
+ });
1305
+ try {
1306
+ await bridge.logSystem(botId, chatId, `Queued overlapping Telegram work loop ${queuedEntry.id} for ${activeKey}.`);
1307
+ await helpers.reportProgress([[
1308
+ `Queued instruction ${queuedEntry.id} for ${currentSession?.session.publicId ?? "this session"}. It will run after the active work finishes.`,
1309
+ "",
1310
+ `Remove it with \`/queue remove ${queuedEntry.id}\`, or remove the latest queued instruction with \`/queue del\`.`,
1311
+ ].join("\n")], undefined, {
1312
+ reply_markup: JSON.stringify({
1313
+ inline_keyboard: [[
1314
+ {
1315
+ text: `/queue remove ${queuedEntry.id}`,
1316
+ callback_data: `remoteagent:queue:remove:${queuedEntry.id}`,
1317
+ },
1318
+ {
1319
+ text: "/queue del",
1320
+ callback_data: "remoteagent:queue:del",
1321
+ },
1322
+ ]],
1323
+ }),
1324
+ });
1325
+ await previousTail.catch(() => undefined);
1326
+ }
1327
+ catch (error) {
1328
+ queuedWorkLoops.delete(queuedEntry.id);
1329
+ release();
1330
+ if (workLoopTails.get(activeKey) === currentTail) {
1331
+ workLoopTails.delete(activeKey);
1332
+ }
1333
+ throw error;
1334
+ }
1335
+ queuedWorkLoops.delete(queuedEntry.id);
1336
+ if (queuedEntry.canceled || (workLoopGenerations.get(activeKey) ?? 0) !== generation || autoContinue.isStopRequested(botId, chatId, sessionId)) {
1337
+ await bridge.logSystem(botId, chatId, `Discarded queued Telegram work loop ${queuedEntry.id} for ${activeKey}.`);
1237
1338
  release();
1238
1339
  if (workLoopTails.get(activeKey) === currentTail) {
1239
1340
  workLoopTails.delete(activeKey);
@@ -1270,6 +1371,7 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1270
1371
  let missingEvidenceRetryCount = 0;
1271
1372
  let deliveredProgressCount = 0;
1272
1373
  let providerCompleted = false;
1374
+ const streamedProgressKeys = new Set();
1273
1375
  const ensureStillBound = async (phase) => {
1274
1376
  if (!sessionId) {
1275
1377
  return;
@@ -1281,6 +1383,23 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1281
1383
  await bridge.stopSessionRun(sessionId, botId, chatId, `Telegram work loop stopped during ${phase} because the chat is now bound to another session.`);
1282
1384
  throw new SilentTelegramAbort(`Session ${currentSession?.session.publicId ?? sessionId} is no longer bound to this chat.`);
1283
1385
  };
1386
+ const deliverStreamedProgress = async (response) => {
1387
+ const parsed = parseReportResponses(bridge.formatResponses([response]), transform);
1388
+ if (parsed.kind !== "progress" || parsed.chunks.length === 0) {
1389
+ return;
1390
+ }
1391
+ const key = progressDeliveryKey(parsed.chunks);
1392
+ if (streamedProgressKeys.has(key)) {
1393
+ return;
1394
+ }
1395
+ streamedProgressKeys.add(key);
1396
+ deliveredProgressCount += 1;
1397
+ await ensureStillBound(`${label} streamed progress delivery`);
1398
+ if (currentSession) {
1399
+ await memoryService.recordProgress(currentSession.session, parsed.chunks.join("\n"));
1400
+ }
1401
+ await helpers.reportProgress(parsed.chunks);
1402
+ };
1284
1403
  if (currentSession) {
1285
1404
  await botManagement.markProviderRunning(botId, sessionId);
1286
1405
  }
@@ -1303,8 +1422,8 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1303
1422
  await bridge.logSystem(botId, chatId, `${turnLabel} started.`);
1304
1423
  try {
1305
1424
  const responses = sessionId
1306
- ? await bridge.routeSessionMessageForChat(sessionId, botId, chatId, prompt)
1307
- : await bridge.routeMessage(botId, chatId, prompt);
1425
+ ? await bridge.routeSessionMessageForChat(sessionId, botId, chatId, prompt, deliverStreamedProgress)
1426
+ : await bridge.routeMessage(botId, chatId, prompt, deliverStreamedProgress);
1308
1427
  await ensureStillBound(`${turnLabel} response`);
1309
1428
  const parsed = parseReportResponses(bridge.formatResponses(responses), transform);
1310
1429
  await bridge.logSystem(botId, chatId, `${turnLabel} returned ${parsed.kind}.`);
@@ -1313,21 +1432,25 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1313
1432
  if (parsed.kind === "progress") {
1314
1433
  untaggedIntentRetryCount = 0;
1315
1434
  missingEvidenceRetryCount = 0;
1316
- deliveredProgressCount += 1;
1317
- if (currentSession) {
1318
- const progress = await memoryService.recordProgress(currentSession.session, parsed.chunks.join("\n"));
1319
- if (progress.repeated) {
1320
- const repeatedMessage = [
1321
- "Repeated progress detected. The same work pattern has appeared 3 or more times.",
1322
- "Automatic continuation stopped so the task can be inspected instead of looping.",
1323
- ].join("\n");
1324
- await bridge.logSystem(botId, chatId, repeatedMessage);
1325
- autoContinue.clear(botId, chatId, sessionId);
1326
- return [repeatedMessage];
1435
+ const key = progressDeliveryKey(parsed.chunks);
1436
+ if (!streamedProgressKeys.has(key)) {
1437
+ streamedProgressKeys.add(key);
1438
+ deliveredProgressCount += 1;
1439
+ if (currentSession) {
1440
+ const progress = await memoryService.recordProgress(currentSession.session, parsed.chunks.join("\n"));
1441
+ if (progress.repeated) {
1442
+ const repeatedMessage = [
1443
+ "Repeated progress detected. The same work pattern has appeared 3 or more times.",
1444
+ "Automatic continuation stopped so the task can be inspected instead of looping.",
1445
+ ].join("\n");
1446
+ await bridge.logSystem(botId, chatId, repeatedMessage);
1447
+ autoContinue.clear(botId, chatId, sessionId);
1448
+ return [repeatedMessage];
1449
+ }
1327
1450
  }
1451
+ await ensureStillBound(`${turnLabel} progress delivery`);
1452
+ await helpers.reportProgress(parsed.chunks);
1328
1453
  }
1329
- await ensureStillBound(`${turnLabel} progress delivery`);
1330
- await helpers.reportProgress(parsed.chunks);
1331
1454
  if (autoContinue.isStopRequested(botId, chatId, sessionId)) {
1332
1455
  const stopMessage = "Automatic continuation stopped after the latest progress report.";
1333
1456
  await bridge.logSystem(botId, chatId, stopMessage);
@@ -1460,9 +1583,82 @@ function cancelQueuedWorkLoops(botId, chatId, sessionId) {
1460
1583
  if (sessionId) {
1461
1584
  keys.push(workLoopKey(botId, chatId));
1462
1585
  }
1586
+ const keySet = new Set(keys);
1587
+ let removed = 0;
1588
+ for (const entry of queuedWorkLoops.values()) {
1589
+ if (!keySet.has(entry.activeKey)) {
1590
+ continue;
1591
+ }
1592
+ entry.canceled = true;
1593
+ entry.release();
1594
+ queuedWorkLoops.delete(entry.id);
1595
+ removed += 1;
1596
+ }
1463
1597
  for (const key of keys) {
1464
1598
  workLoopGenerations.set(key, (workLoopGenerations.get(key) ?? 0) + 1);
1465
1599
  }
1600
+ return removed;
1601
+ }
1602
+ function registerQueuedWorkLoop(input) {
1603
+ const sequence = nextQueuedWorkSequence;
1604
+ nextQueuedWorkSequence += 1;
1605
+ const entry = {
1606
+ id: `Q${String(sequence).padStart(3, "0")}`,
1607
+ sequence,
1608
+ activeKey: input.activeKey,
1609
+ botId: input.botId,
1610
+ chatId: input.chatId,
1611
+ sessionId: input.sessionId,
1612
+ publicSessionId: input.publicSessionId,
1613
+ messagePreview: summarizeQueuedInstruction(input.message),
1614
+ createdAt: new Date().toISOString(),
1615
+ canceled: false,
1616
+ release: input.release,
1617
+ };
1618
+ queuedWorkLoops.set(entry.id, entry);
1619
+ return entry;
1620
+ }
1621
+ function listQueuedWorkLoops(activeKey) {
1622
+ return [...queuedWorkLoops.values()]
1623
+ .filter((entry) => entry.activeKey === activeKey && !entry.canceled)
1624
+ .sort((left, right) => left.sequence - right.sequence);
1625
+ }
1626
+ function removeQueuedWorkLoop(activeKey, selector) {
1627
+ const entries = listQueuedWorkLoops(activeKey);
1628
+ const target = selector
1629
+ ? queuedWorkLoops.get(normalizeQueueId(selector))
1630
+ : entries.at(-1);
1631
+ if (!target || target.activeKey !== activeKey || target.canceled) {
1632
+ return undefined;
1633
+ }
1634
+ target.canceled = true;
1635
+ target.release();
1636
+ queuedWorkLoops.delete(target.id);
1637
+ return target;
1638
+ }
1639
+ function normalizeQueueId(value) {
1640
+ const normalized = value.trim().toUpperCase();
1641
+ if (/^\d+$/.test(normalized)) {
1642
+ return `Q${normalized.padStart(3, "0")}`;
1643
+ }
1644
+ return normalized;
1645
+ }
1646
+ function summarizeQueuedInstruction(message) {
1647
+ const normalized = message.replace(/\s+/g, " ").trim();
1648
+ return normalized.length > 100 ? `${normalized.slice(0, 97)}...` : normalized;
1649
+ }
1650
+ function formatQueuedWorkLoops(activeKey, publicSessionId) {
1651
+ const entries = listQueuedWorkLoops(activeKey);
1652
+ if (entries.length === 0) {
1653
+ return `No queued instructions for ${publicSessionId ?? "this chat"}.`;
1654
+ }
1655
+ return [
1656
+ `Queued instructions for ${publicSessionId ?? "this chat"} (${entries.length})`,
1657
+ ...entries.map((entry) => `${entry.id}: ${entry.messagePreview || "(empty instruction)"}`),
1658
+ "",
1659
+ `Remove one: /queue remove ${entries[0].id}`,
1660
+ "Remove latest: /queue del",
1661
+ ].join("\n");
1466
1662
  }
1467
1663
  class SilentTelegramAbort extends Error {
1468
1664
  constructor(message) {
@@ -1509,6 +1705,9 @@ function parseReportResponses(formattedBlocks, transform) {
1509
1705
  const chunks = transform(parsedBlocks.map((item) => item.text));
1510
1706
  return { kind, chunks };
1511
1707
  }
1708
+ function progressDeliveryKey(chunks) {
1709
+ return chunks.join("\n").replace(/\s+/g, " ").trim();
1710
+ }
1512
1711
  function formatProviderTelegramChunks(chunks, explicitParseMode) {
1513
1712
  if (explicitParseMode) {
1514
1713
  return { chunks, parseMode: explicitParseMode };
@@ -2614,6 +2813,7 @@ async function sendTelegramMessage(botToken, chatId, text, extra) {
2614
2813
  chat_id: String(chatId),
2615
2814
  text,
2616
2815
  parse_mode: extra?.parse_mode,
2816
+ reply_markup: extra?.reply_markup,
2617
2817
  });
2618
2818
  }
2619
2819
  catch (error) {
@@ -2624,6 +2824,7 @@ async function sendTelegramMessage(botToken, chatId, text, extra) {
2624
2824
  return await callTelegramApi(botToken, "sendMessage", {
2625
2825
  chat_id: String(chatId),
2626
2826
  text: stripTelegramHtml(text),
2827
+ reply_markup: extra?.reply_markup,
2627
2828
  });
2628
2829
  }
2629
2830
  }
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { ProviderRecoveryService } from "./services/provider-recovery-service.js
20
20
  import { computePolicyPollIntervalMs, computeRecentMessageRanks } from "./services/polling-policy.js";
21
21
  import { terminateAllSpawnedExecutions } from "./adapters/windows-shell.js";
22
22
  import { setTelegramCommandMenu } from "./telegram-command-menu.js";
23
+ import { buildBotInfoFromIdentity, buildFallbackBotInfo } from "./telegram-bot-identity.js";
23
24
  const execFileAsync = promisify(execFile);
24
25
  const TELEGRAM_GET_UPDATES_HTTP_TIMEOUT_SECONDS = 30;
25
26
  const TELEGRAM_GET_UPDATES_CURL_TIMEOUT_SECONDS = 60;
@@ -68,7 +69,7 @@ async function main() {
68
69
  console.error("Local UI failed to start:", error);
69
70
  });
70
71
  }
71
- const botInfos = await Promise.all(config.telegramBotTokens.map((token, index) => resolveBotInfo(token, index)));
72
+ const botInfos = await Promise.all(config.telegramBotTokens.map((token, index) => resolveBotInfo(token, index, config.telegramBotUsernames[index])));
72
73
  const bots = config.telegramBotTokens.map((token, index) => createBot(token, bridge, botManagement, botInfos[index]));
73
74
  if (config.telegramCommandMenuEnabled) {
74
75
  for (const bot of bots) {
@@ -484,7 +485,7 @@ class AsyncSemaphore {
484
485
  }
485
486
  }
486
487
  }
487
- async function resolveBotInfo(token, index) {
488
+ async function resolveBotInfo(token, index, configuredUsername) {
488
489
  try {
489
490
  const { stdout } = await execFileAsync("curl", [
490
491
  "-sS",
@@ -502,32 +503,7 @@ async function resolveBotInfo(token, index) {
502
503
  catch (error) {
503
504
  console.warn(`Telegram getMe failed for bot ${tokenIdLabel(token)}: ${summarizeTelegramIdentityError(error)}`);
504
505
  }
505
- return buildFallbackBotInfo(token, index);
506
- }
507
- function buildBotInfoFromIdentity(id, username, firstName) {
508
- return {
509
- id,
510
- is_bot: true,
511
- first_name: firstName || username,
512
- username,
513
- can_join_groups: false,
514
- can_read_all_group_messages: false,
515
- supports_inline_queries: false,
516
- };
517
- }
518
- function buildFallbackBotInfo(token, index) {
519
- const id = Number.parseInt(token.split(":", 1)[0] ?? "", 10);
520
- const fallbackUsername = knownBotUsername(id);
521
- const username = fallbackUsername || `bot_${Number.isFinite(id) ? id : index + 1}`;
522
- return {
523
- id: Number.isFinite(id) ? id : index + 1,
524
- is_bot: true,
525
- first_name: username,
526
- username,
527
- can_join_groups: false,
528
- can_read_all_group_messages: false,
529
- supports_inline_queries: false,
530
- };
506
+ return buildFallbackBotInfo(token, index, configuredUsername);
531
507
  }
532
508
  function tokenIdLabel(token) {
533
509
  return token.split(":", 1)[0] || "unknown";
@@ -543,15 +519,6 @@ function summarizeTelegramIdentityError(error) {
543
519
  .filter(Boolean)
544
520
  .join(" ");
545
521
  }
546
- function knownBotUsername(id) {
547
- if (id === 8369496408) {
548
- return "codex_remoteagent_bot";
549
- }
550
- if (id === 8429712341) {
551
- return "sqream_bot";
552
- }
553
- return undefined;
554
- }
555
522
  function commandExists(command) {
556
523
  const trimmed = command.trim();
557
524
  if (!trimmed) {
@@ -284,7 +284,7 @@ export class BridgeService {
284
284
  }
285
285
  return { stopped, sessionPublicId: session.publicId };
286
286
  }
287
- async routeMessage(botId, chatId, message) {
287
+ async routeMessage(botId, chatId, message, onProgress) {
288
288
  const chatSession = await this.requireChat(botId, chatId);
289
289
  await this.log({
290
290
  timestamp: new Date().toISOString(),
@@ -295,7 +295,7 @@ export class BridgeService {
295
295
  direction: "in",
296
296
  text: message,
297
297
  });
298
- return this.withSessionLock(chatSession.session.sessionId, () => this.routeSession(chatSession.session, message, "telegram", botId, chatId));
298
+ return this.withSessionLock(chatSession.session.sessionId, () => this.routeSession(chatSession.session, message, "telegram", botId, chatId, onProgress));
299
299
  }
300
300
  async routeSessionMessage(sessionId, message) {
301
301
  const session = await this.store.getSession(sessionId);
@@ -311,7 +311,7 @@ export class BridgeService {
311
311
  });
312
312
  return this.withSessionLock(session.sessionId, () => this.routeSession(session, message, "pc-ui"));
313
313
  }
314
- async routeSessionMessageForChat(sessionId, botId, chatId, message) {
314
+ async routeSessionMessageForChat(sessionId, botId, chatId, message, onProgress) {
315
315
  const session = await this.store.getSession(sessionId);
316
316
  if (!session) {
317
317
  throw new Error(`Session was not found: ${sessionId}`);
@@ -325,7 +325,7 @@ export class BridgeService {
325
325
  direction: "in",
326
326
  text: message,
327
327
  });
328
- return this.withSessionLock(session.sessionId, () => this.routeSession(session, message, "telegram", botId, chatId));
328
+ return this.withSessionLock(session.sessionId, () => this.routeSession(session, message, "telegram", botId, chatId, onProgress));
329
329
  }
330
330
  formatStatus(chatSession) {
331
331
  if (!chatSession) {
@@ -475,7 +475,7 @@ export class BridgeService {
475
475
  }
476
476
  }
477
477
  }
478
- async routeSession(session, message, requestSource, botId, chatId) {
478
+ async routeSession(session, message, requestSource, botId, chatId, onProgress) {
479
479
  const responses = [];
480
480
  const providers = this.resolveProviders(session.mode);
481
481
  for (const provider of providers) {
@@ -491,6 +491,29 @@ export class BridgeService {
491
491
  message,
492
492
  model: providerSession.model,
493
493
  sandboxMode: providerSession.sandboxMode,
494
+ onProgress: onProgress
495
+ ? async (output) => {
496
+ const progressResponse = {
497
+ provider,
498
+ sessionId: providerSession.sessionId ?? session.sessionId,
499
+ publicSessionId: session.publicId,
500
+ model: providerSession.model ?? this.defaultModelFor(provider),
501
+ cwd: providerSession.cwd,
502
+ output,
503
+ };
504
+ await this.log({
505
+ timestamp: new Date().toISOString(),
506
+ remoteSessionId: session.sessionId,
507
+ botId,
508
+ chatId,
509
+ provider,
510
+ direction: "out",
511
+ sessionId: providerSession.sessionId,
512
+ text: output,
513
+ });
514
+ await onProgress(progressResponse);
515
+ }
516
+ : undefined,
494
517
  });
495
518
  const updatedProviderSession = {
496
519
  ...providerSession,
@@ -0,0 +1,36 @@
1
+ export function buildBotInfoFromIdentity(id, username, firstName) {
2
+ return {
3
+ id,
4
+ is_bot: true,
5
+ first_name: firstName || username,
6
+ username,
7
+ can_join_groups: false,
8
+ can_read_all_group_messages: false,
9
+ supports_inline_queries: false,
10
+ };
11
+ }
12
+ export function buildFallbackBotInfo(token, index, configuredUsername) {
13
+ const id = Number.parseInt(token.split(":", 1)[0] ?? "", 10);
14
+ const persistedUsername = configuredUsername?.trim().replace(/^@/, "");
15
+ const username = persistedUsername
16
+ || knownBotUsername(id)
17
+ || `bot_${Number.isFinite(id) ? id : index + 1}`;
18
+ return {
19
+ id: Number.isFinite(id) ? id : index + 1,
20
+ is_bot: true,
21
+ first_name: username,
22
+ username,
23
+ can_join_groups: false,
24
+ can_read_all_group_messages: false,
25
+ supports_inline_queries: false,
26
+ };
27
+ }
28
+ function knownBotUsername(id) {
29
+ if (id === 8369496408) {
30
+ return "codex_remoteagent_bot";
31
+ }
32
+ if (id === 8429712341) {
33
+ return "sqream_bot";
34
+ }
35
+ return undefined;
36
+ }
@@ -15,6 +15,7 @@ export const TELEGRAM_COMMAND_MENU = [
15
15
  { command: "docs", description: "Pin or find session documents" },
16
16
  { command: "macro", description: "Save or run reusable instructions" },
17
17
  { command: "model", description: "Show or change provider model" },
18
+ { command: "queue", description: "List or remove queued instructions" },
18
19
  { command: "stop", description: "Stop active work and clear queued messages" },
19
20
  { command: "sandbox", description: "Set Codex sandbox mode" },
20
21
  { command: "batch", description: "Collect and send a multi-message batch" },
@@ -44,6 +44,10 @@ Current production bot ownership is intentionally split:
44
44
  Assign each Telegram bot token to one runtime at a time.
45
45
  Bot polling conflicts are treated as incidents, not harmless warnings.
46
46
 
47
+ `TELEGRAM_BOT_TOKENS` and `TELEGRAM_BOT_USERNAMES` are parallel persisted lists.
48
+ At startup, Telegram `getMe` is the preferred identity source, but a temporary DNS or Telegram failure must fall back to the persisted username at the same list position.
49
+ Using a generated `bot_<numeric-id>` identity when a persisted username exists breaks the existing `<username>:<chat-id>` session binding.
50
+
47
51
  When a runtime has several configured Telegram bots, polling pressure can become operationally visible.
48
52
  RemoteAgent reduces that pressure with rank-based polling intervals instead of deep sleep or a special main bot.
49
53
  See [BOT_POLLING_POLICY.md](./BOT_POLLING_POLICY.md).
package/docs/RELEASING.md CHANGED
@@ -174,3 +174,49 @@ Runtime targets:
174
174
  server 30: 0.15.5 active
175
175
  server 26: 0.15.5 running
176
176
  ```
177
+
178
+ ## Release 0.17.0
179
+
180
+ Date: 2026-07-29
181
+
182
+ Changes:
183
+
184
+ - Queued instructions receive runtime-unique `Q001`-style ids.
185
+ - `/queue` lists instructions waiting behind the current session work.
186
+ - `/queue remove <id>` removes one selected waiting instruction.
187
+ - `/queue del` removes the most recently queued instruction.
188
+ - `/stop` reports and clears queued work-loop instructions as well as pending message batches.
189
+ - Telegram startup preserves the configured bot username when `getMe` temporarily fails, preventing existing chat/session bindings from being bypassed by a generated numeric bot identity.
190
+
191
+ Validated:
192
+
193
+ ```bash
194
+ npm run check
195
+ npm run build
196
+ npm run selftest:telegram
197
+ npm run release:publish
198
+ npm run release:deploy -- 0.17.0 all
199
+ ```
200
+
201
+ ## Release 0.17.1
202
+
203
+ Date: 2026-08-03
204
+
205
+ Changes:
206
+
207
+ - Codex JSON stdout is parsed while the provider process is running.
208
+ - `REPORT:progress` messages are delivered to Telegram immediately without starting another provider turn.
209
+ - A streamed final progress message is deduplicated from the normal post-process response path.
210
+ - Queue instructions are announced in one Telegram message instead of two.
211
+ - Queue notices include inline `/queue remove Qxxx` and `/queue del` buttons that execute the existing queue removal behavior.
212
+
213
+ Validated:
214
+
215
+ ```bash
216
+ npm run check
217
+ npm run build
218
+ npm run selftest:codex-stream
219
+ npm run selftest:telegram
220
+ npm run release:publish
221
+ npm run release:deploy -- 0.17.1 30
222
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appback-remoteagent",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "description": "Personal installable session server for continuing local AI work across PC and Telegram",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "start": "node dist/index.js",
20
20
  "check": "tsc --noEmit -p tsconfig.json",
21
21
  "selftest:telegram": "npm run build && node scripts/selftest-telegram-update.mjs",
22
+ "selftest:codex-stream": "npm run build && node scripts/selftest-codex-stream.mjs",
22
23
  "prepare": "npm run build",
23
24
  "prepublishOnly": "node scripts/prepublish-guard.mjs",
24
25
  "release:version": "bash scripts/release-version.sh",
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ const root = path.resolve(new URL("..", import.meta.url).pathname);
7
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "remoteagent-codex-stream-"));
8
+ const fakeCodex = path.join(tmp, "codex");
9
+
10
+ await fs.writeFile(fakeCodex, `#!/usr/bin/env bash
11
+ set -euo pipefail
12
+ output=""
13
+ while [ "$#" -gt 0 ]; do
14
+ if [ "$1" = "-o" ]; then
15
+ output="$2"
16
+ shift 2
17
+ continue
18
+ fi
19
+ shift
20
+ done
21
+ cat >/dev/null
22
+ printf '%s\\n' '{"type":"thread.started","thread_id":"stream-thread"}'
23
+ printf '%s' '{"type":"item.completed","item":{"type":"agent_message","text":"REPORT:progress\\nphase one"}}'
24
+ printf '\\n'
25
+ sleep 0.05
26
+ printf '%s\\n' '{"type":"item.completed","item":{"type":"agent_message","text":"REPORT:progress\\nphase two"}}'
27
+ sleep 0.05
28
+ printf '%s\\n' '{"type":"item.completed","item":{"type":"agent_message","text":"REPORT:result\\nfinished"}}'
29
+ printf '%s\\n' 'REPORT:result' 'finished' > "$output"
30
+ `, "utf8");
31
+ await fs.chmod(fakeCodex, 0o755);
32
+
33
+ const { CodexAdapter } = await import(path.join(root, "dist", "adapters", "codex-adapter.js"));
34
+ const adapter = new CodexAdapter(fakeCodex, 5000, "read-only");
35
+ const progress = [];
36
+ let settled = false;
37
+ const responsePromise = adapter.send({
38
+ chatId: "selftest",
39
+ remoteSessionId: "remote-session",
40
+ publicSessionId: "S001",
41
+ message: "test",
42
+ cwd: tmp,
43
+ onProgress: async (output) => {
44
+ if (settled) {
45
+ throw new Error("Progress arrived after the provider response settled");
46
+ }
47
+ progress.push(output);
48
+ },
49
+ });
50
+ const response = await responsePromise;
51
+ settled = true;
52
+
53
+ if (progress.length !== 2 || !progress[0]?.includes("phase one") || !progress[1]?.includes("phase two")) {
54
+ throw new Error(`Unexpected streamed progress: ${JSON.stringify(progress)}`);
55
+ }
56
+ if (progress.some((item) => item.includes("REPORT:result"))) {
57
+ throw new Error(`Final result leaked through progress callback: ${JSON.stringify(progress)}`);
58
+ }
59
+ if (response.sessionId !== "stream-thread" || response.output !== "REPORT:result\nfinished") {
60
+ throw new Error(`Unexpected final response: ${JSON.stringify(response)}`);
61
+ }
62
+
63
+ console.log(JSON.stringify({ ok: true, streamedProgress: progress.length, final: response.output }, null, 2));
@@ -21,23 +21,27 @@ set -euo pipefail
21
21
  method="unknown"
22
22
  text=""
23
23
  chat_id=""
24
+ reply_markup=""
24
25
  for arg in "$@"; do
25
26
  case "$arg" in
26
27
  https://api.telegram.org/bot*/sendMessage) method="sendMessage" ;;
27
28
  https://api.telegram.org/bot*/editMessageText) method="editMessageText" ;;
28
29
  https://api.telegram.org/bot*/deleteMessage) method="deleteMessage" ;;
29
30
  https://api.telegram.org/bot*/sendDocument) method="sendDocument" ;;
31
+ https://api.telegram.org/bot*/answerCallbackQuery) method="answerCallbackQuery" ;;
30
32
  chat_id=*) chat_id="\${arg#chat_id=}" ;;
31
33
  text=*) text="\${arg#text=}" ;;
34
+ reply_markup=*) reply_markup="\${arg#reply_markup=}" ;;
32
35
  esac
33
36
  done
34
37
  text_b64="$(printf '%s' "$text" | base64 -w 0)"
35
- printf '%s\\t%s\\t%s\\n' "$method" "$chat_id" "$text_b64" >> ${JSON.stringify(telegramCalls)}
38
+ reply_markup_b64="$(printf '%s' "$reply_markup" | base64 -w 0)"
39
+ printf '%s\\t%s\\t%s\\t%s\\n' "$method" "$chat_id" "$text_b64" "$reply_markup_b64" >> ${JSON.stringify(telegramCalls)}
36
40
  case "$method" in
37
41
  sendMessage|editMessageText)
38
42
  printf '{"ok":true,"result":{"message_id":1001}}'
39
43
  ;;
40
- deleteMessage)
44
+ deleteMessage|answerCallbackQuery)
41
45
  printf '{"ok":true,"result":true}'
42
46
  ;;
43
47
  sendDocument)
@@ -69,6 +73,7 @@ const [
69
73
  { FileStore },
70
74
  { AgentMemoryService },
71
75
  { WorkspaceCleanupService },
76
+ { buildFallbackBotInfo },
72
77
  ] = await Promise.all([
73
78
  import(path.join(root, "dist", "bot.js")),
74
79
  import(path.join(root, "dist", "services", "bridge-service.js")),
@@ -76,12 +81,27 @@ const [
76
81
  import(path.join(root, "dist", "store", "file-store.js")),
77
82
  import(path.join(root, "dist", "services", "agent-memory-service.js")),
78
83
  import(path.join(root, "dist", "services", "workspace-cleanup-service.js")),
84
+ import(path.join(root, "dist", "telegram-bot-identity.js")),
79
85
  ]);
80
86
 
87
+ const persistedBotIdentity = buildFallbackBotInfo(
88
+ "8966593034:test-token",
89
+ 0,
90
+ "@appbackadmin_bot",
91
+ );
92
+ if (persistedBotIdentity.username !== "appbackadmin_bot") {
93
+ throw new Error(`Configured bot username was not preserved during getMe fallback: ${persistedBotIdentity.username}`);
94
+ }
95
+
81
96
  const providerCalls = [];
82
97
  let providerMode = "success";
83
98
  let untaggedIntentCalls = 0;
84
99
  let missingEvidenceCalls = 0;
100
+ let streamingFinalProgressCalls = 0;
101
+ let queueHoldStartedResolve;
102
+ let queueHoldReleaseResolve;
103
+ let queueHoldStartedPromise = Promise.resolve();
104
+ let queueHoldReleasePromise = Promise.resolve();
85
105
  const provider = {
86
106
  async send(request) {
87
107
  providerCalls.push(request);
@@ -112,6 +132,49 @@ const provider = {
112
132
  : "REPORT:result\n수정 완료했습니다.\n\n근거:\n- 변경 파일: `src/example.ts`\n- 검증: `npm run check` 통과",
113
133
  };
114
134
  }
135
+ if (providerMode === "queue-hold") {
136
+ queueHoldStartedResolve?.();
137
+ await queueHoldReleasePromise;
138
+ return {
139
+ provider: "codex",
140
+ sessionId: request.sessionId || "mock-thread",
141
+ publicSessionId: request.publicSessionId,
142
+ cwd: request.cwd,
143
+ output: "REPORT:result\nactive queue test completed",
144
+ };
145
+ }
146
+ if (providerMode === "streaming-progress") {
147
+ await request.onProgress?.("REPORT:progress\nstreamed phase one completed");
148
+ await request.onProgress?.("REPORT:progress\nstreamed phase two completed");
149
+ return {
150
+ provider: "codex",
151
+ sessionId: request.sessionId || "mock-thread",
152
+ publicSessionId: request.publicSessionId,
153
+ cwd: request.cwd,
154
+ output: "REPORT:result\nstreamed provider completed with evidence: `stream-test.log`",
155
+ };
156
+ }
157
+ if (providerMode === "streaming-final-progress") {
158
+ streamingFinalProgressCalls += 1;
159
+ if (streamingFinalProgressCalls === 1) {
160
+ const output = "REPORT:progress\nstreamed final progress completed";
161
+ await request.onProgress?.(output);
162
+ return {
163
+ provider: "codex",
164
+ sessionId: request.sessionId || "mock-thread",
165
+ publicSessionId: request.publicSessionId,
166
+ cwd: request.cwd,
167
+ output,
168
+ };
169
+ }
170
+ return {
171
+ provider: "codex",
172
+ sessionId: request.sessionId || "mock-thread",
173
+ publicSessionId: request.publicSessionId,
174
+ cwd: request.cwd,
175
+ output: "REPORT:result\nstreamed continuation completed with evidence: `stream-final.log`",
176
+ };
177
+ }
115
178
  return {
116
179
  provider: "codex",
117
180
  sessionId: request.sessionId || "mock-thread",
@@ -165,10 +228,61 @@ function update(text) {
165
228
  };
166
229
  }
167
230
 
231
+ function callbackUpdate(data, sourceMessageId = messageId++) {
232
+ return {
233
+ update_id: updateId++,
234
+ callback_query: {
235
+ id: `callback-${updateId}`,
236
+ from: { id: 111, is_bot: false, first_name: "Tester", username: "tester" },
237
+ message: {
238
+ message_id: sourceMessageId,
239
+ date: now(),
240
+ chat: { id: 111222333, type: "private", first_name: "Tester", username: "tester" },
241
+ text: "queue controls",
242
+ },
243
+ chat_instance: "selftest",
244
+ data,
245
+ },
246
+ };
247
+ }
248
+
168
249
  async function send(text) {
169
250
  await injectedBot.handleUpdates([update(text)]);
170
251
  }
171
252
 
253
+ async function click(data) {
254
+ await injectedBot.handleUpdates([callbackUpdate(data)]);
255
+ }
256
+
257
+ async function readTelegramCalls() {
258
+ return (await fs.readFile(telegramCalls, "utf8"))
259
+ .trim()
260
+ .split("\n")
261
+ .filter(Boolean)
262
+ .map((line) => {
263
+ const [method, chatId, textB64 = "", replyMarkupB64 = ""] = line.split("\t");
264
+ return {
265
+ method,
266
+ chat_id: chatId,
267
+ text: Buffer.from(textB64, "base64").toString("utf8"),
268
+ reply_markup: Buffer.from(replyMarkupB64, "base64").toString("utf8"),
269
+ };
270
+ });
271
+ }
272
+
273
+ async function waitForTelegramCall(predicate, timeoutMs = 3000) {
274
+ const deadline = Date.now() + timeoutMs;
275
+ while (Date.now() < deadline) {
276
+ const calls = await readTelegramCalls();
277
+ const match = [...calls].reverse().find(predicate);
278
+ if (match) {
279
+ return match;
280
+ }
281
+ await new Promise((resolve) => setTimeout(resolve, 20));
282
+ }
283
+ throw new Error("Timed out waiting for Telegram self-test call.");
284
+ }
285
+
172
286
  await send("/start codex");
173
287
  await send("/option retry 6");
174
288
  await send("/option timeout 600");
@@ -407,6 +521,100 @@ if (evidenceCalls.some((call) => call.method === "sendMessage" && /^수정 완
407
521
  throw new Error(`Evidence-free completion leaked as final Telegram message. Calls: ${JSON.stringify(evidenceCalls, null, 2)}`);
408
522
  }
409
523
 
524
+ providerMode = "streaming-progress";
525
+ await send("/batch start");
526
+ await send("streaming progress regression test");
527
+ await send("/batch send");
528
+
529
+ const streamingCalls = await readTelegramCalls();
530
+ for (const phase of ["streamed phase one completed", "streamed phase two completed"]) {
531
+ const matches = streamingCalls.filter((call) => call.method === "sendMessage" && call.text.includes(phase));
532
+ if (matches.length !== 1) {
533
+ throw new Error(`Expected exactly one streamed Telegram progress message for ${phase}, got ${matches.length}`);
534
+ }
535
+ }
536
+ if (!streamingCalls.some((call) => call.method === "sendMessage" && call.text.includes("streamed provider completed"))) {
537
+ throw new Error("Streaming provider final result was not delivered");
538
+ }
539
+
540
+ providerMode = "streaming-final-progress";
541
+ await send("/batch start");
542
+ await send("streaming final progress deduplication test");
543
+ await send("/batch send");
544
+ const streamingFinalCalls = await readTelegramCalls();
545
+ const streamedFinalProgressMessages = streamingFinalCalls.filter((call) =>
546
+ call.method === "sendMessage" && call.text.includes("streamed final progress completed")
547
+ );
548
+ if (streamedFinalProgressMessages.length !== 1 || streamingFinalProgressCalls !== 2) {
549
+ throw new Error(
550
+ `Final streamed progress was not deduplicated: messages=${streamedFinalProgressMessages.length} providerCalls=${streamingFinalProgressCalls}`,
551
+ );
552
+ }
553
+
554
+ const queueProviderCallsBefore = providerCalls.length;
555
+ providerMode = "queue-hold";
556
+ queueHoldStartedPromise = new Promise((resolve) => {
557
+ queueHoldStartedResolve = resolve;
558
+ });
559
+ queueHoldReleasePromise = new Promise((resolve) => {
560
+ queueHoldReleaseResolve = resolve;
561
+ });
562
+
563
+ await send("/batch start");
564
+ await send("active queue regression test");
565
+ const activeQueueSend = send("/batch send");
566
+ await queueHoldStartedPromise;
567
+
568
+ await send("/batch start");
569
+ await send("first queued instruction");
570
+ const firstQueuedSend = send("/batch send");
571
+ const firstQueueNotice = await waitForTelegramCall((call) => /Queued instruction Q\d+/.test(call.text));
572
+ const firstQueueId = firstQueueNotice.text.match(/Queued instruction (Q\d+)/)?.[1];
573
+ if (!firstQueueId) {
574
+ throw new Error(`First queued instruction did not receive an id: ${firstQueueNotice.text}`);
575
+ }
576
+ const firstQueueNotices = (await readTelegramCalls()).filter((call) =>
577
+ call.method === "sendMessage" && call.text.includes(`Queued instruction ${firstQueueId} for`)
578
+ );
579
+ if (firstQueueNotices.length !== 1) {
580
+ throw new Error(`Queue notice should be one Telegram message, got ${firstQueueNotices.length}`);
581
+ }
582
+ if (!firstQueueNotice.reply_markup.includes(`remoteagent:queue:remove:${firstQueueId}`)
583
+ || !firstQueueNotice.reply_markup.includes("remoteagent:queue:del")) {
584
+ throw new Error(`Queue notice buttons are missing: ${firstQueueNotice.reply_markup}`);
585
+ }
586
+
587
+ await send("/batch start");
588
+ await send("second queued instruction");
589
+ const secondQueuedSend = send("/batch send");
590
+ const secondQueueNotice = await waitForTelegramCall((call) => {
591
+ const queueId = call.text.match(/Queued instruction (Q\d+)/)?.[1];
592
+ return Boolean(queueId && queueId !== firstQueueId);
593
+ });
594
+ const secondQueueId = secondQueueNotice.text.match(/Queued instruction (Q\d+)/)?.[1];
595
+ if (!secondQueueId) {
596
+ throw new Error(`Second queued instruction did not receive an id: ${secondQueueNotice.text}`);
597
+ }
598
+
599
+ await send("/queue");
600
+ const queueListCall = await waitForTelegramCall((call) =>
601
+ call.text.includes(firstQueueId) && call.text.includes(secondQueueId)
602
+ );
603
+ if (!/Queued instructions for S001 \(2\)/.test(queueListCall.text)) {
604
+ throw new Error(`Queue list did not report both entries: ${queueListCall.text}`);
605
+ }
606
+
607
+ await click(`remoteagent:queue:remove:${firstQueueId}`);
608
+ await waitForTelegramCall((call) => call.text.includes(`Removed queued instruction ${firstQueueId}`));
609
+ await click("remoteagent:queue:del");
610
+ await waitForTelegramCall((call) => call.text.includes(`Removed queued instruction ${secondQueueId}`));
611
+
612
+ queueHoldReleaseResolve?.();
613
+ await Promise.all([activeQueueSend, firstQueuedSend, secondQueuedSend]);
614
+ if (providerCalls.length !== queueProviderCallsBefore + 1) {
615
+ throw new Error(`Removed queued instructions reached the provider: ${providerCalls.length - queueProviderCallsBefore} calls`);
616
+ }
617
+
410
618
  console.log(JSON.stringify({
411
619
  ok: true,
412
620
  dataDir,
@@ -419,6 +627,10 @@ console.log(JSON.stringify({
419
627
  providerCalls: providerCalls.length,
420
628
  untaggedIntentCalls,
421
629
  missingEvidenceCalls,
630
+ streamingProgress: true,
631
+ streamingFinalProgressDeduplicated: true,
632
+ queueRemoveById: firstQueueId,
633
+ queueRemoveLatest: secondQueueId,
422
634
  timeoutFinalMessage: true,
423
635
  telegramSendMessages: evidenceCalls.filter((call) => call.method === "sendMessage").length,
424
636
  }, null, 2));