open-design/apps/daemon/tests/plugins-scenario-fallback.test.ts
Cursor Agent 694289295a
feat(plugins): wire bundled-scenario pipeline fallback through apply (spec §23.3.3)
Plan O1 / spec §23.3.3.

When a plugin omits `od.pipeline`, applyPlugin() now consults the
bundled scenario plugins registered by the daemon's bundled boot
walker (apps/daemon/src/plugins/bundled.ts) and copies the matching
scenario's pipeline verbatim into the snapshot. Match key:
`od.taskKind` (defaulting to 'new-generation' when absent).

Pure-side (packages/plugin-runtime):

  - new resolveAppliedPipeline({ manifest, scenarios }) →
      { pipeline, source: 'declared' | 'scenario' | 'none', scenarioId? }
  - RegistryView gains optional `scenarios?: ScenarioRegistryEntry[]`
    so the daemon registry view propagates the lookup table without
    coupling the pure resolver to SQLite.

Daemon-side (apps/daemon):

  - applyPlugin() calls resolveAppliedPipeline and stores the result on
    BOTH `ApplyResult.pipeline` and `AppliedPluginSnapshot.pipeline`
    so a replay reproduces the same stages without re-consulting the
    scenario registry.
  - server.loadPluginRegistryView() now collects bundled scenarios
    from `installed_plugins` (source_kind='bundled', od.kind='scenario',
    non-empty pipeline). Third-party scenarios are intentionally NOT
    eligible — only rows seeded by the bundled boot walker.

Invariants preserved:
  - Apply stays pure: scenarios are read once at the registry-view
    construction site and passed in; apply never opens a DB itself.
  - Scenario plugins themselves never fall back to themselves
    (resolveAppliedPipeline returns 'none' when od.kind='scenario').
  - manifestSourceDigest is unaffected by the fallback (digest is over
    manifest + inputs + resolvedContextRefs); replay invariance held.

Tests: +15 (plugin-runtime: 7 cases on resolveAppliedPipeline;
daemon: 8 cases on plugins-scenario-fallback covering declared
pipeline win, scenario fallback by taskKind, default to
new-generation, scenario plugins don't recurse, no scenarios →
pipeline=undefined, manifestSourceDigest stability across applies,
DB collector round-trip).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:05:54 +00:00

232 lines
8.3 KiB
TypeScript

// Spec §23.3.3 / Plan §3.O1 — bundled-scenario pipeline fallback,
// driven through the live applyPlugin path with the daemon
// registry view.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type {
InstalledPluginRecord,
PluginManifest,
} from '@open-design/contracts';
import { applyPlugin } from '../src/plugins/apply.js';
import { openDatabase } from '../src/db.js';
import { upsertInstalledPlugin } from '../src/plugins/registry.js';
import { resolveAppliedPipeline, type ScenarioRegistryEntry } from '@open-design/plugin-runtime';
let tmpRoot: string;
beforeEach(async () => {
tmpRoot = await mkdtemp(path.join(os.tmpdir(), 'od-scenario-fallback-'));
});
afterEach(async () => {
await rm(tmpRoot, { recursive: true, force: true });
});
const codeMigrationScenarioPipeline = {
stages: [
{ id: 'import', atoms: ['code-import'] },
{ id: 'tokens', atoms: ['design-extract', 'token-map'] },
{ id: 'plan', atoms: ['rewrite-plan'] },
{ id: 'verify',
atoms: ['patch-edit', 'build-test'],
repeat: true,
until: '(build.passing && tests.passing) || iterations>=8' },
{ id: 'review', atoms: ['diff-review'] },
{ id: 'handoff', atoms: ['handoff'] },
],
};
const newGenerationScenarioPipeline = {
stages: [
{ id: 'discovery', atoms: ['discovery-question-form'] },
{ id: 'plan', atoms: ['direction-picker', 'todo-write'] },
{ id: 'generate', atoms: ['file-write', 'live-artifact'] },
{ id: 'critique', atoms: ['critique-theater'],
repeat: true,
until: 'critique.score>=4 || iterations>=3' },
],
};
const baseRegistry = (
scenarios: ScenarioRegistryEntry[] = [],
) => ({
skills: [],
designSystems: [],
craft: [],
atoms: [],
scenarios,
});
const consumerPlugin = (od: NonNullable<PluginManifest['od']>): InstalledPluginRecord => {
const manifest: PluginManifest = {
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
name: 'fixture-consumer',
title: 'Fixture consumer',
version: '0.1.0',
od,
} as PluginManifest;
return {
id: 'fixture-consumer',
title: 'Fixture consumer',
version: '0.1.0',
sourceKind: 'local',
source: '/tmp/fixture',
fsPath: '/tmp/fixture',
trust: 'trusted',
capabilitiesGranted: ['prompt:inject'],
installedAt: Date.now(),
updatedAt: Date.now(),
manifest,
};
};
describe('apply: bundled-scenario pipeline fallback (spec §23.3.3)', () => {
it("uses the consumer's declared pipeline when present", () => {
const plugin = consumerPlugin({
taskKind: 'code-migration',
pipeline: { stages: [{ id: 'custom', atoms: ['todo-write'] }] },
});
const out = applyPlugin({
plugin,
inputs: {},
registry: baseRegistry([
{ id: 'od-code-migration', taskKind: 'code-migration', pipeline: codeMigrationScenarioPipeline },
]),
});
expect(out.result.pipeline?.stages?.[0]?.id).toBe('custom');
expect(out.result.appliedPlugin.pipeline?.stages?.[0]?.id).toBe('custom');
});
it('falls back to the matching scenario pipeline when the consumer omits it', () => {
const plugin = consumerPlugin({ taskKind: 'code-migration' });
const out = applyPlugin({
plugin,
inputs: {},
registry: baseRegistry([
{ id: 'od-new-generation', taskKind: 'new-generation', pipeline: newGenerationScenarioPipeline },
{ id: 'od-code-migration', taskKind: 'code-migration', pipeline: codeMigrationScenarioPipeline },
]),
});
expect(out.result.pipeline?.stages?.map((s) => s.id)).toEqual([
'import', 'tokens', 'plan', 'verify', 'review', 'handoff',
]);
// Snapshot must carry the same fallback-resolved pipeline so a
// replay reproduces the same stages without consulting the
// scenario registry again.
expect(out.result.appliedPlugin.pipeline).toEqual(out.result.pipeline);
});
it("defaults to 'new-generation' when the consumer omits both pipeline and taskKind", () => {
const plugin = consumerPlugin({});
const out = applyPlugin({
plugin,
inputs: {},
registry: baseRegistry([
{ id: 'od-new-generation', taskKind: 'new-generation', pipeline: newGenerationScenarioPipeline },
]),
});
expect(out.result.pipeline?.stages?.[0]?.id).toBe('discovery');
});
it('keeps pipeline undefined when no scenario matches the taskKind', () => {
const plugin = consumerPlugin({ taskKind: 'code-migration' });
const out = applyPlugin({ plugin, inputs: {}, registry: baseRegistry([]) });
expect(out.result.pipeline).toBeUndefined();
expect(out.result.appliedPlugin.pipeline).toBeUndefined();
});
it("does not fall back when the consumer is itself a scenario plugin", () => {
const plugin = consumerPlugin({
kind: 'scenario',
taskKind: 'new-generation',
});
const out = applyPlugin({
plugin,
inputs: {},
registry: baseRegistry([
{ id: 'od-new-generation', taskKind: 'new-generation', pipeline: newGenerationScenarioPipeline },
]),
});
expect(out.result.pipeline).toBeUndefined();
});
it('produces stable manifestSourceDigest across two applies of the consumer + same registry', () => {
const plugin = consumerPlugin({ taskKind: 'code-migration' });
const reg = baseRegistry([
{ id: 'od-code-migration', taskKind: 'code-migration', pipeline: codeMigrationScenarioPipeline },
]);
const a = applyPlugin({ plugin, inputs: {}, registry: reg });
const b = applyPlugin({ plugin, inputs: {}, registry: reg });
// The fallback does not alter the manifestSourceDigest input
// (digest covers the manifest + inputs + resolvedContext refs;
// pipeline source is downstream). e2e-2 invariant holds.
expect(a.manifestSourceDigest).toBe(b.manifestSourceDigest);
});
});
describe('daemon scenarios collector (registry view source)', () => {
it('handles a fresh DB without scenarios installed', async () => {
const dataDir = path.join(tmpRoot, 'fresh');
await mkdir(dataDir, { recursive: true });
const db = openDatabase(tmpRoot, { dataDir });
expect(
db.prepare("SELECT count(*) as c FROM installed_plugins").get(),
).toEqual({ c: 0 });
db.close();
});
it('reads bundled-scenario rows from installed_plugins', async () => {
const dataDir = path.join(tmpRoot, 'scenario');
await mkdir(dataDir, { recursive: true });
const db = openDatabase(tmpRoot, { dataDir });
const folder = path.join(tmpRoot, 'od-code-migration');
await mkdir(folder, { recursive: true });
await writeFile(path.join(folder, 'open-design.json'), JSON.stringify({
name: 'od-code-migration',
version: '0.0.1',
od: {
kind: 'scenario',
taskKind: 'code-migration',
pipeline: codeMigrationScenarioPipeline,
},
}));
upsertInstalledPlugin(db, {
id: 'od-code-migration',
title: 'Code migration scenario',
version: '0.0.1',
sourceKind: 'bundled',
source: folder,
fsPath: folder,
trust: 'bundled',
capabilitiesGranted: [],
installedAt: Date.now(),
updatedAt: Date.now(),
manifest: {
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
name: 'od-code-migration',
version: '0.0.1',
od: {
kind: 'scenario',
taskKind: 'code-migration',
pipeline: codeMigrationScenarioPipeline,
},
} as PluginManifest,
});
// Direct read mirrors what loadPluginRegistryView does, sanity-
// checking the round trip.
const rows = db.prepare(
"SELECT id, source_kind, manifest_json FROM installed_plugins WHERE source_kind='bundled'"
).all() as Array<{ id: string; source_kind: string; manifest_json: string }>;
expect(rows.length).toBe(1);
const manifest = JSON.parse(rows[0]!.manifest_json) as PluginManifest;
expect(manifest.od?.kind).toBe('scenario');
expect(resolveAppliedPipeline({
manifest: { $schema: '', name: 'consumer', version: '0.1.0', od: { taskKind: 'code-migration' } } as PluginManifest,
scenarios: [{ id: 'od-code-migration', taskKind: 'code-migration', pipeline: manifest.od!.pipeline! }],
}).source).toBe('scenario');
db.close();
});
});