From 5698a6b24f7c7b94f3907db61cf8b40021baaf05 Mon Sep 17 00:00:00 2001 From: gimso2x Date: Wed, 5 Aug 2026 23:05:57 +0000 Subject: [PATCH 1/2] feat(telegram): add inline keyboard to /session_recent, accept plain_dir in topic adoption - /session_recent: inline keyboard with switch_inline_query_current_chat buttons inserts /session_resume into chat input, no callback polling needed - topic adoption direct path input: plain_dir instead of existing_path missing directories are created by the lifecycle orchestrator - combined double .map() over recent.entries into single for-loop pass Rejected: multibyte-safe title slice | cosmetic, Telegram enforces 1-256 char limit Confidence: high Scope-risk: narrow Reversibility: clean Tested: bun test (558 pass, 0 fail) Not-tested: runtime end-to-end Telegram API integration --- .../src/sdk/bus/telegram-daemon.ts | 60 +++++++++++++++---- ...ications-lifecycle-command-routing.test.ts | 21 +++++-- .../notifications-telegram-daemon.test.ts | 44 ++++++++------ 3 files changed, 88 insertions(+), 37 deletions(-) diff --git a/packages/coding-agent/src/sdk/bus/telegram-daemon.ts b/packages/coding-agent/src/sdk/bus/telegram-daemon.ts index c698783e50..acdefd4ac6 100644 --- a/packages/coding-agent/src/sdk/bus/telegram-daemon.ts +++ b/packages/coding-agent/src/sdk/bus/telegram-daemon.ts @@ -5450,15 +5450,47 @@ export class TelegramNotificationDaemon { includeInternal: false, allWorkspaces: true, }); - const body = - recent.kind === "error" - ? `Recent sessions could not be verified: ${recent.message}` - : recent.entries.length - ? recent.entries.map(e => `• ${code(e.sessionId)}${e.path ? ` (${code(e.path)})` : ""}`).join("\n") - : "No recent sessions."; - await replyHtml( - recent.kind === "complete" && recent.warnings.length ? `${body}\n\n${recent.warnings.join("\n")}` : body, - ); + if (recent.kind === "error") { + await reply(`Recent sessions could not be verified: ${recent.message}`); + return true; + } + if (!recent.entries.length) { + await reply("No recent sessions."); + return true; + } + const rows: Array<{ line: string; btn: { text: string; switch_inline_query_current_chat: string } }> = []; + for (let i = 0; i < recent.entries.length; i++) { + const e = recent.entries[i]!; + const num = i + 1; + const sid = e.sessionId.slice(0, 12); + rows.push({ + line: `${num}. ${code(sid)} ${e.title ? code(e.title.slice(0, 60)) : ""}${e.path ? `\n ${code(e.path)}` : ""}`, + btn: { + text: `${num}. ${e.title?.slice(0, 35) || sid}`, + switch_inline_query_current_chat: `/session_resume ${sid}`, + }, + }); + } + const lines = rows.map(r => r.line); + const inline_keyboard = rows.map(r => [r.btn]); + const header = + recent.warnings.length ? + `Recent GJC sessions\n${recent.warnings.map(w => `⚠️ ${w}`).join("\n")}\n` + : "Recent GJC sessions\n"; + const body = header + lines.join("\n"); + + const chunks = splitTelegramHtml(body); + for (let i = 0; i < chunks.length; i++) { + await this.botApi + .call("sendMessage", { + chat_id: this.opts.chatId, + ...(threadId !== undefined ? { message_thread_id: threadId } : {}), + text: chunks[i]!, + parse_mode: TELEGRAM_PARSE_MODE, + ...(i === chunks.length - 1 ? { reply_markup: { inline_keyboard } } : {}), + }) + .catch(() => undefined); + } return true; } @@ -11162,7 +11194,7 @@ export class TelegramNotificationDaemon { } const normalized = text ? normalizeLifecyclePath(text) : undefined; - const target = normalized ? ({ kind: "existing_path", path: normalized } as const) : undefined; + const target = normalized ? ({ kind: "plain_dir", path: normalized } as const) : undefined; if (!target || !validateLifecycleTarget("session_create", target).ok) { await this.rememberSeenUpdateId(updateId); await this.botApi @@ -11230,7 +11262,7 @@ export class TelegramNotificationDaemon { }); return true; } - if (parsed.target.kind !== "existing_path") { + if (parsed.target.kind !== "existing_path" && parsed.target.kind !== "plain_dir") { await this.rememberSeenUpdateId(updateId); await this.botApi .call("sendMessage", { @@ -11306,8 +11338,10 @@ export class TelegramNotificationDaemon { return; } const normalizedPath = target.kind === "existing_path" ? normalizeLifecyclePath(target.path) : undefined; - let targetExists = false; - if (normalizedPath) { + // plain_dir creates missing directories, so only validate existence for existing_path. + const needsExistenceCheck = target.kind === "existing_path"; + let targetExists = !needsExistenceCheck; + if (normalizedPath && needsExistenceCheck) { try { const stat = await this.fsImpl.stat?.(normalizedPath); targetExists = stat?.isDirectory?.() === true; diff --git a/packages/coding-agent/test/notifications-lifecycle-command-routing.test.ts b/packages/coding-agent/test/notifications-lifecycle-command-routing.test.ts index 8f97bb1592..667ace43da 100644 --- a/packages/coding-agent/test/notifications-lifecycle-command-routing.test.ts +++ b/packages/coding-agent/test/notifications-lifecycle-command-routing.test.ts @@ -120,7 +120,7 @@ describe("lifecycle command routing (G009)", () => { expect(calls.filter(c => c.method === "sendMessage").length).toBe(0); fs.rmSync(agentDir, { recursive: true, force: true }); }); - test("/session_recent is sent as escaped bullet rows with inline code", async () => { + test("/session_recent sends numbered entries with inline keyboard buttons", async () => { const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-lc-route-")); const { calls, api } = spyBot(); const daemon = makeDaemon(agentDir, api); @@ -138,13 +138,22 @@ describe("lifecycle command routing (G009)", () => { expect(sends.length).toBe(1); expect(sends.every(c => c.body?.parse_mode === TELEGRAM_PARSE_MODE)).toBe(true); expect(sends.every(c => String(c.body?.text).length <= 4096)).toBe(true); - const text = sends.map(c => String(c.body?.text)).join(""); + const text = String(sends[0]!.body?.text ?? ""); expect(text).not.toContain("
");
 		expect(text).toContain("s-019");
-		expect(text).toContain(
-			`${cwd.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")}`,
-		);
-		expect(Array.from(text.matchAll(/^• s-\d{3}<\/code> \(.*<\/code>\)$/gm))).toHaveLength(10);
+		expect(text).toContain("Recent GJC sessions");
+		expect(text.match(/^\d+\. s-\d{3}<\/code>/m)).not.toBeNull();
+
+		// Inline keyboard
+		const markup = sends[0]!.body?.reply_markup as Record | undefined;
+		expect(markup).not.toBeUndefined();
+		const kb = markup!.inline_keyboard as Array>>;
+		expect(kb.length).toBe(10);
+		for (const row of kb) {
+			expect(row.length).toBe(1);
+			expect(row[0]!.switch_inline_query_current_chat).toMatch(/^\/session_resume s-\d{3}$/);
+		}
+
 		cwdSpy.mockRestore();
 		fs.rmSync(agentDir, { recursive: true, force: true });
 	});
diff --git a/packages/coding-agent/test/notifications-telegram-daemon.test.ts b/packages/coding-agent/test/notifications-telegram-daemon.test.ts
index 7eab126222..ab2b3ed894 100644
--- a/packages/coding-agent/test/notifications-telegram-daemon.test.ts
+++ b/packages/coding-agent/test/notifications-telegram-daemon.test.ts
@@ -21846,7 +21846,7 @@ describe("forum_topic_created user-topic adoption", () => {
 
 		expect(frames).toHaveLength(1);
 		expect((frames[0] as { target?: unknown }).target).toEqual({
-			kind: "existing_path",
+			kind: "plain_dir",
 			path: workspace,
 		});
 	});
@@ -21896,30 +21896,38 @@ describe("forum_topic_created user-topic adoption", () => {
 			),
 		).toBe(true);
 	});
-	test("pending topics reject dir and worktree creates instead of spawning duplicate topics", async () => {
+	test("pending topics accept dir, reject worktree creates", async () => {
 		const { daemon, bot, frames } = await adoptionLifecycleHarness();
 		await authorizePendingTopic(daemon, 58, 998);
-		for (const [updateId, text] of [
-			[59, "/session_create dir /tmp/new-session"],
-			[60, "/session_create worktree /tmp/repo feature"],
-		] as const) {
-			await daemon.handleTelegramUpdate({
-				update_id: updateId,
-				message: {
-					chat: { id: 42 },
-					from: { id: 42, is_bot: false },
-					message_thread_id: 998,
-					text,
-				},
-			});
-		}
-		expect(frames).toHaveLength(0);
+		await daemon.handleTelegramUpdate({
+			update_id: 59,
+			message: {
+				chat: { id: 42 },
+				from: { id: 42, is_bot: false },
+				message_thread_id: 998,
+				text: "/session_create dir /tmp/new-session",
+			},
+		});
+		await daemon.handleTelegramUpdate({
+			update_id: 60,
+			message: {
+				chat: { id: 42 },
+				from: { id: 42, is_bot: false },
+				message_thread_id: 998,
+				text: "/session_create worktree /tmp/repo feature",
+			},
+		});
+		expect(frames).toHaveLength(1);
+		expect((frames[0] as { target?: unknown }).target).toEqual({
+			kind: "plain_dir",
+			path: "/tmp/new-session",
+		});
 		expect(
 			bot.calls.filter(
 				call =>
 					call.method === "sendMessage" && String(call.body.text).includes("only with /session_create path "),
 			),
-		).toHaveLength(2);
+		).toHaveLength(1);
 	});
 	test("direct adoption rejects forged senders and bot messages", async () => {
 		const agentDir = tempAgentDir();

From 4236fb71c62506db432a592ac58134a3b5b67df4 Mon Sep 17 00:00:00 2001
From: gimso2x 
Date: Wed, 5 Aug 2026 23:46:34 +0000
Subject: [PATCH 2/2] fix(telegram): drop dead replyHtml, accept plain_dir
 adoption targets

- /session_recent rewrite left replyHtml unused; biome check failed
- TelegramAdoptionTarget and persisted-intent guard now accept plain_dir
  so topic-adoption direct paths survive daemon restart
---
 .../src/sdk/bus/telegram-adoption-intent.ts   |  8 +++++--
 .../src/sdk/bus/telegram-daemon.ts            | 22 +++----------------
 2 files changed, 9 insertions(+), 21 deletions(-)

diff --git a/packages/coding-agent/src/sdk/bus/telegram-adoption-intent.ts b/packages/coding-agent/src/sdk/bus/telegram-adoption-intent.ts
index cd9ee8cce4..4a7aacd056 100644
--- a/packages/coding-agent/src/sdk/bus/telegram-adoption-intent.ts
+++ b/packages/coding-agent/src/sdk/bus/telegram-adoption-intent.ts
@@ -30,7 +30,7 @@ import type { SessionCreateTarget } from "./index";
 
 /** Persisted document version. */
 export const TELEGRAM_ADOPTION_INTENT_VERSION = 1;
-export type TelegramAdoptionTarget = Extract;
+export type TelegramAdoptionTarget = Extract;
 /** Default intent TTL (configurable at write time). Plan starts at 10 minutes. */
 export const DEFAULT_ADOPTION_INTENT_TTL_MS = 10 * 60 * 1000;
 /** Per-intent filename prefix/suffix under the notifications dir. */
@@ -104,7 +104,11 @@ function isRecord(value: unknown): value is Record {
 }
 
 function isSessionCreateTarget(value: unknown): value is TelegramAdoptionTarget {
-	return isRecord(value) && value.kind === "existing_path" && typeof value.path === "string";
+	return (
+		isRecord(value) &&
+		typeof value.path === "string" &&
+		(value.kind === "existing_path" || value.kind === "plain_dir")
+	);
 }
 
 function isPersistedIntent(value: unknown): value is PersistedIntent {
diff --git a/packages/coding-agent/src/sdk/bus/telegram-daemon.ts b/packages/coding-agent/src/sdk/bus/telegram-daemon.ts
index acdefd4ac6..66552f0368 100644
--- a/packages/coding-agent/src/sdk/bus/telegram-daemon.ts
+++ b/packages/coding-agent/src/sdk/bus/telegram-daemon.ts
@@ -5413,21 +5413,6 @@ export class TelegramNotificationDaemon {
 					.catch(() => undefined);
 			}
 		};
-		const replyHtml = async (body: string): Promise => {
-			for (const text of splitTelegramHtml(body)) {
-				await this.botApi
-					.call("sendMessage", {
-						chat_id: this.opts.chatId,
-						...(threadId !== undefined ? { message_thread_id: threadId } : {}),
-						text,
-						parse_mode: TELEGRAM_PARSE_MODE,
-						...(telegramDisableNotification(this.opts.sound, "finalized") === true
-							? { disable_notification: true }
-							: {}),
-					})
-					.catch(() => undefined);
-			}
-		};
 
 		const parsed = parseLifecycleCommand(text, commandCtx);
 		if (parsed.kind === "none") return false;
@@ -5473,10 +5458,9 @@ export class TelegramNotificationDaemon {
 			}
 			const lines = rows.map(r => r.line);
 			const inline_keyboard = rows.map(r => [r.btn]);
-			const header =
-				recent.warnings.length ?
-					`Recent GJC sessions\n${recent.warnings.map(w => `⚠️ ${w}`).join("\n")}\n`
-				:	"Recent GJC sessions\n";
+			const header = recent.warnings.length
+				? `Recent GJC sessions\n${recent.warnings.map(w => `⚠️ ${w}`).join("\n")}\n`
+				: "Recent GJC sessions\n";
 			const body = header + lines.join("\n");
 
 			const chunks = splitTelegramHtml(body);