fix(cron): support Telegram thread IDs in cron add/edit

- Add `--thread-id` support to cron add/edit Telegram delivery.
- Reject non-positive thread IDs and guard cron edit lookup pagination against non-progress/max-page loops.
- Preserve existing delivery mode on thread-only cron edit patches.

Carries forward #51581, #60373, and #60890.

Co-authored-by: ChunHao Chen <crazycjh@gmail.com>
This commit is contained in:
Vincent Koc
2026-04-28 01:50:44 -07:00
committed by GitHub
parent 02908db62b
commit 76cd97289b
6 changed files with 334 additions and 12 deletions

View File

@@ -67,6 +67,7 @@ type CronUpdatePatch = {
mode?: string;
channel?: string;
to?: string;
threadId?: number;
accountId?: string;
bestEffort?: boolean;
};
@@ -81,7 +82,13 @@ type CronAddParams = {
lightContext?: boolean;
toolsAllow?: string[];
};
delivery?: { mode?: string; accountId?: string };
delivery?: {
mode?: string;
channel?: string;
to?: string;
threadId?: number;
accountId?: string;
};
deleteAfterRun?: boolean;
agentId?: string;
sessionTarget?: string;
@@ -379,6 +386,32 @@ describe("cron cli", () => {
expect(params?.delivery?.accountId).toBe("coordinator");
});
it("includes --thread-id on Telegram cron add delivery", async () => {
const params = await runCronAddAndGetParams([
"--name",
"telegram topic add",
"--cron",
"* * * * *",
"--session",
"SESSION:agent:ops:telegram:group:-100123:topic:42",
"--message",
"hello",
"--deliver",
"--channel",
"telegram",
"--to",
"-100123",
"--thread-id",
" 42 ",
]);
expect(params?.sessionTarget).toBe("session:agent:ops:telegram:group:-100123:topic:42");
expect(params?.delivery?.mode).toBe("announce");
expect(params?.delivery?.channel).toBe("telegram");
expect(params?.delivery?.to).toBe("-100123");
expect(params?.delivery?.threadId).toBe(42);
});
it("rejects --account on non-isolated/systemEvent cron add", async () => {
await expectCronCommandExit([
"cron",
@@ -396,6 +429,40 @@ describe("cron cli", () => {
]);
});
it("rejects invalid --thread-id on cron add", async () => {
await expectCronCommandExit([
"cron",
"add",
"--name",
"invalid topic add",
"--cron",
"* * * * *",
"--session",
"isolated",
"--message",
"hello",
"--thread-id",
"topic-42",
]);
});
it("rejects negative --thread-id on cron add", async () => {
await expectCronCommandExit([
"cron",
"add",
"--name",
"invalid negative topic add",
"--cron",
"* * * * *",
"--session",
"isolated",
"--message",
"hello",
"--thread-id",
"-5",
]);
});
it.each([
{ command: "enable" as const, expectedEnabled: true },
{ command: "disable" as const, expectedEnabled: false },
@@ -593,6 +660,47 @@ describe("cron cli", () => {
expect(patch?.patch?.payload?.message).toBeUndefined();
});
it("updates Telegram thread id without requiring --message on cron edit", async () => {
const patch = await runCronEditAndGetPatch([
"--deliver",
"--channel",
"telegram",
"--to",
"-100123",
"--thread-id",
"42",
]);
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.delivery?.mode).toBe("announce");
expect(patch?.patch?.delivery?.channel).toBe("telegram");
expect(patch?.patch?.delivery?.to).toBe("-100123");
expect(patch?.patch?.delivery?.threadId).toBe(42);
});
it("preserves existing delivery mode on thread-only cron edit patches", async () => {
const patch = await runCronEditAndGetPatch(["--thread-id", "42"]);
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
expect(patch?.patch?.delivery?.mode).toBeUndefined();
expect(patch?.patch?.delivery?.threadId).toBe(42);
});
it("normalizes case-insensitive custom session targets on cron edit", async () => {
await runCronCommand(["cron", "edit", "job-1", "--session", "SESSION:Project-Alpha"]);
const patch = getGatewayCallParams<{ patch?: { sessionTarget?: string } }>("cron.update");
expect(patch?.patch?.sessionTarget).toBe("session:Project-Alpha");
});
it("rejects invalid --thread-id on cron edit", async () => {
await expectCronCommandExit(["cron", "edit", "job-1", "--thread-id", "topic-42"]);
});
it("rejects negative --thread-id on cron edit", async () => {
await expectCronCommandExit(["cron", "edit", "job-1", "--thread-id", "-5"]);
});
it("supports --no-deliver on cron edit", async () => {
await runCronCommand(["cron", "edit", "job-1", "--no-deliver"]);
@@ -857,6 +965,120 @@ describe("cron cli", () => {
});
});
it("paginates cron edit existing-job schedule lookups", async () => {
resetGatewayMock();
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
if (method === "cron.status") {
return { enabled: true };
}
if (method === "cron.list") {
const offset = (params as { offset?: number }).offset ?? 0;
if (offset === 0) {
return {
jobs: [
{
...createCronJob("first-page", "First Page"),
schedule: { kind: "cron", expr: "0 * * * *" },
},
],
hasMore: true,
nextOffset: 200,
};
}
return {
jobs: [
{
...createCronJob("job-1", "Target Job"),
schedule: { kind: "cron", expr: "0 */2 * * *", staggerMs: 300_000 },
},
],
hasMore: false,
nextOffset: null,
};
}
return { ok: true, params };
},
);
const program = buildProgram();
await program.parseAsync(["cron", "edit", "job-1", "--exact"], { from: "user" });
const listParams = callGatewayFromCli.mock.calls
.filter((call) => call[0] === "cron.list")
.map((call) => call[2]);
expect(listParams).toEqual([
{ includeDisabled: true, limit: 200, offset: 0 },
{ includeDisabled: true, limit: 200, offset: 200 },
]);
const patch = getGatewayCallParams<CronUpdatePatch>("cron.update");
expect(patch?.patch?.schedule).toEqual({
kind: "cron",
expr: "0 */2 * * *",
staggerMs: 0,
});
});
it("rejects non-advancing cron edit lookup pagination", async () => {
resetGatewayMock();
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
if (method === "cron.status") {
return { enabled: true };
}
if (method === "cron.list") {
return {
jobs: [],
hasMore: true,
nextOffset: (params as { offset?: number }).offset ?? 0,
};
}
return { ok: true, params };
},
);
const program = buildProgram();
await expect(
program.parseAsync(["cron", "edit", "job-1", "--exact"], { from: "user" }),
).rejects.toThrow("__exit__:1");
expect(defaultRuntime.error).toHaveBeenCalledWith(
expect.stringContaining("cron.list pagination did not advance"),
);
});
it("rejects excessive cron edit lookup pagination", async () => {
resetGatewayMock();
callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params?: unknown) => {
if (method === "cron.status") {
return { enabled: true };
}
if (method === "cron.list") {
const offset = (params as { offset?: number }).offset ?? 0;
return {
jobs: [],
hasMore: true,
nextOffset: offset + 200,
};
}
return { ok: true, params };
},
);
const program = buildProgram();
await expect(
program.parseAsync(["cron", "edit", "job-1", "--exact"], { from: "user" }),
).rejects.toThrow("__exit__:1");
const listCalls = callGatewayFromCli.mock.calls.filter((call) => call[0] === "cron.list");
expect(listCalls).toHaveLength(50);
expect(defaultRuntime.error).toHaveBeenCalledWith(
expect.stringContaining("cron.list pagination exceeded maximum pages"),
);
});
it("rejects --exact on edit when existing job is not cron", async () => {
await expectCronEditWithScheduleLookupExit({ kind: "every", everyMs: 60_000 }, ["--exact"]);
});