Skip to content

Commit 3083388

Browse files
authored
Add launch review regression coverage (#3300)
* Add main launch review E2E coverage * Add daemon launch review regression coverage * Tighten plugin authoring completion regressions * fix(web): preserve deck slide on preview switches Generated-By: looper 0.9.2 (runner=fixer, agent=codex) * Add project detail regression coverage
1 parent 938721a commit 3083388

11 files changed

Lines changed: 890 additions & 34 deletions

apps/daemon/tests/chat-route.test.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type http from 'node:http';
2+
import Database from 'better-sqlite3';
23
import { randomUUID } from 'node:crypto';
34
import {
45
chmodSync,
@@ -27,6 +28,7 @@ import {
2728
import { skillCwdAliasSegment } from '../src/cwd-aliases.js';
2829
import { getAgentDef } from '../src/agents.js';
2930
import { readMemoryConfig, writeMemoryConfig } from '../src/memory.js';
31+
import { upsertMessage } from '../src/db.js';
3032
import { renderCodexImagegenOverride } from '../src/prompts/system.js';
3133

3234
const FAKE_VELA_FIXTURE = resolve(process.cwd(), 'tests', 'fixtures', 'fake-vela.mjs');
@@ -216,6 +218,87 @@ process.exit(0);
216218
);
217219
});
218220

221+
222+
it('reuses an existing assistant message row instead of creating a duplicate when assistantMessageId is supplied', async () => {
223+
if (!process.env.OD_DATA_DIR) {
224+
throw new Error('OD_DATA_DIR is required for assistant message reuse tests');
225+
}
226+
const projectId = `proj-${randomUUID()}`;
227+
const assistantMessageId = `assistant-${randomUUID()}`;
228+
229+
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
230+
method: 'POST',
231+
headers: { 'Content-Type': 'application/json' },
232+
body: JSON.stringify({ id: projectId, name: 'Assistant row reuse fixture' }),
233+
});
234+
expect(createProjectResponse.ok).toBe(true);
235+
236+
const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
237+
expect(conversationsResponse.ok).toBe(true);
238+
const conversationsBody = await conversationsResponse.json() as {
239+
conversations: Array<{ id: string }>;
240+
};
241+
const conversationId = conversationsBody.conversations[0]?.id;
242+
expect(conversationId).toBeTruthy();
243+
244+
const dbFile = resolve(process.env.OD_DATA_DIR, 'app.sqlite');
245+
const sqlite = new Database(dbFile);
246+
try {
247+
upsertMessage(sqlite as never, conversationId!, {
248+
id: assistantMessageId,
249+
role: 'assistant',
250+
content: '',
251+
runStatus: 'failed',
252+
startedAt: Date.now() - 1_000,
253+
endedAt: Date.now() - 500,
254+
});
255+
} finally {
256+
sqlite.close();
257+
}
258+
259+
await withFakeAgent(
260+
'opencode',
261+
`
262+
process.stdin.resume();
263+
process.stdin.on('end', () => {
264+
console.log(JSON.stringify({ type: 'step_start' }));
265+
console.log(JSON.stringify({ type: 'text', part: { text: 'reused-assistant-row-ok' } }));
266+
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
267+
process.exit(0);
268+
});
269+
`,
270+
async () => {
271+
const response = await fetch(`${baseUrl}/api/chat`, {
272+
method: 'POST',
273+
headers: { 'Content-Type': 'application/json' },
274+
body: JSON.stringify({
275+
agentId: 'opencode',
276+
projectId,
277+
conversationId,
278+
assistantMessageId,
279+
message: 'retry this turn',
280+
}),
281+
});
282+
const body = await response.text();
283+
expect(response.ok).toBe(true);
284+
expect(body).toContain('reused-assistant-row-ok');
285+
},
286+
);
287+
288+
const verifyDb = new Database(dbFile, { readonly: true });
289+
try {
290+
const rows = verifyDb
291+
.prepare(`SELECT id, content, run_id FROM messages WHERE conversation_id = ? AND role = 'assistant'`)
292+
.all(conversationId) as Array<{ id: string; content: string; run_id: string | null }>;
293+
expect(rows.filter((row) => row.id === assistantMessageId)).toHaveLength(1);
294+
expect(rows.some((row) => row.id !== assistantMessageId && row.content.includes('reused-assistant-row-ok'))).toBe(false);
295+
const reused = rows.find((row) => row.id === assistantMessageId);
296+
expect(reused?.content).toContain('reused-assistant-row-ok');
297+
} finally {
298+
verifyDb.close();
299+
}
300+
});
301+
219302
it('rewrites the OpenCode scanner overflow into a generic retry message', async () => {
220303
const conversationId = `conv-${randomUUID()}`;
221304

@@ -311,6 +394,76 @@ child.on('exit', (code, signal) => {
311394
}
312395
});
313396

397+
it('allows plugin authoring to succeed when the requested generated-plugin artifacts exist before close', async () => {
398+
const projectId = `proj-plugin-authoring-success-${randomUUID()}`;
399+
400+
const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
401+
method: 'POST',
402+
headers: { 'Content-Type': 'application/json' },
403+
body: JSON.stringify({
404+
id: projectId,
405+
name: 'Plugin authoring artifact success fixture',
406+
skillId: null,
407+
designSystemId: null,
408+
}),
409+
});
410+
expect(createProjectResponse.status).toBe(200);
411+
const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
412+
expect(conversationsResponse.status).toBe(200);
413+
const conversationsBody = await conversationsResponse.json() as {
414+
conversations: Array<{ id: string }>;
415+
};
416+
const conversationId = conversationsBody.conversations[0]?.id;
417+
expect(conversationId).toBeTruthy();
418+
419+
await withFakeAgent(
420+
'opencode',
421+
`
422+
const fs = require('node:fs');
423+
const path = require('node:path');
424+
process.stdin.resume();
425+
process.stdin.on('end', () => {
426+
const pluginDir = path.join(process.cwd(), 'generated-plugin');
427+
fs.mkdirSync(pluginDir, { recursive: true });
428+
fs.writeFileSync(path.join(pluginDir, 'open-design.json'), JSON.stringify({ name: 'generated-plugin' }, null, 2));
429+
fs.writeFileSync(path.join(pluginDir, 'SKILL.md'), '# Generated plugin\\n');
430+
console.log(JSON.stringify({ type: 'step_start' }));
431+
console.log(JSON.stringify({ type: 'text', part: { text: '我来帮你创建一个通用的 Open Design 插件脚手架。先读取文档规范,再生成插件文件。' } }));
432+
console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
433+
process.exit(0);
434+
});
435+
`,
436+
async () => {
437+
const createResponse = await fetch(`${baseUrl}/api/runs`, {
438+
method: 'POST',
439+
headers: { 'Content-Type': 'application/json' },
440+
body: JSON.stringify({
441+
agentId: 'opencode',
442+
projectId,
443+
conversationId,
444+
pluginId: 'od-plugin-authoring',
445+
message: '请创建一个可刷新、可审计、由 API 驱动的 Open Design 插件脚手架。',
446+
}),
447+
});
448+
expect(createResponse.status).toBe(202);
449+
const { runId } = await createResponse.json() as { runId: string };
450+
451+
const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`);
452+
const eventsBody = await readSseUntil(eventsResponse, 'event: final');
453+
const statusBody = await waitForRunStatus(baseUrl, runId);
454+
455+
expect(eventsBody).toContain('先读取文档规范,再生成插件文件');
456+
expect(statusBody.status).toBe('succeeded');
457+
458+
const filesResponse = await fetch(`${baseUrl}/api/projects/${projectId}/files`);
459+
expect(filesResponse.status).toBe(200);
460+
const filesBody = await filesResponse.json() as { files: Array<{ name: string }> };
461+
expect(filesBody.files.some((file) => file.name === 'generated-plugin/open-design.json')).toBe(true);
462+
expect(filesBody.files.some((file) => file.name === 'generated-plugin/SKILL.md')).toBe(true);
463+
},
464+
);
465+
});
466+
314467
it('does not report plugin authoring as succeeded when the agent only emits planning text without artifacts', async () => {
315468
const projectId = `proj-plugin-authoring-${randomUUID()}`;
316469

apps/daemon/tests/integrations/vela.routes.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,61 @@ describe('POST /api/integrations/vela/login', () => {
371371
}
372372
});
373373

374+
375+
it('uses the same Settings-configured AMR env for login and subsequent status reads', async () => {
376+
const dataDir = process.env.OD_DATA_DIR as string;
377+
const previous = await readAppConfig(dataDir);
378+
process.env.OPEN_DESIGN_AMR_PROFILE = 'prod';
379+
process.env.VELA_PROFILE = 'prod';
380+
process.env.FAKE_VELA_LOGIN_USER_EMAIL = 'settings-roundtrip@example.com';
381+
await writeAppConfig(dataDir, {
382+
...previous,
383+
agentCliEnv: {
384+
...(previous.agentCliEnv ?? {}),
385+
amr: {
386+
...((previous.agentCliEnv?.amr as Record<string, string>) ?? {}),
387+
VELA_BIN: FAKE_VELA,
388+
OPEN_DESIGN_AMR_PROFILE: 'local',
389+
},
390+
},
391+
});
392+
try {
393+
const before = await getJson<{
394+
loggedIn: boolean;
395+
profile: string;
396+
user: { email?: string } | null;
397+
}>(`${baseUrl}/api/integrations/vela/status`);
398+
expect(before.status).toBe(200);
399+
expect(before.body.loggedIn).toBe(false);
400+
expect(before.body.profile).toBe('local');
401+
402+
const login = await postJson<{
403+
pid: number;
404+
profile: string;
405+
}>(`${baseUrl}/api/integrations/vela/login`);
406+
expect(login.status).toBe(202);
407+
expect(login.body.profile).toBe('local');
408+
409+
for (let i = 0; i < 50; i += 1) {
410+
const current = await getJson<{
411+
loggedIn: boolean;
412+
profile: string;
413+
user: { email?: string } | null;
414+
}>(`${baseUrl}/api/integrations/vela/status`);
415+
if (current.body.loggedIn) {
416+
expect(current.body.profile).toBe('local');
417+
expect(current.body.user?.email).toBe('settings-roundtrip@example.com');
418+
return;
419+
}
420+
await new Promise((resolve) => setTimeout(resolve, 100));
421+
}
422+
throw new Error('expected configured-profile AMR login to become visible via /status');
423+
} finally {
424+
await writeAppConfig(dataDir, previous as unknown as Record<string, unknown>);
425+
delete process.env.FAKE_VELA_LOGIN_USER_EMAIL;
426+
}
427+
});
428+
374429
it('returns 409 when a login subprocess is already in flight', async () => {
375430
// Use the stub's delay knob so the first login is still running when
376431
// the second request arrives; without this the first exits before the

apps/daemon/tests/runs.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,27 @@ describe('chat run service shutdown', () => {
6262
runs.list({ projectId: 'project-1', conversationId: 'conv-b', status: 'active' }),
6363
).toEqual([runB]);
6464
});
65+
it('cancels a queued run immediately without waiting for child process shutdown', async () => {
66+
const runs = createRuns();
67+
const run = runs.create({ projectId: 'project-1', conversationId: 'conv-queued' });
68+
69+
const wait = runs.wait(run);
70+
runs.cancel(run);
71+
72+
expect(run.status).toBe('canceled');
73+
expect(run.cancelRequested).toBe(true);
74+
expect(run.signal).toBe('SIGTERM');
75+
expect(run.events.at(-1)).toMatchObject({
76+
event: 'end',
77+
data: { status: 'canceled', signal: 'SIGTERM' },
78+
});
79+
await expect(wait).resolves.toMatchObject({
80+
status: 'canceled',
81+
signal: 'SIGTERM',
82+
});
83+
});
84+
85+
6586

6687
it('stores effective media execution policy on run status bodies', () => {
6788
const runs = createRuns();

apps/daemon/tests/runtimes/agent-args.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { existsSync, readFileSync } from 'node:fs';
22
import { test } from 'vitest';
33
import {
4-
AGENT_DEFS, aider, antigravity, assert, claude, codex, copilot, cursorAgent, deepseek, devin, detectAgents, gemini, join, kilo, kiro, mkdtempSync, opencode, pi, qoder, qwen, rmSync, spawnEnvForAgent, tmpdir, vibe, writeFileSync, chmodSync,
4+
AGENT_DEFS, aider, antigravity, assert, claude, codex, copilot, cursorAgent, deepseek, devin, detectAgents, gemini, grokBuild, join, kilo, kiro, mkdtempSync, opencode, pi, qoder, qwen, rmSync, spawnEnvForAgent, tmpdir, vibe, writeFileSync, chmodSync,
55
} from './helpers/test-helpers.js';
66
import { writeAntigravityModelSelection } from '../../src/runtimes/defs/antigravity.js';
77
import type { TestAgentDef } from './helpers/test-helpers.js';
@@ -756,6 +756,29 @@ test('codex buildArgs omits model_reasoning_effort when reasoning is "default"',
756756
);
757757
});
758758

759+
test('grok-build inlines the prompt as -p <value> and never falls back to stdin sentinels', () => {
760+
const prompt = 'summarize the current page layout';
761+
const args = grokBuild.buildArgs(
762+
prompt,
763+
[],
764+
[],
765+
{ model: 'grok-4.3', reasoning: 'high' },
766+
{ cwd: '/tmp/od-project' },
767+
);
768+
769+
assert.equal(grokBuild.promptViaStdin, false);
770+
assert.deepEqual(args, [
771+
'-p',
772+
prompt,
773+
'--model',
774+
'grok-4.3',
775+
'--effort',
776+
'high',
777+
]);
778+
assert.equal(args.includes('-'), false);
779+
assert.equal(args.filter((entry) => entry === '-p').length, 1);
780+
});
781+
759782
test('claude flags promptViaStdin and never embeds the prompt in argv', () => {
760783
// Long composed prompts (system prompt + design system + skill body +
761784
// user message) routinely exceed Linux MAX_ARG_STRLEN (~128 KB) and the

apps/daemon/tests/runtimes/env-and-detection.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,56 @@ fsTest('detectAgents marks AMR available from packaged built-in Vela with the bu
588588
}
589589
});
590590

591+
592+
fsTest('detectAgents prefers configured AMR live models over stale fallback defaults', async () => {
593+
const root = mkdtempSync(join(tmpdir(), 'od-detect-amr-live-models-'));
594+
try {
595+
return await withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'OD_RESOURCE_ROOT', 'VELA_OPENCODE_BIN'], async () => {
596+
const fakeVela = join(root, 'vela');
597+
const fakeOpenCode = join(root, 'opencode');
598+
writeFileSync(
599+
fakeVela,
600+
`#!/bin/sh
601+
if [ "$1" = "--version" ]; then echo "vela custom-live"; exit 0; fi
602+
if [ "$1" = "models" ]; then printf "%s\n" "public_model_deepseek_v4_flash vela" "public_model_glm_5 vela"; exit 0; fi
603+
exit 0
604+
`,
605+
);
606+
writeFileSync(fakeOpenCode, `#!/bin/sh
607+
exit 0
608+
`);
609+
chmodSync(fakeVela, 0o755);
610+
chmodSync(fakeOpenCode, 0o755);
611+
process.env.PATH = '';
612+
process.env.OD_AGENT_HOME = join(root, 'empty-home');
613+
delete process.env.OD_RESOURCE_ROOT;
614+
delete process.env.VELA_OPENCODE_BIN;
615+
616+
const agents = await detectAgents({
617+
amr: {
618+
VELA_BIN: fakeVela,
619+
VELA_OPENCODE_BIN: fakeOpenCode,
620+
},
621+
});
622+
const amrAgent = agents.find((agent) => agent.id === 'amr');
623+
624+
assert.ok(amrAgent);
625+
assert.equal(amrAgent.available, true);
626+
assert.equal(amrAgent.path, fakeVela);
627+
assert.equal(amrAgent.version, 'vela custom-live');
628+
assert.equal(amrAgent.modelsSource, 'live');
629+
assert.deepEqual(amrAgent.models, [
630+
{ id: 'deepseek-v4-flash', label: 'deepseek-v4-flash' },
631+
{ id: 'glm-5', label: 'glm-5' },
632+
]);
633+
assert.equal(amrAgent.models.some((model) => model.id === 'default'), false);
634+
assert.equal(amrAgent.models.some((model) => model.id === 'gpt-5.4-mini'), false);
635+
});
636+
} finally {
637+
rmSync(root, { recursive: true, force: true });
638+
}
639+
});
640+
591641
function codexNativeTargetTriple(): string {
592642
if (process.platform === 'darwin' && process.arch === 'arm64') return 'aarch64-apple-darwin';
593643
if (process.platform === 'darwin' && process.arch === 'x64') return 'x86_64-apple-darwin';

apps/web/src/components/FileViewer.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5855,6 +5855,13 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
58555855
win.postMessage({ type: 'od:slide', action }, '*');
58565856
}
58575857

5858+
function syncCachedSlideStateToIframe(target: HTMLIFrameElement | null = iframeRef.current) {
5859+
const active = htmlPreviewSlideState.get(previewStateKey)?.active;
5860+
const win = target?.contentWindow;
5861+
if (!win || typeof active !== 'number') return;
5862+
win.postMessage({ type: 'od:slide', action: 'go', index: active }, '*');
5863+
}
5864+
58585865
function postInspectSet(elementId: string, selector: string, prop: string, value: string) {
58595866
const win = iframeRef.current?.contentWindow;
58605867
if (!win) return;
@@ -7666,6 +7673,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
76667673
}, '*');
76677674
replayInspectOverridesToIframe(frame);
76687675
syncBridgeModes(frame);
7676+
syncCachedSlideStateToIframe(frame);
76697677
if (!useUrlLoadPreview) restorePreviewScrollPosition();
76707678
}}
76717679
/>

0 commit comments

Comments
 (0)