appback-remoteagent 0.17.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.
@@ -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
@@ -229,6 +229,18 @@ export function createBot(token, bridge, botManagement, botInfo) {
229
229
  throw error;
230
230
  }
231
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
+ };
232
244
  const runPlanDocumentReinforcement = async (ctx, count) => {
233
245
  if (!ctx.chat) {
234
246
  throw new Error("Telegram chat context is missing.");
@@ -477,15 +489,7 @@ ${bridge.formatStatus(mapping)}`);
477
489
  await reply(ctx, "Usage: `/queue remove <id>`", { parse_mode: "Markdown" });
478
490
  return;
479
491
  }
480
- const removed = removeQueuedWorkLoop(activeKey, selector);
481
- if (!removed) {
482
- const target = selector ? normalizeQueueId(selector) : "the latest queued instruction";
483
- await reply(ctx, `Queued instruction was not found: ${target}`);
484
- return;
485
- }
486
- await bridge.logSystem(botId, chatId, `Removed queued instruction ${removed.id} for ${activeKey}.`);
487
- await reply(ctx, `Removed queued instruction ${removed.id} from ${removed.publicSessionId ?? "this session"}.\n`
488
- + `Remaining queued instructions: ${listQueuedWorkLoops(activeKey).length}`);
492
+ await reply(ctx, await removeQueuedInstruction(botId, chatId, selector));
489
493
  });
490
494
  bot.command("stop", async (ctx) => {
491
495
  const botId = getBotId();
@@ -900,6 +904,28 @@ ${bridge.formatStatus(mapping)}`);
900
904
  await bridge.reset(botId, chatId);
901
905
  await reply(ctx, "Cleared all pairings for this chat.");
902
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
+ });
903
929
  bot.on("message", async (ctx) => {
904
930
  const botId = getBotId();
905
931
  const chatId = String(ctx.chat.id);
@@ -1197,14 +1223,17 @@ async function runWithPendingAnimation(botToken, chatId, task) {
1197
1223
  pulseTyping();
1198
1224
  try {
1199
1225
  const helpers = {
1200
- reportProgress: async (chunks, parseMode) => {
1226
+ reportProgress: async (chunks, parseMode, messageOptions) => {
1201
1227
  const normalized = await normalizeTelegramDelivery(chunks);
1202
1228
  const progressChunks = flattenChunks(normalized.chunks, 3900);
1203
1229
  if (progressChunks.length === 0 && normalized.documents.length === 0) {
1204
1230
  return;
1205
1231
  }
1206
1232
  const rendered = formatProviderTelegramChunks(progressChunks, parseMode);
1207
- const extra = rendered.parseMode ? { parse_mode: rendered.parseMode } : undefined;
1233
+ const extra = {
1234
+ ...messageOptions,
1235
+ ...(rendered.parseMode ? { parse_mode: rendered.parseMode } : {}),
1236
+ };
1208
1237
  for (const chunk of rendered.chunks) {
1209
1238
  await sendTelegramMessage(botToken, chatId, chunk, extra).catch((error) => {
1210
1239
  console.warn(`[telegram-progress-delivery] chat=${chatId} dropped progress message: ${formatTelegramDeliveryError(error)}`);
@@ -1275,10 +1304,24 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1275
1304
  });
1276
1305
  try {
1277
1306
  await bridge.logSystem(botId, chatId, `Queued overlapping Telegram work loop ${queuedEntry.id} for ${activeKey}.`);
1278
- await helpers.reportProgress([
1279
- `Queued instruction ${queuedEntry.id} for ${currentSession?.session.publicId ?? "this session"}. It will run after the active work finishes.`,
1280
- `Remove it with \`/queue remove ${queuedEntry.id}\`, or remove the latest queued instruction with \`/queue del\`.`,
1281
- ]);
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
+ });
1282
1325
  await previousTail.catch(() => undefined);
1283
1326
  }
1284
1327
  catch (error) {
@@ -1328,6 +1371,7 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1328
1371
  let missingEvidenceRetryCount = 0;
1329
1372
  let deliveredProgressCount = 0;
1330
1373
  let providerCompleted = false;
1374
+ const streamedProgressKeys = new Set();
1331
1375
  const ensureStillBound = async (phase) => {
1332
1376
  if (!sessionId) {
1333
1377
  return;
@@ -1339,6 +1383,23 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1339
1383
  await bridge.stopSessionRun(sessionId, botId, chatId, `Telegram work loop stopped during ${phase} because the chat is now bound to another session.`);
1340
1384
  throw new SilentTelegramAbort(`Session ${currentSession?.session.publicId ?? sessionId} is no longer bound to this chat.`);
1341
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
+ };
1342
1403
  if (currentSession) {
1343
1404
  await botManagement.markProviderRunning(botId, sessionId);
1344
1405
  }
@@ -1361,8 +1422,8 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1361
1422
  await bridge.logSystem(botId, chatId, `${turnLabel} started.`);
1362
1423
  try {
1363
1424
  const responses = sessionId
1364
- ? await bridge.routeSessionMessageForChat(sessionId, botId, chatId, prompt)
1365
- : await bridge.routeMessage(botId, chatId, prompt);
1425
+ ? await bridge.routeSessionMessageForChat(sessionId, botId, chatId, prompt, deliverStreamedProgress)
1426
+ : await bridge.routeMessage(botId, chatId, prompt, deliverStreamedProgress);
1366
1427
  await ensureStillBound(`${turnLabel} response`);
1367
1428
  const parsed = parseReportResponses(bridge.formatResponses(responses), transform);
1368
1429
  await bridge.logSystem(botId, chatId, `${turnLabel} returned ${parsed.kind}.`);
@@ -1371,21 +1432,25 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
1371
1432
  if (parsed.kind === "progress") {
1372
1433
  untaggedIntentRetryCount = 0;
1373
1434
  missingEvidenceRetryCount = 0;
1374
- deliveredProgressCount += 1;
1375
- if (currentSession) {
1376
- const progress = await memoryService.recordProgress(currentSession.session, parsed.chunks.join("\n"));
1377
- if (progress.repeated) {
1378
- const repeatedMessage = [
1379
- "Repeated progress detected. The same work pattern has appeared 3 or more times.",
1380
- "Automatic continuation stopped so the task can be inspected instead of looping.",
1381
- ].join("\n");
1382
- await bridge.logSystem(botId, chatId, repeatedMessage);
1383
- autoContinue.clear(botId, chatId, sessionId);
1384
- 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
+ }
1385
1450
  }
1451
+ await ensureStillBound(`${turnLabel} progress delivery`);
1452
+ await helpers.reportProgress(parsed.chunks);
1386
1453
  }
1387
- await ensureStillBound(`${turnLabel} progress delivery`);
1388
- await helpers.reportProgress(parsed.chunks);
1389
1454
  if (autoContinue.isStopRequested(botId, chatId, sessionId)) {
1390
1455
  const stopMessage = "Automatic continuation stopped after the latest progress report.";
1391
1456
  await bridge.logSystem(botId, chatId, stopMessage);
@@ -1640,6 +1705,9 @@ function parseReportResponses(formattedBlocks, transform) {
1640
1705
  const chunks = transform(parsedBlocks.map((item) => item.text));
1641
1706
  return { kind, chunks };
1642
1707
  }
1708
+ function progressDeliveryKey(chunks) {
1709
+ return chunks.join("\n").replace(/\s+/g, " ").trim();
1710
+ }
1643
1711
  function formatProviderTelegramChunks(chunks, explicitParseMode) {
1644
1712
  if (explicitParseMode) {
1645
1713
  return { chunks, parseMode: explicitParseMode };
@@ -2745,6 +2813,7 @@ async function sendTelegramMessage(botToken, chatId, text, extra) {
2745
2813
  chat_id: String(chatId),
2746
2814
  text,
2747
2815
  parse_mode: extra?.parse_mode,
2816
+ reply_markup: extra?.reply_markup,
2748
2817
  });
2749
2818
  }
2750
2819
  catch (error) {
@@ -2755,6 +2824,7 @@ async function sendTelegramMessage(botToken, chatId, text, extra) {
2755
2824
  return await callTelegramApi(botToken, "sendMessage", {
2756
2825
  chat_id: String(chatId),
2757
2826
  text: stripTelegramHtml(text),
2827
+ reply_markup: extra?.reply_markup,
2758
2828
  });
2759
2829
  }
2760
2830
  }
@@ -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,
package/docs/RELEASING.md CHANGED
@@ -197,3 +197,26 @@ npm run selftest:telegram
197
197
  npm run release:publish
198
198
  npm run release:deploy -- 0.17.0 all
199
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.17.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)
@@ -93,6 +97,7 @@ const providerCalls = [];
93
97
  let providerMode = "success";
94
98
  let untaggedIntentCalls = 0;
95
99
  let missingEvidenceCalls = 0;
100
+ let streamingFinalProgressCalls = 0;
96
101
  let queueHoldStartedResolve;
97
102
  let queueHoldReleaseResolve;
98
103
  let queueHoldStartedPromise = Promise.resolve();
@@ -138,6 +143,38 @@ const provider = {
138
143
  output: "REPORT:result\nactive queue test completed",
139
144
  };
140
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
+ }
141
178
  return {
142
179
  provider: "codex",
143
180
  sessionId: request.sessionId || "mock-thread",
@@ -191,21 +228,44 @@ function update(text) {
191
228
  };
192
229
  }
193
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
+
194
249
  async function send(text) {
195
250
  await injectedBot.handleUpdates([update(text)]);
196
251
  }
197
252
 
253
+ async function click(data) {
254
+ await injectedBot.handleUpdates([callbackUpdate(data)]);
255
+ }
256
+
198
257
  async function readTelegramCalls() {
199
258
  return (await fs.readFile(telegramCalls, "utf8"))
200
259
  .trim()
201
260
  .split("\n")
202
261
  .filter(Boolean)
203
262
  .map((line) => {
204
- const [method, chatId, textB64 = ""] = line.split("\t");
263
+ const [method, chatId, textB64 = "", replyMarkupB64 = ""] = line.split("\t");
205
264
  return {
206
265
  method,
207
266
  chat_id: chatId,
208
267
  text: Buffer.from(textB64, "base64").toString("utf8"),
268
+ reply_markup: Buffer.from(replyMarkupB64, "base64").toString("utf8"),
209
269
  };
210
270
  });
211
271
  }
@@ -461,6 +521,36 @@ if (evidenceCalls.some((call) => call.method === "sendMessage" && /^수정 완
461
521
  throw new Error(`Evidence-free completion leaked as final Telegram message. Calls: ${JSON.stringify(evidenceCalls, null, 2)}`);
462
522
  }
463
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
+
464
554
  const queueProviderCallsBefore = providerCalls.length;
465
555
  providerMode = "queue-hold";
466
556
  queueHoldStartedPromise = new Promise((resolve) => {
@@ -483,6 +573,16 @@ const firstQueueId = firstQueueNotice.text.match(/Queued instruction (Q\d+)/)?.[
483
573
  if (!firstQueueId) {
484
574
  throw new Error(`First queued instruction did not receive an id: ${firstQueueNotice.text}`);
485
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
+ }
486
586
 
487
587
  await send("/batch start");
488
588
  await send("second queued instruction");
@@ -504,9 +604,9 @@ if (!/Queued instructions for S001 \(2\)/.test(queueListCall.text)) {
504
604
  throw new Error(`Queue list did not report both entries: ${queueListCall.text}`);
505
605
  }
506
606
 
507
- await send(`/queue remove ${firstQueueId}`);
607
+ await click(`remoteagent:queue:remove:${firstQueueId}`);
508
608
  await waitForTelegramCall((call) => call.text.includes(`Removed queued instruction ${firstQueueId}`));
509
- await send("/queue del");
609
+ await click("remoteagent:queue:del");
510
610
  await waitForTelegramCall((call) => call.text.includes(`Removed queued instruction ${secondQueueId}`));
511
611
 
512
612
  queueHoldReleaseResolve?.();
@@ -527,6 +627,8 @@ console.log(JSON.stringify({
527
627
  providerCalls: providerCalls.length,
528
628
  untaggedIntentCalls,
529
629
  missingEvidenceCalls,
630
+ streamingProgress: true,
631
+ streamingFinalProgressDeduplicated: true,
530
632
  queueRemoveById: firstQueueId,
531
633
  queueRemoveLatest: secondQueueId,
532
634
  timeoutFinalMessage: true,