mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* Fix plugin publish and PR workflow UX * Update plugin workflow test expectations * Fix fake gh repo view verification path * Fix plugin publish headless tests and preserve PATH in shell wrappers. The publish-repo flow needs real git commits and fake gh auth output that matches gh auth status parsing. Login shells no longer drop PATH so test fakes and agent wrappers stay visible to nested gh/git calls. Co-authored-by: Cursor <cursoragent@cursor.com> * Restore plugin action card when share-task startup fails. If startGeneratedPluginShareTask rejects before a task is created, clear hiddenAssistantPluginActionPaths so the assistant action card reappears. Co-authored-by: Cursor <cursoragent@cursor.com> * Make daemon vitest self-contained for publish-github CLI shell-outs. Build dist/cli.js in tests/setup.ts when missing and set OD_DAEMON_CLI_PATH before server.ts resolves OD_BIN, so headless plugin tests pass from a clean checkout without a prior manual daemon build. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
721 lines
29 KiB
TypeScript
721 lines
29 KiB
TypeScript
// Plan §3.F4 / spec §8 e2e-3 anchor.
|
|
//
|
|
// Verifies the headless `od plugin install → project create → run start`
|
|
// loop end-to-end at the HTTP layer (the same paths the CLI subcommands
|
|
// from §3.F1 / §3.F2 hit). Without an actual agent backend we can't
|
|
// assert "first ND-JSON event has kind='pipeline_stage_started'" — that
|
|
// requires the run-time pipeline runner being wired into the live agent
|
|
// loop. What we can lock today:
|
|
//
|
|
// 1. POST /api/plugins/install (local fixture) succeeds.
|
|
// 2. POST /api/projects { pluginId, pluginInputs } → 200 +
|
|
// appliedPluginSnapshotId pinned to the new project.
|
|
// 3. POST /api/runs { projectId, pluginId, pluginInputs } → 202 +
|
|
// runId.
|
|
// 4. GET /api/runs/:id surfaces appliedPluginSnapshotId on the run
|
|
// status body so a code agent that polled status (rather than
|
|
// streaming events) can still reach the snapshot id.
|
|
// 5. POST /api/applied-plugins/:id is fetchable and returns the same
|
|
// snapshot a replay would re-launch against.
|
|
//
|
|
// Once the pipeline runner is wired into startChatRun (deferred to the
|
|
// Phase 1 follow-up that lands a fully-driven agent loop), this test
|
|
// gets extended to assert the first SSE event is `pipeline_stage_started`.
|
|
|
|
import type http from 'node:http';
|
|
import { execFile } from 'node:child_process';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import path from 'node:path';
|
|
import url from 'node:url';
|
|
import { promisify } from 'node:util';
|
|
import { startServer } from '../src/server.js';
|
|
|
|
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = path.resolve(__dirname, '../../..');
|
|
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'plugin-fixtures', 'sample-plugin');
|
|
const CLI_SRC = path.join(__dirname, '../src/cli.ts');
|
|
const TSX_CLI = path.join(REPO_ROOT, 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
|
const execFileP = promisify(execFile);
|
|
|
|
let server: http.Server;
|
|
let baseUrl: string;
|
|
let shutdown: (() => Promise<void> | void) | undefined;
|
|
|
|
beforeAll(async () => {
|
|
const started = (await startServer({ port: 0, returnServer: true })) as {
|
|
url: string;
|
|
server: http.Server;
|
|
shutdown?: () => Promise<void> | void;
|
|
};
|
|
baseUrl = started.url;
|
|
server = started.server;
|
|
shutdown = started.shutdown;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await Promise.resolve(shutdown?.());
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
});
|
|
|
|
async function withFakeAgent<T>(
|
|
binName: string,
|
|
script: string,
|
|
run: () => Promise<T>,
|
|
): Promise<T> {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'od-headless-agent-bin-'));
|
|
const oldPath = process.env.PATH;
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
const runner = path.join(dir, `${binName}-runner.cjs`);
|
|
await writeFile(runner, script);
|
|
await writeFile(
|
|
path.join(dir, `${binName}.cmd`),
|
|
`@echo off\r\nnode "${runner}" %*\r\n`,
|
|
);
|
|
} else {
|
|
const bin = path.join(dir, binName);
|
|
await writeFile(bin, `#!/usr/bin/env node\n${script}`);
|
|
await chmod(bin, 0o755);
|
|
}
|
|
process.env.PATH = `${dir}${path.delimiter}${oldPath ?? ''}`;
|
|
return await run();
|
|
} finally {
|
|
if (oldPath === undefined) {
|
|
delete process.env.PATH;
|
|
} else {
|
|
process.env.PATH = oldPath;
|
|
}
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function runCli(
|
|
args: string[],
|
|
options: { timeout?: number } = {},
|
|
): Promise<{ stdout: string; stderr: string }> {
|
|
const env: NodeJS.ProcessEnv = {
|
|
...process.env,
|
|
OD_DAEMON_URL: baseUrl,
|
|
};
|
|
delete env.NODE_OPTIONS;
|
|
return await execFileP(process.execPath, [TSX_CLI, CLI_SRC, ...args], {
|
|
cwd: path.join(__dirname, '..'),
|
|
env,
|
|
timeout: options.timeout ?? 20_000,
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
}) as { stdout: string; stderr: string };
|
|
}
|
|
|
|
async function readSseUntilSuccess(resp: Response) {
|
|
if (!resp.body) throw new Error('install: no body');
|
|
const reader = resp.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const blocks = buffer.split('\n\n');
|
|
buffer = blocks.pop() ?? '';
|
|
for (const block of blocks) {
|
|
const eventLine = block.split('\n').find((l) => l.startsWith('event: '));
|
|
const dataLine = block.split('\n').find((l) => l.startsWith('data: '));
|
|
const event = eventLine ? eventLine.slice('event: '.length) : '';
|
|
const data = dataLine ? JSON.parse(dataLine.slice('data: '.length)) : null;
|
|
if (event === 'success') return data;
|
|
if (event === 'error') throw new Error(data?.message ?? 'install failed');
|
|
}
|
|
}
|
|
throw new Error('install stream ended without success');
|
|
}
|
|
|
|
describe('Plan §8 e2e-3 (entry slice) — headless install → project → run', () => {
|
|
it('walks install → project create → run start → status with snapshot pinned', async () => {
|
|
// 1. Install a local fixture plugin via the SSE install endpoint.
|
|
const installResp = await fetch(`${baseUrl}/api/plugins/install`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
body: JSON.stringify({ source: FIXTURE_DIR }),
|
|
});
|
|
expect(installResp.status).toBe(200);
|
|
const installSuccess = await readSseUntilSuccess(installResp);
|
|
expect(installSuccess?.plugin?.id).toBe('sample-plugin');
|
|
|
|
// 2. Create a project bound to the plugin.
|
|
const projectId = `headless-${Date.now()}`;
|
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Headless e2e-3',
|
|
pluginId: 'sample-plugin',
|
|
pluginInputs: { topic: 'agentic design' },
|
|
}),
|
|
});
|
|
expect(createResp.status).toBe(200);
|
|
const createBody = (await createResp.json()) as {
|
|
project: { id: string };
|
|
conversationId: string;
|
|
appliedPluginSnapshotId?: string;
|
|
};
|
|
expect(createBody.project.id).toBe(projectId);
|
|
expect(createBody.appliedPluginSnapshotId).toBeTruthy();
|
|
|
|
// 3. Start a run that re-uses the same applied snapshot id.
|
|
const runResp = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
projectId,
|
|
pluginId: 'sample-plugin',
|
|
appliedPluginSnapshotId: createBody.appliedPluginSnapshotId,
|
|
pluginInputs: { topic: 'agentic design' },
|
|
}),
|
|
});
|
|
expect(runResp.status).toBe(202);
|
|
const runBody = (await runResp.json()) as {
|
|
runId: string;
|
|
pluginId?: string;
|
|
appliedPluginSnapshotId?: string;
|
|
};
|
|
expect(runBody.runId).toBeTruthy();
|
|
expect(runBody.pluginId).toBe('sample-plugin');
|
|
expect(runBody.appliedPluginSnapshotId).toBe(createBody.appliedPluginSnapshotId);
|
|
|
|
// 4. The run status surfaces the snapshot id so a polling client
|
|
// can reach replay without parsing the SSE stream.
|
|
const statusResp = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}`);
|
|
expect(statusResp.status).toBe(200);
|
|
const statusBody = (await statusResp.json()) as {
|
|
id: string;
|
|
projectId: string;
|
|
pluginId: string | null;
|
|
appliedPluginSnapshotId: string | null;
|
|
};
|
|
expect(statusBody.pluginId).toBe('sample-plugin');
|
|
expect(statusBody.appliedPluginSnapshotId).toBe(createBody.appliedPluginSnapshotId);
|
|
|
|
// 5. Replay reads the same snapshot row.
|
|
const snapResp = await fetch(`${baseUrl}/api/applied-plugins/${encodeURIComponent(createBody.appliedPluginSnapshotId!)}`);
|
|
expect(snapResp.status).toBe(200);
|
|
const snap = (await snapResp.json()) as {
|
|
snapshotId: string;
|
|
pluginId: string;
|
|
query?: string;
|
|
inputs?: Record<string, string | number | boolean>;
|
|
};
|
|
expect(snap.snapshotId).toBe(createBody.appliedPluginSnapshotId);
|
|
expect(snap.pluginId).toBe('sample-plugin');
|
|
expect(snap.query).toBe('Generate a {{topic}} brief for {{audience}}.');
|
|
expect(snap.inputs).toEqual({ audience: 'general', topic: 'agentic design' });
|
|
|
|
// Cancel the run so the test cleans up the in-memory child path.
|
|
await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}/cancel`, { method: 'POST' });
|
|
});
|
|
|
|
it('creates share projects for publishing and contributing a user plugin', async () => {
|
|
const installResp = await fetch(`${baseUrl}/api/plugins/install`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
body: JSON.stringify({ source: FIXTURE_DIR }),
|
|
});
|
|
expect(installResp.status).toBe(200);
|
|
const installSuccess = await readSseUntilSuccess(installResp);
|
|
expect(installSuccess?.plugin?.id).toBe('sample-plugin');
|
|
|
|
const shareResp = await fetch(`${baseUrl}/api/plugins/sample-plugin/share-project`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ action: 'publish-github', locale: 'en' }),
|
|
});
|
|
expect(shareResp.status).toBe(200);
|
|
const shareBody = (await shareResp.json()) as {
|
|
ok: boolean;
|
|
project: { id: string; pendingPrompt?: string };
|
|
conversationId: string;
|
|
appliedPluginSnapshotId?: string;
|
|
actionPluginId: string;
|
|
sourcePluginId: string;
|
|
stagedPath: string;
|
|
prompt: string;
|
|
};
|
|
expect(shareBody.ok).toBe(true);
|
|
expect(shareBody.actionPluginId).toBe('od-plugin-publish-github');
|
|
expect(shareBody.sourcePluginId).toBe('sample-plugin');
|
|
expect(shareBody.appliedPluginSnapshotId).toBeTruthy();
|
|
expect(shareBody.stagedPath).toBe('plugin-source/sample-plugin');
|
|
expect(shareBody.prompt).toContain('Publish the local Open Design plugin');
|
|
expect(shareBody.prompt).toContain('/api/projects/$OD_PROJECT_ID/plugins/publish-github');
|
|
expect(shareBody.prompt).toContain('plugin-source/sample-plugin');
|
|
expect(shareBody.project.pendingPrompt).toBe(shareBody.prompt);
|
|
|
|
const filesResp = await fetch(
|
|
`${baseUrl}/api/projects/${encodeURIComponent(shareBody.project.id)}/files`,
|
|
);
|
|
expect(filesResp.status).toBe(200);
|
|
const filesBody = (await filesResp.json()) as { files: Array<{ name: string }> };
|
|
const fileNames = filesBody.files.map((file) => file.name).sort();
|
|
expect(fileNames).toContain('plugin-source/sample-plugin/open-design.json');
|
|
expect(fileNames).toContain('plugin-source/sample-plugin/SKILL.md');
|
|
|
|
const snapshotResp = await fetch(
|
|
`${baseUrl}/api/applied-plugins/${encodeURIComponent(shareBody.appliedPluginSnapshotId!)}`,
|
|
);
|
|
expect(snapshotResp.status).toBe(200);
|
|
const snapshot = (await snapshotResp.json()) as {
|
|
pluginId: string;
|
|
inputs?: Record<string, string | number | boolean>;
|
|
};
|
|
expect(snapshot.pluginId).toBe('od-plugin-publish-github');
|
|
expect(snapshot.inputs).toMatchObject({
|
|
source_plugin_id: 'sample-plugin',
|
|
plugin_context_path: 'plugin-source/sample-plugin',
|
|
});
|
|
|
|
const contributeResp = await fetch(`${baseUrl}/api/plugins/sample-plugin/share-project`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ action: 'contribute-open-design', locale: 'en' }),
|
|
});
|
|
expect(contributeResp.status).toBe(200);
|
|
const contributeBody = (await contributeResp.json()) as {
|
|
ok: boolean;
|
|
project: { id: string };
|
|
appliedPluginSnapshotId?: string;
|
|
actionPluginId: string;
|
|
sourcePluginId: string;
|
|
stagedPath: string;
|
|
prompt: string;
|
|
};
|
|
expect(contributeBody.ok).toBe(true);
|
|
expect(contributeBody.actionPluginId).toBe('od-plugin-contribute-open-design');
|
|
expect(contributeBody.sourcePluginId).toBe('sample-plugin');
|
|
expect(contributeBody.appliedPluginSnapshotId).toBeTruthy();
|
|
expect(contributeBody.stagedPath).toBe('plugin-source/sample-plugin');
|
|
expect(contributeBody.prompt).toContain('/api/projects/$OD_PROJECT_ID/plugins/contribute-open-design');
|
|
|
|
const locator = process.platform === 'win32' ? 'where' : 'which';
|
|
const realGit = ((await execFileP(locator, ['git'])).stdout as string)
|
|
.split(/\r?\n/)
|
|
.find(Boolean)
|
|
?.trim();
|
|
expect(realGit).toBeTruthy();
|
|
const previousRealGit = process.env.OD_REAL_GIT;
|
|
const previousGitAuthorName = process.env.GIT_AUTHOR_NAME;
|
|
const previousGitAuthorEmail = process.env.GIT_AUTHOR_EMAIL;
|
|
const previousGitCommitterName = process.env.GIT_COMMITTER_NAME;
|
|
const previousGitCommitterEmail = process.env.GIT_COMMITTER_EMAIL;
|
|
process.env.OD_REAL_GIT = realGit;
|
|
process.env.GIT_AUTHOR_NAME = 'Open Design Test';
|
|
process.env.GIT_AUTHOR_EMAIL = 'open-design-test@example.com';
|
|
process.env.GIT_COMMITTER_NAME = 'Open Design Test';
|
|
process.env.GIT_COMMITTER_EMAIL = 'open-design-test@example.com';
|
|
try {
|
|
await withFakeAgent(
|
|
'gh',
|
|
`
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { spawnSync } = require('node:child_process');
|
|
const args = process.argv.slice(2);
|
|
function ok(text) {
|
|
if (text) process.stdout.write(text + '\\n');
|
|
process.exit(0);
|
|
}
|
|
if (args[0] === '--version') ok('gh version 2.0.0');
|
|
if (args[0] === 'auth' && args[1] === 'status') ok('Logged in to github.com account test-user');
|
|
if (args[0] === 'api' && args[1] === 'user') ok('test-user');
|
|
if (args[0] === 'repo' && args[1] === 'create') ok('https://github.com/test-user/' + args[2]);
|
|
if (args[0] === 'repo' && args[1] === 'view') {
|
|
const repo = args[2] || '';
|
|
if (!args.includes('--json') && (repo === 'test-user/sample-plugin' || repo.endsWith('/sample-plugin'))) {
|
|
console.error('GraphQL: Could not resolve to a Repository with the name "' + repo + '".');
|
|
process.exit(1);
|
|
}
|
|
if (args.includes('--json')) {
|
|
const fullName = repo || 'test-user/sample-plugin';
|
|
ok(JSON.stringify({ nameWithOwner: fullName, url: 'https://github.com/' + fullName }));
|
|
}
|
|
ok('https://github.com/test-user/' + path.basename(process.cwd()));
|
|
}
|
|
if (args[0] === 'repo' && args[1] === 'fork') ok('forked nexu-io/open-design');
|
|
if (args[0] === 'repo' && args[1] === 'clone') {
|
|
const dest = args[3] || path.basename(args[2]);
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
const init = spawnSync(process.env.OD_REAL_GIT, ['init'], { cwd: dest, stdio: 'inherit' });
|
|
process.exit(init.status ?? 0);
|
|
}
|
|
if (args[0] === 'pr' && args[1] === 'create') ok('https://github.com/nexu-io/open-design/pull/123');
|
|
console.error('unexpected gh command: ' + args.join(' '));
|
|
process.exit(1);
|
|
`,
|
|
async () => {
|
|
await withFakeAgent(
|
|
'git',
|
|
`
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { spawnSync } = require('node:child_process');
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === 'push') {
|
|
console.log('pushed');
|
|
process.exit(0);
|
|
}
|
|
if (args[0] === 'clone') {
|
|
const dest = args[args.length - 1];
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
const init = spawnSync(process.env.OD_REAL_GIT, ['init', '-b', 'main'], { cwd: dest, encoding: 'utf8' });
|
|
if (init.status !== 0) {
|
|
if (init.stderr) process.stderr.write(init.stderr);
|
|
process.exit(init.status ?? 1);
|
|
}
|
|
const remote = args.find((arg) => String(arg).startsWith('https://')) || 'https://github.com/test-user/open-design.git';
|
|
const remoteAdd = spawnSync(process.env.OD_REAL_GIT, ['remote', 'add', 'origin', remote], { cwd: dest, encoding: 'utf8' });
|
|
if (remoteAdd.status !== 0) {
|
|
if (remoteAdd.stderr) process.stderr.write(remoteAdd.stderr);
|
|
process.exit(remoteAdd.status ?? 1);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
const result = spawnSync(process.env.OD_REAL_GIT, args, {
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
encoding: 'utf8',
|
|
});
|
|
if (result.stdout) process.stdout.write(result.stdout);
|
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
process.exit(result.status ?? 0);
|
|
`,
|
|
async () => {
|
|
const publishEndpointResp = await fetch(
|
|
`${baseUrl}/api/projects/${encodeURIComponent(shareBody.project.id)}/plugins/publish-github`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ path: shareBody.stagedPath }),
|
|
},
|
|
);
|
|
expect(publishEndpointResp.status).toBe(200);
|
|
const publishEndpointBody = (await publishEndpointResp.json()) as {
|
|
ok: boolean;
|
|
url?: string;
|
|
};
|
|
expect(publishEndpointBody.ok).toBe(true);
|
|
expect(publishEndpointBody.url).toBe('https://github.com/test-user/sample-plugin');
|
|
|
|
const contributeEndpointResp = await fetch(
|
|
`${baseUrl}/api/projects/${encodeURIComponent(contributeBody.project.id)}/plugins/contribute-open-design`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ path: contributeBody.stagedPath }),
|
|
},
|
|
);
|
|
expect(contributeEndpointResp.status).toBe(200);
|
|
const contributeEndpointBody = (await contributeEndpointResp.json()) as {
|
|
ok: boolean;
|
|
url?: string;
|
|
};
|
|
expect(contributeEndpointBody.ok).toBe(true);
|
|
expect(contributeEndpointBody.url).toBe('https://github.com/nexu-io/open-design/pull/123');
|
|
},
|
|
);
|
|
},
|
|
);
|
|
} finally {
|
|
if (previousRealGit === undefined) {
|
|
delete process.env.OD_REAL_GIT;
|
|
} else {
|
|
process.env.OD_REAL_GIT = previousRealGit;
|
|
}
|
|
if (previousGitAuthorName === undefined) {
|
|
delete process.env.GIT_AUTHOR_NAME;
|
|
} else {
|
|
process.env.GIT_AUTHOR_NAME = previousGitAuthorName;
|
|
}
|
|
if (previousGitAuthorEmail === undefined) {
|
|
delete process.env.GIT_AUTHOR_EMAIL;
|
|
} else {
|
|
process.env.GIT_AUTHOR_EMAIL = previousGitAuthorEmail;
|
|
}
|
|
if (previousGitCommitterName === undefined) {
|
|
delete process.env.GIT_COMMITTER_NAME;
|
|
} else {
|
|
process.env.GIT_COMMITTER_NAME = previousGitCommitterName;
|
|
}
|
|
if (previousGitCommitterEmail === undefined) {
|
|
delete process.env.GIT_COMMITTER_EMAIL;
|
|
} else {
|
|
process.env.GIT_COMMITTER_EMAIL = previousGitCommitterEmail;
|
|
}
|
|
}
|
|
}, 120_000);
|
|
|
|
it('runs the CLI install → project create → plugin run path with query and local SKILL.md in the agent prompt', async () => {
|
|
const pluginRoot = await mkdtemp(path.join(tmpdir(), 'od-headless-cli-plugin-'));
|
|
const pluginId = `headless-cli-plugin-${randomUUID().slice(0, 8)}`;
|
|
const fixture = path.join(pluginRoot, pluginId);
|
|
await mkdir(fixture, { recursive: true });
|
|
await writeFile(
|
|
path.join(fixture, 'open-design.json'),
|
|
JSON.stringify({
|
|
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
|
|
name: pluginId,
|
|
title: 'Headless CLI Plugin',
|
|
version: '1.0.0',
|
|
description: 'Fixture that binds a local SKILL.md for headless CLI tests.',
|
|
license: 'MIT',
|
|
od: {
|
|
kind: 'skill',
|
|
taskKind: 'new-generation',
|
|
useCase: { query: 'Generate a {{topic}} brief for {{audience}}.' },
|
|
context: {
|
|
skills: [{ path: './SKILL.md' }],
|
|
atoms: ['todo-write', 'discovery-question-form'],
|
|
},
|
|
inputs: [
|
|
{ name: 'topic', type: 'string', required: true, label: 'Topic' },
|
|
{ name: 'audience', type: 'string', default: 'general', label: 'Audience' },
|
|
],
|
|
capabilities: ['prompt:inject'],
|
|
},
|
|
}, null, 2),
|
|
);
|
|
await writeFile(
|
|
path.join(fixture, 'SKILL.md'),
|
|
[
|
|
'---',
|
|
`name: ${pluginId}`,
|
|
'description: Local skill loaded by the headless CLI e2e test.',
|
|
'---',
|
|
'# Headless Local Skill',
|
|
'',
|
|
'Follow this local skill during headless runs.',
|
|
].join('\n'),
|
|
);
|
|
|
|
try {
|
|
const install = await runCli(['plugin', 'install', fixture]);
|
|
expect(install.stdout).toContain('[install] ok');
|
|
|
|
const topic = `headless cli ${randomUUID().slice(0, 8)}`;
|
|
const created = await runCli([
|
|
'project',
|
|
'create',
|
|
'--name',
|
|
'CLI headless plugin run',
|
|
'--plugin',
|
|
pluginId,
|
|
'--inputs',
|
|
JSON.stringify({ topic }),
|
|
'--json',
|
|
]);
|
|
const createBody = JSON.parse(created.stdout) as {
|
|
project: { id: string };
|
|
appliedPluginSnapshotId?: string;
|
|
};
|
|
expect(createBody.appliedPluginSnapshotId).toBeTruthy();
|
|
|
|
const captureRoot = await mkdtemp(path.join(tmpdir(), 'od-headless-cli-capture-'));
|
|
const capturePath = path.join(captureRoot, 'prompt.txt');
|
|
const previousCapture = process.env.OD_PROMPT_CAPTURE;
|
|
process.env.OD_PROMPT_CAPTURE = capturePath;
|
|
try {
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
const fs = require('node:fs');
|
|
let input = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
process.stdin.on('end', () => {
|
|
fs.writeFileSync(process.env.OD_PROMPT_CAPTURE, input);
|
|
console.log(JSON.stringify({ type: 'text', part: { text: 'headless-ok' } }));
|
|
});
|
|
`,
|
|
async () => {
|
|
const run = await runCli([
|
|
'plugin',
|
|
'run',
|
|
pluginId,
|
|
'--project',
|
|
createBody.project.id,
|
|
'--inputs',
|
|
JSON.stringify({ topic }),
|
|
'--agent',
|
|
'opencode',
|
|
'--follow',
|
|
], { timeout: 60_000 });
|
|
expect(run.stdout).toContain('[run] started run');
|
|
expect(run.stdout).toContain('"event":"agent"');
|
|
expect(run.stdout).toContain('headless-ok');
|
|
expect(run.stdout).toContain('"event":"end"');
|
|
expect(run.stdout).toContain('"status":"succeeded"');
|
|
},
|
|
);
|
|
|
|
const prompt = await readFile(capturePath, 'utf8');
|
|
expect(prompt).toContain('# Headless Local Skill');
|
|
expect(prompt).toContain('Follow this local skill during headless runs.');
|
|
expect(prompt).toContain('## Active plugin');
|
|
expect(prompt).toContain('The plugin\'s example brief is: _Generate a {{topic}} brief for {{audience}}._');
|
|
expect(prompt).toContain(`- **topic**: ${topic}`);
|
|
expect(prompt).toContain('- **audience**: general');
|
|
expect(prompt).toContain(`# User request\n\nGenerate a ${topic} brief for general.`);
|
|
} finally {
|
|
if (previousCapture === undefined) {
|
|
delete process.env.OD_PROMPT_CAPTURE;
|
|
} else {
|
|
process.env.OD_PROMPT_CAPTURE = previousCapture;
|
|
}
|
|
await rm(captureRoot, { recursive: true, force: true });
|
|
}
|
|
} finally {
|
|
await rm(pluginRoot, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
|
|
// Full §8 e2e-3 contract — once the pipeline runner fires on a run
|
|
// with a declared pipeline, the first ND-JSON event should be
|
|
// `pipeline_stage_started`. Plan §3.I1 wires firePipelineForRun into
|
|
// POST /api/runs so any plugin run with `od.pipeline.stages[*]`
|
|
// emits the stage timeline before the agent's message_chunk stream.
|
|
it('first SSE event on a plugin run with od.pipeline is pipeline_stage_started', async () => {
|
|
// Install a fixture plugin with a 2-stage pipeline. We use a
|
|
// disposable manifest rather than the on-disk fixture so the
|
|
// pipeline shape is locked here.
|
|
const fs = await import('node:fs/promises');
|
|
const os = await import('node:os');
|
|
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'od-headless-pipeline-'));
|
|
const fixture = path.join(tmpRoot, 'pipeline-plugin');
|
|
await fs.mkdir(fixture, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(fixture, 'open-design.json'),
|
|
JSON.stringify({
|
|
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
|
|
name: 'pipeline-plugin',
|
|
title: 'Pipeline Plugin',
|
|
version: '1.0.0',
|
|
description: 'fixture with a declared pipeline',
|
|
license: 'MIT',
|
|
od: {
|
|
kind: 'skill',
|
|
taskKind: 'new-generation',
|
|
useCase: { query: 'Make a {{topic}} brief.' },
|
|
inputs: [{ name: 'topic', type: 'string', required: true, label: 'Topic' }],
|
|
pipeline: {
|
|
stages: [
|
|
{ id: 'discovery', atoms: ['discovery-question-form'] },
|
|
{ id: 'plan', atoms: ['todo-write'] },
|
|
],
|
|
},
|
|
capabilities: ['prompt:inject'],
|
|
},
|
|
}, null, 2),
|
|
);
|
|
await fs.writeFile(
|
|
path.join(fixture, 'SKILL.md'),
|
|
'---\nname: pipeline-plugin\ndescription: fixture with pipeline\n---\n# Pipeline\n',
|
|
);
|
|
|
|
const installResp = await fetch(`${baseUrl}/api/plugins/install`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
body: JSON.stringify({ source: fixture }),
|
|
});
|
|
await readSseUntilSuccess(installResp);
|
|
|
|
const projectId = `pipeline-${Date.now()}`;
|
|
// The fixture declares od.pipeline.stages and is installed under
|
|
// sourceKind='local' (default trust='restricted'). The required
|
|
// capabilities therefore include pipeline:*; the test grants it
|
|
// ephemerally via the resolver so the snapshot is created without
|
|
// re-asking the user.
|
|
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Pipeline e2e-3',
|
|
pluginId: 'pipeline-plugin',
|
|
pluginInputs: { topic: 'agentic design' },
|
|
grantCaps: ['pipeline:*'],
|
|
}),
|
|
});
|
|
expect(createResp.status).toBe(200);
|
|
const createBody = (await createResp.json()) as {
|
|
project: { id: string };
|
|
conversationId: string;
|
|
appliedPluginSnapshotId?: string;
|
|
};
|
|
expect(createBody.appliedPluginSnapshotId).toBeTruthy();
|
|
|
|
const runResp = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
projectId,
|
|
pluginId: 'pipeline-plugin',
|
|
appliedPluginSnapshotId: createBody.appliedPluginSnapshotId,
|
|
grantCaps: ['pipeline:*'],
|
|
}),
|
|
});
|
|
expect(runResp.status).toBe(202);
|
|
const runBody = (await runResp.json()) as { runId: string };
|
|
|
|
// The pipeline emits its first event synchronously inside POST
|
|
// /api/runs (firePipelineForRun runs before design.runs.start
|
|
// schedules the agent), so by the time we GET /api/runs/:id/events
|
|
// the run buffer already contains pipeline_stage_started.
|
|
// Wait briefly for the async tail (devloop iteration log) to settle.
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
|
|
const statusResp = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}`);
|
|
const statusBody = (await statusResp.json()) as { id: string };
|
|
expect(statusBody.id).toBe(runBody.runId);
|
|
|
|
// Read the run's event buffer through the SSE stream — the
|
|
// server pipes every record through res.write, so reading the
|
|
// body until 'end' or pipeline_stage_completed surfaces the
|
|
// first events. We don't actually wait for end (the run is
|
|
// long-running); we just look for the stage-start anchor.
|
|
const eventsResp = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}/events`, {
|
|
headers: { accept: 'text/event-stream' },
|
|
});
|
|
expect(eventsResp.body).toBeTruthy();
|
|
const reader = eventsResp.body!.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
let firstStageEvent: string | null = null;
|
|
let messageChunkSeen = false;
|
|
const start = Date.now();
|
|
while (Date.now() - start < 1500) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const blocks = buffer.split('\n\n');
|
|
buffer = blocks.pop() ?? '';
|
|
for (const block of blocks) {
|
|
const eventLine = block.split('\n').find((l) => l.startsWith('event: '));
|
|
if (!eventLine) continue;
|
|
const event = eventLine.slice('event: '.length);
|
|
if (event === 'pipeline_stage_started' && !firstStageEvent && !messageChunkSeen) {
|
|
firstStageEvent = event;
|
|
}
|
|
if (event === 'message_chunk') messageChunkSeen = true;
|
|
if (firstStageEvent || event === 'end') break;
|
|
}
|
|
if (firstStageEvent) break;
|
|
}
|
|
void reader.cancel().catch(() => undefined);
|
|
|
|
expect(firstStageEvent).toBe('pipeline_stage_started');
|
|
|
|
await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}/cancel`, { method: 'POST' });
|
|
await fs.rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
});
|