Files
open-design/apps/daemon/tests/amr-session-resume.test.ts

717 lines
29 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 { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js';
// End-to-end coverage for AMR (vela) ACP session resume through the FULL server
// close handler — not just `attachAcpSession` in isolation.
//
// AMR is `resumesSessionViaAcpLoad`: turn 1 issues `session/new` and the daemon
// captures vela's durable `openCodeSessionId`; the next turn in the same
// conversation issues `session/load <handle>` to resume the upstream OpenCode
// session instead of re-paying the whole transcript cache-cold.
//
// The headline guard is the dead-handle fallback. When the resumed upstream
// session is gone, vela returns a structured `resume_failed` JSON-RPC error,
// which the ACP bridge turns into a FATAL. The daemon must STILL recognize it as
// a resume failure, clear the stale handle, surface a retryable error, and let
// the NEXT turn start a fresh `session/new` (re-seeded with the full transcript)
// — one cold turn, never a conversation wedged retrying a dead `session/load`.
//
// Regression note: turn-2's error code is `AGENT_EXECUTION_FAILED` whether or
// not the handle is cleared, so the load-bearing assertions are turn-3 (must
// SUCCEED via a fresh session) and the session-bind sequence
// (['new','load','new'], not ['new','load','load']). Before the close-handler
// ordering fix — where `hasFatalError()` short-circuited BEFORE the
// resume-failure reseed block — the dead handle was never cleared, so turn-3
// resumed it again, going ['new','load','load'] and failing.
type StartedServer = {
url: string;
server: Server;
shutdown?: () => Promise<void> | void;
};
type RunStatus = {
id: string;
status: string;
error: string | null;
errorCode: string | null;
eventsLogPath: string;
};
type RunEvent = { event: string; data: unknown };
const HERE = path.dirname(fileURLToPath(import.meta.url));
const FAKE_VELA = path.join(HERE, 'fixtures', 'fake-vela.mjs');
describe('AMR (vela) ACP session resume — full server cycle', () => {
const originalEnv = snapshotEnv();
let started: StartedServer | null = null;
let binDir: string | null = null;
beforeEach(() => {
// These fixtures exercise session transport with plain replies, not OD Next state.
process.env.OD_NEXT_STRATEGY_ROLLOUT = 'off';
});
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.each(['success', 'pending', 'legacy', 'missing', 'mismatch', 'limit'])(
'safely converges compaction continuation: %s', async (scenario) => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-amr-continuation-'));
const bin = path.join(binDir, 'vela');
const fixture = path.join(HERE, 'fixtures', 'fake-vela-continuation.ts');
const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
await writeFile(bin, `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(fixture)} ${quote(scenario)} ${quote(binDir)} "$@"\n`);
await chmod(bin, 0o755);
clearTelemetryEnv();
started = await startServer({ port: 0, returnServer: true }) as StartedServer;
await putConfig(started.url, { agentId: 'amr', agentCliEnv: { amr: { VELA_BIN: bin } } });
const conversation = await createConversation(started.url);
const run = await sendRunAndWait(started.url, conversation, 'write exactly once');
expect(run.status, JSON.stringify({ run, events: (await readRunEvents(run.eventsLogPath)).slice(-8) })).toBe(scenario === 'success' ? 'succeeded' : 'failed');
const ledger = (await readFile(path.join(binDir, 'ledger.jsonl'), 'utf8')).trim().split('\n')
.map((line) => JSON.parse(line));
expect(ledger.filter((row) => row.method === 'session/new')).toHaveLength(1);
expect(ledger.filter((row) => row.method === 'session/prompt')).toHaveLength(1);
expect(await readFile(path.join(binDir, 'tool-executions'), 'utf8')).toBe('write\n');
const canAttempt = !['pending', 'legacy'].includes(scenario);
expect(ledger.filter((row) => row.method === 'session/load')).toHaveLength(canAttempt ? 1 : 0);
const attempts = ledger.filter((row) => row.method === '_session/continue');
expect(attempts).toHaveLength(['success', 'limit'].includes(scenario) ? 1 : 0);
const events = await readRunEvents(run.eventsLogPath);
expect(events.filter((event) => event.event === 'end')).toHaveLength(1);
expect(events.filter((event) => event.event === 'run_retry_attempted')).toHaveLength(canAttempt ? 1 : 0);
expect(hasDiagnostic(events, { type: 'agent_resume_auto_reseed' })).toBe(false);
const final = events.find((event) => event.event === 'run_retry_finished');
expect(final?.data).toMatchObject({ retry_result: scenario === 'success' ? 'success' : canAttempt ? 'failed' : 'suppressed' });
}, 30_000,
);
it('captures the durable handle on turn 1 and resumes it via session/load on turn 2', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-amr-resume-bin-'));
const logPath = path.join(binDir, 'invocations.jsonl');
const bin = await writeVelaWrapper(binDir, 'vela-resume', { logPath });
clearTelemetryEnv();
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'amr',
agentCliEnv: { amr: { VELA_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');
const turn2 = await sendRunAndWait(started.url, conversationId, 'second request');
expect(turn2.status).toBe('succeeded');
// Turn 1 binds a fresh session; turn 2 resumes the captured durable handle.
expect(await readInvocations(logPath)).toEqual(['new', 'load']);
});
it('transparently auto-reseeds within the same turn when the resumed session is gone', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-amr-deadresume-bin-'));
const logPath = path.join(binDir, 'invocations.jsonl');
// The resumed session is gone: any session/load turn fails with the
// structured resume_failed; a fresh session/new turn still succeeds.
const bin = await writeVelaWrapper(binDir, 'vela-deadresume', { logPath, resumeFailed: true });
clearTelemetryEnv();
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'amr',
agentCliEnv: { amr: { VELA_BIN: bin } },
telemetry: { metrics: true, content: false, artifactManifest: false },
privacyDecisionAt: Date.now(),
});
const conversationId = await createConversation(started.url);
// Turn 1 creates + persists the durable handle.
const turn1 = await sendRunAndWait(started.url, conversationId, 'first request');
expect(turn1.status).toBe('succeeded');
// Turn 2 resumes (session/load), but the upstream session is gone → vela
// returns resume_failed. The daemon must NOT surface an error: it clears the
// dead handle and TRANSPARENTLY re-runs the same turn with a fresh session
// (session/new) + full transcript, all within this one run. The user sees a
// succeeded turn, never a "could not be resumed" message.
const turn2 = await sendRunAndWait(started.url, conversationId, 'second request');
expect(turn2.status).toBe('succeeded');
expect(turn2.error ?? '').not.toMatch(/could not be resumed/i);
// The bind sequence proves the in-turn reseed: turn 1 = new, turn 2 =
// load (dead) → new (transparent reseed). Pre-feature this surfaced a failed
// turn-2 and deferred the fresh session to a turn 3 the user had to trigger.
expect(await readInvocations(logPath)).toEqual(['new', 'load', 'new']);
});
it('does not flash a client-visible error event during the transparent reseed', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-amr-reseed-noerror-bin-'));
const logPath = path.join(binDir, 'invocations.jsonl');
const bin = await writeVelaWrapper(binDir, 'vela-deadresume-noerror', { logPath, resumeFailed: true });
clearTelemetryEnv();
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'amr',
agentCliEnv: { amr: { VELA_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');
// Turn 2 resumes a now-dead session: vela returns resume_failed and the ACP
// bridge calls fail() -> send('error') for the failed session/load BEFORE the
// close handler clears the stale handle and re-runs the turn fresh. That
// recovery is meant to be INVISIBLE, so the run's event log must carry no
// client-visible `error` event — only the internal suppression diagnostic.
const turn2 = await sendRunAndWait(started.url, conversationId, 'second request');
expect(turn2.status).toBe('succeeded');
const events = await readRunEvents(turn2.eventsLogPath);
// Red before the fix: an `error` event flashes here even though turn 2
// ultimately succeeds via the reseed, tripping any client that treats an SSE
// `error` as terminal.
expect(events.filter((e) => e.event === 'error')).toEqual([]);
// The failure is held back as a non-user-visible diagnostic instead.
expect(
hasDiagnostic(events, { type: 'agent_resume_failed_suppressed' }),
).toBe(true);
expect(hasDiagnostic(events, {
type: 'agent_resume_auto_reseed',
reason: 'resume_failed',
stale_session_cleared: true,
})).toBe(true);
// The transparent reseed still happened: new → load (dead) → new.
expect(await readInvocations(logPath)).toEqual(['new', 'load', 'new']);
});
it('opens a fresh session next turn when turn 1 yields no durable handle', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-amr-nohandle-bin-'));
const logPath = path.join(binDir, 'invocations.jsonl');
// vela never reports openCodeSessionId → the daemon captures null and must
// clear the row, so the next turn cannot attempt a session/load.
const bin = await writeVelaWrapper(binDir, 'vela-nohandle', { logPath, omitHandle: true });
clearTelemetryEnv();
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'amr',
agentCliEnv: { amr: { VELA_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');
// No handle was ever stored, so BOTH turns open a fresh session — never a
// session/load against a non-existent upstream session.
expect(await readInvocations(logPath)).toEqual(['new', 'new']);
});
it('clears a resumed AMR session after request_too_large so the next turn starts fresh', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-amr-largecontext-bin-'));
const logPath = path.join(binDir, 'invocations.jsonl');
const bin = await writeVelaWrapper(binDir, 'vela-largecontext', {
logPath,
promptErrorOnLoad: '[code=request_too_large] request body exceeds configured limit',
});
clearTelemetryEnv();
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'amr',
agentCliEnv: { amr: { VELA_BIN: bin } },
telemetry: { metrics: true, content: false, artifactManifest: false },
privacyDecisionAt: Date.now(),
});
const conversationId = await createConversation(started.url);
// Turn 1 captures the durable upstream OpenCode handle.
expect((await sendRunAndWait(started.url, conversationId, 'first request')).status)
.toBe('succeeded');
// Turn 2 resumes that handle, but the upstream request is now too large.
// The run should fail honestly, but the daemon must clear the handle so
// the next user retry does not load the same overgrown native session.
const turn2 = await sendRunAndWait(started.url, conversationId, 'second request');
expect(turn2.status).toBe('failed');
expect(turn2.error ?? '').toMatch(/request body exceeds configured limit/i);
const turn2Events = await readRunEvents(turn2.eventsLogPath);
expect(hasDiagnostic(turn2Events, {
type: 'agent_session_cleared_after_prompt_too_large',
reason: 'prompt_too_large',
stale_session_cleared: true,
})).toBe(true);
// Turn 3 proves the stale handle was discarded: it opens session/new and
// succeeds instead of session/load-ing the same oversized upstream session.
expect((await sendRunAndWait(started.url, conversationId, 'third request')).status)
.toBe('succeeded');
expect(await readInvocations(logPath)).toEqual(['new', 'load', 'new']);
});
it('persists the concrete resolved model for a default turn (equivalent explicit follow-up resumes)', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-amr-defaultmodel-bin-'));
const logPath = path.join(binDir, 'invocations.jsonl');
const bin = await writeVelaWrapper(binDir, 'vela-defaultmodel', { logPath });
clearTelemetryEnv();
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'amr',
agentCliEnv: { amr: { VELA_BIN: bin } },
telemetry: { metrics: true, content: false, artifactManifest: false },
privacyDecisionAt: Date.now(),
});
const conversationId = await createConversation(started.url);
// Turn 1 runs with the implicit default model, which resolves to the live
// catalog's first entry (deepseek-v4-flash, the fake preset's head on a cold
// cache). The guard must persist that CONCRETE id, not the raw `default`/null
// request token.
expect((await sendRunAndWait(started.url, conversationId, 'first request')).status)
.toBe('succeeded');
// Turn 2 names the SAME concrete model explicitly. Because turn 1 stored the
// concrete id, the identity still matches and the session resumes. (Pre-fix
// turn 1 stored null/`default`, so this read as model_changed and forced a
// needless cold reseed.)
expect((await sendRunAndWait(started.url, conversationId, 'second request', 'deepseek-v4-flash')).status)
.toBe('succeeded');
expect(await readInvocations(logPath)).toEqual(['new', 'load']);
});
it('resolves an explicit default model to the live catalog default before spawning AMR', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-amr-explicit-default-bin-'));
const logPath = path.join(binDir, 'invocations.jsonl');
const bin = await writeVelaWrapper(binDir, 'vela-explicit-default', {
logPath,
logSetModel: true,
requireSetModel: true,
});
clearTelemetryEnv();
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'amr',
agentCliEnv: { amr: { VELA_BIN: bin } },
telemetry: { metrics: true, content: false, artifactManifest: false },
privacyDecisionAt: Date.now(),
});
const conversationId = await createConversation(started.url);
expect((await sendRunAndWait(started.url, conversationId, 'use account default', 'default')).status)
.toBe('succeeded');
expect((await sendRunAndWait(started.url, conversationId, 'use account default again', 'default')).status)
.toBe('succeeded');
expect(await readInvocations(logPath)).toEqual([
'new',
'set_model:deepseek-v4-flash',
'load',
'set_model:deepseek-v4-flash',
]);
});
it('uses the catalog default model for omitted AMR model selections, skipping disabled catalog heads', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-amr-catalog-default-bin-'));
const logPath = path.join(binDir, 'invocations.jsonl');
const presetCatalog = JSON.stringify({
source: 'preset',
data: [
{ id: 'deepseek-v4-flash', enabled: false },
{ id: 'kimi-k2.6', default: true },
{ id: 'glm-5.1' },
],
});
const remoteCatalog = JSON.stringify({
source: 'remote',
data: [
{ id: 'deepseek-v4-flash', enabled: false },
{ id: 'kimi-k2.6', default: true },
{ id: 'glm-5.1' },
],
});
const bin = await writeVelaWrapper(binDir, 'vela-catalog-default', {
logPath,
logSetModel: true,
modelPresetJson: presetCatalog,
modelListJson: remoteCatalog,
});
clearTelemetryEnv();
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'amr',
agentCliEnv: { amr: { VELA_BIN: bin } },
telemetry: { metrics: true, content: false, artifactManifest: false },
privacyDecisionAt: Date.now(),
});
const conversationId = await createConversation(started.url);
expect((await sendRunAndWait(started.url, conversationId, 'use catalog default')).status)
.toBe('succeeded');
expect(await readInvocations(logPath)).toEqual(['new', 'set_model:kimi-k2.6']);
});
it('rejects explicit AMR default when every catalog model is disabled', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-amr-locked-default-bin-'));
const logPath = path.join(binDir, 'invocations.jsonl');
const lockedCatalog = JSON.stringify({
source: 'preset',
data: [
{ id: 'deepseek-v4-flash', enabled: false },
{ id: 'kimi-k2.6', enabled: false },
],
});
const lockedRemoteCatalog = JSON.stringify({
source: 'remote',
data: [
{ id: 'deepseek-v4-flash', enabled: false },
{ id: 'kimi-k2.6', enabled: false },
],
});
const bin = await writeVelaWrapper(binDir, 'vela-locked-default', {
logPath,
logSetModel: true,
requireSetModel: true,
modelPresetJson: lockedCatalog,
modelListJson: lockedRemoteCatalog,
});
clearTelemetryEnv();
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'amr',
agentCliEnv: { amr: { VELA_BIN: bin } },
telemetry: { metrics: true, content: false, artifactManifest: false },
privacyDecisionAt: Date.now(),
});
const conversationId = await createConversation(started.url);
const run = await sendRunAndWait(started.url, conversationId, 'use account default', 'default');
expect(run.status).toBe('failed');
expect(run.errorCode).toBe('AMR_MODEL_UNAVAILABLE');
expect(await readInvocations(logPath)).toEqual([]);
});
it('reseeds a fresh session (no resume) when the model changes between turns', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-amr-modelchange-bin-'));
const logPath = path.join(binDir, 'invocations.jsonl');
const bin = await writeVelaWrapper(binDir, 'vela-modelchange', { logPath });
clearTelemetryEnv();
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'amr',
agentCliEnv: { amr: { VELA_BIN: bin } },
telemetry: { metrics: true, content: false, artifactManifest: false },
privacyDecisionAt: Date.now(),
});
const conversationId = await createConversation(started.url);
// Turn 1 binds a session under one model and captures the durable handle.
expect((await sendRunAndWait(started.url, conversationId, 'first request', 'deepseek-v3.2')).status)
.toBe('succeeded');
// Turn 2 switches model: the resume-identity guard must invalidate (the
// upstream session was seeded under the old model) and open a FRESH session
// rather than session/load the mismatched one.
expect((await sendRunAndWait(started.url, conversationId, 'second request', 'gemini-2.5-flash')).status)
.toBe('succeeded');
expect(await readInvocations(logPath)).toEqual(['new', 'new']);
});
});
// A tiny executable shim that forwards to fake-vela.mjs under the current node,
// so VELA_BIN resolves to a real executable regardless of the fixture's mode.
// The daemon's `agentCliEnv` is allowlisted (only VELA_BIN survives for AMR), so
// behavior knobs are baked into the wrapper's OWN env here instead of passed
// through config — the wrapper exports them before exec'ing fake-vela.
async function writeVelaWrapper(
dir: string,
name: string,
opts: {
logPath: string;
resumeFailed?: boolean;
omitHandle?: boolean;
promptErrorOnLoad?: string;
logSetModel?: boolean;
requireSetModel?: boolean;
modelPresetJson?: string;
modelListJson?: string;
},
): Promise<string> {
const bin = path.join(dir, name);
const lines = [
'#!/bin/sh',
`export FAKE_VELA_INVOCATION_LOG=${JSON.stringify(opts.logPath)}`,
];
if (opts.requireSetModel !== true) {
// Isolate most resume-cycle tests from the set_model gate; individual
// default-model regressions opt back into the production-shaped strict gate.
lines.push('export FAKE_VELA_REQUIRE_SET_MODEL=0');
}
if (opts.resumeFailed) lines.push('export FAKE_VELA_RESUME_FAILED=1');
if (opts.omitHandle) lines.push('export FAKE_VELA_OMIT_OPENCODE_SESSION_ID=1');
if (opts.promptErrorOnLoad) {
lines.push(`export FAKE_VELA_PROMPT_ERROR_ON_LOAD=${JSON.stringify(opts.promptErrorOnLoad)}`);
}
if (opts.logSetModel) lines.push('export FAKE_VELA_LOG_SET_MODEL=1');
if (opts.modelPresetJson) {
lines.push(`export FAKE_VELA_MODEL_PRESET_JSON=${JSON.stringify(opts.modelPresetJson)}`);
}
if (opts.modelListJson) {
lines.push(`export FAKE_VELA_MODEL_LIST_JSON=${JSON.stringify(opts.modelListJson)}`);
}
lines.push(`exec ${JSON.stringify(process.execPath)} ${JSON.stringify(FAKE_VELA)} "$@"`, '');
await writeFile(bin, lines.join('\n'), 'utf8');
await chmod(bin, 0o755);
return bin;
}
async function readInvocations(logPath: string): Promise<string[]> {
let raw = '';
try {
raw = await readFile(logPath, 'utf8');
} catch {
return [];
}
return raw
.trim()
.split('\n')
.filter(Boolean)
.map((line) => (JSON.parse(line) as { method: string }).method);
}
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 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,
OD_NEXT_STRATEGY_ROLLOUT: process.env.OD_NEXT_STRATEGY_ROLLOUT,
};
}
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 agentCliEnv =
patch.agentCliEnv && typeof patch.agentCliEnv === 'object'
? patch.agentCliEnv as Record<string, unknown>
: {};
const amr =
agentCliEnv.amr && typeof agentCliEnv.amr === 'object'
? agentCliEnv.amr as Record<string, unknown>
: {};
const response = await fetch(`${url}/api/app-config`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
...patch,
agentCliEnv: {
...agentCliEnv,
amr: {
...amr,
VELA_RUNTIME_KEY: 'rt-amr-session-resume-test',
VELA_LINK_URL: 'https://amr-link.example.test/v1',
},
},
}),
});
expect(response.status).toBe(200);
}
async function createConversation(url: string): Promise<string> {
const projectId = `amr_resume_${randomUUID()}`;
const workspaceId = `amr_resume_personal_${projectId}`;
const workspaceMemberId = `amr_resume_owner_${projectId}`;
const workspaceHeaders = {
'x-od-workspace-id': workspaceId,
'x-od-workspace-type': 'personal',
'x-od-workspace-member-id': workspaceMemberId,
'x-od-workspace-role': 'owner',
};
const projectResponse = await fetch(`${url}/api/projects`, {
method: 'POST',
headers: {
'content-type': 'application/json',
...workspaceHeaders,
},
body: JSON.stringify({
id: projectId,
name: 'AMR 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,
workspaceId,
workspaceMemberId,
].join('::');
}
async function sendRunAndWait(
url: string,
encoded: string,
message: string,
model?: string,
): Promise<RunStatus> {
const [projectId, conversationId, workspaceId, workspaceMemberId] =
encoded.split('::');
if (!projectId || !conversationId || !workspaceId || !workspaceMemberId) {
throw new Error(`invalid AMR resume fixture identity: ${encoded}`);
}
const assistantMessageId = `assistant_amr_${randomUUID()}`;
const runResponse = await fetch(`${url}/api/runs`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-od-analytics-device-id': 'amr-resume-test',
'x-od-analytics-session-id': 'amr-resume-session',
'x-od-analytics-client-type': 'web',
'x-od-workspace-id': workspaceId,
'x-od-workspace-type': 'personal',
'x-od-workspace-member-id': workspaceMemberId,
'x-od-workspace-role': 'owner',
},
body: JSON.stringify({
projectId,
conversationId,
assistantMessageId,
clientRequestId: `client_amr_${randomUUID()}`,
agentId: 'amr',
message,
currentPrompt: message,
...(model ? { model } : {}),
}),
});
const body = (await runResponse.json()) as {
runId?: string;
error?: { code?: string; message?: string };
};
expect(runResponse.status, JSON.stringify(body)).toBe(202);
expect(body.runId).toBeTypeOf('string');
return await waitForRun(url, body.runId!, {
'x-od-workspace-id': workspaceId,
'x-od-workspace-type': 'personal',
'x-od-workspace-member-id': workspaceMemberId,
'x-od-workspace-role': 'owner',
});
}
async function waitForRun(
url: string,
runId: string,
headers: Record<string, string>,
): Promise<RunStatus> {
const startedAt = Date.now();
while (Date.now() - startedAt < 15_000) {
const response = await fetch(
`${url}/api/runs/${encodeURIComponent(runId)}`,
{ headers },
);
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`);
}
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 });
}