Validate skills catalog response entries

This commit is contained in:
116405 2026-05-28 19:09:28 +08:00
parent e9112c812f
commit afa615a0bd
6 changed files with 162 additions and 5 deletions

View file

@ -11,7 +11,7 @@ import { parseDesignSystemRenameArgs } from './design-system-rename-args.js';
import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js'; import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js';
import { splitResearchSubcommand } from './research/cli-args.js'; import { splitResearchSubcommand } from './research/cli-args.js';
import { resolveDaemonUrl } from './daemon-url.js'; import { resolveDaemonUrl } from './daemon-url.js';
import { buildSkillCatalogTree } from '@open-design/contracts'; import { buildSkillCatalogTree, parseSkillSummaries } from '@open-design/contracts';
import { requestJsonIpc } from '@open-design/sidecar'; import { requestJsonIpc } from '@open-design/sidecar';
import { SIDECAR_ENV, SIDECAR_MESSAGES } from '@open-design/sidecar-proto'; import { SIDECAR_ENV, SIDECAR_MESSAGES } from '@open-design/sidecar-proto';
@ -5433,14 +5433,20 @@ async function runSkillsTree(args) {
const resp = await fetch(`${base}/api/skills`); const resp = await fetch(`${base}/api/skills`);
if (!resp.ok) return structuredHttpFailure(resp); if (!resp.ok) return structuredHttpFailure(resp);
const data = await resp.json().catch(() => null); const data = await resp.json().catch(() => null);
if (!data || !Array.isArray(data.skills)) { let skills;
try {
skills = data && Array.isArray(data.skills) ? parseSkillSummaries(data.skills) : null;
} catch {
skills = null;
}
if (!skills) {
return exitWithStructuredError({ return exitWithStructuredError({
code: 'daemon-protocol-error', code: 'daemon-protocol-error',
message: 'Malformed /api/skills response: expected { skills: SkillSummary[] }', message: 'Malformed /api/skills response: expected { skills: SkillSummary[] }',
data: { endpoint: '/api/skills' }, data: { endpoint: '/api/skills' },
}); });
} }
const tree = buildSkillCatalogTree(data.skills); const tree = buildSkillCatalogTree(skills);
if (flags.json) return process.stdout.write(JSON.stringify(tree, null, 2) + '\n'); if (flags.json) return process.stdout.write(JSON.stringify(tree, null, 2) + '\n');
console.log(`Skills tree (${tree.total})`); console.log(`Skills tree (${tree.total})`);
for (const mode of tree.modes) { for (const mode of tree.modes) {

View file

@ -39,11 +39,18 @@ describe('od skills CLI', () => {
{ {
id: 'dashboard', id: 'dashboard',
name: 'Dashboard', name: 'Dashboard',
description: 'A dashboard skill.',
triggers: [],
mode: 'prototype', mode: 'prototype',
scenario: 'operation', scenario: 'operation',
platform: 'desktop', platform: 'desktop',
previewType: 'html', previewType: 'html',
designSystemRequired: true, designSystemRequired: true,
defaultFor: [],
upstream: null,
hasBody: true,
examplePrompt: 'Build a dashboard.',
aggregatesExamples: false,
}, },
], ],
})); }));
@ -139,4 +146,65 @@ describe('od skills CLI', () => {
}, },
); );
}); });
it('rejects malformed skill elements in skills tree responses', async () => {
await withSkillsServer(
(_req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({
skills: [
{
id: 'dashboard',
name: 'Dashboard',
description: 'A dashboard skill.',
triggers: [],
mode: 'prototype',
scenario: 'operation',
platform: 'desktop',
previewType: 'html',
designSystemRequired: true,
defaultFor: [],
upstream: null,
hasBody: true,
examplePrompt: 'Build a dashboard.',
aggregatesExamples: false,
},
{ id: 'broken', mode: 'prototype' },
],
}));
},
async (baseUrl) => {
try {
await execFileAsync(
process.execPath,
[
'--import',
'tsx',
cliEntry,
'skills',
'tree',
'--json',
'--daemon-url',
baseUrl,
],
{
cwd: daemonRoot,
env: process.env,
},
);
throw new Error('skills tree command unexpectedly succeeded');
} catch (error: unknown) {
const failed = error as { code?: number; stderr?: string; stdout?: string };
expect(failed.code).toBe(74);
expect(failed.stdout ?? '').toBe('');
const envelope = JSON.parse(failed.stderr ?? '{}') as {
error?: { code?: string; message?: string; data?: { endpoint?: string } };
};
expect(envelope.error?.code).toBe('daemon-protocol-error');
expect(envelope.error?.message).toContain('Malformed /api/skills response');
expect(envelope.error?.data?.endpoint).toBe('/api/skills');
}
},
);
});
}); });

View file

@ -13,6 +13,7 @@ import type {
ImportLocalDesignSystemResponse, ImportLocalDesignSystemResponse,
ReplaceProjectWorkingDirResponse, ReplaceProjectWorkingDirResponse,
} from '@open-design/contracts'; } from '@open-design/contracts';
import { parseSkillSummaries } from '@open-design/contracts';
import type { import type {
AgentInfo, AgentInfo,
AppVersionInfo, AppVersionInfo,
@ -107,12 +108,12 @@ export async function fetchSkills(options?: { throwOnError?: boolean }): Promise
if (options?.throwOnError) throw new Error(`skills ${resp.status}`); if (options?.throwOnError) throw new Error(`skills ${resp.status}`);
return []; return [];
} }
const json = (await resp.json()) as { skills?: SkillSummary[] }; const json = await resp.json() as { skills?: unknown };
if (!Array.isArray(json.skills)) { if (!Array.isArray(json.skills)) {
if (options?.throwOnError) throw new Error('skills response malformed'); if (options?.throwOnError) throw new Error('skills response malformed');
return []; return [];
} }
return json.skills; return parseSkillSummaries(json.skills);
} catch (err) { } catch (err) {
if (options?.throwOnError) throw err; if (options?.throwOnError) throw err;
return []; return [];

View file

@ -252,6 +252,21 @@ describe('IntegrationsView skills tree', () => {
expect(screen.queryByText('No skills match these filters.')).toBeNull(); expect(screen.queryByText('No skills match these filters.')).toBeNull();
}); });
it('shows a load failure when skills payload contains malformed entries', async () => {
renderSkillsIntegration([], {
skillsBody: {
skills: [
skill({ id: 'dashboard', name: 'Dashboard' }),
{ id: 'broken', mode: 'prototype' },
],
},
});
expect(await screen.findByText('Could not load skills. Make sure the local daemon is running, then try again.')).toBeTruthy();
expect(screen.queryByText('No skills match these filters.')).toBeNull();
expect(screen.queryByTestId('integrations-skill-list-row-dashboard')).toBeNull();
});
it('localizes tree guide and legend labels', async () => { it('localizes tree guide and legend labels', async () => {
renderSkillsIntegration([ renderSkillsIntegration([
skill({ id: 'dashboard', name: 'Dashboard', scenario: 'operation', platform: 'desktop' }), skill({ id: 'dashboard', name: 'Dashboard', scenario: 'operation', platform: 'desktop' }),

View file

@ -1,3 +1,5 @@
import { z } from 'zod';
export interface AgentModelOption { export interface AgentModelOption {
id: string; id: string;
label: string; label: string;
@ -140,6 +142,52 @@ export interface SkillsResponse {
skills: SkillSummary[]; skills: SkillSummary[];
} }
export const SkillSummarySchema: z.ZodType<SkillSummary> = z.object({
id: z.string(),
name: z.string(),
displayName: z.record(z.string()).optional(),
description: z.string(),
descriptionI18n: z.record(z.string()).optional(),
triggers: z.array(z.string()),
mode: z.enum([
'prototype',
'deck',
'template',
'design-system',
'image',
'video',
'audio',
]),
surface: z.enum(['web', 'image', 'video', 'audio']).optional(),
platform: z.enum(['desktop', 'mobile']).nullable().optional(),
scenario: z.string().nullable().optional(),
category: z.string().nullable().optional(),
source: z.enum(['built-in', 'user']).optional(),
previewType: z.string(),
designSystemRequired: z.boolean(),
defaultFor: z.array(z.string()),
upstream: z.string().nullable(),
featured: z.number().nullable().optional(),
fidelity: z.enum(['wireframe', 'high-fidelity']).nullable().optional(),
speakerNotes: z.boolean().nullable().optional(),
animations: z.boolean().nullable().optional(),
craftRequires: z.array(z.string()).optional(),
hasBody: z.boolean(),
examplePrompt: z.string(),
examplePromptI18n: z.record(z.string()).optional(),
aggregatesExamples: z.boolean(),
}).passthrough() as z.ZodType<SkillSummary>;
export const SkillSummaryArraySchema = z.array(SkillSummarySchema);
export function parseSkillSummaries(value: unknown): SkillSummary[] {
return SkillSummaryArraySchema.parse(value);
}
export function isSkillSummaries(value: unknown): value is SkillSummary[] {
return SkillSummaryArraySchema.safeParse(value).success;
}
export interface SkillCatalogTreeSkill { export interface SkillCatalogTreeSkill {
id: string; id: string;
name: string; name: string;

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
buildSkillCatalogTree, buildSkillCatalogTree,
parseSkillSummaries,
type SkillSummary, type SkillSummary,
} from '../src/api/registry.js'; } from '../src/api/registry.js';
@ -111,3 +112,21 @@ describe('buildSkillCatalogTree', () => {
expect(leaf?.skill).toBe(source); expect(leaf?.skill).toBe(source);
}); });
}); });
describe('parseSkillSummaries', () => {
it('accepts valid skill summary arrays', () => {
const summaries = parseSkillSummaries([
skill({ id: 'dashboard', name: 'Dashboard' }),
]);
expect(summaries).toHaveLength(1);
expect(summaries[0]?.id).toBe('dashboard');
});
it('rejects malformed skill summary array elements', () => {
expect(() => parseSkillSummaries([
skill({ id: 'dashboard', name: 'Dashboard' }),
{ id: 'broken', mode: 'prototype' },
])).toThrow();
});
});