fix(cron): resolve accountId from agent bindings in isolated sessions

When an isolated cron session has no lastAccountId (e.g. first-run or
fresh session), the message tool receives an undefined accountId which
defaults to "default". In multi-account setups where accounts are named
(e.g. "willy", "betty"), this causes resolveTelegramToken() to fail
because accounts["default"] doesn't exist.

This change adds a fallback in resolveDeliveryTarget(): when the
session-derived accountId is undefined, look up the agent's bound
account from the bindings config using buildChannelAccountBindings().
This mirrors the same binding resolution used for inbound routing,
closing the gap between inbound and outbound account resolution.

Session-derived accountId still takes precedence when present.

Fixes #17889
Related: #12628, #16259

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
simonemacario
2026-02-16 19:01:54 +08:00
committed by Peter Steinberger
parent e9f2e6a829
commit 2ed43fd7b4
2 changed files with 150 additions and 3 deletions

View File

@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
vi.mock("../../config/sessions.js", () => ({
loadSessionStore: vi.fn().mockReturnValue({}),
resolveAgentMainSessionKey: vi.fn().mockReturnValue("agent:test:main"),
resolveStorePath: vi.fn().mockReturnValue("/tmp/test-store.json"),
}));
vi.mock("../../infra/outbound/channel-selection.js", () => ({
resolveMessageChannelSelection: vi.fn().mockResolvedValue({ channel: "telegram" }),
}));
import { loadSessionStore } from "../../config/sessions.js";
import { resolveDeliveryTarget } from "./delivery-target.js";
function makeCfg(overrides?: Partial<OpenClawConfig>): OpenClawConfig {
return {
bindings: [],
channels: {},
...overrides,
} as OpenClawConfig;
}
describe("resolveDeliveryTarget", () => {
it("falls back to bound accountId when session has no lastAccountId", async () => {
vi.mocked(loadSessionStore).mockReturnValue({});
const cfg = makeCfg({
bindings: [
{
agentId: "agent-b",
match: { channel: "telegram", accountId: "account-b" },
},
],
});
const result = await resolveDeliveryTarget(cfg, "agent-b", {
channel: "telegram",
to: "123456",
});
expect(result.accountId).toBe("account-b");
});
it("preserves session lastAccountId when present", async () => {
vi.mocked(loadSessionStore).mockReturnValue({
"agent:test:main": {
sessionId: "sess-1",
updatedAt: 1000,
lastChannel: "telegram",
lastTo: "123456",
lastAccountId: "session-account",
},
});
const cfg = makeCfg({
bindings: [
{
agentId: "agent-b",
match: { channel: "telegram", accountId: "account-b" },
},
],
});
const result = await resolveDeliveryTarget(cfg, "agent-b", {
channel: "telegram",
to: "123456",
});
// Session-derived accountId should take precedence over binding
expect(result.accountId).toBe("session-account");
});
it("returns undefined accountId when no binding and no session", async () => {
vi.mocked(loadSessionStore).mockReturnValue({});
const cfg = makeCfg({ bindings: [] });
const result = await resolveDeliveryTarget(cfg, "agent-b", {
channel: "telegram",
to: "123456",
});
expect(result.accountId).toBeUndefined();
});
it("selects correct binding when multiple agents have bindings", async () => {
vi.mocked(loadSessionStore).mockReturnValue({});
const cfg = makeCfg({
bindings: [
{
agentId: "agent-a",
match: { channel: "telegram", accountId: "account-a" },
},
{
agentId: "agent-b",
match: { channel: "telegram", accountId: "account-b" },
},
],
});
const result = await resolveDeliveryTarget(cfg, "agent-b", {
channel: "telegram",
to: "123456",
});
expect(result.accountId).toBe("account-b");
});
it("ignores bindings for different channels", async () => {
vi.mocked(loadSessionStore).mockReturnValue({});
const cfg = makeCfg({
bindings: [
{
agentId: "agent-b",
match: { channel: "discord", accountId: "discord-account" },
},
],
});
const result = await resolveDeliveryTarget(cfg, "agent-b", {
channel: "telegram",
to: "123456",
});
expect(result.accountId).toBeUndefined();
});
});