mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Validate skills catalog response entries
This commit is contained in:
parent
e9112c812f
commit
afa615a0bd
6 changed files with 162 additions and 5 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 [];
|
||||||
|
|
|
||||||
|
|
@ -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' }),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue