mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
CLI media models with a missing command or empty argument list passed Doctor. When a command existed but args were absent, the runtime invoked the transcriber without the attachment. Doctor and execution now share the media resolver's minimum-input check. Doctor reports the exact configuration path and a manual fix; execution records a typed refusal before spawning and continues configured fallbacks. Gateway startup validation remains unchanged. Custom and literal arguments still work, and a valid command returning empty stdout still completes. The Discord fixture now supplies attachment arguments for its empty-output case and waits for owned work to settle on refusals, preserving warning, no-dispatch, and WAV-cleanup coverage. Release-note context: report incomplete CLI media models in Doctor and prevent command-only transcription attempts while preserving Gateway startup, custom arguments, and configured fallbacks. Fixes #151883. Thanks to @fede-kamel for reporting and clarifying the missing-args failure. No reporter code was adopted. Validation: 203 focused tests reported passing, check:changed passing, clean Codex review, and exact-head CI run 35385552484 successful. Landing REST verification found 217 completed checks: 170 successful, 46 skipped, one neutral, and no failures or pending checks. The follow-up from the reviewed production head changes only the Discord integration test. The published-updater compatibility check was explicitly skipped by maintainer decision: Doctor adds advisory diagnostics only, with no persisted-shape, migration, update-path, or service-lifecycle change. No updater compatibility run is claimed.
286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import { PassThrough } from "node:stream";
|
|
import { setImmediate } from "node:timers/promises";
|
|
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
|
|
import { transcribeAudioFile } from "openclaw/plugin-sdk/media-understanding-runtime";
|
|
import { vi } from "vitest";
|
|
import { loadDiscordVoiceTestHarness } from "../extensions/discord/test-api.js";
|
|
import type { MediaUnderstandingModelConfig } from "../src/config/types.tools.js";
|
|
import { createPluginMetadataSnapshotFixture } from "../src/plugins/plugin-metadata.test-support.js";
|
|
import { createEmptyPluginRegistry } from "../src/plugins/registry-empty.js";
|
|
import { withPluginRuntimeGenerationScope } from "../src/plugins/runtime/generation-scope.js";
|
|
|
|
const { defineDiscordVoiceTests } = await loadDiscordVoiceTestHarness();
|
|
const cli = vi.hoisted(() => vi.fn());
|
|
vi.mock("../src/process/exec.js", () => ({ runExec: cli }));
|
|
|
|
defineDiscordVoiceTests(
|
|
({
|
|
expect,
|
|
it,
|
|
createManager,
|
|
makeVoiceConfig,
|
|
getSessionEntry,
|
|
getSessionConnection,
|
|
handleSpeakingStart,
|
|
startTranscripts,
|
|
decodeOpusStreamChunksMock,
|
|
transcribeAudioFileMock,
|
|
agentCommandMock,
|
|
controlRealtimeVoiceAgentRunMock,
|
|
loggerWarnMock,
|
|
receiveRecordedSpeech,
|
|
}) => {
|
|
it.each(
|
|
["conversation", "control"].flatMap((dispatch) =>
|
|
["oversized", "empty CLI", "fallback"].map((opening) => ({ dispatch, opening })),
|
|
),
|
|
)(
|
|
"preserves whole-utterance $dispatch across capture with $opening opening audio",
|
|
async ({ dispatch, opening }) => {
|
|
const suffix = dispatch === "control" ? "stop that" : "send the update";
|
|
const oversized = opening === "oversized";
|
|
const prefix = oversized ? "Do not" : dispatch === "control" ? "please" : "Opening context";
|
|
const openingFrames = oversized ? 300 : 50;
|
|
cli
|
|
.mockReset()
|
|
.mockResolvedValueOnce({ stdout: "", stderr: "" })
|
|
.mockResolvedValue({ stdout: suffix, stderr: "" });
|
|
const provider = vi.fn(async ({ buffer, model }: { buffer: Buffer; model?: string }) => {
|
|
if (model === "unavailable-stt") {
|
|
throw new Error("synthetic model unavailable");
|
|
}
|
|
return { text: buffer[44] === 1 ? prefix : suffix };
|
|
});
|
|
const registry = createEmptyPluginRegistry();
|
|
registry.mediaUnderstandingProviders.push({
|
|
pluginId: "synthetic-audio",
|
|
source: "test/transcripts-discord-audio.integration.test.ts",
|
|
provider: { id: "synthetic-audio", capabilities: ["audio"], transcribeAudio: provider },
|
|
});
|
|
// Keep the synthetic runtime and discovery inventory in one generation;
|
|
// transcription must not materialize unrelated bundled plugin owners.
|
|
const metadataSnapshot = createPluginMetadataSnapshotFixture({
|
|
plugins: [
|
|
{
|
|
id: "synthetic-audio",
|
|
contracts: { mediaUnderstandingProviders: ["synthetic-audio"] },
|
|
},
|
|
],
|
|
});
|
|
const apiModel: MediaUnderstandingModelConfig = {
|
|
provider: "synthetic-audio",
|
|
model: "synthetic-stt",
|
|
capabilities: ["audio"],
|
|
};
|
|
const models: MediaUnderstandingModelConfig[] =
|
|
opening === "empty CLI"
|
|
? [
|
|
{
|
|
type: "cli",
|
|
command: "synthetic-stt",
|
|
// The process succeeds with empty stdout; its input is still valid.
|
|
args: ["{{AttachmentPath}}"],
|
|
capabilities: ["audio"],
|
|
},
|
|
]
|
|
: [
|
|
...(opening === "fallback" ? [{ ...apiModel, model: "unavailable-stt" }] : []),
|
|
apiModel,
|
|
];
|
|
const cfg = {
|
|
tools: {
|
|
media: {
|
|
audio: { maxBytes: 1_048_576 },
|
|
models,
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
"synthetic-audio": {
|
|
baseUrl: "https://unused.invalid",
|
|
apiKey: "synthetic-fixture-key",
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const manager = createManager(
|
|
makeVoiceConfig({}, { groupPolicy: "open", allowFrom: ["discord:u-owner"] }),
|
|
undefined,
|
|
cfg,
|
|
);
|
|
await manager.join({ guildId: "g1", channelId: "1001" });
|
|
const entry = getSessionEntry(manager);
|
|
const conversations = vi.spyOn(entry.conversations, "enqueue");
|
|
if (dispatch === "control") {
|
|
controlRealtimeVoiceAgentRunMock.mockImplementation(async () => {
|
|
return {
|
|
ok: true,
|
|
mode: "cancel",
|
|
sessionKey: entry.route.sessionKey,
|
|
active: true,
|
|
aborted: true,
|
|
message: "Cancelled",
|
|
speak: false,
|
|
show: true,
|
|
suppress: true,
|
|
};
|
|
});
|
|
}
|
|
const stream = new PassThrough({ objectMode: true });
|
|
getSessionConnection(entry).receiver.subscribe.mockReturnValueOnce(stream);
|
|
const openingDecoded = createDeferred<void>();
|
|
decodeOpusStreamChunksMock.mockImplementation(async (input, options) => {
|
|
let frames = 0;
|
|
for await (const packet of input) {
|
|
await options.onChunk(packet, packet);
|
|
if (++frames === openingFrames) {
|
|
openingDecoded.resolve();
|
|
}
|
|
}
|
|
});
|
|
const wavSizes: number[] = [];
|
|
const wavPaths: string[] = [];
|
|
const results: Awaited<ReturnType<typeof transcribeAudioFile>>[] = [];
|
|
transcribeAudioFileMock.mockImplementation(async (params) => {
|
|
wavPaths.push(params.filePath);
|
|
wavSizes.push((await fs.stat(params.filePath)).size);
|
|
const result = await withPluginRuntimeGenerationScope(
|
|
{ metadataSnapshot, pluginRegistry: registry },
|
|
() => transcribeAudioFile(params),
|
|
);
|
|
results.push(result);
|
|
return result;
|
|
});
|
|
const recorded = createDeferred<void>();
|
|
const sink = vi.fn(() => recorded.resolve());
|
|
const receiving = handleSpeakingStart(manager, entry, "u-owner");
|
|
const receivingOutcome = oversized
|
|
? expect(receiving).rejects.toThrow("speak a shorter segment")
|
|
: receiving;
|
|
const recordingStream = oversized ? new PassThrough({ objectMode: true }) : stream;
|
|
try {
|
|
// Main rejects oversized uncaptured input before writing a WAV. A later
|
|
// capture scan may record its suffix, but cannot admit it as a new command.
|
|
// Successful-empty and fallback siblings use a one-second opening chunk.
|
|
for (let frame = 0; frame < openingFrames; frame++) {
|
|
if (stream.destroyed) {
|
|
break;
|
|
}
|
|
stream.write(Buffer.alloc(3_840, 1));
|
|
await setImmediate();
|
|
}
|
|
if (oversized) {
|
|
await receivingOutcome;
|
|
expect(stream.destroyed).toBe(true);
|
|
expect(transcribeAudioFileMock).not.toHaveBeenCalled();
|
|
getSessionConnection(entry).receiver.subscribe.mockReturnValueOnce(recordingStream);
|
|
entry.connection.receiver.speaking.users.set("u-owner", Date.now());
|
|
} else {
|
|
await openingDecoded.promise;
|
|
}
|
|
expect(await startTranscripts(manager, sink)).toMatchObject({ ok: true });
|
|
for (let frame = 0; frame < 50; frame++) {
|
|
recordingStream.write(Buffer.alloc(3_840, 2));
|
|
}
|
|
recordingStream.end();
|
|
await receivingOutcome;
|
|
if (oversized) {
|
|
// The capture scan owns a new receive operation after the rejected opening.
|
|
await recorded.promise;
|
|
}
|
|
await entry.processingQueue;
|
|
// Join owned work even when transcription fails and produces no sink/dispatch call.
|
|
await Promise.all(conversations.mock.results.map((result) => result.value));
|
|
expect(loggerWarnMock).not.toHaveBeenCalledWith(expect.stringContaining("cli-missing-"));
|
|
expect(wavSizes).toEqual(oversized ? [192_044] : [192_044, 192_044]);
|
|
expect(results[0]).toMatchObject({
|
|
text: oversized ? suffix : opening === "fallback" ? prefix : undefined,
|
|
decision: {
|
|
outcome: opening === "empty CLI" ? "skipped" : "success",
|
|
attachmentDispositions: {
|
|
0: { kind: opening === "empty CLI" ? "failed" : "handled" },
|
|
},
|
|
attachmentProcessing: { 0: "completed" },
|
|
},
|
|
});
|
|
expect(provider).toHaveBeenCalledTimes(opening === "empty CLI" ? 0 : oversized ? 1 : 4);
|
|
expect(cli).toHaveBeenCalledTimes(opening === "empty CLI" ? 2 : 0);
|
|
if (oversized) {
|
|
expect(provider.mock.calls[0]![0].buffer).toHaveLength(192_044);
|
|
expect(provider.mock.calls[0]![0].buffer[44]).toBe(2);
|
|
}
|
|
expect(sink).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ text: suffix }));
|
|
for (const wavPath of wavPaths) {
|
|
await expect(fs.stat(wavPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
}
|
|
const completeText = opening === "fallback" ? `${prefix}\n${suffix}` : suffix;
|
|
if (oversized) {
|
|
expect(agentCommandMock).not.toHaveBeenCalled();
|
|
expect(controlRealtimeVoiceAgentRunMock).not.toHaveBeenCalled();
|
|
} else if (dispatch === "control") {
|
|
expect(controlRealtimeVoiceAgentRunMock).toHaveBeenCalledExactlyOnceWith({
|
|
getToolAuthorityOverlay: expect.any(Function),
|
|
sessionKey: entry.route.sessionKey,
|
|
text: completeText,
|
|
});
|
|
expect(agentCommandMock).not.toHaveBeenCalled();
|
|
} else {
|
|
expect(agentCommandMock).toHaveBeenCalledOnce();
|
|
expect(agentCommandMock.mock.calls[0]?.[0]).toMatchObject({
|
|
message: expect.stringContaining(completeText),
|
|
});
|
|
expect(controlRealtimeVoiceAgentRunMock).not.toHaveBeenCalled();
|
|
}
|
|
} finally {
|
|
stream.end();
|
|
recordingStream.end();
|
|
await receivingOutcome;
|
|
await entry.processingQueue;
|
|
conversations.mockRestore();
|
|
await manager.destroy();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ command: undefined, args: ["{{AttachmentPath}}"], reason: "cli-missing-command" },
|
|
{ command: "synthetic-stt", args: undefined, reason: "cli-missing-attachment-arg" },
|
|
])("finishes voice capture with a warning for $reason", async ({ command, args, reason }) => {
|
|
cli.mockReset();
|
|
const manager = createManager(
|
|
makeVoiceConfig({}, { groupPolicy: "open", allowFrom: ["discord:u-owner"] }),
|
|
undefined,
|
|
{ tools: { media: { models: [{ type: "cli", command, args, capabilities: ["audio"] }] } } },
|
|
);
|
|
await manager.join({ guildId: "g1", channelId: "1001" });
|
|
const sink = vi.fn();
|
|
expect(await startTranscripts(manager, sink)).toMatchObject({ ok: true });
|
|
const wavPaths: string[] = [];
|
|
transcribeAudioFileMock.mockImplementation(async (params) => {
|
|
wavPaths.push(params.filePath);
|
|
return await withPluginRuntimeGenerationScope(
|
|
{
|
|
metadataSnapshot: createPluginMetadataSnapshotFixture({ plugins: [] }),
|
|
pluginRegistry: createEmptyPluginRegistry(),
|
|
},
|
|
() => transcribeAudioFile(params),
|
|
);
|
|
});
|
|
// This joins receive, recording, and conversation completion, including refusal.
|
|
await receiveRecordedSpeech(manager);
|
|
expect(transcribeAudioFileMock).toHaveBeenCalledOnce();
|
|
expect(cli).not.toHaveBeenCalled();
|
|
expect(loggerWarnMock).toHaveBeenCalledWith(
|
|
expect.stringContaining(`discord voice: recording failed: ${reason}; Set `),
|
|
);
|
|
expect(sink).not.toHaveBeenCalled();
|
|
expect(agentCommandMock).not.toHaveBeenCalled();
|
|
expect(controlRealtimeVoiceAgentRunMock).not.toHaveBeenCalled();
|
|
for (const wavPath of wavPaths) {
|
|
await expect(fs.stat(wavPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
}
|
|
});
|
|
},
|
|
);
|