mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-07 23:09:34 +08:00
* feat(daemon): codex native session resume (capture-style) Codex follow-up turns re-pay the whole conversation as cache-cold input: the daemon flattens history into a fresh user message every turn and starts `codex exec` from scratch, so codex rebuilds its own structure and the upstream prefix cache from the previous turn no longer matches. Measured on real codex (consecutive turns): resume reuses 96% of the prefix vs 39% for flattened-resend — ~15x fewer recomputed tokens. Generalize the claude-style `resumesSessionViaCli` path to codex, which is "capture-style": codex mints its OWN thread id and reports it on the first stream event `thread.started`, so the daemon must capture that id and replay it rather than specifying one (claude `--session-id`). - types: add `capturesSessionIdFromStream` to distinguish capture-style from specify-style resume adapters. - codex def: `resumesSessionViaCli` + `capturesSessionIdFromStream`; buildArgs emits `exec resume <thread_id>` when a stored handle exists, else `exec`. `exec resume` rejects `--sandbox`, so the sandbox is passed as `-c sandbox_mode=...` so the per-turn context block byte-matches the create turn and does not break the prefix. - json-event-stream: capture `thread.started.thread_id` onto the status event's `sessionId`. - server: capture the reported id and persist THAT (not the unused minted `newSessionId`) on a create turn; skip the transcript resend on resume. - resume-missing fallback: detect codex `no rollout found for thread id` (generalized `isAgentResumeFailure` dispatch), clear the stale handle, and surface a retryable error so the next turn re-seeds the full transcript — one cold turn, never a broken conversation. The check runs BEFORE the generic stream-error short-circuit so a stale handle is always cleared. Tests: codex buildArgs create/resume shapes; thread.started capture (+ null case); codex resume-failure detection + dispatch; and an end-to-end real- daemon spec (capture -> resume without history resend; dead-thread fallback -> fresh exec next turn). * feat(daemon): opencode native session resume (capture-style) Extend the capture-style resume infra (codex) to OpenCode direct (~16% of users). Same shape: OpenCode mints its own session id and stamps it on every stream event as `sessionID` (e.g. `ses_...`); the daemon captures it and continues the session with `opencode run -s <id>` on the next turn instead of re-flattening the transcript, so the first upstream call reuses the warm prefix cache. Measured on real OpenCode: turn-2 resume = input 162 + cache_read 8192 (~98% reused) vs cache_read 0 cold. - opencode def: thread `runtimeContext` through buildArgs; emit `-s <id>` on a resume turn, plain `run` on create; `resumesSessionViaCli` + `capturesSessionIdFromStream`. - json-event-stream: capture `step_start.sessionID` onto the status event's `sessionId` (OpenCode stamps it on every event; step_start is the opener). - resume-missing fallback: `isOpencodeResumeFailure` (`Session not found` / `NotFoundError`) wired into `isAgentResumeFailure`. Verified against the real CLI: `run -s <missing>` prints `Error: Session not found` to stderr and exits 0 — the hoisted fallback clears the stale handle regardless of exit code, so the next turn re-seeds the full transcript (one cold turn, never broken). Tests: opencode buildArgs create/resume shapes; step_start session capture (+ null case); session-not-found detection + dispatch; and an end-to-end real- daemon spec (capture -> resume without history resend; dead-session fallback -> fresh run next turn, exercising the exit-0 failure path). * fix(daemon): gate codex `-C`/`--add-dir` to create turns (rejected by exec resume) `codex exec resume` rejects the create-only `-C <cwd>` and `--add-dir <dir>` flags (`error: unexpected argument '-C' found`), so the resume branch falling through the shared appends would make every follow-up Codex turn die before the first event. The daemon already spawns the child with `cwd: effectiveCwd`, and resuming by explicit SESSION_ID does not use codex's cwd-based session filtering (verified: resume by UUID succeeds from any cwd), so the resumed turn runs in the right workspace without `-C`; the extra writable dirs were granted when the session was created and ride along with the resumed session. - codex def: gate `-C` / `--add-dir` to `!resumeSessionId`. - unit: resume argv excludes `-C` / `--add-dir` even when cwd + extra dirs are supplied; create argv still includes both. - e2e: the fake codex now rejects `-C` / `--add-dir` on resume exactly like the real binary, so the regression is caught end-to-end; chat-turn filtering keys off the spawn cwd (no `-C` to match on resume turns). Reported by @nettee on #4629. * fix(daemon): resume identity guard — reseed on intervening turns / model / cwd Setting `resumesSessionViaCli` for codex/opencode widened the transcript-skip path, but `agent_sessions` only keyed on (conversation_id, agent_id). A conversation like `Codex -> other agent -> Codex` would resume the stale codex session and skip the intervening turns, so the resumed agent silently lost visible conversation state (reported by @nettee on #4629). The keying also did not encode model/cwd, so a model/cwd switch could keep resuming a session built under different parameters (raised by @PerishCode on #4627). Persist a resume identity with each session row and only resume when it still matches: - `model` / `cwd` — the runtime identity the upstream session was created with; a change forces a fresh session. - `last_message_id` — the assistant message the session last produced. At the next turn's resolve time the latest completed assistant message (excluding the current run's in-flight placeholder) must still be that id; otherwise another agent ran in between (or it was edited away) and the session is behind. On any mismatch `resolveAgentResumeContext` returns `isResuming: false` (with an `invalidationReason`), so the daemon starts a fresh session reseeded with the full transcript — worst case one cold turn, never amnesia. A null cursor (legacy row) is treated as unverifiable and reseeded once. The cursor is advanced on every successful create AND resume turn so back-to-back same-agent turns keep resuming. Tests: resolveAgentResumeContext invalidation matrix (in-sync resume, current placeholder excluded, model_changed, cwd_changed, conversation_advanced, missing_cursor); agent_sessions identity round-trip; and an end-to-end `codex -> claude -> codex` spec asserting codex reseeds (fresh `exec`, no resume) after the intervening turn. * fix(daemon): persist resume identity on the pi capture path too The identity guard routes pi-rpc through resolveAgentResumeContext, but pi persists via persistCapturedAgentSession() which only wrote session_id + stable_prompt_hash. After one successful pi turn the row came back with model/cwd/last_message_id = null, so the guard returned `missing_cursor` and reseeded every turn — silently disabling pi's existing follow-up-session path (reported by @nettee on #4629). Extend persistCapturedAgentSession to store model/cwd/last_message_id and pass them from the pi-rpc call site (run.model / effectiveCwd / run.assistantMessageId), so a successful pi turn keeps a verifiable identity and resumes next turn. Regression test: a stored pi session with the identity resumes; the existing clear-on-no-capture path is unchanged. * fix(daemon): scan only the CLI failure channel for resume-miss, not assistant stdout The resume-miss fallback fed both agentStderrTail AND agentStdoutTail into isAgentResumeFailure(). agentStdoutTail holds ordinary assistant text, so the OpenCode matcher (generic /session not found/i) could fire on a SUCCESSFUL turn whose model output merely mentioned the phrase — clearing the stored session and failing the turn (reported by @nettee on #4629). Detect resume-miss only from each CLI's failure channel: codex/opencode match stderr only; Claude matches prose on stderr and its structured stream-json result event on stdout (never the prose against stdout). Regression test: a generic phrase in successful assistant stdout does not trigger a resume miss. * fix(daemon): resume cursor counts only succeeded assistant turns latestCompletedAssistantMessageId is documented and consumed as the latest COMPLETED assistant turn for the resume-identity guard, but the query matched any assistant message regardless of run_status. A sequence like `codex success -> other agent fails/cancels before output -> codex again` read the failed/canceled placeholder as conversation advancement, returned `conversation_advanced`, and forced a needless cold reseed — silently disabling the resume perf path even though no completed turn was added after the stored session. Filter the cursor to `run_status = 'succeeded'` (a run stamps its terminal status on finish; in-flight placeholders are null and were already excluded). Adds a red-spec (db-agent-sessions: intervening failed/canceled run does not advance the cursor) and makes the resolveAgentResumeContext fixtures faithful by stamping completed assistant turns 'succeeded' and placeholders null. Reported by @nettee on #4629. * fix(daemon): resume cursor admits the session own failed turn (resume-on-failure) The run_status='succeeded' cursor filter regressed the resume-on-failure path: a transiently-failed-but-resumable turn persists a session pointing at its own FAILED assistant message, but that message was then excluded from the cursor, so resolveAgentResumeContext returned conversation_advanced and the next turn cold- restarted instead of --resume (broke run-resume-on-failure.test.ts). Admit the stored session's own lastMessageId through the filter via a new `resumableMessageId` arg: the session it owns still matches its cursor, while a DIFFERENT later failed/canceled turn stays excluded and a later succeeded turn is still detected as genuine advancement. Adds two guard tests: a stored failed cursor still resumes, and a later succeeded turn after the stored failed turn still reseeds. Reported by @nettee on #4629. * feat(daemon): AMR (ACP) session resume — capture durable handle + session/load + resume_failed Wire the AMR/vela runtime into session resume, mirroring the pi-rpc capture pattern (the handle comes from the ACP result, not a --session-id flag or a stream status event): - acp.ts: attachAcpSession accepts resumeSessionId and drives `session/load` (instead of session/new) to resume the prior upstream session; captures the durable `openCodeSessionId` from the result and exposes getDurableSessionId(). - new def flag resumesSessionViaAcpLoad (set on amr.ts) opts AMR into resume without colliding with resumesSessionViaCli / capturesSessionIdFromStream. - server.ts: agentSupportsSessionResume includes it; pass the stored handle as resumeSessionId on a resume turn (guarded by the resume-identity check); persist the captured durable handle on success via persistCapturedAgentSession (with model/cwd/lastMessageId); extend the resume-failure reseed gate. - agent-session-resume.ts: isAmrResumeFailure matches vela's structured {"kind":"resume_failed"} on stdout (the ACP channel), and isAgentResumeFailure dispatches 'amr' to it. skipTranscript already follows isResuming. Tests: session/load drive + durable-handle capture (acp.test); isAmrResumeFailure structured match (stdout only, not bare prose). Daemon typecheck + guard + existing AMR integration / resume suites green. * test(daemon): AMR resume integration — session/load drive, durable handle, resume_failed Extend fake-vela.mjs with openCodeSessionId on session/new, a session/load handler that echoes the durable handle, and FAKE_VELA_RESUME_FAILED to emit the structured resume_failed on prompt. Integration tests: a resume turn drives session/load and captures the handle; a missing resumed session surfaces resume_failed and does not complete. * fix(daemon): clear AMR resume handle on resume_failed before fatal short-circuit vela reports a missing/expired upstream OpenCode session as a structured `resume_failed` JSON-RPC error on session/prompt, which the ACP bridge turns into a fatal. The server close handler's `hasFatalError()` short-circuit ran BEFORE the resume-failure reseed block, so for AMR (resumesSessionViaAcpLoad) the dead durable handle was never cleared — every later turn re-issued session/load against the same dead session and failed forever (#4275 class). Hoist the resume-failure recovery (clear stale handle + retryable reseed error) above the fatal/stream-error short-circuits so it fires for both the codex stream-error shape and the AMR structured-fatal shape. Add server-level coverage (amr-session-resume.test.ts) driving the FULL close handler through startServer + fake-vela: happy session/load resume, the dead-resume -> reseed cycle (red without this fix), null durable handle -> fresh session, and model-change -> fresh session. fake-vela gains a didLoad gate (resume_failed fires only on a resumed turn), an invocation log, a --version handler, and an omit-handle knob. * fix(daemon): resume guard keys off the concrete launched model The resume-identity guard compared the raw request model (`run.model`, often `default`/null) and persisted that into `agent_sessions.model`, but the model actually launched is `safeModel` — for AMR the preflight rewrites a `default` request to the live catalog's first entry. So a conversation that ran under an implicit default stored `null`/`default`; changing the effective default between turns would still pass the guard and resume the old upstream session under the wrong model (skipping transcript reseeding). Hoist model resolution above the resume guard so it (and every `agent_sessions.model` write) keys off the concrete launched model. The AMR default->live-catalog rewrite is mirrored before the guard (read-only, cached); the existing preflight stays authoritative for auth/availability. Adds a red-spec: a default turn followed by an equivalent explicit model now resumes (was a needless cold reseed). Reported by @nettee on #4704. * chore(pack): bump bundled @powerformer/vela-cli to 0.0.18-test.0 Pulls the AMR OpenCode session-reuse changes (powerformer/vela#434, built from its feature branch via the vela-cli test channel) into the packaged bundle so a beta build can validate AMR session resume end-to-end. Reverts to a stable @powerformer/vela-cli pin once #434 ships to vela main. * chore(nix): refresh pnpm deps hash * feat(daemon): transparently auto-reseed when a resumed session is gone When a resume-capable agent (claude/codex/opencode/AMR) tries to resume an upstream CLI session that has expired/pruned, the daemon previously surfaced a retryable "previous session could not be resumed — resend" error and made the user re-send. That is a visible blemish on what is meant to be an invisible optimization. Now the daemon recovers transparently: it clears the dead handle and re-runs the SAME turn once with a fresh session + the full transcript rebuilt from the DB — exactly the pre-session-reuse path. The user sees one (slightly slower) turn, never an error. Reuses the existing same-run retry machinery (scheduleRetryRestart); the re-spawn resolves isResuming=false so it cannot resume-fail again, and a `resumeAutoReseeded` guard is belt-and-suspenders against any loop. Observability: emits an `agent_resume_auto_reseed` diagnostic into the per-run events.jsonl (bundled by the help → diagnostics export, so the full resume → fail → reseed chain is visible in a support bundle with no user-facing signal), and adds a `resume_auto_reseeded` field to the run_finished analytics so the fallback frequency can be monitored (should be rare). Updates the AMR/codex/opencode dead-session server tests to assert the turn now succeeds transparently, and adds run-diagnostics coverage for the new flag. * chore: re-trigger CI on updated main — needs-validation gate moved to merge_group (#4714) * chore(pack): bump bundled @powerformer/vela-cli to 0.0.18-test.1 Pulls the OpenCode interactive-question-tool fix (powerformer/vela#434: OPENCODE_CLIENT=acp withholds the question tool over the ACP bridge, which was crashing AMR turns with an empty-output failure) into the packaged bundle for the QA beta. * chore(nix): refresh pnpm deps hash * fix(daemon): keep AMR same-turn resume reseed invisible (suppress resume_failed error) When an AMR (vela) run resumes an upstream session via session/load and that session is gone, the ACP bridge calls fail() -> send('error') for the failed load BEFORE the child-close handler clears the stale handle and re-runs the turn fresh. The forwarded error flashed a client-visible execution failure — and tripped any client treating an SSE `error` as terminal — a beat before the recovery that is supposed to be invisible. Hold the error back in the ACP send wrapper when this run is resuming via session/load and the agent reports resume_failed: emit a non-user-visible `agent_resume_failed_suppressed` diagnostic instead of forwarding the error. The close handler stays the sole authority on whether the turn ends in an error or a transparent reseed; the resumeAutoReseeded guard still lets a second resume failure fall through to the explicit "resend your message" affordance. Red spec: amr-session-resume.test.ts asserts the run event log carries no client-visible `error` event during the transparent reseed (only the suppression diagnostic), going red on the pre-fix code (an `error` event with kind=resume_failed) and green after. * test(daemon): cover resume reseed event invariants --------- Co-authored-by: open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>
446 lines
16 KiB
TypeScript
446 lines
16 KiB
TypeScript
import type { Server } from 'node:http';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import { startServer } from '../src/server.js';
|
|
|
|
// End-to-end coverage for OpenCode native (capture-style) session resume.
|
|
//
|
|
// OpenCode mints its own session id and stamps it on every stream event as
|
|
// `sessionID` (e.g. `ses_...`). The daemon must:
|
|
// 1. capture that id from the stream and persist it (NOT the daemon-minted
|
|
// `newSessionId`, which OpenCode ignores),
|
|
// 2. continue it next turn with `opencode run -s <id>` and send ONLY the new
|
|
// turn (no flattened-history resend), and
|
|
// 3. when the session store is gone (`Session not found`), clear the stale
|
|
// handle, surface a retryable error, and let the next turn start fresh
|
|
// re-seeded with the full transcript.
|
|
//
|
|
// On origin/main OpenCode is not `resumesSessionViaCli`, so it always runs
|
|
// plain `run` and re-pays the whole transcript — turn-2 here would carry no
|
|
// `-s` and would include the prior reply, going red.
|
|
|
|
type StartedServer = {
|
|
url: string;
|
|
server: Server;
|
|
shutdown?: () => Promise<void> | void;
|
|
};
|
|
|
|
type RunStatus = {
|
|
id: string;
|
|
status: string;
|
|
error: string | null;
|
|
errorCode: string | null;
|
|
eventsLogPath: string;
|
|
resumable?: boolean;
|
|
};
|
|
|
|
type RunInvocation = { argv: string[]; stdin: string; cwd: string };
|
|
type RunEvent = { event: string; data: unknown };
|
|
|
|
const SESSION = 'ses_e2e0000resume0000';
|
|
const FIRST_REPLY_SENTINEL = 'FIRST_TURN_REPLY_SENTINEL_0c7d2';
|
|
|
|
describe('opencode native session resume', () => {
|
|
const originalEnv = snapshotEnv();
|
|
let started: StartedServer | null = null;
|
|
let binDir: string | null = null;
|
|
|
|
afterEach(async () => {
|
|
await Promise.resolve(started?.shutdown?.());
|
|
if (started?.server) {
|
|
await new Promise<void>((resolve) => started?.server.close(() => resolve()));
|
|
}
|
|
started = null;
|
|
if (binDir) await removeTempDir(binDir);
|
|
binDir = null;
|
|
restoreEnv(originalEnv);
|
|
});
|
|
|
|
it('captures the session id on turn 1 and resumes it (without resending history) on turn 2', async () => {
|
|
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-opencode-resume-bin-'));
|
|
const { bin, logPath } = await writeCapturingOpencode(binDir, 'opencode-capture');
|
|
|
|
clearTelemetryEnv();
|
|
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
|
|
await putConfig(started.url, {
|
|
agentId: 'opencode',
|
|
agentCliEnv: { opencode: { OPENCODE_BIN: bin } },
|
|
telemetry: { metrics: true, content: false, artifactManifest: false },
|
|
privacyDecisionAt: Date.now(),
|
|
});
|
|
|
|
const conversationId = await createConversation(started.url);
|
|
|
|
const turn1 = await sendRunAndWait(started.url, conversationId, 'first user request');
|
|
expect(turn1.status).toBe('succeeded');
|
|
|
|
const turn2 = await sendRunAndWait(started.url, conversationId, 'second user request please');
|
|
expect(turn2.status).toBe('succeeded');
|
|
|
|
const runs = await readChatTurnRuns(logPath, conversationId);
|
|
expect(runs).toHaveLength(2);
|
|
const [create, resume] = runs as [RunInvocation, RunInvocation];
|
|
|
|
// Turn 1 is a fresh `run` — no `-s`, no leaked id.
|
|
expect(create.argv[0]).toBe('run');
|
|
expect(create.argv).not.toContain('-s');
|
|
expect(create.argv).not.toContain(SESSION);
|
|
|
|
// Turn 2 continues the captured session id.
|
|
expect(resume.argv).toContain('-s');
|
|
expect(resume.argv[resume.argv.indexOf('-s') + 1]).toBe(SESSION);
|
|
|
|
// The turn-2 prompt must NOT re-send turn-1's assistant reply (history is
|
|
// carried by the resumed session), but must carry the new user message.
|
|
expect(resume.stdin).not.toContain(FIRST_REPLY_SENTINEL);
|
|
expect(resume.stdin).toContain('second user request please');
|
|
});
|
|
|
|
it('transparently auto-reseeds within the same turn on `Session not found`', async () => {
|
|
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-opencode-fallback-bin-'));
|
|
const { bin, logPath } = await writeMissingSessionOpencode(binDir, 'opencode-fallback');
|
|
|
|
clearTelemetryEnv();
|
|
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
|
|
await putConfig(started.url, {
|
|
agentId: 'opencode',
|
|
agentCliEnv: { opencode: { OPENCODE_BIN: bin } },
|
|
telemetry: { metrics: true, content: false, artifactManifest: false },
|
|
privacyDecisionAt: Date.now(),
|
|
});
|
|
|
|
const conversationId = await createConversation(started.url);
|
|
|
|
const turn1 = await sendRunAndWait(started.url, conversationId, 'first request');
|
|
expect(turn1.status).toBe('succeeded');
|
|
|
|
// Turn 2 resumes, but the session store is gone (OpenCode prints "Session
|
|
// not found" and exits 0, exercising the exit-0 resume-failure path). The
|
|
// daemon must NOT surface an error: it clears the dead handle and
|
|
// TRANSPARENTLY re-runs the same turn as a fresh `run` (full transcript),
|
|
// within this one run. The user sees a succeeded turn — no turn 3.
|
|
const turn2 = await sendRunAndWait(started.url, conversationId, 'second request');
|
|
expect(turn2.status).toBe('succeeded');
|
|
|
|
const events = await readRunEvents(turn2.eventsLogPath);
|
|
expect(events.filter((e) => e.event === 'error')).toEqual([]);
|
|
expect(hasDiagnostic(events, {
|
|
type: 'agent_resume_auto_reseed',
|
|
reason: 'resume_failed',
|
|
stale_session_cleared: true,
|
|
})).toBe(true);
|
|
|
|
// The create, then turn 2's dead resume (`-s`), then the in-turn fresh reseed.
|
|
const runs = await readChatTurnRuns(logPath, conversationId);
|
|
expect(runs).toHaveLength(3);
|
|
const [create, deadResume, fresh] = runs as [
|
|
RunInvocation,
|
|
RunInvocation,
|
|
RunInvocation,
|
|
];
|
|
expect(create.argv).not.toContain('-s');
|
|
expect(deadResume.argv).toContain('-s');
|
|
expect(fresh.argv).not.toContain('-s');
|
|
});
|
|
|
|
it('starts fresh on turn 2 when turn 1 succeeds without a captured session id', async () => {
|
|
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-opencode-nohandle-bin-'));
|
|
const { bin, logPath } = await writeNoHandleOpencode(binDir, 'opencode-nohandle');
|
|
|
|
clearTelemetryEnv();
|
|
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
|
|
await putConfig(started.url, {
|
|
agentId: 'opencode',
|
|
agentCliEnv: { opencode: { OPENCODE_BIN: bin } },
|
|
telemetry: { metrics: true, content: false, artifactManifest: false },
|
|
privacyDecisionAt: Date.now(),
|
|
});
|
|
|
|
const conversationId = await createConversation(started.url);
|
|
|
|
expect((await sendRunAndWait(started.url, conversationId, 'first request')).status)
|
|
.toBe('succeeded');
|
|
expect((await sendRunAndWait(started.url, conversationId, 'second request')).status)
|
|
.toBe('succeeded');
|
|
|
|
const runs = await readChatTurnRuns(logPath, conversationId);
|
|
expect(runs).toHaveLength(2);
|
|
const [turn1, turn2] = runs as [RunInvocation, RunInvocation];
|
|
expect(turn1.argv).not.toContain('-s');
|
|
expect(turn2.argv[0]).toBe('run');
|
|
expect(turn2.argv).not.toContain('-s');
|
|
});
|
|
});
|
|
|
|
// Fake opencode CLI: stamps a FIXED session id on a create turn and echoes it
|
|
// on a resume turn. Logs `{argv, stdin}` per invocation.
|
|
async function writeCapturingOpencode(
|
|
dir: string,
|
|
name: string,
|
|
): Promise<{ bin: string; logPath: string }> {
|
|
const bin = path.join(dir, name);
|
|
const logPath = path.join(dir, `${name}-log.jsonl`);
|
|
await writeFile(
|
|
bin,
|
|
fakeOpencodeSource({
|
|
logPath,
|
|
body: `
|
|
const isResume = argv.includes('-s');
|
|
const text = isResume ? 'Resumed reply.' : ${JSON.stringify(FIRST_REPLY_SENTINEL)};
|
|
console.log(JSON.stringify({ type: 'step_start', sessionID: SESSION, part: { type: 'step-start' } }));
|
|
console.log(JSON.stringify({ type: 'text', sessionID: SESSION, part: { type: 'text', text } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', sessionID: SESSION, part: { type: 'step-finish', tokens: { input: 11, output: 7, reasoning: 0, cache: { read: 5, write: 2 } }, cost: 0 } }));
|
|
setTimeout(() => process.exit(0), 10);`,
|
|
}),
|
|
'utf8',
|
|
);
|
|
await chmod(bin, 0o755);
|
|
return { bin, logPath };
|
|
}
|
|
|
|
// Fake opencode CLI: succeeds on a create turn (persisting the session), but a
|
|
// resume turn (`-s`) prints "Session not found" to stderr and exits 0 — the
|
|
// real OpenCode behavior for a missing session.
|
|
async function writeMissingSessionOpencode(
|
|
dir: string,
|
|
name: string,
|
|
): Promise<{ bin: string; logPath: string }> {
|
|
const bin = path.join(dir, name);
|
|
const logPath = path.join(dir, `${name}-log.jsonl`);
|
|
await writeFile(
|
|
bin,
|
|
fakeOpencodeSource({
|
|
logPath,
|
|
body: `
|
|
if (argv.includes('-s')) {
|
|
process.stderr.write('Error: Session not found\\n');
|
|
setTimeout(() => process.exit(0), 10);
|
|
return;
|
|
}
|
|
console.log(JSON.stringify({ type: 'step_start', sessionID: SESSION, part: { type: 'step-start' } }));
|
|
console.log(JSON.stringify({ type: 'text', sessionID: SESSION, part: { type: 'text', text: 'Created reply.' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', sessionID: SESSION, part: { type: 'step-finish', tokens: { input: 8, output: 2, reasoning: 0, cache: { read: 0, write: 0 } }, cost: 0 } }));
|
|
setTimeout(() => process.exit(0), 10);`,
|
|
}),
|
|
'utf8',
|
|
);
|
|
await chmod(bin, 0o755);
|
|
return { bin, logPath };
|
|
}
|
|
|
|
// Fake opencode CLI: succeeds but never stamps `sessionID` on stream events.
|
|
// Without a durable handle, the daemon must leave the next turn on fresh `run`.
|
|
async function writeNoHandleOpencode(
|
|
dir: string,
|
|
name: string,
|
|
): Promise<{ bin: string; logPath: string }> {
|
|
const bin = path.join(dir, name);
|
|
const logPath = path.join(dir, `${name}-log.jsonl`);
|
|
await writeFile(
|
|
bin,
|
|
fakeOpencodeSource({
|
|
logPath,
|
|
body: `
|
|
console.log(JSON.stringify({ type: 'step_start', part: { type: 'step-start' } }));
|
|
console.log(JSON.stringify({ type: 'text', part: { type: 'text', text: 'Reply without session id.' } }));
|
|
console.log(JSON.stringify({ type: 'step_finish', part: { type: 'step-finish', tokens: { input: 8, output: 2, reasoning: 0, cache: { read: 0, write: 0 } }, cost: 0 } }));
|
|
setTimeout(() => process.exit(0), 10);`,
|
|
}),
|
|
'utf8',
|
|
);
|
|
await chmod(bin, 0o755);
|
|
return { bin, logPath };
|
|
}
|
|
|
|
function fakeOpencodeSource(opts: { logPath: string; body: string }): string {
|
|
return `#!/usr/bin/env node
|
|
const fs = require('node:fs');
|
|
const logPath = ${JSON.stringify(opts.logPath)};
|
|
const SESSION = ${JSON.stringify(SESSION)};
|
|
const argv = process.argv.slice(2);
|
|
if (argv.includes('--version')) { console.log('1.17.7'); process.exit(0); }
|
|
if (argv.includes('--help')) { console.log('opencode run [message..]'); process.exit(0); }
|
|
if (argv[0] === 'models') { console.log('anthropic/claude-sonnet-4-5'); process.exit(0); }
|
|
let stdin = '';
|
|
let done = false;
|
|
function finish() {
|
|
if (done) return; done = true;
|
|
try { fs.appendFileSync(logPath, JSON.stringify({ argv, stdin, cwd: process.cwd() }) + '\\n'); } catch {}
|
|
run();
|
|
}
|
|
function run() {${opts.body}
|
|
}
|
|
process.stdin.setEncoding('utf8');
|
|
process.stdin.on('data', (d) => { stdin += d; });
|
|
process.stdin.on('end', finish);
|
|
process.stdin.on('error', finish);
|
|
setTimeout(finish, 1500);
|
|
`;
|
|
}
|
|
|
|
function snapshotEnv(): Record<string, string | undefined> {
|
|
return {
|
|
LANGFUSE_PUBLIC_KEY: process.env.LANGFUSE_PUBLIC_KEY,
|
|
LANGFUSE_SECRET_KEY: process.env.LANGFUSE_SECRET_KEY,
|
|
LANGFUSE_BASE_URL: process.env.LANGFUSE_BASE_URL,
|
|
OPEN_DESIGN_TELEMETRY_RELAY_URL: process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL,
|
|
POSTHOG_KEY: process.env.POSTHOG_KEY,
|
|
POSTHOG_HOST: process.env.POSTHOG_HOST,
|
|
};
|
|
}
|
|
|
|
function restoreEnv(env: Record<string, string | undefined>): void {
|
|
for (const [key, value] of Object.entries(env)) {
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
}
|
|
|
|
function clearTelemetryEnv(): void {
|
|
delete process.env.POSTHOG_KEY;
|
|
delete process.env.POSTHOG_HOST;
|
|
delete process.env.LANGFUSE_PUBLIC_KEY;
|
|
delete process.env.LANGFUSE_SECRET_KEY;
|
|
delete process.env.LANGFUSE_BASE_URL;
|
|
delete process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL;
|
|
}
|
|
|
|
async function putConfig(url: string, patch: Record<string, unknown>): Promise<void> {
|
|
const response = await fetch(`${url}/api/app-config`, {
|
|
method: 'PUT',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(patch),
|
|
});
|
|
expect(response.status).toBe(200);
|
|
}
|
|
|
|
async function createConversation(url: string): Promise<string> {
|
|
const projectId = `opencode_resume_${randomUUID()}`;
|
|
const projectResponse = await fetch(`${url}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'OpenCode resume smoke',
|
|
metadata: { kind: 'prototype' },
|
|
skipDiscoveryBrief: true,
|
|
}),
|
|
});
|
|
expect(projectResponse.status).toBe(200);
|
|
const projectBody = (await projectResponse.json()) as {
|
|
conversationId: string;
|
|
id: string;
|
|
};
|
|
return `${projectId}::${projectBody.conversationId}`;
|
|
}
|
|
|
|
async function sendRunAndWait(
|
|
url: string,
|
|
encoded: string,
|
|
message: string,
|
|
): Promise<RunStatus> {
|
|
const [projectId, conversationId] = encoded.split('::');
|
|
const assistantMessageId = `assistant_opencode_${randomUUID()}`;
|
|
const runResponse = await fetch(`${url}/api/runs`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-od-analytics-device-id': 'opencode-resume-test',
|
|
'x-od-analytics-session-id': 'opencode-resume-session',
|
|
'x-od-analytics-client-type': 'web',
|
|
},
|
|
body: JSON.stringify({
|
|
projectId,
|
|
conversationId,
|
|
assistantMessageId,
|
|
clientRequestId: `client_opencode_${randomUUID()}`,
|
|
agentId: 'opencode',
|
|
message,
|
|
currentPrompt: message,
|
|
}),
|
|
});
|
|
expect(runResponse.status).toBe(202);
|
|
const body = (await runResponse.json()) as { runId: string };
|
|
return await waitForRun(url, body.runId);
|
|
}
|
|
|
|
async function waitForRun(url: string, runId: string): Promise<RunStatus> {
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < 10_000) {
|
|
const response = await fetch(`${url}/api/runs/${encodeURIComponent(runId)}`);
|
|
expect(response.status).toBe(200);
|
|
const run = (await response.json()) as RunStatus;
|
|
if (run.status === 'failed' || run.status === 'succeeded' || run.status === 'canceled') {
|
|
return run;
|
|
}
|
|
await delay(100);
|
|
}
|
|
throw new Error(`run ${runId} did not finish`);
|
|
}
|
|
|
|
// Chat-turn `run` invocations for this conversation, in call order. OpenCode is
|
|
// also used as the daemon's background memory-llm and for `models` probes;
|
|
// those run from the repo root, so filter by the chat turn's cwd (the project
|
|
// working dir, which contains the project id). OpenCode receives its cwd via
|
|
// the spawn `cwd`, not an argv flag.
|
|
async function readChatTurnRuns(
|
|
logPath: string,
|
|
encoded: string,
|
|
): Promise<RunInvocation[]> {
|
|
const projectId = encoded.split('::')[0] ?? encoded;
|
|
let raw = '';
|
|
try {
|
|
raw = await readFile(logPath, 'utf8');
|
|
} catch {
|
|
return [];
|
|
}
|
|
return raw
|
|
.trim()
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line) as RunInvocation)
|
|
.filter(
|
|
(rec) =>
|
|
rec.argv[0] === 'run' &&
|
|
typeof rec.cwd === 'string' &&
|
|
rec.cwd.includes(projectId),
|
|
);
|
|
}
|
|
|
|
async function readRunEvents(eventsLogPath: string): Promise<RunEvent[]> {
|
|
let raw = '';
|
|
try {
|
|
raw = await readFile(eventsLogPath, 'utf8');
|
|
} catch {
|
|
return [];
|
|
}
|
|
return raw
|
|
.trim()
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line) as RunEvent);
|
|
}
|
|
|
|
function hasDiagnostic(events: RunEvent[], expected: Record<string, unknown>): boolean {
|
|
return events.some((event) => {
|
|
if (event.event !== 'diagnostic' || !event.data || typeof event.data !== 'object') {
|
|
return false;
|
|
}
|
|
return Object.entries(expected).every(
|
|
([key, value]) => (event.data as Record<string, unknown>)[key] === value,
|
|
);
|
|
});
|
|
}
|
|
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function removeTempDir(dir: string): Promise<void> {
|
|
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
}
|