mirror of https://github.com/openclaw/openclaw.git
refactor(video): share async task status helpers
This commit is contained in:
parent
527215c343
commit
5a42355d54
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const videoGenerationTaskStatusMocks = vi.hoisted(() => ({
|
||||
buildActiveVideoGenerationTaskPromptContextForSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../video-generation-task-status.js", () => videoGenerationTaskStatusMocks);
|
||||
|
||||
import { resolveAttemptPrependSystemContext } from "./attempt.prompt-helpers.js";
|
||||
|
||||
describe("resolveAttemptPrependSystemContext", () => {
|
||||
it("prepends active video task guidance ahead of hook system context", () => {
|
||||
videoGenerationTaskStatusMocks.buildActiveVideoGenerationTaskPromptContextForSession.mockReturnValue(
|
||||
"Active task hint",
|
||||
);
|
||||
|
||||
const result = resolveAttemptPrependSystemContext({
|
||||
sessionKey: "agent:main:discord:direct:123",
|
||||
trigger: "user",
|
||||
hookPrependSystemContext: "Hook system context",
|
||||
});
|
||||
|
||||
expect(
|
||||
videoGenerationTaskStatusMocks.buildActiveVideoGenerationTaskPromptContextForSession,
|
||||
).toHaveBeenCalledWith("agent:main:discord:direct:123");
|
||||
expect(result).toBe("Active task hint\n\nHook system context");
|
||||
});
|
||||
|
||||
it("skips active video task guidance for non-user triggers", () => {
|
||||
videoGenerationTaskStatusMocks.buildActiveVideoGenerationTaskPromptContextForSession.mockReset();
|
||||
videoGenerationTaskStatusMocks.buildActiveVideoGenerationTaskPromptContextForSession.mockReturnValue(
|
||||
"Should not be used",
|
||||
);
|
||||
|
||||
const result = resolveAttemptPrependSystemContext({
|
||||
sessionKey: "agent:main:discord:direct:123",
|
||||
trigger: "heartbeat",
|
||||
hookPrependSystemContext: "Hook system context",
|
||||
});
|
||||
|
||||
expect(
|
||||
videoGenerationTaskStatusMocks.buildActiveVideoGenerationTaskPromptContextForSession,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(result).toBe("Hook system context");
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import { isCronSessionKey, isSubagentSessionKey } from "../../../routing/session
|
|||
import { joinPresentTextSegments } from "../../../shared/text/join-segments.js";
|
||||
import { prependSystemPromptAdditionAfterCacheBoundary } from "../../system-prompt-cache-boundary.js";
|
||||
import { resolveEffectiveToolFsWorkspaceOnly } from "../../tool-fs-policy.js";
|
||||
import { buildActiveVideoGenerationTaskPromptContextForSession } from "../../video-generation-task-status.js";
|
||||
import type { CompactEmbeddedPiSessionParams } from "../compact.js";
|
||||
import { buildEmbeddedCompactionRuntimeContext } from "../compaction-runtime-context.js";
|
||||
import { log } from "../logger.js";
|
||||
|
|
@ -119,6 +120,18 @@ export function prependSystemPromptAddition(params: {
|
|||
return prependSystemPromptAdditionAfterCacheBoundary(params);
|
||||
}
|
||||
|
||||
export function resolveAttemptPrependSystemContext(params: {
|
||||
sessionKey?: string;
|
||||
trigger?: EmbeddedRunAttemptParams["trigger"];
|
||||
hookPrependSystemContext?: string;
|
||||
}): string | undefined {
|
||||
const activeVideoTaskPromptContext =
|
||||
params.trigger === "user" || params.trigger === "manual"
|
||||
? buildActiveVideoGenerationTaskPromptContextForSession(params.sessionKey)
|
||||
: undefined;
|
||||
return joinPresentTextSegments([activeVideoTaskPromptContext, params.hookPrependSystemContext]);
|
||||
}
|
||||
|
||||
/** Build runtime context passed into context-engine afterTurn hooks. */
|
||||
export function buildAfterTurnRuntimeContext(params: {
|
||||
attempt: Pick<
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
|||
import { resolveToolCallArgumentsEncoding } from "../../../plugins/provider-model-compat.js";
|
||||
import { resolveProviderSystemPromptContribution } from "../../../plugins/provider-runtime.js";
|
||||
import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
||||
import { joinPresentTextSegments } from "../../../shared/text/join-segments.js";
|
||||
import { buildTtsSystemPromptHint } from "../../../tts/tts.js";
|
||||
import { resolveUserPath } from "../../../utils.js";
|
||||
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
|
||||
|
|
@ -94,7 +93,6 @@ import { buildSystemPromptParams } from "../../system-prompt-params.js";
|
|||
import { buildSystemPromptReport } from "../../system-prompt-report.js";
|
||||
import { sanitizeToolCallIdsForCloudCodeAssist } from "../../tool-call-id.js";
|
||||
import { resolveTranscriptPolicy } from "../../transcript-policy.js";
|
||||
import { buildActiveVideoGenerationTaskPromptContextForSession } from "../../video-generation-task-status.js";
|
||||
import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js";
|
||||
import { isRunnerAbortError } from "../abort.js";
|
||||
import { isCacheTtlEligibleProvider } from "../cache-ttl.js";
|
||||
|
|
@ -155,6 +153,7 @@ import {
|
|||
buildAfterTurnRuntimeContext,
|
||||
prependSystemPromptAddition,
|
||||
resolveAttemptFsWorkspaceOnly,
|
||||
resolveAttemptPrependSystemContext,
|
||||
resolvePromptBuildHookResult,
|
||||
resolvePromptModeForSession,
|
||||
shouldWarnOnOrphanedUserRepair,
|
||||
|
|
@ -210,6 +209,7 @@ export {
|
|||
buildAfterTurnRuntimeContext,
|
||||
prependSystemPromptAddition,
|
||||
resolveAttemptFsWorkspaceOnly,
|
||||
resolveAttemptPrependSystemContext,
|
||||
resolvePromptBuildHookResult,
|
||||
resolvePromptModeForSession,
|
||||
shouldWarnOnOrphanedUserRepair,
|
||||
|
|
@ -1523,10 +1523,6 @@ export async function runEmbeddedAttempt(
|
|||
hookRunner,
|
||||
legacyBeforeAgentStartResult: params.legacyBeforeAgentStartResult,
|
||||
});
|
||||
const activeVideoTaskPromptContext =
|
||||
params.trigger === "user" || params.trigger === "manual"
|
||||
? buildActiveVideoGenerationTaskPromptContextForSession(params.sessionKey)
|
||||
: undefined;
|
||||
{
|
||||
if (hookResult?.prependContext) {
|
||||
effectivePrompt = `${hookResult.prependContext}\n\n${effectivePrompt}`;
|
||||
|
|
@ -1543,10 +1539,11 @@ export async function runEmbeddedAttempt(
|
|||
}
|
||||
const prependedOrAppendedSystemPrompt = composeSystemPromptWithHookContext({
|
||||
baseSystemPrompt: systemPromptText,
|
||||
prependSystemContext: joinPresentTextSegments([
|
||||
activeVideoTaskPromptContext,
|
||||
hookResult?.prependSystemContext,
|
||||
]),
|
||||
prependSystemContext: resolveAttemptPrependSystemContext({
|
||||
sessionKey: params.sessionKey,
|
||||
trigger: params.trigger,
|
||||
hookPrependSystemContext: hookResult?.prependSystemContext,
|
||||
}),
|
||||
appendSystemContext: hookResult?.appendSystemContext,
|
||||
});
|
||||
if (prependedOrAppendedSystemPrompt) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import { listTasksForOwnerKey } from "../tasks/runtime-internal.js";
|
||||
import type { TaskRecord, TaskRuntime, TaskStatus } from "../tasks/task-registry.types.js";
|
||||
|
||||
const DEFAULT_ACTIVE_STATUSES = new Set<TaskStatus>(["queued", "running"]);
|
||||
|
||||
export function findActiveSessionTask(params: {
|
||||
sessionKey?: string;
|
||||
runtime?: TaskRuntime;
|
||||
taskKind?: string;
|
||||
statuses?: ReadonlySet<TaskStatus>;
|
||||
sourceIdPrefix?: string;
|
||||
}): TaskRecord | null {
|
||||
const normalizedSessionKey = params.sessionKey?.trim();
|
||||
if (!normalizedSessionKey) {
|
||||
return null;
|
||||
}
|
||||
const statuses = params.statuses ?? DEFAULT_ACTIVE_STATUSES;
|
||||
const taskKind = params.taskKind?.trim();
|
||||
const sourceIdPrefix = params.sourceIdPrefix?.trim();
|
||||
const matches = listTasksForOwnerKey(normalizedSessionKey).filter((task) => {
|
||||
if (task.scopeKind !== "session") {
|
||||
return false;
|
||||
}
|
||||
if (params.runtime && task.runtime !== params.runtime) {
|
||||
return false;
|
||||
}
|
||||
if (!statuses.has(task.status)) {
|
||||
return false;
|
||||
}
|
||||
if (taskKind && task.taskKind !== taskKind) {
|
||||
return false;
|
||||
}
|
||||
if (sourceIdPrefix) {
|
||||
const sourceId = task.sourceId?.trim() ?? "";
|
||||
if (sourceId !== sourceIdPrefix && !sourceId.startsWith(`${sourceIdPrefix}:`)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (matches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return matches.find((task) => task.status === "running") ?? matches[0] ?? null;
|
||||
}
|
||||
|
||||
export function buildSessionAsyncTaskStatusDetails(task: TaskRecord): Record<string, unknown> {
|
||||
return {
|
||||
async: true,
|
||||
active: true,
|
||||
existingTask: true,
|
||||
status: task.status,
|
||||
task: {
|
||||
taskId: task.taskId,
|
||||
...(task.runId ? { runId: task.runId } : {}),
|
||||
},
|
||||
...(task.taskKind ? { taskKind: task.taskKind } : {}),
|
||||
...(task.progressSummary ? { progressSummary: task.progressSummary } : {}),
|
||||
...(task.sourceId ? { sourceId: task.sourceId } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { VIDEO_GENERATION_TASK_KIND } from "../video-generation-task-status.js";
|
||||
import {
|
||||
createVideoGenerationTaskRun,
|
||||
recordVideoGenerationTaskProgress,
|
||||
|
|
@ -48,6 +49,7 @@ describe("video generate background helpers", () => {
|
|||
});
|
||||
expect(taskExecutorMocks.createRunningTaskRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
taskKind: VIDEO_GENERATION_TASK_KIND,
|
||||
sourceId: "video_generate:openai",
|
||||
progressSummary: "Queued video generation",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { DeliveryContext } from "../../utils/delivery-context.js";
|
|||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
import { formatAgentInternalEventsForPrompt, type AgentInternalEvent } from "../internal-events.js";
|
||||
import { deliverSubagentAnnouncement } from "../subagent-announce-delivery.js";
|
||||
import { VIDEO_GENERATION_TASK_KIND } from "../video-generation-task-status.js";
|
||||
|
||||
const log = createSubsystemLogger("agents/tools/video-generate-background");
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ export function createVideoGenerationTaskRun(params: {
|
|||
try {
|
||||
const task = createRunningTaskRun({
|
||||
runtime: "cli",
|
||||
taskKind: VIDEO_GENERATION_TASK_KIND,
|
||||
sourceId: params.providerId ? `video_generate:${params.providerId}` : "video_generate",
|
||||
requesterSessionKey: sessionKey,
|
||||
ownerKey: sessionKey,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { getProviderEnvVars } from "../../secrets/provider-env-vars.js";
|
||||
import { listRuntimeVideoGenerationProviders } from "../../video-generation/runtime.js";
|
||||
import {
|
||||
buildVideoGenerationTaskStatusDetails,
|
||||
buildVideoGenerationTaskStatusText,
|
||||
findActiveVideoGenerationTaskForSession,
|
||||
} from "../video-generation-task-status.js";
|
||||
|
||||
type VideoGenerateActionResult = {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
details: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function getVideoGenerationProviderAuthEnvVars(providerId: string): string[] {
|
||||
return getProviderEnvVars(providerId);
|
||||
}
|
||||
|
||||
export function createVideoGenerateListActionResult(
|
||||
config?: OpenClawConfig,
|
||||
): VideoGenerateActionResult {
|
||||
const providers = listRuntimeVideoGenerationProviders({ config });
|
||||
if (providers.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No video-generation providers are registered." }],
|
||||
details: { providers: [] },
|
||||
};
|
||||
}
|
||||
const lines = providers.map((provider) => {
|
||||
const authHints = getVideoGenerationProviderAuthEnvVars(provider.id);
|
||||
const capabilities = [
|
||||
provider.capabilities.maxVideos ? `maxVideos=${provider.capabilities.maxVideos}` : null,
|
||||
provider.capabilities.maxInputImages
|
||||
? `maxInputImages=${provider.capabilities.maxInputImages}`
|
||||
: null,
|
||||
provider.capabilities.maxInputVideos
|
||||
? `maxInputVideos=${provider.capabilities.maxInputVideos}`
|
||||
: null,
|
||||
provider.capabilities.maxDurationSeconds
|
||||
? `maxDurationSeconds=${provider.capabilities.maxDurationSeconds}`
|
||||
: null,
|
||||
provider.capabilities.supportedDurationSeconds?.length
|
||||
? `supportedDurationSeconds=${provider.capabilities.supportedDurationSeconds.join("/")}`
|
||||
: null,
|
||||
provider.capabilities.supportedDurationSecondsByModel &&
|
||||
Object.keys(provider.capabilities.supportedDurationSecondsByModel).length > 0
|
||||
? `supportedDurationSecondsByModel=${Object.entries(
|
||||
provider.capabilities.supportedDurationSecondsByModel,
|
||||
)
|
||||
.map(([modelId, durations]) => `${modelId}:${durations.join("/")}`)
|
||||
.join("; ")}`
|
||||
: null,
|
||||
provider.capabilities.supportsResolution ? "resolution" : null,
|
||||
provider.capabilities.supportsAspectRatio ? "aspectRatio" : null,
|
||||
provider.capabilities.supportsSize ? "size" : null,
|
||||
provider.capabilities.supportsAudio ? "audio" : null,
|
||||
provider.capabilities.supportsWatermark ? "watermark" : null,
|
||||
]
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join(", ");
|
||||
return [
|
||||
`${provider.id}: default=${provider.defaultModel ?? "none"}`,
|
||||
provider.models?.length ? `models=${provider.models.join(", ")}` : null,
|
||||
capabilities ? `capabilities=${capabilities}` : null,
|
||||
authHints.length > 0 ? `auth=${authHints.join(" / ")}` : null,
|
||||
]
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join(" | ");
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: {
|
||||
providers: providers.map((provider) => ({
|
||||
id: provider.id,
|
||||
defaultModel: provider.defaultModel,
|
||||
models: provider.models ?? [],
|
||||
authEnvVars: getVideoGenerationProviderAuthEnvVars(provider.id),
|
||||
capabilities: provider.capabilities,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createVideoGenerateStatusActionResult(
|
||||
sessionKey?: string,
|
||||
): VideoGenerateActionResult {
|
||||
const activeTask = findActiveVideoGenerationTaskForSession(sessionKey);
|
||||
if (!activeTask) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No active video generation task is currently running for this session.",
|
||||
},
|
||||
],
|
||||
details: {
|
||||
action: "status",
|
||||
active: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: buildVideoGenerationTaskStatusText(activeTask),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
action: "status",
|
||||
...buildVideoGenerationTaskStatusDetails(activeTask),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createVideoGenerateDuplicateGuardResult(
|
||||
sessionKey?: string,
|
||||
): VideoGenerateActionResult | null {
|
||||
const activeTask = findActiveVideoGenerationTaskForSession(sessionKey);
|
||||
if (!activeTask) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: buildVideoGenerationTaskStatusText(activeTask, { duplicateGuard: true }),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
action: "status",
|
||||
duplicateGuard: true,
|
||||
...buildVideoGenerationTaskStatusDetails(activeTask),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as videoGenerationRuntime from "../../video-generation/runtime.js";
|
||||
import { VIDEO_GENERATION_TASK_KIND } from "../video-generation-task-status.js";
|
||||
import {
|
||||
createVideoGenerateDuplicateGuardResult,
|
||||
createVideoGenerateStatusActionResult,
|
||||
} from "./video-generate-tool.actions.js";
|
||||
|
||||
const taskRuntimeInternalMocks = vi.hoisted(() => ({
|
||||
listTasksForOwnerKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../tasks/runtime-internal.js", () => taskRuntimeInternalMocks);
|
||||
|
||||
describe("createVideoGenerateTool status actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(videoGenerationRuntime, "listRuntimeVideoGenerationProviders").mockReturnValue([]);
|
||||
taskRuntimeInternalMocks.listTasksForOwnerKey.mockReset();
|
||||
taskRuntimeInternalMocks.listTasksForOwnerKey.mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("returns active task status instead of starting a duplicate generation", async () => {
|
||||
taskRuntimeInternalMocks.listTasksForOwnerKey.mockReturnValue([
|
||||
{
|
||||
taskId: "task-active",
|
||||
runtime: "cli",
|
||||
taskKind: VIDEO_GENERATION_TASK_KIND,
|
||||
sourceId: "video_generate:openai",
|
||||
requesterSessionKey: "agent:main:discord:direct:123",
|
||||
ownerKey: "agent:main:discord:direct:123",
|
||||
scopeKind: "session",
|
||||
runId: "tool:video_generate:active",
|
||||
task: "friendly lobster surfing",
|
||||
status: "running",
|
||||
deliveryStatus: "not_applicable",
|
||||
notifyPolicy: "silent",
|
||||
createdAt: Date.now(),
|
||||
progressSummary: "Generating video",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = createVideoGenerateDuplicateGuardResult("agent:main:discord:direct:123");
|
||||
const text = (result?.content?.[0] as { text: string } | undefined)?.text ?? "";
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(text).toContain("Video generation task task-active is already running with openai.");
|
||||
expect(text).toContain("Do not call video_generate again for this request.");
|
||||
expect(result?.details).toMatchObject({
|
||||
action: "status",
|
||||
duplicateGuard: true,
|
||||
active: true,
|
||||
existingTask: true,
|
||||
status: "running",
|
||||
taskKind: VIDEO_GENERATION_TASK_KIND,
|
||||
provider: "openai",
|
||||
task: {
|
||||
taskId: "task-active",
|
||||
runId: "tool:video_generate:active",
|
||||
},
|
||||
progressSummary: "Generating video",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports active task status when action=status is requested", async () => {
|
||||
taskRuntimeInternalMocks.listTasksForOwnerKey.mockReturnValue([
|
||||
{
|
||||
taskId: "task-active",
|
||||
runtime: "cli",
|
||||
taskKind: VIDEO_GENERATION_TASK_KIND,
|
||||
sourceId: "video_generate:google",
|
||||
requesterSessionKey: "agent:main:discord:direct:123",
|
||||
ownerKey: "agent:main:discord:direct:123",
|
||||
scopeKind: "session",
|
||||
runId: "tool:video_generate:active",
|
||||
task: "friendly lobster surfing",
|
||||
status: "queued",
|
||||
deliveryStatus: "not_applicable",
|
||||
notifyPolicy: "silent",
|
||||
createdAt: Date.now(),
|
||||
progressSummary: "Queued video generation",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = createVideoGenerateStatusActionResult("agent:main:discord:direct:123");
|
||||
const text = (result.content?.[0] as { text: string } | undefined)?.text ?? "";
|
||||
|
||||
expect(text).toContain("Video generation task task-active is already queued with google.");
|
||||
expect(result.details).toMatchObject({
|
||||
action: "status",
|
||||
active: true,
|
||||
existingTask: true,
|
||||
status: "queued",
|
||||
taskKind: VIDEO_GENERATION_TASK_KIND,
|
||||
provider: "google",
|
||||
task: {
|
||||
taskId: "task-active",
|
||||
},
|
||||
progressSummary: "Queued video generation",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -222,112 +222,6 @@ describe("createVideoGenerateTool", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("returns active task status instead of starting a duplicate generation", async () => {
|
||||
const generateSpy = vi.spyOn(videoGenerationRuntime, "generateVideo");
|
||||
taskRuntimeInternalMocks.listTasksForOwnerKey.mockReturnValue([
|
||||
{
|
||||
taskId: "task-active",
|
||||
runtime: "cli",
|
||||
sourceId: "video_generate:openai",
|
||||
requesterSessionKey: "agent:main:discord:direct:123",
|
||||
ownerKey: "agent:main:discord:direct:123",
|
||||
scopeKind: "session",
|
||||
runId: "tool:video_generate:active",
|
||||
task: "friendly lobster surfing",
|
||||
status: "running",
|
||||
deliveryStatus: "not_applicable",
|
||||
notifyPolicy: "silent",
|
||||
createdAt: Date.now(),
|
||||
progressSummary: "Generating video",
|
||||
},
|
||||
]);
|
||||
|
||||
const tool = createVideoGenerateTool({
|
||||
config: asConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
videoGenerationModel: { primary: "openai/sora-2" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
agentSessionKey: "agent:main:discord:direct:123",
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("expected video_generate tool");
|
||||
}
|
||||
|
||||
const result = await tool.execute("call-dup", { prompt: "friendly lobster surfing" });
|
||||
const text = (result.content?.[0] as { text: string } | undefined)?.text ?? "";
|
||||
|
||||
expect(text).toContain("Video generation task task-active is already running with openai.");
|
||||
expect(text).toContain("Do not call video_generate again for this request.");
|
||||
expect(result.details).toMatchObject({
|
||||
action: "status",
|
||||
duplicateGuard: true,
|
||||
active: true,
|
||||
existingTask: true,
|
||||
status: "running",
|
||||
provider: "openai",
|
||||
task: {
|
||||
taskId: "task-active",
|
||||
runId: "tool:video_generate:active",
|
||||
},
|
||||
progressSummary: "Generating video",
|
||||
});
|
||||
expect(taskExecutorMocks.createRunningTaskRun).not.toHaveBeenCalled();
|
||||
expect(generateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports active task status when action=status is requested", async () => {
|
||||
taskRuntimeInternalMocks.listTasksForOwnerKey.mockReturnValue([
|
||||
{
|
||||
taskId: "task-active",
|
||||
runtime: "cli",
|
||||
sourceId: "video_generate:google",
|
||||
requesterSessionKey: "agent:main:discord:direct:123",
|
||||
ownerKey: "agent:main:discord:direct:123",
|
||||
scopeKind: "session",
|
||||
runId: "tool:video_generate:active",
|
||||
task: "friendly lobster surfing",
|
||||
status: "queued",
|
||||
deliveryStatus: "not_applicable",
|
||||
notifyPolicy: "silent",
|
||||
createdAt: Date.now(),
|
||||
progressSummary: "Queued video generation",
|
||||
},
|
||||
]);
|
||||
|
||||
const tool = createVideoGenerateTool({
|
||||
config: asConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
videoGenerationModel: { primary: "google/veo-3.1-fast-generate-preview" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
agentSessionKey: "agent:main:discord:direct:123",
|
||||
});
|
||||
if (!tool) {
|
||||
throw new Error("expected video_generate tool");
|
||||
}
|
||||
|
||||
const result = await tool.execute("call-status", { action: "status" });
|
||||
const text = (result.content?.[0] as { text: string } | undefined)?.text ?? "";
|
||||
|
||||
expect(text).toContain("Video generation task task-active is already queued with google.");
|
||||
expect(result.details).toMatchObject({
|
||||
action: "status",
|
||||
active: true,
|
||||
existingTask: true,
|
||||
status: "queued",
|
||||
provider: "google",
|
||||
task: {
|
||||
taskId: "task-active",
|
||||
},
|
||||
progressSummary: "Queued video generation",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces provider generation failures inline when there is no detached session", async () => {
|
||||
vi.spyOn(videoGenerationRuntime, "generateVideo").mockRejectedValue(new Error("queue boom"));
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { createSubsystemLogger } from "../../logging/subsystem.js";
|
|||
import { saveMediaBuffer } from "../../media/store.js";
|
||||
import { loadWebMedia } from "../../media/web-media.js";
|
||||
import { readSnakeCaseParamRaw } from "../../param-key.js";
|
||||
import { getProviderEnvVars } from "../../secrets/provider-env-vars.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import type { DeliveryContext } from "../../utils/delivery-context.js";
|
||||
import { resolveVideoGenerationSupportedDurations } from "../../video-generation/duration-support.js";
|
||||
|
|
@ -21,11 +20,6 @@ import type {
|
|||
VideoGenerationSourceAsset,
|
||||
} from "../../video-generation/types.js";
|
||||
import { normalizeProviderId } from "../provider-id.js";
|
||||
import {
|
||||
buildVideoGenerationTaskStatusDetails,
|
||||
buildVideoGenerationTaskStatusText,
|
||||
findActiveVideoGenerationTaskForSession,
|
||||
} from "../video-generation-task-status.js";
|
||||
import {
|
||||
ToolInputError,
|
||||
readNumberParam,
|
||||
|
|
@ -60,6 +54,11 @@ import {
|
|||
type VideoGenerationTaskHandle,
|
||||
wakeVideoGenerationTaskCompletion,
|
||||
} from "./video-generate-background.js";
|
||||
import {
|
||||
createVideoGenerateDuplicateGuardResult,
|
||||
createVideoGenerateListActionResult,
|
||||
createVideoGenerateStatusActionResult,
|
||||
} from "./video-generate-tool.actions.js";
|
||||
|
||||
const log = createSubsystemLogger("agents/tools/video-generate");
|
||||
const MAX_INPUT_IMAGES = 5;
|
||||
|
|
@ -149,10 +148,6 @@ const VideoGenerateToolSchema = Type.Object({
|
|||
),
|
||||
});
|
||||
|
||||
function getVideoGenerationProviderAuthEnvVars(providerId: string): string[] {
|
||||
return getProviderEnvVars(providerId);
|
||||
}
|
||||
|
||||
function resolveVideoGenerationModelCandidates(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
agentDir?: string;
|
||||
|
|
@ -755,113 +750,18 @@ export function createVideoGenerateTool(options?: {
|
|||
applyVideoGenerationModelConfigDefaults(cfg, videoGenerationModelConfig) ?? cfg;
|
||||
|
||||
if (action === "list") {
|
||||
const providers = listRuntimeVideoGenerationProviders({ config: effectiveCfg });
|
||||
if (providers.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No video-generation providers are registered." }],
|
||||
details: { providers: [] },
|
||||
};
|
||||
}
|
||||
const lines = providers.map((provider) => {
|
||||
const authHints = getVideoGenerationProviderAuthEnvVars(provider.id);
|
||||
const capabilities = [
|
||||
provider.capabilities.maxVideos ? `maxVideos=${provider.capabilities.maxVideos}` : null,
|
||||
provider.capabilities.maxInputImages
|
||||
? `maxInputImages=${provider.capabilities.maxInputImages}`
|
||||
: null,
|
||||
provider.capabilities.maxInputVideos
|
||||
? `maxInputVideos=${provider.capabilities.maxInputVideos}`
|
||||
: null,
|
||||
provider.capabilities.maxDurationSeconds
|
||||
? `maxDurationSeconds=${provider.capabilities.maxDurationSeconds}`
|
||||
: null,
|
||||
provider.capabilities.supportedDurationSeconds?.length
|
||||
? `supportedDurationSeconds=${provider.capabilities.supportedDurationSeconds.join("/")}`
|
||||
: null,
|
||||
provider.capabilities.supportedDurationSecondsByModel &&
|
||||
Object.keys(provider.capabilities.supportedDurationSecondsByModel).length > 0
|
||||
? `supportedDurationSecondsByModel=${Object.entries(
|
||||
provider.capabilities.supportedDurationSecondsByModel,
|
||||
)
|
||||
.map(([modelId, durations]) => `${modelId}:${durations.join("/")}`)
|
||||
.join("; ")}`
|
||||
: null,
|
||||
provider.capabilities.supportsResolution ? "resolution" : null,
|
||||
provider.capabilities.supportsAspectRatio ? "aspectRatio" : null,
|
||||
provider.capabilities.supportsSize ? "size" : null,
|
||||
provider.capabilities.supportsAudio ? "audio" : null,
|
||||
provider.capabilities.supportsWatermark ? "watermark" : null,
|
||||
]
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join(", ");
|
||||
return [
|
||||
`${provider.id}: default=${provider.defaultModel ?? "none"}`,
|
||||
provider.models?.length ? `models=${provider.models.join(", ")}` : null,
|
||||
capabilities ? `capabilities=${capabilities}` : null,
|
||||
authHints.length > 0 ? `auth=${authHints.join(" / ")}` : null,
|
||||
]
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join(" | ");
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: {
|
||||
providers: providers.map((provider) => ({
|
||||
id: provider.id,
|
||||
defaultModel: provider.defaultModel,
|
||||
models: provider.models ?? [],
|
||||
authEnvVars: getVideoGenerationProviderAuthEnvVars(provider.id),
|
||||
capabilities: provider.capabilities,
|
||||
})),
|
||||
},
|
||||
};
|
||||
return createVideoGenerateListActionResult(effectiveCfg);
|
||||
}
|
||||
|
||||
if (action === "status") {
|
||||
const activeTask = findActiveVideoGenerationTaskForSession(options?.agentSessionKey);
|
||||
if (!activeTask) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No active video generation task is currently running for this session.",
|
||||
},
|
||||
],
|
||||
details: {
|
||||
action: "status",
|
||||
active: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: buildVideoGenerationTaskStatusText(activeTask),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
action: "status",
|
||||
...buildVideoGenerationTaskStatusDetails(activeTask),
|
||||
},
|
||||
};
|
||||
return createVideoGenerateStatusActionResult(options?.agentSessionKey);
|
||||
}
|
||||
|
||||
const activeTask = findActiveVideoGenerationTaskForSession(options?.agentSessionKey);
|
||||
if (activeTask) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: buildVideoGenerationTaskStatusText(activeTask, { duplicateGuard: true }),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
action: "status",
|
||||
duplicateGuard: true,
|
||||
...buildVideoGenerationTaskStatusDetails(activeTask),
|
||||
},
|
||||
};
|
||||
const duplicateGuardResult = createVideoGenerateDuplicateGuardResult(
|
||||
options?.agentSessionKey,
|
||||
);
|
||||
if (duplicateGuardResult) {
|
||||
return duplicateGuardResult;
|
||||
}
|
||||
|
||||
const prompt = readStringParam(args, "prompt", { required: true });
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
findActiveVideoGenerationTaskForSession,
|
||||
getVideoGenerationTaskProviderId,
|
||||
isActiveVideoGenerationTask,
|
||||
VIDEO_GENERATION_TASK_KIND,
|
||||
} from "./video-generation-task-status.js";
|
||||
|
||||
const taskRuntimeInternalMocks = vi.hoisted(() => ({
|
||||
|
|
@ -25,6 +26,7 @@ describe("video generation task status", () => {
|
|||
isActiveVideoGenerationTask({
|
||||
taskId: "task-1",
|
||||
runtime: "cli",
|
||||
taskKind: VIDEO_GENERATION_TASK_KIND,
|
||||
sourceId: "video_generate:openai",
|
||||
requesterSessionKey: "agent:main",
|
||||
ownerKey: "agent:main",
|
||||
|
|
@ -40,6 +42,7 @@ describe("video generation task status", () => {
|
|||
isActiveVideoGenerationTask({
|
||||
taskId: "task-2",
|
||||
runtime: "cron",
|
||||
taskKind: VIDEO_GENERATION_TASK_KIND,
|
||||
sourceId: "video_generate:openai",
|
||||
requesterSessionKey: "agent:main",
|
||||
ownerKey: "agent:main",
|
||||
|
|
@ -58,6 +61,7 @@ describe("video generation task status", () => {
|
|||
{
|
||||
taskId: "task-queued",
|
||||
runtime: "cli",
|
||||
taskKind: VIDEO_GENERATION_TASK_KIND,
|
||||
sourceId: "video_generate:google",
|
||||
requesterSessionKey: "agent:main",
|
||||
ownerKey: "agent:main",
|
||||
|
|
@ -71,6 +75,7 @@ describe("video generation task status", () => {
|
|||
{
|
||||
taskId: "task-running",
|
||||
runtime: "cli",
|
||||
taskKind: VIDEO_GENERATION_TASK_KIND,
|
||||
sourceId: "video_generate:openai",
|
||||
requesterSessionKey: "agent:main",
|
||||
ownerKey: "agent:main",
|
||||
|
|
@ -95,6 +100,7 @@ describe("video generation task status", () => {
|
|||
active: true,
|
||||
existingTask: true,
|
||||
status: "running",
|
||||
taskKind: VIDEO_GENERATION_TASK_KIND,
|
||||
provider: "openai",
|
||||
progressSummary: "Generating video",
|
||||
});
|
||||
|
|
@ -105,6 +111,7 @@ describe("video generation task status", () => {
|
|||
{
|
||||
taskId: "task-running",
|
||||
runtime: "cli",
|
||||
taskKind: VIDEO_GENERATION_TASK_KIND,
|
||||
sourceId: "video_generate:openai",
|
||||
requesterSessionKey: "agent:main",
|
||||
ownerKey: "agent:main",
|
||||
|
|
|
|||
|
|
@ -1,21 +1,18 @@
|
|||
import { listTasksForOwnerKey } from "../tasks/runtime-internal.js";
|
||||
import type { TaskRecord } from "../tasks/task-registry.types.js";
|
||||
import {
|
||||
buildSessionAsyncTaskStatusDetails,
|
||||
findActiveSessionTask,
|
||||
} from "./session-async-task-status.js";
|
||||
|
||||
const ACTIVE_VIDEO_GENERATION_STATUSES = new Set(["queued", "running"]);
|
||||
export const VIDEO_GENERATION_TASK_KIND = "video_generation";
|
||||
const VIDEO_GENERATION_SOURCE_PREFIX = "video_generate";
|
||||
|
||||
function isActiveStatus(status: string): boolean {
|
||||
return ACTIVE_VIDEO_GENERATION_STATUSES.has(status);
|
||||
}
|
||||
|
||||
export function isActiveVideoGenerationTask(task: TaskRecord): boolean {
|
||||
const sourceId = task.sourceId?.trim() ?? "";
|
||||
return (
|
||||
task.runtime === "cli" &&
|
||||
task.scopeKind === "session" &&
|
||||
isActiveStatus(task.status) &&
|
||||
(sourceId === VIDEO_GENERATION_SOURCE_PREFIX ||
|
||||
sourceId.startsWith(`${VIDEO_GENERATION_SOURCE_PREFIX}:`))
|
||||
task.taskKind === VIDEO_GENERATION_TASK_KIND &&
|
||||
(task.status === "queued" || task.status === "running")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -29,32 +26,18 @@ export function getVideoGenerationTaskProviderId(task: TaskRecord): string | und
|
|||
}
|
||||
|
||||
export function findActiveVideoGenerationTaskForSession(sessionKey?: string): TaskRecord | null {
|
||||
const normalizedSessionKey = sessionKey?.trim();
|
||||
if (!normalizedSessionKey) {
|
||||
return null;
|
||||
}
|
||||
const activeTasks = listTasksForOwnerKey(normalizedSessionKey).filter(
|
||||
isActiveVideoGenerationTask,
|
||||
);
|
||||
if (activeTasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return activeTasks.find((task) => task.status === "running") ?? activeTasks[0] ?? null;
|
||||
return findActiveSessionTask({
|
||||
sessionKey,
|
||||
runtime: "cli",
|
||||
taskKind: VIDEO_GENERATION_TASK_KIND,
|
||||
sourceIdPrefix: VIDEO_GENERATION_SOURCE_PREFIX,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildVideoGenerationTaskStatusDetails(task: TaskRecord): Record<string, unknown> {
|
||||
const provider = getVideoGenerationTaskProviderId(task);
|
||||
return {
|
||||
async: true,
|
||||
active: true,
|
||||
existingTask: true,
|
||||
status: task.status,
|
||||
task: {
|
||||
taskId: task.taskId,
|
||||
...(task.runId ? { runId: task.runId } : {}),
|
||||
},
|
||||
...(task.progressSummary ? { progressSummary: task.progressSummary } : {}),
|
||||
...(task.sourceId ? { sourceId: task.sourceId } : {}),
|
||||
...buildSessionAsyncTaskStatusDetails(task),
|
||||
...(provider ? { provider } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,6 +178,33 @@ describe("task-executor", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("persists explicit task kind metadata on created runs", async () => {
|
||||
await withTaskExecutorStateDir(async () => {
|
||||
const created = createRunningTaskRun({
|
||||
runtime: "cli",
|
||||
taskKind: "video_generation",
|
||||
sourceId: "video_generate:openai",
|
||||
ownerKey: "agent:main:main",
|
||||
scopeKind: "session",
|
||||
childSessionKey: "agent:main:main",
|
||||
runId: "run-executor-kind",
|
||||
task: "Generate lobster video",
|
||||
startedAt: 10,
|
||||
deliveryStatus: "not_applicable",
|
||||
});
|
||||
|
||||
expect(getTaskById(created.taskId)).toMatchObject({
|
||||
taskId: created.taskId,
|
||||
taskKind: "video_generation",
|
||||
sourceId: "video_generate:openai",
|
||||
});
|
||||
expect(findTaskByRunId("run-executor-kind")).toMatchObject({
|
||||
taskId: created.taskId,
|
||||
taskKind: "video_generation",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-creates a one-task flow and keeps it synced with task status", async () => {
|
||||
await withTaskExecutorStateDir(async () => {
|
||||
const created = createRunningTaskRun({
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ function ensureSingleTaskFlow(params: {
|
|||
|
||||
export function createQueuedTaskRun(params: {
|
||||
runtime: TaskRuntime;
|
||||
taskKind?: string;
|
||||
sourceId?: string;
|
||||
requesterSessionKey?: string;
|
||||
ownerKey?: string;
|
||||
|
|
@ -116,6 +117,7 @@ export function getFlowTaskSummary(flowId: string): TaskRegistrySummary {
|
|||
|
||||
export function createRunningTaskRun(params: {
|
||||
runtime: TaskRuntime;
|
||||
taskKind?: string;
|
||||
sourceId?: string;
|
||||
requesterSessionKey?: string;
|
||||
ownerKey?: string;
|
||||
|
|
|
|||
|
|
@ -651,6 +651,7 @@ function findExistingTaskForCreate(params: {
|
|||
function mergeExistingTaskForCreate(
|
||||
existing: TaskRecord,
|
||||
params: {
|
||||
taskKind?: string;
|
||||
requesterOrigin?: TaskDeliveryState["requesterOrigin"];
|
||||
sourceId?: string;
|
||||
parentFlowId?: string;
|
||||
|
|
@ -676,6 +677,9 @@ function mergeExistingTaskForCreate(
|
|||
if (params.sourceId?.trim() && !existing.sourceId?.trim()) {
|
||||
patch.sourceId = params.sourceId.trim();
|
||||
}
|
||||
if (params.taskKind?.trim() && !existing.taskKind?.trim()) {
|
||||
patch.taskKind = params.taskKind.trim();
|
||||
}
|
||||
if (params.parentFlowId?.trim() && !existing.parentFlowId?.trim()) {
|
||||
assertParentFlowLinkAllowed({
|
||||
ownerKey: existing.ownerKey,
|
||||
|
|
@ -1357,6 +1361,7 @@ function ensureListener() {
|
|||
|
||||
export function createTaskRecord(params: {
|
||||
runtime: TaskRuntime;
|
||||
taskKind?: string;
|
||||
sourceId?: string;
|
||||
requesterSessionKey?: string;
|
||||
ownerKey?: string;
|
||||
|
|
@ -1431,6 +1436,7 @@ export function createTaskRecord(params: {
|
|||
const record: TaskRecord = {
|
||||
taskId,
|
||||
runtime: params.runtime,
|
||||
taskKind: params.taskKind?.trim() || undefined,
|
||||
sourceId: params.sourceId?.trim() || undefined,
|
||||
requesterSessionKey,
|
||||
ownerKey,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export type TaskDeliveryState = {
|
|||
export type TaskRecord = {
|
||||
taskId: string;
|
||||
runtime: TaskRuntime;
|
||||
taskKind?: string;
|
||||
sourceId?: string;
|
||||
requesterSessionKey: string;
|
||||
ownerKey: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue