fix: apply missed media/runtime follow-ups from merged PRs

This commit is contained in:
Peter Steinberger
2026-03-02 21:45:29 +00:00
parent f2b37f0aa9
commit a183656f8f
9 changed files with 205 additions and 9 deletions

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { MediaAttachment } from "./types.js";
import { selectAttachments } from "./attachments.js";
import type { MediaAttachment } from "./types.js";
describe("media-understanding selectAttachments guards", () => {
it("does not throw when attachments is undefined", () => {
@@ -26,4 +26,21 @@ describe("media-understanding selectAttachments guards", () => {
expect(run).not.toThrow();
expect(run()).toEqual([]);
});
it("ignores malformed attachment entries inside an array", () => {
const run = () =>
selectAttachments({
capability: "audio",
attachments: [
null,
{ index: 1, path: 123 },
{ index: 2, url: true },
{ index: 3, mime: { nope: true } },
] as unknown as MediaAttachment[],
policy: { prefer: "path" },
});
expect(run).not.toThrow();
expect(run()).toEqual([]);
});
});

View File

@@ -169,7 +169,7 @@ function orderAttachments(
attachments: MediaAttachment[],
prefer?: MediaUnderstandingAttachmentsConfig["prefer"],
): MediaAttachment[] {
const list = Array.isArray(attachments) ? attachments : [];
const list = Array.isArray(attachments) ? attachments.filter(isAttachmentRecord) : [];
if (!prefer || prefer === "first") {
return list;
}
@@ -189,13 +189,36 @@ function orderAttachments(
return list;
}
function isAttachmentRecord(value: unknown): value is MediaAttachment {
if (!value || typeof value !== "object") {
return false;
}
const entry = value as Record<string, unknown>;
if (typeof entry.index !== "number") {
return false;
}
if (entry.path !== undefined && typeof entry.path !== "string") {
return false;
}
if (entry.url !== undefined && typeof entry.url !== "string") {
return false;
}
if (entry.mime !== undefined && typeof entry.mime !== "string") {
return false;
}
if (entry.alreadyTranscribed !== undefined && typeof entry.alreadyTranscribed !== "boolean") {
return false;
}
return true;
}
export function selectAttachments(params: {
capability: MediaUnderstandingCapability;
attachments: MediaAttachment[];
policy?: MediaUnderstandingAttachmentsConfig;
}): MediaAttachment[] {
const { capability, attachments, policy } = params;
const input = Array.isArray(attachments) ? attachments : [];
const input = Array.isArray(attachments) ? attachments.filter(isAttachmentRecord) : [];
const matches = input.filter((item) => {
// Skip already-transcribed audio attachments from preflight
if (capability === "audio" && item.alreadyTranscribed) {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { MediaUnderstandingDecision } from "./types.js";
import { formatDecisionSummary } from "./runner.entries.js";
import type { MediaUnderstandingDecision } from "./types.js";
describe("media-understanding formatDecisionSummary guards", () => {
it("does not throw when decision.attachments is undefined", () => {
@@ -26,4 +26,26 @@ describe("media-understanding formatDecisionSummary guards", () => {
expect(run).not.toThrow();
expect(run()).toBe("video: skipped (0/1)");
});
it("ignores non-string provider/model/reason fields", () => {
const run = () =>
formatDecisionSummary({
capability: "audio",
outcome: "failed",
attachments: [
{
attachmentIndex: 0,
chosen: {
outcome: "failed",
provider: { bad: true },
model: 42,
},
attempts: [{ reason: { malformed: true } }],
},
],
} as unknown as MediaUnderstandingDecision);
expect(run).not.toThrow();
expect(run()).toBe("audio: failed (0/1)");
});
});

View File

@@ -350,15 +350,17 @@ export function formatDecisionSummary(decision: MediaUnderstandingDecision): str
const total = attachments.length;
const success = attachments.filter((entry) => entry?.chosen?.outcome === "success").length;
const chosen = attachments.find((entry) => entry?.chosen)?.chosen;
const provider = chosen?.provider?.trim();
const model = chosen?.model?.trim();
const provider = typeof chosen?.provider === "string" ? chosen.provider.trim() : undefined;
const model = typeof chosen?.model === "string" ? chosen.model.trim() : undefined;
const modelLabel = provider ? (model ? `${provider}/${model}` : provider) : undefined;
const reason = attachments
.flatMap((entry) => {
const attempts = Array.isArray(entry?.attempts) ? entry.attempts : [];
return attempts.map((attempt) => attempt?.reason).filter(Boolean);
return attempts
.map((attempt) => (typeof attempt?.reason === "string" ? attempt.reason : undefined))
.filter((value): value is string => Boolean(value));
})
.find(Boolean);
.find((value) => value.trim().length > 0);
const shortReason = reason ? reason.split(":")[0]?.trim() : undefined;
const countLabel = total > 0 ? ` (${success}/${total})` : "";
const viaLabel = modelLabel ? ` via ${modelLabel}` : "";

View File

@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
const {
normalizeMediaAttachments,
createMediaAttachmentCache,
buildProviderRegistry,
runCapability,
cacheCleanup,
} = vi.hoisted(() => {
const normalizeMediaAttachments = vi.fn();
const cacheCleanup = vi.fn(async () => {});
const createMediaAttachmentCache = vi.fn(() => ({ cleanup: cacheCleanup }));
const buildProviderRegistry = vi.fn(() => new Map());
const runCapability = vi.fn();
return {
normalizeMediaAttachments,
createMediaAttachmentCache,
buildProviderRegistry,
runCapability,
cacheCleanup,
};
});
vi.mock("./runner.js", () => ({
normalizeMediaAttachments,
createMediaAttachmentCache,
buildProviderRegistry,
runCapability,
}));
import { transcribeAudioFile } from "./transcribe-audio.js";
describe("transcribeAudioFile", () => {
beforeEach(() => {
vi.clearAllMocks();
cacheCleanup.mockResolvedValue(undefined);
});
it("does not force audio/wav when mime is omitted", async () => {
normalizeMediaAttachments.mockReturnValue([{ index: 0, path: "/tmp/note.mp3" }]);
runCapability.mockResolvedValue({
outputs: [{ kind: "audio.transcription", text: " hello " }],
});
const result = await transcribeAudioFile({
filePath: "/tmp/note.mp3",
cfg: {} as OpenClawConfig,
});
expect(normalizeMediaAttachments).toHaveBeenCalledWith({
MediaPath: "/tmp/note.mp3",
MediaType: undefined,
});
expect(result).toEqual({ text: "hello" });
expect(cacheCleanup).toHaveBeenCalledTimes(1);
});
it("returns undefined and skips cache when there are no attachments", async () => {
normalizeMediaAttachments.mockReturnValue([]);
const result = await transcribeAudioFile({
filePath: "/tmp/missing.wav",
cfg: {} as OpenClawConfig,
});
expect(result).toEqual({ text: undefined });
expect(createMediaAttachmentCache).not.toHaveBeenCalled();
expect(runCapability).not.toHaveBeenCalled();
});
it("always cleans up cache on errors", async () => {
const cfg = {
tools: { media: { audio: { timeoutSeconds: 10 } } },
} as unknown as OpenClawConfig;
normalizeMediaAttachments.mockReturnValue([{ index: 0, path: "/tmp/note.wav" }]);
runCapability.mockRejectedValue(new Error("boom"));
await expect(
transcribeAudioFile({
filePath: "/tmp/note.wav",
cfg,
}),
).rejects.toThrow("boom");
expect(runCapability).toHaveBeenCalledWith(
expect.objectContaining({
capability: "audio",
cfg,
config: cfg.tools?.media?.audio,
}),
);
expect(cacheCleanup).toHaveBeenCalledTimes(1);
});
});

View File

@@ -23,7 +23,7 @@ export async function transcribeAudioFile(params: {
}): Promise<{ text: string | undefined }> {
const ctx = {
MediaPath: params.filePath,
MediaType: params.mime ?? "audio/wav",
MediaType: params.mime,
};
const attachments = normalizeMediaAttachments(ctx);
if (attachments.length === 0) {