feat(user-errors): surface missing-API-key + cron failures in UserErrorCenter (#4165) (#4169)

This commit is contained in:
oxoxDev
2026-06-30 17:52:53 +05:30
committed by GitHub
parent 1537cf7f60
commit 34bc6906f1
21 changed files with 309 additions and 5 deletions
+4
View File
@@ -6121,7 +6121,11 @@ const messages: TranslationMap = {
'userErrors.budgetExceeded.body': 'نفدت الميزانية المُدارة. أضف ميزانية أو غيّر خطتك.',
'userErrors.insufficientCredits.title': 'مطلوب رصيد المزود',
'userErrors.insufficientCredits.body': 'نفد رصيد المزود. أعد الشحن أو حدّث مفتاح API.',
'userErrors.apiKeyMissing.title': 'مطلوب مفتاح API',
'userErrors.apiKeyMissing.body':
'لا يوجد مفتاح API لمزوّد الذكاء الاصطناعي. أضِفه في إعدادات المزوّد للمتابعة.',
'userErrors.scope.chat': 'الدردشة',
'userErrors.scope.cron': 'مهمة مجدوَلة',
// Agent World — Identity trading (confirm-before-spend + balance gate)
'agentWorld.trading.amountLabel': 'المبلغ',
'agentWorld.trading.networkLabel': 'الشبكة',
+4
View File
@@ -6249,7 +6249,11 @@ const messages: TranslationMap = {
'userErrors.insufficientCredits.title': 'প্রদানকারীর ক্রেডিট প্রয়োজন',
'userErrors.insufficientCredits.body':
'AI প্রদানকারীর ক্রেডিট শেষ। রিচার্জ করুন বা API কী বদলান।',
'userErrors.apiKeyMissing.title': 'API কী প্রয়োজন',
'userErrors.apiKeyMissing.body':
'আপনার AI প্রদানকারীর কোনো API কী সেট নেই। চালিয়ে যেতে প্রদানকারী সেটিংসে একটি যোগ করুন।',
'userErrors.scope.chat': 'চ্যাট',
'userErrors.scope.cron': 'নির্ধারিত কাজ',
// Agent World — Identity trading (confirm-before-spend + balance gate)
'agentWorld.trading.amountLabel': 'পরিমাণ',
'agentWorld.trading.networkLabel': 'নেটওয়ার্ক',
+4
View File
@@ -6422,7 +6422,11 @@ const messages: TranslationMap = {
'userErrors.insufficientCredits.title': 'Anbieter-Guthaben erforderlich',
'userErrors.insufficientCredits.body':
'Deinem KI-Anbieter ist das Guthaben ausgegangen. Lade es auf oder aktualisiere den Schlüssel.',
'userErrors.apiKeyMissing.title': 'API-Schlüssel erforderlich',
'userErrors.apiKeyMissing.body':
'Für deinen KI-Anbieter ist kein API-Schlüssel hinterlegt. Füge in den Anbietereinstellungen einen hinzu, um fortzufahren.',
'userErrors.scope.chat': 'Chat',
'userErrors.scope.cron': 'Geplante Aufgabe',
// Agent World — Identity trading (confirm-before-spend + balance gate)
'agentWorld.trading.amountLabel': 'Betrag',
'agentWorld.trading.networkLabel': 'Netzwerk',
+4
View File
@@ -6559,7 +6559,11 @@ const en: TranslationMap = {
'userErrors.insufficientCredits.title': 'Provider credits required',
'userErrors.insufficientCredits.body':
'Your AI provider is out of credits. Top it up or update its API key to continue.',
'userErrors.apiKeyMissing.title': 'API key required',
'userErrors.apiKeyMissing.body':
'Your AI provider has no API key set. Add one in provider settings to continue.',
'userErrors.scope.chat': 'Chat',
'userErrors.scope.cron': 'Scheduled job',
};
export default en;
+4
View File
@@ -6381,7 +6381,11 @@ const messages: TranslationMap = {
'userErrors.insufficientCredits.title': 'Se requieren créditos del proveedor',
'userErrors.insufficientCredits.body':
'Tu proveedor de IA se quedó sin créditos. Recárgalo o actualiza su clave de API.',
'userErrors.apiKeyMissing.title': 'Se requiere clave de API',
'userErrors.apiKeyMissing.body':
'Tu proveedor de IA no tiene una clave de API configurada. Añade una en los ajustes del proveedor para continuar.',
'userErrors.scope.chat': 'Chat',
'userErrors.scope.cron': 'Tarea programada',
// Agent World — Identity trading (confirm-before-spend + balance gate)
'agentWorld.trading.amountLabel': 'Importe',
'agentWorld.trading.networkLabel': 'Red',
+4
View File
@@ -6401,7 +6401,11 @@ const messages: TranslationMap = {
'userErrors.insufficientCredits.title': 'Crédits du fournisseur requis',
'userErrors.insufficientCredits.body':
"Votre fournisseur IA n'a plus de crédits. Rechargez-le ou mettez à jour sa clé API.",
'userErrors.apiKeyMissing.title': 'Clé API requise',
'userErrors.apiKeyMissing.body':
"Aucune clé API n'est définie pour votre fournisseur d'IA. Ajoutez-en une dans les paramètres du fournisseur pour continuer.",
'userErrors.scope.chat': 'Chat',
'userErrors.scope.cron': 'Tâche planifiée',
// Agent World — Identity trading (confirm-before-spend + balance gate)
'agentWorld.trading.amountLabel': 'Montant',
'agentWorld.trading.networkLabel': 'Réseau',
+4
View File
@@ -6253,7 +6253,11 @@ const messages: TranslationMap = {
'userErrors.insufficientCredits.title': 'प्रदाता क्रेडिट आवश्यक',
'userErrors.insufficientCredits.body':
'AI प्रदाता के क्रेडिट समाप्त। रिचार्ज करें या API कुंजी बदलें।',
'userErrors.apiKeyMissing.title': 'API कुंजी आवश्यक',
'userErrors.apiKeyMissing.body':
'आपके AI प्रदाता के लिए कोई API कुंजी सेट नहीं है। जारी रखने के लिए प्रदाता सेटिंग्स में एक जोड़ें।',
'userErrors.scope.chat': 'चैट',
'userErrors.scope.cron': 'निर्धारित कार्य',
// Agent World — Identity trading (confirm-before-spend + balance gate)
'agentWorld.trading.amountLabel': 'राशि',
'agentWorld.trading.networkLabel': 'नेटवर्क',
+4
View File
@@ -6275,7 +6275,11 @@ const messages: TranslationMap = {
'userErrors.insufficientCredits.title': 'Kredit penyedia diperlukan',
'userErrors.insufficientCredits.body':
'Penyedia AI Anda kehabisan kredit. Isi ulang atau perbarui kunci API-nya.',
'userErrors.apiKeyMissing.title': 'Kunci API diperlukan',
'userErrors.apiKeyMissing.body':
'Penyedia AI Anda belum memiliki kunci API. Tambahkan satu di pengaturan penyedia untuk melanjutkan.',
'userErrors.scope.chat': 'Obrolan',
'userErrors.scope.cron': 'Tugas terjadwal',
// Agent World — Identity trading (confirm-before-spend + balance gate)
'agentWorld.trading.amountLabel': 'Jumlah',
'agentWorld.trading.networkLabel': 'Jaringan',
+4
View File
@@ -6368,7 +6368,11 @@ const messages: TranslationMap = {
'userErrors.insufficientCredits.title': 'Crediti del provider necessari',
'userErrors.insufficientCredits.body':
'Il tuo provider IA ha esaurito i crediti. Ricaricalo o aggiorna la chiave API.',
'userErrors.apiKeyMissing.title': 'Chiave API richiesta',
'userErrors.apiKeyMissing.body':
'Il tuo provider IA non ha una chiave API impostata. Aggiungine una nelle impostazioni del provider per continuare.',
'userErrors.scope.chat': 'Chat',
'userErrors.scope.cron': 'Attività pianificata',
// Agent World — Identity trading (confirm-before-spend + balance gate)
'agentWorld.trading.amountLabel': 'Importo',
'agentWorld.trading.networkLabel': 'Rete',
+4
View File
@@ -6189,7 +6189,11 @@ const messages: TranslationMap = {
'userErrors.budgetExceeded.body': '관리형 AI 예산이 모두 소진되었습니다.',
'userErrors.insufficientCredits.title': '제공업체 크레딧 필요',
'userErrors.insufficientCredits.body': 'AI 제공업체 크레딧이 소진되었습니다.',
'userErrors.apiKeyMissing.title': 'API 키 필요',
'userErrors.apiKeyMissing.body':
'AI 제공업체에 API 키가 설정되지 않았습니다. 제공업체 설정에서 추가하세요.',
'userErrors.scope.chat': '채팅',
'userErrors.scope.cron': '예약된 작업',
// Agent World — Identity trading (confirm-before-spend + balance gate)
'agentWorld.trading.amountLabel': '금액',
'agentWorld.trading.networkLabel': '네트워크',
+4
View File
@@ -6343,7 +6343,11 @@ const messages: TranslationMap = {
'userErrors.insufficientCredits.title': 'Wymagane środki u dostawcy',
'userErrors.insufficientCredits.body':
'Twój dostawca AI nie ma już środków. Doładuj je lub zaktualizuj klucz API.',
'userErrors.apiKeyMissing.title': 'Wymagany klucz API',
'userErrors.apiKeyMissing.body':
'Twój dostawca AI nie ma ustawionego klucza API. Dodaj go w ustawieniach dostawcy, aby kontynuować.',
'userErrors.scope.chat': 'Czat',
'userErrors.scope.cron': 'Zaplanowane zadanie',
// Agent World — Identity trading (confirm-before-spend + balance gate)
'agentWorld.trading.amountLabel': 'Kwota',
'agentWorld.trading.networkLabel': 'Sieć',
+4
View File
@@ -6360,7 +6360,11 @@ const messages: TranslationMap = {
'userErrors.insufficientCredits.title': 'Créditos do provedor necessários',
'userErrors.insufficientCredits.body':
'Seu provedor de IA ficou sem créditos. Recarregue-o ou atualize a chave de API.',
'userErrors.apiKeyMissing.title': 'Chave de API necessária',
'userErrors.apiKeyMissing.body':
'Seu provedor de IA não tem uma chave de API definida. Adicione uma nas configurações do provedor para continuar.',
'userErrors.scope.chat': 'Chat',
'userErrors.scope.cron': 'Tarefa agendada',
// Agent World — Identity trading (confirm-before-spend + balance gate)
'agentWorld.trading.amountLabel': 'Valor',
'agentWorld.trading.networkLabel': 'Rede',
+4
View File
@@ -6316,7 +6316,11 @@ const messages: TranslationMap = {
'userErrors.budgetExceeded.body': 'Управляемый бюджет ИИ исчерпан. Измените план.',
'userErrors.insufficientCredits.title': 'Требуются кредиты провайдера',
'userErrors.insufficientCredits.body': 'У провайдера закончились кредиты. Пополните их.',
'userErrors.apiKeyMissing.title': 'Требуется ключ API',
'userErrors.apiKeyMissing.body':
'У провайдера ИИ не задан ключ API. Добавьте его в настройках провайдера.',
'userErrors.scope.chat': 'Чат',
'userErrors.scope.cron': 'Запланированная задача',
// Agent World — Identity trading (confirm-before-spend + balance gate)
'agentWorld.trading.amountLabel': 'Сумма',
'agentWorld.trading.networkLabel': 'Сеть',
+3
View File
@@ -5931,7 +5931,10 @@ const messages: TranslationMap = {
'userErrors.budgetExceeded.body': '托管 AI 预算已用尽,请增加预算或更改套餐。',
'userErrors.insufficientCredits.title': '需要提供商额度',
'userErrors.insufficientCredits.body': '提供商额度已用完,请充值或更新 API 密钥。',
'userErrors.apiKeyMissing.title': '需要 API 密钥',
'userErrors.apiKeyMissing.body': '您的 AI 提供商未设置 API 密钥,请在提供商设置中添加以继续。',
'userErrors.scope.chat': '聊天',
'userErrors.scope.cron': '定时任务',
// Agent World — Identity trading (confirm-before-spend + balance gate)
'agentWorld.trading.amountLabel': '金额',
'agentWorld.trading.networkLabel': '网络',
@@ -35,6 +35,31 @@ describe('classifyUserActionableError', () => {
expect(a?.kind).toBe('budget_exceeded');
});
it('classifies a configured provider with no API key (cron user_error kind token)', () => {
// Core emits the stable kind token in error_type — classify must accept it.
const a = classifyUserActionableError({ errorType: 'api_key_missing', scope: 'cron' });
expect(a?.kind).toBe('api_key_missing');
expect(a?.scope).toBe('cron');
expect(a?.action).toBe('open_provider_settings');
expect(a?.titleKey).toBe('userErrors.apiKeyMissing.title');
// …and the verbatim credential-guard prose (mirrors Rust is_api_key_unset_message).
for (const msg of [
'openrouter: API key not set',
'Missing API key for provider',
'No API key is configured',
'no api key supplied',
]) {
expect(classifyUserActionableError({ message: msg })?.kind).toBe('api_key_missing');
}
});
it('does NOT classify an invalid/rejected API key (401) as missing-key', () => {
// A present-but-rejected key is a different state — must not be promoted.
expect(classifyUserActionableError({ message: 'Invalid API key (401)' })).toBeNull();
expect(classifyUserActionableError({ message: 'Incorrect API key provided' })).toBeNull();
});
it('returns null for generic / non-actionable errors and empty input', () => {
expect(classifyUserActionableError({ message: GENERIC_MSG })).toBeNull();
expect(classifyUserActionableError({ message: '', errorType: 'inference' })).toBeNull();
+26
View File
@@ -100,5 +100,31 @@ export function classifyUserActionableError(
};
}
// Provider configured but no API key set — a deterministic credential-guard
// failure (no HTTP). Matches the stable `api_key_missing` kind token emitted
// by core (e.g. cron `user_error`) AND the verbatim guard prose, mirroring
// the Rust single-source matcher `is_api_key_unset_message` (observability.rs)
// so a wording drift on either side fails its own test rather than silently
// dropping the signal (TAURI-RUST-HCK / #4165).
const isApiKeyMissing =
text.includes('api_key_missing') ||
text.includes('api key not set') ||
text.includes('missing api key') ||
text.includes('no api key is configured') ||
text.includes('no api key supplied');
if (isApiKeyMissing) {
return {
id: userErrorId('api_key_missing', scope, signal.provider),
kind: 'api_key_missing',
severity: 'warning',
scope,
sourceDomain: signal.sourceDomain,
provider: signal.provider,
titleKey: 'userErrors.apiKeyMissing.title',
bodyKey: 'userErrors.apiKeyMissing.body',
action: 'open_provider_settings',
};
}
return null;
}
@@ -53,6 +53,12 @@ vi.mock('../coreRpcClient', () => ({
getCoreRpcToken: vi.fn(async () => 'mock-core-bearer'),
}));
// Capture the metadata-only ingest the `user_error` handler routes through.
const ingestRuntimeErrorSignalMock = vi.fn();
vi.mock('../../lib/userErrors/report', () => ({
ingestRuntimeErrorSignal: (...args: unknown[]) => ingestRuntimeErrorSignalMock(...args),
}));
/** Build a mock socket that captures event handlers in `handlers`. */
function buildMockSocket(): { handlers: EventHandlerMap; mockSocket: object } {
const handlers: EventHandlerMap = {};
@@ -333,4 +339,73 @@ describe('socketService — agent_meetings event handlers (lines 428-480)', () =
expect.objectContaining({ payload: { error: 'bot crashed' } })
);
});
it('routes a "user_error" broadcast through the metadata-only ingest (#4165)', async () => {
const { handlers, mockSocket } = buildMockSocket();
vi.doMock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) }));
getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc');
ingestRuntimeErrorSignalMock.mockClear();
const { socketService } = await import('../socketService');
socketService.connect('jwt-test-user-error');
await pollUntil(() => expect(handlers['user_error']).toBeDefined());
// Stable kind token + scope only — never a raw provider body. The fixture
// deliberately includes a raw `message` to prove the handler drops it
// (no-leak contract), not just that it omits it by default.
handlers['user_error']!({
error_type: 'api_key_missing',
error_source: 'cron',
error_provider: 'openrouter',
message: 'raw upstream provider text',
});
expect(ingestRuntimeErrorSignalMock).toHaveBeenCalledTimes(1);
const signal = ingestRuntimeErrorSignalMock.mock.calls[0]?.[1] as Record<string, unknown>;
expect(signal).toMatchObject({
errorType: 'api_key_missing',
scope: 'cron',
sourceDomain: 'cron',
provider: 'openrouter',
});
// No-leak contract: the raw provider `message` must NEVER be forwarded.
expect(signal.message).toBeUndefined();
});
it('defaults "user_error" sourceDomain to cron when error_source is absent (#4165)', async () => {
const { handlers, mockSocket } = buildMockSocket();
vi.doMock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) }));
getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc');
ingestRuntimeErrorSignalMock.mockClear();
const { socketService } = await import('../socketService');
socketService.connect('jwt-test-user-error-default');
await pollUntil(() => expect(handlers['user_error']).toBeDefined());
handlers['user_error']!({ error_type: 'insufficient_credits' });
expect(ingestRuntimeErrorSignalMock).toHaveBeenCalledWith(
storeMock.dispatch,
expect.objectContaining({ errorType: 'insufficient_credits', sourceDomain: 'cron' })
);
});
it('drops an empty "user_error" payload without ingesting (#4165)', async () => {
const { handlers, mockSocket } = buildMockSocket();
vi.doMock('socket.io-client', () => ({ io: vi.fn(() => mockSocket) }));
getCoreRpcUrlMock.mockResolvedValue('http://127.0.0.1:7788/rpc');
ingestRuntimeErrorSignalMock.mockClear();
const { socketService } = await import('../socketService');
socketService.connect('jwt-test-user-error-empty');
await pollUntil(() => expect(handlers['user_error']).toBeDefined());
handlers['user_error']!(null);
expect(ingestRuntimeErrorSignalMock).not.toHaveBeenCalled();
});
});
+32
View File
@@ -3,6 +3,7 @@ import { type Socket } from 'socket.io-client';
import { getCoreStateSnapshot } from '../lib/coreState/store';
import { SocketIOMCPTransportImpl } from '../lib/mcp';
import { ingestRuntimeErrorSignal } from '../lib/userErrors/report';
import { store } from '../store';
import {
setBackendMeetError,
@@ -430,6 +431,37 @@ class SocketService {
store.dispatch(setCompanionState(event));
});
// Permanent user-config / billing failures surfaced from background jobs
// (e.g. cron) — core broadcasts these to the "system" room as a
// metadata-only `user_error` event carrying a stable kind token in
// `error_type` (never a raw provider body). Routed through the same
// classifier the chat runtime uses so the UserErrorCenter renders them
// durably with a deep-link action, even though no chat thread is active.
// Producer: core cron scheduler `publish_cron_user_error` (#4165 /
// TAURI-RUST-HCK follow-up).
this.socket.on('user_error', (data: unknown) => {
const obj = data as Record<string, unknown> | null;
if (!obj) {
socketWarn('user_error dropped — empty payload');
return;
}
const errorType = typeof obj.error_type === 'string' ? obj.error_type : undefined;
const provider = typeof obj.error_provider === 'string' ? obj.error_provider : undefined;
const sourceDomain = typeof obj.error_source === 'string' ? obj.error_source : 'cron';
socketLog('user_error kind=%s source=%s', errorType ?? 'none', sourceDomain);
// Metadata-only ingest: forward the stable kind token + scope ONLY, never
// a raw `message` body. The cron producer already omits it, but we drop
// any `obj.message` here too so a future/buggy broadcast can't leak raw
// provider text into the UI — classify() keys on `errorType` for this
// path. Locks the no-leak contract FE-side (CodeRabbit #4169).
ingestRuntimeErrorSignal(store.dispatch, {
errorType,
scope: 'cron',
sourceDomain,
provider,
});
});
// Backend Meet bot events — forwarded from core's DomainEvent bus
this.socket.on('agent_meetings:joined', (data: unknown) => {
const obj = data as Record<string, unknown> | null;
+1 -1
View File
@@ -14,7 +14,7 @@
*/
/** Stable discriminator the UI branches on. Extend as new states are added. */
export type UserErrorKind = 'insufficient_credits' | 'budget_exceeded';
export type UserErrorKind = 'insufficient_credits' | 'budget_exceeded' | 'api_key_missing';
/** Where the failure originated, for grouping/labelling (privacy-safe). */
export type UserErrorScope = 'chat' | 'cron' | 'provider' | 'integration' | 'workspace';
+36 -4
View File
@@ -579,12 +579,12 @@ async fn execute_job_with_retry(
// Suppressed the retries-exhausted Sentry report for a permanent
// user-config / billing state. Metadata-only breadcrumb so the
// suppression is diagnosable in production without the raw provider body.
let reason = if credits_exhausted {
"insufficient_credits_402"
let (reason, user_error_kind) = if credits_exhausted {
("insufficient_credits_402", "insufficient_credits")
} else if budget_exhausted {
"budget_exhausted_400"
("budget_exhausted_400", "budget_exceeded")
} else {
"api_key_unset"
("api_key_unset", "api_key_missing")
};
log::debug!(
"[cron] action=suppress_retries_exhausted_report reason={reason} job_id={} retries={}",
@@ -598,6 +598,11 @@ async fn execute_job_with_retry(
// surfaced here — only the `&'static str` constants from
// `permanent_halt_message`.
last_output = permanent_halt_message(credits_exhausted, budget_exhausted).to_string();
// Also surface the actionable state to the UserErrorCenter so the user
// can fix it (add an API key / top up credits / raise the budget) even
// with no chat thread open. Broadcast-only + metadata-only — see
// `publish_cron_user_error` (#4165 / TAURI-RUST-HCK follow-up).
publish_cron_user_error(user_error_kind);
}
(false, last_output)
@@ -618,6 +623,33 @@ fn permanent_halt_message(credits_exhausted: bool, budget_exhausted: bool) -> &'
}
}
/// Surface a permanent cron user-config / billing halt to every connected
/// client's UserErrorCenter.
///
/// Broadcasts a metadata-only `user_error` web-channel event to the `"system"`
/// room (which every socket auto-joins). The payload carries only the stable
/// `kind` token in `error_type` — one of `api_key_missing` / `insufficient_credits`
/// / `budget_exceeded`, mirroring the frontend `UserErrorKind` discriminator —
/// plus `error_source = "cron"`. It NEVER carries the raw provider body (see the
/// metadata-only rule in CLAUDE.md), so no secrets / PII leave the core.
///
/// The frontend `socketService` listens for `user_error` and routes it through
/// the same classifier the chat runtime uses, so a background (no-delivery) job
/// failure is no longer silent — it lands in the shell's UserErrorCenter with a
/// deep-link action even though no chat thread is active.
fn publish_cron_user_error(kind: &str) {
log::debug!("[cron] action=surface_user_error kind={kind}");
crate::openhuman::channels::providers::web::publish_web_channel_event(
crate::core::socketio::WebChannelEvent {
event: "user_error".to_string(),
client_id: "system".to_string(),
error_type: Some(kind.to_string()),
error_source: Some("cron".to_string()),
..Default::default()
},
);
}
async fn process_due_jobs(config: &Config, security: &Arc<SecurityPolicy>, jobs: Vec<CronJob>) {
let max_concurrent = config.scheduler.max_concurrent.max(1);
let mut in_flight = stream::iter(jobs.into_iter().map(|job| {
+59
View File
@@ -1680,3 +1680,62 @@ async fn deliver_if_configured_empty_success_skips_chat_and_alert() {
assert!(deliver_if_configured(&config, &job, "", true).await.is_ok());
assert_eq!(cron_alerts(&config).await, 0);
}
/// Receive the next `user_error` broadcast on `rx` carrying `kind`, skipping any
/// unrelated events. The web-channel bus is a process-global broadcast, so a
/// sibling test running concurrently may interleave its own `user_error` (a
/// different kind) onto the same channel — filtering on `kind` keeps each test
/// deterministic regardless of ordering.
///
/// A concurrent flood can also push our event past the channel capacity before
/// we read it, surfacing as `Lagged` (the receiver fell behind, not a real
/// absence). We treat `Lagged` as recoverable and keep scanning (CodeRabbit
/// #4169); only a terminal `Empty`/`Closed` — the matching event genuinely was
/// not published — panics.
fn next_user_error(
rx: &mut tokio::sync::broadcast::Receiver<crate::core::socketio::WebChannelEvent>,
kind: &str,
) -> crate::core::socketio::WebChannelEvent {
use tokio::sync::broadcast::error::TryRecvError;
loop {
match rx.try_recv() {
Ok(ev) if ev.event == "user_error" && ev.error_type.as_deref() == Some(kind) => {
break ev
}
Ok(_) => continue,
// Receiver fell behind a concurrent flood — the dropped slots can't
// have held *our* just-published event before this point, so skip
// ahead and keep scanning rather than failing spuriously.
Err(TryRecvError::Lagged(_)) => continue,
Err(e) => panic!("expected a user_error broadcast for kind={kind}, bus said: {e:?}"),
}
}
}
#[test]
fn publish_cron_user_error_broadcasts_metadata_only_for_each_kind() {
use crate::openhuman::channels::providers::web::subscribe_web_channel_events;
// Folded from two tests that both published `api_key_missing` to the
// process-global bus and could false-pass off each other's broadcast under
// parallel execution (CodeRabbit #4169). One subscription + serialized
// publishes means each assertion can only be satisfied by THIS test's own
// emission, so a regression in `publish_cron_user_error` actually fails.
// The three tokens are exactly the `UserErrorKind` values classify.ts accepts.
let mut rx = subscribe_web_channel_events();
for kind in ["insufficient_credits", "budget_exceeded", "api_key_missing"] {
publish_cron_user_error(kind);
let ev = next_user_error(&mut rx, kind);
// Broadcast to the "system" room every connected socket auto-joins.
assert_eq!(ev.client_id, "system");
// Stable kind token mirrors the frontend `UserErrorKind` discriminator.
assert_eq!(ev.error_type.as_deref(), Some(kind));
assert_eq!(ev.error_source.as_deref(), Some("cron"));
// Metadata-only: a `user_error` NEVER carries the raw provider body
// (CLAUDE.md) and is thread-less (no chat context).
assert!(ev.message.is_none(), "user_error must not carry a raw body");
assert!(ev.full_response.is_none());
assert!(ev.thread_id.is_empty(), "cron user_error is thread-less");
assert!(ev.request_id.is_empty());
}
}