feat: integrate automatic game resource delivery

This commit is contained in:
2026-09-06 09:50:49 +08:00
parent f721966f3c
commit 4df4bc9624
20 changed files with 1667 additions and 191 deletions

View File

@@ -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',

View File

@@ -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();

View File

@@ -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);

View 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();
});
});

View File

@@ -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' }))

View File

@@ -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);

View File

@@ -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,