i18n(routines): localize "Agent completed without producing any output" error

Closes part of #2902. Addresses the specific example error called out in
the issue body; further daemon-surfaced messages can be wired in
incrementally by appending to `KNOWN_DAEMON_ERROR_PREFIXES`.

When a Scheduled Routine run fails because the agent process exited
cleanly without producing output, the daemon emits a structured SSE
error payload (`AGENT_EXECUTION_FAILED`, see
`apps/daemon/src/server.ts` around line 11334) with an English message:

  "Agent completed without producing any output. The model or provider
   may have returned an empty response — check the agent logs for
   upstream errors."

The web side surfaces `run.error` as a plain string via
`runFailureReason` in `apps/web/src/components/RoutinesSection.tsx`,
which bypasses i18n entirely. Result: even on a `zh-CN` / `zh-TW`
locale the failure shows in English, breaking the Chinese-language
experience for what is otherwise one of the most user-visible Routines
surfaces (the run history error row and the per-routine card).

Changes:
- Add an i18n key `routines.error.agentEmptyOutput` with the exact
  English source string copied verbatim from the daemon, plus explicit
  zh-CN and zh-TW translations. zh-CN gets an explicit entry (rather
  than relying on `...en` spread) so the tier-1 parity lock from #2920
  stays green. Other locales fall back via the existing `...en` spread
  shape; their dedicated translations can be filled in by native
  speakers in follow-ups.
- Add `localizeRoutineError(t, raw)` in `RoutinesSection.tsx` with a
  `KNOWN_DAEMON_ERROR_PREFIXES` table mapping stable daemon message
  prefixes onto i18n keys. Match on `startsWith` so any trailing
  context the daemon may append still routes to the same translation.
- Thread `t` into `runFailureReason` so both callsites (the run
  history list and the per-routine card) get the localized text.
- Unknown daemon errors pass through unchanged — untranslated
  failures still surface, they just stay in English until a matching
  i18n key is added. No behavior regression for any existing error.

Verified locally:
- `git diff main --stat`: 5 files, +47 / -8.
- New key present in `en.ts`, `zh-CN.ts`, `zh-TW.ts`; `Dict` updated
  in `types.ts`.
- Tier-1 lock dry-run: `zh-CN.ts` declares 2311 explicit keys matching
  `en.ts`'s 2311 value-bearing keys, with no `...en` spread.

Drafted with Claude (Anthropic) assistance; reviewed and submitted by me.
This commit is contained in:
zhongrenfei1-hub 2026-05-26 14:06:22 +08:00
parent 52f6ca0cf5
commit a203e93978
5 changed files with 62 additions and 8 deletions

View file

@ -172,14 +172,56 @@ function formatRunTimestamp(ts: number): string {
});
}
function runFailureReason(run: {
// Maps known daemon-surfaced failure message prefixes onto i18n keys, so
// users running the app in a non-English locale see a translated reason
// instead of the raw English string the daemon ships to web (see e.g.
// `apps/daemon/src/server.ts` `createSseErrorPayload` callsites). We match
// on a stable prefix rather than the full string so any trailing context
// the daemon may append (e.g. `: <provider detail>`) is preserved verbatim
// after the localized prefix — losing it would hide debugging context the
// caller may rely on. Anything not in this list passes through untouched —
// untranslated failures continue to surface so behavior never regresses,
// they just stay in English until a matching i18n key is added here.
// Each entry is the *full current daemon message* (copied verbatim from
// the corresponding `createSseErrorPayload` call) paired with the i18n
// key that translates it. We match on full-string prefix so the current
// contract resolves to `t(key)` exactly; if the daemon later appends
// extra context (e.g. `: <provider detail>`), the extra text is
// preserved as a suffix on the translated message rather than dropped.
const KNOWN_DAEMON_ERROR_PREFIXES: ReadonlyArray<readonly [string, keyof Dict]> = [
[
// Source: apps/daemon/src/server.ts `AGENT_EXECUTION_FAILED` payload.
'Agent completed without producing any output. The model or provider may have returned an empty response — check the agent logs for upstream errors.',
'routines.error.agentEmptyOutput',
],
];
function localizeRoutineError(t: TranslateFn, raw: string): string {
for (const [prefix, key] of KNOWN_DAEMON_ERROR_PREFIXES) {
if (raw.startsWith(prefix)) {
// Preserve any suffix the daemon may have appended (e.g. provider
// detail after the known prefix). When the daemon emits exactly
// the prefix string today, `suffix` is empty and the result is
// just `t(key)`; nothing changes for the current contract.
const suffix = raw.slice(prefix.length);
return `${t(key)}${suffix}`;
}
}
return raw;
}
function runFailureReason(
t: TranslateFn,
run: {
status: RoutineRun['status'];
error?: string | null;
summary?: string | null;
} | null | undefined): string | null {
} | null | undefined,
): string | null {
if (!run || run.status !== 'failed') return null;
const reason = (run.error || run.summary || '').trim();
return reason || null;
if (!reason) return null;
return localizeRoutineError(t, reason);
}
type FormState = {
@ -412,7 +454,7 @@ function RunHistory({
return (
<ul className="routines-history">
{runs.map((r) => {
const failureReason = runFailureReason(r);
const failureReason = runFailureReason(t, r);
return (
<li key={r.id} className="routines-history-row">
<StatusPill status={r.status} t={t} />
@ -754,7 +796,7 @@ export function RoutinesSection({ onClose }: RoutinesSectionProps) {
: t('routines.targetCreate');
const isBusy = busyId === r.id;
const isExpanded = expandedId === r.id;
const failureReason = runFailureReason(r.lastRun);
const failureReason = runFailureReason(t, r.lastRun);
return (
<li key={r.id} className={`routines-card routines-item${r.enabled ? '' : ' is-disabled'}`}>
<div className="routines-item-head">

View file

@ -900,6 +900,8 @@ export const en: Dict = {
'Delete this automation? Past runs and their projects are kept.',
'routines.errorPickProject':
'Pick a project to reuse, or switch to “Create new each run”',
'routines.error.agentEmptyOutput':
'Agent completed without producing any output. The model or provider may have returned an empty response — check the agent logs for upstream errors.',
'entry.helpAria': 'Help',
'entry.helpMenuAria': 'Help menu',
'entry.helpGetHelp': 'Get help on GitHub',

View file

@ -897,6 +897,8 @@ export const zhCN: Dict = {
'routines.status.canceled': '已取消',
'routines.confirmDelete': '删除此自动化?过往运行记录及其项目将予以保留。',
'routines.errorPickProject': '请选择要复用的项目,或切换为“每次运行新建项目”。',
'routines.error.agentEmptyOutput':
'智能体执行完成但未产生任何输出。模型或服务商可能返回了空响应——请查看智能体日志以排查上游错误。',
'entry.helpAria': '帮助',
'entry.helpMenuAria': '帮助菜单',
'entry.helpGetHelp': '在 GitHub 上获取帮助',

View file

@ -643,6 +643,8 @@ export const zhTW: Dict = {
'routines.status.canceled': '已取消',
'routines.confirmDelete': '刪除此自動化?過往執行記錄及其專案將予以保留。',
'routines.errorPickProject': '請選擇要重複使用的專案,或切換為「每次執行建立新專案」。',
'routines.error.agentEmptyOutput':
'代理執行完成但未產生任何輸出。模型或服務商可能傳回了空回應——請查看代理日誌以排查上游錯誤。',
'newproj.tabPrototype': '原型',
'newproj.tabLiveArtifact': '即時成品',

View file

@ -1210,6 +1210,12 @@ export interface Dict {
'routines.status.canceled': string;
'routines.confirmDelete': string;
'routines.errorPickProject': string;
// Routine run failure messages surfaced from the daemon. Each key
// corresponds to a well-known daemon-side error string we recognize
// and localize in `localizeRoutineError` (apps/web/src/components/
// RoutinesSection.tsx). Unknown daemon errors keep their raw string
// so untranslated failures still surface, just in English.
'routines.error.agentEmptyOutput': string;
// Bottom-of-rail help menu
'entry.helpAria': string;
'entry.helpMenuAria': string;