mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* feat(od-next): open registered runtimes and CLI BYOK Use the effective runtime registry for OD Next admission and remove retired capability qualification. Keep native observation and existing resume/cold recovery, with the evidence graph owned by observability contracts. Add run-scoped BYOK connection flags with caller-selected environment key input and reuse the existing provider bridge. Accept equivalent frozen Skill JSON and Base64 representations while preserving content and ownership boundaries. Validated with focused contracts, runtime, public Run and CLI regressions; eight real native agents and a real BYOK request/production pair; guard and workspace typecheck. Refs #8701. * fix(web): describe BYOK project file tools Explain that API-provider project runs use OpenCode file tools and require OpenCode to be available. Update the existing notice in all 19 locales and its component/browser regressions. Validated with red/green notice checks, real settings and preview follow-ups, guard and workspace typecheck. Refs #8701. * fix(od-next): preserve hidden prototype form feedback Keep HTML hidden semantics within the shared layout layer without overriding authored product CSS or hidden-until-found behavior. This fixes name errors remaining visible after a successful registration. Validated with a red/green Chromium regression, three resource-boundary checks, a real Claude follow-up with desktop/mobile form interaction, guard and workspace typecheck. Refs #8701. * test(od-next): make Run fixtures select their execution path Explicitly select ordinary Runs for retry persistence, plain stdout replay and the compatibility single-run telemetry case. Keep task hierarchy and terminal fallback telemetry cases active, and assert the selected path. Restore owner write permission to the leftover-agent fixture's frozen directories only during teardown so recursive deletion works on POSIX. Skip symlinks and preserve cleanup errors and every original behavior check. Reproduced all four CI failures before the fix. Focused daemon and E2E checks passed (12 tests), along with guard, workspace typecheck and focused LSP diagnostics. Refs #8701. * fix(daemon): avoid passing Pi resource directories as prompt files Pi reads existing --append-system-prompt paths as files. Stop passing skills, design systems and task input directories there; their resource paths already travel in the composed RPC prompt. Validated with 104 focused runtime/RPC tests, a real Pi 1.1.0 offline startup red/green probe, workspace typecheck and guard. * fix(web): coalesce streamed calls in generation progress Fold in-flight snapshots and terminal inputs through the existing tool-event reducer before rendering the Design Files progress feed. Keep the latest input, stable call identity and the limit of twelve distinct calls. Reproduced the duplicate React key warning before the fix. Runtime and progress-feed regressions now pass alongside existing in-flight tool-row tests. * fix(web): preserve forms and browser storage in prototype previews Permit client-side form submission in opaque interactive previews. Route Web Storage artifacts through the existing isolated powered preview, including daemon hints for large files, and retain guards when isolation is unavailable. Verified URL and srcDoc form interaction, persisted values after reload, and host isolation with focused Chromium regressions. Focused viewer/runtime and daemon tests, workspace typecheck and guard passed. * test(web): keep srcDoc fixtures on the opaque preview path Use an existing non-storage sandbox-shim signal in transport lifecycle fixtures now that Web Storage artifacts select the isolated preview origin. Preserve every reload, activation, recovery and manual-save assertion. Validated all 338 cases in the three affected FileViewer test files, workspace guard and typecheck, and focused diagnostics. * fix(byok): accept keyless IPv6 loopback providers Recognize the bracketed IPv6 hostname returned by WHATWG URL parsing in both settings validation and the OpenCode BYOK runtime. Keep remote hosts key-required. Validated with focused daemon and Web tests, settings browser checks, workspace typecheck and guard. * fix(daemon): restore Pi sessions and preserve completed RPC turns Load prior Pi history with switch_session and capture the authoritative sessionFile through nonblocking get_state. Treat a host-requested SIGTERM after a clean completed turn as shutdown, including Pi builds that report exit 143, while preserving real errors and cancellation. Validated with focused protocol and public Run lifecycle tests, a real Pi generation and native resumed edit, workspace typecheck and guard. * fix(preview): isolate project storage and preserve hidden controls Give powered previews stable per-project loopback origins and reserve those hosts for matching powered GETs before API or static exceptions. Advertise the capability so newer Web clients retain the opaque fallback with older daemons. Reuse the active powered transport in Present. Share a low-specificity hidden-control guard across URL and srcDoc previews without overriding explicit author rules or until-found; preserve doctype mode when synthesizing a missing head. Validated with actual two-project storage, Workers, reload, Present and API isolation browser witnesses, form/hidden regressions, focused Web and daemon checks, workspace typecheck and guard. * test(e2e): align public canaries with best-effort OD Next Read the remaining recipe identity marker without the retired serialized package hash. Make the fake OpenCode help probe finite. Exercise the current public question-form action, completed question turns/new answer tasks, and no-artifact normal endings while retaining cancellation, artifact and deck assertions. Validated with six fake-runtime cases and focused active/off public browser canaries with explicit rollout flags; no production completion policy or timeout was relaxed. * fix(od-next): guide focus restoration after list updates Prefer native modal lifecycle and restore focus to a current control after list rerenders, falling back to the primary action when a filtered item disappears. Real OpenCode and Pi prototypes exposed detached-trigger focus loss. Keep this as best-effort build guidance, without output contracts or automatic repair rounds. Validated the actual artifacts through normal public edit tasks and browser interaction checks; profile resource tests passed. * fix(od-next): retain focus through inline task updates Extend the existing live-control guidance to inline list actions. Real Copilot and Qoder boards also lost keyboard focus when status updates rebuilt their cards; hidden filtered items need a visible fallback. * fix: align Discovery integration tests and presentation storage * test(daemon): tolerate transient memory origin cleanup races --------- Co-authored-by: zhouzheng <zhouzheng@refly.ai>
1300 lines
49 KiB
TypeScript
1300 lines
49 KiB
TypeScript
import { chmod, copyFile, mkdir, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { DECK_SKELETON_HTML } from '@open-design/contracts';
|
|
|
|
const PROTOCOL_DECK_CANARY_HTML = DECK_SKELETON_HTML
|
|
.replace('<!-- SLOT: deck title -->', 'Deck protocol matrix canary')
|
|
.replace('<!-- SLOT: slide 1 content -->', '<h1>Matrix Slide One</h1>')
|
|
.replace('<!-- SLOT: slide 2 content -->', '<h1>Matrix Slide Two</h1>')
|
|
.replace(
|
|
'<!-- ... add as many <section class="slide"> blocks as the brief asks\n for. The first one is .active; the rest are not. -->',
|
|
'<section class="slide" data-screen-label="03"><h1>Matrix Slide Three</h1></section>',
|
|
);
|
|
|
|
const LEGACY_TEMPLATE_DECK_CANARY_HTML = `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Legacy template matrix canary</title>
|
|
<style>
|
|
html, body { margin: 0; width: 100%; height: 100%; }
|
|
body { display: flex; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; }
|
|
.slide { flex: 0 0 100vw; width: 100vw; height: 100vh; scroll-snap-align: start; display: grid; place-items: center; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<section class="slide" data-screen-label="01"><h1>Matrix Slide One</h1></section>
|
|
<section class="slide" data-screen-label="02"><h1>Matrix Slide Two</h1></section>
|
|
<section class="slide" data-screen-label="03"><h1>Matrix Slide Three</h1></section>
|
|
<script>
|
|
(function () {
|
|
var slides = Array.prototype.slice.call(document.querySelectorAll('.slide'));
|
|
var active = 0;
|
|
function go(index) {
|
|
active = Math.max(0, Math.min(slides.length - 1, index));
|
|
window.scrollTo({ left: active * window.innerWidth, behavior: 'smooth' });
|
|
}
|
|
function onKey(event) {
|
|
if (event.key === 'ArrowRight') { event.preventDefault(); go(active + 1); }
|
|
if (event.key === 'ArrowLeft') { event.preventDefault(); go(active - 1); }
|
|
}
|
|
window.addEventListener('keydown', onKey, true);
|
|
document.addEventListener('keydown', onKey, true);
|
|
document.body.setAttribute('tabindex', '-1');
|
|
document.body.focus({ preventScroll: true });
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
|
|
export type FakeAgentId =
|
|
| 'claude'
|
|
| 'codex'
|
|
| 'copilot'
|
|
| 'cursor-agent'
|
|
| 'deepseek'
|
|
| 'gemini'
|
|
| 'opencode'
|
|
| 'qoder'
|
|
| 'qwen';
|
|
|
|
export type FakeAgentRuntime = {
|
|
agentId: FakeAgentId;
|
|
bin: string;
|
|
envKey: string;
|
|
env: Record<string, string>;
|
|
invocation?: { path: string; nonce: string };
|
|
};
|
|
|
|
export type FakeAgentRuntimeOptions = {
|
|
root?: string;
|
|
runtimeIds?: FakeAgentId[];
|
|
recordInvocations?: boolean;
|
|
};
|
|
|
|
export type FakeAcpHandshakeRuntime = {
|
|
bin: string;
|
|
env: Record<string, string>;
|
|
invocationLog: string;
|
|
};
|
|
|
|
export type FakeAcpHandshakeRuntimeOptions = {
|
|
root?: string;
|
|
};
|
|
|
|
/** Install the intentionally failing ACP fixture owned by the E2E harness. */
|
|
export async function createFakeAcpHandshakeRuntime(
|
|
options: FakeAcpHandshakeRuntimeOptions = {},
|
|
): Promise<FakeAcpHandshakeRuntime> {
|
|
const root = options.root ?? path.join(
|
|
tmpdir(),
|
|
`open-design-fake-acp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
);
|
|
await mkdir(root, { recursive: true });
|
|
const script = path.join(root, 'fake-acp-handshake-cli.mjs');
|
|
await copyFile(
|
|
fileURLToPath(new URL('../resources/fake-acp-handshake-cli.ts', import.meta.url)),
|
|
script,
|
|
);
|
|
|
|
const invocationLog = path.join(root, 'invocations.jsonl');
|
|
const bin = process.platform === 'win32'
|
|
? path.join(root, 'fake-acp-handshake-cli.cmd')
|
|
: path.join(root, 'fake-acp-handshake-cli');
|
|
if (process.platform === 'win32') {
|
|
await writeFile(
|
|
bin,
|
|
`@echo off\r\nset "FAKE_ACP_INVOCATION_LOG=${invocationLog}"\r\n"${process.execPath}" "${script}" %*\r\n`,
|
|
'utf8',
|
|
);
|
|
} else {
|
|
await writeFile(
|
|
bin,
|
|
`#!/bin/sh\nFAKE_ACP_INVOCATION_LOG=${JSON.stringify(invocationLog)} exec ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} "$@"\n`,
|
|
'utf8',
|
|
);
|
|
await chmod(bin, 0o755);
|
|
}
|
|
|
|
return {
|
|
bin,
|
|
invocationLog,
|
|
env: {
|
|
KIMI_BIN: bin,
|
|
},
|
|
};
|
|
}
|
|
|
|
const AGENT_BIN_NAMES: Record<FakeAgentId, string> = {
|
|
claude: 'claude-e2e.cjs',
|
|
codex: 'codex-e2e.cjs',
|
|
copilot: 'copilot-e2e.cjs',
|
|
'cursor-agent': 'cursor-agent-e2e.cjs',
|
|
deepseek: 'deepseek-e2e.cjs',
|
|
gemini: 'gemini-e2e.cjs',
|
|
opencode: 'opencode-e2e.cjs',
|
|
qoder: 'qodercli-e2e.cjs',
|
|
qwen: 'qwen-e2e.cjs',
|
|
};
|
|
|
|
const AGENT_BIN_ENV_KEYS: Record<FakeAgentId, string> = {
|
|
claude: 'CLAUDE_BIN',
|
|
codex: 'CODEX_BIN',
|
|
copilot: 'COPILOT_BIN',
|
|
'cursor-agent': 'CURSOR_AGENT_BIN',
|
|
deepseek: 'DEEPSEEK_BIN',
|
|
gemini: 'GEMINI_BIN',
|
|
opencode: 'OPENCODE_BIN',
|
|
qoder: 'QODER_BIN',
|
|
qwen: 'QWEN_BIN',
|
|
};
|
|
|
|
export const FAKE_AGENT_RUNTIME_IDS: FakeAgentId[] = [
|
|
'claude',
|
|
'opencode',
|
|
'cursor-agent',
|
|
'qwen',
|
|
'qoder',
|
|
'copilot',
|
|
];
|
|
|
|
export async function createFakeAgentRuntimes(
|
|
runtimeIds?: FakeAgentId[],
|
|
): Promise<Record<FakeAgentId, FakeAgentRuntime>>;
|
|
export async function createFakeAgentRuntimes(
|
|
options?: FakeAgentRuntimeOptions,
|
|
): Promise<Record<FakeAgentId, FakeAgentRuntime>>;
|
|
export async function createFakeAgentRuntimes(
|
|
input: FakeAgentId[] | FakeAgentRuntimeOptions = {},
|
|
): Promise<Record<FakeAgentId, FakeAgentRuntime>> {
|
|
const runtimeIds = Array.isArray(input)
|
|
? input
|
|
: (input.runtimeIds ?? ['codex', ...FAKE_AGENT_RUNTIME_IDS]);
|
|
const root = Array.isArray(input)
|
|
? path.join(tmpdir(), `open-design-fake-agents-${process.pid}`)
|
|
: (input.root ?? path.join(tmpdir(), `open-design-fake-agents-${process.pid}`));
|
|
await mkdir(root, { recursive: true });
|
|
|
|
const runtimes = {} as Record<FakeAgentId, FakeAgentRuntime>;
|
|
for (const agentId of runtimeIds) {
|
|
const script = path.join(root, AGENT_BIN_NAMES[agentId]);
|
|
const parsedScript = path.parse(script);
|
|
const bin = process.platform === 'win32'
|
|
? path.join(parsedScript.dir, `${parsedScript.name}.cmd`)
|
|
: script;
|
|
const invocation = !Array.isArray(input) && input.recordInvocations && agentId === 'codex'
|
|
? { path: path.join(root, 'codex-invocations.jsonl'), nonce: randomUUID() }
|
|
: undefined;
|
|
if (invocation) await writeFile(invocation.path, '', 'utf8');
|
|
await writeFile(script, renderFakeAgentScript(agentId, invocation), 'utf8');
|
|
if (process.platform === 'win32') {
|
|
await writeFile(bin, '@echo off\r\nnode "%~dp0%~n0.cjs" %*\r\n', 'utf8');
|
|
} else {
|
|
await chmod(bin, 0o755);
|
|
}
|
|
const envKey = AGENT_BIN_ENV_KEYS[agentId];
|
|
runtimes[agentId] = { agentId, bin, envKey, env: { [envKey]: bin }, ...(invocation ? { invocation } : {}) };
|
|
}
|
|
return runtimes;
|
|
}
|
|
|
|
function renderFakeAgentScript(agentId: FakeAgentId, invocation?: FakeAgentRuntime['invocation']): string {
|
|
return `#!/usr/bin/env node
|
|
const agentId = ${JSON.stringify(agentId)};
|
|
const args = process.argv.slice(2);
|
|
const codexAppServer = agentId === 'codex' && args.includes('app-server');
|
|
const invocation = ${JSON.stringify(invocation ?? null)};
|
|
let codexThreadId = 'fake-codex-session';
|
|
let codexTurnId = 'fake-codex-turn';
|
|
let codexCwd = '';
|
|
const { mkdir, writeFile: writeFileFs } = require('node:fs/promises');
|
|
const { readFileSync, writeFileSync } = require('node:fs');
|
|
const { join } = require('node:path');
|
|
const protocolDeckCanaryHtml = ${JSON.stringify(PROTOCOL_DECK_CANARY_HTML)};
|
|
const legacyTemplateDeckCanaryHtml = ${JSON.stringify(LEGACY_TEMPLATE_DECK_CANARY_HTML)};
|
|
|
|
if (args.includes('--version')) {
|
|
process.stdout.write(agentId + '-e2e 0.0.0\\n');
|
|
process.exitCode = 0;
|
|
} else if (agentId === 'claude' && args[0] === '-p' && args.includes('--help')) {
|
|
process.stdout.write('--add-dir --include-partial-messages\\n');
|
|
process.exitCode = 0;
|
|
} else if (agentId === 'opencode' && args[0] === 'run' && args.includes('--help')) {
|
|
process.stdout.write('--format --dir --session --dangerously-skip-permissions\\n');
|
|
process.exitCode = 0;
|
|
} else if ((agentId === 'opencode' || agentId === 'cursor-agent') && args[0] === 'models') {
|
|
process.stdout.write('fake/default\\n');
|
|
process.exitCode = 0;
|
|
} else {
|
|
|
|
let prompt = '';
|
|
let emitted = false;
|
|
let emitTimer = null;
|
|
recordInvocation('started');
|
|
if (codexAppServer) {
|
|
const lines = require('node:readline').createInterface({ input: process.stdin });
|
|
let initialized = false;
|
|
let threadReady = false;
|
|
lines.on('line', (line) => {
|
|
try {
|
|
const request = JSON.parse(line);
|
|
recordInvocation('request', { method: request.method });
|
|
const reply = (result) => writeJson({ id: request.id, result });
|
|
if (request.method === 'initialize') {
|
|
initialized = true;
|
|
reply({ userAgent: 'codex-e2e' });
|
|
} else if (request.method === 'initialized') {
|
|
return;
|
|
} else if (initialized && ['thread/start', 'thread/resume'].includes(request.method)) {
|
|
codexThreadId = request.params?.threadId || 'fake-codex-session';
|
|
codexCwd = request.params?.cwd || '';
|
|
threadReady = true;
|
|
reply({ thread: { id: codexThreadId } });
|
|
} else if (threadReady && request.method === 'turn/start' && !emitted) {
|
|
codexTurnId = 'fake-codex-turn';
|
|
reply({ turn: { id: codexTurnId, status: 'inProgress' } });
|
|
notifyCodex('turn/started', { turn: { id: codexTurnId, status: 'inProgress' } });
|
|
const input = (request.params?.input || [])
|
|
.filter((item) => item.type === 'text').map((item) => item.text).join('\\n');
|
|
void emitRun(input).catch(failUnhandled);
|
|
} else if (request.id != null) {
|
|
writeJson({ id: request.id, error: { code: -32601, message: 'Unsupported or out-of-order fixture method: ' + request.method } });
|
|
}
|
|
} catch (error) {
|
|
failUnhandled(error);
|
|
}
|
|
});
|
|
} else {
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.resume();
|
|
process.stdin.on('data', (chunk) => {
|
|
prompt += chunk;
|
|
if (emitted) return;
|
|
if (emitTimer) clearTimeout(emitTimer);
|
|
emitTimer = setTimeout(() => {
|
|
void emitRun(prompt).catch(failUnhandled);
|
|
}, 25);
|
|
});
|
|
process.stdin.on('end', () => {
|
|
void emitRun(prompt).catch(failUnhandled);
|
|
});
|
|
if (process.stdin.isTTY || agentId === 'deepseek') {
|
|
prompt = args.join(' ');
|
|
void emitRun(prompt).catch(failUnhandled);
|
|
}
|
|
}
|
|
|
|
function recordInvocation(event, extra = {}) {
|
|
if (!invocation) return;
|
|
require('node:fs').appendFileSync(invocation.path, JSON.stringify({
|
|
nonce: invocation.nonce, pid: process.pid,
|
|
mode: codexAppServer ? 'app-server' : 'exec-json', event, ...extra,
|
|
}) + '\\n');
|
|
}
|
|
|
|
function notifyCodex(method, params) {
|
|
writeJson({ method, params: { threadId: codexThreadId, turnId: codexTurnId, ...params } });
|
|
}
|
|
|
|
function completeCodexTurn(error) {
|
|
recordInvocation('completed', { threadId: codexThreadId, turnId: codexTurnId, failed: !!error });
|
|
notifyCodex('turn/completed', {
|
|
turn: { id: codexTurnId, status: error ? 'failed' : 'completed', error: error ? { message: error } : null },
|
|
});
|
|
}
|
|
|
|
async function emitRun(promptText) {
|
|
if (emitted) return;
|
|
emitted = true;
|
|
if (promptText.includes('Hold the daemon run open until canceled')) {
|
|
// Stay running (busy) without ever emitting a terminal result, so a test
|
|
// can queue a follow-up turn and interrupt it via send-now. Keep the event
|
|
// loop alive indefinitely; the daemon kills this child (SIGTERM) when the
|
|
// run is canceled.
|
|
setInterval(() => {}, 1 << 30);
|
|
return;
|
|
}
|
|
if (promptText.includes('Return an intentional daemon smoke failure')) {
|
|
emitFailure();
|
|
return;
|
|
}
|
|
if (promptText.includes('Return a daemon 429 service failure')) {
|
|
emitServiceFailure(429);
|
|
return;
|
|
}
|
|
if (promptText.includes('Return a daemon 503 service failure')) {
|
|
emitServiceFailure(503);
|
|
return;
|
|
}
|
|
if (promptText.includes('Return a daemon model-not-found failure')) {
|
|
emitModelUnavailableFailure();
|
|
return;
|
|
}
|
|
if (promptText.includes('Return a daemon timeout failure')) {
|
|
emitTimeoutFailure();
|
|
return;
|
|
}
|
|
if (promptText.includes('Return a daemon socket-drop failure')) {
|
|
emitSocketDropFailure();
|
|
return;
|
|
}
|
|
if (promptText.includes('Return a Claude prompt-too-long failure')) {
|
|
emitClaudePromptTooLongFailure();
|
|
return;
|
|
}
|
|
if (promptText.includes('Return repeated OpenCode tool failures')) {
|
|
emitOpenCodeRepeatedToolFailures();
|
|
return;
|
|
}
|
|
if (promptText.includes('Return an empty daemon smoke response')) {
|
|
emitEmptySuccess();
|
|
return;
|
|
}
|
|
if (promptText.includes('This is the production turn')) {
|
|
await emitOdNextProductionRun(promptText);
|
|
return;
|
|
}
|
|
if (promptText.includes('[form answers') && promptText.includes('od-next-canary-platform')) {
|
|
emitOdNextPlanningRun(promptText);
|
|
return;
|
|
}
|
|
if (promptText.includes('Create an OD Next clarification canary artifact')) {
|
|
emitOdNextClarificationRequest(promptText);
|
|
return;
|
|
}
|
|
if (promptText.includes('Create an OD Next PowerPoint protocol canary from this prototype project')) {
|
|
const requiredDeckProtocolMarkers = [
|
|
'OD Deck Protocol v1',
|
|
'data-od-deck-protocol="1"',
|
|
"type: 'od:deck-ready'",
|
|
"type: 'od:slide-state'",
|
|
];
|
|
const missingDeckProtocolMarkers = requiredDeckProtocolMarkers.filter(
|
|
(marker) => !promptText.includes(marker),
|
|
);
|
|
if (missingDeckProtocolMarkers.length > 0) {
|
|
process.stderr.write(
|
|
'OD Next prototype-to-PPT prompt is missing Deck Protocol v1 markers: '
|
|
+ missingDeckProtocolMarkers.join(', ')
|
|
+ '\\n',
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
if (promptText.includes('<recipe_identity ')) {
|
|
emitOdNextPlanningRun(promptText);
|
|
} else {
|
|
await emitDeckProtocolCanaryRun(protocolDeckCanaryHtml, 'Created the classic/off-rollout Deck Protocol canary.');
|
|
}
|
|
return;
|
|
}
|
|
if (promptText.includes('Create a selected-template deck navigation canary')) {
|
|
const requiredTemplateMarkers = ['assets/template.html'];
|
|
const missingTemplateMarkers = requiredTemplateMarkers.filter(
|
|
(marker) => !promptText.includes(marker),
|
|
);
|
|
if (missingTemplateMarkers.length > 0) {
|
|
process.stderr.write(
|
|
'Selected deck template prompt is missing markers: '
|
|
+ missingTemplateMarkers.join(', ')
|
|
+ '\\n',
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
if (promptText.includes('<recipe_identity ')) {
|
|
const requiredCompatibilityMarkers = [
|
|
'selected or existing scaffold compatibility',
|
|
'assets/template.html',
|
|
];
|
|
const missingCompatibilityMarkers = requiredCompatibilityMarkers.filter(
|
|
(marker) => !promptText.includes(marker),
|
|
);
|
|
if (missingCompatibilityMarkers.length > 0) {
|
|
process.stderr.write(
|
|
'OD Next selected-template prompt is missing compatibility markers: '
|
|
+ missingCompatibilityMarkers.join(', ')
|
|
+ '\\n',
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
if (promptText.includes('data-od-deck-protocol="1"')) {
|
|
process.stderr.write('OD Next selected-template prompt unexpectedly injected Deck Protocol v1\\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
emitOdNextPlanningRun(promptText, 'request', 'ppt', { legacyDeck: true });
|
|
} else {
|
|
await emitDeckProtocolCanaryRun(
|
|
legacyTemplateDeckCanaryHtml,
|
|
'Created the selected-template legacy deck canary.',
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (promptText.includes('Create an OD Next blocked canary')) {
|
|
emitOdNextBlockedRun();
|
|
return;
|
|
}
|
|
if (promptText.includes('Create an OD Next active canary artifact')) {
|
|
emitOdNextPlanningRun(promptText);
|
|
return;
|
|
}
|
|
if (promptText.includes('Return a stderr-only daemon smoke failure')) {
|
|
process.stderr.write('stderr-only daemon smoke failure from fake ' + agentId + '\\n');
|
|
process.exitCode = 1;
|
|
exitSoon(1);
|
|
return;
|
|
}
|
|
if (
|
|
promptText.includes('Create an OpenDesign plugin for:') &&
|
|
promptText.includes('produce a folder named generated-plugin')
|
|
) {
|
|
await emitPluginAuthoringRun();
|
|
return;
|
|
}
|
|
// Checked before the plan-document fixture: a follow-up generation turn's
|
|
// stdin can carry the first turn's "Create a deterministic plan document"
|
|
// text as conversation history.
|
|
if (promptText.includes('Generate the deterministic artifact from the plan document')) {
|
|
await emitPlanArtifactGenerateRun();
|
|
return;
|
|
}
|
|
if (promptText.includes('Create a deterministic media-only artifact')) {
|
|
await emitMediaOnlyRun();
|
|
return;
|
|
}
|
|
if (promptText.includes('Create a deterministic plan document')) {
|
|
await emitPlanDocumentRun();
|
|
return;
|
|
}
|
|
// Work-completeness fixtures (#1247 / #1060): drive a Claude run that ends its
|
|
// turn while its TodoWrite plan still has unfinished tasks (or is truncated by
|
|
// max_tokens), so tests can assert the run reports endedWithUnfinishedWork.
|
|
if (promptText.includes('Emit an unfinished-todo run')) {
|
|
emitClaudeTodoRun([
|
|
{ content: 'Draft layout', status: 'completed' },
|
|
{ content: 'Build components', status: 'in_progress' },
|
|
{ content: 'Run QA', status: 'pending' },
|
|
], 'end_turn');
|
|
return;
|
|
}
|
|
if (promptText.includes('Emit a stopped-todo run')) {
|
|
emitClaudeTodoRun([{ content: 'Build components', status: 'stopped' }], 'end_turn');
|
|
return;
|
|
}
|
|
if (promptText.includes('Emit a max-tokens truncated run')) {
|
|
// All todos look done, but the turn was cut off — truncation alone must flag
|
|
// the run incomplete.
|
|
emitClaudeTodoRun([{ content: 'Draft layout', status: 'completed' }], 'max_tokens');
|
|
return;
|
|
}
|
|
if (promptText.includes('Emit an all-completed-todo run')) {
|
|
emitClaudeTodoRun([
|
|
{ content: 'Draft layout', status: 'completed' },
|
|
{ content: 'Build components', status: 'completed' },
|
|
], 'end_turn');
|
|
return;
|
|
}
|
|
if (promptText.includes('Edit the existing deterministic smoke artifact through the managed project alias')) {
|
|
await emitManagedAliasArtifactEditRun(promptText);
|
|
return;
|
|
}
|
|
if (promptText.includes('Edit the existing deterministic smoke artifact')) {
|
|
await emitExistingArtifactEditRun(promptText);
|
|
return;
|
|
}
|
|
const isSlowReload = promptText.includes('Create a slow reload deterministic smoke artifact');
|
|
const isDelayed = promptText.includes('Create a delayed deterministic smoke artifact');
|
|
if (isDelayed && promptText.includes('<recipe_identity ')) {
|
|
emitOdNextPlanningRun(promptText, 'request', undefined, { homeFirstRun: true });
|
|
return;
|
|
}
|
|
const isChunked = promptText.includes('Create a chunked deterministic smoke artifact');
|
|
const isFollowUp = promptText.includes('Create a follow-up deterministic smoke artifact');
|
|
const isDefaultSmoke = promptText.includes('Create a deterministic smoke artifact');
|
|
const isOrbit = promptText.includes("Create today's Orbit daily digest as a Live Artifact.");
|
|
if (isOrbit) {
|
|
await emitOrbitRun();
|
|
return;
|
|
}
|
|
const isRuntime = promptText.match(/Fake runtime smoke for ([a-z0-9-]+)/i);
|
|
const runtimeId = isRuntime ? isRuntime[1] : agentId;
|
|
const heading = isSlowReload ? 'Slow Reload Daemon Smoke' : isDelayed ? 'Delayed Daemon Smoke' : isChunked ? 'Chunked Daemon Smoke' : isFollowUp ? 'Follow-up Daemon Smoke' : isDefaultSmoke ? 'Real Daemon Smoke' : 'Fake Agent Runtime ' + runtimeId;
|
|
const identifier = isSlowReload ? 'slow-reload-daemon-smoke' : isDelayed ? 'delayed-daemon-smoke' : isChunked ? 'chunked-daemon-smoke' : isFollowUp ? 'follow-up-daemon-smoke' : isDefaultSmoke ? 'real-daemon-smoke' : 'fake-agent-runtime-' + runtimeId;
|
|
const text = isSlowReload ? 'Generated after a reload while the daemon run was active.' : isDelayed ? 'Generated after a delayed daemon turn.' : isChunked ? 'Chunked through the daemon run path.' : isFollowUp ? 'Generated after an earlier daemon turn.' : isDefaultSmoke ? 'Generated through the daemon run path.' : 'Generated through fake ' + runtimeId + ' runtime.';
|
|
const html = '<!doctype html><html><body><main><h1>' + heading + '</h1><p>' + text + '</p></main></body></html>';
|
|
const artifact = '<artifact identifier="' + identifier + '" type="text/html" title="' + heading + '">' + html + '</artifact>';
|
|
const assistantText = isSlowReload
|
|
? 'I stayed attached after the reload and will persist the artifact now.\\n\\n' + artifact
|
|
: isDelayed
|
|
? 'I recovered the delayed reasoning path and will persist the artifact now.\\n\\n' + artifact
|
|
: artifact;
|
|
if (isSlowReload) {
|
|
await new Promise((resolve) => setTimeout(resolve, 15_000));
|
|
}
|
|
if (isDelayed) {
|
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
}
|
|
emitSuccess(assistantText, isChunked, isDelayed || isSlowReload);
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
|
|
function promptIdentity(promptText, label) {
|
|
const prefix = '- ' + label + ': ';
|
|
const line = promptText.split('\\n').find((candidate) => candidate.startsWith(prefix));
|
|
if (!line) throw new Error('OD Next fake could not read ' + label);
|
|
return line.slice(prefix.length).split(String.fromCharCode(96)).join('');
|
|
}
|
|
|
|
const odNextIdentityPath = join(__dirname, 'od-next-' + agentId + '-identity.json');
|
|
|
|
function odNextPromptIdentity(promptText) {
|
|
const identityStart = promptText.indexOf('<recipe_identity ');
|
|
const identityEnd = identityStart < 0 ? -1 : promptText.indexOf('/>', identityStart);
|
|
if (identityStart >= 0 && identityEnd >= 0) {
|
|
const identityMarker = promptText.slice(identityStart, identityEnd);
|
|
const attribute = (name) => {
|
|
const prefix = name + '="';
|
|
const start = identityMarker.indexOf(prefix);
|
|
if (start < 0) throw new Error('OD Next fake could not read recipe_identity.' + name);
|
|
const valueStart = start + prefix.length;
|
|
const end = identityMarker.indexOf('"', valueStart);
|
|
if (end < 0) throw new Error('OD Next fake could not finish recipe_identity.' + name);
|
|
return identityMarker.slice(valueStart, end);
|
|
};
|
|
const taskTypeMatch = /<task_type>\\s*([^<]+?)\\s*<\\/task_type>/.exec(promptText);
|
|
const identity = {
|
|
version: attribute('strategy_version'),
|
|
snapshotId: attribute('applied_snapshot'),
|
|
taskProfileVersion: attribute('task_profile_version'),
|
|
taskType: taskTypeMatch ? taskTypeMatch[1].trim() : 'prototype',
|
|
};
|
|
writeFileSync(odNextIdentityPath, JSON.stringify(identity), 'utf8');
|
|
return identity;
|
|
}
|
|
if (promptText.includes('- strategy: ')) {
|
|
const strategy = promptIdentity(promptText, 'strategy').split('@');
|
|
const identity = {
|
|
version: strategy[1],
|
|
snapshotId: promptIdentity(promptText, 'applied snapshot'),
|
|
taskProfileVersion: '2.0.0',
|
|
taskType: 'prototype',
|
|
};
|
|
writeFileSync(odNextIdentityPath, JSON.stringify(identity), 'utf8');
|
|
return identity;
|
|
}
|
|
return JSON.parse(readFileSync(odNextIdentityPath, 'utf8'));
|
|
}
|
|
|
|
function emitOdNextPlanningRun(promptText, inputStage = 'request', taskTypeOverride, options = {}) {
|
|
const identity = odNextPromptIdentity(promptText);
|
|
if (taskTypeOverride) {
|
|
identity.taskType = taskTypeOverride;
|
|
}
|
|
if (options.legacyDeck) identity.legacyDeck = true;
|
|
if (options.homeFirstRun) identity.homeFirstRun = true;
|
|
if (taskTypeOverride || options.legacyDeck || options.homeFirstRun) {
|
|
writeFileSync(odNextIdentityPath, JSON.stringify(identity), 'utf8');
|
|
}
|
|
const key = /<od-production-ready key="([a-f0-9]+)"/.exec(promptText)?.[1];
|
|
if (!key) throw new Error('OD Next fake requires the current production marker key');
|
|
emitSuccess('The local canary plan is ready.\\n<od-production-ready key="' + key + '" />', false, false);
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
|
|
function emitOdNextClarificationRequest(promptText) {
|
|
odNextPromptIdentity(promptText);
|
|
const state = {
|
|
schema: 'open-design.strategy-state/v2', route: 'full_plan', inputStage: 'request',
|
|
outcome: 'clarification_required', executionMode: null, executionIntent: 'produce',
|
|
reasonCodes: ['od_next_clarification_required'],
|
|
};
|
|
const form = '<question-form id="od-next-canary-platform" title="Choose platform">'
|
|
+ '{"questions":[{"id":"platform","label":"Target platform","type":"radio",'
|
|
+ '"options":[{"label":"Desktop web","value":"desktop"}],"required":true}]}'
|
|
+ '</question-form>';
|
|
emitSuccess(
|
|
'One platform choice is required.\\n' + form
|
|
,
|
|
false,
|
|
false,
|
|
);
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
|
|
function emitOdNextBlockedRun() {
|
|
const state = {
|
|
schema: 'open-design.strategy-state/v2', route: 'full_plan', inputStage: 'request',
|
|
outcome: 'blocked', executionMode: null,
|
|
reasonCodes: ['od_next_canary_fixture_blocked'],
|
|
};
|
|
emitSuccess(
|
|
'The local canary was blocked by its fixture guard.\\n'
|
|
,
|
|
false,
|
|
false,
|
|
);
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
|
|
async function emitOdNextProductionRun(promptText) {
|
|
const identity = odNextPromptIdentity(promptText);
|
|
const legacyDeck = identity.legacyDeck === true;
|
|
const homeFirstRun = identity.homeFirstRun === true;
|
|
if (homeFirstRun) await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
await writeFileFs(
|
|
join(projectDir(), homeFirstRun ? 'delayed-daemon-smoke.html' : 'od-next-active-canary.html'),
|
|
homeFirstRun ? '<!doctype html><html><body><h1>Delayed Daemon Smoke</h1><p>Generated after a delayed daemon turn.</p></body></html>'
|
|
: legacyDeck ? legacyTemplateDeckCanaryHtml : protocolDeckCanaryHtml,
|
|
'utf8',
|
|
);
|
|
const state = {
|
|
schema: 'open-design.strategy-state/v2', route: 'full_plan', inputStage: 'production',
|
|
outcome: 'completed', executionMode: 'simple', executionIntent: 'produce', reasonCodes: [],
|
|
};
|
|
emitSuccess(
|
|
(homeFirstRun
|
|
? 'I recovered the delayed reasoning path and will persist the artifact now.\\n'
|
|
: legacyDeck
|
|
? 'Created the selected-template legacy deck canary.\\n'
|
|
: 'Created od-next-active-canary.html through the continued native session.\\n')
|
|
,
|
|
false,
|
|
false,
|
|
);
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
|
|
async function emitDeckProtocolCanaryRun(html, message) {
|
|
await writeFileFs(join(projectDir(), 'od-next-active-canary.html'), html, 'utf8');
|
|
emitSuccess(message + '\\n', false, false);
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
|
|
async function emitPluginAuthoringRun() {
|
|
const folder = join(projectDir(), 'generated-plugin');
|
|
await mkdir(join(folder, 'examples'), { recursive: true });
|
|
await writeFileFs(
|
|
join(folder, 'open-design.json'),
|
|
JSON.stringify({
|
|
specVersion: 1,
|
|
name: 'generated-plugin',
|
|
version: '0.1.0',
|
|
description: 'Fake plugin authoring smoke scaffold.',
|
|
mode: 'agent',
|
|
taskKind: 'new-generation',
|
|
inputs: [{ id: 'prompt', type: 'string', label: 'Prompt' }],
|
|
}, null, 2) + '\\n',
|
|
'utf8',
|
|
);
|
|
await writeFileFs(
|
|
join(folder, 'SKILL.md'),
|
|
'# Generated Plugin\\n\\nThis fake plugin exists for plugin authoring smoke coverage.\\n',
|
|
'utf8',
|
|
);
|
|
await writeFileFs(
|
|
join(folder, 'examples', 'demo.md'),
|
|
'# Demo\\n\\nGenerated by the fake plugin authoring runtime.\\n',
|
|
'utf8',
|
|
);
|
|
const summary = [
|
|
'Created generated-plugin with open-design.json, SKILL.md, and examples/demo.md.',
|
|
'od plugin validate: passed',
|
|
'od plugin pack: generated-plugin-0.1.0.tgz',
|
|
'od plugin install --source: passed',
|
|
].join('\\n');
|
|
emitSuccess(summary, false, false);
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
|
|
async function emitPlanDocumentRun() {
|
|
await writeFileFs(
|
|
join(projectDir(), 'plan.md'),
|
|
[
|
|
'# Deterministic Plan',
|
|
'',
|
|
'## Scope',
|
|
'- Confirm the target workflow.',
|
|
'- Draft the project milestones.',
|
|
'',
|
|
'## Risks',
|
|
'- Keep the plan editable before design handoff.',
|
|
'',
|
|
].join('\\n'),
|
|
'utf8',
|
|
);
|
|
emitSuccess('Created plan.md with a deterministic planning outline.', false, false);
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
|
|
async function emitMediaOnlyRun() {
|
|
const png = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W6McAAAAASUVORK5CYII=',
|
|
'base64',
|
|
);
|
|
await writeFileFs(join(projectDir(), 'media-only.png'), png);
|
|
emitSuccess('Created media-only.png as the only file produced by this turn.', false, false);
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
|
|
// Plan-mode generation turn (issue: Plan 模式生成 HTML 后没有自动打开): the
|
|
// agent reads the reviewed plan document, writes the final HTML deliverable
|
|
// as a project FILE (not an inline <artifact> echo), then touches the plan
|
|
// document again (e.g. updating its "Next step" section). Mirrors the real
|
|
// event order captured in the bug report: index.html first, plan.md second.
|
|
async function emitPlanArtifactGenerateRun() {
|
|
const dir = projectDir();
|
|
const html = '<!doctype html><html><body><main><h1>Plan Generated Deck</h1><p>Generated from the reviewed plan document.</p></main></body></html>';
|
|
const planUpdate = [
|
|
'# Deterministic Plan',
|
|
'',
|
|
'## Scope',
|
|
'- Confirm the target workflow.',
|
|
'- Draft the project milestones.',
|
|
'',
|
|
'## Next step',
|
|
'- Review the generated index.html.',
|
|
'',
|
|
].join('\\n');
|
|
await writeFileFs(join(dir, 'index.html'), html, 'utf8');
|
|
await writeFileFs(join(dir, 'plan.md'), planUpdate, 'utf8');
|
|
if (agentId === 'claude') {
|
|
// Emit the real Claude stream-json shape (tool_use + tool_result pairs)
|
|
// so the web per-write auto-open path sees the same events a live
|
|
// filesystem run produces.
|
|
writeJson({ type: 'system', subtype: 'init', model: 'fake-claude', session_id: 'fake-session' });
|
|
writeJson({
|
|
type: 'assistant',
|
|
message: {
|
|
id: 'msg-1',
|
|
content: [{ type: 'tool_use', id: 'toolu-plan-html', name: 'Write', input: { file_path: join(dir, 'index.html'), content: html } }],
|
|
},
|
|
});
|
|
writeJson({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'toolu-plan-html', content: 'ok' }] } });
|
|
writeJson({
|
|
type: 'assistant',
|
|
message: {
|
|
id: 'msg-2',
|
|
content: [{ type: 'tool_use', id: 'toolu-plan-md', name: 'Write', input: { file_path: join(dir, 'plan.md'), content: planUpdate } }],
|
|
},
|
|
});
|
|
writeJson({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'toolu-plan-md', content: 'ok' }] } });
|
|
writeJson({
|
|
type: 'assistant',
|
|
message: { id: 'msg-3', content: [{ type: 'text', text: 'Generated index.html from plan.md and refreshed the plan next steps.' }] },
|
|
});
|
|
writeJson({ type: 'result', usage: { input_tokens: 1, output_tokens: 1 }, total_cost_usd: 0, duration_ms: 1, stop_reason: 'end_turn' });
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
return;
|
|
}
|
|
emitSuccess('Generated index.html from plan.md and refreshed the plan next steps.', false, false);
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
|
|
async function emitExistingArtifactEditRun(promptText) {
|
|
const projectId = process.env.OD_PROJECT_ID || projectIdFromPrompt(promptText);
|
|
const daemonUrl = process.env.OD_DAEMON_URL;
|
|
if (!projectId || !daemonUrl) {
|
|
throw new Error('fake artifact edit requires OD_PROJECT_ID and OD_DAEMON_URL');
|
|
}
|
|
const response = await fetch(new URL('/api/projects/' + encodeURIComponent(projectId) + '/files', daemonUrl), {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: 'real-daemon-smoke.html',
|
|
content: '<!doctype html><html><body><main><h1>Real Daemon Smoke Edited</h1><p>Edited in place by a follow-up daemon run.</p></main></body></html>',
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('fake artifact edit write failed: HTTP ' + response.status + ' ' + (await response.text()).slice(0, 500));
|
|
}
|
|
emitSuccess('Updated real-daemon-smoke.html in place with a deterministic follow-up edit.', false, false);
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
|
|
async function emitManagedAliasArtifactEditRun(promptText) {
|
|
if (agentId !== 'claude') {
|
|
throw new Error('managed-project alias edit fixture requires the Claude fake runtime');
|
|
}
|
|
const projectId = process.env.OD_PROJECT_ID || projectIdFromPrompt(promptText);
|
|
if (!projectId) {
|
|
throw new Error('managed-project alias edit fixture requires OD_PROJECT_ID');
|
|
}
|
|
const fileName = 'real-daemon-smoke.html';
|
|
const content = '<!doctype html><html><body><main><h1 data-od-id="smoke-title">Real Daemon Smoke Edited</h1><p>Edited in place through a managed-project alias.</p></main></body></html>';
|
|
await writeFileFs(join(projectDir(promptText), fileName), content, 'utf8');
|
|
|
|
writeJson({ type: 'system', subtype: 'init', model: 'fake-claude', session_id: 'fake-session' });
|
|
writeJson({
|
|
type: 'assistant',
|
|
message: {
|
|
id: 'msg-managed-alias-write',
|
|
content: [{
|
|
type: 'tool_use',
|
|
id: 'toolu-managed-alias-write',
|
|
name: 'Write',
|
|
input: {
|
|
file_path: '.od/projects/' + projectId + '/' + fileName,
|
|
content,
|
|
},
|
|
}],
|
|
},
|
|
});
|
|
writeJson({
|
|
type: 'user',
|
|
message: {
|
|
content: [{
|
|
type: 'tool_result',
|
|
tool_use_id: 'toolu-managed-alias-write',
|
|
content: 'Updated ' + fileName,
|
|
}],
|
|
},
|
|
});
|
|
writeJson({
|
|
type: 'assistant',
|
|
message: {
|
|
id: 'msg-managed-alias-summary',
|
|
content: [{ type: 'text', text: 'Updated ' + fileName + ' in place.' }],
|
|
},
|
|
});
|
|
writeJson({
|
|
type: 'result',
|
|
usage: { input_tokens: 1, output_tokens: 1 },
|
|
total_cost_usd: 0,
|
|
duration_ms: 1,
|
|
stop_reason: 'end_turn',
|
|
});
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
|
|
function projectIdFromPrompt(promptText = '') {
|
|
const marker = '.od/projects/';
|
|
const idx = promptText.indexOf(marker);
|
|
if (idx === -1) return '';
|
|
return promptText
|
|
.slice(idx + marker.length)
|
|
.split(/[\\s/]/)[0]
|
|
.replace(/[^a-zA-Z0-9_-].*$/, '');
|
|
}
|
|
|
|
function projectDir(promptText = '') {
|
|
const marker = 'current working directory: \`';
|
|
const idx = promptText.toLowerCase().indexOf(marker);
|
|
const fromPrompt = idx === -1
|
|
? ''
|
|
: promptText.slice(idx + marker.length).split('\`')[0] || '';
|
|
const fromEnv = process.env.OD_DATA_DIR && process.env.OD_PROJECT_ID
|
|
? join(process.env.OD_DATA_DIR, 'projects', process.env.OD_PROJECT_ID)
|
|
: '';
|
|
const cwdFlagIndex = args.indexOf('-C');
|
|
const fromArgs = cwdFlagIndex >= 0 && typeof args[cwdFlagIndex + 1] === 'string'
|
|
? args[cwdFlagIndex + 1]
|
|
: '';
|
|
return process.env.OD_PROJECT_DIR || fromEnv || fromArgs || codexCwd || fromPrompt || process.cwd();
|
|
}
|
|
|
|
function writeJson(value) {
|
|
process.stdout.write(JSON.stringify(value) + '\\n');
|
|
}
|
|
|
|
function exitSoon(code) {
|
|
// app-server completes a turn before the host closes stdin. Keep the pipe
|
|
// alive until that EOF, just like the real server; exit 0 is not the handshake.
|
|
if (codexAppServer && code === 0) return;
|
|
setTimeout(() => process.exit(code), 10);
|
|
}
|
|
|
|
// Emit a Claude stream-json turn that carries a TodoWrite tool_use snapshot and
|
|
// a chosen terminal stop_reason. The turn ends cleanly (exit 0 -> succeeded), but
|
|
// its declared work is left in whatever state the todos describe — the fixture the
|
|
// #1247 / #1060 completeness tests drive. Only the claude runtime models a
|
|
// content-level tool_use + per-turn stop_reason, so these fixtures use it.
|
|
function emitClaudeTodoRun(todos, stopReason) {
|
|
if (agentId !== 'claude') {
|
|
throw new Error('emitClaudeTodoRun fixtures require the claude fake runtime, got ' + agentId);
|
|
}
|
|
writeJson({ type: 'system', subtype: 'init', model: 'fake-claude', session_id: 'fake-session' });
|
|
writeJson({
|
|
type: 'assistant',
|
|
message: {
|
|
id: 'msg-1',
|
|
stop_reason: stopReason,
|
|
content: [
|
|
{ type: 'tool_use', id: 'tw-1', name: 'TodoWrite', input: { todos } },
|
|
{ type: 'text', text: 'Here is the plan.' },
|
|
],
|
|
},
|
|
});
|
|
writeJson({ type: 'result', usage: { input_tokens: 1, output_tokens: 1 }, total_cost_usd: 0, duration_ms: 1, stop_reason: stopReason });
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
|
|
function emitSuccess(artifact, isChunked, includeThinking) {
|
|
const first = artifact.slice(0, Math.ceil(artifact.length / 2));
|
|
const second = artifact.slice(Math.ceil(artifact.length / 2));
|
|
switch (agentId) {
|
|
case 'codex':
|
|
if (codexAppServer) {
|
|
const item = { id: 'fake-message', type: 'agentMessage', phase: 'final_answer' };
|
|
notifyCodex('item/started', { item: { ...item, text: '' } });
|
|
for (const delta of isChunked ? [first, second] : [artifact]) {
|
|
notifyCodex('item/agentMessage/delta', { itemId: item.id, delta });
|
|
}
|
|
notifyCodex('item/completed', { item: { ...item, text: artifact } });
|
|
completeCodexTurn();
|
|
return;
|
|
}
|
|
writeJson({ type: 'thread.started', thread_id: 'fake-codex-session' });
|
|
writeJson({ type: 'turn.started' });
|
|
if (isChunked) {
|
|
writeJson({ type: 'item.completed', item: { type: 'agent_message', text: first } });
|
|
writeJson({ type: 'item.completed', item: { type: 'agent_message', text: second } });
|
|
} else {
|
|
writeJson({ type: 'item.completed', item: { type: 'agent_message', text: artifact } });
|
|
}
|
|
writeJson({ type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1 } });
|
|
return;
|
|
case 'claude':
|
|
writeJson({ type: 'system', subtype: 'init', model: 'fake-claude', session_id: 'fake-session' });
|
|
writeJson({
|
|
type: 'assistant',
|
|
message: {
|
|
id: 'msg-1',
|
|
content: [
|
|
...(includeThinking ? [{ type: 'thinking', thinking: 'Recovered delayed reasoning trace.' }] : []),
|
|
{ type: 'text', text: artifact },
|
|
],
|
|
},
|
|
});
|
|
writeJson({ type: 'result', usage: { input_tokens: 1, output_tokens: 1 }, total_cost_usd: 0, duration_ms: 1, stop_reason: 'end_turn' });
|
|
return;
|
|
case 'gemini':
|
|
writeJson({ type: 'init', session_id: 'fake-gemini', model: 'fake-gemini' });
|
|
writeJson({ type: 'message', role: 'assistant', content: artifact, delta: true });
|
|
writeJson({ type: 'result', status: 'success', stats: { input_tokens: 1, output_tokens: 1, cached: 0, duration_ms: 1 } });
|
|
return;
|
|
case 'opencode':
|
|
writeJson({ type: 'step_start', sessionID: 'fake-opencode', part: { type: 'step-start' } });
|
|
writeJson({ type: 'text', sessionID: 'fake-opencode', part: { type: 'text', text: artifact } });
|
|
writeJson({ type: 'step_finish', sessionID: 'fake-opencode', part: { type: 'step-finish', tokens: { input: 1, output: 1 }, cost: 0 } });
|
|
return;
|
|
case 'cursor-agent':
|
|
writeJson({ type: 'system', subtype: 'init', model: 'fake-cursor' });
|
|
writeJson({ type: 'assistant', timestamp_ms: 1, message: { role: 'assistant', content: [{ type: 'text', text: artifact }] } });
|
|
writeJson({ type: 'result', duration_ms: 1, usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 } });
|
|
return;
|
|
case 'qoder':
|
|
writeJson({ type: 'system', subtype: 'init', qodercli_version: '0.0.0', model: 'fake-qoder', session_id: 'fake-qoder' });
|
|
writeJson({ type: 'assistant', message: { content: [{ type: 'text', text: artifact }] }, session_id: 'fake-qoder' });
|
|
writeJson({ type: 'result', subtype: 'success', duration_ms: 1, is_error: false, stop_reason: 'end_turn', total_cost_usd: 0, usage: { input_tokens: 1, output_tokens: 1 } });
|
|
return;
|
|
case 'copilot':
|
|
writeJson({ type: 'session.tools_updated', data: { model: 'fake-copilot' } });
|
|
writeJson({ type: 'assistant.turn_start', data: {} });
|
|
writeJson({ type: 'assistant.message_delta', data: { deltaContent: artifact } });
|
|
writeJson({ type: 'result', success: true, exitCode: 0, usage: { input_tokens: 1, output_tokens: 1, sessionDurationMs: 1 } });
|
|
return;
|
|
case 'qwen':
|
|
case 'deepseek':
|
|
process.stdout.write(artifact + '\\n');
|
|
return;
|
|
default:
|
|
process.stdout.write(artifact + '\\n');
|
|
}
|
|
}
|
|
|
|
async function emitOrbitRun() {
|
|
const artifact = await createOrbitLiveArtifact();
|
|
const text = 'Orbit fake digest registered live artifact ' + artifact.id + ' for project ' + artifact.projectId + '.';
|
|
emitSuccess(text, false);
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
|
|
async function createOrbitLiveArtifact() {
|
|
const baseUrl = process.env.OD_DAEMON_URL;
|
|
const token = process.env.OD_TOOL_TOKEN;
|
|
if (!baseUrl || !token) {
|
|
throw new Error('Orbit fake run requires OD_DAEMON_URL and OD_TOOL_TOKEN');
|
|
}
|
|
const url = new URL('/api/tools/live-artifacts/create', baseUrl);
|
|
const payload = {
|
|
input: {
|
|
title: 'Orbit Daily Digest',
|
|
slug: 'orbit-daily-digest',
|
|
preview: { type: 'html', entry: 'index.html' },
|
|
document: {
|
|
format: 'html_template_v1',
|
|
templatePath: 'template.html',
|
|
generatedPreviewPath: 'index.html',
|
|
dataPath: 'data.json',
|
|
dataJson: {
|
|
headline: 'Orbit daily digest',
|
|
takeaway1: 'Fake connector activity was summarized through the daemon Orbit path.',
|
|
takeaway2: 'The live artifact tool token was accepted.',
|
|
takeaway3: 'The digest can be opened and previewed from the Orbit project.',
|
|
checked: 'fake activity feed and fake task updates',
|
|
},
|
|
},
|
|
},
|
|
templateHtml: '<!doctype html><html><body><main><h1>{{data.headline}}</h1><ul><li>{{data.takeaway1}}</li><li>{{data.takeaway2}}</li><li>{{data.takeaway3}}</li></ul><p>{{data.checked}}</p></main></body></html>',
|
|
provenanceJson: {
|
|
generatedAt: new Date().toISOString(),
|
|
generatedBy: 'agent',
|
|
sources: [{ label: 'Fake Orbit e2e data', type: 'derived' }],
|
|
},
|
|
};
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
authorization: 'Bearer ' + token,
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const text = await response.text();
|
|
let body = {};
|
|
try {
|
|
body = text ? JSON.parse(text) : {};
|
|
} catch {
|
|
body = { raw: text };
|
|
}
|
|
if (!response.ok || !body.artifact) {
|
|
throw new Error('Orbit live artifact create failed: HTTP ' + response.status + ' ' + text.slice(0, 500));
|
|
}
|
|
return body.artifact;
|
|
}
|
|
|
|
function failUnhandled(error) {
|
|
process.stderr.write((error && error.stack ? error.stack : String(error)) + '\\n');
|
|
process.exitCode = 1;
|
|
exitSoon(1);
|
|
}
|
|
|
|
function emitFailure() {
|
|
switch (agentId) {
|
|
case 'codex':
|
|
if (codexAppServer) return completeCodexTurn('intentional fake codex failure');
|
|
writeJson({ type: 'thread.started' });
|
|
writeJson({ type: 'turn.started' });
|
|
writeJson({ type: 'turn.failed', error: { message: 'intentional fake codex failure' } });
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
return;
|
|
case 'opencode':
|
|
writeJson({ type: 'error', error: { data: { message: 'intentional fake opencode failure' } } });
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
return;
|
|
case 'qoder':
|
|
writeJson({ type: 'assistant', message: { content: [] }, error: { message: 'intentional fake qoder failure' } });
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
return;
|
|
default:
|
|
process.stderr.write('intentional fake ' + agentId + ' failure\\n');
|
|
process.exitCode = 1;
|
|
exitSoon(1);
|
|
}
|
|
}
|
|
|
|
function emitServiceFailure(statusCode) {
|
|
const message =
|
|
statusCode === 429
|
|
? 'HTTP 429 Too Many Requests: rate limit exceeded for the current provider.'
|
|
: 'HTTP 503 Service Unavailable: upstream model provider is temporarily unavailable.';
|
|
switch (agentId) {
|
|
case 'codex':
|
|
if (codexAppServer) return completeCodexTurn(message);
|
|
writeJson({ type: 'thread.started' });
|
|
writeJson({ type: 'turn.started' });
|
|
writeJson({ type: 'turn.failed', error: { message } });
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
return;
|
|
case 'opencode':
|
|
writeJson({ type: 'error', error: { data: { message } } });
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
return;
|
|
default:
|
|
process.stderr.write(message + '\\n');
|
|
process.exitCode = 1;
|
|
exitSoon(1);
|
|
}
|
|
}
|
|
|
|
function emitModelUnavailableFailure() {
|
|
const message = 'The selected model is not available for this account: model not found.';
|
|
writeJson({ type: 'thread.started' });
|
|
writeJson({ type: 'turn.started' });
|
|
writeJson({ type: 'turn.failed', error: { message } });
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
|
|
function emitTimeoutFailure() {
|
|
const message = 'The upstream model request timed out while waiting for a response.';
|
|
writeJson({ type: 'thread.started' });
|
|
writeJson({ type: 'turn.started' });
|
|
writeJson({ type: 'turn.failed', error: { message } });
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
|
|
// Reproduces a connection that dropped mid-response. This shape is NOT guessed:
|
|
// it was captured by pointing the real Claude Code CLI (2.1.168) at a fake
|
|
// Anthropic endpoint that accepts the request, starts streaming, then destroys
|
|
// the TCP socket. The real CLI exhausts its internal retries and then, on
|
|
// STDOUT (not stderr), emits the SDK error as a synthetic assistant text block
|
|
// plus a result frame carrying is_error:true with subtype "success", and exits
|
|
// 1. The daemon's claude diagnostic reads the stdout tail and classifies it as
|
|
// a retryable connection drop.
|
|
//
|
|
// Only claude is modeled here: the daemon connection-drop diagnostic is
|
|
// claude-specific, and the real Codex CLI fails this class differently (it
|
|
// streams "Reconnecting... N/5" and a turn.failed event over its own
|
|
// transport), so a faithful Codex case belongs with Codex-specific handling,
|
|
// not this claude reproduction.
|
|
function emitSocketDropFailure() {
|
|
const sdkError =
|
|
'API Error: The socket connection was closed unexpectedly. For more information, pass \`verbose: true\` in the second argument to fetch()';
|
|
if (agentId === 'claude') {
|
|
writeJson({ type: 'system', subtype: 'init', model: 'fake-claude', session_id: 'fake-session' });
|
|
writeJson({
|
|
type: 'assistant',
|
|
message: {
|
|
id: 'msg-1',
|
|
model: '<synthetic>',
|
|
role: 'assistant',
|
|
stop_reason: 'stop_sequence',
|
|
content: [{ type: 'text', text: sdkError }],
|
|
},
|
|
error: 'unknown',
|
|
});
|
|
writeJson({
|
|
type: 'result',
|
|
subtype: 'success',
|
|
is_error: true,
|
|
result: sdkError,
|
|
stop_reason: 'stop_sequence',
|
|
duration_ms: 1,
|
|
total_cost_usd: 0,
|
|
usage: { input_tokens: 0, output_tokens: 0 },
|
|
});
|
|
process.exitCode = 1;
|
|
exitSoon(1);
|
|
return;
|
|
}
|
|
// Other runtimes are not the subject of the claude connection diagnostic;
|
|
// surface a generic non-zero exit carrying the same SDK error text.
|
|
process.stderr.write(sdkError + '\\n');
|
|
process.exitCode = 1;
|
|
exitSoon(1);
|
|
}
|
|
|
|
// Mirrors the terminal result frame emitted by Claude Code when its composed
|
|
// prompt exceeds the model context window. This deliberately goes through the
|
|
// stream parser and run-failure classifier instead of pre-seeding a normalized
|
|
// AGENT_PROMPT_TOO_LARGE event in the project message store.
|
|
function emitClaudePromptTooLongFailure() {
|
|
if (agentId !== 'claude') {
|
|
process.stderr.write('prompt-too-long fixture requires the claude fake runtime\\n');
|
|
process.exitCode = 1;
|
|
exitSoon(1);
|
|
return;
|
|
}
|
|
writeJson({ type: 'system', subtype: 'init', model: 'fake-claude', session_id: 'fake-session' });
|
|
writeJson({
|
|
type: 'result',
|
|
subtype: 'error_during_execution',
|
|
is_error: true,
|
|
result: 'Prompt is too long',
|
|
stop_reason: null,
|
|
});
|
|
process.exitCode = 1;
|
|
exitSoon(1);
|
|
}
|
|
|
|
// Exercise the real OpenCode JSON event parser with the structured shape fixed
|
|
// in #6933. A completed tool part with exitCode != 0 must become an errored
|
|
// tool_result; four identical failures cross the daemon guard's default WARN
|
|
// threshold while still allowing the run to finish (HALT is opt-in).
|
|
function emitOpenCodeRepeatedToolFailures() {
|
|
if (agentId !== 'opencode') {
|
|
process.stderr.write('repeated tool-failure fixture requires the opencode fake runtime\\n');
|
|
process.exitCode = 1;
|
|
exitSoon(1);
|
|
return;
|
|
}
|
|
writeJson({ type: 'step_start', sessionID: 'fake-opencode-loop', part: { type: 'step-start' } });
|
|
for (let index = 0; index < 4; index += 1) {
|
|
writeJson({
|
|
type: 'tool_use',
|
|
sessionID: 'fake-opencode-loop',
|
|
part: {
|
|
type: 'tool',
|
|
tool: 'bash',
|
|
callID: 'fake-opencode-failure-' + index,
|
|
state: {
|
|
status: 'completed',
|
|
input: { command: 'cat missing-open-design-file.txt' },
|
|
output: 'cat: missing-open-design-file.txt: No such file or directory',
|
|
exitCode: 1,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
writeJson({
|
|
type: 'text',
|
|
sessionID: 'fake-opencode-loop',
|
|
part: { type: 'text', text: 'Stopped retrying after repeated tool failures.' },
|
|
});
|
|
writeJson({
|
|
type: 'step_finish',
|
|
sessionID: 'fake-opencode-loop',
|
|
part: { type: 'step-finish', tokens: { input: 1, output: 1 }, cost: 0 },
|
|
});
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
|
|
function emitEmptySuccess() {
|
|
switch (agentId) {
|
|
case 'codex':
|
|
if (codexAppServer) return completeCodexTurn();
|
|
writeJson({ type: 'thread.started' });
|
|
writeJson({ type: 'turn.started' });
|
|
writeJson({ type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 0 } });
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
return;
|
|
default:
|
|
process.exitCode = 0;
|
|
exitSoon(0);
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
}
|