feat: add official game audio generation client

This commit is contained in:
2026-09-21 12:17:32 +08:00
parent d222d17500
commit 09e0ce5cf3
28 changed files with 1835 additions and 31 deletions

View File

@@ -82,9 +82,10 @@ async function installCodingFirstChatHost(
hostConnection: HostConnection,
featureComplete = false,
managedCapabilities = false,
audioPreview?: { executionId: string; path: string; dataUrl: string },
): Promise<void> {
await electronApp.evaluate(async (_, payload) => {
const { connection, featureComplete, managedCapabilities } = payload;
const { connection, featureComplete, managedCapabilities, audioPreview } = payload;
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
type MainState = {
captured: CapturedRequest[];
@@ -456,6 +457,9 @@ async function installCodingFirstChatHost(
) => {
const path = request.path ?? '';
const method = request.method ?? 'GET';
if (audioPreview && path === '/api/coding/game-audio/' + audioPreview.executionId + '/outputs/0') {
return respond({ path: audioPreview.path, dataUrl: audioPreview.dataUrl });
}
const body = typeof request.body === 'string' && request.body
? JSON.parse(request.body) as Record<string, unknown>
: undefined;
@@ -737,9 +741,71 @@ async function installCodingFirstChatHost(
if (path === '/api/coding/runtime/diagnostics') return respond({ runtime: { revision: { provider: 1, resources: 1 }, workers: [{ conversationId: conversation.id, generation: 1, state: 'running', stage: 'running' }] } });
return respond({ success: false, error: `Unhandled E2E route: ${method} ${path}` }, 404);
});
}, { connection: hostConnection, featureComplete, managedCapabilities });
}, { connection: hostConnection, featureComplete, managedCapabilities, audioPreview });
}
test('saved Game Audio offers click-only playable local preview in Electron', async ({ launchElectronApp }, testInfo) => {
const app = await launchElectronApp({ skipSetup: true });
let page = await getStableWindow(app);
const connection = await page.evaluate(async () => ({
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
}));
const executionId = '00000000-0000-4000-8000-000000000601';
const relative = 'assets/generated/game-audio/' + executionId + '/sound-1.wav';
// Synthetic quarter-second PCM sample, never a claimed Meowa output.
const wav = Buffer.alloc(4044);
wav.write('RIFF', 0); wav.writeUInt32LE(4036, 4); wav.write('WAVEfmt ', 8);
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28);
wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34);
wav.write('data', 36); wav.writeUInt32LE(4000, 40);
await installCodingFirstChatHost(app, connection, true, false, {
executionId, path: relative, dataUrl: 'data:audio/wav;base64,' + wav.toString('base64'),
});
await settleSnapshot(app);
await disableCodingEventSource(page);
await page.reload();
page = await getStableWindow(app);
await page.getByTestId('ai-module-option-programming').click();
await page.evaluate(() => { window.location.hash = '/chat'; });
await expect(page.getByTestId('coding-conversation-header')).toBeVisible();
const snapshot = await page.evaluate(async () => {
const response = await window.electron.ipcRenderer.invoke('hostapi:fetch', {
path: '/api/coding/conversations/conversation-pi-first-chat/snapshot', method: 'GET',
}) as { data: { json: { snapshot: Record<string, unknown> } } };
return response.data.json.snapshot;
});
const details = {
schema: 'makelore-capability.v1', plugin_id: 'makelore.game-audio', plugin_version: '1.0.0',
capability_id: 'game-audio.sound', operation: 'generate', request_id: 'pi:r:t',
success: true, status: 200, code: null, error: null, retryable: false, payload_schema: 'game-audio.v1',
billing: { mode: 'platform_metered', status: 'settled', reserved_points: '0.04',
actual_points: '0.04', usage_amount: 4, unit: 'half_second' },
data: { executionId, logicalOperationId: 'pi:r:t', projectId: 'project-e2e',
providerStatus: 'succeeded', deliveryStatus: 'saved', phase: 'saved', outputCount: 1,
files: [{ index: 0, path: relative, bytes: wav.length, mimeType: 'audio/wav' }] },
};
await emitCodingEvent(page, 'snapshot', {
type: 'snapshot', conversationId: 'conversation-pi-first-chat', workerGeneration: 1, seq: 10,
snapshot: { ...snapshot, cursor: { workerGeneration: 1, seq: 10 }, nodes: [{
kind: 'tool', id: 'audio-preview', toolCallId: 'audio-preview', toolName: 'game_sound_generate',
title: '生成游戏音效', status: 'complete', inputText: '', output: [], details,
}] },
});
await expect(page.getByTestId('tool-progress-preview')).toContainText('游戏音频 · 已保存');
await page.getByTestId('coding-process-group').locator('summary').first().click();
await page.locator('[data-node-id="audio-preview"] > summary').click();
await expect(page.locator('audio')).toHaveCount(0);
await page.getByRole('button', { name: '试听 sound-1.wav' }).click();
const player = page.getByLabel('试听 sound-1.wav', { exact: true });
await expect(player).toHaveAttribute('controls', '');
expect(await player.getAttribute('autoplay')).toBeNull();
await expect.poll(() => player.evaluate((element) => (element as HTMLAudioElement).duration)).toBe(0.25);
expect(await player.evaluate((element) => (element as HTMLAudioElement).paused)).toBe(true);
await page.screenshot({ path: testInfo.outputPath('audio-preview.png') });
});
async function readState(electronApp: ElectronApplication): Promise<{
captured: CapturedRequest[];
snapshotPending: boolean;

View File

@@ -12,15 +12,19 @@ vi.mock('@/lib/coding-attachments', () => ({
describe('CodingConversationTimeline', () => {
afterEach(() => vi.unstubAllGlobals());
it('shows one Game Resource progress card from generation through automatic project save', async () => {
it.each([
['makelore.game-resource', '游戏资源'],
['makelore.game-audio', '游戏音频'],
])('shows one %s progress card through automatic project save', async (pluginId, label) => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const base = createProductSnapshot('conversation-game-resource-progress', 1);
const conversationId = 'conversation-progress-' + pluginId;
const base = createProductSnapshot(conversationId, 1);
const details = (phase: 'saving' | 'saved') => ({
schema: 'makelore-capability.v1' as const,
plugin_id: 'makelore.game-resource',
plugin_id: pluginId,
plugin_version: '1.0.0',
capability_id: 'game-resource.generate',
operation: 'generate',
@@ -82,28 +86,28 @@ describe('CodingConversationTimeline', () => {
};
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-game-resource-progress',
conversationId,
workerGeneration: 1,
seq: base.cursor.seq,
snapshot: snapshot(base.cursor.seq, 'saving'),
});
render(<CodingConversationTimeline conversationId="conversation-game-resource-progress" />);
render(<CodingConversationTimeline conversationId={conversationId} />);
expect(screen.getByTestId('tool-progress-preview')).toHaveTextContent(
'游戏资源 · 正在保存到项目',
label + ' · 正在保存到项目',
);
const nextSeq = base.cursor.seq + 1;
act(() => codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-game-resource-progress',
conversationId,
workerGeneration: 1,
seq: nextSeq,
snapshot: snapshot(nextSeq, 'saved'),
}));
await waitFor(() => expect(screen.getByTestId('tool-progress-preview'))
.toHaveTextContent('游戏资源 · 已保存 2 个文件'));
.toHaveTextContent(label + ' · 已保存 2 个文件'));
});
it('resolves attachment refs into temporary object URLs without base64 state', async () => {

View File

@@ -9,6 +9,8 @@ import {
DATA_SERVICE_PLUGIN_DEFINITION,
GAME_RESOURCE_BUNDLED_RELEASE_ID,
GAME_RESOURCE_PLUGIN_ID,
GAME_AUDIO_PLUGIN_ID,
GAME_AUDIO_BUNDLED_RELEASE_ID,
PROJECT_SCAFFOLD_BUNDLED_RELEASE_ID,
PROJECT_SCAFFOLD_PLUGIN_ID,
type CodingPluginDefinition,
@@ -458,11 +460,14 @@ describe('effective plugin resolver', () => {
expect(refresh).not.toHaveBeenCalled();
});
it('materializes an acquired code-owned bundled hosted Plugin without Package Store bytes or Agent assignment', async () => {
it.each([
[GAME_RESOURCE_PLUGIN_ID, GAME_RESOURCE_BUNDLED_RELEASE_ID],
[GAME_AUDIO_PLUGIN_ID, GAME_AUDIO_BUNDLED_RELEASE_ID],
])('materializes acquired bundled %s without Package Store bytes or Agent assignment', async (pluginId, releaseId) => {
const bundled: CodingPluginDefinition = {
...serverDefinition,
id: GAME_RESOURCE_PLUGIN_ID,
releaseId: GAME_RESOURCE_BUNDLED_RELEASE_ID,
id: pluginId,
releaseId,
provenance: { source: 'bundled', packageRoot: 'game-resource' },
skills: [{
id: 'game-resource',

View File

@@ -38,13 +38,15 @@ describe('bundled coding plugin manifests', () => {
'data-service',
'game-resource',
'project-scaffold',
'game-audio',
]);
expect(resolveBundledCodingPluginRootPaths(path.resolve('resources/coding-plugins'))).toEqual([
PACKAGE_ROOT,
GAME_RESOURCE_ROOT,
PROJECT_SCAFFOLD_ROOT,
path.resolve('resources/coding-plugins/game-audio'),
]);
expect(definitions).toHaveLength(3);
expect(definitions).toHaveLength(4);
expect(definitions[0]).toMatchObject({
id: 'makelore.data-service',
adapterId: 'data-service',
@@ -53,7 +55,7 @@ describe('bundled coding plugin manifests', () => {
});
expect(definitions[0]?.tools.map(({ name }) => name)).toEqual(DATA_SERVICE_TOOL_NAMES);
expect(definitions[0]).toEqual(DATA_SERVICE_PLUGIN_DEFINITION);
expect(definitions.slice(1)).toMatchObject([
expect(definitions.slice(1, 3)).toMatchObject([
{
id: 'makelore.game-resource', version: '1.0.0', runtimeKind: 'platform_hosted',
acquisitionMode: 'user_acquired', releaseId: '00000000-0000-4000-8000-000000000105',
@@ -160,6 +162,7 @@ describe('bundled coding plugin manifests', () => {
path.resolve('tmp/data-service'),
path.resolve('tmp/game-resource'),
path.resolve('tmp/project-scaffold'),
path.resolve('tmp/game-audio'),
]);
});

View File

@@ -46,6 +46,16 @@ async function invoke(ctx: HostApiContext, method: string, target: string, body?
}
describe('coding plugin Host routes', () => {
it('previews only a receipt output, without accepting project paths or remote URLs', async () => {
const readSavedOutput = vi.fn().mockResolvedValue({ path: 'assets/generated/game-audio/file.mp3', dataUrl: 'data:audio/mpeg;base64,SUQz' });
const ctx = { codingProducts: { gameAudio: { readSavedOutput } } } as unknown as HostApiContext;
const id = '00000000-0000-4000-8000-000000000601';
const route = '/api/coding/game-audio/' + id + '/outputs/0';
expect(await invoke(ctx, 'GET', route)).toMatchObject({ handled: true, status: 200 });
expect(readSavedOutput).toHaveBeenCalledWith(id, 0);
expect(await invoke(ctx, 'GET', route + '?projectPath=/forged')).toMatchObject({ status: 400 });
expect(readSavedOutput).toHaveBeenCalledTimes(1);
});
it('uses only the local project handle for GET and preserves the bounded projection', async () => {
const projection = {
schemaVersion: 1 as const,

View File

@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest';
import { GameAudioClient } from '../../electron/services/game-audio-client';
const execution = '00000000-0000-4000-8000-000000000601';
const release = '00000000-0000-4000-8000-000000000505';
const project = '00000000-0000-4000-8000-000000000602';
const receipt = () => ({
schema_version: 1, plugin_id: 'makelore.game-audio', execution_id: execution,
release_id: release, project_id: project, logical_operation_id: 'audio-request',
kind: 'sound', provider_status: 'succeeded', output_ready: true,
outputs: [{ index: 0, name: 'sound-1' }], progress_percent: 100,
poll_interval_seconds: 2, error_code: null,
billing: { mode: 'platform_metered', status: 'settled', reserved_points: '0.16',
actual_points: '0.04', usage_amount: 4, unit: 'half_second' },
});
const command = {
release_id: release, release_admission_id: project, project_id: project,
logical_operation_id: 'audio-request', pricing_version_id: project,
kind: 'sound' as const, mode: 'single' as const, duration: 2, count: 1,
loop: false, prompt: 'A coin chime', confirmed: true as const,
};
function setup(fetchImpl: typeof fetch) {
return new GameAudioClient({ fetchImpl, apiBaseUrl: 'https://square.test',
getAccessToken: async () => 'session-token',
getAccountBinding: () => ({ accountKey: 'owner', epoch: 1 }) });
}
describe('Game Audio typed Main transport', () => {
it('normalizes nullable unsettled amounts into the shared optional receipt field', async () => {
const wire = { ...receipt(), provider_status: 'running', output_ready: false, outputs: [],
billing: { ...receipt().billing, status: 'dispatched', actual_points: null } };
const job = await setup(vi.fn<typeof fetch>().mockResolvedValue(Response.json(wire))).get(execution, 'owner');
expect(job.billing).not.toHaveProperty('actual_points');
});
it('uses fixed routes and retries a 401 only once with exactly the same command', async () => {
const fetchImpl = vi.fn<typeof fetch>()
.mockResolvedValueOnce(new Response('', { status: 401 }))
.mockResolvedValueOnce(Response.json(receipt()));
const client = setup(fetchImpl);
expect(await client.generate(command, 'owner')).toEqual(receipt());
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(fetchImpl.mock.calls[0]?.[0]).toBe('https://square.test/api/plugins/v1/hosted/game-audio/generations');
expect(fetchImpl.mock.calls[0]?.[1]?.body).toBe(JSON.stringify(command));
expect(fetchImpl.mock.calls[1]?.[1]?.body).toBe(fetchImpl.mock.calls[0]?.[1]?.body);
});
it('rejects contradictory output/billing and response identity', async () => {
for (const value of [
{ ...receipt(), output_ready: false },
{ ...receipt(), execution_id: '../escape' },
{ ...receipt(), project_id: execution },
{ ...receipt(), billing: { ...receipt().billing, actual_points: 'bad' } },
{ ...receipt(), outputs: [{ index: 1, name: 'sound-2' }] },
]) {
await expect(setup(vi.fn<typeof fetch>().mockResolvedValue(Response.json(value)))
.generate(command, 'owner')).rejects.toMatchObject({ code: 'plugin_backend_invalid' });
}
});
it('checks account binding before network and again after a response', async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(Response.json(receipt()));
await expect(setup(fetchImpl).get(execution, 'other')).rejects.toMatchObject({ code: 'plugin_account_changed' });
expect(fetchImpl).not.toHaveBeenCalled();
let epoch = 1;
const client = new GameAudioClient({
getAccessToken: async () => 'token', getAccountBinding: () => ({ accountKey: 'owner', epoch }),
fetchImpl: async () => { epoch++; return Response.json(receipt()); },
});
await expect(client.get(execution, 'owner')).rejects.toMatchObject({ code: 'plugin_account_changed' });
});
it('bounds chunked JSON and downloads only supported audio MIME', async () => {
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
pull(controller) { controller.enqueue(new Uint8Array(600_000)); },
cancel() { cancelled = true; },
});
await expect(setup(vi.fn<typeof fetch>().mockResolvedValue(new Response(body)))
.get(execution, 'owner')).rejects.toMatchObject({ code: 'plugin_backend_invalid' });
expect(cancelled).toBe(true);
await expect(setup(vi.fn<typeof fetch>().mockResolvedValue(new Response('html', {
headers: { 'Content-Type': 'text/html' },
}))).download(execution, 0, 'owner')).rejects.toMatchObject({ code: 'game_audio_content_invalid' });
const audio = await setup(vi.fn<typeof fetch>().mockResolvedValue(new Response('ID3test', {
headers: { 'Content-Type': 'audio/mpeg' },
}))).download(execution, 0, 'owner');
expect(audio.extension).toBe('mp3');
});
});

View File

@@ -0,0 +1,127 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PiProjectWriteLeaseCoordinator } from '../../electron/coding-runtime/pi/write-lease';
import { GameAudioDeliveryCoordinator, GameAudioReceiptStore } from '../../electron/services/game-audio-delivery';
import { GameAudioClientError, type AudioCommand, type AudioGeneration } from '../../electron/services/game-audio-client';
const directories: string[] = [];
afterEach(async () => { await Promise.all(directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); });
const execution = '00000000-0000-4000-8000-000000000601';
const project = '00000000-0000-4000-8000-000000000602';
const release = '00000000-0000-4000-8000-000000000505';
const command: AudioCommand = { release_id: release, release_admission_id: project,
project_id: project, logical_operation_id: 'request', pricing_version_id: project, prompt: 'coin',
confirmed: true, kind: 'sound', mode: 'pack', count: 2, duration: 2, loop: false };
function generation(active = false): AudioGeneration {
return { schema_version: 1, plugin_id: 'makelore.game-audio', execution_id: execution, release_id: release,
project_id: project, logical_operation_id: 'request', kind: 'sound', provider_status: active ? 'running' : 'succeeded',
output_ready: !active, outputs: active ? [] : [{ index: 0, name: 'sound-1' }, { index: 1, name: 'sound-2' }],
progress_percent: active ? 40 : 100, poll_interval_seconds: 2, error_code: null,
billing: { mode: 'platform_metered', status: active ? 'dispatched' : 'settled',
reserved_points: '0.08', actual_points: active ? null : '0.08', usage_amount: 8, unit: 'half_second' } };
}
async function setup() {
const root = await mkdtemp(path.join(os.tmpdir(), 'makelore-audio-'));
directories.push(root);
const receipts = new GameAudioReceiptStore(path.join(root, 'user-data', 'receipts.json'));
const context = { accountKey: 'owner', conversationId: 'conversation', runId: 'run',
localProjectId: 'local-project', durableProjectId: project, projectPath: root, logicalOperationId: 'request' };
const leases = new PiProjectWriteLeaseCoordinator();
const client = {
generate: vi.fn(async () => generation(true)), get: vi.fn(async () => generation()),
byOperation: vi.fn(async () => generation()),
download: vi.fn(async () => ({ bytes: new TextEncoder().encode('ID3audio'), extension: 'mp3', mimeType: 'audio/mpeg' })),
};
const recordTouchedPaths = vi.fn(async () => undefined);
const options = { client, receipts, leases, recordTouchedPaths, getAccountKey: () => 'owner',
sleep: vi.fn(async () => undefined) };
return { root, context, receipts, leases, client, options };
}
describe('Game Audio durable automatic delivery', () => {
it('persists intent before POST and saves all outputs under the frozen project without another confirmation', async () => {
const s = await setup();
s.client.generate.mockImplementation(async () => {
expect((await s.receipts.list())[0]?.context).toEqual(s.context);
expect(s.leases.activeCount).toBe(0);
return generation(true);
});
s.client.download.mockImplementation(async () => {
expect(s.leases.activeCount).toBe(0);
return { bytes: new TextEncoder().encode('ID3audio'), extension: 'mp3', mimeType: 'audio/mpeg' };
});
const delivery = new GameAudioDeliveryCoordinator(s.options);
const progress = vi.fn();
const result = await delivery.generateAndMaterialize(s.context, command, progress);
expect(result.deliveryStatus).toBe('saved');
expect(result.files).toHaveLength(2);
for (const file of result.files) expect(await readFile(path.join(s.root, file.path), 'utf8')).toBe('ID3audio');
expect(result.files[1]?.path).toBe('assets/generated/game-audio/' + execution + '/sound-2.mp3');
expect(s.options.recordTouchedPaths).toHaveBeenCalled();
expect(progress.mock.calls.some(([value]) => value.phase === 'generating')).toBe(true);
await delivery.generateAndMaterialize(s.context, command);
expect(s.client.generate).toHaveBeenCalledTimes(1);
expect(s.client.download).toHaveBeenCalledTimes(2);
expect(await delivery.readSavedOutput(execution, 1)).toEqual({
path: result.files[1]?.path, dataUrl: 'data:audio/mpeg;base64,' + Buffer.from('ID3audio').toString('base64'),
});
await expect(delivery.readSavedOutput(execution, 9)).rejects.toMatchObject({ status: 404 });
const other = new GameAudioDeliveryCoordinator({ ...s.options, getAccountKey: () => 'other' });
await expect(other.readSavedOutput(execution, 0)).rejects.toMatchObject({ status: 404 });
other.dispose();
delivery.dispose();
});
it('recovers a lost POST response with by-operation, never re-submits', async () => {
const s = await setup();
s.client.generate.mockRejectedValue(new Error('response lost'));
const delivery = new GameAudioDeliveryCoordinator(s.options);
expect((await delivery.generateAndMaterialize(s.context, command)).deliveryStatus).toBe('saved');
expect(s.client.byOperation).toHaveBeenCalledWith('request', 'owner');
expect(s.client.generate).toHaveBeenCalledTimes(1);
delivery.dispose();
});
it('reports a definite pre-dispatch rejection as not started and never resumes it', async () => {
const s = await setup();
s.client.generate.mockRejectedValue(new GameAudioClientError('plugin_pricing_changed', 409, 'Price changed'));
const delivery = new GameAudioDeliveryCoordinator(s.options);
expect(await delivery.generateAndMaterialize(s.context, command))
.toMatchObject({ providerStatus: 'failed', billing: { status: 'not_started' } });
await delivery.resumePending();
expect(s.client.byOperation).not.toHaveBeenCalled();
delivery.dispose();
});
it('automatically resumes a transient download failure without resubmitting', async () => {
const s = await setup();
s.client.download.mockRejectedValueOnce(new Error('offline'));
vi.useFakeTimers();
const delivery = new GameAudioDeliveryCoordinator(s.options);
try {
expect((await delivery.generateAndMaterialize(s.context, command)).deliveryStatus).toBe('delivery_failed');
await vi.advanceTimersByTimeAsync(30_000);
vi.useRealTimers();
await vi.waitFor(async () => expect((await s.receipts.list())[0]?.deliveryStatus).toBe('saved'));
expect(s.client.generate).toHaveBeenCalledTimes(1);
} finally { delivery.dispose(); vi.useRealTimers(); }
});
it('resumes only the missing output after restart and pauses for another account', async () => {
const s = await setup();
s.client.download.mockResolvedValueOnce({ bytes: new TextEncoder().encode('ID3one'), extension: 'mp3', mimeType: 'audio/mpeg' })
.mockRejectedValue(new Error('download offline'));
const first = new GameAudioDeliveryCoordinator(s.options);
expect((await first.generateAndMaterialize(s.context, command)).deliveryStatus).toBe('delivery_failed');
first.dispose();
s.client.download.mockClear().mockResolvedValue({ bytes: new TextEncoder().encode('ID3two'), extension: 'mp3', mimeType: 'audio/mpeg' });
const other = new GameAudioDeliveryCoordinator({ ...s.options, getAccountKey: () => 'other' });
await other.resumePending();
expect(s.client.download).not.toHaveBeenCalled();
other.dispose();
const restored = new GameAudioDeliveryCoordinator(s.options);
await restored.resumePending();
expect(s.client.generate).toHaveBeenCalledTimes(1);
expect(s.client.download).toHaveBeenCalledTimes(1);
expect(s.client.download).toHaveBeenCalledWith(execution, 1, 'owner');
expect((await s.receipts.list())[0]?.deliveryStatus).toBe('saved');
restored.dispose();
});
});

View File

@@ -0,0 +1,19 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, expect, it, vi } from 'vitest';
import { GameAudioFiles } from '../../src/pages/Chat/GameAudioFiles';
const mocks = vi.hoisted(() => ({ read: vi.fn() }));
vi.mock('@/lib/host-api', () => ({ hostApiFetch: mocks.read }));
afterEach(() => vi.unstubAllGlobals());
it('loads only saved local audio on explicit preview; never auto-plays or asks to save again', async () => {
const executionId = '00000000-0000-4000-8000-000000000601';
const relative = 'assets/generated/game-audio/00000000-0000-4000-8000-000000000601/sound-1.mp3';
mocks.read.mockResolvedValue({ path: relative, dataUrl: 'data:audio/mpeg;base64,SUQz' });
const { unmount } = render(<GameAudioFiles data={{ executionId, files: [{ index: 0, path: relative, bytes: 3 }] }} />);
expect(mocks.read).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '试听 sound-1.mp3' }));
const player = await screen.findByLabelText('试听 sound-1.mp3', { selector: 'audio' });
expect(player).toHaveAttribute('controls');
expect(player).not.toHaveAttribute('autoplay');
expect(mocks.read).toHaveBeenCalledWith('/api/coding/game-audio/' + executionId + '/outputs/0');
unmount();
});

View File

@@ -0,0 +1,99 @@
// @vitest-environment node
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { loadBundledCodingPluginDefinitionsSync } from '../../electron/coding-plugins/manifest';
import { isProjectWideCodingPluginId } from '../../shared/coding-plugins';
import { GameAudioPluginAdapter } from '../../electron/coding-plugins/adapters/game-audio';
import { CodingCapabilityRegistryImpl } from '../../electron/coding-plugins/registry';
import type { PluginPolicyClientState } from '../../electron/services/plugin-policy-client';
import type { AudioDeliveryResult } from '../../electron/services/game-audio-delivery';
const context = { conversationId: 'conversation', runId: 'run', resourceId: 'tool',
requestId: 'trusted-id', localProjectId: 'local', projectPath: '/project',
durableProjectId: 'durable', workerRole: 'parent' as const, effectiveSkillIds: ['game-audio'],
pluginReleaseId: '00000000-0000-4000-8000-000000000505' };
function definition() {
return loadBundledCodingPluginDefinitionsSync(path.resolve('resources/coding-plugins'))
.find((item) => item.id === 'makelore.game-audio')!;
}
describe('official Game Audio plugin', () => {
it('passes normalized receipts through the registry with progress and saved files', async () => {
const plugin = definition();
const delivered: AudioDeliveryResult = {
executionId: '00000000-0000-4000-8000-000000000601', logicalOperationId: 'request',
providerStatus: 'succeeded', deliveryStatus: 'saved', phase: 'saved', progressPercent: 100,
outputCount: 1, files: [{ index: 0, path: 'assets/generated/game-audio/00000000-0000-4000-8000-000000000601/music-1.mp3', bytes: 10, mimeType: 'audio/mpeg' }],
billing: { mode: 'platform_metered', status: 'settled', reserved_points: '1.00', actual_points: '1.00', usage_amount: 1, unit: 'generation' },
};
const adapter = new GameAudioPluginAdapter({
delivery: { generateAndMaterialize: async (_context, _command, progress) => {
progress?.({ ...delivered, providerStatus: 'running', deliveryStatus: 'waiting', phase: 'generating', files: [],
billing: { mode: 'platform_metered', status: 'dispatched', reserved_points: '1.00', usage_amount: 1, unit: 'generation' } });
return delivered;
} },
client: { cancel: vi.fn() },
admission: { resolve: vi.fn(async () => ({ releaseId: context.pluginReleaseId, releaseAdmissionId: 'admission' })) },
getPricingVersion: () => 'price', getAccountKey: () => 'owner',
});
const policy: PluginPolicyClientState = { status: 'current', revision: 1, lastVerifiedAt: 1,
catalog: { schema_version: 1, catalog_version: '1', pricing_version: 'price',
plugins: [{ plugin_id: plugin.id, supported_contract_versions: [1], status: 'active',
capabilities: plugin.operations.map((op) => ({ capability_id: op.capabilityId, operations: [{
operation: op.operation, billing: { mode: 'platform_metered', entitlement_scope: 'plugin_usage', notice: 'Metered',
unit_name: 'generation', unit_size: 1, rate_points: '1.00', minimum_charge_points: '0.00', rounding_mode: 'ceil' },
}] })) }] } };
const registry = new CodingCapabilityRegistryImpl({ definitions: [plugin], adapters: [adapter],
getEnabledPluginIds: async () => [plugin.id], policyClient: { getState: () => policy, refresh: vi.fn() } });
const onUpdate = vi.fn();
const output = await registry.invoke({ toolName: 'game_music_generate', workerRole: 'parent',
effectiveSkillIds: ['game-audio'], context: { conversationId: 'c', runId: 'r', resourceId: 't',
projectId: 'p', projectPath: '/project', skillIds: ['game-audio'] },
value: { prompt: 'forest', confirmed: true }, onUpdate });
expect(output.details).toMatchObject({ success: true, data: { files: delivered.files } });
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({
details: expect.objectContaining({ success: true, billing: { ...delivered.billing, status: 'dispatched', actual_points: undefined } }),
}));
});
it('ships one project-wide Skill and exactly four statically priced parent tools', () => {
const plugin = definition();
expect(plugin).toBeDefined();
expect(isProjectWideCodingPluginId(plugin.id)).toBe(true);
expect(plugin.tools.map((tool) => [tool.name, tool.capabilityId, tool.operation])).toEqual([
['game_music_generate', 'game-audio.music', 'generate'],
['game_music_preview', 'game-audio.music', 'preview'],
['game_sound_generate', 'game-audio.sound', 'generate'],
['game_audio_cancel', 'game-audio.control', 'cancel'],
]);
expect(plugin.tools.every((tool) => tool.roles.length === 1 && tool.roles[0] === 'parent' && !tool.projectWriteLease)).toBe(true);
});
it('requires literal confirmation; Main freezes identity, mode and trusted price before auto-delivery', async () => {
const generateAndMaterialize = vi.fn(async (): Promise<AudioDeliveryResult> => ({
executionId: null, logicalOperationId: 'trusted-id', providerStatus: 'running',
deliveryStatus: 'waiting', files: [], outputCount: 0, phase: 'generating', progressPercent: 40,
billing: { mode: 'platform_metered', status: 'receipt_unavailable' },
}));
const resolve = vi.fn(async () => ({ releaseId: context.pluginReleaseId, releaseAdmissionId: 'admission' }));
const adapter = new GameAudioPluginAdapter({
delivery: { generateAndMaterialize },
client: { cancel: vi.fn() }, admission: { resolve },
getPricingVersion: () => 'trusted-price', getAccountKey: () => 'owner',
});
const tool = definition().tools[1]!;
expect(await adapter.invoke(context, tool, { prompt: 'ambient forest', confirmed: 1 }))
.toMatchObject({ success: false, code: 'confirmation_required', billing: { status: 'not_started' } });
expect(generateAndMaterialize).not.toHaveBeenCalled();
expect(await adapter.invoke(context, tool, { prompt: 'ambient forest', confirmed: true }))
.toMatchObject({ success: true, status: 202 });
expect(generateAndMaterialize).toHaveBeenCalledWith(expect.objectContaining({
accountKey: 'owner', logicalOperationId: 'trusted-id', projectPath: '/project',
}), expect.objectContaining({
kind: 'music', mode: 'demo', pricing_version_id: 'trusted-price', project_id: 'durable',
release_id: context.pluginReleaseId, release_admission_id: 'admission', logical_operation_id: 'trusted-id',
}), expect.any(Function));
expect(await adapter.invoke(context, tool, { prompt: 'x', confirmed: true, mode: 'full' }))
.toMatchObject({ success: false, code: 'plugin_input_invalid' });
generateAndMaterialize.mockRejectedValueOnce(new Error('receipt disk failed after POST'));
expect(await adapter.invoke(context, tool, { prompt: 'forest', confirmed: true }))
.toMatchObject({ success: false, billing: { status: 'receipt_unavailable' } });
});
});

View File

@@ -30,6 +30,7 @@ const MARKETPLACE_ARTIFACT_TEXT = [
'makelore-plugin-release.v1', 'skill_only', 'platform_hosted',
'plugin_signature_invalid', 'signing key is not trusted',
'makelore.game-resource', '/api/plugins/v1/hosted/game-resource/generations',
'makelore.game-audio', '/api/plugins/v1/hosted/game-audio/generations',
'makelore-model-tool.v1', 'model.web-search', 'forced_search',
'makelore-device-package.v1', '/api/coding/device-packages', 'device-parent-workers',
'/api/coding/plugin-marketplace',
@@ -399,6 +400,7 @@ describe('final Pi product artifact verification', () => {
await writeFile(path.join(source, 'dist', 'assets', 'plugin-marketplace.js'), [
'makelore-plugin-release.v1 skill_only platform_hosted plugin_signature_invalid signing key is not trusted',
'makelore.game-resource /api/plugins/v1/hosted/game-resource/generations',
'makelore.game-audio /api/plugins/v1/hosted/game-audio/generations',
'makelore-model-tool.v1 model.web-search forced_search',
'makelore-device-package.v1 /api/coding/device-packages device-parent-workers',
'/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/',
@@ -419,6 +421,7 @@ describe('final Pi product artifact verification', () => {
await writeFile(path.join(staleSource, 'dist-electron', 'stale-marketplace.js'), [
'makelore-plugin-release.v1 skill_only platform_hosted plugin_signature_invalid signing key is not trusted',
'makelore.game-resource /api/plugins/v1/hosted/game-resource/generations',
'makelore.game-audio /api/plugins/v1/hosted/game-audio/generations',
'/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/',
'effectiveSkillIds pluginReleaseIds',
'/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 为你的智能体添加插件,拓展更多能力。 acquire enable_project /project-config/plugins',