diff --git a/docs/automation/tasks.md b/docs/automation/tasks.md index aa797e5c52c7..81b134e83148 100644 --- a/docs/automation/tasks.md +++ b/docs/automation/tasks.md @@ -108,7 +108,7 @@ Not every agent run creates a task. Heartbeat turns and normal interactive chat Automation tasks (main-session and isolated) use `silent` notify policy - they create records for tracking but do not generate task notifications of their own; the scheduler owns its delivery path. - Session-backed `image_generate`, `music_generate`, and `video_generate` runs also use `silent` notify policy. They still create task records, but completion is handed back to the original agent session as an internal wake so the agent can write the follow-up message and attach the finished media itself. The requester agent follows its normal visible-reply contract: automatic final reply when configured, or `message(action="send")` plus `NO_REPLY` when the session requires message-tool replies. If the requester session is no longer active or its active wake fails, and the completion agent misses some or all generated media, OpenClaw sends an idempotent direct fallback with only the missing media to the original channel target. + Session-backed `image_generate`, `music_generate`, and `video_generate` runs also use `silent` notify policy. They still create task records, but completion is handed back to the original agent session as an internal wake. The requester agent follows its current visible-reply contract: successful completion includes a short user-facing caption and every structured generated attachment from the completion event, while failure produces a concise visible failure. Internal task and session details stay private. If the requester session is no longer active or its active wake fails, and the completion agent misses some or all generated media, OpenClaw sends an idempotent direct fallback with only the missing media to the original channel target. diff --git a/docs/tools/image-generation.md b/docs/tools/image-generation.md index 97d584106572..6cfe2849782f 100644 --- a/docs/tools/image-generation.md +++ b/docs/tools/image-generation.md @@ -11,12 +11,12 @@ sidebarTitle: "Image generation" The `image_generate` tool creates and edits images through your configured providers. In chat sessions it runs asynchronously: OpenClaw records a background task, returns the task id immediately, and wakes the agent when -the provider finishes. The completion agent follows the session's normal -visible-reply mode: automatic final reply delivery when configured, or -`message(action="send")` when the session requires the message tool. If the -requester session is inactive or its active wake fails, OpenClaw sends an -idempotent direct fallback with the generated images so the result is not -lost. +the provider finishes. The task record stays silent, while the completion +agent follows the session's current visible-reply contract with a short +user-facing caption and every structured generated attachment. If generation +fails, the agent returns a concise visible failure instead. If the requester +session is inactive or its active wake fails, OpenClaw sends an idempotent +direct fallback with the generated images so the result is not lost. The tool only appears when at least one image-generation provider is @@ -60,8 +60,8 @@ or sign in with OpenAI ChatGPT/Codex OAuth. The agent calls `image_generate` automatically. No tool allow-listing needed - it is enabled by default when a provider is available. The tool - returns a background task id, then the completion agent sends the - generated attachment through the `message` tool when it is ready. + returns a background task id, then the completion agent replies with every + generated attachment when it is ready. diff --git a/src/agents/tools/media-generate-background-shared.ts b/src/agents/tools/media-generate-background-shared.ts index e7c4027e8e3c..310c8dc10dc1 100644 --- a/src/agents/tools/media-generate-background-shared.ts +++ b/src/agents/tools/media-generate-background-shared.ts @@ -385,13 +385,13 @@ function buildMediaGenerationReplyInstruction(params: { if (params.status === "ok") { return [ `The ${params.completionLabel} is ready for the original chat.`, - 'Use the current visible-reply contract: if this session requires message-tool replies, call message(action="send") with a short caption and every structured attachment from the internal event, then reply only NO_REPLY.', - "Otherwise, write the normal final reply and attach every generated media path with final-reply MEDIA lines.", + "Follow the current visible-reply contract with a short user-facing caption and every structured generated attachment from this event.", + "Keep internal task/session details private and do not copy the internal event text verbatim.", ].join(" "); } return [ `${params.completionLabel[0]?.toUpperCase() ?? "T"}${params.completionLabel.slice(1)} generation task failed for the original chat.`, - 'Use the current visible-reply contract: call message(action="send") when message-tool replies are required, otherwise write the normal final reply.', + "Follow the current visible-reply contract with a concise user-facing failure message.", "Keep internal task/session details private and do not copy the internal event text verbatim.", ].join(" "); } diff --git a/src/agents/tools/media-generate-background.test.ts b/src/agents/tools/media-generate-background.test.ts index bb9797387acc..fbc398e15541 100644 --- a/src/agents/tools/media-generate-background.test.ts +++ b/src/agents/tools/media-generate-background.test.ts @@ -160,15 +160,6 @@ function getDeliveredInternalEvents(): Array> { return params.internalEvents as Array>; } -function expectReplyInstructionContains(text: string) { - const event = getDeliveredInternalEvents().find( - (item) => typeof item.replyInstruction === "string" && item.replyInstruction.includes(text), - ); - if (!event) { - throw new Error(`Expected reply instruction containing ${text}`); - } -} - // Music background tests cover task-run creation, progress recording, and // completion delivery through the durable requester-agent handoff. describe("music generate background helpers", () => { @@ -246,29 +237,58 @@ describe("music generate background helpers", () => { expect(announceDeliveryMocks.deliverSubagentAnnouncement).toHaveBeenCalledTimes(1); }); - it("tells channel completion agents to follow the visible-reply contract", async () => { - announceDeliveryMocks.deliverSubagentAnnouncement.mockResolvedValue({ - delivered: true, - path: "direct", - }); - const completion = createMediaCompletionFixture({ - runId: "tool:music_generate:abc", - taskLabel: "night-drive synthwave", - result: "Generated 1 track.\nMEDIA:/tmp/generated-night-drive.mp3", - mediaUrls: ["/tmp/generated-night-drive.mp3"], - }); + it.each([ + "agent:main:discord:direct:123", + "agent:main:discord:channel:C123", + "agent:main:whatsapp:123@g.us", + ])( + "gives %s tool-agnostic visible-reply guidance with every generated attachment", + async (requesterSessionKey) => { + announceDeliveryMocks.deliverSubagentAnnouncement.mockResolvedValue({ + delivered: true, + path: "direct", + }); + const attachments = [ + { + type: "audio" as const, + path: "/tmp/generated-night-drive.mp3", + mimeType: "audio/mpeg", + name: "night-drive.mp3", + }, + { + type: "image" as const, + path: "/tmp/generated-night-drive-cover.png", + mimeType: "image/png", + name: "night-drive-cover.png", + }, + ]; + const completion = createMediaCompletionFixture({ + runId: "tool:music_generate:abc", + taskLabel: "night-drive synthwave", + result: "Generated a track and cover art.", + }); - await musicGenerationTaskLifecycle.wakeTaskCompletion({ - ...completion, - handle: { - ...completion.handle, - requesterSessionKey: "agent:main:discord:channel:C123", - }, - }); + await musicGenerationTaskLifecycle.wakeTaskCompletion({ + ...completion, + attachments, + handle: { + ...completion.handle, + requesterSessionKey, + }, + }); - expectReplyInstructionContains("visible-reply contract"); - expectReplyInstructionContains("final-reply MEDIA lines"); - }); + const event = getDeliveredInternalEvents().at(0); + expect(event?.attachments).toEqual(attachments); + const replyInstruction = String(event?.replyInstruction); + expect(replyInstruction).toContain("current visible-reply contract"); + expect(replyInstruction).toContain("short user-facing caption"); + expect(replyInstruction).toContain("every structured generated attachment from this event"); + expect(replyInstruction).toContain("Keep internal task/session details private"); + expect(replyInstruction).not.toContain('message(action="send")'); + expect(replyInstruction).not.toContain("NO_REPLY"); + expect(replyInstruction).not.toContain("MEDIA:"); + }, + ); it("keeps failed completion notices in the durable agent-loop handoff", async () => { announceDeliveryMocks.deliverSubagentAnnouncement.mockResolvedValue({ @@ -294,33 +314,6 @@ describe("music generate background helpers", () => { expect(taskDeliveryRuntimeMocks.sendMessage).not.toHaveBeenCalled(); expect(announceDeliveryMocks.deliverSubagentAnnouncement).toHaveBeenCalledTimes(1); }); - - it.each(["agent:main:discord:guild-123:channel-456", "agent:main:whatsapp:123@g.us"])( - "warns legacy group/channel completion agents for %s", - async (requesterSessionKey) => { - announceDeliveryMocks.deliverSubagentAnnouncement.mockResolvedValue({ - delivered: true, - path: "direct", - }); - const completion = createMediaCompletionFixture({ - runId: "tool:music_generate:abc", - taskLabel: "night-drive synthwave", - result: "Generated 1 track.\nMEDIA:/tmp/generated-night-drive.mp3", - mediaUrls: ["/tmp/generated-night-drive.mp3"], - }); - - await musicGenerationTaskLifecycle.wakeTaskCompletion({ - ...completion, - handle: { - ...completion.handle, - requesterSessionKey, - }, - }); - - expectReplyInstructionContains("visible-reply contract"); - expectReplyInstructionContains("final-reply MEDIA lines"); - }, - ); }); // Video generation background tests cover detached task lifecycle, keepalive @@ -478,5 +471,13 @@ describe("video generate background helpers", () => { expect(announceDeliveryMocks.deliverSubagentAnnouncement).toHaveBeenCalledTimes(1); expect(taskDeliveryRuntimeMocks.sendMessage).not.toHaveBeenCalled(); + const replyInstruction = String(getDeliveredInternalEvents().at(0)?.replyInstruction); + expect(replyInstruction).toContain("current visible-reply contract"); + expect(replyInstruction).toContain("concise user-facing failure"); + expect(replyInstruction).toContain("Keep internal task/session details private"); + expect(replyInstruction).toContain("do not copy the internal event text verbatim"); + expect(replyInstruction).not.toContain('message(action="send")'); + expect(replyInstruction).not.toContain("NO_REPLY"); + expect(replyInstruction).not.toContain("MEDIA:"); }); }); diff --git a/test/scripts/profile-extension-memory.test.ts b/test/scripts/profile-extension-memory.test.ts index 507fa5f39ee9..be9c78265315 100644 --- a/test/scripts/profile-extension-memory.test.ts +++ b/test/scripts/profile-extension-memory.test.ts @@ -385,9 +385,17 @@ describe("scripts/profile-extension-memory", () => { }); try { - await waitForCondition(() => existsSync(descendantPidPath)); - descendantPid = Number.parseInt(readFileSync(descendantPidPath, "utf8"), 10); - expect(Number.isInteger(descendantPid)).toBe(true); + await waitForCondition(() => { + if (!existsSync(descendantPidPath)) { + return false; + } + const candidatePid = Number.parseInt(readFileSync(descendantPidPath, "utf8"), 10); + if (!Number.isInteger(candidatePid) || candidatePid <= 0) { + return false; + } + descendantPid = candidatePid; + return true; + }); expect(isProcessAlive(descendantPid)).toBe(true); const runnerExit = waitForChildExit(runner);