appback-remoteagent 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -267,8 +267,7 @@ export class AgentMemoryService {
267
267
  return existed;
268
268
  }
269
269
  async getMacro(aliasOrIndex) {
270
- const macros = Object.values(await this.readMacros())
271
- .sort((left, right) => left.alias.localeCompare(right.alias, "ko"));
270
+ const macros = await this.getMacros();
272
271
  const trimmed = aliasOrIndex.trim();
273
272
  if (/^[0-9]+$/.test(trimmed)) {
274
273
  const index = Number.parseInt(trimmed, 10);
@@ -277,9 +276,12 @@ export class AgentMemoryService {
277
276
  const normalizedAlias = this.normalizeMacroAlias(trimmed);
278
277
  return macros.find((macro) => macro.alias === normalizedAlias);
279
278
  }
280
- async listMacros() {
281
- const macros = Object.values(await this.readMacros())
279
+ async getMacros() {
280
+ return Object.values(await this.readMacros())
282
281
  .sort((left, right) => left.alias.localeCompare(right.alias, "ko"));
282
+ }
283
+ async listMacros() {
284
+ const macros = await this.getMacros();
283
285
  if (macros.length === 0) {
284
286
  return "No macros are stored.";
285
287
  }
@@ -31,6 +31,9 @@ export class BotManagementService {
31
31
  }
32
32
  return this.formatBots(bots, await this.pollingState.list());
33
33
  }
34
+ async listBotChoices() {
35
+ return (await this.listConfiguredBots()).map(({ id, username }) => ({ id, username }));
36
+ }
34
37
  async formatCurrentBotSummary(currentBotId) {
35
38
  const env = await this.readEnvConfig();
36
39
  const bots = this.zipBots(env.tokens, env.usernames);
@@ -181,15 +181,12 @@ export class BridgeService {
181
181
  }, chatSession.session.workspace);
182
182
  }
183
183
  async formatModelSelection(botId, chatId) {
184
- const chatSession = await this.requireChat(botId, chatId);
185
- const provider = chatSession.session.mode;
186
- this.ensurePaired(chatSession, provider);
187
- const providerSession = chatSession.session[provider];
188
- const presets = MODEL_PRESETS[provider] ?? [];
184
+ const selection = await this.getModelSelection(botId, chatId);
185
+ const { provider, currentModel, presets } = selection;
189
186
  const lines = [
190
- `session: ${chatSession.session.publicId}`,
187
+ `session: ${selection.sessionPublicId}`,
191
188
  `mode: ${provider}`,
192
- `currentModel: ${providerSession.model ?? this.defaultModelFor(provider)}`,
189
+ `currentModel: ${currentModel}`,
193
190
  "availablePresets:",
194
191
  ...presets.map((item, index) => ` ${index + 1}. ${item}`),
195
192
  "",
@@ -200,6 +197,18 @@ export class BridgeService {
200
197
  }
201
198
  return lines.join("\n");
202
199
  }
200
+ async getModelSelection(botId, chatId) {
201
+ const chatSession = await this.requireChat(botId, chatId);
202
+ const provider = chatSession.session.mode;
203
+ this.ensurePaired(chatSession, provider);
204
+ const providerSession = chatSession.session[provider];
205
+ return {
206
+ sessionPublicId: chatSession.session.publicId,
207
+ provider,
208
+ currentModel: providerSession.model ?? this.defaultModelFor(provider),
209
+ presets: [...(MODEL_PRESETS[provider] ?? [])],
210
+ };
211
+ }
203
212
  async status(botId, chatId) {
204
213
  return this.store.getChatSession(botId, chatId);
205
214
  }
@@ -284,7 +293,7 @@ export class BridgeService {
284
293
  }
285
294
  return { stopped, sessionPublicId: session.publicId };
286
295
  }
287
- async routeMessage(botId, chatId, message) {
296
+ async routeMessage(botId, chatId, message, onProgress) {
288
297
  const chatSession = await this.requireChat(botId, chatId);
289
298
  await this.log({
290
299
  timestamp: new Date().toISOString(),
@@ -295,7 +304,7 @@ export class BridgeService {
295
304
  direction: "in",
296
305
  text: message,
297
306
  });
298
- return this.withSessionLock(chatSession.session.sessionId, () => this.routeSession(chatSession.session, message, "telegram", botId, chatId));
307
+ return this.withSessionLock(chatSession.session.sessionId, () => this.routeSession(chatSession.session, message, "telegram", botId, chatId, onProgress));
299
308
  }
300
309
  async routeSessionMessage(sessionId, message) {
301
310
  const session = await this.store.getSession(sessionId);
@@ -311,7 +320,7 @@ export class BridgeService {
311
320
  });
312
321
  return this.withSessionLock(session.sessionId, () => this.routeSession(session, message, "pc-ui"));
313
322
  }
314
- async routeSessionMessageForChat(sessionId, botId, chatId, message) {
323
+ async routeSessionMessageForChat(sessionId, botId, chatId, message, onProgress) {
315
324
  const session = await this.store.getSession(sessionId);
316
325
  if (!session) {
317
326
  throw new Error(`Session was not found: ${sessionId}`);
@@ -325,7 +334,7 @@ export class BridgeService {
325
334
  direction: "in",
326
335
  text: message,
327
336
  });
328
- return this.withSessionLock(session.sessionId, () => this.routeSession(session, message, "telegram", botId, chatId));
337
+ return this.withSessionLock(session.sessionId, () => this.routeSession(session, message, "telegram", botId, chatId, onProgress));
329
338
  }
330
339
  formatStatus(chatSession) {
331
340
  if (!chatSession) {
@@ -475,7 +484,7 @@ export class BridgeService {
475
484
  }
476
485
  }
477
486
  }
478
- async routeSession(session, message, requestSource, botId, chatId) {
487
+ async routeSession(session, message, requestSource, botId, chatId, onProgress) {
479
488
  const responses = [];
480
489
  const providers = this.resolveProviders(session.mode);
481
490
  for (const provider of providers) {
@@ -491,6 +500,29 @@ export class BridgeService {
491
500
  message,
492
501
  model: providerSession.model,
493
502
  sandboxMode: providerSession.sandboxMode,
503
+ onProgress: onProgress
504
+ ? async (output) => {
505
+ const progressResponse = {
506
+ provider,
507
+ sessionId: providerSession.sessionId ?? session.sessionId,
508
+ publicSessionId: session.publicId,
509
+ model: providerSession.model ?? this.defaultModelFor(provider),
510
+ cwd: providerSession.cwd,
511
+ output,
512
+ };
513
+ await this.log({
514
+ timestamp: new Date().toISOString(),
515
+ remoteSessionId: session.sessionId,
516
+ botId,
517
+ chatId,
518
+ provider,
519
+ direction: "out",
520
+ sessionId: providerSession.sessionId,
521
+ text: output,
522
+ });
523
+ await onProgress(progressResponse);
524
+ }
525
+ : undefined,
494
526
  });
495
527
  const updatedProviderSession = {
496
528
  ...providerSession,
package/docs/RELEASING.md CHANGED
@@ -197,3 +197,48 @@ 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
+ ```
223
+
224
+ ## Release 0.18.0
225
+
226
+ Date: 2026-08-03
227
+
228
+ Changes:
229
+
230
+ - Telegram list and option responses provide inline command controls.
231
+ - Queue notices provide inline remove-latest and remove-by-id controls in one message.
232
+ - Codex progress streaming accepts both normalized CLI `item.completed` events and raw `event_msg`/`response_item` agent-message events.
233
+ - The Codex stream self-test covers every supported progress event shape and keeps final results out of the progress callback.
234
+
235
+ Validated:
236
+
237
+ ```bash
238
+ npm run check
239
+ npm run build
240
+ npm run selftest:codex-stream
241
+ npm run selftest:telegram
242
+ npm run release:publish
243
+ npm run release:deploy -- 0.18.0 30
244
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appback-remoteagent",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
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,70 @@
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":"event_msg","payload":{"type":"agent_message","message":"REPORT:progress\\nphase two"}}'
27
+ sleep 0.05
28
+ printf '%s\\n' '{"type":"response_item","payload":{"type":"message","content":[{"type":"output_text","text":"REPORT:progress\\nphase three"}]}}'
29
+ sleep 0.05
30
+ printf '%s\\n' '{"type":"item.completed","item":{"type":"agent_message","text":"REPORT:result\\nfinished"}}'
31
+ printf '%s\\n' 'REPORT:result' 'finished' > "$output"
32
+ `, "utf8");
33
+ await fs.chmod(fakeCodex, 0o755);
34
+
35
+ const { CodexAdapter } = await import(path.join(root, "dist", "adapters", "codex-adapter.js"));
36
+ const adapter = new CodexAdapter(fakeCodex, 5000, "read-only");
37
+ const progress = [];
38
+ let settled = false;
39
+ const responsePromise = adapter.send({
40
+ chatId: "selftest",
41
+ remoteSessionId: "remote-session",
42
+ publicSessionId: "S001",
43
+ message: "test",
44
+ cwd: tmp,
45
+ onProgress: async (output) => {
46
+ if (settled) {
47
+ throw new Error("Progress arrived after the provider response settled");
48
+ }
49
+ progress.push(output);
50
+ },
51
+ });
52
+ const response = await responsePromise;
53
+ settled = true;
54
+
55
+ if (
56
+ progress.length !== 3
57
+ || !progress[0]?.includes("phase one")
58
+ || !progress[1]?.includes("phase two")
59
+ || !progress[2]?.includes("phase three")
60
+ ) {
61
+ throw new Error(`Unexpected streamed progress: ${JSON.stringify(progress)}`);
62
+ }
63
+ if (progress.some((item) => item.includes("REPORT:result"))) {
64
+ throw new Error(`Final result leaked through progress callback: ${JSON.stringify(progress)}`);
65
+ }
66
+ if (response.sessionId !== "stream-thread" || response.output !== "REPORT:result\nfinished") {
67
+ throw new Error(`Unexpected final response: ${JSON.stringify(response)}`);
68
+ }
69
+
70
+ 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,52 @@ 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
+
257
+ function findInlineButton(call, label) {
258
+ if (!call?.reply_markup) {
259
+ return undefined;
260
+ }
261
+ const markup = JSON.parse(call.reply_markup);
262
+ return markup.inline_keyboard?.flat().find((button) => button.text === label);
263
+ }
264
+
198
265
  async function readTelegramCalls() {
199
266
  return (await fs.readFile(telegramCalls, "utf8"))
200
267
  .trim()
201
268
  .split("\n")
202
269
  .filter(Boolean)
203
270
  .map((line) => {
204
- const [method, chatId, textB64 = ""] = line.split("\t");
271
+ const [method, chatId, textB64 = "", replyMarkupB64 = ""] = line.split("\t");
205
272
  return {
206
273
  method,
207
274
  chat_id: chatId,
208
275
  text: Buffer.from(textB64, "base64").toString("utf8"),
276
+ reply_markup: Buffer.from(replyMarkupB64, "base64").toString("utf8"),
209
277
  };
210
278
  });
211
279
  }
@@ -380,6 +448,83 @@ if (calls.some((call) => /미완료 TODO|\/task|새 작업으로 접수/.test(ca
380
448
  throw new Error(`Task gate language leaked to Telegram replies. Calls: ${JSON.stringify(calls, null, 2)}`);
381
449
  }
382
450
 
451
+ await send("/new");
452
+ await send("/list");
453
+ const sessionListCall = await waitForTelegramCall((call) => call.text.includes("Sessions (2/2)"));
454
+ const firstSessionButton = findInlineButton(sessionListCall, `S001 · ${path.basename(session.workspace)}`);
455
+ if (!firstSessionButton?.callback_data?.startsWith("remoteagent:action:")) {
456
+ throw new Error(`Session switch button is missing: ${sessionListCall.reply_markup}`);
457
+ }
458
+ await click(firstSessionButton.callback_data);
459
+ await waitForTelegramCall((call) => call.text.includes("Switched this chat to session S001."));
460
+
461
+ await send("/model");
462
+ const modelListCall = await waitForTelegramCall((call) => call.text.includes("availablePresets:"));
463
+ const modelButton = findInlineButton(modelListCall, "gpt-5.6-terra");
464
+ if (!modelButton?.callback_data) {
465
+ throw new Error(`Model selection button is missing: ${modelListCall.reply_markup}`);
466
+ }
467
+ await click(modelButton.callback_data);
468
+ await waitForTelegramCall((call) => call.text.includes("Set codex model to gpt-5.6-terra."));
469
+ const modelState = JSON.parse(await fs.readFile(path.join(dataDir, "state.json"), "utf8"));
470
+ if (modelState.sessions[session.sessionId]?.codex?.model !== "gpt-5.6-terra") {
471
+ throw new Error("Model button did not update the bound session model");
472
+ }
473
+
474
+ await send("/option");
475
+ const optionListCall = await waitForTelegramCall((call) => call.text.startsWith("Runtime options"));
476
+ const timeoutButton = findInlineButton(optionListCall, "Timeout");
477
+ if (!timeoutButton?.callback_data) {
478
+ throw new Error(`Runtime option button is missing: ${optionListCall.reply_markup}`);
479
+ }
480
+ await click(timeoutButton.callback_data);
481
+ await waitForTelegramCall((call) => call.text.includes("Current provider execution timeout: 600s"));
482
+
483
+ await send("/sandbox");
484
+ const sandboxListCall = await waitForTelegramCall((call) => call.text.startsWith("Codex sandbox"));
485
+ const readOnlyButton = findInlineButton(sandboxListCall, "read-only");
486
+ const dangerButton = findInlineButton(sandboxListCall, "danger-full-access");
487
+ if (!readOnlyButton?.callback_data || !dangerButton?.callback_data) {
488
+ throw new Error(`Sandbox selection buttons are missing: ${sandboxListCall.reply_markup}`);
489
+ }
490
+ await click(readOnlyButton.callback_data);
491
+ await waitForTelegramCall((call) => call.text.includes("Set Codex sandbox to read-only."));
492
+ await click(dangerButton.callback_data);
493
+ const sandboxConfirmCall = await waitForTelegramCall((call) => call.text.includes("Confirm Codex sandbox change"));
494
+ if (!findInlineButton(sandboxConfirmCall, "Confirm danger-full-access")?.callback_data) {
495
+ throw new Error(`Danger sandbox confirmation button is missing: ${sandboxConfirmCall.reply_markup}`);
496
+ }
497
+
498
+ await send("/macro set button-test inspect the callback path");
499
+ await send("/batch start");
500
+ await send("/macro");
501
+ const macroListCall = await waitForTelegramCall((call) => call.text.includes("Macros (1)"));
502
+ const macroButton = findInlineButton(macroListCall, "button-test");
503
+ if (!macroButton?.callback_data) {
504
+ throw new Error(`Macro execution button is missing: ${macroListCall.reply_markup}`);
505
+ }
506
+ await click(macroButton.callback_data);
507
+ await send("/batch send");
508
+ await waitForTelegramCall((call) => call.text.includes("mock provider completed"));
509
+
510
+ await fs.appendFile(path.join(dataDir, ".env"), [
511
+ "TELEGRAM_BOT_TOKENS=000000:test-token",
512
+ "TELEGRAM_BOT_USERNAMES=remoteagent_test_bot",
513
+ "",
514
+ ].join("\n"), "utf8");
515
+ await send("/bots");
516
+ const botsCall = await waitForTelegramCall((call) => call.text.includes("Configured bots (1)"));
517
+ const botLink = findInlineButton(botsCall, "@bot_0");
518
+ const refreshButton = findInlineButton(botsCall, "Refresh");
519
+ if (botLink?.url !== "https://t.me/bot_0" || !refreshButton?.callback_data) {
520
+ throw new Error(`Bot link or refresh button is missing: ${botsCall.reply_markup}`);
521
+ }
522
+ await click(refreshButton.callback_data);
523
+ const refreshedBotsCalls = (await readTelegramCalls()).filter((call) => call.text.includes("Configured bots (1)"));
524
+ if (refreshedBotsCalls.length < 2) {
525
+ throw new Error("Bot refresh callback did not render the bot list again");
526
+ }
527
+
383
528
  providerMode = "timeout";
384
529
  await send("/batch start");
385
530
  await send("timeout regression test");
@@ -461,6 +606,36 @@ if (evidenceCalls.some((call) => call.method === "sendMessage" && /^수정 완
461
606
  throw new Error(`Evidence-free completion leaked as final Telegram message. Calls: ${JSON.stringify(evidenceCalls, null, 2)}`);
462
607
  }
463
608
 
609
+ providerMode = "streaming-progress";
610
+ await send("/batch start");
611
+ await send("streaming progress regression test");
612
+ await send("/batch send");
613
+
614
+ const streamingCalls = await readTelegramCalls();
615
+ for (const phase of ["streamed phase one completed", "streamed phase two completed"]) {
616
+ const matches = streamingCalls.filter((call) => call.method === "sendMessage" && call.text.includes(phase));
617
+ if (matches.length !== 1) {
618
+ throw new Error(`Expected exactly one streamed Telegram progress message for ${phase}, got ${matches.length}`);
619
+ }
620
+ }
621
+ if (!streamingCalls.some((call) => call.method === "sendMessage" && call.text.includes("streamed provider completed"))) {
622
+ throw new Error("Streaming provider final result was not delivered");
623
+ }
624
+
625
+ providerMode = "streaming-final-progress";
626
+ await send("/batch start");
627
+ await send("streaming final progress deduplication test");
628
+ await send("/batch send");
629
+ const streamingFinalCalls = await readTelegramCalls();
630
+ const streamedFinalProgressMessages = streamingFinalCalls.filter((call) =>
631
+ call.method === "sendMessage" && call.text.includes("streamed final progress completed")
632
+ );
633
+ if (streamedFinalProgressMessages.length !== 1 || streamingFinalProgressCalls !== 2) {
634
+ throw new Error(
635
+ `Final streamed progress was not deduplicated: messages=${streamedFinalProgressMessages.length} providerCalls=${streamingFinalProgressCalls}`,
636
+ );
637
+ }
638
+
464
639
  const queueProviderCallsBefore = providerCalls.length;
465
640
  providerMode = "queue-hold";
466
641
  queueHoldStartedPromise = new Promise((resolve) => {
@@ -483,6 +658,16 @@ const firstQueueId = firstQueueNotice.text.match(/Queued instruction (Q\d+)/)?.[
483
658
  if (!firstQueueId) {
484
659
  throw new Error(`First queued instruction did not receive an id: ${firstQueueNotice.text}`);
485
660
  }
661
+ const firstQueueNotices = (await readTelegramCalls()).filter((call) =>
662
+ call.method === "sendMessage" && call.text.includes(`Queued instruction ${firstQueueId} for`)
663
+ );
664
+ if (firstQueueNotices.length !== 1) {
665
+ throw new Error(`Queue notice should be one Telegram message, got ${firstQueueNotices.length}`);
666
+ }
667
+ if (!firstQueueNotice.reply_markup.includes(`remoteagent:queue:remove:${firstQueueId}`)
668
+ || !firstQueueNotice.reply_markup.includes("remoteagent:queue:del")) {
669
+ throw new Error(`Queue notice buttons are missing: ${firstQueueNotice.reply_markup}`);
670
+ }
486
671
 
487
672
  await send("/batch start");
488
673
  await send("second queued instruction");
@@ -504,9 +689,9 @@ if (!/Queued instructions for S001 \(2\)/.test(queueListCall.text)) {
504
689
  throw new Error(`Queue list did not report both entries: ${queueListCall.text}`);
505
690
  }
506
691
 
507
- await send(`/queue remove ${firstQueueId}`);
692
+ await click(`remoteagent:queue:remove:${firstQueueId}`);
508
693
  await waitForTelegramCall((call) => call.text.includes(`Removed queued instruction ${firstQueueId}`));
509
- await send("/queue del");
694
+ await click("remoteagent:queue:del");
510
695
  await waitForTelegramCall((call) => call.text.includes(`Removed queued instruction ${secondQueueId}`));
511
696
 
512
697
  queueHoldReleaseResolve?.();
@@ -527,6 +712,8 @@ console.log(JSON.stringify({
527
712
  providerCalls: providerCalls.length,
528
713
  untaggedIntentCalls,
529
714
  missingEvidenceCalls,
715
+ streamingProgress: true,
716
+ streamingFinalProgressDeduplicated: true,
530
717
  queueRemoveById: firstQueueId,
531
718
  queueRemoveLatest: secondQueueId,
532
719
  timeoutFinalMessage: true,