switchroom 0.13.37 → 0.13.39
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/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/answer-stream.ts +28 -3
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +89 -20
- package/telegram-plugin/dist/server.js +1 -1
- package/telegram-plugin/gateway/gateway.ts +29 -7
- package/telegram-plugin/permission-title.ts +154 -23
- package/telegram-plugin/steering.ts +38 -7
- package/telegram-plugin/tests/answer-stream.test.ts +86 -0
- package/telegram-plugin/tests/permission-title.test.ts +147 -3
- package/telegram-plugin/tests/steering.test.ts +37 -4
- package/telegram-plugin/uat/scenarios/jtbd-pending-progress-html-dm.test.ts +124 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -47744,8 +47744,8 @@ var {
|
|
|
47744
47744
|
} = import__.default;
|
|
47745
47745
|
|
|
47746
47746
|
// src/build-info.ts
|
|
47747
|
-
var VERSION = "0.13.
|
|
47748
|
-
var COMMIT_SHA = "
|
|
47747
|
+
var VERSION = "0.13.39";
|
|
47748
|
+
var COMMIT_SHA = "8681f423";
|
|
47749
47749
|
|
|
47750
47750
|
// src/cli/agent.ts
|
|
47751
47751
|
init_source();
|
package/package.json
CHANGED
|
@@ -244,15 +244,22 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
|
|
|
244
244
|
* must clear the draft. Best-effort: a failed clear is logged but
|
|
245
245
|
* not re-thrown — the worst case is a transient stale draft that
|
|
246
246
|
* Telegram's own 30 s draft expiry eventually mops up.
|
|
247
|
+
*
|
|
248
|
+
* #1792 — accepts an explicit `targetDraftId` so `forceNewMessage`
|
|
249
|
+
* can clear the OLD id before bumping the closure's `draftId`. The
|
|
250
|
+
* default reads the live closure, which is what stop() / retract()
|
|
251
|
+
* want — clear whatever's current at the time the call lands.
|
|
247
252
|
*/
|
|
248
|
-
async function clearDraftBestEffort(
|
|
249
|
-
|
|
253
|
+
async function clearDraftBestEffort(
|
|
254
|
+
targetDraftId: number | undefined = draftId,
|
|
255
|
+
): Promise<void> {
|
|
256
|
+
if (!usesDraftTransport || draftApi == null || targetDraftId == null) return
|
|
250
257
|
try {
|
|
251
258
|
const params: { message_thread_id?: number } = {}
|
|
252
259
|
if (threadId != null) params.message_thread_id = threadId
|
|
253
260
|
await draftApi(
|
|
254
261
|
chatId,
|
|
255
|
-
|
|
262
|
+
targetDraftId,
|
|
256
263
|
'',
|
|
257
264
|
Object.keys(params).length > 0 ? params : undefined,
|
|
258
265
|
)
|
|
@@ -531,6 +538,18 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
|
|
|
531
538
|
stopped = false
|
|
532
539
|
materialized = false
|
|
533
540
|
if (usesDraftTransport) {
|
|
541
|
+
// #1792: clear the OLD draftId BEFORE rotating. Otherwise the
|
|
542
|
+
// stale content stays in the user's compose box until the 30 s
|
|
543
|
+
// Telegram draft expiry — the typical caller (gateway.ts mid-
|
|
544
|
+
// turn rapid-steer path: `forceNewMessage(); stop();`) cleans
|
|
545
|
+
// up the prior turn's stream, so the prior draft's content is
|
|
546
|
+
// semantically retracted. Fire-and-forget — forceNewMessage is
|
|
547
|
+
// sync; the worst-case failure mode is the same 30 s expiry
|
|
548
|
+
// we'd have had without the call.
|
|
549
|
+
const staleDraftId = draftId
|
|
550
|
+
if (staleDraftId != null) {
|
|
551
|
+
void clearDraftBestEffort(staleDraftId)
|
|
552
|
+
}
|
|
534
553
|
draftId = allocateDraftId()
|
|
535
554
|
}
|
|
536
555
|
log?.(`answer-stream: forceNewMessage (gen=${generation})`)
|
|
@@ -546,6 +565,10 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
|
|
|
546
565
|
// #1704: clear the compose-box draft. stop() is sync — fire and
|
|
547
566
|
// forget. A dropped clear falls back on Telegram's own 30 s
|
|
548
567
|
// draft expiry; the worst case is a transient stale preview.
|
|
568
|
+
// (#1792: the stale-id-after-rotation hazard is owned by
|
|
569
|
+
// forceNewMessage itself now — it clears its own draftId before
|
|
570
|
+
// rotating. stop() just clears whatever's current; clearing an
|
|
571
|
+
// already-cleared or never-used id is a harmless no-op.)
|
|
549
572
|
void clearDraftBestEffort()
|
|
550
573
|
},
|
|
551
574
|
|
|
@@ -563,6 +586,8 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
|
|
|
563
586
|
// draft sitting in the user's input area and blocks them from
|
|
564
587
|
// typing until the 30 s draft expiry. Awaited so a follow-up
|
|
565
588
|
// sendMessage on the same chat doesn't race a stale draft edit.
|
|
589
|
+
// (See #1792 note in stop() — forceNewMessage owns its own stale
|
|
590
|
+
// id cleanup; retract just clears whatever's current.)
|
|
566
591
|
await clearDraftBestEffort()
|
|
567
592
|
// Delete the preliminary message if one was sent and deleteMessage
|
|
568
593
|
// is wired. Best-effort: failures are logged but not re-thrown.
|
|
@@ -431,7 +431,7 @@ const TOOL_SCHEMAS = [
|
|
|
431
431
|
chat_id: { type: 'string', description: 'Chat to render the approval card in (use the chat_id of the user message that triggered the workflow).' },
|
|
432
432
|
key: { type: 'string', description: 'Vault key the agent wants access to (matches the key shown in the VAULT-BROKER-DENIED error, e.g. `fatsecret/credentials`).' },
|
|
433
433
|
scope: { type: 'string', enum: ['read', 'write'], description: 'Access scope: "read" (default) for `vault:<key>` references; "write" if the agent needs to put new values.' },
|
|
434
|
-
reason: { type: 'string', description: '
|
|
434
|
+
reason: { type: 'string', description: 'REQUIRED in practice — short human-readable rationale rendered on the card (e.g. "to look up today\'s food log entries"). The approval card now renders "why: not provided" when this is omitted, which signals to the operator that the agent skipped its explanation — they will usually Deny. Always supply a one-line rationale.' },
|
|
435
435
|
duration: { type: 'string', description: 'Requested grant TTL, like "30d" or "12h". Default 30d, capped at 90d. Beyond 90d the operator should use the host CLI explicitly.' },
|
|
436
436
|
message_thread_id: { type: 'string', description: 'Forum topic thread ID. Auto-applied from the last inbound message if not specified.' },
|
|
437
437
|
},
|
|
@@ -24797,7 +24797,7 @@ var TOOL_SCHEMAS = [
|
|
|
24797
24797
|
chat_id: { type: "string", description: "Chat to render the approval card in (use the chat_id of the user message that triggered the workflow)." },
|
|
24798
24798
|
key: { type: "string", description: "Vault key the agent wants access to (matches the key shown in the VAULT-BROKER-DENIED error, e.g. `fatsecret/credentials`)." },
|
|
24799
24799
|
scope: { type: "string", enum: ["read", "write"], description: 'Access scope: "read" (default) for `vault:<key>` references; "write" if the agent needs to put new values.' },
|
|
24800
|
-
reason: { type: "string", description: `
|
|
24800
|
+
reason: { type: "string", description: `REQUIRED in practice \u2014 short human-readable rationale rendered on the card (e.g. "to look up today's food log entries"). The approval card now renders "why: not provided" when this is omitted, which signals to the operator that the agent skipped its explanation \u2014 they will usually Deny. Always supply a one-line rationale.` },
|
|
24801
24801
|
duration: { type: "string", description: 'Requested grant TTL, like "30d" or "12h". Default 30d, capped at 90d. Beyond 90d the operator should use the host CLI explicitly.' },
|
|
24802
24802
|
message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message if not specified." }
|
|
24803
24803
|
},
|
|
@@ -37781,14 +37781,14 @@ function createAnswerStream(config) {
|
|
|
37781
37781
|
scheduledTimer = null;
|
|
37782
37782
|
}
|
|
37783
37783
|
}
|
|
37784
|
-
async function clearDraftBestEffort() {
|
|
37785
|
-
if (!usesDraftTransport || draftApi == null ||
|
|
37784
|
+
async function clearDraftBestEffort(targetDraftId = draftId) {
|
|
37785
|
+
if (!usesDraftTransport || draftApi == null || targetDraftId == null)
|
|
37786
37786
|
return;
|
|
37787
37787
|
try {
|
|
37788
37788
|
const params = {};
|
|
37789
37789
|
if (threadId != null)
|
|
37790
37790
|
params.message_thread_id = threadId;
|
|
37791
|
-
await draftApi(chatId,
|
|
37791
|
+
await draftApi(chatId, targetDraftId, "", Object.keys(params).length > 0 ? params : undefined);
|
|
37792
37792
|
} catch {}
|
|
37793
37793
|
}
|
|
37794
37794
|
async function sendDraft(text) {
|
|
@@ -38008,6 +38008,10 @@ function createAnswerStream(config) {
|
|
|
38008
38008
|
stopped = false;
|
|
38009
38009
|
materialized = false;
|
|
38010
38010
|
if (usesDraftTransport) {
|
|
38011
|
+
const staleDraftId = draftId;
|
|
38012
|
+
if (staleDraftId != null) {
|
|
38013
|
+
clearDraftBestEffort(staleDraftId);
|
|
38014
|
+
}
|
|
38011
38015
|
draftId = allocateDraftId2();
|
|
38012
38016
|
}
|
|
38013
38017
|
log?.(`answer-stream: forceNewMessage (gen=${generation})`);
|
|
@@ -39638,9 +39642,13 @@ function parseSteerPrefix(body) {
|
|
|
39638
39642
|
function escapeXmlAttribute(s) {
|
|
39639
39643
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
39640
39644
|
}
|
|
39645
|
+
function decodeXmlEntities(s) {
|
|
39646
|
+
return s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, " ").replace(/&/g, "&");
|
|
39647
|
+
}
|
|
39641
39648
|
function formatPriorAssistantPreview(text, maxChars = 200) {
|
|
39642
39649
|
const stripped = text.replace(/<[^>]*>/g, "");
|
|
39643
|
-
const
|
|
39650
|
+
const decoded = decodeXmlEntities(stripped);
|
|
39651
|
+
const collapsed = decoded.replace(/\s+/g, " ").trim();
|
|
39644
39652
|
const truncated = collapsed.length > maxChars ? collapsed.slice(0, maxChars) : collapsed;
|
|
39645
39653
|
return escapeXmlAttribute(truncated);
|
|
39646
39654
|
}
|
|
@@ -48305,6 +48313,8 @@ function defaultReadEvents(stateDir) {
|
|
|
48305
48313
|
import { basename as basename5 } from "node:path";
|
|
48306
48314
|
var COMMAND_TITLE_MAX = 40;
|
|
48307
48315
|
var PATH_TITLE_MAX = 40;
|
|
48316
|
+
var DESCRIPTION_LINE_MAX = 240;
|
|
48317
|
+
var INPUT_VALUE_MAX = 60;
|
|
48308
48318
|
var MCP_TOOL_DESCRIPTIONS = {
|
|
48309
48319
|
"mcp__agent-config__config_get": "Read its own merged config",
|
|
48310
48320
|
"mcp__agent-config__cron_list": "List its own scheduled tasks",
|
|
@@ -48329,15 +48339,17 @@ var MCP_TOOL_DESCRIPTIONS = {
|
|
|
48329
48339
|
function summarizeToolForTitle(toolName, inputPreview) {
|
|
48330
48340
|
if (toolName.startsWith("mcp__")) {
|
|
48331
48341
|
const curated = MCP_TOOL_DESCRIPTIONS[toolName];
|
|
48332
|
-
|
|
48333
|
-
|
|
48334
|
-
|
|
48335
|
-
|
|
48336
|
-
|
|
48337
|
-
|
|
48338
|
-
|
|
48339
|
-
|
|
48340
|
-
|
|
48342
|
+
const base = curated ? curated : (() => {
|
|
48343
|
+
const parts = toolName.split("__");
|
|
48344
|
+
if (parts.length >= 3) {
|
|
48345
|
+
const server = parts[1];
|
|
48346
|
+
const verb = parts.slice(2).join("__").replace(/_/g, " ");
|
|
48347
|
+
return `${server}: ${verb}`;
|
|
48348
|
+
}
|
|
48349
|
+
return toolName;
|
|
48350
|
+
})();
|
|
48351
|
+
const argHint = firstScalarArgHint(parseInput(inputPreview));
|
|
48352
|
+
return argHint ? `${base} (${argHint})` : base;
|
|
48341
48353
|
}
|
|
48342
48354
|
const input = parseInput(inputPreview);
|
|
48343
48355
|
if (!input)
|
|
@@ -48345,7 +48357,13 @@ function summarizeToolForTitle(toolName, inputPreview) {
|
|
|
48345
48357
|
switch (toolName) {
|
|
48346
48358
|
case "Skill": {
|
|
48347
48359
|
const skill = readString(input, "skill") ?? readString(input, "skill_name") ?? readString(input, "skillName") ?? readString(input, "name") ?? skillBasenameFromPath(input);
|
|
48348
|
-
|
|
48360
|
+
if (skill)
|
|
48361
|
+
return `${toolName} (${skill})`;
|
|
48362
|
+
const command = readString(input, "command");
|
|
48363
|
+
if (command)
|
|
48364
|
+
return `${toolName}: ${truncate5(command, COMMAND_TITLE_MAX)}`;
|
|
48365
|
+
const argHint = firstScalarArgHint(input);
|
|
48366
|
+
return argHint ? `${toolName} (${argHint})` : toolName;
|
|
48349
48367
|
}
|
|
48350
48368
|
case "Bash": {
|
|
48351
48369
|
const command = readString(input, "command");
|
|
@@ -48373,6 +48391,47 @@ function summarizeToolForTitle(toolName, inputPreview) {
|
|
|
48373
48391
|
return toolName;
|
|
48374
48392
|
}
|
|
48375
48393
|
}
|
|
48394
|
+
function formatPermissionCardBody(opts) {
|
|
48395
|
+
const summary = summarizeToolForTitle(opts.toolName, opts.inputPreview);
|
|
48396
|
+
const lines = [];
|
|
48397
|
+
const agentBit = opts.agentName && opts.agentName.length > 0 ? `<b>${escapeTgHtml(opts.agentName)}</b> \u00b7 ` : "";
|
|
48398
|
+
lines.push(`\uD83D\uDD10 ${agentBit}${escapeTgHtml(summary)}`);
|
|
48399
|
+
const rawWhy = (opts.description ?? "").replace(/\s+/g, " ").trim();
|
|
48400
|
+
const truncatedWhy = rawWhy.length > DESCRIPTION_LINE_MAX ? rawWhy.slice(0, DESCRIPTION_LINE_MAX - 1) + "\u2026" : rawWhy;
|
|
48401
|
+
if (truncatedWhy.length > 0) {
|
|
48402
|
+
lines.push(`why: <i>${escapeTgHtml(truncatedWhy)}</i>`);
|
|
48403
|
+
} else {
|
|
48404
|
+
lines.push(`why: <i>not provided</i>`);
|
|
48405
|
+
}
|
|
48406
|
+
return lines.join(`
|
|
48407
|
+
`);
|
|
48408
|
+
}
|
|
48409
|
+
function escapeTgHtml(text) {
|
|
48410
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
48411
|
+
}
|
|
48412
|
+
function firstScalarArgHint(input) {
|
|
48413
|
+
if (!input)
|
|
48414
|
+
return null;
|
|
48415
|
+
const SKIP = new Set([
|
|
48416
|
+
"chat_id",
|
|
48417
|
+
"chatId",
|
|
48418
|
+
"message_thread_id",
|
|
48419
|
+
"messageThreadId",
|
|
48420
|
+
"request_id",
|
|
48421
|
+
"requestId"
|
|
48422
|
+
]);
|
|
48423
|
+
for (const [key, value] of Object.entries(input)) {
|
|
48424
|
+
if (SKIP.has(key))
|
|
48425
|
+
continue;
|
|
48426
|
+
if (typeof value === "string" && value.length > 0) {
|
|
48427
|
+
return `${key}: ${truncate5(value, INPUT_VALUE_MAX)}`;
|
|
48428
|
+
}
|
|
48429
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
48430
|
+
return `${key}: ${String(value)}`;
|
|
48431
|
+
}
|
|
48432
|
+
}
|
|
48433
|
+
return null;
|
|
48434
|
+
}
|
|
48376
48435
|
function parseInput(raw) {
|
|
48377
48436
|
if (!raw || typeof raw !== "string")
|
|
48378
48437
|
return null;
|
|
@@ -48671,10 +48730,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
48671
48730
|
}
|
|
48672
48731
|
|
|
48673
48732
|
// ../src/build-info.ts
|
|
48674
|
-
var VERSION = "0.13.
|
|
48675
|
-
var COMMIT_SHA = "
|
|
48676
|
-
var COMMIT_DATE = "2026-05-
|
|
48677
|
-
var LATEST_PR =
|
|
48733
|
+
var VERSION = "0.13.39";
|
|
48734
|
+
var COMMIT_SHA = "8681f423";
|
|
48735
|
+
var COMMIT_DATE = "2026-05-25T07:06:31Z";
|
|
48736
|
+
var LATEST_PR = 1797;
|
|
48678
48737
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
48679
48738
|
|
|
48680
48739
|
// gateway/boot-version.ts
|
|
@@ -50838,14 +50897,22 @@ ${reminder}
|
|
|
50838
50897
|
const { requestId, toolName, description, inputPreview } = msg;
|
|
50839
50898
|
pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() });
|
|
50840
50899
|
const access = loadAccess();
|
|
50841
|
-
const text =
|
|
50900
|
+
const text = formatPermissionCardBody({
|
|
50901
|
+
toolName,
|
|
50902
|
+
inputPreview,
|
|
50903
|
+
description,
|
|
50904
|
+
agentName: _client.agentName
|
|
50905
|
+
});
|
|
50842
50906
|
const alwaysRule = resolveAlwaysAllowRule(toolName, inputPreview);
|
|
50843
50907
|
const keyboard = new import_grammy9.InlineKeyboard().text("See more", `perm:more:${requestId}`).text("\u2705 Allow", `perm:allow:${requestId}`).text("\u274C Deny", `perm:deny:${requestId}`);
|
|
50844
50908
|
if (alwaysRule != null) {
|
|
50845
50909
|
keyboard.row().text(`\uD83D\uDD01 Always allow ${alwaysRule.label}`, `perm:always:${requestId}`);
|
|
50846
50910
|
}
|
|
50847
50911
|
for (const chat_id of access.allowFrom) {
|
|
50848
|
-
bot.api.sendMessage(chat_id, text, {
|
|
50912
|
+
bot.api.sendMessage(chat_id, text, {
|
|
50913
|
+
parse_mode: "HTML",
|
|
50914
|
+
reply_markup: keyboard
|
|
50915
|
+
}).catch((e) => {
|
|
50849
50916
|
process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}
|
|
50850
50917
|
`);
|
|
50851
50918
|
});
|
|
@@ -52130,6 +52197,8 @@ function renderVaultRequestAccessCard(req) {
|
|
|
52130
52197
|
lines.push(`scope: <code>${scopeLabel}</code> \xB7 duration: <code>${durationLabel}</code>`);
|
|
52131
52198
|
if (req.reason && req.reason.length > 0) {
|
|
52132
52199
|
lines.push(`why: <i>${escapeHtmlForTg(req.reason)}</i>`);
|
|
52200
|
+
} else {
|
|
52201
|
+
lines.push(`why: <i>not provided</i>`);
|
|
52133
52202
|
}
|
|
52134
52203
|
lines.push("");
|
|
52135
52204
|
lines.push(`<i>Tap Approve to mint a scoped grant token (same flow as <code>switchroom vault grant</code>). Tap Deny to refuse \u2014 the agent will receive a denial result.</i>`);
|
|
@@ -24492,7 +24492,7 @@ var init_bridge = __esm(async () => {
|
|
|
24492
24492
|
chat_id: { type: "string", description: "Chat to render the approval card in (use the chat_id of the user message that triggered the workflow)." },
|
|
24493
24493
|
key: { type: "string", description: "Vault key the agent wants access to (matches the key shown in the VAULT-BROKER-DENIED error, e.g. `fatsecret/credentials`)." },
|
|
24494
24494
|
scope: { type: "string", enum: ["read", "write"], description: 'Access scope: "read" (default) for `vault:<key>` references; "write" if the agent needs to put new values.' },
|
|
24495
|
-
reason: { type: "string", description: `
|
|
24495
|
+
reason: { type: "string", description: `REQUIRED in practice \u2014 short human-readable rationale rendered on the card (e.g. "to look up today's food log entries"). The approval card now renders "why: not provided" when this is omitted, which signals to the operator that the agent skipped its explanation \u2014 they will usually Deny. Always supply a one-line rationale.` },
|
|
24496
24496
|
duration: { type: "string", description: 'Requested grant TTL, like "30d" or "12h". Default 30d, capped at 90d. Beyond 90d the operator should use the host CLI explicitly.' },
|
|
24497
24497
|
message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message if not specified." }
|
|
24498
24498
|
},
|
|
@@ -356,7 +356,7 @@ import { maybeRenderUpdateAnnouncement } from './update-announce.js'
|
|
|
356
356
|
import { createIssuesCardHandle, type IssuesCardHandle } from '../issues-card.js'
|
|
357
357
|
import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.js'
|
|
358
358
|
import { list as listIssues, resolve as resolveIssue } from '../../src/issues/index.js'
|
|
359
|
-
import { summarizeToolForTitle } from '../permission-title.js'
|
|
359
|
+
import { summarizeToolForTitle, formatPermissionCardBody } from '../permission-title.js'
|
|
360
360
|
import { resolveAlwaysAllowRule } from '../permission-rule.js'
|
|
361
361
|
import {
|
|
362
362
|
readClaudeJsonOverage,
|
|
@@ -3863,10 +3863,18 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3863
3863
|
const { requestId, toolName, description, inputPreview } = msg
|
|
3864
3864
|
pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() })
|
|
3865
3865
|
const access = loadAccess()
|
|
3866
|
-
//
|
|
3867
|
-
//
|
|
3868
|
-
//
|
|
3869
|
-
|
|
3866
|
+
// #1790 — multi-line collapsed body so the operator can see what
|
|
3867
|
+
// is being requested and why without tapping "See more". Mirrors
|
|
3868
|
+
// the `vault_request_access` card layout (the gold standard).
|
|
3869
|
+
// The detail (expanded `tool_name` / pretty `input_preview`)
|
|
3870
|
+
// still surfaces on the See-more tap; this is the
|
|
3871
|
+
// collapsed-view fix only. Sent with parse_mode=HTML below.
|
|
3872
|
+
const text = formatPermissionCardBody({
|
|
3873
|
+
toolName,
|
|
3874
|
+
inputPreview,
|
|
3875
|
+
description,
|
|
3876
|
+
agentName: _client.agentName,
|
|
3877
|
+
})
|
|
3870
3878
|
// Build the keyboard. The "🔁 Always" button only appears when we
|
|
3871
3879
|
// can synthesize a meaningful allow-rule for this tool — for an
|
|
3872
3880
|
// unknown tool we'd write a useless rule (or worse, a rule that
|
|
@@ -3887,8 +3895,13 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3887
3895
|
.text(`🔁 Always allow ${alwaysRule.label}`, `perm:always:${requestId}`)
|
|
3888
3896
|
}
|
|
3889
3897
|
for (const chat_id of access.allowFrom) {
|
|
3890
|
-
//
|
|
3891
|
-
|
|
3898
|
+
// parse_mode=HTML pairs with formatPermissionCardBody (#1790)
|
|
3899
|
+
// so the <b>/<i> tags render as formatting.
|
|
3900
|
+
// allow-raw-bot-api: permission-request keyboard fan-out; reply_markup + parse_mode only, no thread_id
|
|
3901
|
+
void bot.api.sendMessage(chat_id, text, {
|
|
3902
|
+
parse_mode: 'HTML',
|
|
3903
|
+
reply_markup: keyboard,
|
|
3904
|
+
}).catch(e => {
|
|
3892
3905
|
process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}\n`)
|
|
3893
3906
|
})
|
|
3894
3907
|
}
|
|
@@ -5998,8 +6011,17 @@ function renderVaultRequestAccessCard(req: PendingVaultRequestAccess): string {
|
|
|
5998
6011
|
lines.push(`🔐 <b>${escapeHtmlForTg(req.agent)}</b> wants vault access`)
|
|
5999
6012
|
lines.push(`key: <code>${escapeHtmlForTg(req.key)}</code>`)
|
|
6000
6013
|
lines.push(`scope: <code>${scopeLabel}</code> · duration: <code>${durationLabel}</code>`)
|
|
6014
|
+
// #1790 — always render the why-line, even when the agent omitted
|
|
6015
|
+
// `reason`. Rendering "not provided" makes a missing rationale
|
|
6016
|
+
// visibly an agent-side failure (the tool description nudges the
|
|
6017
|
+
// model to supply one — see executeVaultRequestAccess); skipping
|
|
6018
|
+
// the line silently used to make the omission look like a card-
|
|
6019
|
+
// template choice, which the operator couldn't tell apart from a
|
|
6020
|
+
// legitimate "no reason needed" case.
|
|
6001
6021
|
if (req.reason && req.reason.length > 0) {
|
|
6002
6022
|
lines.push(`why: <i>${escapeHtmlForTg(req.reason)}</i>`)
|
|
6023
|
+
} else {
|
|
6024
|
+
lines.push(`why: <i>not provided</i>`)
|
|
6003
6025
|
}
|
|
6004
6026
|
lines.push('')
|
|
6005
6027
|
lines.push(`<i>Tap Approve to mint a scoped grant token (same flow as <code>switchroom vault grant</code>). Tap Deny to refuse — the agent will receive a denial result.</i>`)
|
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Build
|
|
3
|
-
* approval message. Pre-fix the title was always `🔐 Permission:
|
|
4
|
-
* ${toolName}` — for a `Skill` or `Bash` call the user couldn't tell
|
|
5
|
-
* which skill / command was being approved without tapping "See more".
|
|
2
|
+
* Build the inline-keyboard permission approval message — title + body.
|
|
6
3
|
*
|
|
7
|
-
*
|
|
8
|
-
* render the full description + input_preview block; this helper just
|
|
9
|
-
* lifts the most identifying field into the title so the user can
|
|
10
|
-
* approve at a glance.
|
|
4
|
+
* Two related concerns:
|
|
11
5
|
*
|
|
12
|
-
*
|
|
6
|
+
* `summarizeToolForTitle` (one line, no escaping) is the bare summary
|
|
7
|
+
* used in the always-allow rule label and as the body-builder's
|
|
8
|
+
* internal building block. Pre-#186 the title was always `🔐
|
|
9
|
+
* Permission: ${toolName}` — for a `Skill` or `Bash` call the user
|
|
10
|
+
* couldn't tell which skill / command was being approved without
|
|
11
|
+
* tapping "See more".
|
|
12
|
+
*
|
|
13
|
+
* `formatPermissionCardBody` (multi-line, HTML-escaped for
|
|
14
|
+
* parse_mode=HTML) is the body of the card itself. Pre-#1790 the
|
|
15
|
+
* collapsed card was a single line — operators had to tap "See more"
|
|
16
|
+
* to see the agent's stated reason or input preview. This mirrors
|
|
17
|
+
* the vault `vault_request_access` card's three-line layout (the
|
|
18
|
+
* gold standard) so every approval surface answers "what" + "why"
|
|
19
|
+
* without an expand tap.
|
|
20
|
+
*
|
|
21
|
+
* See #186 (title) and #1790 (body).
|
|
13
22
|
*/
|
|
14
23
|
|
|
15
24
|
import { basename } from "node:path";
|
|
16
25
|
|
|
17
26
|
const COMMAND_TITLE_MAX = 40;
|
|
18
27
|
const PATH_TITLE_MAX = 40;
|
|
28
|
+
const DESCRIPTION_LINE_MAX = 240;
|
|
29
|
+
const INPUT_VALUE_MAX = 60;
|
|
19
30
|
|
|
20
31
|
/**
|
|
21
32
|
* Human-friendly descriptions for switchroom-managed MCP tools. The
|
|
@@ -70,17 +81,26 @@ export function summarizeToolForTitle(
|
|
|
70
81
|
// description (so the card reads "Read its own merged config"
|
|
71
82
|
// instead of "mcp__agent-config__config_get"). Fall through to a
|
|
72
83
|
// generic `<server>: <verb-with-spaces>` shape for unknown MCP
|
|
73
|
-
// tools and finally to the raw name when even that fails.
|
|
84
|
+
// tools and finally to the raw name when even that fails. When
|
|
85
|
+
// we have an input preview, append the first arg-value pair so
|
|
86
|
+
// the operator sees what's being requested without expanding —
|
|
87
|
+
// e.g. `Read its own merged config (key: coolify/api-token)`
|
|
88
|
+
// rather than just `Read its own merged config`. (#1790)
|
|
74
89
|
if (toolName.startsWith("mcp__")) {
|
|
75
90
|
const curated = MCP_TOOL_DESCRIPTIONS[toolName];
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
91
|
+
const base = curated
|
|
92
|
+
? curated
|
|
93
|
+
: (() => {
|
|
94
|
+
const parts = toolName.split("__");
|
|
95
|
+
if (parts.length >= 3) {
|
|
96
|
+
const server = parts[1]!;
|
|
97
|
+
const verb = parts.slice(2).join("__").replace(/_/g, " ");
|
|
98
|
+
return `${server}: ${verb}`;
|
|
99
|
+
}
|
|
100
|
+
return toolName;
|
|
101
|
+
})();
|
|
102
|
+
const argHint = firstScalarArgHint(parseInput(inputPreview));
|
|
103
|
+
return argHint ? `${base} (${argHint})` : base;
|
|
84
104
|
}
|
|
85
105
|
|
|
86
106
|
const input = parseInput(inputPreview);
|
|
@@ -90,17 +110,26 @@ export function summarizeToolForTitle(
|
|
|
90
110
|
case "Skill": {
|
|
91
111
|
// Claude Code's Skill tool input shape has shifted across versions
|
|
92
112
|
// and skill flavours. Read defensively from every known field
|
|
93
|
-
// before falling back
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
113
|
+
// before falling back. The skill name is the most identifying
|
|
114
|
+
// field of the prompt; never drop it silently.
|
|
115
|
+
//
|
|
116
|
+
// (#1790) Final fallback added: when no skill-name key matches,
|
|
117
|
+
// try `command` (some Skill variants pass the invocation under
|
|
118
|
+
// that key), then the first scalar arg-value pair. Pre-fix the
|
|
119
|
+
// default returned a bare `Skill` with zero context — operators
|
|
120
|
+
// saw "🔐 Permission: Skill" with no way to tell what was being
|
|
121
|
+
// asked without tapping See more.
|
|
97
122
|
const skill =
|
|
98
123
|
readString(input, "skill") ??
|
|
99
124
|
readString(input, "skill_name") ??
|
|
100
125
|
readString(input, "skillName") ??
|
|
101
126
|
readString(input, "name") ??
|
|
102
127
|
skillBasenameFromPath(input);
|
|
103
|
-
|
|
128
|
+
if (skill) return `${toolName} (${skill})`;
|
|
129
|
+
const command = readString(input, "command");
|
|
130
|
+
if (command) return `${toolName}: ${truncate(command, COMMAND_TITLE_MAX)}`;
|
|
131
|
+
const argHint = firstScalarArgHint(input);
|
|
132
|
+
return argHint ? `${toolName} (${argHint})` : toolName;
|
|
104
133
|
}
|
|
105
134
|
case "Bash": {
|
|
106
135
|
const command = readString(input, "command");
|
|
@@ -129,6 +158,108 @@ export function summarizeToolForTitle(
|
|
|
129
158
|
}
|
|
130
159
|
}
|
|
131
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Build the multi-line collapsed body of an approval card (#1790).
|
|
163
|
+
*
|
|
164
|
+
* Pre-fix the card was a single line — `🔐 Permission: <title>` —
|
|
165
|
+
* and the agent's stated `description` plus the input preview only
|
|
166
|
+
* surfaced when the operator tapped "See more". For skill / generic
|
|
167
|
+
* tool prompts the title alone (e.g. `Skill (mail)`) is rarely
|
|
168
|
+
* enough to approve at a glance; the operator needs to see *why*
|
|
169
|
+
* before they tap Allow / Deny.
|
|
170
|
+
*
|
|
171
|
+
* Layout mirrors the `vault_request_access` card (the gold standard):
|
|
172
|
+
*
|
|
173
|
+
* 🔐 <agent> · <tool summary>
|
|
174
|
+
* why: <description-or-"not provided">
|
|
175
|
+
*
|
|
176
|
+
* The agent line is dropped when `agentName` is null (the
|
|
177
|
+
* gateway's bridge client may be anonymous during early-boot edge
|
|
178
|
+
* cases — better to render the title than a misleading blank).
|
|
179
|
+
*
|
|
180
|
+
* Output is HTML-escaped and intended for `parse_mode: 'HTML'`
|
|
181
|
+
* via Telegram's Bot API.
|
|
182
|
+
*/
|
|
183
|
+
export function formatPermissionCardBody(opts: {
|
|
184
|
+
toolName: string;
|
|
185
|
+
inputPreview: string | undefined;
|
|
186
|
+
description: string | undefined;
|
|
187
|
+
agentName: string | null;
|
|
188
|
+
}): string {
|
|
189
|
+
const summary = summarizeToolForTitle(opts.toolName, opts.inputPreview);
|
|
190
|
+
const lines: string[] = [];
|
|
191
|
+
|
|
192
|
+
const agentBit = opts.agentName && opts.agentName.length > 0
|
|
193
|
+
? `<b>${escapeTgHtml(opts.agentName)}</b> · `
|
|
194
|
+
: "";
|
|
195
|
+
lines.push(`🔐 ${agentBit}${escapeTgHtml(summary)}`);
|
|
196
|
+
|
|
197
|
+
// The agent's stated reason. Always render the line — when the
|
|
198
|
+
// agent omitted a `description`, render an explicit
|
|
199
|
+
// `why: <i>not provided</i>` rather than skip silently, so the
|
|
200
|
+
// missing-rationale is visible as an agent-side failure (matches
|
|
201
|
+
// the vault card's #1790 treatment of an omitted `reason`).
|
|
202
|
+
const rawWhy = (opts.description ?? "").replace(/\s+/g, " ").trim();
|
|
203
|
+
const truncatedWhy =
|
|
204
|
+
rawWhy.length > DESCRIPTION_LINE_MAX
|
|
205
|
+
? rawWhy.slice(0, DESCRIPTION_LINE_MAX - 1) + "…"
|
|
206
|
+
: rawWhy;
|
|
207
|
+
if (truncatedWhy.length > 0) {
|
|
208
|
+
lines.push(`why: <i>${escapeTgHtml(truncatedWhy)}</i>`);
|
|
209
|
+
} else {
|
|
210
|
+
lines.push(`why: <i>not provided</i>`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return lines.join("\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Minimal HTML escape for Telegram `parse_mode=HTML`. Mirrors
|
|
218
|
+
* `escapeHtmlForTg` in gateway.ts; duplicated here to keep
|
|
219
|
+
* permission-title.ts free of a gateway import (the file is
|
|
220
|
+
* referenced by both server.ts and gateway.ts).
|
|
221
|
+
*/
|
|
222
|
+
function escapeTgHtml(text: string): string {
|
|
223
|
+
return text
|
|
224
|
+
.replace(/&/g, "&")
|
|
225
|
+
.replace(/</g, "<")
|
|
226
|
+
.replace(/>/g, ">");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Return a `key: value` hint for the first scalar (string / number /
|
|
231
|
+
* boolean) arg in the input preview. Used as a last-ditch context
|
|
232
|
+
* line on uncurated MCP tools and Skill calls whose canonical
|
|
233
|
+
* skill-name fields are all missing.
|
|
234
|
+
*
|
|
235
|
+
* Skips obviously-routing keys (`chat_id`, `message_thread_id`,
|
|
236
|
+
* `request_id`) that aren't useful to a human operator deciding
|
|
237
|
+
* whether to approve. Returns `null` when nothing scalar remains.
|
|
238
|
+
*/
|
|
239
|
+
function firstScalarArgHint(
|
|
240
|
+
input: Record<string, unknown> | null,
|
|
241
|
+
): string | null {
|
|
242
|
+
if (!input) return null;
|
|
243
|
+
const SKIP = new Set([
|
|
244
|
+
"chat_id",
|
|
245
|
+
"chatId",
|
|
246
|
+
"message_thread_id",
|
|
247
|
+
"messageThreadId",
|
|
248
|
+
"request_id",
|
|
249
|
+
"requestId",
|
|
250
|
+
]);
|
|
251
|
+
for (const [key, value] of Object.entries(input)) {
|
|
252
|
+
if (SKIP.has(key)) continue;
|
|
253
|
+
if (typeof value === "string" && value.length > 0) {
|
|
254
|
+
return `${key}: ${truncate(value, INPUT_VALUE_MAX)}`;
|
|
255
|
+
}
|
|
256
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
257
|
+
return `${key}: ${String(value)}`;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
132
263
|
function parseInput(raw: string | undefined): Record<string, unknown> | null {
|
|
133
264
|
if (!raw || typeof raw !== "string") return null;
|
|
134
265
|
const trimmed = raw.trim();
|
|
@@ -73,22 +73,53 @@ export function escapeXmlAttribute(s: string): string {
|
|
|
73
73
|
.replace(/'/g, ''')
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Decode the small set of HTML / XML entities switchroom emits when it
|
|
78
|
+
* renders model output as Telegram HTML. Pre-#1791 this function did
|
|
79
|
+
* not decode and `formatPriorAssistantPreview` then re-escaped the
|
|
80
|
+
* already-encoded entities, so a turn containing inline `<code>` would
|
|
81
|
+
* surface to the model on the next inbound as `&amp;lt;…&amp;gt;`
|
|
82
|
+
* (triple-encoded). The model had to mentally decode three layers to
|
|
83
|
+
* recover the original characters it wrote — measurably hostile to
|
|
84
|
+
* comprehension on turns with placeholders, JSX, XML, generics, etc.
|
|
85
|
+
*
|
|
86
|
+
* Decoding before re-escape closes that loop: the attribute boundary
|
|
87
|
+
* stays safe because `escapeXmlAttribute` runs unchanged at the tail.
|
|
88
|
+
*
|
|
89
|
+
* Limited to the canonical six entities — there's no general HTML
|
|
90
|
+
* entity table here, which keeps the surface predictable.
|
|
91
|
+
*/
|
|
92
|
+
function decodeXmlEntities(s: string): string {
|
|
93
|
+
return s
|
|
94
|
+
.replace(/</g, '<')
|
|
95
|
+
.replace(/>/g, '>')
|
|
96
|
+
.replace(/"/g, '"')
|
|
97
|
+
.replace(/'/g, "'")
|
|
98
|
+
.replace(/ /g, ' ')
|
|
99
|
+
// `&` last so we don't accidentally re-decode `&lt;` → `<` on
|
|
100
|
+
// a single pass — the order above relies on `&` still being
|
|
101
|
+
// intact during the prior replaces.
|
|
102
|
+
.replace(/&/g, '&')
|
|
103
|
+
}
|
|
104
|
+
|
|
76
105
|
/**
|
|
77
106
|
* Produce a short, safe preview of the last assistant turn for injection
|
|
78
107
|
* as an XML attribute. Strips HTML tags (so `<b>foo</b>` becomes `foo`),
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* survives as `&amp;` after escape, which is fine: the attribute is
|
|
84
|
-
* for the model's situational awareness, not faithful rendering.
|
|
108
|
+
* decodes the canonical six XML entities so the model sees the original
|
|
109
|
+
* characters (not triple-encoded `&amp;lt;` — see #1791), collapses
|
|
110
|
+
* all whitespace runs into single spaces, truncates to `maxChars` visible
|
|
111
|
+
* characters, then XML-escapes for safe attribute injection.
|
|
85
112
|
*/
|
|
86
113
|
export function formatPriorAssistantPreview(text: string, maxChars = 200): string {
|
|
87
114
|
// Strip HTML tags. Anything angle-bracketed between < and > goes away;
|
|
88
115
|
// this is deliberately liberal (no tag-name whitelist) because the
|
|
89
116
|
// preview is for the model's eyes only.
|
|
90
117
|
const stripped = text.replace(/<[^>]*>/g, '')
|
|
91
|
-
|
|
118
|
+
// #1791: decode entities BEFORE collapse/truncate/re-escape so the
|
|
119
|
+
// model sees the prose it actually wrote. The re-escape at the tail
|
|
120
|
+
// preserves attribute-injection safety.
|
|
121
|
+
const decoded = decodeXmlEntities(stripped)
|
|
122
|
+
const collapsed = decoded.replace(/\s+/g, ' ').trim()
|
|
92
123
|
const truncated = collapsed.length > maxChars ? collapsed.slice(0, maxChars) : collapsed
|
|
93
124
|
return escapeXmlAttribute(truncated)
|
|
94
125
|
}
|
|
@@ -527,6 +527,92 @@ describe('answer-stream — clears sendMessageDraft on terminal paths (#1704)',
|
|
|
527
527
|
})
|
|
528
528
|
})
|
|
529
529
|
|
|
530
|
+
// ─── #1792 — forceNewMessage clears the stale draftId before rotating ───
|
|
531
|
+
//
|
|
532
|
+
// Background: `forceNewMessage()` rotates `draftId` to a fresh allocation
|
|
533
|
+
// so the stream can be re-used for a new turn (typical caller: gateway
|
|
534
|
+
// rapid-steer path in `handleSessionEvent` enqueue branch — calls
|
|
535
|
+
// `forceNewMessage(); stop()` on the prior turn's stream before opening
|
|
536
|
+
// the new turn). Pre-#1792, the rotation orphaned the prior turn's
|
|
537
|
+
// draft content in the user's compose box until Telegram's 30 s draft
|
|
538
|
+
// expiry — `stop()`'s fire-and-forget clear closed over the (now-new)
|
|
539
|
+
// `draftId`, so the clear targeted the unused id, not the stale one.
|
|
540
|
+
//
|
|
541
|
+
// Post-fix: `forceNewMessage` itself clears the stale draftId BEFORE
|
|
542
|
+
// rotating. `stop()` continues to clear whatever draftId is current
|
|
543
|
+
// at the time it runs (defensive, also fine: clearing an unused id
|
|
544
|
+
// is a harmless no-op for the user).
|
|
545
|
+
|
|
546
|
+
describe('answer-stream — forceNewMessage clears the stale draft before rotating (#1792)', () => {
|
|
547
|
+
it('clears the pre-rotation draftId when forceNewMessage rotates', async () => {
|
|
548
|
+
const sendMessage = makeSendMessage()
|
|
549
|
+
const editMessageText = makeEditMessageText()
|
|
550
|
+
const sendMessageDraft = makeSendMessageDraft()
|
|
551
|
+
const stream = createAnswerStream({
|
|
552
|
+
chatId: 'chat1',
|
|
553
|
+
isPrivateChat: true,
|
|
554
|
+
throttleMs: 250,
|
|
555
|
+
sendMessage,
|
|
556
|
+
editMessageText,
|
|
557
|
+
sendMessageDraft,
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
// Open the stream — this allocates draftId N and fires sendDraft(N).
|
|
561
|
+
stream.update('first turn thought')
|
|
562
|
+
await flushMicrotasks()
|
|
563
|
+
expect(sendMessageDraft).toHaveBeenCalledTimes(1)
|
|
564
|
+
const staleDraftId = (sendMessageDraft.mock.calls[0] as unknown as [string, number, string, unknown])[1]
|
|
565
|
+
sendMessageDraft.mockClear()
|
|
566
|
+
|
|
567
|
+
// Rotate. forceNewMessage must enqueue a clear against the OLD
|
|
568
|
+
// draftId before bumping to the new allocation — pre-fix the
|
|
569
|
+
// stale content stayed in the compose box for 30 s.
|
|
570
|
+
stream.forceNewMessage()
|
|
571
|
+
await flushMicrotasks()
|
|
572
|
+
|
|
573
|
+
expect(sendMessageDraft).toHaveBeenCalledTimes(1)
|
|
574
|
+
const clearedId = (sendMessageDraft.mock.calls[0] as unknown as [string, number, string, unknown])[1]
|
|
575
|
+
const clearedText = (sendMessageDraft.mock.calls[0] as unknown as [string, number, string, unknown])[2]
|
|
576
|
+
expect(clearedId).toBe(staleDraftId)
|
|
577
|
+
expect(clearedText).toBe('')
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
it('the gateway sequence forceNewMessage(); stop() clears the stale draftId', async () => {
|
|
581
|
+
// Mirrors the only production caller — telegram-plugin/gateway/
|
|
582
|
+
// gateway.ts:6476-6477 cleans up the prior turn's answer-stream
|
|
583
|
+
// before opening a new turn (rapid steer / queue path).
|
|
584
|
+
const sendMessage = makeSendMessage()
|
|
585
|
+
const editMessageText = makeEditMessageText()
|
|
586
|
+
const sendMessageDraft = makeSendMessageDraft()
|
|
587
|
+
const stream = createAnswerStream({
|
|
588
|
+
chatId: 'chat1',
|
|
589
|
+
isPrivateChat: true,
|
|
590
|
+
throttleMs: 250,
|
|
591
|
+
sendMessage,
|
|
592
|
+
editMessageText,
|
|
593
|
+
sendMessageDraft,
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
stream.update('prior turn thought')
|
|
597
|
+
await flushMicrotasks()
|
|
598
|
+
const staleDraftId = (sendMessageDraft.mock.calls[0] as unknown as [string, number, string, unknown])[1]
|
|
599
|
+
sendMessageDraft.mockClear()
|
|
600
|
+
|
|
601
|
+
stream.forceNewMessage()
|
|
602
|
+
stream.stop()
|
|
603
|
+
await flushMicrotasks()
|
|
604
|
+
|
|
605
|
+
// The stale id must have been cleared by ONE of the two calls
|
|
606
|
+
// (forceNewMessage in this design); the new unused id may also
|
|
607
|
+
// be cleared by stop() — harmless. The load-bearing invariant
|
|
608
|
+
// is "the stale id reaches sendMessageDraft('') somewhere".
|
|
609
|
+
const clearedIds = (sendMessageDraft.mock.calls as unknown as Array<[string, number, string, unknown]>)
|
|
610
|
+
.filter(c => c[2] === '')
|
|
611
|
+
.map(c => c[1])
|
|
612
|
+
expect(clearedIds).toContain(staleDraftId)
|
|
613
|
+
})
|
|
614
|
+
})
|
|
615
|
+
|
|
530
616
|
describe('answer-stream — empty / whitespace-only text is a no-op', () => {
|
|
531
617
|
it('update("") does not trigger any transport call', async () => {
|
|
532
618
|
const sendMessage = makeSendMessage()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from 'vitest'
|
|
2
|
-
import { summarizeToolForTitle } from '../permission-title.js'
|
|
2
|
+
import { summarizeToolForTitle, formatPermissionCardBody } from '../permission-title.js'
|
|
3
3
|
|
|
4
4
|
describe('summarizeToolForTitle (#186)', () => {
|
|
5
5
|
test('Skill: surfaces the skill name in brackets', () => {
|
|
@@ -49,12 +49,37 @@ describe('summarizeToolForTitle (#186)', () => {
|
|
|
49
49
|
expect(summarizeToolForTitle('Skill', undefined)).toBe('Skill')
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
-
test('falls back to bare toolName when expected key is missing', () => {
|
|
52
|
+
test('falls back to bare toolName for non-Skill tools when expected key is missing', () => {
|
|
53
53
|
const input = JSON.stringify({ unrelated: 'x' })
|
|
54
|
-
|
|
54
|
+
// Bash has no first-arg fallback (its only identifying field is command).
|
|
55
55
|
expect(summarizeToolForTitle('Bash', input)).toBe('Bash')
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
+
// #1790 — the prior contract was "fall back to bare toolName when no
|
|
59
|
+
// skill-name key matched"; that produced operator-hostile cards like
|
|
60
|
+
// `🔐 Permission: Skill` with zero context. The Skill summarizer now
|
|
61
|
+
// tries `command`, then a first-scalar-arg hint, before giving up.
|
|
62
|
+
test('Skill: when no skill-name key matches, falls back to command field (#1790)', () => {
|
|
63
|
+
const input = JSON.stringify({ command: 'gen calendar event' })
|
|
64
|
+
expect(summarizeToolForTitle('Skill', input)).toBe('Skill: gen calendar event')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('Skill: when no skill-name and no command, surfaces the first scalar arg (#1790)', () => {
|
|
68
|
+
const input = JSON.stringify({ unrelated: 'x' })
|
|
69
|
+
expect(summarizeToolForTitle('Skill', input)).toBe('Skill (unrelated: x)')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('Skill: skips routing-only keys when surfacing first scalar arg (#1790)', () => {
|
|
73
|
+
// chat_id / message_thread_id / request_id never help an operator
|
|
74
|
+
// decide; the helper skips them and finds the next useful field.
|
|
75
|
+
const input = JSON.stringify({
|
|
76
|
+
chat_id: '12345',
|
|
77
|
+
message_thread_id: '42',
|
|
78
|
+
topic: 'morning summary',
|
|
79
|
+
})
|
|
80
|
+
expect(summarizeToolForTitle('Skill', input)).toBe('Skill (topic: morning summary)')
|
|
81
|
+
})
|
|
82
|
+
|
|
58
83
|
test('Bash: collapses internal whitespace before truncating', () => {
|
|
59
84
|
const input = JSON.stringify({
|
|
60
85
|
command: 'echo \t hello\nworld',
|
|
@@ -134,4 +159,123 @@ describe('summarizeToolForTitle (#186)', () => {
|
|
|
134
159
|
test('MCP malformed: bare mcp__ prefix without __<server>__<verb> shape is left alone', () => {
|
|
135
160
|
expect(summarizeToolForTitle('mcp__bad', undefined)).toBe('mcp__bad')
|
|
136
161
|
})
|
|
162
|
+
|
|
163
|
+
// #1790 — append a `(key: value)` hint when an MCP tool's preview
|
|
164
|
+
// carries a scalar arg. Gives operators context on curated and
|
|
165
|
+
// uncurated MCP tools alike without an expand tap.
|
|
166
|
+
test('MCP curated tool appends first-arg hint when input_preview present (#1790)', () => {
|
|
167
|
+
const input = JSON.stringify({ key: 'coolify/api-token' })
|
|
168
|
+
expect(summarizeToolForTitle('mcp__agent-config__config_get', input)).toBe(
|
|
169
|
+
'Read its own merged config (key: coolify/api-token)',
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('MCP uncurated tool appends first-arg hint (#1790)', () => {
|
|
174
|
+
const input = JSON.stringify({ folder_id: 'abc123' })
|
|
175
|
+
expect(summarizeToolForTitle('mcp__google-workspace__list_files', input)).toBe(
|
|
176
|
+
'google-workspace: list files (folder_id: abc123)',
|
|
177
|
+
)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('MCP arg hint skips routing-only keys (#1790)', () => {
|
|
181
|
+
const input = JSON.stringify({ chat_id: '12345', query: 'budget Q3' })
|
|
182
|
+
expect(summarizeToolForTitle('mcp__hindsight__recall', input)).toBe(
|
|
183
|
+
'Recall relevant memories (query: budget Q3)',
|
|
184
|
+
)
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
189
|
+
// #1790 — formatPermissionCardBody: multi-line collapsed-view body
|
|
190
|
+
// for approval cards. Mirrors the vault_request_access card layout.
|
|
191
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
describe('formatPermissionCardBody (#1790)', () => {
|
|
194
|
+
test('renders agent · summary, then a why-line, when both are present', () => {
|
|
195
|
+
const body = formatPermissionCardBody({
|
|
196
|
+
toolName: 'Skill',
|
|
197
|
+
inputPreview: JSON.stringify({ skill: 'mail' }),
|
|
198
|
+
description: 'Compose the morning brief',
|
|
199
|
+
agentName: 'clerk',
|
|
200
|
+
})
|
|
201
|
+
expect(body).toBe(
|
|
202
|
+
[
|
|
203
|
+
'🔐 <b>clerk</b> · Skill (mail)',
|
|
204
|
+
'why: <i>Compose the morning brief</i>',
|
|
205
|
+
].join('\n'),
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('renders "why: <i>not provided</i>" when description is missing', () => {
|
|
210
|
+
const body = formatPermissionCardBody({
|
|
211
|
+
toolName: 'Bash',
|
|
212
|
+
inputPreview: JSON.stringify({ command: 'ls /tmp' }),
|
|
213
|
+
description: undefined,
|
|
214
|
+
agentName: 'gymbro',
|
|
215
|
+
})
|
|
216
|
+
expect(body).toBe(
|
|
217
|
+
['🔐 <b>gymbro</b> · Bash: ls /tmp', 'why: <i>not provided</i>'].join('\n'),
|
|
218
|
+
)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('renders "not provided" when description is whitespace-only', () => {
|
|
222
|
+
const body = formatPermissionCardBody({
|
|
223
|
+
toolName: 'Bash',
|
|
224
|
+
inputPreview: JSON.stringify({ command: 'ls /tmp' }),
|
|
225
|
+
description: ' \n ',
|
|
226
|
+
agentName: 'gymbro',
|
|
227
|
+
})
|
|
228
|
+
expect(body).toContain('why: <i>not provided</i>')
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('drops the agent prefix when agentName is null (early-boot edge)', () => {
|
|
232
|
+
const body = formatPermissionCardBody({
|
|
233
|
+
toolName: 'Skill',
|
|
234
|
+
inputPreview: JSON.stringify({ skill: 'mail' }),
|
|
235
|
+
description: 'do the thing',
|
|
236
|
+
agentName: null,
|
|
237
|
+
})
|
|
238
|
+
expect(body).toBe(['🔐 Skill (mail)', 'why: <i>do the thing</i>'].join('\n'))
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('HTML-escapes < > & in agentName / summary / description', () => {
|
|
242
|
+
const body = formatPermissionCardBody({
|
|
243
|
+
toolName: 'Bash',
|
|
244
|
+
inputPreview: JSON.stringify({ command: 'echo "a < b && c > d"' }),
|
|
245
|
+
description: 'compare a < b & c > d',
|
|
246
|
+
agentName: 'agent<test>',
|
|
247
|
+
})
|
|
248
|
+
expect(body).toContain('<test>')
|
|
249
|
+
expect(body).toContain('&')
|
|
250
|
+
expect(body).not.toContain('<test>')
|
|
251
|
+
// The literal "<i>not provided</i>" and "<b>...</b>" wrapping tags
|
|
252
|
+
// around legitimate fields must survive untouched — only the
|
|
253
|
+
// user-supplied content is escaped.
|
|
254
|
+
expect(body).toContain('<b>')
|
|
255
|
+
expect(body).toContain('<i>')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('truncates a very long description with an ellipsis', () => {
|
|
259
|
+
const longWhy = 'x'.repeat(500)
|
|
260
|
+
const body = formatPermissionCardBody({
|
|
261
|
+
toolName: 'Skill',
|
|
262
|
+
inputPreview: JSON.stringify({ skill: 'mail' }),
|
|
263
|
+
description: longWhy,
|
|
264
|
+
agentName: 'clerk',
|
|
265
|
+
})
|
|
266
|
+
// 240-char ceiling + trailing ellipsis
|
|
267
|
+
expect(body).toContain('xxxx…</i>')
|
|
268
|
+
// First line still intact
|
|
269
|
+
expect(body.split('\n')[0]).toBe('🔐 <b>clerk</b> · Skill (mail)')
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('collapses internal whitespace in description so the layout stays one-line', () => {
|
|
273
|
+
const body = formatPermissionCardBody({
|
|
274
|
+
toolName: 'Skill',
|
|
275
|
+
inputPreview: JSON.stringify({ skill: 'mail' }),
|
|
276
|
+
description: 'first\n\nsecond\t\t paragraph',
|
|
277
|
+
agentName: 'clerk',
|
|
278
|
+
})
|
|
279
|
+
expect(body).toContain('why: <i>first second paragraph</i>')
|
|
280
|
+
})
|
|
137
281
|
})
|
|
@@ -138,10 +138,43 @@ describe('formatPriorAssistantPreview', () => {
|
|
|
138
138
|
expect(formatPriorAssistantPreview('a & b < c')).toBe('a & b < c')
|
|
139
139
|
})
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
141
|
+
// ─── #1791 — decode entities before re-escape ───────────────────────────
|
|
142
|
+
// Pre-fix this function did NOT decode, so an already-encoded source
|
|
143
|
+
// (e.g. the rendered HTML stored in history) was re-escaped on top of
|
|
144
|
+
// its own encoding. The model saw `&amp;lt;bar&amp;gt;` (triple
|
|
145
|
+
// encoded) instead of `<bar>`. Decoding before the trim/re-escape pass
|
|
146
|
+
// closes that loop; the attribute boundary stays safe because
|
|
147
|
+
// escapeXmlAttribute runs unchanged at the tail.
|
|
148
|
+
|
|
149
|
+
test('decodes & before re-escape (single-pass, not triple) — #1791', () => {
|
|
150
|
+
// Source stored in history as escaped HTML: `a & b`.
|
|
151
|
+
// Pre-fix output: `a &amp; b`. Post-fix: `a & b` (single).
|
|
152
|
+
expect(formatPriorAssistantPreview('a & b')).toBe('a & b')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('decodes < / > inside stripped tags — #1791', () => {
|
|
156
|
+
// The classic #1120 case: model wrote `Path: \`/tmp/foo-<bar>/\``,
|
|
157
|
+
// markdownToHtml stored it as `<code>/tmp/foo-<bar>/</code>`,
|
|
158
|
+
// strip removes the <code> tags, decode brings back the angle
|
|
159
|
+
// brackets, escape re-encodes safely for the attribute.
|
|
160
|
+
expect(formatPriorAssistantPreview('<code>/tmp/foo-<bar>/</code>'))
|
|
161
|
+
.toBe('/tmp/foo-<bar>/')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('decodes " / ' / — #1791', () => {
|
|
165
|
+
expect(formatPriorAssistantPreview('say "hi"')).toBe('say "hi"')
|
|
166
|
+
expect(formatPriorAssistantPreview('it's here')).toBe("it's here")
|
|
167
|
+
expect(formatPriorAssistantPreview('a b')).toBe('a b')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('does not over-decode: bare `&lt;` decodes to `<`, not `<` — #1791', () => {
|
|
171
|
+
// The decode order (< / > / " / ' / first, then
|
|
172
|
+
// &) ensures a single pass doesn't strip two layers of escape.
|
|
173
|
+
// A literal `&lt;` in source (i.e. someone deliberately encoded
|
|
174
|
+
// the word "<") becomes `<` after one decode pass, and then
|
|
175
|
+
// re-escapes back to `&lt;`. Pin this so the order isn't accidentally
|
|
176
|
+
// flipped to a re-decode loop.
|
|
177
|
+
expect(formatPriorAssistantPreview('&lt;')).toBe('&lt;')
|
|
145
178
|
})
|
|
146
179
|
|
|
147
180
|
test('empty string returns empty', () => {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UAT — pending-progress edit preserves HTML formatting (#1698 regression gate).
|
|
3
|
+
*
|
|
4
|
+
* Promoted from the one-off `pr1706-pending-progress-html-dm.test.ts`
|
|
5
|
+
* verification scenario per #1793. The pending-progress / silent-anchor
|
|
6
|
+
* / answer-stream code family in `telegram-plugin/` all touch the
|
|
7
|
+
* parse_mode contract on cross-turn edits; the existing UAT suite
|
|
8
|
+
* (`cross-turn-pending-progress-dm.test.ts`, `jtbd-fast-trivial-dm.test.ts`,
|
|
9
|
+
* `jtbd-soft-commit-dm.test.ts`) covers cadence / round-trip / pacing
|
|
10
|
+
* but does NOT pin the parse_mode contract. #1698 shipped to prod and
|
|
11
|
+
* the existing suite went green throughout — this scenario closes that
|
|
12
|
+
* blind spot.
|
|
13
|
+
*
|
|
14
|
+
* Method:
|
|
15
|
+
* 1. Ask the agent to send ONE reply with both <b> and <code> via
|
|
16
|
+
* the reply tool (default html format).
|
|
17
|
+
* 2. Dispatch a background bash so the turn ends with pending async.
|
|
18
|
+
* 3. End turn. Pending-progress activates.
|
|
19
|
+
* 4. After ~60-90s, observe the first edit. Assert text reads back
|
|
20
|
+
* WITHOUT literal `<b>` / `<code>` substrings (Telegram parsed
|
|
21
|
+
* under HTML, formatting moved to entities, mtcute Message.text
|
|
22
|
+
* returns plain prose). Pre-fix, parse_mode was dropped on the
|
|
23
|
+
* edit and the tags would render as literal characters.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from "vitest";
|
|
27
|
+
import { spinUp, type ObservedMessage } from "../harness.js";
|
|
28
|
+
|
|
29
|
+
const SLEEP_SECONDS = 90;
|
|
30
|
+
const OVERALL_DEADLINE_MS = 4 * 60_000;
|
|
31
|
+
|
|
32
|
+
const PROMPT =
|
|
33
|
+
`Please run \`sleep ${SLEEP_SECONDS}\` in the background using the ` +
|
|
34
|
+
`Bash tool with \`run_in_background: true\`. Send exactly ONE reply, ` +
|
|
35
|
+
`using the reply tool with default html format, containing this text ` +
|
|
36
|
+
`VERBATIM:\n\n` +
|
|
37
|
+
`<b>Worker dispatched.</b> Running <code>sleep ${SLEEP_SECONDS}</code> ` +
|
|
38
|
+
`in background.\n\n` +
|
|
39
|
+
`Do NOT send any other reply until the sleep finishes. Just dispatch ` +
|
|
40
|
+
`the bash, send that one HTML reply, end your turn. When it finishes ` +
|
|
41
|
+
`much later, reply with the single word "done".`;
|
|
42
|
+
|
|
43
|
+
const SUFFIX_RE = /\n\n— still working \(\d+m\)$/;
|
|
44
|
+
|
|
45
|
+
describe("uat: pending-progress edit preserves HTML formatting (#1698 regression gate)", () => {
|
|
46
|
+
it(
|
|
47
|
+
"first pending-progress edit reads back WITHOUT literal HTML tags",
|
|
48
|
+
async () => {
|
|
49
|
+
const sc = await spinUp({ agent: "test-harness" });
|
|
50
|
+
try {
|
|
51
|
+
const startedAt = Date.now();
|
|
52
|
+
await sc.sendDM(PROMPT);
|
|
53
|
+
|
|
54
|
+
let anchorMsgId: number | null = null;
|
|
55
|
+
let editText: string | null = null;
|
|
56
|
+
const deadline = startedAt + OVERALL_DEADLINE_MS;
|
|
57
|
+
|
|
58
|
+
while (Date.now() < deadline) {
|
|
59
|
+
try {
|
|
60
|
+
const msg = await sc.expectMessage(
|
|
61
|
+
(m: ObservedMessage) => m.fromBot,
|
|
62
|
+
{ from: "bot", timeout: deadline - Date.now() },
|
|
63
|
+
);
|
|
64
|
+
const rel = Date.now() - startedAt;
|
|
65
|
+
console.log(
|
|
66
|
+
`[jtbd-pending-progress-html] +${(rel / 1000).toFixed(1)}s ` +
|
|
67
|
+
`${msg.edited ? "EDIT" : "FRESH"} msg=${msg.messageId} ` +
|
|
68
|
+
`${JSON.stringify(msg.text.slice(0, 120))}`,
|
|
69
|
+
);
|
|
70
|
+
if (!msg.edited && anchorMsgId == null) {
|
|
71
|
+
anchorMsgId = msg.messageId;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (
|
|
75
|
+
msg.edited &&
|
|
76
|
+
anchorMsgId === msg.messageId &&
|
|
77
|
+
SUFFIX_RE.test(msg.text)
|
|
78
|
+
) {
|
|
79
|
+
editText = msg.text;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
expect(
|
|
88
|
+
anchorMsgId,
|
|
89
|
+
"agent never sent its initial HTML reply — UAT env issue",
|
|
90
|
+
).not.toBeNull();
|
|
91
|
+
expect(
|
|
92
|
+
editText,
|
|
93
|
+
`no pending-progress edit observed within ${OVERALL_DEADLINE_MS / 1000}s — ` +
|
|
94
|
+
`model may not have dispatched async, or pending-progress is disabled`,
|
|
95
|
+
).not.toBeNull();
|
|
96
|
+
|
|
97
|
+
// ── THE #1698 REGRESSION GATE ─────────────────────────────────
|
|
98
|
+
// mtcute's Message.text returns the parsed text — formatting
|
|
99
|
+
// lives in `entities`. So a working parse_mode=HTML edit shows
|
|
100
|
+
// clean prose with no literal "<b>" / "<code>" substrings.
|
|
101
|
+
// Pre-fix the gateway dropped parse_mode on the cross-turn
|
|
102
|
+
// edit and Telegram stored the tags as plain characters.
|
|
103
|
+
expect(
|
|
104
|
+
editText,
|
|
105
|
+
`#1698 regression: pending-progress edit text contains literal "<b>" — ` +
|
|
106
|
+
`parse_mode was dropped and Telegram is storing the original HTML tags as plain.`,
|
|
107
|
+
).not.toContain("<b>");
|
|
108
|
+
expect(editText).not.toContain("</b>");
|
|
109
|
+
expect(editText).not.toContain("<code>");
|
|
110
|
+
expect(editText).not.toContain("</code>");
|
|
111
|
+
|
|
112
|
+
// Sanity — the model's prose is still visible (without tags).
|
|
113
|
+
expect(editText).toContain("Worker dispatched");
|
|
114
|
+
|
|
115
|
+
// Belt-and-braces — the suffix landed (proves the edit was
|
|
116
|
+
// pending-progress and not some other path).
|
|
117
|
+
expect(editText).toMatch(SUFFIX_RE);
|
|
118
|
+
} finally {
|
|
119
|
+
await sc.tearDown();
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
OVERALL_DEADLINE_MS + 30_000,
|
|
123
|
+
);
|
|
124
|
+
});
|