feat(translation): render-time translation middleware — pooled endpoints, AIMD pacing, streaming segmentation - #696
Conversation
… 600-1500 char window
…e at 3s/800 chars
…% and soft-rejects at half
- missingSourceNumbers now dedupes digit runs before judging, matching the backend's !runs.contains(current) so a repeated run cannot inflate the missing ratio - remove the unconsumed STREAM_TAIL_CHUNK_MIN_CHARS export: its 600 floor contradicted the actual TAIL_MIN_SENTENCE_CHARS=400 floor - state buildContextPrefix's [n] guarantee honestly: the scaffolding lines carry no [n] shape, but an interpolated previous segment that does degrades safely via the numbered-parse fallback - drop the stray third argument from four missingSourceNumbers test calls (the implementation takes two; vitest did not typecheck them)
The frontend prepends the previous segment as a read-only reference block to the same outbound text — the model's term-consistency anchor, which must keep riding to the endpoint. But the block is not content: the backend ran every local judgment on that full text, so the reference's numbers counted as source numbers the model was told not to output (missing_source_numbers rejected faithful translations in a loop), its English boilerplate cleared the echo gate's 30-letter bar, and the already-target-language path returned the block verbatim. Strip the block once at the entry of translate_with_cache and use the body for skip detection, the cache key, the quality-gate source, and the cache insert — while the endpoint request still carries the full outbound. A stub-endpoint regression test pins the wire shape: the model sees the reference block, the gates do not.
Sweep of the remaining translation-middleware work: backend quality gates and settings, streaming translation hooks (sentence-boundary chunking, width-filled batches, bounded settle flush), carry-context reference, and the frontend wiring/i18n. Includes the settle-flush fix that bounds re-requests at the nearest reconnectable piece instead of re-translating already-covered content.
The auto-commit hook swept .ccg/ task state, .zcode/ session plans, a stale runtest script, and empty scratch outputs into the WIP commit. Untrack them, ignore the paths, and drop the debug-only probe test (console.log assertions) from tracking; the probe file stays on disk.
…tity release Three layers against deterministic echo/answer failures on unreliable endpoints: every outbound body rides a <translate> envelope (source as DATA, separate from instructions); retries escalate a constraint line because an identical request at temperature 0 returns an identical wrong answer; and a settle gap whose retry budget is spent stitches the chain with an identity piece instead of freezing every translated piece behind it (display store only — never cached as a translation).
The backend strips the <translate> envelope for skip detection, the cache key, and the quality gates (same contract as the reference-block strip), while the full outbound still rides to the endpoint; the system prompt names the envelope so models respect the boundary.
…its retry Failed regions outlive the hook instance that recorded them: a pendingGapsStore keyed alongside the piece store records every unlanded segment (dispatch rollback, failed settle flush), and a replay effect re-dispatches recorded gaps on mount and re-key — deliberately not viewport-gated, since the settle-time re-key is exactly when the old instance's variant-retry chain dies. Backoff and pause budgets keep a dead endpoint from looping. Dispatch paths also claim their segments in flight now: the cursor rollback and text-flush effect re-runs could send the same segment twice (observed once with the carry-context reference, once without), spending quota twice for one translation.
An endpoint that returns the source unchanged used to slip past the echo gate whenever the chunk masked down to placeholders plus a few words — the gate's >=30-letter prose bar never fired on code-heavy thinking text, and the echo was served as a "translation" (observed three times on one relay with no rejection recorded). The new exact-echo gate compares content instead of script coverage: placeholders stripped from both sides, whitespace normalized, reply equal to source means echo. Placeholder-only chunks stay exempt — echoing [[CBLK0]] back IS the correct translation. Mirrored on both sides (frontend judge, backend gate) with the same normalization, so one reply cannot pass one side and fail the other.
… flight An endpoint answering in 76-112 s used to hold a Background-lane slot for the whole round trip and only reached the health window when the reply landed — three such requests stalled the entire thinking translation lane for over a minute while the reader stared at raw text. The soft in-flight deadline (30 s) now records a SlowInflight event the moment the wait becomes reader-visible and halves the provider's rate on the spot, so later chunks rotate elsewhere while the slow request is still out. The event weighs half on stability, like a 429 — nothing failed, but the lane is being held — and is deliberately excluded from the speed window, where the eventual reply records the true round trip (counting both would double-punish one ask). Quality never hears about it: no reply was judged.
An endpoint that answers a wide chunk with a self-written essay (the observed shape: a 423-character block returned as a 1600-character document) was refused and re-requested whole — at temperature 0 the retry found the same rope and wrote the same essay. An invention-shaped rejection on a chunk over 800 characters now buys one split: the chunk goes back out as two halves judged independently, narrowing the space the model can wander in. Any other rejection, a short chunk, or a half that fails again keeps the original verdict. Splitting exposed a latent splitter bug: the strong sentence-end set omitted the ASCII period, so English — the primary source language — never cut at sentence boundaries anywhere (tail chunks, stable-unit splits, settle flushes all degraded to whitespace-at-window-edge). The half-split boundary now lands on real English sentence ends too.
Reconstructing which UI block produced which backend request from a mixed traffic log meant matching on text prefixes. The blockKey now rides the translate call end to end — cache key first field, through both IPC surfaces, into the client's sending/response/failure lines — so "this paragraph failed three times on this endpoint" is one grep away.
…ly comes through The final review caught the half-split firing on the wrong door: the backend's length gate rejects the reply before the frontend judge ever sees it, and the observed production invention (423-character chunk, 1600-character document) arrived exactly that way — result.error, never judged — so the split never fired for it. An invention-shaped result.error now triggers the split on both the lone-chunk and the per-chunk fallback path; the judge-side trigger keeps only the INVENTED_CONTENT code (the 'far longer' text match there was dead — fixed codes only) and the join now restores the boundary space a same-line split consumes. Also: deterministic placeholder/surrogate straddle tests (the old assertion never exercised the move), and a comment on why the dispatch cursor may cover a still-inflight segment.
- A segment with no Unicode letter (separators, symbol runs, digit-only fragments) maps to itself in every language, so it now lands as an identity piece instead of a request that can only be echo-refused. One content rule covers every decoration without a symbol whitelist. - A gap refused by three consecutive replays is dropped instead of being replayed through every widening backoff forever: the raw source stays displayed and the concurrent slot is freed. The count is keyed by content, so a re-keyed instance cannot resurrect the region.
A reply that settled just as the block re-keyed was dropped by the isCurrent check: neither the store nor the state got it, and the replacement instance re-requested the segment from scratch. Observed in the 02:49 session — the same thinking-tail segment was fetched three times, each reply arriving fine, each discard re-queueing it; the display stayed raw for the whole turn. All three landing paths (streaming land, gap replay, settle flush) now merge their pieces into the store synchronously before the gate. The content-addressed restore hands them to whoever takes the key next, so a re-key costs a re-render instead of a re-request.
The 04:14 session showed a reply whose FIRST paragraph was Chinese (the model's preamble under a global zh convention) while the rest was English. Translating the preamble to zh returns it verbatim, the echo gate refused it, and the gap was eventually given up — leaving no piece at offset 0. The display chain breaks at the first byte, so all thirteen translated paragraphs behind it rendered as raw English under a toggle claiming the block was translated. Two fixes plus a race found while reproducing: - Giving up a gap now stitches the raw source as an identity piece (the settle flush's whitespace stitch, extended to give-ups): the chain stays whole and the raw lead renders ahead of the translated rest. - A segment already written in the display language (Han-dominant against a zh target, judged on settings.targetLang ?? uiLocale) lands as an identity piece without a request — the echo it would get back IS the correct translation. - The settle flush now also holds off while any in-flight SUBSEGMENT of its gap is on the wire: a flush request sent past the fallback landings could be refused and given up, stitching the whole span raw and burying the subsegment's translation under an identity piece the chain can never see past. This replaces the accepted one-shot double-spend: the flush re-runs on the landing and spends one request, not two.
Four mermaid diagrams over the translation middleware: the end-to-end call chain (frontend hooks -> backend pool -> endpoint), the streaming segmentation and display-chain assembly, the quality gates with retry escalation, and a full lifecycle sequence for one reply. Edge labels validated against the mermaid parser the app itself embeds.
The old behavior dropped every line after the daily 512MB ceiling until the UTC rotation, which silenced exactly the logs a user debugging a problem was looking at. Crossing the ceiling now truncates the day's file and resumes with a fresh budget — the disk stays bounded and the log never goes quiet. A failed truncation drops its line and retries on the next crossing. Verified live: a file parked at the ceiling was cut to zero on the first line of a fresh process.
A provider that keeps failing stayed in the rotation until its client- error count benched it for the whole session — too blunt for a flaky endpoint and irreversible without a restart. Providers can now be parked and revived explicitly, and the auto-cooldown is a setting instead of a hardcoded session exile: - Three new commands (and web routes): provider_reset, provider_disable, provider_cooldown. The settings page renders a Ban/Timer/RotateCcw action per provider row plus a header strategy popover. - Consecutive failures (429s and hard errors alike) now trigger a bounded cooldown — failure_threshold (default 3) and cooldown_seconds (default 60) live in settings, read per request so a mid-flight edit cannot rewrite the thresholds under an in-flight pick. - Two lane concurrency caps: priority_max_concurrent for prose the reader is waiting on, background_max_concurrent for thinking-block translation so it cannot crowd out the priority lane on a small endpoint. - translate_body setting (default true) so reply-prose translation can be turned off while thinking blocks keep theirs. - Saves broadcast translation-settings-changed to every open frontend; other windows re-fetch instead of keeping the mount-time snapshot. - New i18n keys across all 10 locales; settings tests cover the new UI surface. Housekeeping: gate the Windows resource link directive behind tauri-runtime (server-mode test exes need no manifest), ignore security-scan artifacts.
# Conflicts: # src-tauri/src/lib.rs # src/components/message/message-list-view.tsx # src/i18n/messages/ar.json # src/i18n/messages/de.json # src/i18n/messages/en.json # src/i18n/messages/es.json # src/i18n/messages/fr.json # src/i18n/messages/ja.json # src/i18n/messages/ko.json # src/i18n/messages/pt.json # src/i18n/messages/zh-CN.json # src/i18n/messages/zh-TW.json
ReviewFirst — thank you for this. It's an enormous piece of work, and it shows: the commit messages are some of the best I've seen in this repo (each one explains why, with the observed failure that motivated it), the i18n is genuinely complete (145 keys × 10 locales, real translations, no English fallbacks), the streaming tests describe real races rather than happy paths, and masking code spans out of the request before it leaves the machine is exactly the right call. The problem is real too — reading long English agent output while switching to a translation tool does break the flow, and "don't touch the agent, the prompt, or the session files" is the right constraint to build under. I want to be equally straight with you about where it stands: I can't merge it as it is. There are four blocking issues, three of which make CI red, plus a set of correctness problems in the gate/cache/pool interaction. I've written all of them up below with reproductions so none of it is guesswork, and there's a section at the end on how I'd suggest splitting this up — because I think the biggest issue isn't any single bug, it's that this is three PRs' worth of surface area in one. What I ran
The last two are the repo's actual gates ( BlockingB1 ·
|
|
Thank you for the unusually thorough review — I agreed with the core diagnosis that this was three PRs' worth of surface area, so I rebuilt it from The replacement stack is:
How the review items landed:
The pool PR deliberately keeps multi-provider rotation, AIMD, Retry-After and failure cooldowns, two lanes, manual operations and per-provider counters, while dropping health ranking, retirement, probes and the six-hour trend chart. Verification was rerun against the actual repository gates rather than the narrower commands from the old PR:
I'll close this oversized PR in favor of the replacement stack. The fork PRs are intentionally based on the preceding fork branch so their diffs stay reviewable; after #700 lands they can be rebased/retargeted upstream one at a time. |
Summary
Adds a render-time translation middleware that translates agent output (reply prose and thinking blocks) into the UI language as messages render — without touching the agent, the prompt, or the session files. Closes #695.
Structure
src-tauri/src/translation/:pool.rs(rotation, lanes, cooldowns),aimd.rs,health.rs,metrics.rs(per-provider counters, 10-column stats, 6h series),client.rs(protocol adapters, gates),settings.rs(validation, change broadcast),cache.rs; wired into both desktop commands and the Axum web router.use-translated-text.ts/use-streaming-translated-text.ts(segmentation, display chain, piece store with source validation), message-render integration, settings page with pool table + trend chart; i18n keys in all 10 locales.tauri-runtime(server-mode test exes need no manifest).Testing
tsc --noEmit: 0 errorscargo checkdesktop +--no-default-featuresserver: both greencargo test --lib: 3647 tests passing30 commits, each with a detailed rationale in its message; the branch is based on current
main.