Files
open-design/apps/daemon/tests/run-failure-detail-persisted-message.test.ts
cd07d359df feat(chat): surface daemon failure_detail to sharpen run-error guidance (#5321)
* feat(chat): surface daemon failure_detail to sharpen run-error guidance

The daemon already classifies each run failure into failure_category /
failure_detail (run-failure-classification.ts) for retry policy and
telemetry, but kept it daemon-internal — so the chat could only key error
guidance off the coarse errorCode. That collapses distinct causes that
share one code: a hard quota and a transient 429 both arrive as
RATE_LIMITED, and a text-detected missing CLI leaks in as the opaque
AGENT_EXECUTION_FAILED.

Propagate the classification to the client (contract DTO + SSE end frame,
mirroring the proven `resumable` path) and let resolveRunFailureUi take
`detail` as a first-class input that can override a too-coarse code:

- hard_quota / workspace_credits_exhausted -> a distinct "Quota exhausted"
  card that drops the futile Retry (primaryAction 'none') and steers to the
  hosted-AMR switch card instead.
- cli_not_installed (text-detected) -> the same install-the-CLI card the
  AGENT_UNAVAILABLE code path already renders.

A transient 429 without a hard-quota detail still offers Retry, and the
Antigravity terminal switch-model flow still wins over the generic detail
override.

Stacks on #5308 (the code->guidance mapping this detail layer refines).

* chore(i18n): rebrand user-facing "AMR" to "Open Design Cloud"

The hosted model service is now Open Design Cloud, but the run-error
cards this PR sharpens (switch / auth / balance), plus settings account +
wallet labels, avatar console, onboarding gate, and the handoff website
link, still showed the old "AMR" (or bare "Open Design") name. Normalize
every user-visible brand mention to "Open Design Cloud" across all 19
locales. Value-only: no keys, agent ids, URLs, or raw daemon error
strings touched.

* fix(chat): persist daemon failure classification onto assistant message

The daemon emits the terminal `error` SSE frame from the child-close
handler before a run is finalized, so `failureCategory` / `failureDetail`
(computed at finalize) weren't known when that frame was first persisted
to the assistant message — the stored `status:error` event kept only
`{ detail, code }`. A failure persisted without the live web error
handler saving the message, or a conversation reloaded before that save
landed, therefore fell back to the coarse errorCode UI and lost the
specific fix guidance this PR adds.

Stamp the finalize-time classification onto the persisted assistant
message: enrich the last `status:error` event in place (append one only
if a failed run somehow never persisted an error frame). Covered by a
daemon regression that reads the stored assistant message via the
conversation-messages API after a failed hard-quota run.

* fix(chat): keep daemon failure classification on the live web reload/recovery paths

Two live-propagation gaps let the sharpened run-error card degrade back to
the coarse/raw UI even though the daemon now sends the classification:

- appendErrorStatusEvent() no-op'd whenever the message already had a
  trailing status:error with the same detail. In the reload/reattach race
  (reload reads the daemon-persisted error frame first, then the run
  finishes and onError arrives with the finalize-time classification) that
  dropped the new code / failureCategory / failureDetail. Merge those into
  the existing event in place instead; no-op only when nothing is added.

- The error-frame-then-status recovery probe in providers/daemon.ts broke
  out on a terminal failed status without copying failureCategory /
  failureDetail (unlike the post-loop status fallback), so markErrorRunFailure
  stamped null and the failureDetail mapping fell back to the generic card on
  the reconnect path. Mirror the two assignments before the break.

Covered by chat-events (in-place merge) and daemon-sse-reconnect (error
frame + terminal status fetch, no end frame) regressions.

* fix(web): update AMR tier-upgrade test for 3-arg resolveRunFailureUi

The main merge kept a two-argument call site that passed agentId as
detail after the signature gained an explicit detail parameter.

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci: re-trigger validate workspace on approved head

Co-authored-by: Cursor <cursoragent@cursor.com>

* test: align AMR rebrand assertions with Open Design Cloud copy

Update unit and e2e selectors that still expected pre-rebrand strings
for account status, handoff website link, and switch-and-retry CTA.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: xiaoche-hub <298951296+xiaoche-hub@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 03:46:01 +00:00

240 lines
8.5 KiB
TypeScript

import type { Server } from 'node:http';
import { randomUUID } from 'node:crypto';
import { chmod, mkdtemp, 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';
// #895 regression: the daemon classifies a failure's fine-grained cause
// (failure_category / failure_detail) at finalize. The live web error handler
// stamps that onto the assistant message, but a failure persisted purely on the
// daemon side — or a conversation reloaded before the web save lands — reads the
// stored message instead of the live SSE stream. Historically that stored
// `status:error` event only carried `{ detail, code }`, so a reload fell back to
// the coarse errorCode UI and lost the specific fix guidance.
//
// This asserts the STORED assistant message (not just the run-status DTO)
// carries `failureCategory` / `failureDetail` after a failed hard-quota run.
type StartedServer = {
url: string;
server: Server;
shutdown?: () => Promise<void> | void;
};
type RunStatus = { id: string; status: string };
type PersistedEvent = {
kind?: string;
label?: string;
detail?: string;
code?: string;
failureCategory?: string;
failureDetail?: string;
};
type StoredMessage = {
id: string;
role: string;
events?: PersistedEvent[];
};
type RunHandles = {
projectId: string;
conversationId: string;
assistantMessageId: string;
status: RunStatus;
};
describe('run failure classification persisted to assistant message', () => {
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('stamps failureCategory/failureDetail onto the stored error event on a hard-quota failure', async () => {
binDir = await mkdtemp(path.join(os.tmpdir(), 'od-failure-detail-msg-bin-'));
const fakeClaude = await writeHardQuotaClaude(binDir, 'claude-hard-quota');
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;
started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
await putConfig(started.url, {
agentId: 'claude',
agentCliEnv: { claude: { CLAUDE_BIN: fakeClaude } },
telemetry: { metrics: true, content: false, artifactManifest: false },
privacyDecisionAt: Date.now(),
});
const { projectId, conversationId } = await createConversation(started.url);
const run = await sendRunAndWait(started.url, projectId, conversationId);
expect(run.status.status).toBe('failed');
// Read the STORED message the same way a reload does (daemon HTTP API),
// not the live stream, so this proves the daemon-owned persistence path.
const stored = await fetchAssistantMessage(
started.url,
projectId,
conversationId,
run.assistantMessageId,
);
expect(stored).not.toBeNull();
const errorEvent = [...(stored?.events ?? [])]
.reverse()
.find((event) => event.kind === 'status' && event.label === 'error');
expect(errorEvent, 'persisted assistant message should carry a status:error event').toBeTruthy();
expect(errorEvent?.failureCategory).toBe('rate_limit');
expect(errorEvent?.failureDetail).toBe('hard_quota');
});
});
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;
}
}
// Fake Claude CLI: emits the init frame, then dies with a hard-quota billing
// message on stderr (matches isHardQuotaText -> detail 'hard_quota', which is
// non-retryable so the run fails on the first attempt).
async function writeHardQuotaClaude(dir: string, name: string): Promise<string> {
const bin = path.join(dir, name);
await writeFile(
bin,
`#!/usr/bin/env node
if (process.argv.includes('--version')) { console.log('claude-code 1.0.0-hard-quota'); process.exit(0); }
if (process.argv.includes('--help')) { console.log('Usage: claude -p [--include-partial-messages]'); process.exit(0); }
console.log(JSON.stringify({ type: 'system', subtype: 'init', model: 'claude-quota-test' }));
process.stderr.write('You have exceeded your current quota. Please upgrade your plan to continue.\\n');
setTimeout(() => process.exit(1), 20);
`,
'utf8',
);
await chmod(bin, 0o755);
return bin;
}
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<{ projectId: string; conversationId: string }> {
const projectId = `failure_detail_msg_${randomUUID()}`;
const projectResponse = await fetch(`${url}/api/projects`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
id: projectId,
name: 'Failure detail persisted message smoke',
metadata: { kind: 'prototype' },
skipDiscoveryBrief: true,
}),
});
expect(projectResponse.status).toBe(200);
const projectBody = (await projectResponse.json()) as { conversationId: string; id: string };
return { projectId, conversationId: projectBody.conversationId };
}
async function sendRunAndWait(
url: string,
projectId: string,
conversationId: string,
): Promise<RunHandles> {
const assistantMessageId = `assistant_failure_detail_msg_${randomUUID()}`;
const runResponse = await fetch(`${url}/api/runs`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-od-analytics-device-id': 'failure-detail-msg-test',
'x-od-analytics-session-id': 'failure-detail-msg-session',
'x-od-analytics-client-type': 'web',
},
body: JSON.stringify({
projectId,
conversationId,
assistantMessageId,
clientRequestId: `client_failure_detail_msg_${randomUUID()}`,
agentId: 'claude',
message: 'please do the task',
currentPrompt: 'please do the task',
}),
});
expect(runResponse.status).toBe(202);
const body = (await runResponse.json()) as { runId: string };
const status = await waitForRun(url, body.runId);
return { projectId, conversationId, assistantMessageId, status };
}
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`);
}
async function fetchAssistantMessage(
url: string,
projectId: string,
conversationId: string,
assistantMessageId: string,
): Promise<StoredMessage | null> {
const response = await fetch(
`${url}/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}/messages`,
);
expect(response.status).toBe(200);
const body = (await response.json()) as { messages?: StoredMessage[] };
return body.messages?.find((message) => message.id === assistantMessageId) ?? null;
}
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 });
}