Compare commits

...

2 Commits

Author SHA1 Message Date
Tyler Yust
5f5a1569f5 Fix cron announce fallback to use origin session context 2026-02-18 16:10:49 -08:00
Tyler Yust
da440003e5 Fix cron announce fallback to use main session when unpinned 2026-02-18 15:52:24 -08:00
4 changed files with 147 additions and 16 deletions

View File

@@ -184,6 +184,71 @@ describe("runCronIsolatedAgentTurn", () => {
});
});
it("uses cron origin session for unpinned announce fallback and implicit delivery resolution", async () => {
await withTempCronHome(async (home) => {
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
const originSessionKey = "agent:main:bluebubbles:direct:+19257864429";
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "main-session",
updatedAt: Date.now(),
lastChannel: "telegram",
lastTo: "999",
},
[originSessionKey]: {
sessionId: "origin-session",
updatedAt: Date.now(),
lastChannel: "telegram",
lastTo: "123",
},
},
null,
2,
),
"utf-8",
);
const deps = createCliDeps();
mockAgentPayloads([{ text: "Final weather summary" }]);
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath, {
session: {
store: storePath,
mainKey: "main",
dmScope: "per-channel-peer",
},
channels: {
telegram: { botToken: "t-1" },
},
}),
deps,
job: {
...makeJob({ kind: "agentTurn", message: "do it" }),
sessionKey: originSessionKey,
delivery: { mode: "announce" },
},
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("ok");
expect(runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
const announceArgs = vi.mocked(runSubagentAnnounceFlow).mock.calls[0]?.[0] as
| {
requesterSessionKey?: string;
requesterOrigin?: { channel?: string; to?: string };
}
| undefined;
expect(announceArgs?.requesterSessionKey).toBe(originSessionKey);
expect(announceArgs?.requesterOrigin?.channel).toBe("telegram");
expect(announceArgs?.requesterOrigin?.to).toBe("123");
});
});
it("passes resolved threadId into shared subagent announce flow", async () => {
await withTempCronHome(async (home) => {
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });

View File

@@ -128,4 +128,31 @@ describe("resolveDeliveryTarget", () => {
expect(result.accountId).toBeUndefined();
});
it("prefers origin session context for implicit last-target resolution", async () => {
vi.mocked(loadSessionStore).mockReturnValue({
"agent:test:main": {
sessionId: "main-session",
updatedAt: 1000,
lastChannel: "telegram",
lastTo: "main-target",
lastAccountId: "main-account",
},
"agent:test:bluebubbles:direct:+19257864429": {
sessionId: "origin-session",
updatedAt: 1001,
lastChannel: "telegram",
lastTo: "origin-target",
lastAccountId: "origin-account",
},
});
const result = await resolveDeliveryTarget(makeCfg(), "agent-b", {
channel: "last",
sessionKey: "agent:test:bluebubbles:direct:+19257864429",
});
expect(result.channel).toBe("telegram");
expect(result.accountId).toBe("origin-account");
});
});

View File

@@ -21,6 +21,7 @@ export async function resolveDeliveryTarget(
jobPayload: {
channel?: "last" | ChannelId;
to?: string;
sessionKey?: string;
},
): Promise<{
channel: Exclude<OutboundChannel, "none">;
@@ -32,6 +33,8 @@ export async function resolveDeliveryTarget(
}> {
const requestedChannel = typeof jobPayload.channel === "string" ? jobPayload.channel : "last";
const explicitTo = typeof jobPayload.to === "string" ? jobPayload.to : undefined;
const originSessionKey =
typeof jobPayload.sessionKey === "string" ? jobPayload.sessionKey.trim() : "";
const allowMismatchedLastTo = requestedChannel === "last";
const sessionCfg = cfg.session;
@@ -39,14 +42,32 @@ export async function resolveDeliveryTarget(
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
const store = loadSessionStore(storePath);
const main = store[mainSessionKey];
const origin = originSessionKey ? store[originSessionKey] : undefined;
const preliminary = resolveSessionDeliveryTarget({
const preliminaryFromOrigin = origin
? resolveSessionDeliveryTarget({
entry: origin,
requestedChannel,
explicitTo,
allowMismatchedLastTo,
})
: undefined;
const preliminaryFromMain = resolveSessionDeliveryTarget({
entry: main,
requestedChannel,
explicitTo,
allowMismatchedLastTo,
});
const hasResolvedTarget = (value?: { channel?: string; to?: string }) =>
Boolean(value?.channel && value?.to);
const useMainContext =
hasResolvedTarget(preliminaryFromMain) && !hasResolvedTarget(preliminaryFromOrigin);
const contextEntry = useMainContext ? main : (origin ?? main);
const preliminary = useMainContext
? preliminaryFromMain
: (preliminaryFromOrigin ?? preliminaryFromMain);
let fallbackChannel: Exclude<OutboundChannel, "none"> | undefined;
if (!preliminary.channel) {
try {
@@ -59,7 +80,7 @@ export async function resolveDeliveryTarget(
const resolved = fallbackChannel
? resolveSessionDeliveryTarget({
entry: main,
entry: contextEntry,
requestedChannel,
explicitTo,
fallbackChannel,

View File

@@ -101,6 +101,15 @@ function resolveCronDeliveryBestEffort(job: CronJob): boolean {
return false;
}
function hasPinnedCronAnnounceTarget(plan: { channel?: string; to?: string }): boolean {
const to = typeof plan.to === "string" ? plan.to.trim() : "";
if (to) {
return true;
}
const channel = typeof plan.channel === "string" ? plan.channel.trim().toLowerCase() : "";
return Boolean(channel && channel !== "last");
}
async function resolveCronAnnounceSessionKey(params: {
cfg: OpenClawConfig;
agentId: string;
@@ -359,10 +368,17 @@ export async function runCronIsolatedAgentTurn(params: {
const agentPayload = params.job.payload.kind === "agentTurn" ? params.job.payload : null;
const deliveryPlan = resolveCronDeliveryPlan(params.job);
const deliveryRequested = deliveryPlan.requested;
const cronOriginSessionKey =
params.job.sessionKey?.trim() ||
resolveAgentMainSessionKey({
cfg: params.cfg,
agentId,
});
const resolvedDelivery = await resolveDeliveryTarget(cfgWithAgentDefaults, agentId, {
channel: deliveryPlan.channel ?? "last",
to: deliveryPlan.to,
sessionKey: cronOriginSessionKey,
});
const { formattedTime, timeLine } = resolveCronStyleNow(params.cfg, now);
@@ -658,21 +674,23 @@ export async function runCronIsolatedAgentTurn(params: {
}
}
} else if (synthesizedText) {
const announceMainSessionKey = resolveAgentMainSessionKey({
cfg: params.cfg,
agentId,
});
const announceSessionKey = await resolveCronAnnounceSessionKey({
cfg: cfgWithAgentDefaults,
agentId,
fallbackSessionKey: announceMainSessionKey,
delivery: {
channel: resolvedDelivery.channel,
to: resolvedDelivery.to,
accountId: resolvedDelivery.accountId,
threadId: resolvedDelivery.threadId,
},
const announceUsesPinnedTarget = hasPinnedCronAnnounceTarget({
channel: deliveryPlan.channel,
to: deliveryPlan.to,
});
const announceSessionKey = announceUsesPinnedTarget
? await resolveCronAnnounceSessionKey({
cfg: cfgWithAgentDefaults,
agentId,
fallbackSessionKey: cronOriginSessionKey,
delivery: {
channel: resolvedDelivery.channel,
to: resolvedDelivery.to,
accountId: resolvedDelivery.accountId,
threadId: resolvedDelivery.threadId,
},
})
: cronOriginSessionKey;
const taskLabel =
typeof params.job.name === "string" && params.job.name.trim()
? params.job.name.trim()