mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(daemon): resolve the AMR default model from the caller's catalog A run that asked for `default` before the caller's `vela model list` had arrived was concretized from the bundled `vela model preset` seed. The seed is the same for every plan and OD orders deepseek-v4-flash first, so free-tier callers (entitled only to deepseek-v4.1-flash since 09-24) had Vela reject `session/set_model` before any work began. On 0.24.1 that was 22 runs from 21 users in the last 7 days, 13 of 16 bundled runs showing the user's choice as `default`. - When `default` is requested and only the preset is cached, wait up to 12s for the caller's remote catalog (`getAuthoritative`). - Remember remote catalogs separately; a later preset seed never displaces one when a default model is chosen. - Order deepseek-v4.1-flash first: it is the one chat model every plan, including free, is entitled to, so a flagless list still lands on it. * fix(daemon): bound the AMR default-model catalog wait per refresh CI follow-up. A `default` run looked the catalog up twice (the resume probe and the run itself) and each lookup waited up to 12 s for the caller's remote catalog, so a slow or broken `vela model list` held the run queued for up to 24 s. The AMR e2e runs timed out at 20 s that way. - The wait is counted from the start of the remote refresh, so one run, or several concurrent runs, share a single wait. - When the last refresh failed, the preset is used at once; the next refresh still starts in the background. - e2e fake vela: answer `model list --all --format json`, the form the daemon actually sends. It only matched the flag-less form and hung. * test(e2e): let the fake vela answer the daemon's `model list --all` probe The catalog probe spawns `vela model list --all --format json`. The matcher stripped `--all` with its preceding space only, so `' --format json'` never equalled `'--format json'`: the probe fell through to the login check, the cache recorded a failed refresh, and default runs settled on the preset. Match the subcommand and the `--format json` flags in any order, as fake-vela.mjs and turn.test.ts already do, and cover the exact daemon argv for both the catalog and the 401 path.
4743 lines
179 KiB
TypeScript
4743 lines
179 KiB
TypeScript
import type http from 'node:http';
|
|
import Database from 'better-sqlite3';
|
|
import { randomUUID } from 'node:crypto';
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
promises as fsp,
|
|
readFileSync,
|
|
realpathSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { tmpdir } from 'node:os';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { delimiter, join, resolve } from 'node:path';
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
import {
|
|
composeLiveInstructionPrompt,
|
|
describeStablePromptCache,
|
|
designSystemIdFromPluginSnapshot,
|
|
resolveChatExtraAllowedDirs,
|
|
resolveEffectiveDesignSystemSelection,
|
|
resolveResearchCommandContract,
|
|
startServer,
|
|
} from '../src/server.js';
|
|
import { skillCwdAliasSegment } from '../src/cwd-aliases.js';
|
|
import { amrModelLoadingCache } from '../src/runtimes/amr-model-cache.js';
|
|
import { resetLiveModelsForTests } from '../src/runtimes/models.js';
|
|
import { getAgentDef } from '../src/agents.js';
|
|
import { readAppConfig, writeAppConfig } from '../src/app-config.js';
|
|
import { readMemoryConfig, writeMemoryConfig } from '../src/memory.js';
|
|
import {
|
|
ensureWorkspaceProject,
|
|
ensureWorkspaceResource,
|
|
getProject,
|
|
upsertMessage,
|
|
} from '../src/db.js';
|
|
import { teamResourceWorkspaceRoot } from '../src/collab/team-resource-materialization.js';
|
|
import { workspaceTeamDesignSystemBindingResourceId } from '../src/design-systems/workspace-team-binding.js';
|
|
|
|
const FAKE_VELA_FIXTURE = resolve(process.cwd(), 'tests', 'fixtures', 'fake-vela.mjs');
|
|
|
|
async function withFakeAgent<T>(
|
|
binName: string,
|
|
script: string,
|
|
run: () => Promise<T>,
|
|
): Promise<T> {
|
|
const dir = await fsp.mkdtemp(join(tmpdir(), 'od-chat-route-bin-'));
|
|
const oldPath = process.env.PATH;
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
const runner = join(dir, `${binName}-test-runner.cjs`);
|
|
await fsp.writeFile(runner, script);
|
|
await fsp.writeFile(
|
|
join(dir, `${binName}.cmd`),
|
|
`@echo off\r\nnode "${runner}" %*\r\n`,
|
|
);
|
|
} else {
|
|
const bin = join(dir, binName);
|
|
await fsp.writeFile(bin, `#!/usr/bin/env node\n${script}`);
|
|
await fsp.chmod(bin, 0o755);
|
|
}
|
|
process.env.PATH = `${dir}${delimiter}${oldPath ?? ''}`;
|
|
return await run();
|
|
} finally {
|
|
process.env.PATH = oldPath;
|
|
killProcessesUsingPath(dir);
|
|
await fsp.rm(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function withCodexExecJson<T>(run: () => Promise<T>): Promise<T> {
|
|
const previous = process.env.OD_CODEX_TRANSPORT;
|
|
process.env.OD_CODEX_TRANSPORT = 'exec-json';
|
|
try {
|
|
return await run();
|
|
} finally {
|
|
if (previous == null) delete process.env.OD_CODEX_TRANSPORT;
|
|
else process.env.OD_CODEX_TRANSPORT = previous;
|
|
}
|
|
}
|
|
|
|
function killProcessesUsingPath(pathFragment: string): void {
|
|
if (process.platform === 'win32') return;
|
|
let output = '';
|
|
try {
|
|
output = execFileSync('pgrep', ['-f', pathFragment], { encoding: 'utf8' });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const line of output.split('\n')) {
|
|
const pid = Number(line.trim());
|
|
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) continue;
|
|
try {
|
|
process.kill(-pid, 'SIGKILL');
|
|
} catch {
|
|
try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('/api/chat', () => {
|
|
let server: http.Server;
|
|
let baseUrl: string;
|
|
let originalMemoryConfig: Awaited<ReturnType<typeof readMemoryConfig>> | null = null;
|
|
const originalPath = process.env.PATH;
|
|
const originalAgentHome = process.env.OD_AGENT_HOME;
|
|
const tempDirs: string[] = [];
|
|
|
|
async function createPersonalWorkspaceBoundProjectFixture(label: string) {
|
|
if (!process.env.OD_DATA_DIR) {
|
|
throw new Error('OD_DATA_DIR is required for AMR Workspace scope tests');
|
|
}
|
|
const projectId = `proj-${randomUUID()}`;
|
|
const workspaceId = `personal-ws-${randomUUID()}`;
|
|
const workspaceMemberId = `personal-member-${randomUUID()}`;
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: projectId, name: label }),
|
|
});
|
|
expect(createProjectResponse.ok).toBe(true);
|
|
|
|
const sqlite = new Database(resolve(process.env.OD_DATA_DIR, 'app.sqlite'));
|
|
try {
|
|
ensureWorkspaceProject(sqlite as never, {
|
|
projectId,
|
|
workspaceId,
|
|
visibility: 'personal',
|
|
createdByWorkspaceMemberId: workspaceMemberId,
|
|
});
|
|
} finally {
|
|
sqlite.close();
|
|
}
|
|
|
|
return {
|
|
projectId,
|
|
headers: {
|
|
'x-od-workspace-id': workspaceId,
|
|
'x-od-workspace-member-id': workspaceMemberId,
|
|
'x-od-workspace-type': 'personal',
|
|
'x-od-workspace-role': 'owner',
|
|
},
|
|
};
|
|
}
|
|
|
|
async function runAmrModelRequest(model: string, extraEnv: Record<string, string> = {}): Promise<{
|
|
body: string;
|
|
invocations: string[];
|
|
responseOk: boolean;
|
|
}> {
|
|
if (!process.env.OD_DATA_DIR) {
|
|
throw new Error('OD_DATA_DIR is required for AMR model capability tests');
|
|
}
|
|
const previousRuntimeKey = process.env.VELA_RUNTIME_KEY;
|
|
const previousLinkUrl = process.env.VELA_LINK_URL;
|
|
const previousInvocationLog = process.env.FAKE_VELA_INVOCATION_LOG;
|
|
const previousLogSetModel = process.env.FAKE_VELA_LOG_SET_MODEL;
|
|
const previousLogPrompt = process.env.FAKE_VELA_LOG_PROMPT;
|
|
const invocationLog = join(tmpdir(), `od-amr-model-request-${randomUUID()}.jsonl`);
|
|
const previousExtraEnv = Object.fromEntries(Object.keys(extraEnv).map((key) => [key, process.env[key]]));
|
|
try {
|
|
Object.assign(process.env, extraEnv);
|
|
process.env.VELA_RUNTIME_KEY = `fake-runtime-key-${randomUUID()}`;
|
|
process.env.VELA_LINK_URL = 'https://amr-link.open-design.ai/v1';
|
|
process.env.FAKE_VELA_INVOCATION_LOG = invocationLog;
|
|
process.env.FAKE_VELA_LOG_SET_MODEL = '1';
|
|
process.env.FAKE_VELA_LOG_PROMPT = '1';
|
|
const workspaceFixture = await createPersonalWorkspaceBoundProjectFixture(
|
|
`AMR model request ${randomUUID()}`,
|
|
);
|
|
let body = '';
|
|
let responseOk = false;
|
|
|
|
await withFakeAgent(
|
|
'vela',
|
|
`
|
|
const { spawn } = require('node:child_process');
|
|
const fixture = ${JSON.stringify(FAKE_VELA_FIXTURE)};
|
|
const child = spawn(process.execPath, [fixture, ...process.argv.slice(2)], {
|
|
stdio: 'inherit',
|
|
env: process.env,
|
|
});
|
|
child.on('exit', (code, signal) => {
|
|
if (signal) process.kill(process.pid, signal);
|
|
process.exit(code ?? 0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...workspaceFixture.headers,
|
|
},
|
|
body: JSON.stringify({
|
|
agentId: 'amr',
|
|
projectId: workspaceFixture.projectId,
|
|
message: 'Exercise the resolved AMR model.',
|
|
model,
|
|
}),
|
|
});
|
|
responseOk = response.ok;
|
|
body = await response.text();
|
|
},
|
|
);
|
|
|
|
const invocations = existsSync(invocationLog)
|
|
? readFileSync(invocationLog, 'utf8')
|
|
.trim()
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map((line) => (JSON.parse(line) as { method: string }).method)
|
|
: [];
|
|
return { body, invocations, responseOk };
|
|
} finally {
|
|
rmSync(invocationLog, { force: true });
|
|
for (const [key, value] of Object.entries(previousExtraEnv)) {
|
|
if (value == null) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
if (previousRuntimeKey == null) delete process.env.VELA_RUNTIME_KEY;
|
|
else process.env.VELA_RUNTIME_KEY = previousRuntimeKey;
|
|
if (previousLinkUrl == null) delete process.env.VELA_LINK_URL;
|
|
else process.env.VELA_LINK_URL = previousLinkUrl;
|
|
if (previousInvocationLog == null) delete process.env.FAKE_VELA_INVOCATION_LOG;
|
|
else process.env.FAKE_VELA_INVOCATION_LOG = previousInvocationLog;
|
|
if (previousLogSetModel == null) delete process.env.FAKE_VELA_LOG_SET_MODEL;
|
|
else process.env.FAKE_VELA_LOG_SET_MODEL = previousLogSetModel;
|
|
if (previousLogPrompt == null) delete process.env.FAKE_VELA_LOG_PROMPT;
|
|
else process.env.FAKE_VELA_LOG_PROMPT = previousLogPrompt;
|
|
}
|
|
}
|
|
|
|
async function createPluginFixture(args: {
|
|
pluginId: string;
|
|
dirName: string;
|
|
localSkillPath?: string;
|
|
}): Promise<string> {
|
|
const root = await fsp.mkdtemp(join(tmpdir(), 'od-plugin-fixture-'));
|
|
tempDirs.push(root);
|
|
const fixtureDir = resolve(root, args.dirName);
|
|
const baseFixtureDir = resolve(
|
|
process.cwd(),
|
|
'tests',
|
|
'fixtures',
|
|
'plugin-fixtures',
|
|
'sample-plugin',
|
|
);
|
|
await fsp.cp(baseFixtureDir, fixtureDir, { recursive: true });
|
|
const manifestPath = resolve(fixtureDir, 'open-design.json');
|
|
const manifest = JSON.parse(await fsp.readFile(manifestPath, 'utf8')) as {
|
|
name: string;
|
|
title: string;
|
|
od?: { context?: { skills?: Array<{ ref?: string; path?: string }> } };
|
|
};
|
|
manifest.name = args.pluginId;
|
|
manifest.title = args.pluginId;
|
|
if (args.localSkillPath) {
|
|
manifest.od ??= {};
|
|
manifest.od.context ??= {};
|
|
manifest.od.context.skills = [{ path: args.localSkillPath }];
|
|
}
|
|
await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
return fixtureDir;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
if (process.env.OD_DATA_DIR) {
|
|
originalMemoryConfig = await readMemoryConfig(process.env.OD_DATA_DIR);
|
|
await writeMemoryConfig(process.env.OD_DATA_DIR, {
|
|
enabled: false,
|
|
extraction: null,
|
|
});
|
|
}
|
|
const started = await startServer({
|
|
port: 0,
|
|
returnServer: true,
|
|
}) as {
|
|
url: string;
|
|
server: http.Server;
|
|
};
|
|
baseUrl = started.url;
|
|
server = started.server;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalPath == null) {
|
|
delete process.env.PATH;
|
|
} else {
|
|
process.env.PATH = originalPath;
|
|
}
|
|
if (originalAgentHome == null) {
|
|
delete process.env.OD_AGENT_HOME;
|
|
} else {
|
|
process.env.OD_AGENT_HOME = originalAgentHome;
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
for (const dir of tempDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
if (server) {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
}
|
|
if (process.env.OD_DATA_DIR && originalMemoryConfig) {
|
|
await writeMemoryConfig(process.env.OD_DATA_DIR, {
|
|
enabled: originalMemoryConfig.enabled,
|
|
extraction: originalMemoryConfig.extraction,
|
|
});
|
|
}
|
|
});
|
|
|
|
it('does not reference an out-of-scope response while starting a run', async () => {
|
|
process.env.PATH = '';
|
|
const emptyAgentHome = mkdtempSync(join(tmpdir(), 'od-empty-agent-home-'));
|
|
tempDirs.push(emptyAgentHome);
|
|
process.env.OD_AGENT_HOME = emptyAgentHome;
|
|
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'claude',
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).not.toContain('res is not defined');
|
|
expect(body).toContain('AGENT_UNAVAILABLE');
|
|
});
|
|
|
|
it.each([false, true])('keeps serving without a conversation row (project bound: %s)', async (projectBound) => {
|
|
const projectId = projectBound ? `unbound_chat_${randomUUID()}` : undefined;
|
|
if (projectId) {
|
|
const created = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: projectId, name: 'Unpersisted legacy chat' }),
|
|
});
|
|
expect(created.status).toBe(200);
|
|
}
|
|
const conversationId = `missing-conversation-${randomUUID()}`;
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
console.log(JSON.stringify({ type: 'step_start', sessionID: 'missing-conversation-session' }));
|
|
console.log(JSON.stringify({
|
|
type: 'text',
|
|
sessionID: 'missing-conversation-session',
|
|
part: { text: '<question-form id="discovery">{"questions":[{"id":"style","label":"Style?"}]}</question-form>' },
|
|
}));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
conversationId,
|
|
...(projectId ? { projectId } : {}),
|
|
message: 'ask for clarification',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('<question-form');
|
|
expect(body).toContain('"status":"succeeded"');
|
|
|
|
const healthResponse = await fetch(`${baseUrl}/api/health`);
|
|
expect(healthResponse.status).toBe(200);
|
|
},
|
|
);
|
|
});
|
|
|
|
it('marks json stream runs failed when an error frame exits with code 0', async () => {
|
|
const conversationId = `conv-${randomUUID()}`;
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
console.log(JSON.stringify({
|
|
type: 'error',
|
|
error: { message: 'model not found: fake-opencode-model' },
|
|
}));
|
|
process.exit(0);
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
conversationId,
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('AGENT_EXECUTION_FAILED');
|
|
expect(body).toContain('model not found: fake-opencode-model');
|
|
expect(body).toContain('"status":"failed"');
|
|
expect(body).not.toContain('"status":"succeeded"');
|
|
|
|
const runsResponse = await fetch(
|
|
`${baseUrl}/api/runs?conversationId=${encodeURIComponent(conversationId)}`,
|
|
);
|
|
const runsBody = (await runsResponse.json()) as {
|
|
runs: Array<{ conversationId: string | null; status: string; exitCode: number | null }>;
|
|
};
|
|
|
|
expect(runsBody.runs).toHaveLength(1);
|
|
expect(runsBody.runs[0]).toMatchObject({
|
|
conversationId,
|
|
status: 'failed',
|
|
exitCode: 1,
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
it('marks OpenCode tool-only runs failed when no assistant output is produced', async () => {
|
|
const conversationId = `conv-${randomUUID()}`;
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
console.log(JSON.stringify({ type: 'step_start', sessionID: 'opencode-tool-only-session' }));
|
|
console.log(JSON.stringify({
|
|
type: 'tool_use',
|
|
sessionID: 'opencode-tool-only-session',
|
|
part: {
|
|
tool: 'Read',
|
|
callID: 'call-read-1',
|
|
state: {
|
|
status: 'completed',
|
|
input: JSON.stringify({ file: 'src/app.ts' }),
|
|
output: 'file contents',
|
|
},
|
|
},
|
|
}));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 0 } } }));
|
|
process.exit(0);
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
conversationId,
|
|
message: 'read the file and summarize it',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('"type":"tool_use"');
|
|
expect(body).toContain('"type":"tool_result"');
|
|
expect(body).toContain('AGENT_EXECUTION_FAILED');
|
|
expect(body).toContain('Agent completed without producing any output');
|
|
expect(body).toContain('"status":"failed"');
|
|
expect(body).not.toContain('"status":"succeeded"');
|
|
|
|
const runsResponse = await fetch(
|
|
`${baseUrl}/api/runs?conversationId=${encodeURIComponent(conversationId)}`,
|
|
);
|
|
const runsBody = (await runsResponse.json()) as {
|
|
runs: Array<{ conversationId: string | null; status: string; exitCode: number | null }>;
|
|
};
|
|
|
|
expect(runsBody.runs).toHaveLength(1);
|
|
expect(runsBody.runs[0]).toMatchObject({
|
|
conversationId,
|
|
status: 'failed',
|
|
exitCode: 0,
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
/*
|
|
* 「问完就交棒」的一整条链路,从假 CLI 的字节一路到 run 的终态。
|
|
*
|
|
* 真机 run 441ff961-bd66-4c4a-91e7-812f1d489668(打包版 beta 0.21.1-beta.7):
|
|
* agent 先写下四条待办(1 条 in_progress + 3 条 pending),再发一个可渲染的
|
|
* `<question-form>` 交棒给用户,进程 exit 0、无 signal、无 error。
|
|
* 它被 stamp 成 endedWithUnfinishedWork,项目卡与 Pet 任务中心于是画成
|
|
* `incomplete`,聊天页脚说「已停止,仍有未完成任务」—— 而没有任何东西停过它。
|
|
*
|
|
* 这条用例走真实的 emitAgentEvent 收口(`captureRunWorkCompletenessSignals`
|
|
* 在那里把 text_delta 攒进 run.askUserScanText),所以它同时钉住了信号的采集
|
|
* 和 finish() 的判定;runs.test.ts 那几条只钉后者。
|
|
*/
|
|
it('does not stamp unfinished work on a turn that ended by asking the user', async () => {
|
|
const conversationId = `conv-${randomUUID()}`;
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
console.log(JSON.stringify({ type: 'step_start', sessionID: 'opencode-ask-user-session' }));
|
|
console.log(JSON.stringify({
|
|
type: 'tool_use',
|
|
sessionID: 'opencode-ask-user-session',
|
|
part: {
|
|
tool: 'todowrite',
|
|
callID: 'call-todo-1',
|
|
state: {
|
|
status: 'completed',
|
|
input: JSON.stringify({ todos: [
|
|
{ content: 'Collect the brand brief', status: 'in_progress' },
|
|
{ content: 'Decide the imagery strategy', status: 'pending' },
|
|
{ content: 'Fill inputs.json', status: 'pending' },
|
|
{ content: 'Render the landing page', status: 'pending' },
|
|
] }),
|
|
output: 'ok',
|
|
},
|
|
},
|
|
}));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: '开始之前先确认几件事。\\n<question-form id="brand-brief" title="Brand brief">\\n{"questions":[{"id":"brand_name","label":"Brand name","type":"text"}]}\\n</question-form>' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
conversationId,
|
|
message: '帮我做个落地页。',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('<question-form');
|
|
expect(body).toContain('"status":"succeeded"');
|
|
|
|
const runsResponse = await fetch(
|
|
`${baseUrl}/api/runs?conversationId=${encodeURIComponent(conversationId)}`,
|
|
);
|
|
const runsBody = (await runsResponse.json()) as {
|
|
runs: Array<{ status: string; endedWithUnfinishedWork: boolean }>;
|
|
};
|
|
expect(runsBody.runs).toHaveLength(1);
|
|
expect(runsBody.runs[0]).toMatchObject({
|
|
status: 'succeeded',
|
|
endedWithUnfinishedWork: false,
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
// 量法能看见缺陷:同一份待办、同一条链路,只把可渲染的表单换成被引用的裸标记,
|
|
// 这一轮就必须重新报「有未完成的活」。产物 HTML / 代码示例里出现这段文本的回合
|
|
// 不许因此被静音。
|
|
it('still stamps unfinished work when the form markup was only quoted', async () => {
|
|
const conversationId = `conv-${randomUUID()}`;
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
console.log(JSON.stringify({ type: 'step_start', sessionID: 'opencode-quoted-form-session' }));
|
|
console.log(JSON.stringify({
|
|
type: 'tool_use',
|
|
sessionID: 'opencode-quoted-form-session',
|
|
part: {
|
|
tool: 'todowrite',
|
|
callID: 'call-todo-1',
|
|
state: {
|
|
status: 'completed',
|
|
input: JSON.stringify({ todos: [
|
|
{ content: 'Collect the brand brief', status: 'in_progress' },
|
|
{ content: 'Render the landing page', status: 'pending' },
|
|
] }),
|
|
output: 'ok',
|
|
},
|
|
},
|
|
}));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: '顺带演示一下 <question-form> 这个标记怎么写,我先接着做。' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
conversationId,
|
|
message: '帮我做个落地页。',
|
|
}),
|
|
});
|
|
expect((await response.text())).toContain('"status":"succeeded"');
|
|
|
|
const runsResponse = await fetch(
|
|
`${baseUrl}/api/runs?conversationId=${encodeURIComponent(conversationId)}`,
|
|
);
|
|
const runsBody = (await runsResponse.json()) as {
|
|
runs: Array<{ status: string; endedWithUnfinishedWork: boolean }>;
|
|
};
|
|
expect(runsBody.runs).toHaveLength(1);
|
|
expect(runsBody.runs[0]).toMatchObject({
|
|
status: 'succeeded',
|
|
endedWithUnfinishedWork: true,
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
it('passes OPENCODE_CONFIG_CONTENT external_directory rules for the managed project cwd', async () => {
|
|
if (!process.env.OD_DATA_DIR) {
|
|
throw new Error('OD_DATA_DIR is required for OpenCode cwd permission tests');
|
|
}
|
|
|
|
const projectId = `proj-${randomUUID()}`;
|
|
const markerDir = await fsp.mkdtemp(join(tmpdir(), 'od-opencode-config-'));
|
|
tempDirs.push(markerDir);
|
|
const envFile = join(markerDir, 'opencode-config-content.json');
|
|
const cwdFile = join(markerDir, 'cwd.txt');
|
|
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: projectId, name: 'OpenCode cwd permission fixture' }),
|
|
});
|
|
expect(createProjectResponse.ok).toBe(true);
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
const fs = require('node:fs');
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => {
|
|
fs.writeFileSync(${JSON.stringify(envFile)}, process.env.OPENCODE_CONFIG_CONTENT || '');
|
|
fs.writeFileSync(${JSON.stringify(cwdFile)}, process.cwd());
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: 'cwd-permission-ok' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('cwd-permission-ok');
|
|
|
|
const effectiveCwd = (await fsp.readFile(cwdFile, 'utf8')).trim();
|
|
const raw = await fsp.readFile(envFile, 'utf8');
|
|
const parsed = JSON.parse(raw) as {
|
|
permission?: {
|
|
external_directory?: Record<string, string>;
|
|
};
|
|
};
|
|
|
|
const externalDirectory = parsed.permission?.external_directory ?? {};
|
|
const cwdAliases = new Set([effectiveCwd]);
|
|
if (effectiveCwd.startsWith('/private/var/')) {
|
|
cwdAliases.add(effectiveCwd.replace(/^\/private\/var\//, '/var/'));
|
|
}
|
|
const allowedCwd = [...cwdAliases].find(
|
|
(cwd) =>
|
|
externalDirectory[cwd] === 'allow' &&
|
|
externalDirectory[`${cwd}/*`] === 'allow' &&
|
|
externalDirectory[`${cwd}/**`] === 'allow',
|
|
);
|
|
expect(allowedCwd).toBeTruthy();
|
|
},
|
|
);
|
|
});
|
|
|
|
it('allowlists the active skill root that lives outside the repository skills dir', async () => {
|
|
if (!process.env.OD_DATA_DIR) {
|
|
throw new Error('OD_DATA_DIR is required for OpenCode skill permission tests');
|
|
}
|
|
|
|
// #8173: the skill preamble advertises the skill's absolute root as its
|
|
// fallback path, but the allowlist was built from the repository `skills/`
|
|
// tree alone. A skill installed under the daemon data dir — the same shape
|
|
// as the bundled plugin folder in the report — landed outside it, so
|
|
// OpenCode asked for `external_directory` approval on a file OD itself told
|
|
// the agent to open.
|
|
const skillId = `od-skill-root-${randomUUID().slice(0, 8)}`;
|
|
const userSkillDir = join(process.env.OD_DATA_DIR, 'skills', skillId);
|
|
mkdirSync(join(userSkillDir, 'references'), { recursive: true });
|
|
writeFileSync(
|
|
join(userSkillDir, 'SKILL.md'),
|
|
`---\nname: ${skillId}\ndescription: External directory allowlist fixture.\n---\n`
|
|
+ '# Fixture\n\nRead `references/layouts.md` before writing anything.\n',
|
|
);
|
|
writeFileSync(join(userSkillDir, 'references', 'layouts.md'), '# Layouts\n');
|
|
tempDirs.push(userSkillDir);
|
|
|
|
const projectId = `proj-${randomUUID()}`;
|
|
const markerDir = await fsp.mkdtemp(join(tmpdir(), 'od-opencode-skill-dir-'));
|
|
tempDirs.push(markerDir);
|
|
const envFile = join(markerDir, 'opencode-config-content.json');
|
|
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'OpenCode skill root permission fixture',
|
|
skillId,
|
|
}),
|
|
});
|
|
expect(createProjectResponse.ok).toBe(true);
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
const fs = require('node:fs');
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => {
|
|
fs.writeFileSync(${JSON.stringify(envFile)}, process.env.OPENCODE_CONFIG_CONTENT || '');
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: 'skill-permission-ok' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
skillId,
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
expect(response.ok).toBe(true);
|
|
expect(await response.text()).toContain('skill-permission-ok');
|
|
|
|
const parsed = JSON.parse(readFileSync(envFile, 'utf8')) as {
|
|
permission?: { external_directory?: Record<string, string> };
|
|
};
|
|
const externalDirectory = parsed.permission?.external_directory ?? {};
|
|
const roots = new Set([userSkillDir, realpathSync(userSkillDir)]);
|
|
const allowedRoot = [...roots].find(
|
|
(root) =>
|
|
externalDirectory[root] === 'allow' &&
|
|
externalDirectory[`${root}/**`] === 'allow',
|
|
);
|
|
|
|
expect(allowedRoot).toBeTruthy();
|
|
},
|
|
);
|
|
});
|
|
|
|
it('passes BYOK provider config to the daemon-backed OpenCode runtime', async () => {
|
|
if (!process.env.OD_DATA_DIR) {
|
|
throw new Error('OD_DATA_DIR is required for BYOK OpenCode config tests');
|
|
}
|
|
|
|
const projectId = `proj-${randomUUID()}`;
|
|
const markerDir = await fsp.mkdtemp(join(tmpdir(), 'od-byok-opencode-config-'));
|
|
tempDirs.push(markerDir);
|
|
const envFile = join(markerDir, 'opencode-config-content.json');
|
|
const keyFile = join(markerDir, 'byok-key.txt');
|
|
const argsFile = join(markerDir, 'args.json');
|
|
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: projectId, name: 'BYOK OpenCode fixture' }),
|
|
});
|
|
expect(createProjectResponse.ok).toBe(true);
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
const fs = require('node:fs');
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => {
|
|
fs.writeFileSync(${JSON.stringify(envFile)}, process.env.OPENCODE_CONFIG_CONTENT || '');
|
|
fs.writeFileSync(${JSON.stringify(keyFile)}, process.env.OPEN_DESIGN_BYOK_API_KEY || '');
|
|
fs.writeFileSync(${JSON.stringify(argsFile)}, JSON.stringify(process.argv.slice(2)));
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: 'byok-opencode-ok' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'byok-opencode',
|
|
projectId,
|
|
message: 'hello',
|
|
model: 'deepseek-v4-flash',
|
|
byokProvider: {
|
|
protocol: 'senseaudio',
|
|
apiKey: 'sk-test-byok',
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
model: 'deepseek-v4-flash',
|
|
requiresApiKey: true,
|
|
},
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('byok-opencode-ok');
|
|
|
|
expect(await fsp.readFile(keyFile, 'utf8')).toBe('sk-test-byok');
|
|
expect(JSON.parse(await fsp.readFile(argsFile, 'utf8'))).toEqual([
|
|
'run',
|
|
'--format',
|
|
'json',
|
|
'--dir',
|
|
expect.stringContaining(projectId),
|
|
'-m',
|
|
'open-design-byok/deepseek-v4-flash',
|
|
]);
|
|
const parsed = JSON.parse(await fsp.readFile(envFile, 'utf8')) as {
|
|
provider?: Record<string, {
|
|
npm?: string;
|
|
options?: Record<string, unknown>;
|
|
models?: Record<string, unknown>;
|
|
}>;
|
|
};
|
|
const provider = parsed.provider?.['open-design-byok'];
|
|
expect(provider).toMatchObject({
|
|
npm: '@ai-sdk/openai-compatible',
|
|
options: {
|
|
baseURL: 'https://api.senseaudio.cn',
|
|
apiKey: '{env:OPEN_DESIGN_BYOK_API_KEY}',
|
|
},
|
|
});
|
|
expect(provider?.models?.['deepseek-v4-flash']).toEqual({
|
|
name: 'deepseek-v4-flash',
|
|
modalities: { input: ['text', 'image'], output: ['text'] },
|
|
limit: {
|
|
context: 128_000,
|
|
output: 16_384,
|
|
},
|
|
});
|
|
expect(JSON.stringify(parsed)).not.toContain('sk-test-byok');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('passes keyless BYOK provider config without auth fields to OpenCode', async () => {
|
|
if (!process.env.OD_DATA_DIR) {
|
|
throw new Error('OD_DATA_DIR is required for BYOK OpenCode config tests');
|
|
}
|
|
|
|
const projectId = `proj-${randomUUID()}`;
|
|
const markerDir = await fsp.mkdtemp(join(tmpdir(), 'od-byok-opencode-keyless-'));
|
|
tempDirs.push(markerDir);
|
|
const envFile = join(markerDir, 'opencode-config-content.json');
|
|
const keyFile = join(markerDir, 'byok-key.txt');
|
|
const argsFile = join(markerDir, 'args.json');
|
|
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: projectId, name: 'BYOK keyless OpenCode fixture' }),
|
|
});
|
|
expect(createProjectResponse.ok).toBe(true);
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
const fs = require('node:fs');
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => {
|
|
fs.writeFileSync(${JSON.stringify(envFile)}, process.env.OPENCODE_CONFIG_CONTENT || '');
|
|
fs.writeFileSync(${JSON.stringify(keyFile)}, process.env.OPEN_DESIGN_BYOK_API_KEY || '');
|
|
fs.writeFileSync(${JSON.stringify(argsFile)}, JSON.stringify(process.argv.slice(2)));
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: 'byok-opencode-keyless-ok' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'byok-opencode',
|
|
projectId,
|
|
message: 'hello',
|
|
model: 'model',
|
|
byokProvider: {
|
|
protocol: 'openai',
|
|
apiKey: '',
|
|
baseUrl: 'http://127.0.0.1:8000/v1',
|
|
model: 'model',
|
|
requiresApiKey: false,
|
|
},
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('byok-opencode-keyless-ok');
|
|
|
|
expect(await fsp.readFile(keyFile, 'utf8')).toBe('');
|
|
expect(JSON.parse(await fsp.readFile(argsFile, 'utf8'))).toEqual([
|
|
'run',
|
|
'--format',
|
|
'json',
|
|
'--dir',
|
|
expect.stringContaining(projectId),
|
|
'-m',
|
|
'open-design-byok/model',
|
|
]);
|
|
const rawConfig = await fsp.readFile(envFile, 'utf8');
|
|
const parsed = JSON.parse(rawConfig) as {
|
|
provider?: Record<string, {
|
|
npm?: string;
|
|
options?: Record<string, unknown>;
|
|
models?: Record<string, unknown>;
|
|
}>;
|
|
};
|
|
const provider = parsed.provider?.['open-design-byok'];
|
|
expect(provider).toMatchObject({
|
|
npm: '@ai-sdk/openai-compatible',
|
|
options: {
|
|
baseURL: 'http://127.0.0.1:8000/v1',
|
|
},
|
|
});
|
|
expect(provider?.options).not.toHaveProperty('apiKey');
|
|
expect(rawConfig).not.toContain('OPEN_DESIGN_BYOK_API_KEY');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('does not pass BYOK provider config to other local runtimes', async () => {
|
|
if (!process.env.OD_DATA_DIR) {
|
|
throw new Error('OD_DATA_DIR is required for BYOK OpenCode config tests');
|
|
}
|
|
|
|
const projectId = `proj-${randomUUID()}`;
|
|
const markerDir = await fsp.mkdtemp(join(tmpdir(), 'od-byok-opencode-isolation-'));
|
|
tempDirs.push(markerDir);
|
|
const envFile = join(markerDir, 'opencode-config-content.json');
|
|
const keyFile = join(markerDir, 'byok-key.txt');
|
|
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: projectId, name: 'BYOK isolation fixture' }),
|
|
});
|
|
expect(createProjectResponse.ok).toBe(true);
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
const fs = require('node:fs');
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => {
|
|
fs.writeFileSync(${JSON.stringify(envFile)}, process.env.OPENCODE_CONFIG_CONTENT || '');
|
|
fs.writeFileSync(${JSON.stringify(keyFile)}, process.env.OPEN_DESIGN_BYOK_API_KEY || '');
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: 'opencode-ok' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
message: 'hello',
|
|
model: 'deepseek-v4-flash',
|
|
byokProvider: {
|
|
protocol: 'senseaudio',
|
|
apiKey: 'sk-test-byok',
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
},
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('opencode-ok');
|
|
expect(await fsp.readFile(keyFile, 'utf8')).toBe('');
|
|
const rawConfig = await fsp.readFile(envFile, 'utf8');
|
|
expect(rawConfig).not.toContain('open-design-byok');
|
|
expect(rawConfig).not.toContain('sk-test-byok');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('strips inherited OpenCode server auth env before spawning the opencode CLI', async () => {
|
|
const inheritedPassword = process.env.OPENCODE_SERVER_PASSWORD;
|
|
process.env.OPENCODE_SERVER_PASSWORD = 'test-parent-server-password';
|
|
|
|
const markerDir = await fsp.mkdtemp(join(tmpdir(), 'od-opencode-env-'));
|
|
tempDirs.push(markerDir);
|
|
const envFile = join(markerDir, 'opencode-server-password.txt');
|
|
|
|
try {
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
const fs = require('node:fs');
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => {
|
|
fs.writeFileSync(${JSON.stringify(envFile)}, process.env.OPENCODE_SERVER_PASSWORD || '');
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: 'opencode-env-ok' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('opencode-env-ok');
|
|
expect(await fsp.readFile(envFile, 'utf8')).toBe('');
|
|
},
|
|
);
|
|
} finally {
|
|
if (inheritedPassword == null) {
|
|
delete process.env.OPENCODE_SERVER_PASSWORD;
|
|
} else {
|
|
process.env.OPENCODE_SERVER_PASSWORD = inheritedPassword;
|
|
}
|
|
}
|
|
});
|
|
|
|
|
|
it('reuses an existing assistant message row instead of creating a duplicate when assistantMessageId is supplied', async () => {
|
|
if (!process.env.OD_DATA_DIR) {
|
|
throw new Error('OD_DATA_DIR is required for assistant message reuse tests');
|
|
}
|
|
const projectId = `proj-${randomUUID()}`;
|
|
const assistantMessageId = `assistant-${randomUUID()}`;
|
|
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: projectId, name: 'Assistant row reuse fixture' }),
|
|
});
|
|
expect(createProjectResponse.ok).toBe(true);
|
|
|
|
const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
|
|
expect(conversationsResponse.ok).toBe(true);
|
|
const conversationsBody = await conversationsResponse.json() as {
|
|
conversations: Array<{ id: string }>;
|
|
};
|
|
const conversationId = conversationsBody.conversations[0]?.id;
|
|
expect(conversationId).toBeTruthy();
|
|
|
|
const dbFile = resolve(process.env.OD_DATA_DIR, 'app.sqlite');
|
|
const sqlite = new Database(dbFile);
|
|
try {
|
|
upsertMessage(sqlite as never, conversationId!, {
|
|
id: assistantMessageId,
|
|
role: 'assistant',
|
|
content: '',
|
|
runStatus: 'failed',
|
|
startedAt: Date.now() - 1_000,
|
|
endedAt: Date.now() - 500,
|
|
});
|
|
} finally {
|
|
sqlite.close();
|
|
}
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => {
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: 'reused-assistant-row-ok' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
conversationId,
|
|
assistantMessageId,
|
|
message: 'retry this turn',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('reused-assistant-row-ok');
|
|
},
|
|
);
|
|
|
|
const verifyDb = new Database(dbFile, { readonly: true });
|
|
try {
|
|
const rows = verifyDb
|
|
.prepare(`SELECT id, content, run_id FROM messages WHERE conversation_id = ? AND role = 'assistant'`)
|
|
.all(conversationId) as Array<{ id: string; content: string; run_id: string | null }>;
|
|
expect(rows.filter((row) => row.id === assistantMessageId)).toHaveLength(1);
|
|
expect(rows.some((row) => row.id !== assistantMessageId && row.content.includes('reused-assistant-row-ok'))).toBe(false);
|
|
const reused = rows.find((row) => row.id === assistantMessageId);
|
|
expect(reused?.content).toContain('reused-assistant-row-ok');
|
|
} finally {
|
|
verifyDb.close();
|
|
}
|
|
});
|
|
|
|
it('does not leave a pinned assistant message queued when legacy chat fails before spawning', async () => {
|
|
if (!process.env.OD_DATA_DIR) {
|
|
throw new Error('OD_DATA_DIR is required for assistant message pin tests');
|
|
}
|
|
const projectId = `proj-${randomUUID()}`;
|
|
const assistantMessageId = `assistant-failed-${randomUUID()}`;
|
|
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: projectId, name: 'Failed assistant pin fixture' }),
|
|
});
|
|
expect(createProjectResponse.ok).toBe(true);
|
|
|
|
const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
|
|
expect(conversationsResponse.ok).toBe(true);
|
|
const conversationsBody = await conversationsResponse.json() as {
|
|
conversations: Array<{ id: string }>;
|
|
};
|
|
const conversationId = conversationsBody.conversations[0]?.id;
|
|
expect(conversationId).toBeTruthy();
|
|
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: `missing-agent-${randomUUID()}`,
|
|
projectId,
|
|
conversationId,
|
|
assistantMessageId,
|
|
message: 'fail before spawn',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('unknown agent');
|
|
|
|
const dbFile = resolve(process.env.OD_DATA_DIR, 'app.sqlite');
|
|
let lastStatus: string | null = null;
|
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
const sqlite = new Database(dbFile, { readonly: true });
|
|
try {
|
|
const row = sqlite
|
|
.prepare(`SELECT run_status FROM messages WHERE id = ?`)
|
|
.get(assistantMessageId) as { run_status: string | null } | undefined;
|
|
lastStatus = row?.run_status ?? null;
|
|
if (lastStatus && lastStatus !== 'queued' && lastStatus !== 'running') break;
|
|
} finally {
|
|
sqlite.close();
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
|
|
expect(lastStatus).toBe('failed');
|
|
});
|
|
|
|
it('rewrites the OpenCode scanner overflow into a generic retry message', async () => {
|
|
const conversationId = `conv-${randomUUID()}`;
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
process.stderr.write('json-rpc id 4: opencode event stream: read opencode SSE: bufio.Scanner: token too long\\n');
|
|
process.exit(1);
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
conversationId,
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('AGENT_EXECUTION_FAILED');
|
|
expect(body).toContain('The run failed due to an unknown upstream streaming error. Please retry.');
|
|
expect(body).toContain('event: stderr');
|
|
expect(body).toContain('"status":"failed"');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('rejects a requested media-only model before AMR ACP launch', async () => {
|
|
const { body, invocations, responseOk } = await runAmrModelRequest('nano-banana-2');
|
|
|
|
expect(responseOk).toBe(true);
|
|
expect(invocations).toEqual([]);
|
|
expect(body).toContain('AMR_MODEL_UNAVAILABLE');
|
|
expect(body).toContain('model_not_chat_capable');
|
|
expect(body).toContain('"status":"failed"');
|
|
expect(body).not.toContain('Hello from fake vela.');
|
|
});
|
|
|
|
it('forwards an unknown custom-provider chat slug when the AMR catalog is stale', async () => {
|
|
const { body, invocations, responseOk } = await runAmrModelRequest(
|
|
'custom-provider/future-chat-2027',
|
|
);
|
|
|
|
expect(responseOk).toBe(true);
|
|
expect(invocations).toEqual([
|
|
'new',
|
|
'set_model:custom-provider/future-chat-2027',
|
|
'prompt',
|
|
]);
|
|
expect(body).not.toContain('AMR_MODEL_UNAVAILABLE');
|
|
expect(body).toContain('"type":"text_delta","delta":"Hello from fake "');
|
|
expect(body).toContain('"type":"text_delta","delta":"vela."');
|
|
expect(body).toContain('"status":"succeeded"');
|
|
});
|
|
|
|
// AMR 0.24.1, 10/5-10/7: 9 runs from 7 users failed with "session/set_model
|
|
// modelId is not available" because the saved model had left the AMR catalog.
|
|
it('switches to the AMR catalog default when vela rejects a delisted model', async () => {
|
|
const { body, invocations, responseOk } = await runAmrModelRequest('claude-opus-4.8', {
|
|
FAKE_VELA_SET_MODEL_UNAVAILABLE: 'claude-opus-4.8',
|
|
});
|
|
|
|
expect(responseOk).toBe(true);
|
|
expect(invocations[0]).toBe('new');
|
|
expect(invocations[1]).toBe('set_model_rejected:claude-opus-4.8');
|
|
expect(invocations[2]).toMatch(/^set_model:(deepseek-v4-flash|deepseek-v3\.2)$/);
|
|
expect(invocations[3]).toBe('prompt');
|
|
expect(body).toContain('"label":"model_fallback"');
|
|
expect(body).toContain('claude-opus-4.8 is no longer available in AMR.');
|
|
expect(body).not.toContain('AMR_MODEL_UNAVAILABLE');
|
|
expect(body).toContain('"status":"succeeded"');
|
|
});
|
|
|
|
it('survives transient AMR Link catalog failures without aborting the run', async () => {
|
|
// The run preflight resolves the AMR catalog through the shared
|
|
// AmrModelLoadingCache, which degrades to the offline `vela model preset`
|
|
// seed whenever the authoritative `vela model list` is momentarily
|
|
// unavailable (and refreshes the remote catalog in the background). So a
|
|
// transient catalog failure must NOT abort the run — the per-run path no
|
|
// longer blocks on a synchronous `model list` retry loop.
|
|
const previousRuntimeKey = process.env.VELA_RUNTIME_KEY;
|
|
const previousLinkUrl = process.env.VELA_LINK_URL;
|
|
const stateFile = join(tmpdir(), `od-amr-model-retry-${randomUUID()}.json`);
|
|
try {
|
|
// Unique key so the shared model cache key is unique per test run.
|
|
process.env.VELA_RUNTIME_KEY = `fake-runtime-key-${randomUUID()}`;
|
|
process.env.VELA_LINK_URL = 'https://amr-link.open-design.ai/v1';
|
|
const workspaceFixture =
|
|
await createPersonalWorkspaceBoundProjectFixture('Transient AMR catalog fixture');
|
|
|
|
await withFakeAgent(
|
|
'vela',
|
|
`
|
|
const { existsSync, readFileSync, writeFileSync } = require('node:fs');
|
|
const { spawn } = require('node:child_process');
|
|
const fixture = ${JSON.stringify(FAKE_VELA_FIXTURE)};
|
|
const stateFile = ${JSON.stringify(stateFile)};
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === 'model' && args[1] === 'list') {
|
|
const state = existsSync(stateFile)
|
|
? JSON.parse(readFileSync(stateFile, 'utf8'))
|
|
: { attempts: 0 };
|
|
state.attempts += 1;
|
|
writeFileSync(stateFile, JSON.stringify(state), 'utf8');
|
|
if (state.attempts < 3) {
|
|
process.stderr.write('Get "https://amr-link.open-design.ai/v1/models": context deadline exceeded\\n');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
const child = spawn(process.execPath, [fixture, ...args], {
|
|
stdio: 'inherit',
|
|
env: process.env,
|
|
});
|
|
child.on('exit', (code, signal) => {
|
|
if (signal) process.kill(process.pid, signal);
|
|
process.exit(code ?? 0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...workspaceFixture.headers,
|
|
},
|
|
body: JSON.stringify({
|
|
agentId: 'amr',
|
|
projectId: workspaceFixture.projectId,
|
|
message: 'hello',
|
|
model: 'deepseek-v3.2',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('"type":"text_delta","delta":"Hello from fake "');
|
|
expect(body).toContain('"type":"text_delta","delta":"vela."');
|
|
expect(body).not.toContain('model_catalog_unavailable');
|
|
expect(body).not.toContain('AMR_MODEL_UNAVAILABLE');
|
|
expect(body).not.toContain('AMR_WORKSPACE_SCOPE_REQUIRED');
|
|
// The catalog probe runs at least once (remote attempted, then the
|
|
// run proceeds from the preset seed). We no longer assert an exact
|
|
// synchronous retry count: the remote retry/backoff now happens in
|
|
// the cache's background refresh, not on the per-run hot path.
|
|
const attempts = JSON.parse(readFileSync(stateFile, 'utf8')) as { attempts: number };
|
|
expect(attempts.attempts).toBeGreaterThanOrEqual(1);
|
|
},
|
|
);
|
|
} finally {
|
|
rmSync(stateFile, { force: true });
|
|
if (previousRuntimeKey == null) delete process.env.VELA_RUNTIME_KEY;
|
|
else process.env.VELA_RUNTIME_KEY = previousRuntimeKey;
|
|
if (previousLinkUrl == null) delete process.env.VELA_LINK_URL;
|
|
else process.env.VELA_LINK_URL = previousLinkUrl;
|
|
}
|
|
});
|
|
|
|
it('proceeds with the AMR run via the cached/preset catalog when the live model list is unavailable', async () => {
|
|
// Red spec for the packaged-prerelease "AMR model the selected model is not
|
|
// available from Vela" report: the run preflight used to do a fresh,
|
|
// blocking `vela model list` (authoritative remote catalog) on EVERY run
|
|
// and fail-close the run whenever that single call timed out / errored —
|
|
// even though the user is logged in, the model picker already shows a
|
|
// model (seeded from the offline `vela model preset`), and the selected
|
|
// model is real. Under CorpLink/飞连 the remote call routinely exceeds the
|
|
// 10s timeout, so a logged-in user with a valid model could not run AMR at
|
|
// all. The fix reuses the shared AmrModelLoadingCache (cached remote when
|
|
// hot, otherwise the offline preset seed) instead of a per-run blocking
|
|
// remote probe, so a transient `model list` failure no longer kills the run.
|
|
const previousRuntimeKey = process.env.VELA_RUNTIME_KEY;
|
|
const previousLinkUrl = process.env.VELA_LINK_URL;
|
|
try {
|
|
// A unique runtime key both marks the user as logged-in AND makes the
|
|
// shared model cache key unique so this case never reuses another test's
|
|
// cached remote catalog.
|
|
process.env.VELA_RUNTIME_KEY = `fake-runtime-key-${randomUUID()}`;
|
|
process.env.VELA_LINK_URL = 'https://amr-link.open-design.ai/v1';
|
|
const workspaceFixture =
|
|
await createPersonalWorkspaceBoundProjectFixture('Cached AMR catalog fixture');
|
|
|
|
await withFakeAgent(
|
|
'vela',
|
|
`
|
|
const { spawn } = require('node:child_process');
|
|
const fixture = ${JSON.stringify(FAKE_VELA_FIXTURE)};
|
|
const args = process.argv.slice(2);
|
|
// Simulate a persistently unreachable authoritative catalog (gateway
|
|
// timeout / 飞连 congestion): every \`vela model list\` fails. \`model preset\`,
|
|
// \`login\`, and \`agent run\` still delegate to the fixture, mirroring the real
|
|
// CLI where the offline preset and the ACP run do not need the gateway.
|
|
if (args[0] === 'model' && args[1] === 'list') {
|
|
process.stderr.write('Get "https://amr-link.open-design.ai/v1/models": context deadline exceeded\\n');
|
|
process.exit(1);
|
|
}
|
|
const child = spawn(process.execPath, [fixture, ...args], {
|
|
stdio: 'inherit',
|
|
env: process.env,
|
|
});
|
|
child.on('exit', (code, signal) => {
|
|
if (signal) process.kill(process.pid, signal);
|
|
process.exit(code ?? 0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...workspaceFixture.headers,
|
|
},
|
|
body: JSON.stringify({
|
|
agentId: 'amr',
|
|
projectId: workspaceFixture.projectId,
|
|
message: 'hello',
|
|
// Present in the preset seed (DEFAULT_MODEL_PRESET_JSON) but the
|
|
// live `model list` is unavailable, so only the preset path can
|
|
// surface it.
|
|
model: 'glm-5.1',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
// The run must NOT be fail-closed on the unavailable live catalog.
|
|
expect(body).not.toContain('AMR_MODEL_UNAVAILABLE');
|
|
expect(body).not.toContain('model_catalog_unavailable');
|
|
expect(body).not.toContain('is not available from Vela');
|
|
expect(body).not.toContain('AMR_WORKSPACE_SCOPE_REQUIRED');
|
|
// It must actually proceed into the ACP run and stream assistant text.
|
|
expect(body).toContain('"type":"text_delta","delta":"Hello from fake "');
|
|
expect(body).toContain('"type":"text_delta","delta":"vela."');
|
|
},
|
|
);
|
|
} finally {
|
|
if (previousRuntimeKey == null) delete process.env.VELA_RUNTIME_KEY;
|
|
else process.env.VELA_RUNTIME_KEY = previousRuntimeKey;
|
|
if (previousLinkUrl == null) delete process.env.VELA_LINK_URL;
|
|
else process.env.VELA_LINK_URL = previousLinkUrl;
|
|
}
|
|
});
|
|
|
|
it('persists exact AMR prompt budget context across new and resumed sessions', async () => {
|
|
const previousRuntimeKey = process.env.VELA_RUNTIME_KEY;
|
|
const previousLinkUrl = process.env.VELA_LINK_URL;
|
|
const previousPreset = process.env.FAKE_VELA_MODEL_PRESET_JSON;
|
|
const previousList = process.env.FAKE_VELA_MODEL_LIST_JSON;
|
|
const model = 'claude-observability-5';
|
|
try {
|
|
process.env.VELA_RUNTIME_KEY = `fake-runtime-key-${randomUUID()}`;
|
|
process.env.VELA_LINK_URL = 'https://amr-link.open-design.ai/v1';
|
|
process.env.FAKE_VELA_MODEL_PRESET_JSON = JSON.stringify({
|
|
source: 'preset',
|
|
data: [{
|
|
id: model,
|
|
enabled: true,
|
|
default: true,
|
|
metadata: { contextWindowTokens: 200_000 },
|
|
}],
|
|
});
|
|
process.env.FAKE_VELA_MODEL_LIST_JSON = JSON.stringify({
|
|
source: 'remote',
|
|
data: [{
|
|
id: model,
|
|
enabled: true,
|
|
default: true,
|
|
metadata: { contextWindowTokens: 200_000 },
|
|
}],
|
|
});
|
|
const workspaceFixture =
|
|
await createPersonalWorkspaceBoundProjectFixture('AMR prompt budget fixture');
|
|
const conversationsResponse = await fetch(
|
|
`${baseUrl}/api/projects/${workspaceFixture.projectId}/conversations`,
|
|
);
|
|
const conversationsBody = await conversationsResponse.json() as {
|
|
conversations: Array<{ id: string }>;
|
|
};
|
|
const conversationId = conversationsBody.conversations[0]?.id;
|
|
expect(conversationId).toBeTruthy();
|
|
|
|
await withFakeAgent(
|
|
'vela',
|
|
`
|
|
const { spawn } = require('node:child_process');
|
|
const fixture = ${JSON.stringify(FAKE_VELA_FIXTURE)};
|
|
const child = spawn(process.execPath, [fixture, ...process.argv.slice(2)], {
|
|
stdio: 'inherit',
|
|
env: process.env,
|
|
});
|
|
child.on('exit', (code, signal) => {
|
|
if (signal) process.kill(process.pid, signal);
|
|
process.exit(code ?? 0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const runTurn = async (message: string) => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...workspaceFixture.headers,
|
|
},
|
|
body: JSON.stringify({
|
|
agentId: 'amr',
|
|
projectId: workspaceFixture.projectId,
|
|
conversationId,
|
|
model,
|
|
message,
|
|
currentPrompt: message,
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`);
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: final');
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
expect(statusBody.status).toBe('succeeded');
|
|
return eventsBody;
|
|
};
|
|
|
|
const first = await runTurn('first exact-frame turn');
|
|
expect(first).toContain('"name":"prompt_budget_v1"');
|
|
expect(first).toContain('"sessionMode":"new"');
|
|
expect(first).toContain('"contextWindowSource":"model_metadata"');
|
|
expect(first).toContain('"contextWindowTokens":200000');
|
|
expect(first).toContain('"priorSessionUsageSource":"unknown"');
|
|
|
|
const second = await runTurn('second resumed turn');
|
|
expect(second).toContain('"name":"prompt_budget_v1"');
|
|
expect(second).toContain('"sessionMode":"resume"');
|
|
expect(second).toContain('"contextWindowSource":"model_metadata"');
|
|
expect(second).toContain('"priorSessionUsageSource":"agent_session"');
|
|
expect(second).toContain('"priorSessionInputTokens":12');
|
|
},
|
|
);
|
|
} finally {
|
|
if (previousRuntimeKey == null) delete process.env.VELA_RUNTIME_KEY;
|
|
else process.env.VELA_RUNTIME_KEY = previousRuntimeKey;
|
|
if (previousLinkUrl == null) delete process.env.VELA_LINK_URL;
|
|
else process.env.VELA_LINK_URL = previousLinkUrl;
|
|
if (previousPreset == null) delete process.env.FAKE_VELA_MODEL_PRESET_JSON;
|
|
else process.env.FAKE_VELA_MODEL_PRESET_JSON = previousPreset;
|
|
if (previousList == null) delete process.env.FAKE_VELA_MODEL_LIST_JSON;
|
|
else process.env.FAKE_VELA_MODEL_LIST_JSON = previousList;
|
|
}
|
|
});
|
|
|
|
it('resolves an AMR default model from the caller catalog, not the shared preset seed', async () => {
|
|
// A free-tier caller: the bundled preset lists deepseek-v4-flash first, but
|
|
// the per-user catalog only allows deepseek-v4.1-flash. A run started before
|
|
// that catalog arrives must not turn `default` into a model vela rejects.
|
|
const saved = Object.fromEntries([
|
|
'VELA_RUNTIME_KEY', 'VELA_LINK_URL', 'FAKE_VELA_MODEL_PRESET_JSON', 'FAKE_VELA_MODEL_LIST_JSON',
|
|
'FAKE_VELA_MODEL_LIST_DELAY_MS', 'FAKE_VELA_ALLOWED_MODELS', 'FAKE_VELA_LOG_SET_MODEL_REQUEST', 'FAKE_VELA_INVOCATION_LOG',
|
|
].map((key) => [key, process.env[key]]));
|
|
const invocationLog = join(mkdtempSync(join(tmpdir(), 'od-amr-default-model-')), 'vela.log');
|
|
try {
|
|
amrModelLoadingCache.resetForTests();
|
|
resetLiveModelsForTests();
|
|
process.env.VELA_RUNTIME_KEY = `fake-runtime-key-${randomUUID()}`;
|
|
process.env.VELA_LINK_URL = 'https://amr-link.open-design.ai/v1';
|
|
process.env.FAKE_VELA_MODEL_PRESET_JSON = JSON.stringify({
|
|
source: 'preset',
|
|
data: [{ id: 'deepseek-v4-flash' }, { id: 'deepseek-v4-pro' }],
|
|
});
|
|
process.env.FAKE_VELA_MODEL_LIST_JSON = JSON.stringify({
|
|
source: 'remote',
|
|
data: [
|
|
{ id: 'deepseek-v4.1-flash', enabled: true, default: true },
|
|
{ id: 'deepseek-v4-flash', enabled: false },
|
|
{ id: 'deepseek-v4-pro', enabled: false },
|
|
],
|
|
});
|
|
process.env.FAKE_VELA_MODEL_LIST_DELAY_MS = '1500';
|
|
process.env.FAKE_VELA_ALLOWED_MODELS = 'deepseek-v4.1-flash';
|
|
process.env.FAKE_VELA_LOG_SET_MODEL_REQUEST = '1';
|
|
process.env.FAKE_VELA_INVOCATION_LOG = invocationLog;
|
|
const workspaceFixture =
|
|
await createPersonalWorkspaceBoundProjectFixture('AMR default model fixture');
|
|
|
|
await withFakeAgent(
|
|
'vela',
|
|
`
|
|
const { spawn } = require('node:child_process');
|
|
const fixture = ${JSON.stringify(FAKE_VELA_FIXTURE)};
|
|
const child = spawn(process.execPath, [fixture, ...process.argv.slice(2)], {
|
|
stdio: 'inherit',
|
|
env: process.env,
|
|
});
|
|
child.on('exit', (code, signal) => {
|
|
if (signal) process.kill(process.pid, signal);
|
|
process.exit(code ?? 0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', ...workspaceFixture.headers },
|
|
body: JSON.stringify({
|
|
agentId: 'amr',
|
|
projectId: workspaceFixture.projectId,
|
|
model: 'default',
|
|
message: 'hello',
|
|
currentPrompt: 'hello',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
const requested = readFileSync(invocationLog, 'utf8')
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map((line) => (JSON.parse(line) as { method: string }).method)
|
|
.filter((method) => method.startsWith('set_model_request:'));
|
|
expect(requested).toEqual(['set_model_request:deepseek-v4.1-flash']);
|
|
expect(statusBody.status).toBe('succeeded');
|
|
},
|
|
);
|
|
} finally {
|
|
for (const [key, value] of Object.entries(saved)) {
|
|
if (value == null) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
amrModelLoadingCache.resetForTests();
|
|
resetLiveModelsForTests();
|
|
}
|
|
});
|
|
|
|
it('keeps service tier overrides when /api/runs omits model but settings has one', async () => {
|
|
if (!process.env.OD_DATA_DIR) {
|
|
throw new Error('OD_DATA_DIR is required for service tier settings tests');
|
|
}
|
|
const argsPath = join(tmpdir(), `od-codex-args-${randomUUID()}.json`);
|
|
const previousConfig = await readAppConfig(process.env.OD_DATA_DIR);
|
|
try {
|
|
await writeAppConfig(process.env.OD_DATA_DIR, {
|
|
agentModels: { codex: { model: 'gpt-5.5' } },
|
|
});
|
|
await withCodexExecJson(() => withFakeAgent(
|
|
'codex',
|
|
`
|
|
const fs = require('node:fs');
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === 'debug' && args[1] === 'models') {
|
|
console.log(JSON.stringify([{ id: 'gpt-5.5', name: 'gpt-5.5', service_tiers: [{ id: 'priority', label: 'Fast' }] }]));
|
|
process.exit(0);
|
|
}
|
|
if (args[0] === 'login' && args[1] === 'status') {
|
|
console.log('Logged in');
|
|
process.exit(0);
|
|
}
|
|
fs.writeFileSync(${JSON.stringify(argsPath)}, JSON.stringify(args));
|
|
console.log(JSON.stringify({ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'done' }] }));
|
|
process.exit(0);
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'codex',
|
|
message: 'hello',
|
|
serviceTier: 'priority',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
await waitForRunStatus(baseUrl, runId);
|
|
|
|
const args = JSON.parse(readFileSync(argsPath, 'utf8')) as string[];
|
|
expect(args).toContain('--model');
|
|
expect(args).toContain('gpt-5.5');
|
|
expect(args).toContain('service_tier="priority"');
|
|
},
|
|
));
|
|
} finally {
|
|
rmSync(argsPath, { force: true });
|
|
await writeAppConfig(process.env.OD_DATA_DIR, {
|
|
agentModels: previousConfig.agentModels ?? null,
|
|
});
|
|
}
|
|
});
|
|
|
|
it('keeps service tier overrides when /api/runs omits model and settings has none', async () => {
|
|
if (!process.env.OD_DATA_DIR) {
|
|
throw new Error('OD_DATA_DIR is required for service tier settings tests');
|
|
}
|
|
const argsPath = join(tmpdir(), `od-codex-args-${randomUUID()}.json`);
|
|
const previousConfig = await readAppConfig(process.env.OD_DATA_DIR);
|
|
try {
|
|
await writeAppConfig(process.env.OD_DATA_DIR, { agentModels: null });
|
|
await withCodexExecJson(() => withFakeAgent(
|
|
'codex',
|
|
`
|
|
const fs = require('node:fs');
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === 'debug' && args[1] === 'models') {
|
|
console.log(JSON.stringify([{ id: 'gpt-5.5', name: 'gpt-5.5', service_tiers: [{ id: 'priority', label: 'Fast' }] }]));
|
|
process.exit(0);
|
|
}
|
|
if (args[0] === 'login' && args[1] === 'status') {
|
|
console.log('Logged in');
|
|
process.exit(0);
|
|
}
|
|
fs.writeFileSync(${JSON.stringify(argsPath)}, JSON.stringify(args));
|
|
console.log(JSON.stringify({ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'done' }] }));
|
|
process.exit(0);
|
|
`,
|
|
async () => {
|
|
await fetch(`${baseUrl}/api/agents`);
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'codex',
|
|
message: 'hello',
|
|
serviceTier: 'priority',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
await waitForRunStatus(baseUrl, runId);
|
|
|
|
const args = JSON.parse(readFileSync(argsPath, 'utf8')) as string[];
|
|
expect(args).toContain('--model');
|
|
expect(args).toContain('gpt-5.5');
|
|
expect(args).toContain('service_tier="priority"');
|
|
},
|
|
));
|
|
} finally {
|
|
rmSync(argsPath, { force: true });
|
|
await writeAppConfig(process.env.OD_DATA_DIR, {
|
|
agentModels: previousConfig.agentModels ?? null,
|
|
});
|
|
}
|
|
});
|
|
|
|
it('allows plugin authoring to succeed when the requested generated-plugin artifacts exist before close', async () => {
|
|
const projectId = `proj-plugin-authoring-success-${randomUUID()}`;
|
|
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Plugin authoring artifact success fixture',
|
|
skillId: null,
|
|
designSystemId: null,
|
|
}),
|
|
});
|
|
expect(createProjectResponse.status).toBe(200);
|
|
const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
|
|
expect(conversationsResponse.status).toBe(200);
|
|
const conversationsBody = await conversationsResponse.json() as {
|
|
conversations: Array<{ id: string }>;
|
|
};
|
|
const conversationId = conversationsBody.conversations[0]?.id;
|
|
expect(conversationId).toBeTruthy();
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => {
|
|
const pluginDir = path.join(process.cwd(), 'generated-plugin');
|
|
fs.mkdirSync(pluginDir, { recursive: true });
|
|
fs.writeFileSync(path.join(pluginDir, 'open-design.json'), JSON.stringify({ name: 'generated-plugin' }, null, 2));
|
|
fs.writeFileSync(path.join(pluginDir, 'SKILL.md'), '# Generated plugin\\n');
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: '我来帮你创建一个通用的 OpenDesign 插件脚手架。先读取文档规范,再生成插件文件。' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
conversationId,
|
|
pluginId: 'od-plugin-authoring',
|
|
message: '请创建一个可刷新、可审计、由 API 驱动的 OpenDesign 插件脚手架。',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`);
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: final');
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('先读取文档规范,再生成插件文件');
|
|
expect(statusBody.status).toBe('succeeded');
|
|
|
|
const filesResponse = await fetch(`${baseUrl}/api/projects/${projectId}/files`);
|
|
expect(filesResponse.status).toBe(200);
|
|
const filesBody = await filesResponse.json() as { files: Array<{ name: string }> };
|
|
expect(filesBody.files.some((file) => file.name === 'generated-plugin/open-design.json')).toBe(true);
|
|
expect(filesBody.files.some((file) => file.name === 'generated-plugin/SKILL.md')).toBe(true);
|
|
},
|
|
);
|
|
});
|
|
|
|
it('does not report plugin authoring as succeeded when the agent only emits planning text without artifacts', async () => {
|
|
const projectId = `proj-plugin-authoring-${randomUUID()}`;
|
|
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Plugin authoring completion fixture',
|
|
skillId: null,
|
|
designSystemId: null,
|
|
}),
|
|
});
|
|
expect(createProjectResponse.status).toBe(200);
|
|
const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
|
|
expect(conversationsResponse.status).toBe(200);
|
|
const conversationsBody = await conversationsResponse.json() as {
|
|
conversations: Array<{ id: string }>;
|
|
};
|
|
const conversationId = conversationsBody.conversations[0]?.id;
|
|
expect(conversationId).toBeTruthy();
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => {
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: '我来帮你创建一个通用的 OpenDesign 插件脚手架。先读取文档规范,再生成插件文件。' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
conversationId,
|
|
pluginId: 'od-plugin-authoring',
|
|
message: '请创建一个可刷新、可审计、由 API 驱动的 OpenDesign 插件脚手架。',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const {
|
|
runId,
|
|
pluginId,
|
|
appliedPluginSnapshotId,
|
|
} = await createResponse.json() as {
|
|
runId: string;
|
|
pluginId: string | null;
|
|
appliedPluginSnapshotId: string | null;
|
|
};
|
|
expect(pluginId).toBe('od-plugin-authoring');
|
|
expect(appliedPluginSnapshotId).toBeTruthy();
|
|
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`);
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: final');
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('先读取文档规范,再生成插件文件');
|
|
expect(statusBody.status).not.toBe('succeeded');
|
|
|
|
const filesResponse = await fetch(`${baseUrl}/api/projects/${projectId}/files`);
|
|
expect(filesResponse.status).toBe(200);
|
|
const filesBody = await filesResponse.json() as { files: Array<{ name: string }> };
|
|
expect(filesBody.files.some((file) => file.name.startsWith('generated-plugin/'))).toBe(false);
|
|
},
|
|
);
|
|
});
|
|
it('does not fail plugin authoring when the turn-1 reply is a clarifying question-form awaiting the brief', async () => {
|
|
// The `od-plugin-authoring` plugin's turn-1 flow is to emit a
|
|
// `<question-form>` collecting the plugin brief, then STOP and wait for
|
|
// the user to answer — artifacts only land on the follow-up turn. The
|
|
// missing-artifacts guard must not treat that expected pause as a
|
|
// failure (regression: "Plugin authoring ended before generating the
|
|
// required generated-plugin artifacts.").
|
|
const projectId = `proj-plugin-authoring-question-${randomUUID()}`;
|
|
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Plugin authoring question-form fixture',
|
|
skillId: null,
|
|
designSystemId: null,
|
|
}),
|
|
});
|
|
expect(createProjectResponse.status).toBe(200);
|
|
const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
|
|
expect(conversationsResponse.status).toBe(200);
|
|
const conversationsBody = await conversationsResponse.json() as {
|
|
conversations: Array<{ id: string }>;
|
|
};
|
|
const conversationId = conversationsBody.conversations[0]?.id;
|
|
expect(conversationId).toBeTruthy();
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => {
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: '先确认几个问题再开始搭建。\\n<question-form id="discovery" title="Plugin brief">\\n{"questions":[{"id":"purpose","label":"What should it do?","type":"text"}]}\\n</question-form>' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
conversationId,
|
|
pluginId: 'od-plugin-authoring',
|
|
message: '帮我做个插件。',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`);
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: final');
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('<question-form');
|
|
expect(eventsBody).not.toContain('ended before generating the required generated-plugin artifacts');
|
|
expect(statusBody.status).toBe('succeeded');
|
|
},
|
|
);
|
|
});
|
|
it('does not fail plugin authoring when the clarifying form uses the <ask-question> alias', async () => {
|
|
// `<ask-question>` is the alias the web form parser accepts alongside
|
|
// the canonical `<question-form>` (apps/web/src/artifacts/question-form.ts).
|
|
// Models sometimes drift to it; the UI still renders a valid brief form,
|
|
// so the daemon's missing-artifacts guard must recognize the alias too —
|
|
// otherwise the same "ended before generating…" regression returns for a
|
|
// supported clarification shape.
|
|
const projectId = `proj-plugin-authoring-alias-${randomUUID()}`;
|
|
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Plugin authoring ask-question alias fixture',
|
|
skillId: null,
|
|
designSystemId: null,
|
|
}),
|
|
});
|
|
expect(createProjectResponse.status).toBe(200);
|
|
const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
|
|
expect(conversationsResponse.status).toBe(200);
|
|
const conversationsBody = await conversationsResponse.json() as {
|
|
conversations: Array<{ id: string }>;
|
|
};
|
|
const conversationId = conversationsBody.conversations[0]?.id;
|
|
expect(conversationId).toBeTruthy();
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => {
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: '先确认几个问题再开始搭建。\\n<ask-question id="discovery" title="Plugin brief">\\n{"questions":[{"id":"purpose","label":"What should it do?","type":"text"}]}\\n</ask-question>' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
conversationId,
|
|
pluginId: 'od-plugin-authoring',
|
|
message: '帮我做个插件。',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`);
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: final');
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('<ask-question');
|
|
expect(eventsBody).not.toContain('ended before generating the required generated-plugin artifacts');
|
|
expect(statusBody.status).toBe('succeeded');
|
|
},
|
|
);
|
|
});
|
|
it('still fails plugin authoring when a question-form tag wraps a non-renderable (non-JSON) body', async () => {
|
|
// The clarification carve-out must match the web parser's renderable-form
|
|
// contract (JSON body with a `questions` array), not just the opening
|
|
// tag. A `<question-form>` whose body is not valid form JSON renders as
|
|
// raw prose in the UI — no usable brief card — so suppressing the
|
|
// missing-artifacts failure for it would turn a hard failure into a false
|
|
// success. This pins that the guard stays gated on a renderable body.
|
|
const projectId = `proj-plugin-authoring-badform-${randomUUID()}`;
|
|
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Plugin authoring malformed-form fixture',
|
|
skillId: null,
|
|
designSystemId: null,
|
|
}),
|
|
});
|
|
expect(createProjectResponse.status).toBe(200);
|
|
const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
|
|
expect(conversationsResponse.status).toBe(200);
|
|
const conversationsBody = await conversationsResponse.json() as {
|
|
conversations: Array<{ id: string }>;
|
|
};
|
|
const conversationId = conversationsBody.conversations[0]?.id;
|
|
expect(conversationId).toBeTruthy();
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => {
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: '先确认几个问题。\\n<question-form id="discovery">\\nWhat should it do? (free text)\\n</question-form>' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
conversationId,
|
|
pluginId: 'od-plugin-authoring',
|
|
message: '帮我做个插件。',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`);
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: final');
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('ended before generating the required generated-plugin artifacts');
|
|
expect(statusBody.status).not.toBe('succeeded');
|
|
},
|
|
);
|
|
});
|
|
it('does not fail plugin authoring when a valid form follows a Unicode preamble that expands under toLowerCase', async () => {
|
|
// The mirrored close-tag scan must stay in the original-string coordinate
|
|
// space. Some code points expand under toLowerCase ("İ" -> "i̇"), so
|
|
// lowercasing the whole buffer before indexing would desync the close-tag
|
|
// offset and corrupt the JSON body slice, failing a valid form. The
|
|
// preamble here contains "İ" before a well-formed form block.
|
|
const projectId = `proj-plugin-authoring-unicode-${randomUUID()}`;
|
|
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Plugin authoring unicode-preamble fixture',
|
|
skillId: null,
|
|
designSystemId: null,
|
|
}),
|
|
});
|
|
expect(createProjectResponse.status).toBe(200);
|
|
const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
|
|
expect(conversationsResponse.status).toBe(200);
|
|
const conversationsBody = await conversationsResponse.json() as {
|
|
conversations: Array<{ id: string }>;
|
|
};
|
|
const conversationId = conversationsBody.conversations[0]?.id;
|
|
expect(conversationId).toBeTruthy();
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => {
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: 'İstanbul brief — 先确认几个问题。\\n<ask-question id="discovery" title="Plugin brief">\\n{"questions":[{"id":"purpose","label":"What should it do?","type":"text"}]}\\n</ask-question>' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
conversationId,
|
|
pluginId: 'od-plugin-authoring',
|
|
message: '帮我做个插件。',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`);
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: final');
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).not.toContain('ended before generating the required generated-plugin artifacts');
|
|
expect(statusBody.status).toBe('succeeded');
|
|
},
|
|
);
|
|
});
|
|
it('closes the # Instructions block with an explicit "do not echo" guard so models do not parrot the prompt back', async () => {
|
|
// claude-opus-4-7 (and a few other instruction-tuned models) start
|
|
// their reply by echoing the # Instructions block verbatim, which
|
|
// shows up to users as the system prompt leading the visible
|
|
// answer. server.ts:9934 closes every Instructions block with a
|
|
// trailing guard line; this test pins the literal so a future
|
|
// refactor cannot silently drop it.
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
let prompt = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => {
|
|
prompt += chunk;
|
|
});
|
|
process.stdin.on('end', () => {
|
|
const checks = [
|
|
prompt.includes('Do not quote, restate, or echo the # Instructions block above')
|
|
? 'has-echo-guard'
|
|
: 'missing-echo-guard',
|
|
];
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('has-echo-guard');
|
|
expect(body).not.toContain('missing-echo-guard');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('injects @-mention skillIds into the composed system prompt', async () => {
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
let prompt = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => {
|
|
prompt += chunk;
|
|
});
|
|
process.stdin.on('end', () => {
|
|
const checks = [
|
|
prompt.includes('## Composed skill — faq-page') ? 'has-composed-skill-header' : 'missing-composed-skill-header',
|
|
prompt.includes('# FAQ Page Skill') ? 'has-faq-skill-body' : 'missing-faq-skill-body',
|
|
prompt.includes('category filtering') ? 'has-faq-skill-content' : 'missing-faq-skill-content',
|
|
];
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
message: 'build an faq page',
|
|
skillIds: ['faq-page'],
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('has-composed-skill-header');
|
|
expect(body).toContain('has-faq-skill-body');
|
|
expect(body).toContain('has-faq-skill-content');
|
|
expect(body).not.toContain('missing-composed-skill-header');
|
|
expect(body).not.toContain('missing-faq-skill-body');
|
|
expect(body).not.toContain('missing-faq-skill-content');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('stages ad-hoc skill side files into the project cwd', async () => {
|
|
const projectId = `project-${randomUUID()}`;
|
|
const stagedRelativePath = `.od-skills/${skillCwdAliasSegment(resolve(process.cwd(), '..', '..', 'skills', 'release-notes-one-pager'))}/references/checklist.md`;
|
|
const expectedChecklist = await fsp.readFile(
|
|
resolve(process.cwd(), '..', '..', 'skills', 'release-notes-one-pager', 'references', 'checklist.md'),
|
|
'utf8',
|
|
);
|
|
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Ad hoc staged skill project',
|
|
}),
|
|
});
|
|
|
|
expect(createProjectResponse.ok).toBe(true);
|
|
|
|
const fakeAgentScript = `
|
|
const fs = require('node:fs');
|
|
const stagedChecklist = fs.readFileSync(${JSON.stringify(stagedRelativePath)}, 'utf8');
|
|
if (stagedChecklist !== ${JSON.stringify(expectedChecklist)}) {
|
|
console.error('staged-skill-side-files-mismatch');
|
|
process.exit(1);
|
|
}
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => {
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: 'staged-skill-side-files-before-spawn' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`;
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
fakeAgentScript,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
message: 'draft the release notes',
|
|
skillIds: ['release-notes-one-pager'],
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('staged-skill-side-files-before-spawn');
|
|
},
|
|
);
|
|
|
|
const stagedFileResponse = await fetch(
|
|
`${baseUrl}/api/projects/${projectId}/raw/${stagedRelativePath}`,
|
|
);
|
|
const stagedFileBody = await stagedFileResponse.text();
|
|
|
|
expect(stagedFileResponse.ok).toBe(true);
|
|
expect(stagedFileBody).toBe(expectedChecklist);
|
|
});
|
|
|
|
it('stages side files for every composed skill into the project cwd', async () => {
|
|
const projectId = `project-${randomUUID()}`;
|
|
const stagedPaths = [
|
|
`.od-skills/${skillCwdAliasSegment(resolve(process.cwd(), '..', '..', 'skills', 'release-notes-one-pager'))}/references/checklist.md`,
|
|
`.od-skills/${skillCwdAliasSegment(resolve(process.cwd(), '..', '..', 'skills', 'swiss-creative-mode-template'))}/references/checklist.md`,
|
|
] as const;
|
|
const expectedBodies = await Promise.all(
|
|
[
|
|
resolve(process.cwd(), '..', '..', 'skills', 'release-notes-one-pager', 'references', 'checklist.md'),
|
|
resolve(process.cwd(), '..', '..', 'skills', 'swiss-creative-mode-template', 'references', 'checklist.md'),
|
|
].map((file) => fsp.readFile(file, 'utf8')),
|
|
);
|
|
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Multi staged skill project',
|
|
}),
|
|
});
|
|
|
|
expect(createProjectResponse.ok).toBe(true);
|
|
|
|
const fakeAgentScript = `
|
|
const fs = require('node:fs');
|
|
const stagedBodies = [
|
|
fs.readFileSync(${JSON.stringify(stagedPaths[0])}, 'utf8'),
|
|
fs.readFileSync(${JSON.stringify(stagedPaths[1])}, 'utf8'),
|
|
];
|
|
const expectedBodies = ${JSON.stringify(expectedBodies)};
|
|
if (JSON.stringify(stagedBodies) !== JSON.stringify(expectedBodies)) {
|
|
console.error('multi-staged-skill-side-files-mismatch');
|
|
process.exit(1);
|
|
}
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => {
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: 'multi-staged-skill-side-files-before-spawn' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`;
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
fakeAgentScript,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
message: 'compose multiple skills',
|
|
skillIds: ['release-notes-one-pager', 'swiss-creative-mode-template'],
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('multi-staged-skill-side-files-before-spawn');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('propagates the composed skill mode for ad-hoc-only deck skills', async () => {
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
let prompt = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => {
|
|
prompt += chunk;
|
|
});
|
|
process.stdin.on('end', () => {
|
|
const checks = [
|
|
prompt.includes('## Composed skill — open-design-landing-deck') ? 'has-deck-skill-header' : 'missing-deck-skill-header',
|
|
prompt.includes('# Slide deck — fixed framework (this is non-negotiable for deck mode)') ? 'has-deck-framework' : 'missing-deck-framework',
|
|
];
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
message: 'build an editorial brand deck',
|
|
skillIds: ['open-design-landing-deck'],
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('has-deck-skill-header');
|
|
expect(body).toContain('has-deck-framework');
|
|
expect(body).not.toContain('missing-deck-skill-header');
|
|
expect(body).not.toContain('missing-deck-framework');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('preserves a persisted media skill as the primary surface over a composed deck mention', async () => {
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
let prompt = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => {
|
|
prompt += chunk;
|
|
});
|
|
process.stdin.on('end', () => {
|
|
const checks = [
|
|
prompt.includes('# imagegen') ? 'has-base-image-skill-body' : 'missing-base-image-skill-body',
|
|
prompt.includes('## Composed skill — open-design-landing-deck') ? 'has-composed-deck-skill-header' : 'missing-composed-deck-skill-header',
|
|
prompt.includes('## Media generation contract (load-bearing — overrides softer wording above)') ? 'has-image-contract' : 'missing-image-contract',
|
|
prompt.includes('# Slide deck — fixed framework (this is non-negotiable for deck mode)') ? 'unexpected-deck-framework' : 'kept-deck-framework-out',
|
|
];
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
message: 'generate an image while also referencing a deck template',
|
|
skillId: 'imagegen',
|
|
skillIds: ['open-design-landing-deck'],
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('has-base-image-skill-body');
|
|
expect(body).toContain('has-composed-deck-skill-header');
|
|
expect(body).toContain('has-image-contract');
|
|
expect(body).toContain('kept-deck-framework-out');
|
|
expect(body).not.toContain('missing-base-image-skill-body');
|
|
expect(body).not.toContain('missing-composed-deck-skill-header');
|
|
expect(body).not.toContain('missing-image-contract');
|
|
expect(body).not.toContain('unexpected-deck-framework');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('honors mediaExecution on legacy chat requests', async () => {
|
|
const conversationId = `conv-${randomUUID()}`;
|
|
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: `missing-agent-${randomUUID()}`,
|
|
conversationId,
|
|
message: 'plan an image without using OD media',
|
|
skillId: 'imagegen',
|
|
mediaExecution: {
|
|
mode: 'disabled',
|
|
allowedSurfaces: ['image'],
|
|
},
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('unknown agent');
|
|
|
|
const runsResponse = await fetch(
|
|
`${baseUrl}/api/runs?conversationId=${encodeURIComponent(conversationId)}`,
|
|
);
|
|
const runsBody = await runsResponse.json() as {
|
|
runs: Array<{ mediaExecution?: { mode?: string; allowedSurfaces?: string[] } }>;
|
|
};
|
|
expect(runsBody.runs).toHaveLength(1);
|
|
expect(runsBody.runs[0]?.mediaExecution).toMatchObject({
|
|
mode: 'disabled',
|
|
allowedSurfaces: ['image'],
|
|
});
|
|
});
|
|
|
|
it('rejects invalid mediaExecution on legacy chat requests', async () => {
|
|
const conversationId = `conv-${randomUUID()}`;
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
conversationId,
|
|
message: 'generate an image',
|
|
mediaExecution: { mode: 'provider-router' },
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(body).toContain('mediaExecution.mode');
|
|
|
|
const runsResponse = await fetch(
|
|
`${baseUrl}/api/runs?conversationId=${encodeURIComponent(conversationId)}`,
|
|
);
|
|
const runsBody = await runsResponse.json() as { runs: unknown[] };
|
|
expect(runsBody.runs).toEqual([]);
|
|
});
|
|
|
|
it('propagates ad-hoc skill critique policy into the chat resolver', async () => {
|
|
if (!process.env.OD_DATA_DIR) {
|
|
throw new Error('OD_DATA_DIR is required for user skill critique-policy tests');
|
|
}
|
|
|
|
const skillId = `critique-opt-out-${randomUUID()}`;
|
|
const skillDir = resolve(process.env.OD_DATA_DIR, 'skills', skillId);
|
|
const originalCritiqueEnabled = process.env.OD_CRITIQUE_ENABLED;
|
|
|
|
await fsp.mkdir(skillDir, { recursive: true });
|
|
await fsp.writeFile(
|
|
resolve(skillDir, 'SKILL.md'),
|
|
`---
|
|
name: ${skillId}
|
|
description: Ad-hoc critique opt-out regression fixture.
|
|
od:
|
|
critique:
|
|
policy: opt-out
|
|
---
|
|
|
|
# Critique opt-out fixture
|
|
|
|
This skill should suppress critique when selected through skillIds.
|
|
`,
|
|
'utf8',
|
|
);
|
|
|
|
process.env.OD_CRITIQUE_ENABLED = 'true';
|
|
|
|
try {
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
let prompt = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => {
|
|
prompt += chunk;
|
|
});
|
|
process.stdin.on('end', () => {
|
|
const checks = [
|
|
prompt.includes('## Composed skill — ${skillId}') ? 'has-opt-out-skill-header' : 'missing-opt-out-skill-header',
|
|
prompt.includes('<CRITIQUE_RUN') ? 'unexpected-critique-panel' : 'critique-panel-disabled-by-skill-policy',
|
|
];
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
designSystemId: 'default',
|
|
message: 'draft an opt-out skill artifact',
|
|
skillIds: [skillId],
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('has-opt-out-skill-header');
|
|
expect(body).toContain('critique-panel-disabled-by-skill-policy');
|
|
expect(body).not.toContain('missing-opt-out-skill-header');
|
|
expect(body).not.toContain('unexpected-critique-panel');
|
|
},
|
|
);
|
|
} finally {
|
|
if (originalCritiqueEnabled == null) {
|
|
delete process.env.OD_CRITIQUE_ENABLED;
|
|
} else {
|
|
process.env.OD_CRITIQUE_ENABLED = originalCritiqueEnabled;
|
|
}
|
|
await fsp.rm(skillDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('preserves plugin-local and composed @-mention skills in plugin-bound runs', async () => {
|
|
const pluginId = `plugin-local-${randomUUID()}`;
|
|
const pluginFixtureDir = await createPluginFixture({
|
|
pluginId,
|
|
dirName: `plugin-local-${randomUUID()}`,
|
|
localSkillPath: './SKILL.md',
|
|
});
|
|
const installResponse = await fetch(`${baseUrl}/api/plugins/install`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', accept: 'text/event-stream' },
|
|
body: JSON.stringify({ source: pluginFixtureDir }),
|
|
});
|
|
const installBody = await installResponse.text();
|
|
|
|
expect(installResponse.status).toBe(200);
|
|
expect(installBody).toContain(`"id":"${pluginId}"`);
|
|
|
|
const projectId = `project-${randomUUID()}`;
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Plugin-bound skill composition project',
|
|
pluginId,
|
|
pluginInputs: { topic: 'agentic design' },
|
|
}),
|
|
});
|
|
const createProjectBody = await createProjectResponse.json() as {
|
|
appliedPluginSnapshotId?: string;
|
|
};
|
|
|
|
expect(createProjectResponse.ok).toBe(true);
|
|
expect(createProjectBody.appliedPluginSnapshotId).toBeTruthy();
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
let prompt = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => {
|
|
prompt += chunk;
|
|
});
|
|
process.stdin.on('end', () => {
|
|
const checks = [
|
|
prompt.includes('# Sample Plugin') ? 'has-plugin-skill-body' : 'missing-plugin-skill-body',
|
|
prompt.includes('## Composed skill — faq-page') ? 'has-composed-skill-header' : 'missing-composed-skill-header',
|
|
prompt.includes('# FAQ Page Skill') ? 'has-composed-skill-body' : 'missing-composed-skill-body',
|
|
];
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const createRunResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
message: 'build a plugin-backed faq page',
|
|
appliedPluginSnapshotId: createProjectBody.appliedPluginSnapshotId,
|
|
skillIds: ['faq-page'],
|
|
}),
|
|
});
|
|
const createRunBody = await createRunResponse.json() as { runId: string };
|
|
|
|
expect(createRunResponse.status).toBe(202);
|
|
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${createRunBody.runId}/events`);
|
|
const body = await readSseUntil(eventsResponse, 'event: final');
|
|
|
|
expect(body).toContain('has-plugin-skill-body');
|
|
expect(body).toContain('has-composed-skill-header');
|
|
expect(body).toContain('has-composed-skill-body');
|
|
expect(body).not.toContain('missing-plugin-skill-body');
|
|
expect(body).not.toContain('missing-composed-skill-header');
|
|
expect(body).not.toContain('missing-composed-skill-body');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('stages colliding plugin and composed skill dirs under distinct aliases', async () => {
|
|
if (!process.env.OD_DATA_DIR) {
|
|
throw new Error('OD_DATA_DIR is required for colliding skill-dir staging tests');
|
|
}
|
|
|
|
const pluginId = `plugin-collision-${randomUUID()}`;
|
|
const pluginFixtureDir = await createPluginFixture({
|
|
pluginId,
|
|
dirName: 'sample-plugin',
|
|
localSkillPath: './SKILL.md',
|
|
});
|
|
const installResponse = await fetch(`${baseUrl}/api/plugins/install`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', accept: 'text/event-stream' },
|
|
body: JSON.stringify({ source: pluginFixtureDir }),
|
|
});
|
|
const installBody = await installResponse.text();
|
|
|
|
expect(installResponse.status).toBe(200);
|
|
expect(installBody).toContain(`"id":"${pluginId}"`);
|
|
|
|
const projectId = `project-${randomUUID()}`;
|
|
const userSkillDir = resolve(process.env.OD_DATA_DIR, 'skills', 'sample-plugin');
|
|
const userChecklist = 'user-skill-checklist';
|
|
const userAlias = skillCwdAliasSegment(userSkillDir);
|
|
|
|
await fsp.mkdir(resolve(userSkillDir, 'references'), { recursive: true });
|
|
await fsp.writeFile(
|
|
resolve(userSkillDir, 'SKILL.md'),
|
|
'# Sample-plugin side-file fixture\n\nRead references/checklist.md before drafting.',
|
|
'utf8',
|
|
);
|
|
await fsp.writeFile(resolve(userSkillDir, 'references', 'checklist.md'), userChecklist, 'utf8');
|
|
|
|
try {
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Colliding skill-dir project',
|
|
pluginId,
|
|
pluginInputs: { topic: 'agentic design' },
|
|
}),
|
|
});
|
|
const createProjectBody = await createProjectResponse.json() as {
|
|
appliedPluginSnapshotId?: string;
|
|
};
|
|
const installedPluginResponse = await fetch(`${baseUrl}/api/plugins/${pluginId}`);
|
|
const installedPluginBody = await installedPluginResponse.json() as { fsPath: string };
|
|
const pluginAlias = skillCwdAliasSegment(installedPluginBody.fsPath);
|
|
|
|
expect(createProjectResponse.ok).toBe(true);
|
|
expect(installedPluginResponse.ok).toBe(true);
|
|
expect(createProjectBody.appliedPluginSnapshotId).toBeTruthy();
|
|
expect(pluginAlias).not.toBe(userAlias);
|
|
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
const fs = require('node:fs');
|
|
const pluginSkill = fs.readFileSync(${JSON.stringify(`.od-skills/${pluginAlias}/SKILL.md`)}, 'utf8');
|
|
const userChecklist = fs.readFileSync(${JSON.stringify(`.od-skills/${userAlias}/references/checklist.md`)}, 'utf8');
|
|
if (!pluginSkill.includes('# Sample Plugin')) {
|
|
console.error('plugin-skill-stage-missing');
|
|
process.exit(1);
|
|
}
|
|
if (userChecklist !== ${JSON.stringify(userChecklist)}) {
|
|
console.error('colliding-skill-stage-mismatch');
|
|
process.exit(1);
|
|
}
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => {
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: 'colliding-skill-dirs-staged' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const createRunResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
message: 'use both plugin and user skill side files',
|
|
appliedPluginSnapshotId: createProjectBody.appliedPluginSnapshotId,
|
|
skillIds: ['sample-plugin'],
|
|
}),
|
|
});
|
|
const createRunBody = await createRunResponse.json() as { runId: string };
|
|
|
|
expect(createRunResponse.status).toBe(202);
|
|
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${createRunBody.runId}/events`);
|
|
const body = await readSseUntil(eventsResponse, 'event: final');
|
|
|
|
expect(body).toContain('colliding-skill-dirs-staged');
|
|
},
|
|
);
|
|
} finally {
|
|
await fsp.rm(userSkillDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('canonicalizes aliased skill ids before deduping composed skills', async () => {
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
let prompt = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => {
|
|
prompt += chunk;
|
|
});
|
|
process.stdin.on('end', () => {
|
|
const hasDuplicateComposedAlias = prompt.includes('## Composed skill — open-design-landing');
|
|
const checks = [
|
|
hasDuplicateComposedAlias ? 'duplicate-alias-composed-skill' : 'deduped-alias-composed-skill',
|
|
prompt.includes('# open-design-landing') ? 'has-base-alias-skill-body' : 'missing-base-alias-skill-body',
|
|
];
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
message: 'build the OpenDesign landing page',
|
|
skillId: 'editorial-collage',
|
|
skillIds: ['open-design-landing'],
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('deduped-alias-composed-skill');
|
|
expect(body).toContain('has-base-alias-skill-body');
|
|
expect(body).not.toContain('duplicate-alias-composed-skill');
|
|
expect(body).not.toContain('missing-base-alias-skill-body');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('classifies Cursor Agent authentication stderr as a typed run error', async () => {
|
|
await withFakeAgent(
|
|
'cursor-agent',
|
|
`
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === '--version') {
|
|
console.log('2026.05.07-test');
|
|
process.exit(0);
|
|
}
|
|
if (args[0] === 'models') {
|
|
console.log('auto');
|
|
process.exit(0);
|
|
}
|
|
console.error("Authentication required. Please run 'agent login' first, or set CURSOR_API_KEY environment variable.");
|
|
process.exit(1);
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'cursor-agent',
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsController = new AbortController();
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
|
signal: eventsController.signal,
|
|
});
|
|
const eventsBody = await readSseUntil(eventsResponse, 'AGENT_AUTH_REQUIRED');
|
|
eventsController.abort();
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('event: error');
|
|
expect(eventsBody).toContain('AGENT_AUTH_REQUIRED');
|
|
expect(eventsBody).toContain('cursor-agent login');
|
|
expect(eventsBody).toContain('cursor-agent status');
|
|
expect(statusBody.status).toBe('failed');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('classifies Cursor Agent Not logged in stderr as a typed run error', async () => {
|
|
await withFakeAgent(
|
|
'cursor-agent',
|
|
`
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === '--version') {
|
|
console.log('2026.05.07-test');
|
|
process.exit(0);
|
|
}
|
|
if (args[0] === 'models') {
|
|
console.log('auto');
|
|
process.exit(0);
|
|
}
|
|
console.error('Not logged in');
|
|
process.exit(1);
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'cursor-agent',
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsController = new AbortController();
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
|
signal: eventsController.signal,
|
|
});
|
|
const eventsBody = await readSseUntil(eventsResponse, 'AGENT_AUTH_REQUIRED');
|
|
eventsController.abort();
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('event: error');
|
|
expect(eventsBody).toContain('AGENT_AUTH_REQUIRED');
|
|
expect(eventsBody).toContain('cursor-agent login');
|
|
expect(eventsBody).toContain('cursor-agent status');
|
|
expect(statusBody.status).toBe('failed');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('classifies Cursor Agent stdout auth text as a typed run error', async () => {
|
|
await withFakeAgent(
|
|
'cursor-agent',
|
|
`
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === '--version') {
|
|
console.log('2026.05.07-test');
|
|
process.exit(0);
|
|
}
|
|
if (args[0] === 'models') {
|
|
console.log('auto');
|
|
process.exit(0);
|
|
}
|
|
console.log('ConnectError: [unauthenticated]');
|
|
process.exit(1);
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'cursor-agent',
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsController = new AbortController();
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
|
signal: eventsController.signal,
|
|
});
|
|
const eventsBody = await readSseUntil(eventsResponse, 'AGENT_AUTH_REQUIRED');
|
|
eventsController.abort();
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('event: error');
|
|
expect(eventsBody).toContain('AGENT_AUTH_REQUIRED');
|
|
expect(eventsBody).toContain('cursor-agent login');
|
|
expect(eventsBody).toContain('cursor-agent status');
|
|
expect(eventsBody).not.toContain('AGENT_EXECUTION_FAILED');
|
|
expect(statusBody.status).toBe('failed');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('classifies Cursor Agent stdout error payloads as typed auth failures', async () => {
|
|
const cursorErrorLine = JSON.stringify({
|
|
type: 'error',
|
|
message: 'Error: [unauthenticated] Error',
|
|
});
|
|
await withFakeAgent(
|
|
'cursor-agent',
|
|
`
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === '--version') {
|
|
console.log('2026.05.07-test');
|
|
process.exit(0);
|
|
}
|
|
if (args[0] === 'models') {
|
|
console.log('auto');
|
|
process.exit(0);
|
|
}
|
|
console.log(${JSON.stringify(cursorErrorLine)});
|
|
process.exit(1);
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'cursor-agent',
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsController = new AbortController();
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
|
signal: eventsController.signal,
|
|
});
|
|
const eventsBody = await readSseUntil(eventsResponse, 'AGENT_AUTH_REQUIRED');
|
|
eventsController.abort();
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('event: error');
|
|
expect(eventsBody).toContain('AGENT_AUTH_REQUIRED');
|
|
expect(eventsBody).toContain('cursor-agent login');
|
|
expect(eventsBody).toContain('cursor-agent status');
|
|
expect(eventsBody).not.toContain('AGENT_EXECUTION_FAILED');
|
|
expect(statusBody.status).toBe('failed');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('classifies DeepSeek TUI config guidance as typed auth failures', async () => {
|
|
await withFakeAgent(
|
|
'deepseek',
|
|
`
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === '--version') {
|
|
console.log('deepseek 0.3.0-test');
|
|
process.exit(0);
|
|
}
|
|
console.error('KEY=<your-key> deepseek --api-key <your-key>');
|
|
console.error('api_key = "<your-key>" in ~/.deepseek/config.toml');
|
|
process.exit(1);
|
|
`,
|
|
async () => {
|
|
const deepseek = getAgentDef('deepseek');
|
|
expect(deepseek).toBeDefined();
|
|
const originalBudget = deepseek?.maxPromptArgBytes;
|
|
if (deepseek) deepseek.maxPromptArgBytes = 200_000;
|
|
try {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'deepseek',
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsController = new AbortController();
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
|
signal: eventsController.signal,
|
|
});
|
|
const eventsBody = await readSseUntil(eventsResponse, 'AGENT_AUTH_REQUIRED');
|
|
eventsController.abort();
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('event: error');
|
|
expect(eventsBody).toContain('AGENT_AUTH_REQUIRED');
|
|
expect(eventsBody).toContain('~/.deepseek/config.toml');
|
|
expect(eventsBody).toContain('DEEPSEEK_API_KEY');
|
|
expect(eventsBody).not.toContain('cursor-agent login');
|
|
expect(eventsBody).not.toContain('AGENT_EXECUTION_FAILED');
|
|
expect(statusBody.status).toBe('failed');
|
|
} finally {
|
|
if (deepseek) {
|
|
if (originalBudget === undefined) {
|
|
delete deepseek.maxPromptArgBytes;
|
|
} else {
|
|
deepseek.maxPromptArgBytes = originalBudget;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
// OPEND-3495. The fake `agy` enforces the real CLI's prompt contract
|
|
// instead of echoing whatever it is handed:
|
|
// - `-p/--print <text>` ignores stdin entirely (agy 1.1.1 changelog: "no
|
|
// longer reading stdin when a prompt is provided via a flag"), so a
|
|
// daemon that puts a pointer in `-p` and the prompt on stdin gets a reply
|
|
// that never saw the prompt;
|
|
// - `--input-format stream-json` (requires `--output-format stream-json`)
|
|
// reads NDJSON `{"event":"user","message":{"content":…}}` messages from
|
|
// stdin and answers with agy's stream-json events (see
|
|
// tests/fixtures/antigravity-stream-json).
|
|
// The reply reports what actually arrived, so the test can only pass when
|
|
// the composed prompt reaches agy through the channel agy reads.
|
|
const FAKE_AGY_CONTRACT = `
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === '--version') {
|
|
console.log('1.3.1');
|
|
process.exit(0);
|
|
}
|
|
if (args.includes('-p') || args.includes('--print') || args.includes('--prompt')) {
|
|
process.stdout.write('I do not see any instructions in your message.\\n');
|
|
process.exit(0);
|
|
}
|
|
const inputIndex = args.indexOf('--input-format');
|
|
const outputIndex = args.indexOf('--output-format');
|
|
if (inputIndex === -1 || args[inputIndex + 1] !== 'stream-json' || outputIndex === -1 || args[outputIndex + 1] !== 'stream-json') {
|
|
process.stderr.write('Error: no prompt provided\\n');
|
|
process.exit(2);
|
|
}
|
|
let stdin = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => { stdin += chunk; });
|
|
process.stdin.on('end', () => {
|
|
const emit = (event) => process.stdout.write(JSON.stringify(event) + '\\n');
|
|
const conversation_id = 'agy-fake-1';
|
|
emit({ event: 'init', conversation_id, init: { cwd: process.cwd(), tools: ['run_command'], permission_mode: 'request-review' } });
|
|
const messages = stdin.split('\\n').filter((line) => line.trim()).map((line) => JSON.parse(line));
|
|
if (messages.length !== 1 || messages[0].event !== 'user' || typeof messages[0].message?.content !== 'string') {
|
|
process.stderr.write('error: stream input "user" message has no content\\n');
|
|
emit({ event: 'result', result: { conversation_id, status: 'ERROR', response: '', error: 'stream input "user" message has no content', duration_seconds: 0, num_turns: 0 } });
|
|
process.exit(1);
|
|
}
|
|
const content = messages[0].message.content;
|
|
emit({ event: 'step_update', step_update: { conversation_id, step_index: 0, state: 'DONE', step_type: 'user_input' } });
|
|
const reply = 'RECEIVED ' + content.length + ' chars ending ' + JSON.stringify(content.slice(-40));
|
|
emit({ event: 'step_update', step_update: { conversation_id, step_index: 1, state: 'DONE', step_type: 'agent_response', text_delta: reply, duration_seconds: 0.5, usage: { input_tokens: 9, output_tokens: 3, thinking_tokens: 0, cache_read_tokens: 0, total_tokens: 12 } } });
|
|
emit({ event: 'result', result: { conversation_id, status: 'SUCCESS', response: reply, duration_seconds: 0.6, num_turns: 1, usage: { input_tokens: 9, output_tokens: 3, thinking_tokens: 0, cache_read_tokens: 0, total_tokens: 12 } } });
|
|
process.exit(0);
|
|
});
|
|
`;
|
|
|
|
function fakeAgyReplaying(name: string, exitCode: number): string {
|
|
const fixtures = fileURLToPath(new URL('./fixtures/antigravity-stream-json/', import.meta.url));
|
|
const stdout = readFileSync(join(fixtures, `${name}.stdout.ndjson`), 'utf8');
|
|
const stderr = readFileSync(join(fixtures, `${name}.stderr.txt`), 'utf8');
|
|
return `
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === '--version') {
|
|
console.log('1.3.1');
|
|
process.exit(0);
|
|
}
|
|
process.stdin.resume();
|
|
process.stdin.on('end', () => {
|
|
process.stdout.write(${JSON.stringify(stdout)});
|
|
process.stderr.write(${JSON.stringify(stderr)});
|
|
process.exit(${exitCode});
|
|
});
|
|
`;
|
|
}
|
|
|
|
it('delivers the whole composed prompt to Antigravity through agy stream-json stdin', async () => {
|
|
const marker = 'OPEND_3495_END_OF_USER_REQUEST';
|
|
await withFakeAgent('agy', FAKE_AGY_CONTRACT, async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'antigravity',
|
|
message: `${'Pad the composed prompt past the Windows command-line cap. '.repeat(700)}${marker}`,
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsController = new AbortController();
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
|
signal: eventsController.signal,
|
|
});
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: final');
|
|
eventsController.abort();
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('"type":"text_delta","delta":"RECEIVED ');
|
|
expect(eventsBody).toContain(marker);
|
|
expect(eventsBody).not.toContain('I do not see any instructions');
|
|
expect(eventsBody).not.toContain('event: stdout');
|
|
expect(eventsBody).toContain('"type":"usage"');
|
|
expect(statusBody.status).toBe('succeeded');
|
|
});
|
|
});
|
|
|
|
it('reports a signed-out Antigravity CLI as AGENT_AUTH_REQUIRED', async () => {
|
|
await withFakeAgent('agy', fakeAgyReplaying('signed-out', 1), async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ agentId: 'antigravity', message: 'hello' }),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsController = new AbortController();
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
|
signal: eventsController.signal,
|
|
});
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: final');
|
|
eventsController.abort();
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('event: error');
|
|
expect(eventsBody).toContain('AGENT_AUTH_REQUIRED');
|
|
expect(eventsBody).toContain('open a terminal and run `agy` once');
|
|
expect(eventsBody).not.toContain('event: stdout');
|
|
expect(statusBody.status).toBe('failed');
|
|
});
|
|
});
|
|
|
|
it('reports an exhausted Antigravity quota as RATE_LIMITED', async () => {
|
|
await withFakeAgent('agy', fakeAgyReplaying('quota-exhausted', 3), async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ agentId: 'antigravity', message: 'hello' }),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsController = new AbortController();
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
|
signal: eventsController.signal,
|
|
});
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: final');
|
|
eventsController.abort();
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('event: error');
|
|
expect(eventsBody).toContain('RATE_LIMITED');
|
|
expect(eventsBody).toContain('Individual quota reached');
|
|
expect(eventsBody).toContain('Switch Model picker');
|
|
expect(eventsBody).not.toContain('event: stdout');
|
|
expect(statusBody.status).toBe('failed');
|
|
});
|
|
});
|
|
|
|
it('fails plain-stream runs when stdout artifact persistence fails', async () => {
|
|
await withFakeAgent(
|
|
'qwen',
|
|
`
|
|
const args = process.argv.slice(2);
|
|
if (args[0] === '--version') {
|
|
console.log('1.107.0-test');
|
|
process.exit(0);
|
|
}
|
|
process.stdout.write('<artifact identifier="blocked" type="text/html" title="Blocked"><!doctype html><html><body>Name to confirm</body></html></artifact>\\n');
|
|
process.exit(0);
|
|
`,
|
|
async () => {
|
|
const projectId = `plain-artifact-fail-${randomUUID()}`;
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: projectId, name: 'Plain artifact failure' }),
|
|
});
|
|
expect(createProjectResponse.ok).toBe(true);
|
|
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'qwen',
|
|
projectId,
|
|
message: 'emit blocked artifact',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsController = new AbortController();
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
|
signal: eventsController.signal,
|
|
});
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: final');
|
|
eventsController.abort();
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('event: error');
|
|
expect(eventsBody).toContain('plain-stream artifact');
|
|
expect(statusBody.status).toBe('failed');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('surfaces Qoder assistant error records through the SSE error channel', async () => {
|
|
const qoderErrorLine = JSON.stringify({
|
|
type: 'assistant',
|
|
message: { content: [] },
|
|
error: { message: 'Qoder authentication expired' },
|
|
});
|
|
await withFakeAgent(
|
|
'qodercli',
|
|
`console.log(${JSON.stringify(qoderErrorLine)});\nprocess.exit(0);\n`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'qoder',
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsController = new AbortController();
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
|
signal: eventsController.signal,
|
|
});
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: error');
|
|
eventsController.abort();
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('event: error');
|
|
expect(eventsBody).toContain('Qoder authentication expired');
|
|
expect(eventsBody).not.toContain('event: agent\\ndata: {"type":"error"');
|
|
expect(statusBody.status).toBe('failed');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('marks reasoning-only stream runs failed when no assistant output is produced', async () => {
|
|
const reasoningLine = JSON.stringify({
|
|
type: 'assistant',
|
|
message: {
|
|
content: [
|
|
{
|
|
type: 'thinking',
|
|
thinking: 'I should inspect the project before answering.',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
const resultLine = JSON.stringify({
|
|
type: 'result',
|
|
is_error: false,
|
|
usage: { input_tokens: 1, output_tokens: 0 },
|
|
});
|
|
|
|
await withFakeAgent(
|
|
'qodercli',
|
|
`
|
|
console.log(${JSON.stringify(reasoningLine)});
|
|
console.log(${JSON.stringify(resultLine)});
|
|
process.exit(0);
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'qoder',
|
|
message: 'think but do not answer',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsController = new AbortController();
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
|
signal: eventsController.signal,
|
|
});
|
|
const eventsBody = await readSseUntil(
|
|
eventsResponse,
|
|
'Agent completed without producing any output',
|
|
);
|
|
eventsController.abort();
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('"type":"thinking_delta"');
|
|
expect(eventsBody).toContain('AGENT_EXECUTION_FAILED');
|
|
expect(eventsBody).toContain('Agent completed without producing any output');
|
|
expect(eventsBody).not.toContain('"status":"succeeded"');
|
|
expect(statusBody.status).toBe('failed');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('fails Qoder runs when the result reports is_error with exit code 0', async () => {
|
|
const qoderResultLine = JSON.stringify({
|
|
type: 'result',
|
|
subtype: 'error',
|
|
duration_ms: 17,
|
|
is_error: true,
|
|
stop_reason: 'tool_use_failed',
|
|
total_cost_usd: 0,
|
|
usage: {
|
|
input_tokens: 3,
|
|
output_tokens: 1,
|
|
},
|
|
});
|
|
await withFakeAgent(
|
|
'qodercli',
|
|
`console.log(${JSON.stringify(qoderResultLine)});\nprocess.exit(0);\n`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'qoder',
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsController = new AbortController();
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
|
signal: eventsController.signal,
|
|
});
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: error');
|
|
eventsController.abort();
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('event: agent');
|
|
expect(eventsBody).toContain('"type":"usage"');
|
|
expect(eventsBody).toContain('"isError":true');
|
|
expect(eventsBody).toContain('event: error');
|
|
expect(eventsBody).toContain('Qoder run failed: tool_use_failed');
|
|
expect(statusBody.status).toBe('failed');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('fails stalled json-stream runs after the inactivity timeout elapses', async () => {
|
|
const previous = process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS;
|
|
process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = '500';
|
|
try {
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
process.on('SIGTERM', () => process.exit(143));
|
|
setInterval(() => {}, 1000);
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsController = new AbortController();
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
|
signal: eventsController.signal,
|
|
});
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: error');
|
|
eventsController.abort();
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('event: error');
|
|
expect(eventsBody).toContain('Agent stalled without emitting any new output');
|
|
expect(eventsBody).toContain('Phase details: spawned agent opencode;');
|
|
expect(eventsBody).not.toContain('spawned agent binary');
|
|
expect(eventsBody).toMatch(/stdout arrived: (yes|no)/);
|
|
expect(statusBody.status).toBe('failed');
|
|
},
|
|
);
|
|
} finally {
|
|
if (previous == null) {
|
|
delete process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS;
|
|
} else {
|
|
process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = previous;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('keeps Claude stream runs alive while structured output is still flowing', async () => {
|
|
const previous = process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS;
|
|
process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = '3000';
|
|
try {
|
|
await withFakeAgent(
|
|
'claude',
|
|
`
|
|
const lines = [
|
|
JSON.stringify({ type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-1' }, ttft_ms: 10 } }),
|
|
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_start', index: 0, content_block: { type: 'text' } } }),
|
|
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hello ' } } }),
|
|
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'world' } } }),
|
|
JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }),
|
|
JSON.stringify({ type: 'result', usage: { input_tokens: 1, output_tokens: 2 }, duration_ms: 700, stop_reason: 'end_turn' }),
|
|
];
|
|
let index = 0;
|
|
console.log(lines[index++]);
|
|
const timer = setInterval(() => {
|
|
if (index >= lines.length) {
|
|
clearInterval(timer);
|
|
process.exit(0);
|
|
return;
|
|
}
|
|
console.log(lines[index++]);
|
|
}, 750);
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'claude',
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
expect(statusBody.status).toBe('succeeded');
|
|
},
|
|
);
|
|
} finally {
|
|
if (previous == null) {
|
|
delete process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS;
|
|
} else {
|
|
process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = previous;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('surfaces Claude auth diagnostics through the SSE error channel', async () => {
|
|
await withFakeAgent(
|
|
'claude',
|
|
`
|
|
console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 }));
|
|
process.exit(1);
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'claude',
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsController = new AbortController();
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
|
signal: eventsController.signal,
|
|
});
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: error');
|
|
eventsController.abort();
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('event: error');
|
|
expect(eventsBody).toContain('/login');
|
|
expect(eventsBody).toContain('CLAUDE_CONFIG_DIR');
|
|
expect(statusBody.status).toBe('failed');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('prefers a terminal Claude prompt-length error over auth-shaped stderr (#6979)', async () => {
|
|
await withFakeAgent(
|
|
'claude',
|
|
`
|
|
console.error(JSON.stringify({ apiKeySource: 'none' }));
|
|
console.log(JSON.stringify({
|
|
type: 'result',
|
|
subtype: 'error_during_execution',
|
|
is_error: true,
|
|
result: 'Prompt is too long',
|
|
stop_reason: null,
|
|
}));
|
|
process.exit(1);
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'claude',
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsController = new AbortController();
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
|
signal: eventsController.signal,
|
|
});
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: error');
|
|
eventsController.abort();
|
|
await waitForRunStatus(baseUrl, runId);
|
|
const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`);
|
|
const statusBody = await statusResponse.json() as {
|
|
status: string;
|
|
failureCategory: string | null;
|
|
failureDetail: string | null;
|
|
};
|
|
|
|
expect(eventsBody).toContain('AGENT_PROMPT_TOO_LARGE');
|
|
expect(eventsBody).toContain('Prompt is too long');
|
|
expect(eventsBody).toContain('"retryable":false');
|
|
expect(eventsBody).not.toContain('could not authenticate');
|
|
expect(statusBody).toMatchObject({
|
|
status: 'failed',
|
|
failureCategory: 'prompt_too_large',
|
|
failureDetail: 'prompt_too_large',
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
it('does not treat prompt-length text in an assistant payload as the terminal cause (#6979)', async () => {
|
|
await withFakeAgent(
|
|
'claude',
|
|
`
|
|
console.log(JSON.stringify({
|
|
type: 'assistant',
|
|
parent_tool_use_id: null,
|
|
message: {
|
|
id: 'msg-prompt-text',
|
|
content: [{ type: 'text', text: 'The upstream phrase was: Prompt is too long.' }],
|
|
stop_reason: 'end_turn',
|
|
},
|
|
}));
|
|
console.log(JSON.stringify({
|
|
type: 'result',
|
|
subtype: 'error_during_execution',
|
|
is_error: true,
|
|
result: 'A different terminal failure',
|
|
stop_reason: null,
|
|
}));
|
|
process.exit(1);
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'claude',
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsController = new AbortController();
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
|
signal: eventsController.signal,
|
|
});
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: error');
|
|
eventsController.abort();
|
|
await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('AGENT_EXECUTION_FAILED');
|
|
expect(eventsBody).toContain('A different terminal failure');
|
|
expect(eventsBody).not.toContain('AGENT_PROMPT_TOO_LARGE');
|
|
},
|
|
);
|
|
});
|
|
|
|
it('caps oversized inactivity overrides so Node does not fire the timer immediately', async () => {
|
|
const previous = process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS;
|
|
process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = '10000000000';
|
|
try {
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
setTimeout(() => {
|
|
console.log(JSON.stringify({ type: 'text', part: { text: 'done' } }));
|
|
process.exit(0);
|
|
}, 50);
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
expect(statusBody.status).toBe('succeeded');
|
|
},
|
|
);
|
|
} finally {
|
|
if (previous == null) {
|
|
delete process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS;
|
|
} else {
|
|
process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = previous;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('marks stalled runs failed even when the child ignores SIGTERM', async () => {
|
|
const previous = process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS;
|
|
process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = '500';
|
|
try {
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
process.on('SIGTERM', () => {});
|
|
setInterval(() => {}, 1000);
|
|
`,
|
|
async () => {
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
message: 'hello',
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
|
|
const eventsController = new AbortController();
|
|
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
|
|
signal: eventsController.signal,
|
|
});
|
|
const eventsBody = await readSseUntil(eventsResponse, 'event: error');
|
|
eventsController.abort();
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(eventsBody).toContain('Agent stalled without emitting any new output');
|
|
expect(statusBody.status).toBe('failed');
|
|
},
|
|
);
|
|
} finally {
|
|
if (previous == null) {
|
|
delete process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS;
|
|
} else {
|
|
process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = previous;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('marks submitted discovery form answers as the active turn before the transcript', async () => {
|
|
const captureDir = mkdtempSync(join(tmpdir(), 'od-form-answer-prompt-'));
|
|
tempDirs.push(captureDir);
|
|
const capturePath = join(captureDir, 'prompt.txt');
|
|
const previousCapturePath = process.env.OD_CAPTURE_PROMPT_PATH;
|
|
process.env.OD_CAPTURE_PROMPT_PATH = 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_CAPTURE_PROMPT_PATH, input, 'utf8');
|
|
console.log(JSON.stringify({ type: 'text', part: { text: 'building now' } }));
|
|
});
|
|
`,
|
|
async () => {
|
|
const formAnswers = [
|
|
'[form answers — discovery]',
|
|
'- output: Dashboard / tool UI',
|
|
'- brand: Pick a direction for me [value: pick_direction]',
|
|
].join('\n');
|
|
const transcript = [
|
|
'## user',
|
|
'Design a metrics dashboard.',
|
|
'',
|
|
'## assistant',
|
|
'<question-form id="discovery" title="Quick brief — 30 seconds"></question-form>',
|
|
'',
|
|
'## user',
|
|
formAnswers,
|
|
].join('\n');
|
|
|
|
const createResponse = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
message: transcript,
|
|
currentPrompt: formAnswers,
|
|
}),
|
|
});
|
|
expect(createResponse.status).toBe(202);
|
|
const { runId } = await createResponse.json() as { runId: string };
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
|
|
expect(statusBody.status).toBe('succeeded');
|
|
expect(existsSync(capturePath)).toBe(true);
|
|
const prompt = readFileSync(capturePath, 'utf8');
|
|
const transitionIdx = prompt.indexOf('## Latest user turn - form answers submitted');
|
|
const transcriptIdx = prompt.indexOf('## Full conversation transcript');
|
|
expect(transitionIdx).toBeGreaterThan(-1);
|
|
expect(transcriptIdx).toBeGreaterThan(transitionIdx);
|
|
expect(prompt).toContain(
|
|
'The user has answered the discovery form. Do not re-emit the answered form or repeat fields it already answered.',
|
|
);
|
|
expect(prompt).toContain(
|
|
'Apply the submitted answers and continue with RULE 2 / RULE 3 or the matching active workflow.',
|
|
);
|
|
expect(prompt).toContain(
|
|
'Only if a new, materially blocking requirement remains unresolved',
|
|
);
|
|
expect(prompt).toContain(formAnswers);
|
|
},
|
|
);
|
|
} finally {
|
|
if (previousCapturePath == null) {
|
|
delete process.env.OD_CAPTURE_PROMPT_PATH;
|
|
} else {
|
|
process.env.OD_CAPTURE_PROMPT_PATH = previousCapturePath;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('latches intent signals on the conversation so a signal-free later turn keeps the deck framework', async () => {
|
|
// Red spec for specs/current/intent-signal-cache-hotfix.md §3 case 6 (R2).
|
|
// History is trimmed on agent switch (scopeHistoryToAgent) and
|
|
// non-transcript clients never resend prior turns, so a deck signal that
|
|
// fired on turn 1 must persist on the conversation row — recomputing it
|
|
// from the scanned text alone lets it flip OFF again and re-sends the
|
|
// ~17K stable block in both directions.
|
|
const MAYBE_DECK_HEADING = '## If this brief is a slide deck / keynote / presentation';
|
|
if (!process.env.OD_DATA_DIR) {
|
|
throw new Error('OD_DATA_DIR is required for intent-signal latch tests');
|
|
}
|
|
|
|
const projectId = `proj-${randomUUID()}`;
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id: projectId, name: 'Intent signal latch fixture' }),
|
|
});
|
|
expect(createProjectResponse.ok).toBe(true);
|
|
|
|
const createConversationResponse = await fetch(
|
|
`${baseUrl}/api/projects/${projectId}/conversations`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
},
|
|
);
|
|
expect(createConversationResponse.ok).toBe(true);
|
|
const { conversation } = await createConversationResponse.json() as {
|
|
conversation: { id: string };
|
|
};
|
|
const conversationId = conversation.id;
|
|
|
|
const captureDir = mkdtempSync(join(tmpdir(), 'od-intent-latch-'));
|
|
tempDirs.push(captureDir);
|
|
const previousCapturePath = process.env.OD_CAPTURE_PROMPT_PATH;
|
|
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_CAPTURE_PROMPT_PATH, input, 'utf8');
|
|
console.log(JSON.stringify({ type: 'text', part: { text: 'ok' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
});
|
|
`,
|
|
async () => {
|
|
const runTurn = async (
|
|
turn: { message: string; currentPrompt: string },
|
|
capturePath: string,
|
|
): Promise<string> => {
|
|
process.env.OD_CAPTURE_PROMPT_PATH = capturePath;
|
|
const response = await fetch(`${baseUrl}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
conversationId,
|
|
message: turn.message,
|
|
currentPrompt: turn.currentPrompt,
|
|
}),
|
|
});
|
|
expect(response.status).toBe(202);
|
|
const { runId } = await response.json() as { runId: string };
|
|
const statusBody = await waitForRunStatus(baseUrl, runId);
|
|
expect(statusBody.status).toBe('succeeded');
|
|
return readFileSync(capturePath, 'utf8');
|
|
};
|
|
|
|
// Turn 1 mentions a deck in the user's own words → framework present.
|
|
const deckBrief = '帮我做一份路演材料,先出内容大纲';
|
|
const turn1Prompt = await runTurn(
|
|
{ message: `## user\n${deckBrief}`, currentPrompt: deckBrief },
|
|
join(captureDir, 'turn1.txt'),
|
|
);
|
|
expect(turn1Prompt).toContain(MAYBE_DECK_HEADING);
|
|
|
|
// Turn 2 carries no deck vocabulary and a trimmed transcript
|
|
// (agent-switch trim / non-transcript client): the latched
|
|
// conversation signal must keep the framework present.
|
|
const followUp = '把主色调调亮一点';
|
|
const turn2Prompt = await runTurn(
|
|
{ message: `## user\n${followUp}`, currentPrompt: followUp },
|
|
join(captureDir, 'turn2.txt'),
|
|
);
|
|
expect(turn2Prompt).toContain(MAYBE_DECK_HEADING);
|
|
|
|
// The latch is persisted on the conversation row.
|
|
const dbFile = resolve(process.env.OD_DATA_DIR as string, 'app.sqlite');
|
|
const sqlite = new Database(dbFile, { readonly: true });
|
|
try {
|
|
const row = sqlite
|
|
.prepare(`SELECT intent_signals_json AS intentSignalsJson FROM conversations WHERE id = ?`)
|
|
.get(conversationId) as { intentSignalsJson: string | null } | undefined;
|
|
expect(row?.intentSignalsJson).toBeTruthy();
|
|
expect(JSON.parse(row?.intentSignalsJson ?? '{}')).toMatchObject({ deck: true });
|
|
} finally {
|
|
sqlite.close();
|
|
}
|
|
},
|
|
);
|
|
} finally {
|
|
if (previousCapturePath == null) {
|
|
delete process.env.OD_CAPTURE_PROMPT_PATH;
|
|
} else {
|
|
process.env.OD_CAPTURE_PROMPT_PATH = previousCapturePath;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('uses a project design system in sandboxed chat runs without an explicit run designSystemId', async () => {
|
|
const projectId = `project-ds-${randomUUID()}`;
|
|
const projectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Project DS fixture',
|
|
designSystemId: 'default',
|
|
skipDiscoveryBrief: true,
|
|
}),
|
|
});
|
|
expect(projectResponse.ok).toBe(true);
|
|
|
|
const conversationId = `conv-${randomUUID()}`;
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
let prompt = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => {
|
|
prompt += chunk;
|
|
});
|
|
process.stdin.on('end', () => {
|
|
const checks = [
|
|
prompt.includes('## Active design system') ? 'has-active-design-system' : 'missing-active-design-system',
|
|
prompt.includes('Treat the following DESIGN.md as authoritative') ? 'has-design-system-contract' : 'missing-design-system-contract',
|
|
];
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
conversationId,
|
|
message: 'draft a branded artifact',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('has-active-design-system');
|
|
expect(body).toContain('has-design-system-contract');
|
|
expect(body).not.toContain('missing-active-design-system');
|
|
expect(body).not.toContain('missing-design-system-contract');
|
|
|
|
const runsResponse = await fetch(
|
|
`${baseUrl}/api/runs?conversationId=${encodeURIComponent(conversationId)}`,
|
|
);
|
|
const runsBody = await runsResponse.json() as {
|
|
runs: Array<{
|
|
designSystemId: string | null;
|
|
designSystemRequestedId: string | null;
|
|
designSystemSelectionSource: string | null;
|
|
designSystemDigest: string | null;
|
|
promptCache?: { hit: boolean; missReason: string | null };
|
|
}>;
|
|
};
|
|
expect(runsBody.runs).toHaveLength(1);
|
|
expect(runsBody.runs[0]).toMatchObject({
|
|
designSystemId: 'default',
|
|
designSystemRequestedId: 'default',
|
|
designSystemSelectionSource: 'project',
|
|
promptCache: { hit: false, missReason: 'new-session' },
|
|
});
|
|
expect(runsBody.runs[0]?.designSystemDigest).toMatch(/^[a-f0-9]{64}$/);
|
|
},
|
|
);
|
|
});
|
|
|
|
it('does not compose another member Personal design system from a persisted project id', async () => {
|
|
if (!process.env.OD_DATA_DIR) {
|
|
throw new Error('OD_DATA_DIR is required for Workspace design-system prompt tests');
|
|
}
|
|
const workspaceFixture =
|
|
await createPersonalWorkspaceBoundProjectFixture('Foreign Personal DS prompt fixture');
|
|
const workspaceId = workspaceFixture.headers['x-od-workspace-id'];
|
|
const foreignMemberId = `foreign-member-${randomUUID()}`;
|
|
const dirId = `foreign-prompt-${randomUUID()}`;
|
|
const designSystemId = `user:${dirId}`;
|
|
const secretMarker = `FOREIGN_PERSONAL_DS_${randomUUID()}`;
|
|
const designSystemDir = resolve(process.env.OD_DATA_DIR, 'design-systems', dirId);
|
|
await fsp.mkdir(designSystemDir, { recursive: true });
|
|
await fsp.writeFile(
|
|
resolve(designSystemDir, 'DESIGN.md'),
|
|
`# Foreign Personal design system\n\n${secretMarker}\n`,
|
|
'utf8',
|
|
);
|
|
await fsp.writeFile(
|
|
resolve(designSystemDir, 'metadata.json'),
|
|
`${JSON.stringify({
|
|
title: 'Foreign Personal design system',
|
|
status: 'published',
|
|
workspaceId,
|
|
}, null, 2)}\n`,
|
|
'utf8',
|
|
);
|
|
|
|
const sqlite = new Database(resolve(process.env.OD_DATA_DIR, 'app.sqlite'));
|
|
try {
|
|
ensureWorkspaceResource(
|
|
sqlite as never,
|
|
'design_system',
|
|
workspaceId,
|
|
designSystemId,
|
|
{
|
|
visibility: 'personal',
|
|
resourceState: 'active',
|
|
createdByWorkspaceMemberId: foreignMemberId,
|
|
updatedByWorkspaceMemberId: foreignMemberId,
|
|
},
|
|
);
|
|
sqlite.prepare('UPDATE projects SET design_system_id = ? WHERE id = ?')
|
|
.run(designSystemId, workspaceFixture.projectId);
|
|
} finally {
|
|
sqlite.close();
|
|
}
|
|
|
|
try {
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
let prompt = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => { prompt += chunk; });
|
|
process.stdin.on('end', () => {
|
|
const result = prompt.includes(${JSON.stringify(secretMarker)})
|
|
? 'foreign-personal-design-system-leaked'
|
|
: 'foreign-personal-design-system-blocked';
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: result } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...workspaceFixture.headers,
|
|
},
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId: workspaceFixture.projectId,
|
|
message: 'draft without reading another member private brand',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('foreign-personal-design-system-blocked');
|
|
expect(body).not.toContain('foreign-personal-design-system-leaked');
|
|
},
|
|
);
|
|
} finally {
|
|
await fsp.rm(designSystemDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('composes the project creator Personal design system', async () => {
|
|
if (!process.env.OD_DATA_DIR) {
|
|
throw new Error('OD_DATA_DIR is required for Workspace design-system prompt tests');
|
|
}
|
|
const workspaceFixture =
|
|
await createPersonalWorkspaceBoundProjectFixture('Own Personal DS prompt fixture');
|
|
const workspaceId = workspaceFixture.headers['x-od-workspace-id'];
|
|
const workspaceMemberId = workspaceFixture.headers['x-od-workspace-member-id'];
|
|
const dirId = `own-personal-prompt-${randomUUID()}`;
|
|
const designSystemId = `user:${dirId}`;
|
|
const personalMarker = `OWN_PERSONAL_DS_${randomUUID()}`;
|
|
const designSystemDir = resolve(process.env.OD_DATA_DIR, 'design-systems', dirId);
|
|
await fsp.mkdir(designSystemDir, { recursive: true });
|
|
await fsp.writeFile(
|
|
resolve(designSystemDir, 'DESIGN.md'),
|
|
`# Own Personal design system\n\n${personalMarker}\n`,
|
|
'utf8',
|
|
);
|
|
await fsp.writeFile(
|
|
resolve(designSystemDir, 'metadata.json'),
|
|
`${JSON.stringify({
|
|
title: 'Own Personal design system',
|
|
status: 'published',
|
|
workspaceId,
|
|
}, null, 2)}\n`,
|
|
'utf8',
|
|
);
|
|
|
|
const sqlite = new Database(resolve(process.env.OD_DATA_DIR, 'app.sqlite'));
|
|
try {
|
|
ensureWorkspaceResource(
|
|
sqlite as never,
|
|
'design_system',
|
|
workspaceId,
|
|
designSystemId,
|
|
{
|
|
visibility: 'personal',
|
|
resourceState: 'active',
|
|
createdByWorkspaceMemberId: workspaceMemberId,
|
|
updatedByWorkspaceMemberId: workspaceMemberId,
|
|
},
|
|
);
|
|
sqlite.prepare('UPDATE projects SET design_system_id = ? WHERE id = ?')
|
|
.run(designSystemId, workspaceFixture.projectId);
|
|
} finally {
|
|
sqlite.close();
|
|
}
|
|
|
|
try {
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
let prompt = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => { prompt += chunk; });
|
|
process.stdin.on('end', () => {
|
|
const result = prompt.includes(${JSON.stringify(personalMarker)})
|
|
? 'own-personal-design-system-visible'
|
|
: 'own-personal-design-system-missing';
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: result } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...workspaceFixture.headers,
|
|
},
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId: workspaceFixture.projectId,
|
|
message: 'draft with my Personal brand',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('own-personal-design-system-visible');
|
|
expect(body).not.toContain('own-personal-design-system-missing');
|
|
},
|
|
);
|
|
} finally {
|
|
await fsp.rm(designSystemDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('composes a Team design system without touching same-slug Personal or foreign projects', async () => {
|
|
if (!process.env.OD_DATA_DIR) {
|
|
throw new Error('OD_DATA_DIR is required for Workspace design-system prompt tests');
|
|
}
|
|
const projectId = `proj-${randomUUID()}`;
|
|
const workspaceId = `team-ws-${randomUUID()}`;
|
|
const workspaceMemberId = `team-member-${randomUUID()}`;
|
|
const personalBackingProjectId = `personal-ds-project-${randomUUID()}`;
|
|
const foreignProjectId = `foreign-project-${randomUUID()}`;
|
|
for (const [id, name] of [
|
|
[projectId, 'Team DS prompt fixture'],
|
|
[personalBackingProjectId, 'Personal DS backing project'],
|
|
[foreignProjectId, 'Foreign project'],
|
|
]) {
|
|
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ id, name }),
|
|
});
|
|
expect(createProjectResponse.ok).toBe(true);
|
|
}
|
|
|
|
const dirId = `team-prompt-${randomUUID()}`;
|
|
const designSystemId = `user:${dirId}`;
|
|
const teamMarker = `TEAM_DS_${randomUUID()}`;
|
|
const teamTokensMarker = `TEAM_TOKENS_${randomUUID()}`;
|
|
const globalMarker = `GLOBAL_DS_${randomUUID()}`;
|
|
const globalTokensMarker = `GLOBAL_TOKENS_${randomUUID()}`;
|
|
const foreignProjectMarker = `FOREIGN_PROJECT_DS_${randomUUID()}`;
|
|
const designSystemsRoot = resolve(process.env.OD_DATA_DIR, 'design-systems');
|
|
const designSystemDir = resolve(
|
|
teamResourceWorkspaceRoot(designSystemsRoot, workspaceId),
|
|
dirId,
|
|
);
|
|
const globalDesignSystemDir = resolve(designSystemsRoot, dirId);
|
|
await fsp.mkdir(designSystemDir, { recursive: true });
|
|
await fsp.mkdir(globalDesignSystemDir, { recursive: true });
|
|
await fsp.writeFile(
|
|
resolve(designSystemDir, 'DESIGN.md'),
|
|
`# Team design system\n\n${teamMarker}\n`,
|
|
'utf8',
|
|
);
|
|
await fsp.writeFile(
|
|
resolve(designSystemDir, 'tokens.css'),
|
|
`:root { --team-marker: ${teamTokensMarker}; }\n`,
|
|
'utf8',
|
|
);
|
|
await fsp.writeFile(
|
|
resolve(designSystemDir, 'metadata.json'),
|
|
`${JSON.stringify({
|
|
title: 'Team design system',
|
|
status: 'published',
|
|
teamSynced: true,
|
|
workspaceId,
|
|
projectId: foreignProjectId,
|
|
}, null, 2)}\n`,
|
|
'utf8',
|
|
);
|
|
await fsp.writeFile(
|
|
resolve(globalDesignSystemDir, 'DESIGN.md'),
|
|
`# Global design system\n\n${globalMarker}\n`,
|
|
'utf8',
|
|
);
|
|
await fsp.writeFile(
|
|
resolve(globalDesignSystemDir, 'tokens.css'),
|
|
`:root { --global-marker: ${globalTokensMarker}; }\n`,
|
|
'utf8',
|
|
);
|
|
await fsp.writeFile(
|
|
resolve(globalDesignSystemDir, 'metadata.json'),
|
|
`${JSON.stringify({
|
|
title: 'Global design system',
|
|
status: 'published',
|
|
projectId: personalBackingProjectId,
|
|
}, null, 2)}\n`,
|
|
'utf8',
|
|
);
|
|
|
|
const projectsRoot = resolve(process.env.OD_DATA_DIR, 'projects');
|
|
await Promise.all([
|
|
fsp.mkdir(resolve(projectsRoot, personalBackingProjectId), { recursive: true }),
|
|
fsp.mkdir(resolve(projectsRoot, foreignProjectId), { recursive: true }),
|
|
]);
|
|
await fsp.writeFile(
|
|
resolve(projectsRoot, personalBackingProjectId, 'DESIGN.md'),
|
|
'# Personal backing project\n\nMust remain untouched by a Team run.\n',
|
|
'utf8',
|
|
);
|
|
await fsp.writeFile(
|
|
resolve(projectsRoot, foreignProjectId, 'DESIGN.md'),
|
|
`# Foreign project\n\n${foreignProjectMarker}\n`,
|
|
'utf8',
|
|
);
|
|
|
|
const sqlite = new Database(resolve(process.env.OD_DATA_DIR, 'app.sqlite'));
|
|
let personalBackingProjectBefore: ReturnType<typeof getProject>;
|
|
let projectCountBefore = 0;
|
|
try {
|
|
ensureWorkspaceProject(sqlite as never, {
|
|
projectId,
|
|
workspaceId,
|
|
visibility: 'team',
|
|
createdByWorkspaceMemberId: workspaceMemberId,
|
|
});
|
|
ensureWorkspaceProject(sqlite as never, {
|
|
projectId: personalBackingProjectId,
|
|
workspaceId: `personal-ws-${randomUUID()}`,
|
|
visibility: 'personal',
|
|
createdByWorkspaceMemberId: `personal-member-${randomUUID()}`,
|
|
});
|
|
ensureWorkspaceProject(sqlite as never, {
|
|
projectId: foreignProjectId,
|
|
workspaceId: `foreign-ws-${randomUUID()}`,
|
|
visibility: 'personal',
|
|
createdByWorkspaceMemberId: `foreign-member-${randomUUID()}`,
|
|
});
|
|
ensureWorkspaceResource(
|
|
sqlite as never,
|
|
'design_system',
|
|
workspaceId,
|
|
workspaceTeamDesignSystemBindingResourceId(workspaceId, designSystemId),
|
|
{
|
|
visibility: 'team',
|
|
resourceState: 'active',
|
|
createdByWorkspaceMemberId: workspaceMemberId,
|
|
updatedByWorkspaceMemberId: workspaceMemberId,
|
|
},
|
|
);
|
|
sqlite.prepare('UPDATE projects SET design_system_id = ? WHERE id = ?')
|
|
.run(designSystemId, projectId);
|
|
personalBackingProjectBefore = getProject(
|
|
sqlite as never,
|
|
personalBackingProjectId,
|
|
);
|
|
projectCountBefore = (
|
|
sqlite.prepare('SELECT COUNT(*) AS count FROM projects').get() as { count: number }
|
|
).count;
|
|
} finally {
|
|
sqlite.close();
|
|
}
|
|
|
|
try {
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
let prompt = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => { prompt += chunk; });
|
|
process.stdin.on('end', () => {
|
|
const result = prompt.includes(${JSON.stringify(teamMarker)})
|
|
&& prompt.includes(${JSON.stringify(teamTokensMarker)})
|
|
&& !prompt.includes(${JSON.stringify(globalMarker)})
|
|
&& !prompt.includes(${JSON.stringify(globalTokensMarker)})
|
|
&& !prompt.includes(${JSON.stringify(foreignProjectMarker)})
|
|
? 'team-design-system-visible'
|
|
: 'team-design-system-missing';
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: result } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-od-workspace-id': workspaceId,
|
|
'x-od-workspace-member-id': workspaceMemberId,
|
|
'x-od-workspace-type': 'team',
|
|
'x-od-workspace-role': 'owner',
|
|
},
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
message: 'draft with the Team brand',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
const verificationDb = new Database(
|
|
resolve(process.env.OD_DATA_DIR as string, 'app.sqlite'),
|
|
);
|
|
try {
|
|
expect.soft(
|
|
getProject(verificationDb as never, personalBackingProjectId),
|
|
).toEqual(personalBackingProjectBefore);
|
|
expect.soft(
|
|
(verificationDb.prepare('SELECT COUNT(*) AS count FROM projects').get() as {
|
|
count: number;
|
|
}).count,
|
|
).toBe(projectCountBefore);
|
|
} finally {
|
|
verificationDb.close();
|
|
}
|
|
|
|
expect.soft(response.ok).toBe(true);
|
|
expect.soft(body).toContain('team-design-system-visible');
|
|
expect.soft(body).not.toContain('team-design-system-missing');
|
|
},
|
|
);
|
|
} finally {
|
|
await fsp.rm(designSystemDir, { recursive: true, force: true });
|
|
await fsp.rm(globalDesignSystemDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('keeps requested design systems separate from missing injected design systems', async () => {
|
|
const missingDesignSystemId = `missing-ds-${randomUUID()}`;
|
|
const projectId = `project-missing-ds-${randomUUID()}`;
|
|
const projectResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Missing project DS fixture',
|
|
skipDiscoveryBrief: true,
|
|
}),
|
|
});
|
|
expect(projectResponse.ok).toBe(true);
|
|
|
|
const conversationId = `conv-${randomUUID()}`;
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
let prompt = '';
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (chunk) => {
|
|
prompt += chunk;
|
|
});
|
|
process.stdin.on('end', () => {
|
|
const checks = [
|
|
prompt.includes('## Active design system') ? 'has-active-design-system' : 'missing-active-design-system',
|
|
prompt.includes('Treat the following DESIGN.md as authoritative') ? 'has-design-system-contract' : 'missing-design-system-contract',
|
|
];
|
|
console.log(JSON.stringify({ type: 'step_start' }));
|
|
console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
|
|
process.exit(0);
|
|
});
|
|
`,
|
|
async () => {
|
|
const response = await fetch(`${baseUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
agentId: 'opencode',
|
|
projectId,
|
|
conversationId,
|
|
designSystemId: missingDesignSystemId,
|
|
message: 'draft a branded artifact',
|
|
}),
|
|
});
|
|
const body = await response.text();
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(body).toContain('missing-design-system-contract');
|
|
expect(body).not.toContain('has-design-system-contract');
|
|
|
|
const runsResponse = await fetch(
|
|
`${baseUrl}/api/runs?conversationId=${encodeURIComponent(conversationId)}`,
|
|
);
|
|
const runsBody = await runsResponse.json() as {
|
|
runs: Array<{
|
|
designSystemId: string | null;
|
|
designSystemRequestedId: string | null;
|
|
designSystemSelectionSource: string | null;
|
|
designSystemDigest: string | null;
|
|
}>;
|
|
};
|
|
expect(runsBody.runs).toHaveLength(1);
|
|
expect(runsBody.runs[0]).toMatchObject({
|
|
designSystemId: null,
|
|
designSystemRequestedId: missingDesignSystemId,
|
|
designSystemSelectionSource: 'none',
|
|
designSystemDigest: null,
|
|
});
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('daemon run creation during shutdown', () => {
|
|
it('rejects new run creation while shutdown cleanup is still in flight', async () => {
|
|
const previousGrace = process.env.OD_CHAT_RUN_SHUTDOWN_GRACE_MS;
|
|
process.env.OD_CHAT_RUN_SHUTDOWN_GRACE_MS = '100';
|
|
const started = await startServer({ port: 0, returnServer: true }) as {
|
|
url: string;
|
|
server: http.Server;
|
|
shutdown: () => Promise<void>;
|
|
};
|
|
try {
|
|
await withFakeAgent(
|
|
'opencode',
|
|
`
|
|
process.on('SIGTERM', () => {});
|
|
setInterval(() => {}, 1000);
|
|
`,
|
|
async () => {
|
|
const activeResponse = await fetch(`${started.url}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ agentId: 'opencode', message: 'hello' }),
|
|
});
|
|
expect(activeResponse.status).toBe(202);
|
|
const { runId } = await activeResponse.json() as { runId: string };
|
|
await waitForRunStatus(started.url, runId, (status) => status === 'running');
|
|
|
|
const shutdownPromise = started.shutdown();
|
|
|
|
const runResponse = await fetch(`${started.url}/api/runs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ agentId: 'opencode', message: 'late run' }),
|
|
});
|
|
const chatResponse = await fetch(`${started.url}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ agentId: 'opencode', message: 'late chat' }),
|
|
});
|
|
|
|
expect(runResponse.status).toBe(503);
|
|
expect(chatResponse.status).toBe(503);
|
|
await shutdownPromise;
|
|
},
|
|
);
|
|
} finally {
|
|
if (previousGrace == null) {
|
|
delete process.env.OD_CHAT_RUN_SHUTDOWN_GRACE_MS;
|
|
} else {
|
|
process.env.OD_CHAT_RUN_SHUTDOWN_GRACE_MS = previousGrace;
|
|
}
|
|
await new Promise<void>((resolve) => started.server.close(() => resolve()));
|
|
}
|
|
});
|
|
});
|
|
|
|
async function readSseUntil(response: Response, marker: string): Promise<string> {
|
|
const reader = response.body!.getReader();
|
|
const decoder = new TextDecoder();
|
|
let body = '';
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
const { done, value } = await reader.read();
|
|
if (done) return body;
|
|
body += decoder.decode(value, { stream: true });
|
|
if (body.includes(marker)) return body;
|
|
}
|
|
return body;
|
|
}
|
|
|
|
async function waitForRunStatus(
|
|
baseUrl: string,
|
|
runId: string,
|
|
done: (status: string) => boolean = (status) => status !== 'queued' && status !== 'running',
|
|
): Promise<{ status: string }> {
|
|
let lastStatus = 'unknown';
|
|
for (let attempt = 0; attempt < 500; attempt += 1) {
|
|
const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`);
|
|
const statusBody = await statusResponse.json() as { status: string };
|
|
lastStatus = statusBody.status;
|
|
if (done(statusBody.status)) return statusBody;
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
throw new Error(`run did not reach expected status; last status: ${lastStatus}`);
|
|
}
|
|
|
|
describe('chat prompt helpers', () => {
|
|
it('appends a final prompt override after the client system prompt and removes earlier duplicates', () => {
|
|
const override = '## Final runtime policy\nUse the shared media dispatcher.';
|
|
const clientMediaContract =
|
|
'## Media generation contract\nclient contract wins unless a later override says otherwise';
|
|
|
|
const prompt = composeLiveInstructionPrompt({
|
|
daemonSystemPrompt: `daemon prompt\n${override}`,
|
|
runtimeToolPrompt: 'runtime tools',
|
|
clientSystemPrompt: clientMediaContract,
|
|
finalPromptOverride: override,
|
|
});
|
|
|
|
const clientIdx = prompt.indexOf(clientMediaContract);
|
|
const overrideIdx = prompt.indexOf('## Final runtime policy');
|
|
expect(clientIdx).toBeGreaterThan(-1);
|
|
expect(overrideIdx).toBeGreaterThan(clientIdx);
|
|
expect(prompt.match(/## Final runtime policy/g)).toHaveLength(1);
|
|
});
|
|
|
|
it('defaults enabled research without an explicit query to the current message', () => {
|
|
const prompt = resolveResearchCommandContract(
|
|
{ enabled: true },
|
|
'EV market 2025 trends',
|
|
);
|
|
|
|
expect(prompt).toContain('Canonical query for this run:');
|
|
expect(prompt).toContain('EV market 2025 trends');
|
|
expect(prompt).toContain('the first tool action must be the research command');
|
|
|
|
const explicit = resolveResearchCommandContract(
|
|
{ enabled: true, query: 'explicit canonical query' },
|
|
'legacy full transcript must not replace it',
|
|
);
|
|
expect(explicit).toContain('explicit canonical query');
|
|
expect(explicit).not.toContain('legacy full transcript must not replace it');
|
|
});
|
|
|
|
it('resolves design-system selection precedence for run prompt composition', () => {
|
|
expect(resolveEffectiveDesignSystemSelection({
|
|
requestDesignSystemId: 'request-ds',
|
|
pluginDesignSystemId: 'plugin-ds',
|
|
projectDesignSystemId: 'project-ds',
|
|
appDefaultDesignSystemId: 'default-ds',
|
|
})).toEqual({ id: 'request-ds', source: 'request' });
|
|
|
|
expect(resolveEffectiveDesignSystemSelection({
|
|
pluginDesignSystemId: 'plugin-ds',
|
|
projectDesignSystemId: 'project-ds',
|
|
appDefaultDesignSystemId: 'default-ds',
|
|
})).toEqual({ id: 'plugin-ds', source: 'plugin' });
|
|
|
|
expect(resolveEffectiveDesignSystemSelection({
|
|
projectDesignSystemId: 'project-ds',
|
|
appDefaultDesignSystemId: 'default-ds',
|
|
})).toEqual({ id: 'project-ds', source: 'project' });
|
|
|
|
expect(resolveEffectiveDesignSystemSelection({
|
|
requestDesignSystemId: null,
|
|
projectDesignSystemId: 'project-ds',
|
|
disabledDesignSystemIds: ['project-ds'],
|
|
allowAppDefault: false,
|
|
})).toEqual({ id: null, source: 'none' });
|
|
|
|
expect(resolveEffectiveDesignSystemSelection({
|
|
appDefaultDesignSystemId: 'default-ds',
|
|
})).toEqual({ id: 'default-ds', source: 'app-default' });
|
|
|
|
expect(resolveEffectiveDesignSystemSelection({
|
|
appDefaultDesignSystemId: 'default-ds',
|
|
allowAppDefault: false,
|
|
})).toEqual({ id: null, source: 'none' });
|
|
});
|
|
|
|
it('extracts the primary design-system id from a plugin snapshot', () => {
|
|
expect(designSystemIdFromPluginSnapshot({
|
|
resolvedContext: {
|
|
items: [
|
|
{ kind: 'skill', id: 'landing' },
|
|
{ kind: 'design-system', id: 'secondary' },
|
|
{ kind: 'design-system', id: 'primary', primary: true },
|
|
],
|
|
},
|
|
})).toBe('primary');
|
|
|
|
expect(designSystemIdFromPluginSnapshot({
|
|
resolvedContext: {
|
|
items: [
|
|
{ kind: 'design-system', id: 'fallback' },
|
|
],
|
|
},
|
|
})).toBe('fallback');
|
|
|
|
expect(designSystemIdFromPluginSnapshot({ resolvedContext: { items: [] } })).toBeNull();
|
|
});
|
|
|
|
it('describes stable prompt cache hits and miss reasons', () => {
|
|
expect(describeStablePromptCache({
|
|
isResuming: false,
|
|
storedStablePromptHash: null,
|
|
currentStableHash: 'hash-a',
|
|
})).toEqual({
|
|
stablePromptHash: 'hash-a',
|
|
hit: false,
|
|
missReason: 'new-session',
|
|
changedSections: null,
|
|
});
|
|
|
|
expect(describeStablePromptCache({
|
|
isResuming: true,
|
|
storedStablePromptHash: 'hash-a',
|
|
currentStableHash: 'hash-a',
|
|
})).toEqual({
|
|
stablePromptHash: 'hash-a',
|
|
hit: true,
|
|
missReason: null,
|
|
changedSections: null,
|
|
});
|
|
|
|
expect(describeStablePromptCache({
|
|
isResuming: true,
|
|
storedStablePromptHash: 'hash-a',
|
|
currentStableHash: 'hash-b',
|
|
})).toEqual({
|
|
stablePromptHash: 'hash-b',
|
|
hit: false,
|
|
missReason: 'stable-prompt-changed',
|
|
// No section map supplied: report no attribution rather than invent one.
|
|
changedSections: null,
|
|
});
|
|
});
|
|
|
|
it('names the drifted sections when a section map is supplied', () => {
|
|
expect(describeStablePromptCache({
|
|
isResuming: true,
|
|
storedStablePromptHash: 'hash-a',
|
|
currentStableHash: 'hash-b',
|
|
storedStableSections: { memory: 'm1', skill: 's1' },
|
|
currentStableSections: { memory: 'm2', skill: 's1' },
|
|
})).toEqual({
|
|
stablePromptHash: 'hash-b',
|
|
hit: false,
|
|
missReason: 'stable-prompt-changed',
|
|
changedSections: ['memory'],
|
|
});
|
|
});
|
|
|
|
it('reports unattributed drift when the prefix moved but no tracked section did', () => {
|
|
// The hash is the source of truth, so this is a real miss; an empty list
|
|
// would read as a drift with no cause instead of the coverage gap it is.
|
|
expect(describeStablePromptCache({
|
|
isResuming: true,
|
|
storedStablePromptHash: 'hash-a',
|
|
currentStableHash: 'hash-b',
|
|
storedStableSections: { memory: 'm1' },
|
|
currentStableSections: { memory: 'm1' },
|
|
})).toMatchObject({
|
|
missReason: 'stable-prompt-changed',
|
|
changedSections: ['unattributed'],
|
|
});
|
|
});
|
|
|
|
it('does not attribute sections for a missing stored hash', () => {
|
|
// A legacy/reseeded row has no baseline to diff, so every section would
|
|
// read as "changed" and drown the signal real drift carries.
|
|
expect(describeStablePromptCache({
|
|
isResuming: true,
|
|
storedStablePromptHash: null,
|
|
currentStableHash: 'hash-b',
|
|
storedStableSections: null,
|
|
currentStableSections: { memory: 'm1' },
|
|
})).toEqual({
|
|
stablePromptHash: 'hash-b',
|
|
hit: false,
|
|
missReason: 'missing-stored-hash',
|
|
changedSections: null,
|
|
});
|
|
});
|
|
|
|
it('does not grant media-specific extra directories to Codex', () => {
|
|
const dirs = resolveChatExtraAllowedDirs({
|
|
agentId: ' CoDeX ',
|
|
skillsDir: '/repo/skills',
|
|
designSystemsDir: '/repo/design-systems',
|
|
linkedDirs: ['/linked/reference'],
|
|
existsSync: () => true,
|
|
});
|
|
|
|
expect(dirs).toEqual([]);
|
|
});
|
|
|
|
it('keeps resource and linked dirs for non-Codex agents', () => {
|
|
const existingDirs = new Set([
|
|
'/repo/skills',
|
|
'/repo/design-systems',
|
|
'/linked/reference',
|
|
]);
|
|
const dirs = resolveChatExtraAllowedDirs({
|
|
agentId: 'claude',
|
|
skillsDir: '/repo/skills',
|
|
designSystemsDir: '/repo/design-systems',
|
|
linkedDirs: ['/linked/reference'],
|
|
existsSync: (dir: string) => existingDirs.has(dir),
|
|
});
|
|
|
|
expect(dirs).toEqual([
|
|
'/repo/skills',
|
|
'/repo/design-systems',
|
|
'/linked/reference',
|
|
]);
|
|
});
|
|
|
|
});
|