feat: integrate automatic game resource delivery
This commit is contained in:
@@ -5,7 +5,10 @@ import type { PluginPolicyClientState } from '../../electron/services/plugin-pol
|
||||
import {
|
||||
CodingCapabilityRegistryImpl,
|
||||
} from '../../electron/coding-plugins/registry';
|
||||
import type { CodingPluginAdapter } from '../../electron/coding-plugins/registry';
|
||||
import type {
|
||||
AdapterInvocationResult,
|
||||
CodingPluginAdapter,
|
||||
} from '../../electron/coding-plugins/registry';
|
||||
import { createDataServicePluginAdapter } from '../../electron/coding-plugins/adapters/data-service';
|
||||
import {
|
||||
createDataServiceOperations,
|
||||
@@ -277,14 +280,28 @@ describe('CodingCapabilityRegistry', () => {
|
||||
getPolicyState: vi.fn(() => hostedPolicy),
|
||||
getInstalledDefinition: vi.fn(async () => hostedDefinition),
|
||||
} as unknown as EffectivePluginResolver;
|
||||
const invoke = vi.fn(async () => ({
|
||||
success: true as const, status: 202, code: null, error: null, retryable: false as const,
|
||||
payload_schema: 'game-resource.v1', data: { executionId: 'execution-a', status: 'accepted' },
|
||||
billing: {
|
||||
mode: 'platform_metered' as const, status: 'dispatched' as const,
|
||||
reserved_points: '2.00', usage_amount: 1, unit: 'generation',
|
||||
},
|
||||
}));
|
||||
const dispatchedBilling = {
|
||||
mode: 'platform_metered' as const, status: 'dispatched' as const,
|
||||
reserved_points: '2.00', usage_amount: 1, unit: 'generation',
|
||||
};
|
||||
const invoke = vi.fn(async (
|
||||
_context: unknown,
|
||||
_tool: unknown,
|
||||
_value: unknown,
|
||||
onProgress?: (result: AdapterInvocationResult) => void,
|
||||
) => {
|
||||
onProgress?.({
|
||||
success: true, status: 202, code: null, error: null, retryable: false,
|
||||
payload_schema: 'game-resource.v1',
|
||||
data: { phase: 'generating', executionId: 'execution-a' },
|
||||
billing: dispatchedBilling,
|
||||
});
|
||||
return {
|
||||
success: true as const, status: 202, code: null, error: null, retryable: false as const,
|
||||
payload_schema: 'game-resource.v1', data: { executionId: 'execution-a', status: 'accepted' },
|
||||
billing: dispatchedBilling,
|
||||
};
|
||||
});
|
||||
const capabilityRegistry = registry({
|
||||
definitions: [], effectiveResolver,
|
||||
adapters: [{ pluginId: hostedDefinition.id, inspect: async () => ({ status: 'ready' }), invoke }],
|
||||
@@ -292,10 +309,12 @@ describe('CodingCapabilityRegistry', () => {
|
||||
getEnabledPluginIds: async () => [hostedDefinition.id],
|
||||
});
|
||||
|
||||
const onUpdate = vi.fn();
|
||||
const result = await capabilityRegistry.invoke({
|
||||
toolName: 'game_resource_generate',
|
||||
context: { ...context, skillIds: ['game-resource'], effectiveSnapshot: frozenSnapshot },
|
||||
workerRole: 'parent', effectiveSkillIds: ['game-resource'], value: { kind: 'pixel' },
|
||||
onUpdate,
|
||||
});
|
||||
|
||||
expect(effectiveResolver.getInstalledDefinition).toHaveBeenCalledWith(
|
||||
@@ -305,7 +324,17 @@ describe('CodingCapabilityRegistry', () => {
|
||||
expect(invoke).toHaveBeenCalledWith(expect.objectContaining({
|
||||
requestId: 'pi:run-a:resource-a', workerRole: 'parent', effectiveSkillIds: ['game-resource'],
|
||||
pluginReleaseId: hostedDefinition.releaseId,
|
||||
}), hostedDefinition.tools[0], { kind: 'pixel' });
|
||||
}), hostedDefinition.tools[0], { kind: 'pixel' }, expect.any(Function));
|
||||
expect(onUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
details: expect.objectContaining({
|
||||
schema: 'makelore-capability.v1',
|
||||
plugin_id: hostedDefinition.id,
|
||||
operation: 'generate',
|
||||
status: 202,
|
||||
data: { phase: 'generating', executionId: 'execution-a' },
|
||||
billing: dispatchedBilling,
|
||||
}),
|
||||
}));
|
||||
expect(result.details).toMatchObject({
|
||||
schema: 'makelore-capability.v1', plugin_id: hostedDefinition.id,
|
||||
capability_id: 'game-resource.generate', operation: 'generate',
|
||||
|
||||
@@ -12,6 +12,100 @@ vi.mock('@/lib/coding-attachments', () => ({
|
||||
describe('CodingConversationTimeline', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('shows one Game Resource progress card from generation through automatic project save', async () => {
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
const { CodingConversationTimeline } = await import(
|
||||
'@/pages/Chat/CodingConversationTimeline'
|
||||
);
|
||||
const base = createProductSnapshot('conversation-game-resource-progress', 1);
|
||||
const details = (phase: 'saving' | 'saved') => ({
|
||||
schema: 'makelore-capability.v1' as const,
|
||||
plugin_id: 'makelore.game-resource',
|
||||
plugin_version: '1.0.0',
|
||||
capability_id: 'game-resource.generate',
|
||||
operation: 'generate',
|
||||
request_id: 'pi:game-run:game-tool',
|
||||
success: true,
|
||||
status: phase === 'saved' ? 200 : 202,
|
||||
code: null,
|
||||
error: null,
|
||||
retryable: false,
|
||||
billing: phase === 'saved' ? {
|
||||
mode: 'platform_metered' as const,
|
||||
status: 'settled' as const,
|
||||
reserved_points: '2.00',
|
||||
actual_points: '2.00',
|
||||
usage_amount: 1,
|
||||
unit: 'generation',
|
||||
} : {
|
||||
mode: 'platform_metered' as const,
|
||||
status: 'dispatched' as const,
|
||||
reserved_points: '2.00',
|
||||
usage_amount: 1,
|
||||
unit: 'generation',
|
||||
},
|
||||
payload_schema: 'game-resource.v1',
|
||||
data: {
|
||||
phase,
|
||||
executionId: '11111111-1111-4111-8111-111111111111',
|
||||
providerStatus: 'succeeded',
|
||||
deliveryStatus: phase,
|
||||
outputCount: 2,
|
||||
files: phase === 'saved' ? [
|
||||
{ path: 'assets/generated/game-resource/execution/output-1.png', bytes: 128 },
|
||||
{ path: 'assets/generated/game-resource/execution/output-2.png', bytes: 256 },
|
||||
] : [],
|
||||
},
|
||||
});
|
||||
const snapshot = (seq: number, phase: 'saving' | 'saved') => {
|
||||
const toolDetails = details(phase);
|
||||
return {
|
||||
...base,
|
||||
cursor: { ...base.cursor, seq },
|
||||
nodes: [{
|
||||
kind: 'tool' as const,
|
||||
id: 'tool-game-resource-progress',
|
||||
toolCallId: 'game-tool',
|
||||
toolName: 'game_resource_generate',
|
||||
title: '生成游戏资源',
|
||||
inputText: '',
|
||||
status: phase === 'saved' ? 'complete' as const : 'running' as const,
|
||||
output: [{
|
||||
kind: 'text' as const,
|
||||
id: `tool-output-game-resource-${phase}`,
|
||||
text: JSON.stringify(toolDetails),
|
||||
status: phase === 'saved' ? 'complete' as const : 'streaming' as const,
|
||||
}],
|
||||
details: toolDetails,
|
||||
}],
|
||||
};
|
||||
};
|
||||
codingConversationStore.getState().applySnapshotEvent({
|
||||
type: 'snapshot',
|
||||
conversationId: 'conversation-game-resource-progress',
|
||||
workerGeneration: 1,
|
||||
seq: base.cursor.seq,
|
||||
snapshot: snapshot(base.cursor.seq, 'saving'),
|
||||
});
|
||||
|
||||
render(<CodingConversationTimeline conversationId="conversation-game-resource-progress" />);
|
||||
|
||||
expect(screen.getByTestId('tool-progress-preview')).toHaveTextContent(
|
||||
'游戏资源 · 正在保存到项目',
|
||||
);
|
||||
|
||||
const nextSeq = base.cursor.seq + 1;
|
||||
act(() => codingConversationStore.getState().applySnapshotEvent({
|
||||
type: 'snapshot',
|
||||
conversationId: 'conversation-game-resource-progress',
|
||||
workerGeneration: 1,
|
||||
seq: nextSeq,
|
||||
snapshot: snapshot(nextSeq, 'saved'),
|
||||
}));
|
||||
await waitFor(() => expect(screen.getByTestId('tool-progress-preview'))
|
||||
.toHaveTextContent('游戏资源 · 已保存 2 个文件'));
|
||||
});
|
||||
|
||||
it('resolves attachment refs into temporary object URLs without base64 state', async () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:authenticated-preview');
|
||||
const revokeObjectURL = vi.fn();
|
||||
|
||||
@@ -72,6 +72,20 @@ describe('bundled coding plugin manifests', () => {
|
||||
tools: [],
|
||||
},
|
||||
]);
|
||||
expect(definitions[1]?.tools.map(({ name }) => name)).toEqual([
|
||||
'game_resource_templates',
|
||||
'game_resource_generate',
|
||||
'game_resource_cancel',
|
||||
'game_asset_browser',
|
||||
'game_asset_review',
|
||||
]);
|
||||
const gameResourceSkill = await readFile(
|
||||
path.join(GAME_RESOURCE_ROOT, 'skills/game-resource/SKILL.md'),
|
||||
'utf8',
|
||||
);
|
||||
expect(gameResourceSkill).toContain('保存全部输出到当前项目');
|
||||
expect(gameResourceSkill).not.toContain('game_resource_status');
|
||||
expect(gameResourceSkill).not.toContain('game_resource_save_output');
|
||||
expect(startupDefinitions).toEqual(definitions);
|
||||
expect(Object.isFrozen(startupDefinitions)).toBe(true);
|
||||
expect(Object.isFrozen(startupDefinitions[0]?.operations)).toBe(true);
|
||||
|
||||
494
tests/unit/game-resource-delivery.test.ts
Normal file
494
tests/unit/game-resource-delivery.test.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } 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 {
|
||||
GameResourceDeliveryCoordinator,
|
||||
GameResourceDeliveryReceiptStore,
|
||||
} from '../../electron/services/game-resource-delivery';
|
||||
import type {
|
||||
GameResourceClient,
|
||||
GameResourceGeneration,
|
||||
} from '../../electron/services/game-resource-client';
|
||||
|
||||
const EXECUTION_ID = '11111111-1111-4111-8111-111111111111';
|
||||
const RELEASE_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const PROJECT_ID = '33333333-3333-4333-8333-333333333333';
|
||||
const roots: string[] = [];
|
||||
|
||||
const dispatchedBilling = {
|
||||
mode: 'platform_metered' as const,
|
||||
status: 'dispatched' as const,
|
||||
reserved_points: '2.00',
|
||||
usage_amount: 1,
|
||||
unit: 'generation',
|
||||
};
|
||||
|
||||
const settledBilling = {
|
||||
mode: 'platform_metered' as const,
|
||||
status: 'settled' as const,
|
||||
reserved_points: '2.00',
|
||||
actual_points: '2.00',
|
||||
usage_amount: 1,
|
||||
unit: 'generation',
|
||||
};
|
||||
|
||||
function generation(
|
||||
status: GameResourceGeneration['status'],
|
||||
overrides: Partial<GameResourceGeneration> = {},
|
||||
): GameResourceGeneration {
|
||||
return {
|
||||
executionId: EXECUTION_ID,
|
||||
releaseId: RELEASE_ID,
|
||||
projectId: PROJECT_ID,
|
||||
logicalOperationId: 'pi:run-a:resource-a',
|
||||
kind: 'pixel',
|
||||
templateName: 'character',
|
||||
status,
|
||||
outputCount: status === 'succeeded' ? 2 : 0,
|
||||
pollIntervalSeconds: 3,
|
||||
billing: status === 'succeeded' ? settledBilling : dispatchedBilling,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function fixture(overrides: {
|
||||
generate?: () => Promise<GameResourceGeneration>;
|
||||
get?: () => Promise<GameResourceGeneration>;
|
||||
download?: (executionId: string, outputIndex?: number) => Promise<{
|
||||
bytes: Uint8Array;
|
||||
fileName: string | null;
|
||||
contentType: string;
|
||||
}>;
|
||||
writeOutput?: (filePath: string, bytes: Uint8Array) => Promise<void>;
|
||||
sleep?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
|
||||
acquireBackgroundLease?: () => () => void;
|
||||
} = {}) {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-game-delivery-'));
|
||||
const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-game-receipts-'));
|
||||
roots.push(root, userDataDir);
|
||||
const generate = vi.fn(overrides.generate ?? (async () => generation('accepted')));
|
||||
const get = vi.fn(overrides.get ?? (async () => generation('succeeded')));
|
||||
const download = vi.fn(overrides.download ?? (async (_executionId, index = 0) => ({
|
||||
bytes: new Uint8Array([index + 1, index + 2]),
|
||||
fileName: index === 0 ? 'hero.png' : 'portrait.webp',
|
||||
contentType: index === 0 ? 'image/png' : 'image/webp',
|
||||
})));
|
||||
const client = { generate, get, download } as unknown as Pick<
|
||||
GameResourceClient,
|
||||
'generate' | 'get' | 'download'
|
||||
>;
|
||||
const leases = new PiProjectWriteLeaseCoordinator();
|
||||
const touched = vi.fn(async () => undefined);
|
||||
const progress = vi.fn();
|
||||
const receiptStore = new GameResourceDeliveryReceiptStore(
|
||||
path.join(userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'),
|
||||
);
|
||||
const coordinator = new GameResourceDeliveryCoordinator({
|
||||
client,
|
||||
receipts: receiptStore,
|
||||
leases,
|
||||
sleep: overrides.sleep ?? (async () => undefined),
|
||||
recordTouchedPaths: touched,
|
||||
...(overrides.writeOutput ? { writeOutput: overrides.writeOutput } : {}),
|
||||
...(overrides.acquireBackgroundLease
|
||||
? { acquireBackgroundLease: overrides.acquireBackgroundLease }
|
||||
: {}),
|
||||
});
|
||||
const context = {
|
||||
conversationId: 'conversation-a',
|
||||
runId: 'run-a',
|
||||
localProjectId: 'local-project-a',
|
||||
durableProjectId: PROJECT_ID,
|
||||
projectPath: root,
|
||||
logicalOperationId: 'pi:run-a:resource-a',
|
||||
};
|
||||
const request = {
|
||||
releaseAdmissionId: 'admission-a',
|
||||
releaseId: RELEASE_ID,
|
||||
projectId: PROJECT_ID,
|
||||
logicalOperationId: context.logicalOperationId,
|
||||
kind: 'pixel' as const,
|
||||
templateName: 'character',
|
||||
templateConfig: {},
|
||||
requirement: 'A blue-armored hero',
|
||||
};
|
||||
return {
|
||||
coordinator, receiptStore, context, request, root, userDataDir,
|
||||
client, generate, get, download, leases, touched, progress,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map(async (root) => await rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('GameResourceDeliveryCoordinator', () => {
|
||||
it('submits once, hides polling, saves every output under the frozen project, and reports progress', async () => {
|
||||
let activeProjectPath: string | undefined;
|
||||
const states = [generation('running'), generation('succeeded')];
|
||||
let fixtureValue: Awaited<ReturnType<typeof fixture>>;
|
||||
fixtureValue = await fixture({
|
||||
get: async () => {
|
||||
expect(fixtureValue.leases.activeCount).toBe(0);
|
||||
activeProjectPath = path.join(fixtureValue.root, '..', 'another-project');
|
||||
return states.shift() as GameResourceGeneration;
|
||||
},
|
||||
download: async (_executionId, index = 0) => {
|
||||
expect(fixtureValue.leases.activeCount).toBe(0);
|
||||
expect(fixtureValue.progress.mock.calls.at(-1)?.[0].phase).toBe('saving');
|
||||
return {
|
||||
bytes: new Uint8Array([index + 1, index + 2]),
|
||||
fileName: index === 0 ? 'hero.png' : 'portrait.webp',
|
||||
contentType: index === 0 ? 'image/png' : 'image/webp',
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = await fixtureValue.coordinator.generateAndMaterialize({
|
||||
context: fixtureValue.context,
|
||||
request: fixtureValue.request,
|
||||
onProgress: fixtureValue.progress,
|
||||
});
|
||||
|
||||
expect(activeProjectPath).not.toBe(fixtureValue.root);
|
||||
expect(fixtureValue.generate).toHaveBeenCalledTimes(1);
|
||||
expect(fixtureValue.get).toHaveBeenCalledTimes(2);
|
||||
expect(fixtureValue.download.mock.calls.map((call) => call[1])).toEqual([0, 1]);
|
||||
expect(result).toMatchObject({
|
||||
executionId: EXECUTION_ID,
|
||||
providerStatus: 'succeeded',
|
||||
deliveryStatus: 'saved',
|
||||
files: [
|
||||
{ path: `assets/generated/game-resource/${EXECUTION_ID}/output-1.png`, bytes: 2 },
|
||||
{ path: `assets/generated/game-resource/${EXECUTION_ID}/output-2.webp`, bytes: 2 },
|
||||
],
|
||||
billing: settledBilling,
|
||||
});
|
||||
await expect(readFile(path.join(
|
||||
fixtureValue.root,
|
||||
'assets', 'generated', 'game-resource', EXECUTION_ID, 'output-1.png',
|
||||
))).resolves.toEqual(Buffer.from([1, 2]));
|
||||
await expect(readFile(path.join(
|
||||
fixtureValue.root,
|
||||
'assets', 'generated', 'game-resource', EXECUTION_ID, 'output-2.webp',
|
||||
))).resolves.toEqual(Buffer.from([2, 3]));
|
||||
expect(fixtureValue.leases.activeCount).toBe(0);
|
||||
expect(fixtureValue.touched).toHaveBeenCalledWith(
|
||||
'conversation-a',
|
||||
'run-a',
|
||||
result.files.map(({ path: filePath }) => filePath),
|
||||
);
|
||||
expect(fixtureValue.progress.mock.calls.map(([item]) => item.phase)).toEqual([
|
||||
'submitted', 'generating', 'generating', 'saving', 'saved',
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates concurrent replay and returns persisted saved paths without another submit or download', async () => {
|
||||
let releaseGenerate: ((value: GameResourceGeneration) => void) | undefined;
|
||||
const generated = new Promise<GameResourceGeneration>((resolve) => { releaseGenerate = resolve; });
|
||||
const value = await fixture({ generate: async () => await generated });
|
||||
|
||||
const first = value.coordinator.generateAndMaterialize({ context: value.context, request: value.request });
|
||||
const second = value.coordinator.generateAndMaterialize({ context: value.context, request: value.request });
|
||||
releaseGenerate?.(generation('succeeded'));
|
||||
const [left, right] = await Promise.all([first, second]);
|
||||
|
||||
expect(left).toEqual(right);
|
||||
expect(value.generate).toHaveBeenCalledTimes(1);
|
||||
expect(value.download).toHaveBeenCalledTimes(2);
|
||||
|
||||
const replay = await value.coordinator.generateAndMaterialize({
|
||||
context: value.context,
|
||||
request: value.request,
|
||||
});
|
||||
expect(replay).toEqual(left);
|
||||
expect(value.generate).toHaveBeenCalledTimes(1);
|
||||
expect(value.download).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps a background lifecycle lease through polling and delivery', async () => {
|
||||
let held = false;
|
||||
const release = vi.fn(() => { held = false; });
|
||||
const value = await fixture({
|
||||
acquireBackgroundLease: () => {
|
||||
held = true;
|
||||
return release;
|
||||
},
|
||||
generate: async () => {
|
||||
expect(held).toBe(true);
|
||||
return generation('accepted');
|
||||
},
|
||||
get: async () => {
|
||||
expect(held).toBe(true);
|
||||
return generation('succeeded');
|
||||
},
|
||||
download: async (_executionId, index = 0) => {
|
||||
expect(held).toBe(true);
|
||||
return {
|
||||
bytes: new Uint8Array([index + 1]),
|
||||
fileName: 'hero.png',
|
||||
contentType: 'image/png',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await expect(value.coordinator.generateAndMaterialize({
|
||||
context: value.context,
|
||||
request: value.request,
|
||||
})).resolves.toMatchObject({ deliveryStatus: 'saved' });
|
||||
expect(held).toBe(false);
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('resumes an accepted persisted execution after the original waiter stops', async () => {
|
||||
const value = await fixture({
|
||||
sleep: async () => { throw new Error('waiter stopped'); },
|
||||
});
|
||||
await expect(value.coordinator.generateAndMaterialize({
|
||||
context: value.context,
|
||||
request: value.request,
|
||||
})).rejects.toThrow('waiter stopped');
|
||||
expect(value.generate).toHaveBeenCalledTimes(1);
|
||||
|
||||
const restarted = new GameResourceDeliveryCoordinator({
|
||||
client: value.client,
|
||||
receipts: new GameResourceDeliveryReceiptStore(
|
||||
path.join(value.userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'),
|
||||
),
|
||||
leases: value.leases,
|
||||
sleep: async () => undefined,
|
||||
});
|
||||
await expect(restarted.resumePending()).resolves.toEqual([expect.objectContaining({
|
||||
executionId: EXECUTION_ID,
|
||||
providerStatus: 'succeeded',
|
||||
deliveryStatus: 'saved',
|
||||
})]);
|
||||
expect(value.generate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('persists provider success and resumes only local delivery after a write failure', async () => {
|
||||
let failWrite = true;
|
||||
const writeOutput = vi.fn(async (target: string, bytes: Uint8Array) => {
|
||||
if (failWrite) throw Object.assign(new Error('disk unavailable'), { code: 'ENOSPC' });
|
||||
await import('node:fs/promises').then(async ({ mkdir, writeFile }) => {
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, bytes, { flag: 'wx' });
|
||||
});
|
||||
});
|
||||
const value = await fixture({
|
||||
generate: async () => generation('succeeded'),
|
||||
writeOutput,
|
||||
});
|
||||
|
||||
await expect(value.coordinator.generateAndMaterialize({
|
||||
context: value.context,
|
||||
request: value.request,
|
||||
})).resolves.toMatchObject({
|
||||
providerStatus: 'succeeded',
|
||||
deliveryStatus: 'delivery_failed',
|
||||
errorCode: 'game_resource_delivery_failed',
|
||||
files: [],
|
||||
});
|
||||
expect(value.generate).toHaveBeenCalledTimes(1);
|
||||
|
||||
failWrite = false;
|
||||
const restarted = new GameResourceDeliveryCoordinator({
|
||||
client: value.client,
|
||||
receipts: new GameResourceDeliveryReceiptStore(
|
||||
path.join(value.userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'),
|
||||
),
|
||||
leases: value.leases,
|
||||
sleep: async () => undefined,
|
||||
writeOutput,
|
||||
recordTouchedPaths: value.touched,
|
||||
});
|
||||
const resumed = await restarted.resumePending();
|
||||
|
||||
expect(resumed).toEqual([expect.objectContaining({
|
||||
executionId: EXECUTION_ID,
|
||||
providerStatus: 'succeeded',
|
||||
deliveryStatus: 'saved',
|
||||
})]);
|
||||
expect(value.generate).toHaveBeenCalledTimes(1);
|
||||
expect(value.get).not.toHaveBeenCalled();
|
||||
expect(value.download).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it('never overwrites an existing project file with different bytes', async () => {
|
||||
const value = await fixture({ generate: async () => generation('succeeded') });
|
||||
const relativePath = `assets/generated/game-resource/${EXECUTION_ID}/output-1.png`;
|
||||
const target = path.join(value.root, ...relativePath.split('/'));
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, Buffer.from([99, 98]));
|
||||
|
||||
await expect(value.coordinator.generateAndMaterialize({
|
||||
context: value.context,
|
||||
request: value.request,
|
||||
})).resolves.toMatchObject({
|
||||
providerStatus: 'succeeded',
|
||||
deliveryStatus: 'delivery_failed',
|
||||
errorCode: 'game_resource_delivery_failed',
|
||||
});
|
||||
await expect(readFile(target)).resolves.toEqual(Buffer.from([99, 98]));
|
||||
expect(value.generate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('persists provider success and resumes only local delivery after a download failure', async () => {
|
||||
let failDownload = true;
|
||||
const value = await fixture({
|
||||
generate: async () => generation('succeeded'),
|
||||
download: async (_executionId, index = 0) => {
|
||||
if (failDownload) throw new Error('download unavailable');
|
||||
return {
|
||||
bytes: new Uint8Array([index + 1, index + 2]),
|
||||
fileName: index === 0 ? 'hero.png' : 'portrait.webp',
|
||||
contentType: index === 0 ? 'image/png' : 'image/webp',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await expect(value.coordinator.generateAndMaterialize({
|
||||
context: value.context,
|
||||
request: value.request,
|
||||
})).resolves.toMatchObject({
|
||||
providerStatus: 'succeeded',
|
||||
deliveryStatus: 'delivery_failed',
|
||||
errorCode: 'game_resource_delivery_failed',
|
||||
files: [],
|
||||
});
|
||||
expect(value.generate).toHaveBeenCalledTimes(1);
|
||||
|
||||
failDownload = false;
|
||||
const restarted = new GameResourceDeliveryCoordinator({
|
||||
client: value.client,
|
||||
receipts: new GameResourceDeliveryReceiptStore(
|
||||
path.join(value.userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'),
|
||||
),
|
||||
leases: value.leases,
|
||||
sleep: async () => undefined,
|
||||
recordTouchedPaths: value.touched,
|
||||
});
|
||||
await expect(restarted.resumePending()).resolves.toEqual([expect.objectContaining({
|
||||
executionId: EXECUTION_ID,
|
||||
providerStatus: 'succeeded',
|
||||
deliveryStatus: 'saved',
|
||||
})]);
|
||||
expect(value.generate).toHaveBeenCalledTimes(1);
|
||||
expect(value.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries a transient local delivery failure without submitting another generation', async () => {
|
||||
let downloadAttempts = 0;
|
||||
const sleep = vi.fn(async () => undefined);
|
||||
const value = await fixture({
|
||||
generate: async () => generation('succeeded'),
|
||||
sleep,
|
||||
download: async (_executionId, index = 0) => {
|
||||
downloadAttempts += 1;
|
||||
if (downloadAttempts === 1) throw new Error('temporary download failure');
|
||||
return {
|
||||
bytes: new Uint8Array([index + 1, index + 2]),
|
||||
fileName: index === 0 ? 'hero.png' : 'portrait.webp',
|
||||
contentType: index === 0 ? 'image/png' : 'image/webp',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await expect(value.coordinator.generateAndMaterialize({
|
||||
context: value.context,
|
||||
request: value.request,
|
||||
})).resolves.toMatchObject({
|
||||
providerStatus: 'succeeded',
|
||||
deliveryStatus: 'saved',
|
||||
});
|
||||
expect(value.generate).toHaveBeenCalledTimes(1);
|
||||
expect(value.get).not.toHaveBeenCalled();
|
||||
expect(sleep).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('continues resuming later receipts when an earlier reconciliation is unavailable', async () => {
|
||||
let generationIndex = 0;
|
||||
let sleepCalls = 0;
|
||||
const value = await fixture({
|
||||
generate: async () => {
|
||||
const first = generationIndex++ === 0;
|
||||
return generation(first ? 'accepted' : 'succeeded', {
|
||||
executionId: first
|
||||
? '11111111-1111-4111-8111-111111111111'
|
||||
: '44444444-4444-4444-8444-444444444444',
|
||||
});
|
||||
},
|
||||
download: async () => { throw new Error('delivery unavailable'); },
|
||||
sleep: async () => {
|
||||
if (sleepCalls++ === 0) throw new Error('waiter stopped');
|
||||
},
|
||||
});
|
||||
const secondContext = {
|
||||
...value.context,
|
||||
logicalOperationId: 'pi:run-a:resource-b',
|
||||
};
|
||||
const secondRequest = {
|
||||
...value.request,
|
||||
logicalOperationId: secondContext.logicalOperationId,
|
||||
};
|
||||
await expect(value.coordinator.generateAndMaterialize({
|
||||
context: value.context,
|
||||
request: value.request,
|
||||
})).rejects.toThrow('waiter stopped');
|
||||
await value.coordinator.generateAndMaterialize({
|
||||
context: secondContext,
|
||||
request: secondRequest,
|
||||
});
|
||||
|
||||
const resumedClient = {
|
||||
generate: vi.fn(async () => { throw new Error('must not regenerate'); }),
|
||||
get: vi.fn(async () => { throw new Error('first reconciliation unavailable'); }),
|
||||
download: vi.fn(async (executionId: string, index = 0) => {
|
||||
if (executionId === EXECUTION_ID) throw new Error('first delivery remains unavailable');
|
||||
return {
|
||||
bytes: new Uint8Array([index + 1]),
|
||||
fileName: 'result.png',
|
||||
contentType: 'image/png',
|
||||
};
|
||||
}),
|
||||
} as unknown as Pick<GameResourceClient, 'generate' | 'get' | 'download'>;
|
||||
const restarted = new GameResourceDeliveryCoordinator({
|
||||
client: resumedClient,
|
||||
receipts: new GameResourceDeliveryReceiptStore(
|
||||
path.join(value.userDataDir, 'coding-runtime', 'game-resource', 'receipts.json'),
|
||||
),
|
||||
leases: value.leases,
|
||||
sleep: async () => undefined,
|
||||
});
|
||||
|
||||
const results = await restarted.resumePending();
|
||||
|
||||
expect(results).toEqual([expect.objectContaining({
|
||||
executionId: '44444444-4444-4444-8444-444444444444',
|
||||
deliveryStatus: 'saved',
|
||||
})]);
|
||||
expect(resumedClient.generate).not.toHaveBeenCalled();
|
||||
expect(resumedClient.get).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not hold the write lease or create files for a pending-review generation', async () => {
|
||||
const value = await fixture({
|
||||
generate: async () => generation('pending_review', { outputCount: 0 }),
|
||||
});
|
||||
|
||||
await expect(value.coordinator.generateAndMaterialize({
|
||||
context: value.context,
|
||||
request: value.request,
|
||||
})).resolves.toMatchObject({
|
||||
providerStatus: 'pending_review',
|
||||
deliveryStatus: 'not_started',
|
||||
files: [],
|
||||
});
|
||||
expect(value.download).not.toHaveBeenCalled();
|
||||
expect(value.leases.activeCount).toBe(0);
|
||||
expect(value.touched).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,11 @@ import type { CodingPluginToolDefinition } from '../../shared/coding-plugins';
|
||||
import {
|
||||
GameResourcePluginAdapter,
|
||||
} from '../../electron/coding-plugins/adapters/game-resource';
|
||||
import { PiProjectWriteLeaseCoordinator } from '../../electron/coding-runtime/pi/write-lease';
|
||||
import {
|
||||
GameResourceDeliveryCoordinator,
|
||||
GameResourceDeliveryReceiptStore,
|
||||
} from '../../electron/services/game-resource-delivery';
|
||||
import type { GameResourceClient, GameResourceGeneration } from '../../electron/services/game-resource-client';
|
||||
import type { MarketplacePackageClientPort, PluginPackageStore } from '../../electron/coding-plugins/package-store';
|
||||
import type { TrustedCodingCapabilityContext } from '../../electron/coding-plugins/registry';
|
||||
@@ -52,8 +57,8 @@ function tool(
|
||||
capabilityId,
|
||||
operation,
|
||||
roles: ['parent'],
|
||||
mutation: name === 'game_resource_status' ? 'read' : 'write',
|
||||
projectWriteLease: name === 'game_resource_save_output',
|
||||
mutation: operation === 'templates' ? 'read' : 'write',
|
||||
projectWriteLease: false,
|
||||
permissions: [`hosted.game-resource.${operation.replaceAll('_', '-')}`],
|
||||
inputSchema: { type: 'object' },
|
||||
};
|
||||
@@ -61,7 +66,8 @@ function tool(
|
||||
|
||||
async function fixture() {
|
||||
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-game-resource-'));
|
||||
roots.push(projectPath);
|
||||
const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-game-resource-receipts-'));
|
||||
roots.push(projectPath, userDataDir);
|
||||
const resolve = vi.fn(async () => ({
|
||||
resolveRequestId: 'pi:run-a:resource-a',
|
||||
resolveRequestDigest: 'a'.repeat(64),
|
||||
@@ -84,8 +90,15 @@ async function fixture() {
|
||||
bytes: new Uint8Array([1, 2, 3]), fileName: 'hero.png', contentType: 'image/png',
|
||||
})),
|
||||
};
|
||||
const delivery = new GameResourceDeliveryCoordinator({
|
||||
client: client as unknown as GameResourceClient,
|
||||
receipts: new GameResourceDeliveryReceiptStore(path.join(userDataDir, 'receipts.json')),
|
||||
leases: new PiProjectWriteLeaseCoordinator(),
|
||||
sleep: async () => undefined,
|
||||
});
|
||||
const adapter = new GameResourcePluginAdapter({
|
||||
client: client as unknown as GameResourceClient,
|
||||
delivery,
|
||||
marketplace: { resolve } as unknown as MarketplacePackageClientPort,
|
||||
packageStore: {
|
||||
getInstalled: vi.fn(async () => ({
|
||||
@@ -113,15 +126,16 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('GameResourcePluginAdapter', () => {
|
||||
it('resolves a fresh admission and keeps project reference bytes behind the hosted boundary', async () => {
|
||||
it('resolves admission, hides polling, and automatically saves generated output in the project', async () => {
|
||||
const { adapter, client, context, projectPath, resolve } = await fixture();
|
||||
await writeFile(path.join(projectPath, 'reference.png'), new Uint8Array([4, 5, 6]));
|
||||
const onProgress = vi.fn();
|
||||
|
||||
const result = await adapter.invoke(context, tool('game_resource_generate'), {
|
||||
kind: 'pixel', templateName: 'character', requirement: 'Blue-armored hero',
|
||||
confirmed: true,
|
||||
referencePaths: ['reference.png'],
|
||||
});
|
||||
}, onProgress);
|
||||
|
||||
expect(resolve).toHaveBeenCalledWith(expect.objectContaining({
|
||||
resolveRequestId: 'pi:run-a:resource-a', channel: 'stable',
|
||||
@@ -133,9 +147,26 @@ describe('GameResourcePluginAdapter', () => {
|
||||
referenceFiles: [{ name: 'reference.png', mimeType: 'image/png', dataBase64: 'BAUG' }],
|
||||
}));
|
||||
expect(result).toMatchObject({
|
||||
success: true, status: 202, billing,
|
||||
data: { executionId: EXECUTION_ID, status: 'accepted', outputCount: 0 },
|
||||
success: true, status: 200, billing,
|
||||
data: {
|
||||
executionId: EXECUTION_ID,
|
||||
providerStatus: 'succeeded',
|
||||
deliveryStatus: 'saved',
|
||||
files: [{
|
||||
path: `assets/generated/game-resource/${EXECUTION_ID}/output-1.png`,
|
||||
bytes: 3,
|
||||
}],
|
||||
},
|
||||
});
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
expect(client.download).toHaveBeenCalledWith(EXECUTION_ID, 0);
|
||||
await expect(readFile(path.join(
|
||||
projectPath,
|
||||
'assets', 'generated', 'game-resource', EXECUTION_ID, 'output-1.png',
|
||||
))).resolves.toEqual(Buffer.from([1, 2, 3]));
|
||||
expect(onProgress.mock.calls.map(([progress]) => progress.data.phase)).toEqual([
|
||||
'submitted', 'generating', 'saving', 'saved',
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toContain(projectPath);
|
||||
});
|
||||
|
||||
@@ -151,28 +182,6 @@ describe('GameResourcePluginAdapter', () => {
|
||||
expect(client.generate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires explicit confirmation and never overwrites a project file', async () => {
|
||||
const { adapter, client, context, projectPath } = await fixture();
|
||||
const saveTool = tool('game_resource_save_output', 'game-resource.library', 'save_output');
|
||||
|
||||
await expect(adapter.invoke(context, saveTool, {
|
||||
executionId: EXECUTION_ID, relativePath: 'assets/hero.png', confirmed: false,
|
||||
})).resolves.toMatchObject({ success: false, code: 'confirmation_required' });
|
||||
expect(client.download).not.toHaveBeenCalled();
|
||||
|
||||
await expect(adapter.invoke(context, saveTool, {
|
||||
executionId: EXECUTION_ID, relativePath: 'assets/hero.png', confirmed: true,
|
||||
})).resolves.toMatchObject({
|
||||
success: true, data: { savedPath: 'assets/hero.png', bytes: 3 },
|
||||
});
|
||||
await expect(readFile(path.join(projectPath, 'assets/hero.png'))).resolves.toEqual(Buffer.from([1, 2, 3]));
|
||||
|
||||
await expect(adapter.invoke(context, saveTool, {
|
||||
executionId: EXECUTION_ID, relativePath: 'assets/hero.png', confirmed: true,
|
||||
})).resolves.toMatchObject({ success: false, code: 'game_resource_destination_exists' });
|
||||
await expect(readFile(path.join(projectPath, 'assets/hero.png'))).resolves.toEqual(Buffer.from([1, 2, 3]));
|
||||
});
|
||||
|
||||
it('lists server-owned templates through the same release admission', async () => {
|
||||
const { adapter, context, client } = await fixture();
|
||||
await expect(adapter.invoke(context, tool('game_resource_templates'), { kind: 'pixel' }))
|
||||
|
||||
@@ -320,6 +320,115 @@ describe('Makelore Pi extension bundle', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('streams job-tool progress through the authenticated product bridge', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-product-stream-'));
|
||||
roots.push(root);
|
||||
const host = new PiManagedExtensionHost();
|
||||
hosts.push(host);
|
||||
const details = (phase: string) => ({
|
||||
schema: 'makelore-capability.v1' as const,
|
||||
plugin_id: 'makelore.game-resource',
|
||||
plugin_version: '1.0.0',
|
||||
capability_id: 'game-resource.generate',
|
||||
operation: 'generate',
|
||||
request_id: 'pi:stream-run:stream-tool',
|
||||
success: true,
|
||||
status: phase === 'saved' ? 200 : 202,
|
||||
code: null,
|
||||
error: null,
|
||||
retryable: false,
|
||||
billing: {
|
||||
mode: 'platform_metered' as const,
|
||||
status: phase === 'saved' ? 'settled' as const : 'dispatched' as const,
|
||||
reserved_points: '2.00',
|
||||
...(phase === 'saved' ? { actual_points: '2.00' } : {}),
|
||||
usage_amount: 1,
|
||||
unit: 'generation',
|
||||
},
|
||||
payload_schema: 'game-resource.v1',
|
||||
data: { phase, executionId: 'execution-a' },
|
||||
});
|
||||
const invoke = vi.fn(async (input: {
|
||||
onUpdate?: (result: { content: []; details: ReturnType<typeof details> }) => void;
|
||||
}) => {
|
||||
input.onUpdate?.({ content: [], details: details('generating') });
|
||||
input.onUpdate?.({ content: [], details: details('saving') });
|
||||
return { content: [], details: details('saved') };
|
||||
});
|
||||
host.configureProductTools(new PiProductTools({
|
||||
browser: {} as AgentBrowserModule,
|
||||
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
|
||||
bundledSkillsDir: path.resolve('resources/coding-skills'),
|
||||
capabilityRegistry: { invoke } as unknown as CodingCapabilityRegistryImpl,
|
||||
}));
|
||||
const jobTool: CodingPluginToolDefinition = {
|
||||
name: 'game_resource_generate',
|
||||
label: 'Generate game resource',
|
||||
description: 'Generate and save a game resource.',
|
||||
capabilityId: 'game-resource.generate',
|
||||
operation: 'generate',
|
||||
roles: ['parent'],
|
||||
mutation: 'write',
|
||||
projectWriteLease: false,
|
||||
permissions: ['hosted.game-resource.generate'],
|
||||
executionMode: 'job',
|
||||
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
|
||||
};
|
||||
const registration = await host.registerWorker({
|
||||
conversationId: 'stream-conversation', generation: 1, projectId: 'stream-project',
|
||||
projectPath: root, extensionsDir: root, tools: [jobTool],
|
||||
});
|
||||
await host.bindRun('stream-conversation', 1, 'stream-run');
|
||||
const previous = {
|
||||
bridge: process.env.MAKELORE_PI_BRIDGE_URL,
|
||||
token: process.env.MAKELORE_PI_WORKER_TOKEN,
|
||||
context: process.env.MAKELORE_PI_CONTEXT_FILE,
|
||||
role: process.env.MAKELORE_PI_WORKER_ROLE,
|
||||
};
|
||||
Object.assign(process.env, registration.env);
|
||||
try {
|
||||
const module = await import(
|
||||
/* @vite-ignore */ `${pathToFileURL(registration.extensionPath).href}?stream=${Date.now()}`
|
||||
) as {
|
||||
default(factory: {
|
||||
registerTool(tool: ExtensionTool): void;
|
||||
on(event: string, handler: ExtensionHandler): void;
|
||||
}): void | Promise<void>;
|
||||
};
|
||||
const tools = new Map<string, ExtensionTool>();
|
||||
await module.default({
|
||||
registerTool: (tool) => tools.set(tool.name, tool),
|
||||
on: () => undefined,
|
||||
});
|
||||
const updates: unknown[] = [];
|
||||
const result = await tools.get('game_resource_generate')?.execute?.(
|
||||
'stream-tool',
|
||||
{},
|
||||
new AbortController().signal,
|
||||
(update: unknown) => updates.push(update),
|
||||
);
|
||||
|
||||
expect(updates).toEqual([
|
||||
{ content: [], details: details('generating') },
|
||||
{ content: [], details: details('saving') },
|
||||
]);
|
||||
expect(result).toEqual({ content: [], details: details('saved') });
|
||||
expect(invoke).toHaveBeenCalledWith(expect.objectContaining({
|
||||
toolName: 'game_resource_generate',
|
||||
onUpdate: expect.any(Function),
|
||||
}));
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
const environmentKey = key === 'bridge' ? 'MAKELORE_PI_BRIDGE_URL'
|
||||
: key === 'token' ? 'MAKELORE_PI_WORKER_TOKEN'
|
||||
: key === 'context' ? 'MAKELORE_PI_CONTEXT_FILE'
|
||||
: 'MAKELORE_PI_WORKER_ROLE';
|
||||
if (value === undefined) delete process.env[environmentKey];
|
||||
else process.env[environmentKey] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('executes versioned product tools through the authenticated real bundle', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-product-bundle-'));
|
||||
roots.push(root);
|
||||
|
||||
@@ -289,7 +289,7 @@ describe('managed Pi worker opener', () => {
|
||||
expect(argv).toContain('grilling');
|
||||
expect(argv).toContain('--session-id');
|
||||
expect(argv).toContain('--extension');
|
||||
expect(argv).toContain('makelore-runtime-v5.mjs');
|
||||
expect(argv).toContain('makelore-runtime-v6.mjs');
|
||||
expect(options.additionalArgs?.filter((argument) => argument === '--extension')).toHaveLength(2);
|
||||
expect(options.additionalArgs).toEqual(expect.arrayContaining([
|
||||
'--skill', deviceSkillPath, '--extension', deviceExtensionPath,
|
||||
|
||||
Reference in New Issue
Block a user