From f5d2db2a6042e9299ee574ebdc0cc6ed95a7f63a Mon Sep 17 00:00:00 2001 From: ai-hpc Date: Mon, 25 May 2026 16:54:14 +0200 Subject: [PATCH] fix(agents): keep cron media completions run-scoped --- src/agents/openclaw-tools.ts | 12 ++- src/agents/openclaw-tools.tts-config.test.ts | 89 ++++++++++++++++++- src/agents/subagent-announce-delivery.test.ts | 41 +++++++++ src/agents/subagent-announce-delivery.ts | 12 ++- 4 files changed, 147 insertions(+), 7 deletions(-) diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index e23fa348421..c6e070fd546 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -6,6 +6,7 @@ import { callGateway } from "../gateway/call.js"; import { isEmbeddedMode } from "../infra/embedded-mode.js"; import { getActiveSecretsRuntimeSnapshot } from "../secrets/runtime-state.js"; import { getActiveRuntimeWebToolsMetadata } from "../secrets/runtime-web-tools-state.js"; +import { isCronRunSessionKey } from "../sessions/session-key-utils.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.js"; import type { GatewayMessageChannel } from "../utils/message-channel.js"; import { resolveAgentWorkspaceDir, resolveSessionAgentIds } from "./agent-scope.js"; @@ -201,6 +202,11 @@ export function createOpenClawTools( toolAllowlist: options?.pluginToolAllowlist, toolDenylist: options?.pluginToolDenylist, }); + const trimmedRunSessionKey = options?.runSessionKey?.trim(); + const mediaGenerationAgentSessionKey = + trimmedRunSessionKey && isCronRunSessionKey(trimmedRunSessionKey) + ? trimmedRunSessionKey + : options?.agentSessionKey; const imageToolAgentDir = options?.agentDir; const imageTool = resolveImageToolFactoryAvailable({ config: availabilityConfig ?? resolvedConfig, @@ -226,7 +232,7 @@ export function createOpenClawTools( config: options?.config, agentDir: options?.agentDir, authProfileStore: options?.authProfileStore, - agentSessionKey: options?.agentSessionKey, + agentSessionKey: mediaGenerationAgentSessionKey, requesterOrigin: deliveryContext ?? undefined, workspaceDir, sandbox, @@ -240,7 +246,7 @@ export function createOpenClawTools( config: options?.config, agentDir: options?.agentDir, authProfileStore: options?.authProfileStore, - agentSessionKey: options?.agentSessionKey, + agentSessionKey: mediaGenerationAgentSessionKey, requesterOrigin: deliveryContext ?? undefined, workspaceDir, sandbox, @@ -254,7 +260,7 @@ export function createOpenClawTools( config: options?.config, agentDir: options?.agentDir, authProfileStore: options?.authProfileStore, - agentSessionKey: options?.agentSessionKey, + agentSessionKey: mediaGenerationAgentSessionKey, requesterOrigin: deliveryContext ?? undefined, workspaceDir, sandbox, diff --git a/src/agents/openclaw-tools.tts-config.test.ts b/src/agents/openclaw-tools.tts-config.test.ts index 4cb8c23ff8c..8c73091dfa9 100644 --- a/src/agents/openclaw-tools.tts-config.test.ts +++ b/src/agents/openclaw-tools.tts-config.test.ts @@ -17,6 +17,9 @@ const mocks = vi.hoisted(() => { return { stubTool, createCronToolOptions: vi.fn(), + createImageGenerateToolOptions: vi.fn(), + createMusicGenerateToolOptions: vi.fn(), + createVideoGenerateToolOptions: vi.fn(), textToSpeech: vi.fn(async () => ({ success: true, audioPath: "/tmp/openclaw/tts-config-test.opus", @@ -50,7 +53,10 @@ vi.mock("./tools/gateway-tool.js", () => ({ })); vi.mock("./tools/image-generate-tool.js", () => ({ - createImageGenerateTool: () => mocks.stubTool("image_generate"), + createImageGenerateTool: (options: unknown) => { + mocks.createImageGenerateToolOptions(options); + return mocks.stubTool("image_generate"); + }, })); vi.mock("./tools/image-tool.js", () => ({ @@ -62,7 +68,10 @@ vi.mock("./tools/message-tool.js", () => ({ })); vi.mock("./tools/music-generate-tool.js", () => ({ - createMusicGenerateTool: () => mocks.stubTool("music_generate"), + createMusicGenerateTool: (options: unknown) => { + mocks.createMusicGenerateToolOptions(options); + return mocks.stubTool("music_generate"); + }, })); vi.mock("./tools/nodes-tool.js", () => ({ @@ -106,7 +115,10 @@ vi.mock("./tools/update-plan-tool.js", () => ({ })); vi.mock("./tools/video-generate-tool.js", () => ({ - createVideoGenerateTool: () => mocks.stubTool("video_generate"), + createVideoGenerateTool: (options: unknown) => { + mocks.createVideoGenerateToolOptions(options); + return mocks.stubTool("video_generate"); + }, })); vi.mock("./tools/web-tools.js", () => ({ @@ -134,6 +146,9 @@ function getTextToSpeechParams() { describe("createOpenClawTools TTS config wiring", () => { beforeEach(() => { mocks.createCronToolOptions.mockClear(); + mocks.createImageGenerateToolOptions.mockClear(); + mocks.createMusicGenerateToolOptions.mockClear(); + mocks.createVideoGenerateToolOptions.mockClear(); mocks.textToSpeech.mockClear(); }); @@ -266,6 +281,74 @@ describe("createOpenClawTools TTS config wiring", () => { }); }); +describe("createOpenClawTools media generation session wiring", () => { + beforeEach(() => { + mocks.createImageGenerateToolOptions.mockClear(); + mocks.createMusicGenerateToolOptions.mockClear(); + mocks.createVideoGenerateToolOptions.mockClear(); + }); + + it("uses the isolated cron run key for background media completions", () => { + const config = { + agents: { + defaults: { + imageGenerationModel: { primary: "image-owner/model" }, + videoGenerationModel: { primary: "video-owner/model" }, + musicGenerationModel: { primary: "music-owner/model" }, + }, + }, + } satisfies OpenClawConfig; + + createOpenClawTools({ + config, + agentSessionKey: "agent:main:cron:daily-media", + runSessionKey: "agent:main:cron:daily-media:run:run-123", + disableMessageTool: true, + disablePluginTools: true, + }); + + expect(mocks.createImageGenerateToolOptions).toHaveBeenCalledWith( + expect.objectContaining({ + agentSessionKey: "agent:main:cron:daily-media:run:run-123", + }), + ); + expect(mocks.createVideoGenerateToolOptions).toHaveBeenCalledWith( + expect.objectContaining({ + agentSessionKey: "agent:main:cron:daily-media:run:run-123", + }), + ); + expect(mocks.createMusicGenerateToolOptions).toHaveBeenCalledWith( + expect.objectContaining({ + agentSessionKey: "agent:main:cron:daily-media:run:run-123", + }), + ); + }); + + it("keeps the requester session key for non-cron media completions", () => { + const config = { + agents: { + defaults: { + imageGenerationModel: { primary: "image-owner/model" }, + }, + }, + } satisfies OpenClawConfig; + + createOpenClawTools({ + config, + agentSessionKey: "agent:main:slack:channel:C123", + runSessionKey: "agent:main:slack:channel:C123:run:run-123", + disableMessageTool: true, + disablePluginTools: true, + }); + + expect(mocks.createImageGenerateToolOptions).toHaveBeenCalledWith( + expect.objectContaining({ + agentSessionKey: "agent:main:slack:channel:C123", + }), + ); + }); +}); + describe("createOpenClawTools cron context wiring", () => { beforeEach(() => { mocks.createCronToolOptions.mockClear(); diff --git a/src/agents/subagent-announce-delivery.test.ts b/src/agents/subagent-announce-delivery.test.ts index 2397d66c703..3a7c1a88bd7 100644 --- a/src/agents/subagent-announce-delivery.test.ts +++ b/src/agents/subagent-announce-delivery.test.ts @@ -2385,6 +2385,47 @@ describe("deliverSubagentAnnouncement completion delivery", () => { ); }); + it("no-ops stale isolated cron run media completions", async () => { + const callGateway = createGatewayMock(); + const sendMessage = createSendMessageMock(); + const queueEmbeddedPiMessageWithOutcome = createQueueOutcomeMock(true); + const result = await deliverSlackChannelAnnouncement({ + callGateway, + sendMessage, + queueEmbeddedPiMessageWithOutcome, + sessionId: "stale-cron-run-session", + isActive: false, + requesterSessionKey: "agent:main:cron:daily-media:run:run-123", + expectsCompletionMessage: true, + directIdempotencyKey: "announce-stale-cron-media", + sourceTool: "image_generate", + internalEvents: [ + { + type: "task_completion", + source: "image_generation", + childSessionKey: "image_generate:task-123", + childSessionId: "task-123", + announceType: "image generation task", + taskLabel: "daily media", + status: "ok", + statusLabel: "completed successfully", + result: "Generated 1 image.\nMEDIA:/tmp/generated-daily.png", + mediaUrls: ["/tmp/generated-daily.png"], + replyInstruction: "Deliver the generated image through the requester run.", + }, + ], + }); + + expectRecordFields(result, { + delivered: true, + path: "none", + phases: [{ phase: "direct-primary", delivered: true, path: "none", error: undefined }], + }); + expect(queueEmbeddedPiMessageWithOutcome).not.toHaveBeenCalled(); + expect(callGateway).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + }); + it.each([ { name: "legacy Discord channel", diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index fd216e4a6d5..7021fce64c0 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -10,7 +10,7 @@ import { isAgentMediatedCompletionSourceTool, shouldPreserveUserFacingSessionStateForInputProvenance, } from "../sessions/input-provenance.js"; -import { isCronSessionKey } from "../sessions/session-key-utils.js"; +import { isCronRunSessionKey, isCronSessionKey } from "../sessions/session-key-utils.js"; import { isNonTerminalAgentRunStatus } from "../shared/agent-run-status.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { mergeDeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js"; @@ -908,6 +908,16 @@ async function sendSubagentAnnounceDirectly(params: { )}`, ); } + if ( + params.expectsCompletionMessage && + isCronRunSessionKey(canonicalRequesterSessionKey) && + !resolveRequesterSessionActivity(canonicalRequesterSessionKey).isActive + ) { + return { + delivered: true, + path: "none", + }; + } if (params.signal?.aborted) { return { delivered: false,