Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/coding-agent/src/sdk/bus/telegram-adoption-intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import type { SessionCreateTarget } from "./index";

/** Persisted document version. */
export const TELEGRAM_ADOPTION_INTENT_VERSION = 1;
export type TelegramAdoptionTarget = Extract<SessionCreateTarget, { kind: "existing_path" }>;
export type TelegramAdoptionTarget = Extract<SessionCreateTarget, { kind: "existing_path" | "plain_dir" }>;
/** 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. */
Expand Down Expand Up @@ -104,7 +104,11 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}

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 {
Expand Down
74 changes: 46 additions & 28 deletions packages/coding-agent/src/sdk/bus/telegram-daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5413,21 +5413,6 @@ export class TelegramNotificationDaemon {
.catch(() => undefined);
}
};
const replyHtml = async (body: string): Promise<void> => {
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;
Expand All @@ -5450,15 +5435,46 @@ 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
? `<b>Recent GJC sessions</b>\n${recent.warnings.map(w => `⚠️ ${w}`).join("\n")}\n`
: "<b>Recent GJC sessions</b>\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;
}

Expand Down Expand Up @@ -11162,7 +11178,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
Expand Down Expand Up @@ -11230,7 +11246,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", {
Expand Down Expand Up @@ -11306,8 +11322,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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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("<pre>");
expect(text).toContain("<code>s-019</code>");
expect(text).toContain(
`<code>${cwd.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")}</code>`,
);
expect(Array.from(text.matchAll(/^• <code>s-\d{3}<\/code> \(<code>.*<\/code>\)$/gm))).toHaveLength(10);
expect(text).toContain("<b>Recent GJC sessions</b>");
expect(text.match(/^\d+\. <code>s-\d{3}<\/code>/m)).not.toBeNull();

// Inline keyboard
const markup = sends[0]!.body?.reply_markup as Record<string, unknown> | undefined;
expect(markup).not.toBeUndefined();
const kb = markup!.inline_keyboard as Array<Array<Record<string, string>>>;
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 });
});
Expand Down
44 changes: 26 additions & 18 deletions packages/coding-agent/test/notifications-telegram-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
Expand Down Expand Up @@ -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 <dir>"),
),
).toHaveLength(2);
).toHaveLength(1);
});
test("direct adoption rejects forged senders and bot messages", async () => {
const agentDir = tempAgentDir();
Expand Down
Loading