mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
test/shared-task-runtime-boundaries
3788
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
34ac405bfe |
fix(release): gate the Thursday patch cut on the previous patch, not the minor base (#7977)
* refactor(release): extract the patch-cut pre-flight out of inline shell Faithful port, no behavior change: resolve-patch-cut.ts computes the same version and the same X.Y.0 gate target the shell did, and runs the same gh release lookup. Moving it out of YAML is what makes the gate decision testable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(release): pin the patch cut on the previous patch, not the minor base Red against the current guard: with release/v0.22.2 cut and unshipped, the pre-flight gates open-design-v0.22.0, finds it published, and cuts v0.22.3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(release): gate the Thursday patch cut on the previous patch The guard checked the minor base X.Y.0, so it only ever guarded a line's first patch. Once X.Y.0 shipped, X.Y.2, X.Y.3, ... all passed no matter how many unshipped releases had stacked up beneath them. Gate on the release the cut actually stacks on — the highest release/vX.Y.Z below it — which is the same target cut-release.yml uses for the Tuesday minor, and still gives the answer the old minor-base comment wanted (cutting 0.15.1 checks open-design-v0.15.0, not the older 0.14.0). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(release): surface patch gate lookup failures --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4e460eae41 |
fix(updater): coordinate shutdown and observe update handoff (#7982)
* fix(updater): wait for shutdown without false quit failures * feat(updater): observe persisted shutdown and successor stages |
||
|
|
246ec7d06d |
fix(chat): resolve folded strategy retries to their failed run (#7981)
Co-authored-by: lefarcen <935902669@qq.com> |
||
|
|
6734dbba2e |
fix(chat): restore persisted strategy task blocked fields in conversation history (#7967)
* fix(chat): restore persisted strategy task blocked fields in conversation history * fix(chat): scope history task projection to its conversation --------- Co-authored-by: lefarcen <935902669@qq.com> |
||
|
|
a3da5e06c8 |
fix(chat): automatically retry transient conversation reads (#7954)
* fix(chat): recover transient transcript loads within a bounded window * fix(chat): retry transient transcript responses without a hint * test(web): await writable scope before deleting a conversation --------- Co-authored-by: lefarcen <935902669@qq.com> |
||
|
|
9c5ee3c915 |
fix(chat): resume Cloud retries after authority and history settle (#7953)
* fix(chat): preserve Cloud retry across workspace role projections * fix(web): preserve Cloud retry until project authority settles --------- Co-authored-by: lefarcen <935902669@qq.com> |
||
|
|
be0887b392 | fix(packaged): wait for updater predecessor before instance discovery (#7970) | ||
|
|
81044a03ca |
docs(whats-new): refresh Product Design Hackathon announcement (#7960)
Co-authored-by: zhouzheng <zhouzheng@refly.ai> |
||
|
|
7fa06a408b | fix(diagnostics): retain bounded environment evidence for workspace failures (#7951) | ||
|
|
53a47bf910 |
fix(web): stamp platform metadata on safety telemetry (#7944)
Co-authored-by: nettee <nettee.liu@gmail.com> |
||
|
|
365313443d |
fix(e2e): make packaged Home readiness timeout deterministic (#7952)
Use a fake clock to verify the existing 10ms deadline and the final readiness snapshot after multiple inspections. Cover a first inspection that consumes the whole budget, where a single observation is valid. Keep the readiness helper and application behavior unchanged. Co-authored-by: lefarcen <935902669@qq.com> |
||
|
|
d54f5cf07d |
fix(observability): add canonical client monitoring kinds (#7945)
Co-authored-by: nettee <nettee.liu@gmail.com> |
||
|
|
21bba485b6 |
fix(whats-new): support localized Arena CTA labels (#7943)
Co-authored-by: zhouzheng <zhouzheng@refly.ai> |
||
|
|
f197844e27 |
fix(chat): keep each folded run's own clock (#7921)
* fix(chat): keep each folded run's own clock
A logical OD Next task runs as several physical Runs, and reopening a
conversation folds them into one turn. The fold keeps one message row, so
only Run 0's createdAt and Run N's endedAt survive and every Run boundary
in between is discarded. The renderer is then left with one clock for N
Runs, which shows up as two separate reports:
- a Run's shell with no timestamped events of its own (clarification
Runs typically have none, and the plain-stream agent family never
emits any) reaches neither the "opened this round" nor the "closed
this round" fallback, so its head is a bare "Done" with no duration;
- a thinking gap, inferred from "last timestamped event until the next
one", runs straight across the Run boundary and charges earlier Runs'
wall-clock time - including the minutes the user spent answering a
question form - to the current Run's Thoughts row, which then reports
more than the card head containing it.
Both are reload-only: strategyTaskRunIndex is served by the history
endpoint alone, so the same conversation renders correctly while live and
loses the numbers once reopened.
Restore the lost boundary rather than adjust either number. The fold
stamps each Run's own span onto the done_key it already emits, and the
renderer scopes the shell clock and the thinking gap to that Run. On the
recorded three-Run conversation the reloaded turn now reports 1m 20s /
2m 39s / 4m 42s and a 2m 57s Thoughts row - identical, to the millisecond,
to what the same Runs report live.
Also make a card's span cover the thinking row it credits, so the same
invariant holds for a second card opened mid-Run after a done marker.
OPEND-2823, OPEND-2824
* fix(chat): stop a card borrowing another run's clock
Review of #7921 caught the other half of the same defect. shellElapsed
still seeded from/to from the turn-global firstStartedAt/lastEndedAt when
a shell had no shellSpan of its own, then unioned those leftovers with the
new per-run bounds. A folded successor run that emits only status and text
- the typical clarification run, and every plain-stream agent - has no
shellSpan, so its card picked up the preceding run's tool timestamps and
reported that run's time as its own.
So the first commit fixed "a middle run shows no number" but left "the
number belongs to another run", behind a different trigger.
Drop the turn-global seed instead of clearing those clocks in closeRun().
firstStartedAt/lastEndedAt are the min/max of every timestamp in the turn,
so when this shell has no span of its own any value there was necessarily
stamped by a different shell: the fallback is, by construction, always
borrowing another card's clock and can never once be this card's own time.
Clearing at the run boundary would also only cover the cross-run trigger -
the identical borrow happens inside a single run when a second card opens
after a done marker with no timestamped events, where closeRun() never
runs - and it would silently change thinkGapStart and shellQuiet, which
read the same two clocks.
On the review's shape a card that ran for 20s reported 5m 10s; it now
reports 20s, which is what the same run reports live.
OPEND-2823, OPEND-2824
* docs(contracts): clarify missing folded-run timing boundaries
|
||
|
|
00c12c6451 |
feat(web): wire up the ChatPanel observability that was never called (#7930)
* feat(web): make chat telemetry name the run it is talking about `setChatCorrelation()` has had no production caller since #7518. Every `client_chat_*` event therefore shipped with an empty correlation block, and `client_chat_interaction_latency` — the one chat observability event with real traffic (2,694 events / 30 days) — derives its `streaming` flag from `chatCorrelation().run_id`. With no producer the flag was false on every event ever sent, so the panel read "every chat stall happens while the app sits idle": a self-consistent, wrong story, and the reason the metric was worse than no metric. Wire the four producers that already had their values in hand: - run id + agent id, opened at every run start (`streamViaDaemon`, the next physical run of a strategy-task chain, and `reattachDaemonRun` — the path a page refresh takes back onto a run still in flight) and closed in the same `finally` that settles the stuck-run watchdog. Named helpers, because the pair is load-bearing: only an opener and `streaming` is stuck true forever; only a closer and it is stuck false. - conversation id + project id, once `listMessages` resolves. - release channel, off `/api/version` — the daemon has always answered with `channel` and the web read `version` and dropped the rest. - replay session id, handed over from the posthog-js instance inside `loaded()`. This is the only possible source: the ESM build never publishes itself as `window.posthog`, so the global-lookup fallback returns undefined forever while looking implemented. No new events. This only makes the traffic already being sent tell the truth. `model_id` stays absent — it is not in scope at either run-start site, and a guess would be worse than the gap. * feat(analytics): record which failure sentence the user actually read `run_failed_toast` says a user was shown a failure card and names the daemon's `error_code`. It cannot say what the user READ — those two facts are separated by a mapping table, and `resolveRunErrorCardDescription` states in as many words that the table can always be one row short. When it is, the card falls back to a blank "the task failed" with no diagnosis. That is the worst impression this surface produces, and it was the one we could not count. Add two fields to the exposure: - `message_key` — the i18n key of the mapped copy, or `generic_fallback` when the table had no line for it. Always present, never omitted: the fallback RATE is the number that matters, and a rate needs a denominator, so an omitted key would drop the fallback impressions out of the very count meant to measure them. - `failure_category` — the daemon's own classification off the run's error event, defaulting to the enum's existing `unknown` member rather than vanishing, so this event stays joinable against `run_finished` and `run_recovery_action`. `message_key` is typed as `string`, not the web's `RunFailureMessageKey` union: that union lives in apps/web and grows with every copy change, and pinning it in contracts would make adding a sentence a contracts change. * feat(web): attach the chat-health monitor to the chat log `chat-health.ts` has answered four questions since #7518 — how long until the user can read the conversation, is the DOM growing without bound, who is about to OOM, how janky is the UI while a run streams — and nothing but its own unit spec has ever called it. All four tiles were empty in production. Open a surface on the `.chat-log` element while the Chat tab is showing it, and tear it down when it goes away. Two effect dependencies, each guarding a real failure: - `tab`, because the log is conditionally rendered and `logRef.current` is null on the Comments tab — without it, coming back to Chat never reattaches. - `activeConversationId`, because that div carries no conversation key, so React reuses the very same DOM node across a switch. There is no node-level signal that the surface should be replaced; the id is the only one. The spec pins this by asserting node identity is unchanged AND that a second surface opened anyway. `markFirstPaint` lands together with `setStreamEventCount`, deliberately: a first-paint duration on its own cannot be attributed, because a slow open is either "many messages" or "one message with hundreds of tool events behind it", and only the event count separates them. Rendered-row count uses the same `:scope > *` definition `client_chat_dom_growth` already uses, so the two events are comparable. Run start / end feed the jank window from the daemon provider, which is where the authoritative run lifecycle lives. Both no-op when no chat surface is mounted. Also extracts `isChatVirtualized()` so the renderer and the telemetry read the same predicate. Two copies of `items.length > THRESHOLD` are identical today and drift the first time the rule grows a second term — at which point the telemetry would describe a render mode the renderer no longer uses, silently. `markChatOpenIntent` is NOT wired here. Hanging it off the two conversation handlers would only cover `conversation_switch`; `project_switch` has 10+ navigate sites in App.tsx and `cold_boot` passes through no handler at all. The right home is `router.ts`, which is the single navigation entry point and can derive the open kind from the route transition. That is its own change. Until then `open_kind` honestly reads `remount` rather than a fabricated `cold_boot`. * fix(web): stop the wiring from reintroducing the bug it removes Three review findings, all real, all the same family: an event that cannot name what it measured. 1. `client_chat_stream_health` was going out with an empty `run_id`. `chatSurfaceRunEnded()` does not merely bookkeep — it FLUSHES the jank window, and the flush spreads `chatCorrelation()`. The terminal path cleared the correlation one line earlier, so the one event this PR newly enables shipped anonymous: the exact defect the PR exists to remove, reintroduced by the fix for it. One rule now covers both ends of a run: the chat-health call goes FIRST, because it is the one that can emit; the correlation mutation goes second. Applied symmetrically at the three run-start sites too — `runStarted()` flushes any window a previous run left open (its terminal event never arrived), and that flush belongs to the OLD run, so it has to happen before the block is repointed. `trackRunTerminal` is unaffected either way; it carries its own context and never reads this block. 2. A surface opened mid-run did not know a run was running. Surfaces are recreated on every conversation switch and every return to the Chat tab, but `chatSurfaceRunStarted` only ever reached the surface that existed when the provider fired it. The new surface kept its window shut and dropped every long task until the next run start — precisely the stretch a user is watching, because they came back to the tab to see something generate. `openChatSurface` now adopts the in-flight run. It reads it from the correlation context rather than keeping a second flag: `chat-interaction.ts` already derives its `streaming` breakdown the same way, and one source cannot disagree with itself. No double counting — `runCount` and the window clock are per-surface, and adoption only ever runs against the surface constructed a line above it. The spec pins this with three long tasks split across a mid-run swap and asserts they add up to three, not four. 3. `conversation_id` / `project_id` moved from ProjectView to the ChatPane boundary. ChatPane has three hosts — ProjectView, DesignSystemFlow and workspace/SideChatTab — feeding one set of observability modules and one dashboard. Setting it in a single host left the other two emitting orphan rows indistinguishable from genuinely un-correlated ones. Both already pass the ids in as props. The effect is declared ahead of the surface-open effect so the opening `conversation_open` sample is already stamped. |
||
|
|
9f73dcebf3 |
fix(chat): make the run-failure card's retry tell the truth about the send gate (#7927)
* fix(chat): make the run-failure card's retry tell the truth about the send gate Three reports that all land on the same gate in ProjectView: `currentConversationActionDisabled`, which every recovery handler consults and no button was ever wired to. OPEND-2821 — the retry button had no `disabled` at all, so it always looked live. Clicking it reported a `manual_retry` recovery click and then `handleRetry` returned. Split that boolean into a four-way reason (read-only / messages-unavailable / billing-unresolved / conversation-busy), disable the card's recovery actions when it is set, and say which one it is. The split is a partition of the same predicate, proven exhaustively. OPEND-2758 — `handleSend` paints the new turn before the preflight and before POST /api/runs (OPEND-2614, deliberate), and that paint makes `retryableAssistantMessage` return null, so the card vanished 1-2s before anything was confirmed. Announce the pending retry, pin the card on the message being retried, lock the button and label it "Retrying", and drop the announcement only when the replacement message gets its run id -- or when the send never started, which restores the original card and its actions. OPEND-2719 — an exhausted wallet parked the send in the pending-send queue. The queue means "this runs once capacity frees up"; an empty wallet never frees up, so the message sat there forever. Stop queueing that one branch (`signed_out` keeps its queue-and-resume path) and hand the draft back to the composer through the existing `restore-draft` outcome. The report's other half did not reproduce: the dialog and balance card were already re-firing on every blocked send. Note on telemetry: a blocked click no longer counts as a recovery click. It never produced a run to join against, so those were unjoinable orphans; the existing surface_view still records that the action was offered. * fix(chat): claim the composer draft positively instead of by exclusion Review on #7927: the no-queue path for an exhausted wallet used an exclusion predicate — "not a retry and not a queue drain, therefore the composer owns it". ProjectView has eleven other direct `handleSend` callers (share to community, design-system feedback and review decisions, continue remaining tasks, resume run, question-form answers, Home auto-send, resend-failed-user-message, brand extraction and enrichment, the auto-audit repair effect, Antigravity OAuth repair). None of them holds its payload in the composer, and all of them were being swept onto that path — which silently took away their queue. The question-form answer is the worst case: it keeps the only copy of the answer and its uploads, and releases the form only on a durable-queue acknowledgement. Replace it with an explicit `composerOwnedDraft` marker that only `handleComposerSend` sets. Everything else falls through to the queueing path it had before this branch, unchanged. The marker is transport-only and is stripped alongside `acceptDurableQueue` before queue persistence: once a send is parked, the queue item owns the payload, not the composer. |
||
|
|
92bf0b7ad7 |
docs(release): add v0.22.1 changelog (#7932)
Co-authored-by: zhouzheng <zhouzheng@refly.ai> |
||
|
|
8be9b0fcaf |
fix(daemon): deliver artifacts with syntax warning telemetry (#7925)
* fix(daemon): deliver artifacts with syntax warning telemetry * docs: record main-based syntax warning acceptance * fix(daemon): surface internal syntax failures at delivery boundary --------- Co-authored-by: zhouzheng <zhouzheng@refly.ai> |
||
|
|
f95b9a6eef |
fix(daemon): let a failed media generation reach its run terminal (#7933)
A turn whose only deliverable failed reported "Completed" with a green
check. The daemon knew better: it logged `[media] {"event":"failed",
"run_id":…,"status":400}` and shipped the same run id to analytics, then
told the run nothing. Two halves of one invariant:
- routes/media.ts never wrote the failure back. `runId` was already in
`options.grant`; both failure paths (the provider rejection and the
synchronous dispatch throw) marked the task failed, emitted analytics,
logged, and returned. The run's verdict was left to the child's exit
code, and the agent exits 0 after apologising in prose.
- the turn-completion marker vetoed "unfinished" on nothing but "marker
matches the run's key, and some non-empty text follows it". In the
reported run that following text was the S22 apology we ask the model
to copy verbatim — so the apology suppressed the very signal it was
apologising for, over a TodoWrite snapshot that had a `cancelled` item.
`noteMediaTaskFailure` is the dual of `associateLateRunProducedFile`:
one hands the turn the bytes a 202 dispatch produced, the other hands it
the fact that it produced none. The terminal derivation reads that
host-recorded fact as its own term, outside the marker/strategy/todo
clause, so the model's account of its own turn cannot overrule a failure
the host watched happen. The evidence is never the agent's words: the
apology is product copy and may be rewritten at any time.
Scoped to the attempt (`prepareRestart` clears it) and gated on
`succeeded`, so a user-canceled turn and a turn holding no host
counter-evidence keep exactly the verdict they have today.
|
||
|
|
a10e4671bd |
fix(web): open the model picker on the model in effect (#7917)
Both model lists cap their height and scroll — the compact home list at six rows, the searchable popover at 280px — so a catalog longer than the cap opened on its first row with the model actually running below the fold. Users switching models frequently had to hunt for the selection the list had already made (OPEND-2812). The selection itself was never missing: the compact rows carry `aria-checked` and the searchable options carry `aria-selected` / `data-selected`, and the chip shows the same model. Only the scroll position was wrong, so this adds no new state — `anchorSelectionInView` reads the selection back out of the rendered list and scrolls those rows into view once per open, with `block: 'nearest'` so a row already visible does not move. Nothing marked selected means nothing scrolls. The two-level company browse anchors both panes: the company rail now mirrors its `has-selected` state onto `data-selected` so the vendor holding the selection is brought into view alongside its model. |
||
|
|
a0d00198e5 |
feat(whats-new): announce OpenDesign Arena for 0.22.0 (#7905)
* feat(whats-new): announce OpenDesign Arena for 0.22.0 Product copy for the 0.22.0 post-update card. Bumping `id` to 0.22.0 is what makes the card re-open for everyone who has already dismissed 0.21.0. The zh-CN CTA points at /zh/llm-arena-for-design/, not the /zh/llm-arena/llm-arena-for-design/ the copy originally carried — that path 404s, the shorter one is live. Both destinations verified 200 before commit. * feat(whats-new): update Arena numbers to DeepSeek V4.1 Flash Second bullet revised by product: the model is V4.1 Flash, and it now scores 98% of GPT-6 Astra at 1% of the cost (was 85% / 2%). The zh-CN CTA in this revision matches the corrected path already in this branch, confirming the earlier /zh/llm-arena/... was a typo. --------- Co-authored-by: lefarcen <ontf116@gmail.com> |
||
|
|
35fa33b3c7 |
fix(e2e): align packaged Home smoke inputs and evidence (#7918)
* fix(e2e): use daemon-owned request input in Codex plans * fix(e2e): scope Codex smoke receipts to app-server processes --------- Co-authored-by: zhouzheng <zhouzheng@refly.ai> |
||
|
|
d07102a3b9 |
feat(release): post the prerelease card on trigger and time every platform (#7916)
Reverses the "withhold the card until the first artifact publishes" rule. The card is now posted on the watcher's first poll, while every platform is still queued, and every later state edits that same message. The reason the card was withheld — a "构建中…" message left standing forever when a build dies — has since been answered by the machinery that grew around the card: a dead lane renders its own terminal state, a release where nothing shipped turns the card red, the watcher's 140-minute budget stamps the card instead of abandoning it, and a watcher that never delivered draws the fallback bot. Silence for the whole build window bought nothing. `shouldPostFirstCard` conflated two predicates. One half was already `anyPackagePublished`; the other is now `allPlatformsFailed`, and it moves from gating the post to being the card's red verdict — the header's old "some platform reported and none shipped" proxy is only equivalent while the card does not exist before total failure, and with the card posted early it reads 全部平台构建失败 the moment one lane fails, or red at t=0 because `build_linux` is skipped on every run. Each platform row now carries its build time (`用时 11m42s` once finished, `已用 18m` while running) and the footer carries the whole round's clock, both read from the `started_at` / `completed_at` already in the polled job list plus the origin run's `created_at`. Live numbers are quantized to the minute so a 30-second poll does not PATCH the card on every cycle. |
||
|
|
d232620c16 |
fix(e2e): align packaged Home smoke with Codex app-server (#7914)
Co-authored-by: zhouzheng <zhouzheng@refly.ai> |
||
|
|
bb9682a5f9 |
feat(daemon): make OD Next the default route, and keep the switch usable (#7876)
* feat(daemon): make OD Next the default route, and keep an opt-out opted out An installation that configured nothing ran the ordinary strategy route, so shipping OD Next in a build never changed how a run behaved until someone opened Settings and asked for it. That made the Labs switch the whole rollout: the strategy could only reach the users who already knew to look for it. `resolveRequestedMode` now resolves an unconfigured installation to `active`. Every other admission condition already defaulted open — assignment bucket, both content flags, the production approval flag — so this last default was the only thing holding the route closed. The one-line change is the small part. Flipping the default inverts which case is load-bearing. While the strategy was opt-in, an installation that lost its saved mode kept the behaviour it already had; now an installation that loses its saved mode is switched back on. So the invariant this has to keep is that an installation which opted out reads `off` through every later release, and that rests on opting out being stored rather than erased: `off` is a value the config carries, `applyConfigValue` drops the key only for a value it cannot store, and `assertWritableControlValues` refuses those on the way in. That last guard is the same code as before but now protects the opposite direction. It used to stop a typo from masquerading as an opt-out; it now stops a typo from revoking one. Its docblock, the field comments on both sides of the contract, the `od strategy` help text, and the prompt-composition note all described the opt-in world and would have read as the reverse of the truth. Tests carry the semantics: - a new assertion pins the opt-out invariant, and checks not just the value read back but that the decision built from it actually evaluates to `off`; - the route test now separates `default` from a saved `off`, which resolve to opposite routes and no longer to the same one; - the app-config acceptance case against a real server is rewritten end to end. It was "nothing configured takes the ordinary route, and the same running daemon takes OD Next once you opt in". It is now the opt-out direction, and proves it from the agent invocation log rather than from the run record: the first run's prompt is the strategy's, the run after `off` is saved is not. Packaged installs have no second control — the packaged child environment allowlist carries no `OD_NEXT_*` key — so the saved mode is the only thing standing between an opted-out user and this default. e2e is deliberately untouched here and follows separately. Its specs mostly never set this mode, so prototype and deck cases now take the OD Next route. Pinning the env var in the harness is not the fix: the env var outranks the saved config by design, which would break the spec asserting that an app-config write takes effect with `app_config` as its source. * test(daemon): stop the panel-grammar spec failing on the clock `thinking-stream-panel-grammar` asserted that the whole SSE body contains neither `Critic` nor `8.1`, on the stated premise that both "只可能来自标记内部". That premise does not hold for the second one. The body also carries the daemon's own `start` envelope, and `toolTokenExpiresAt` is an ISO timestamp: a run whose token expiry lands on a second ending in `8` with milliseconds starting with `1` produces `…:48.161Z`, which contains `8.1`. Roughly one run in a hundred, and it hit on CI — with a thinking stream the log shows was clean, so the spec failed on the clock rather than on a leak. The two attribute-fragment assertions now run against the concatenated `thinking_delta` / `text_delta` text instead of the raw body. That is not a weaker check: persistence consumes exactly those deltas, so it is what the spec's own "SSE clean ≡ database clean" argument was always about, and the envelope fields it drops were never a place a leak could surface. The tag regex stays on the whole body, where an opening `<PANELIST` cannot appear by accident. The two prose assertions move to the same extracted text, which is what keeps the negative assertions honest: an extractor that selected the wrong event type would return an empty string, and every `not.toContain` would pass against it. * feat(daemon): remove the OD Next stop latch A run that hit certain failures wrote a row to `strategy_rollout_controls`. From the next request on, every task on that installation took the legacy route. The row outranked the saved mode, survived restart, and the Labs switch kept reading "on" while the notice underneath said the harness had been paused — with no control to unpause it. The only way back was an operator running `od strategy rollout reset`. The code already argued against this. The docblock over `rolloutStopSignalForBlockedContinuation` says only a failure of the machine contract itself may raise a latch, because an agent turn that mis-populates a machine block "is already fail-closed for that task ... and it must not silently return every later request in the daemon to the legacy path." One signal had already been retired for doing exactly that: `complex_child_unverified` took OD Next away from Codex, Claude and OpenCode because a fourth runtime lacks a child-lifecycle producer. This generalises those narrowings instead of waiting for the next signal to earn its own. It also stopped being survivable. While OD Next was opt-in, the latch could only reach the few installations that had asked for it; with the default flipped it reaches everyone, and the population exposed to an automatic, restart-surviving, UI-unrecoverable disable is now the whole install base. Deleting it changes nothing about how any single failure is handled. All three live trigger points were additive actions taken *after* the existing handling: `native_resume_failed` already blocked the continuation, sent `AGENT_SESSION_RESUME_FAILED` and finished the run failed; the blocked- continuation cases were already set to `outcome: 'blocked'` with their reason codes persisted for attribution. What goes away is only the side effect on every later request. A fourth trigger — the run-duration threshold — never fired at all: `OD_NEXT_STRATEGY_MAX_RUN_DURATION_MS` has no default anywhere, so the comparison ran against NaN. What is removed: the table and its migration, the write path and its telemetry module, the single read in the run route, the reset endpoint, the `od strategy rollout reset` subcommand, the latched lock in the Labs switch and its 19 locale strings, and five contract types. `readOdNextRolloutControlStatus` stays but no longer takes a database — the effective mode is now a pure function of the requested mode and the content flags, so two callers reading it at the same moment cannot disagree. Status is still worth an endpoint because `requestedModeSource` names the authority, and `default` and a saved `off` resolve to opposite routes. Installations currently holding a latch recover on their own: with nothing left to read the row, their next run is admitted normally. No migration needed, and a downgrade is safe — older code recreates the table, reads it empty, and treats the installation as unlatched. Tests carry the invariant rather than a list. The cases that pinned which signals were allowed to latch are replaced by one that pins the shape: the decision is a function of the policy and this run's facts and takes no argument through which an earlier run could reach it, so there is no signal left to enumerate and none can be added by accident. The real-server case now asserts that status still reports the deciding authority while both the reset endpoint and its CLI subcommand are gone. * fix(daemon): never leave the harness switch stuck, whatever the config says Field report from a packaged build of this branch: the Design Harness switch read on, greyed out, with "detected a problem, generation is using the original approach" underneath, and every task on the machine taking the legacy route. Nothing in the UI could clear it. The diagnostics name the cause exactly. One agent turn ended mid-block — `Unclosed <open-design-plan-contract> block`, 2 characters of visible text — and that raised `machine_contract_leak`, whose stop mode is `off`, not `observe`. A truncated response took the whole install off OD Next; the next daemon start logged `requestedMode: 'active'`, `effectiveMode: 'off'`, `od_next_rollout_stop_latched`, which is the screenshot. That is not a rare condition. A stream ending early is the most ordinary failure an agent has, and flipping the default hands it the entire install base. Three changes, one property: the switch a user can see is a switch a user can use. 1. The stop latch is removed (previously PR #7879, folded in here). A build that defaults on and still ships an automatic, restart-surviving, UI-unrecoverable disable is not shippable as two PRs — the window between them is exactly the build that was reported. Everything the latch did on top of the existing per-task handling is gone; the per-task handling is untouched. 2. An unreadable saved config no longer reads as an unconfigured one. The read path kept two states apart where it needed three: no file at all, a readable choice, and a config that exists but cannot be believed — malformed JSON, a non-object body, a mode this build does not recognise. The third used to collapse into the first harmlessly, because unconfigured meant `off` and so did an opt-out. Now unconfigured means `active`, so the same collapse turns "we cannot read your choice" into "you chose OD Next", against the installations most likely to have chosen the opposite. It resolves to `off` instead — the only answer that cannot override a real decision, and the recoverable one, since a user who never opted out sees the switch off and can turn it back on. `assertWritableControlValues` was never enough here: it guards writes through the API and can say nothing about a file that arrived bad, was hand-edited, or was written by another version. 3. A failed status read is retried from the switch instead of being permanent. It rendered the row locked and lifted only on remount, so a transient failure left a control that does nothing. A click on that lock now re-reads. The environment lock stays inert deliberately: the daemon would ignore the write, and pretending otherwise is worse than doing nothing — and no packaged install can reach it, since the packaged child env allowlist carries no `OD_NEXT_*`. After this, the only greyed state a packaged user can reach is the moment before the first read returns. Installations already holding a latch recover on their own: with nothing left to read the row, their next run is admitted normally. No migration, and a downgrade is safe — older code recreates the table, reads it empty, and treats the installation as unlatched. Coverage follows the same split. The config cases run against real files through `readAppConfig` rather than against the policy reader in isolation, because the hole was in the join: truncated JSON, a non-object body, an unrecognised mode, a typo, a non-string — each reads `off`, while a missing file and an explicit `null` still reach the default. One case asserts the whole chain, saving `off` then truncating the file and checking the mode survives, with a fresh installation as the negative control so the guard cannot swallow the rollout it protects. The UI case fails the first status read, clicks, and asserts the row becomes operable. * refactor(daemon): drop the JSDoc left above the removed latch classifier The block described `MACHINE_CONTRACT_BOUNDARY_CODES` and `rolloutStopSignalForBlockedContinuation`, both deleted with the stop latch. Left in place it reads as documentation for the export below it, implying this service still classifies machine-contract failures when it no longer does. * fix(daemon): tell operators why `od strategy rollout reset` is gone The command shipped in 0.21.0 and 0.21.1. Removing it left the subcommand falling through to the generic usage dump, so an operator who scripted it saw a list it was simply missing from and had to guess whether the command was renamed, moved, or broken. It now names itself: the daemon-instance stop latch it cleared no longer exists, so there is nothing to reset, and the message points at the two commands that replaced its use — `od config set odNextStrategyMode off` to opt out, and `od strategy rollout status` to see which authority decides the mode. It still exits non-zero rather than succeeding as a deprecated no-op. The command meant "recover this machine's rollout". Reporting success for a recovery that neither happened nor can happen is the same lie the latch itself told — a control that says it acted while nothing changed — and this release is removing that lie rather than relocating it into the CLI. * fix(daemon): stop a broken config file from opting an installation out The previous commit fixed a real hole — a saved `off` could be silently revoked — but fixed it too widely and in the wrong direction for this rollout. It treated a config file that failed to parse at all as evidence of an opt-out, which it is not. A file that will not parse resets every preference to its default: agent, telemetry, all of it. Singling this one key out to resolve against its default was inconsistent with the rest of the file, and the population it hits is the wrong one — everyone whose config breaks, not everyone who opted out. Since the second group is a subset of the first, the guard opted more installations out of the rollout than it protected, in a change whose entire purpose is opting them in. The guard now covers only what it can actually see: `odNextStrategyMode` present with a value that is not one of the modes — a hand edit, a typo, a mode written by some other version. Something was configured and cannot be read, so it reads `off` rather than becoming "you chose OD Next". A fresh install, a missing file, an unparseable file and a config without the key all reach the default, which is `active`. That keeps both invariants this step owes: the default is on for anyone who has not said otherwise, and a user who did say otherwise keeps their answer. The switch stays operable in every one of these states, so nothing here can leave a user looking at a control that does nothing. * style(daemon): drop the blank line left at EOF by the classifier removal The line separated `resolveAutomaticContinuationEvidence` from the JSDoc and classifier below it. Removing those left the separator as a trailing blank line, which `git diff --check` reports. No logic change. * docs(daemon): stop enumerating which releases shipped `rollout reset` The comment named 0.21.0 and 0.21.1. 0.22.0 shipped today and carries the command too, so the enumeration was already wrong the day it was written -- any list of shipped versions goes stale by construction. Say "every release since 0.21.0" instead, which stays true without maintenance. The point the sentence is making is unchanged, and slightly stronger: the surface has been out for three releases, so an operator who scripted it gets told what happened rather than shown a help list it is missing from. --------- Co-authored-by: Cheems <216108319+hihicheems@users.noreply.github.com> |
||
|
|
70665d2b66 |
chore(release): bump main to 0.22.1 ahead of stable 0.22.0 (#7903)
Co-authored-by: open-design-release-bot[bot] <open-design-release-bot[bot]@users.noreply.github.com> |
||
|
|
fd85994589 |
fix(web): stop a compositor scroll clamp from silently killing auto-follow (#7898)
* fix(web): stop a compositor scroll clamp from silently killing auto-followThe chat log decides "did the user scroll away" from one criterion: the
position got smaller AND `scrollHeight` did not change. The file's own
comment justifies that with "content-driven displacement always comes with
a `scrollHeight` change" — true for content-driven displacement, false for
a compositor clamp.Chromium keeps its own copy of how far a scroller travels so a wheel does
not have to wait on the main thread. On the chat log that copy can go stale
while layout moves on. From a real user's diagnostic bundle (Electron 41 /
Chromium 146): scrollTop 245.5 layoutMax 718 scrollHeight 1307 unreachablePx 472.5Jump-to-latest writes `scrollTo({top: 1307})`, the position lands at 718.5
(the real layout bottom), and one wheel notch later the compositor throws it
back onto its own stale ceiling of 245.5. Content is static, so
`scrollHeight` never moves — both halves of the criterion hold, and every
clamp reads as one more user scroll-up. Auto-follow goes off without a word.The discriminator is wheel direction. A user scrolling up either carries an
upward wheel — which ChatPane's own `onWheel` already releases follow on,
ahead of the scroll event — or carries no wheel at all (scrollbar drag,
keyboard, touch). So "the only wheel in this window pointed down" is never
true of a user scroll-up; it says the user asked to move TOWARD the bottom.
A position that moves away from the bottom under that input is not their
hand. The same shape was measured independently for the freeze detector:
`scrollTop = 800`, one downward notch, thrown back to 91.The other candidates do not hold up. Landing on a known stale ceiling needs
the ceiling, which only the freeze detector's state machine can infer — and
`chat-scroll-takeover.ts` records that detector's false-positive rate as
unknown. The gap to `layoutMax` carries no information, since a user can
stop anywhere. Adjacency to a JS write is falsified by the bundle itself:
the clamp landed 3.8s after the write with zero JS write records in between,
and a window that wide swallows ordinary scrolls.This does not fix the stale ceiling — that is the takeover module's job and
it stays switched off pending a product call. It only stops the clamp from
also costing the user auto-follow.The criterion is deliberately biased to under-fire: a clamp with no wheel
witness is still treated as a user scroll-up. Welding follow on would be
worse than the bug.
* fix(web): give the wheel witness a lifetime
nettee's review on #7898 found the over-suppression this criterion was
supposed to avoid — a stale witness welding auto-follow ON, which is the
failure mode that is worse than the bug being fixed. The path is complete:
1. the user is already at the bottom and wheels down once — the position
does not move, so no `scroll` event fires and the witness is never
consumed;
2. switching conversations from the history control happens OUTSIDE the
log element, so no pointerdown/keydown reaches it, and the effect that
resets baseline and follow intent did not touch the witness;
3. a later NON-wheel move to earlier content (find-in-page, a focus-driven
scroll) is then compared against that stale downward witness, read as a
clamp, and follow never releases.
The witness now has three independent bounds, each covering a hole the other
two cannot:
· Consumed on use. Any scroll event spends it, including one that moves
nothing — one witness explains one displacement, never the next.
· Scoped to the scroll context. `WheelWitness.atScrollTop` records where
the scroller was; the displacement's origin must be exactly that, so a
baseline moved by anything else voids the note structurally rather than
on a timer. Conversation switch, log node swap and panel unmount clear it
alongside baseline and follow intent.
· Expires after a frame. The only bound that can kill a witness no scroll
event will ever consume. A frame is not a tuned constant: the clamp is
dispatched in the same rendering update as the notch that caused it, and
per the HTML update-the-rendering steps scroll events run BEFORE that
frame's animation callbacks. (Not to be confused with the 3.8s in the
diagnostic bundle — that is the gap between the JS write and the clamp,
not between the wheel and the clamp.)
Recording is skipped entirely when `requestAnimationFrame` is unavailable:
without the expiry bound a witness could outlive its displacement, and no
witness just means today's behavior.
Mutation-checked line by line. Three points are honest defence in depth and
say so in-place: the conversation-switch and unmount clears cannot be turned
red today (a conversation switch always schedules a frame, and `setTab` has
no call site), and the upward-wheel counter is pinned at the predicate level
only, because `onWheel` already releases follow on an upward notch.
|
||
|
|
dd278cea65 |
fix(release): allow publishing whats new from release branches (#7902)
Co-authored-by: zhouzheng <zhouzheng@refly.ai> |
||
|
|
c8f6db60c0 |
perf(od-next): streamline tool work and reinforce protocol output (#7804)
* perf(od-next): streamline tool use and media input prompts * fix(od-next): drop the runtime-state literal from the core tool rule The v2.1.1 core prompt named `open-design-runtime-state` inside the "not a tool name" bullet. In the two post-optimization eval batches the model called that exact name as a tool in 5 of 29 deliveries (1 of 30 before the sentence existed): a negated mention raises the name's salience right where the model is about to emit the runtime-state block. Keep the rule, drop the literal: protocol blocks are structured text output, not tools. Bump core asset to 2.1.2 and the bundled package to 2.0.2; orchestration is untouched and stays 2.0.1. * fix(od-next): carry protocol output guidance into tool optimization * fix(od-next): preserve run workspace scope in dynamic skill reads --------- Co-authored-by: bone3deep1962-collab <bone3deep1962@gmail.com> Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com> |
||
|
|
d2e7c3b5f3 |
fix(observability): 滚动探针上报之后不该把自己关掉 (#7896)
* fix(observability): 滚动探针上报之后不该把自己关掉`Surface.reported` 同时挂着两件事:「这个 surface 的遥测已经发过了」和 「停止一切采样」。第二件是意外的 —— 这个标志被九处 listener / observer 回调读取,于是发出事件的同时也关掉了滚动采样器、滚轮通路、活动轨迹和 shortfall ledger,而 `ScrollFreezeState.reported` 在纯 detector 里做了同样 的事:`observeWheelBatch` 第一行直接 return,baseline 停在上报那一刻, `describeSnapBackRoute` 因此永远读作 disarmed —— 偏偏那条单格路线就是为 陈旧的合成器天花板写的。真机上的代价:点击落到 718.5(布局真底部)之后 3.8 秒回到 245.5,中间零 条 JS 写入,写入拦截全程武装。而同一台机器相隔 5.4 分钟导出的三份诊断包 `stallWheelCount` 都是 83、`steps` 逐字节相同,快照写着 `blocked_by=surface_unreported` / `snapBack.armed: false`。一次标准复现 在结构上不可能被抓到。现在去重只发生在 sink:`freezeTelemetryAlreadySent()` 一个地方读这个标志, docblock 写明规则。detector、ledger、活动环、写入拦截、snap-back 路线在 已上报的 surface 上照常运行,阈值一个没动,每个 surface 仍然只发一次 `client_chat_scroll_frozen`。内存不变:所有收集器本来就是定长环(activity / ledger.steps / ledger.probes / transitions / write trace),总数另用计数器记,没有随时间或转录长度增长的 结构。 * fix(observability): 归因闸门要无条件跑,不能被去重挡在后面 评审说得对。上一版把遥测去重放在 inner-scroller 闸门之上,理由是省一次 祖先链遍历 —— 那是性能理由,挡不住数据正确性问题。 后果:已上报的 surface 上,用户在代码块 / 工具输出里正常滚动,那些滚轮 根本没在要求聊天记录移动,却会一路推高 stallWheelCount。因为把这一格再 拿出来的唯一动作就是那个分支里的 streak retraction,而闸门把它变成不可达。 于是一次普通的嵌套滚动,会在后续快照里长得像「聊天区连续 N 格纹丝不动」 —— 正是这把尺子要量的那个信号。尺子被自己污染,比尺子慢一点严重得多。 上一版新写的测试还把那个假的 +6 连击钉成了期望,等于在保护缺陷。 inner-scroller 闸门是归因,不是上报:这一格是打给谁的滚动条,跟事件发没 发过无关,所以它现在无条件先跑,retraction 也无条件执行。 去重仍然只有一处 —— report() 自带的 latch,每个 surface 仍然只发一次 client_chat_scroll_frozen。撤掉 report() 那行 latch 会让 16 条测试变红, 其中包括仓库里本来就有的「still refuses to report the same surface twice」。 innerScrollerSuppressions 的语义单独处理,不再靠闸门顺序保护:它的含义是 「看见了冻结却没上报」,所以计数条件收在「这个 surface 还能上报」上, retraction 本身不受影响。规则写进字段自己的 docblock。 测试改成断言相反的事:被吸收的滚轮不把 streak 留在阈值上(每次触到就被 retract),并补一条未上报 surface 的对照,证明这个闸门的行为跟事件发没发 过无关。 已知遗留、本 PR 不动:recordCeilingProbe 在 observeWheelBatch 之前无条件 记录,被内层滚动区吸收的滚轮同样会写进 shortfall ledger。这在 main 上、 上报之前就是这样,不是这条分支引入的;要修得把归因闸门提到每一批滚轮之前 跑,那是另一个尺度的改动,单独开。 * fix(observability): 被内层滚动区吃掉的滚轮不该进 shortfall ledger 上一条只挡住了 streak,没挡住 ledger。自动评审把这半边也点出来了,是对的, 而且这半边更要紧:ledger.first 永不淘汰。 一格打给代码块的滚轮,在算术上跟真的冻结一轮完全一样 —— 聊天区确实没动, 也确实还有余量,只不过用户根本没在滚它。于是一次嵌套滚动就能永久占住 shortfall_first_*,也就是整份报告最主要被读的那个字段。streak 还能靠 retraction 拿回来,first 拿不回来,所以只能在写进去之前就拦掉。 做法:归因(countAbsorbingScrollers)提到每个带滚轮的帧的开头问一次,一个 答案同时供 ledger 和冻结判定用。原来「太贵所以只在上报时问」的理由经不起 读代码:那是从滚轮目标到聊天区的祖先链,量级是嵌套深度不是转录长度,滚轮 打在聊天区自己身上时直接返回,absorbsWheelInDirection 先用几何量筛掉再考虑 getComputedStyle,而且此处布局刚被上一句 scrollHeight 读干净。冻结路径上原 本那次调用现在被复用,不是新增。 注意这条也改变了未上报 surface 的 ledger 内容(少了被吸收滚轮那些轮次), 所以 shortfall_first_* 的取值会变 —— 变准,但确实变了。事件名、属性、类型、 每 surface 一次的基数都没动。 测试补两条断言:被吸收的滚轮在已上报 surface 上不增加 probeCount 也不改 probes;未上报的对照 surface probeCount 为 0、first 为 null。 * fix(observability): 撤回裁决不该连带抹掉「已上报」这条记录 这条是我自己引进来的,而且正好是我写 docblock 要消除的那个模式:我把 ScrollFreezeState.reported 重新定义成「永久记录:detector 已经判过一次 冻结」,然后把下面那行无条件写 false 的代码留在原地。 之前它不会出事,是因为上报之后滚轮通路整个是关的,这个分支根本到不了。 这个 PR 把通路打开了,分支就变成上报后可达:observeWheelBatch 返回 reported: true 的状态,紧接着被撤回逻辑写成 false,于是快照上出现 surface.reported: true 配 detector.reported: false —— 恰好说给同时看这 两个字段的那个人听,而且跟我刚写下的契约自相矛盾。 改成从 active.reported 取:上报前撤回仍然回到 false(裁决被丢弃,没有事件 发出,这个 surface 之后还要能上报真正的冻结),上报后保持 true(事件确实 发出去了,这就是那条记录)。撤回的是裁决,不是历史。 测试两侧都钉:已上报 surface 撤回后 detector.reported 仍为 true、 surface_unreported 仍判 false;未上报对照 surface 撤回后为 false、 surface_unreported 仍判 true。 |
||
|
|
5f7baf9c9f |
fix(web): keep a failed question-form turn in the transcript (#7900)
Answering an inline question form opens a new turn on screen before POST /api/runs has returned a runId. When that create never yields a run, ProjectView's stream onError deletes the optimistic assistant row by design (no agent process ever existed) and keeps only the user row, stamped sendFailed, with its always-on Retry. It also clears the global error banner. buildChatRenderItems then hid that row too: it skips every user message starting with `[form answers`, because a delivered answer is already summarized on the assistant message above it (#5496). That premise does not hold for an answer that never reached a turn, so the failed round lost its only evidence and its only way back — the round the user watched start simply vanished, with no error, no card and no retry, while the form stayed locked as answered. Hide a form-answer row only when it did not fail to send, and strip the `[form answers - <id>]` routing header from the bubble so the visible row shows the user's own answers instead of the machine payload. Resend still replays the untouched content. Red spec: apps/web/tests/components/ChatPane.question-form-send-failed.test.tsx |
||
|
|
25475defab |
fix(e2e): stop the packaged mac first-run gate failing on budget alone (#7899)
The `[P0] cold first Home run` packaged mac case has been red on almost
every beta build for eleven days (1 pass in 12 attempts) and nobody saw
it, because release-beta's smoke step is `continue-on-error: true`.
Four changes, in the order they matter:
1. Capture evidence before cleanup. A failing case uninstalled the app
and the next case deleted the runtime namespace, so the only surviving
trace was `logs: {skipped: true}`. The failure path now writes the
desktop log, a raw `/api/runs` diagnostics snapshot, and the error into
the release report before anything is torn down.
2. Give the wait a budget that matches the chain, split in two. 15s had to
cover cold project creation through rendered output; it is now 60s for
"the daemon owns a finished run" plus 30s for "the output surfaced", so
a timeout says which half broke instead of leaving three observations
that cannot falsify each other. Polling moved inside the page: every
sample used to cost a `tools-pack mac inspect` process, which ate about
a third of the old budget on its own.
3. Drop the injected 503. `POST /api/runs` does not answer
WORKSPACE_AUTHORITY_UNAVAILABLE — local run creation is deliberately
independent of Workspace membership availability — so the case spent
an extra create round trip plus a 500ms backoff on a branch production
cannot reach. Run-create retry semantics stay covered by
apps/web/tests/providers/sse.test.ts.
4. Make the exempted red audible. The exemption stays (at a 1/12 pass rate
a blocking gate would stall the daily beta), but `tools-release
write-report` now turns a failed smoke into a run annotation and a
caution banner at the top of the step summary.
|
||
|
|
057aa62ce1 | docs(i18n): refresh French core documentation (#7754) | ||
|
|
9bb516218d |
fix(chat): keep Retry alive when no host can take the Cloud handoff〔Switch to Cloud〕 does nothing on its own — it hands the failed turn to the (#7897)
host: `onSwitchToAmrAndRetry`, falling back to `onOpenAmrSettings`. Two of the three ChatPane hosts wire neither. `workspace/SideChatTab` wires only `onRetry`; `DesignSystemFlow` wires nothing. There the button's onClick walks both branches and nothing happens.An inert button is not the whole cost. Everything downstream of `showCloudSwitchCta` assumes the primary slot has been taken: `errorActionVariant` demotes the ladder's working Retry to secondary, and `contactSupportIsPrimary` stops promoting. A rung-4 card — the ones with no recovery action at all — then carries zero primaries and one dead button, so after a failed run the only prominent button on screen is fake.Same invariant and same shape as `balanceCardCannotTakeTheHandoff` and `reconnectRowCannotTakeTheHandoff`: a handoff only holds while the target is actually present. This opens no exception on OPEND-2772's "cover every failure" — whether the CTA applies is still `runFailureUi.cloudSwitchCta`; the new predicate only answers whether this host can catch it.`ChatPane.error-card-ladder.test.tsx`'s fixture wired no AMR handler, so its 〔Switch to Cloud〕 assertions were modelling SideChatTab while describing ProjectView. It now wires the handler it was always talking about; all 11 expectations are unchanged.Alongside it, OPEND-2807's verbatim label change for `chat.runError.contactSupportCta` (联系支持 -> 联系我们) across all 19 locales. The key already existed; only the value moves. The design mirror's `data-tip="联系支持"` predates the ticket, so the ticket wins — recorded in `w124-chat-tooltips-and-panel-head.test.tsx`.Split out of #7882. That PR additionally collapses the card to exactly three buttons and drops 〔Continue〕 and antigravity terminal auth; it touches the functional-regression line and stays there awaiting a product ruling. Nothing from it is carried here — the card's button count and kinds are identical to main, only the side-chat inertness and one label change.Red specs: apps/web/tests/components/chat/cloud-cta-handoff-presence.test.tsx apps/web/tests/i18n/opend-2807-contact-us-cta.test.ts |
||
|
|
93b09911d7 |
docs: fix dead Regional terminology anchor in TRANSLATIONS.md (#7792)
The heading '## 🌍 Regional Terminology' slugifies with a leading
hyphen (emoji stripped, space kept as separator), so the plain
#regional-terminology anchor matched nothing.
|
||
|
|
3ddff71205 |
feat(ci): publish the What's New card from the repository (#7892)
* feat(ci): publish the What's New card from the repository Move the post-update highlight document into the repo and publish it from GitHub Actions, so changing the card is a pull request instead of a task that needs personal Cloudflare credentials. - docs/whats-new.json is the source of truth, next to its spec doc. - pnpm guard validates it through the daemon's own parser on every PR, so a document that would silently show no card cannot merge. - whats-new-publish.yml uploads on merge to main (and on manual dispatch, with a read-only dry-run mode) and verifies the object by reading it back from the origin the daemon fetches, past the edge cache. * docs: name the CI job that actually runs the guard check * fix(ci): gate the What's New publish behind a main-only environment A workflow_dispatch runs the workflow file from the ref it is dispatched against, so the in-workflow main-only assertion was editable by whoever triggered it: anyone with write access could publish unreviewed content to every installed client from their own branch. Split the workflow so credentials live in one place and that place is gated by GitHub rather than by YAML. `validate` holds no secrets and runs on any ref; `publish` declares the `whats-new-publish` environment, whose deployment-branch policy allows main only and which owns the R2 secrets. Dropping the environment declaration to escape the branch policy also drops the secrets. Also points the card at the new Arena URLs supplied by product. * fix(ci): pin the What's New publish trust boundary in a check that runs The missing-credentials error still told operators the R2 keys are repository secrets. Following that string is the exact setup the previous revision closed: repository secrets are readable from any job on any branch, so a modified workflow dispatched from an unreviewed ref could publish to every installed client — or just take the keys. Point the error at the `whats-new-publish` environment and refuse the repository path in the same breath. The boundary itself had no coverage: nothing read the workflow, so moving `environment:` or a `secrets.*` expression back onto the arbitrary-ref job would stay green while still publishing correctly from main. That coverage cannot be lane-routed. Running the planner over `.github/workflows/whats-new-publish.yml` selects neither `web_tests_required` nor `ui_p0_validation_required`, so the `e2e_vitest` workload does not run for a workflow-only edit — an assertion living only there would be skipped for exactly the change class it guards. The check therefore lives in `pnpm guard`, which CI invokes from the unconditional preflight job, same reasoning as the document guard next to it. It asserts that the credential-bearing job declares the environment by its exact name, needs `validate` and is gated on its output with no `always()`/`cancelled()` escape, reads all four bucket bindings, and that no other job attaches an environment or reads any secret at all. The e2e suite drives it over the real workflow mutated into each of those regressions in turn, so every assertion is proven able to fail; gutting the checker turns all eleven red. * chore(whats-new): seed the document from the live card Bringing the workflow in with new copy attached ties the infrastructure to a content decision. The Arena CTA this document pointed at is not live — `/llm-arena-for-design/` and `/zh/llm-arena/llm-arena-for-design/` both 404 — and the card renders regardless, so merging would have put a dead button on every client's Home. Seed the repository copy from the object that is already serving instead: byte-identical to `https://whatsnew.open-design.ai/whats-new.json`, id `0.21.0`. That puts the current state under version control and makes the first automatic publish idempotent — it uploads exactly what is already there, so the merge exercises the whole chain (validate, upload, read-back against the origin the daemon fetches) without changing anything a user sees. The Arena copy is not lost; it moves to the PR body as a follow-up for the release owner, who can now change the card through this workflow without holding personal Cloudflare credentials. * fix(ci): detect secrets by context, not by access shape The guard added last round matched `${{ secrets.NAME }}` with a regex over the raw workflow text. A guard whose whole job is to stop a secret reaching the arbitrary-ref job had a blind spot for most of the ways to read one. Verified against the previous implementation, each returning zero violations while a secret reached `validate`: `secrets['NAME']`, `secrets["NAME"]`, `secrets [ 'NAME' ]`, `secrets . NAME`, `toJSON(secrets)`, a bare `secrets`, and `fromJSON(toJSON(secrets)).NAME`. Extending the regex would have repeated the mistake that caused it. One bypass proves why: a double-quoted YAML scalar may carry a backslash line continuation, so STOLEN: "${{ sec\ rets.NAME }}" parses to a live secret read while the source text contains no such substring. No text scanner can see that, however many shapes it lists. So the workflow is read through a YAML parser, and detection is a single property instead of an enumeration: GitHub's expression language has no string-to-context evaluation, so a secret cannot be reached without naming the `secrets` context as an identifier. Finding that identifier inside any `${{ … }}` expression covers every access form, including ones nobody has thought of. Parsing also retires the hand-rolled job splitter and its own failure modes, and lets the check distinguish "no violations" from "nothing was examined" — an unparseable document, a flow-style jobs mapping, or a renamed publish job now fail closed rather than passing quietly. Two holes the shape list never covered are closed as a side effect: a job-level `secrets:` key passes credentials to a called workflow with no expression at all, and workflow-level `env:` is inherited by every job. `yaml@2.9.0` becomes a root devDependency. It was already in the lockfile via `tools/release` and pinned repository-wide by the existing pnpm override, so the lockfile gains only the root importer entry. Test coverage moves with it: 26 of 46 cases now go red when the checker is gutted, up from 11. --------- Co-authored-by: lefarcen <ontf116@gmail.com> |
||
|
|
fc959c7a5a |
fix(web): give an invalid API key its own error card (#7893)
* fix(web): give an invalid API key its own error card
A BYOK run that fails because the user's API key is wrong renders the
generic fallback card: 「任务执行失败」 + 「这次没能顺利完成……把日志发给我们」
+ 〔联系支持〕. The daemon had already classified it exactly right
(category `auth`, detail `invalid_api_key`, user_action `login`), but
`invalid_api_key` appeared zero times in `apps/web/src`, so the run fell
through every mapping table to the last-resort card. The result is that
a wrong API key sends the user to customer support, with no entry point
to the screen that holds the key.
Add the S05 row (`DETAIL_FAILURE_UI`), copy verbatim from the product
error-copy doc, and reuse the existing 〔去设置〕 action
(`onOpenSettings('execution')`) — the same destination the send-time
BYOK preflight gate already uses.
* fix(web): scope the invalid-key Settings action to BYOK agents
`invalid_api_key` is read off the flattened stderr of EVERY agent
(`authDetail`, apps/daemon/src/run-failure-classification.ts), so the new
S05 row was also claiming local CLI runs. A `claude` that prints
`Invalid API key · Please run /login` arrives as
`AGENT_AUTH_REQUIRED` + `invalid_api_key`, and the row — which resolves
before the code branches — replaced its S02 「{agent} 尚未登录」 +
terminal-login guidance with a button that opens a screen where that key
cannot be changed. `opencode` / `kimi` / `qwen` have no key field in the
`execution` section at all.
Scope the card to the tier whose key we actually hold: the BYOK / API
provider agents. `byokApiKeyIsEditableInSettings`
(apps/web/src/utils/byokProvider.ts) is the same line the send-time BYOK
gate draws — `ProjectView.requiresByokPreflight` is
`mode === 'api' || agentId === 'byok-opencode'`, and a `mode === 'api'`
run is stamped onto its message as one of the eight `*-api` ids from
`API_PROTOCOL_AGENT_IDS`. That table moves to `byokProvider.ts` (which
has runtime-free imports) so `runtime/amr-guidance.ts` can read it
without pulling the provider stack in; `utils/apiProtocol.ts` imports it
back.
Everything outside that tier keeps the path it had before this branch:
`AGENT_AUTH_REQUIRED` / `UNAUTHORIZED` fall through to the existing S02
card, Antigravity keeps its terminal-auth branch (it resolves earlier),
AMR keeps S04. No new copy and no new i18n key — S02 is already in use.
Red check: removing the `byokApiKeyIsEditableInSettings` guard turns the
two native-CLI cases red with the bug itself —
`expected 'chat.runError.title.apiKeyInvalid Claude' to contain
'chat.runError.title.signInRequired.other'`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQSowXMsTxbEoZyGYfxgyb
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
9560772510 |
fix(daemon): keep the failure verdict alive from the watchdog to the card (#7884)
* fix(daemon): name the ACP stage-timeout verdict instead of writing prose
The ACP stage watchdog is the daemon's own verdict — nothing upstream
reports anything, the daemon decides a stage is over and kills the child.
It shipped that verdict as a bare `{ message: 'ACP <stage> timed out after
<n>ms' }`, so the only thing that could recover "this run timed out"
downstream was a regex over that English sentence (`isTimeoutText`). Any
path that rewrites, wraps, localizes or drops an ACP error message re-filed
a watchdog kill as `process_exit / exit_code` — `retryable: false`,
`user_action: 'none'` — which is the generic card with no Retry, for a
failure whose entire remedy is a retry.
The watchdog now stamps `retryable: true` and `details.kind = 'timeout'`
(the same discriminator `acp_child_exit` / `acp_no_visible_output` /
`amr_model` already carry), and the classifier reads that marker rather
than re-deriving the verdict from the sentence. The sentence itself is
unchanged: it is what the details drawer shows and what telemetry reads.
Deliberately narrow — only the watchdog writes the marker, and only the
timeout branch reads it. Agent-supplied JSON-RPC data lands under
`error.data`, never `error.details`, so an agent cannot forge the verdict;
`empty_output` still resolves first, so a run that stalled AND produced
nothing still says it produced nothing.
`acp-stall-last-progress-age` re-anchors its non-vacuity guard on the
child's own exit status (143 = its SIGTERM handler ran) instead of on the
derived `AGENT_EXIT_143` code, which never described this failure.
* fix(daemon): recognise opencode's compaction-continuation stream EOF
vela 0.0.35 (#1847) moved the compaction continuation onto its own request
and worded its EOF differently: `opencode compaction continuation ended
before prompt completion`. That phrase matched nothing in the repository,
so the same bridge-level stream EOF the resume-failure table already
handles — one phase later — could not reach the re-seed path at all and
ended the conversation on a hard failure.
Added to `AMR_OPENCODE_EVENT_STREAM_RESUME_FAILURE_PATTERNS` in the same
shape as its siblings (un-anchored, case-insensitive), so a compaction that
dies mid-continuation clears the stale handle and re-runs the turn with a
fresh session — one slightly slower cold turn instead of a dead end.
The gate stays narrow: `resolveAgentResumeFailurePolicy` only fires when a
stored handle was actually being continued (`isResuming` + a resume session
id), and `run.resumeAutoReseeded` allows the recovery once. A create turn
carrying the same phrase is untouched.
* fix(daemon): keep an upstream that says "retryable" retryable
Real corpus (2026-09-08, runs 423140d1 and e9bea966): opencode reported a
closed upstream socket AFTER exhausting its own three attempts and said so
in machine-readable form — `"isRetryable": true`. The daemon recorded
`upstream_unavailable / stream_disconnected` with `retryable: false`, the
opposite of what upstream stated.
Where the word was dropped: opencode's field is `isRetryable`, and vela
hands the whole `session.error` envelope over as a JSON string inside the
ACP error message rather than as `error.data`. `rpcErrorRetryable` read
only `data.retryable`; the classifier's `latestRetryable` reads
`error.data.isRetryable` and never saw it either. With no verdict from
either reader, `fail()` fell back to its own `retryable: false` default,
and that fabricated verdict became the run's.
The cost is not the error card (`stream_disconnected` still draws a Retry)
but the daemon's own recovery: `isResumableFailure` returns false the
moment `retryable` is false, so `run.resumable` never gets set and the
Continue affordance disappears, and `decidePostToolResumeRecovery` requires
`retryable === true`, so the silent post-tool continuation never fires.
Both readers now accept the `isRetryable` spelling. Only `true` is read: an
explicit upstream "no" is already handled by the branches that can disprove
it (a 4xx re-fails identically and routes to `upstream_client_error`), and
letting a coarse SDK flag force `false` is drift the classifier refuses by
name. `request_too_large` still wins over it.
Verified against the frozen ACP landing table: no row moves. Removing
`fail()`'s `?? false` default instead would have moved 17 of them,
including turning a 429 rate limit into a retry — left alone deliberately.
* fix(daemon): read a structural isRetryable only when it says true
`rpcErrorRetryable` accepted the vendor `isRetryable` field in both
directions, so an adapter that forwards opencode's `error.data` object
structurally (rather than embedded in the message string, which is the
shape the corpus happened to carry) could hand the bridge a `false`.
`session.ts` composes the two readers with `??`, so that `false` did not
merely record an upstream "no": it short-circuited
`inferRpcErrorRetryable`, which for the same payload in string form
returns `undefined` and lets a transport blip be read as retryable.
`fail()` then stamped `retryable: false`, the classifier adopted it as
`retryableHint`, and `isResumableFailure` withdrew Continue — the exact
failure this branch exists to remove, reachable through the other shape.
Read only `true`, matching the rule `inferRpcErrorRetryable` already
applies to the string form. ACP's own `retryable` keeps both values: it
is a deliberate protocol-level statement, not a status-code-derived SDK
flag riding along inside a vendor payload.
Red spec covers the structural shape the previous negative test did not:
parity between the two shapes on the real 404 `error.data`, and a
structural `isRetryable:false` on the row-B1 stream-idle-timeout blip
that must not overrule the classifier. Both go red with this change
backed out. The frozen ACP landing table is untouched — 47 rows, 17
moved-code rows, 196 assertions, zero lines changed.
* fix(daemon): namespace the ACP watchdog verdict so agent data cannot claim it
`error.details` is not a daemon-private slot. `fail()` copies an agent's
JSON-RPC `error.data` into it verbatim (`fail(rpcErr, { details })`), so an
adapter sending `{"error":{"data":{"kind":"timeout"}}}` reached
`hasDaemonTimeoutVerdict` as a stage-watchdog kill: `failure_category:
'timeout'`, `terminal_trigger: 'acp_stage_timeout'`, and precedence over
the forced-signal guard — with the watchdog never having fired.
Rename the marker to `acp_stage_timeout` and match that exact value, so no
upstream payload collides with it by accident. Both docblocks now describe
the shared slot accurately, including the residual: this is collision
resistance, not authentication. An adapter that deliberately sent
`kind: 'acp_stage_timeout'` would still be believed, and closing that needs
a daemon-only field agent payload is never copied into — noted at `fail()`,
not done here.
The previous anti-forgery test asserted this against `error.data`, a shape
the session bridge never emits, so it passed without exercising the copy.
Replaced with one that drives the real bridge: the agent answers
`session/prompt` with a JSON-RPC error carrying `error.data`, and the frame
the bridge actually emits is what gets classified. It also pins the copy
itself, so a future change that stops forwarding agent data is visible.
Added the matching positive control through the same real bridge.
Backing both source lines out turns 4 tests red, in both directions.
Frozen ACP landing table untouched: 47 rows, 17-entry moved list, 196/196,
zero lines changed.
|
||
|
|
78f389a051 |
fix(web): restore the product's run-error copy across 19 locales (#7887)
* fix(web): use the product doc's polished copy on run error cards
Six run-error cards were showing copy engineering had written, not the
copy product signed off. The authoritative source is the Feishu doc
"运行报错场景文案", whose每格 carries three things: 原文时机, a draft
「原文提示」 (with buttons and slots), and the table's 润色标题 / 润色正文.
Only the last two are final — S30's card had been transcribed from the
draft column.
Replaced verbatim, across all 19 locales:
S07 AMR_MODEL_UNAVAILABLE 当前模型不可用
S12 timeout / inactivity 等待回复超时
S13 model_not_supported … 当前模型不支持此任务 (new card)
S22 AGENT_RUNTIME_DEF_INVALID Open Design 运行异常
S23 ARTIFACT_NOT_FOUND 暂无可预览的文件 (body was null)
S30 the five environment causes 当前地区暂不支持此服务
Two structural fixes the copy forced:
- S13 is two rows in the doc ("模型不存在" and "模型能力不支持"), and S07
is a third. One title key served all three. Split out
`title.modelCapabilityUnsupported` + its message so the three sentences
stop overwriting each other.
- S23's message key was null, so the card fell through to the generic
「这次没能顺利完成」 — a run that ENDED NORMALLY was being described as a
failure, with nothing said about what to do. The doc has a sentence for
it; it is now wired.
Not changed, and why:
- S08 供应商额度用完 and S13 模型不存在 need {供应商} / {模型名}; S16 needs
{套餐名}; S07 并发上限 needs {N}. None of those values reach the error
card — the failure event carries only code / failureDetail / the raw
upstream string. Rather than putting a literal `{模型名}` in front of a
user, those cells wait for a data path.
- `title.quotaExhausted` still serves both S08 and S15; splitting it is
moot while both consumers are blocked (S08 on {供应商}, S15 on a routing
ruling).
Red spec: apps/web/tests/components/ChatPane.run-error-copy-from-spec.test.tsx
asserts the rendered card text (title line + chat-run-error-description),
not keys or classes. 27 assertions red before this change, green after,
and red again when the implementation is reverted.
* fix(web): stop diagnosing local failures as a regional block
S30's polished copy («当前地区暂不支持此服务» / «暂不支持当前网络所在地区,
请尝试切换网络后再试。») was wired to all five client-environment details.
None of them is a regional block, so the card told users their region was
unsupported and to switch networks after a local SQLite/WAL write failed.
Evidence — `clientEnvironmentFailureDetail`
(`apps/daemon/src/run-failure-classification.ts:299-306`) is the only producer
of these five, and each matches something local:
host_policy_block Windows Application Control / AppLocker
local_storage_failure SQLite/WAL I/O, readonly, locked, corrupt
certificate_failure certificate / CERT_ / self-signed / unable to verify
proxy_configuration unsupported proxy protocol / proxy configuration
network_configuration ENOTFOUND / ECONNREFUSED / EHOSTUNREACH / getaddrinfo
The clincher: the daemon does detect a regional block, and it is not one of
these. `Country, region, or territory not supported` matches
`isUpstreamClientErrorText` and lands on `upstream_client_error` — a detail this
card never reaches (pinned by `run-failure-classification.test.ts:1375`).
The product doc's S30 table has exactly one polished row, and its 「场景内的情况」
is 「地区不支持」. No polished cell exists for certificate, proxy, host policy or
local storage, so there is nothing to copy verbatim for those. Rather than
invent wording, all five keep their previous copy and carry 待拍板 notes naming
the missing cell.
Also removes the five S30 rows from the verbatim-copy matrix (they were never
doc cells) and adds the invariant: no client-environment detail may render a
regional diagnosis, and each still names its own cause.
Refs PR #7887 review thread PRRT_kwDOSOgY8s6gJ452.
|
||
|
|
819c10d49f |
fix(chat): make the failure card, the queued action and the fork note stop disappearing (#7871)
* fix(web): ellipsize the fork divider note instead of hard-cutting it The fork divider footnote lost its ellipsis when #7863 merged the two-block layout into one line. `.fork-note` became `display: flex` so the branch icon and the label could sit side by side, but the three truncation declarations (`overflow: hidden`, `text-overflow: ellipsis`, `white-space: nowrap`) stayed on `.fork-sep span` — the very same element. Bare text inside a flex container lives in an anonymous flex item, and `text-overflow` is not inherited, so the anonymous box falls back to `clip`: the 62% cap still clipped, just without the three dots. Move the label into its own `.fork-note-label` span and hang the truncation on that layer, with `min-width: 0` to release the flex item's default `min-width: auto`. `.fork-sep span` becomes `.fork-sep > span` so the inner label is not pinned to `flex: none` — a non-shrinkable flex item is always exactly as wide as its content and can never overflow. Also finishes OPEND-2714's copy: the 16 locales other than en / zh-CN / zh-TW still carried translations of the retired English sentence. They now say what `Continued from chat` says, each within 1.5x the English length so they stay inside the 62% cap. * test(web): witness the fork note ellipsis in a browser, not in chat.css apps/web/src/components/chat/AGENTS.md §5 forbids asserting CSS class names or declarations in chat tests, and the spec added in the previous commit did exactly that: it located the label by className, parsed chat.css, and pinned the resolved values of text-overflow, overflow, white-space, min-width and flex. Delete it. Replace it with a narrow browser witness. e2e/ui/fork-note-ellipsis.test.ts forks a conversation through the real daemon endpoint, renders it in the longest shipped locale (German), squeezes the chat panel until the label overflows, then checks two observable things: the label really is overflowing its box, and its paint differs from the same element forced to `text-overflow: clip`. Comparing the element against itself keeps the oracle baseline-free, and a build that still hard-cuts paints identically under both, so the check cannot pass vacuously. Asserting the declarations was the wrong instrument anyway: the broken build declared `text-overflow: ellipsis` too. What was wrong was the box it sat on, and only a laid-out browser can see that. The one structural fact worth keeping - the label is carried by its own element rather than as bare text beside the icon - moves into the existing fork-divider spec and keys off a data-testid instead of a class name. Enrolls the new file in the project-workspace UI P0 group. An unenrolled P1 would run only in the prerelease full pool, which means shipping a witness that nothing on the merge path ever executes. * test(e2e): wait for the fork divider before squeezing the chat panel The width squeeze is a raw custom-property write onto the split, which React's own width effect would overwrite if a late fetch re-rendered between the write and the measurement. Wait for the divider to paint at the default width first, then squeeze with nothing awaited in between. Also corrects a stale doc comment that named a helper this file never had. * fix(e2e): squeeze the chat panel without racing its width transition CI caught the guard doing exactly what it was written to catch: it failed with "content 161px vs box 161px", meaning the note never overflowed and the ellipsis check would have been measuring nothing. The cause was the squeeze, not the copy. `--project-chat-panel-width` is a registered `@property` with a 200ms transition, so writing it tweens from the default 460px and the measurement taken right after read the panel mid-flight. Suspend the transition the way the product's own resize handle does (`.is-resizing-chat`), and do the write and the read inside a single evaluate so no re-render can land between them. Also measures the label before and after the squeeze, so the next failure names its own cause: a panel that never narrowed is a fixture problem, a narrowed panel that still fits the text is a copy problem. The previous message could not tell those apart. * chore(web): drop the now-unused splitList export from the cascade helper It was exported only to feed the CSS-parsing spec that the previous commit deleted. Nothing outside the helper calls it any more, so put it back to a module-private function and leave the shared test infrastructure exactly as it was on main. * fix(web): collapse the queued row's leading action into one 「引导对话」 button The queued-send row's leading action had two faces: a 74px labelled 「引导对话」 while `canSteerCurrentTurn` held, and an unlabelled 22px "send now" icon otherwise. Both were wired to the same `sendQueuedChatSendNow` — the gate only changed the name, the tooltip and the analytics `element` value. Product ruled on 2026-09-08: 「引导对话就是原本的立即发送啊,只不过我们 换了个名字跟 codex 客户端对齐了下」. So the gate goes, the fallback face goes, and the row keeps one button that is always 「引导对话」. The delivered spec agrees. `729fa43ce7:docs/design/chat-panel-next.html` component 17 "Queue" draws exactly one action there — `aria-label="引导对话" data-tip="引导对话"` with `<svg/><span>引导对话</span>` — and never the icon-only send-now key. `data-tip` is the plain name, so `title` / `data-tooltip` / `aria-label` collapse from `chat.queuedSteerInterrupts` back to `chat.queuedSteer`; visible text and accessible name are now identical (WCAG 2.5.3 Label in Name). Order is untouched: steer → edit → delete (OPEND-2715). Analytics: this surface no longer emits `ui_click` with `element: 'send_now'`. The surviving button reports `'steer'`, matching the name it now carries. The `send_now` member stays in the contract union because PostHog still holds the historical events. Props: `onSteerQueuedSend` existed only to carry the gate, so it is removed from ChatPane, SideChatTab and both ProjectView call sites, along with `canSteerCurrentTurn`. `onSendQueuedNow` survives as the single handler — it has the second pass-through face in `SideChatTab`'s `controlledChat`, and it names what the handler actually does. `steerBlockedReason` is left alone: it had no producer before this change either, and retiring a deliberately parked placeholder is its own call. * fix(prompts): stop asking the model for a per-question help string OPEND-2707. The clarification card stopped rendering the per-question subtitle (`q.help`) in #7863, but the prompt layer kept asking the model to write one — so the model spent tokens on copy nobody would ever see, and believed it had already told the user something it had not. The user ruled on 2026-09-08 to go all the way and retire the prompt half too. Three prompt surfaces carried it: - `core-slim.ts` routed leftover context into "the individual question labels/help"; context now has one destination, the question label. - The UI locale override (daemon + contracts mirror) listed "helper text" among the question-form controls that must be localized — naming the field without naming it. The other four entries in that list all still render. - The ElevenLabs voice picker is the only form the repo itself authors, and it shipped the only `help` string in the tree. Its content is real information (the visible labels are voice descriptions; the submitted value is the voice_id), so it moves into the question label rather than being dropped. `FormQuestion` has no per-question `description`, and `placeholder` is already taken by "Choose a voice". That form is `JSON.stringify`d whole into the system prompt, so it doubles as the model's worked example of what a question may look like — leaving a `help` key there would have quietly undone the rest of the change. `e2e/tests/question-form-help-retired.test.ts` guards all seven authorized prompt paths, mirroring `question-form-visual-style-retired.test.ts`. It reads source bytes, so it also asserts the parser keeps tolerating `help` — the dormant field must not be removed along with the prompt guidance. Snapshot: only `totalChars` moves, on the three scenarios that compose the touched text; no section is added or removed. * docs(web): record FormQuestion.help as dormant, not dead OPEND-2707 follow-up. With the renderer, the CSS rule, the prompt guidance and the last host-authored producer all gone, `help` now has no producer and no consumer anywhere in the tree. It still stays. Removing `help?: string` would break `QuestionForm.no-question-subtitle.test.tsx` and `QuestionForm.test.tsx` — and the first of those is the only positive evidence that the subtitle is not rendered. Deleting a zero-cost field at the price of the test that guards the invariant is a bad trade. Cached prompts, older clients and the model's own memory of the old format can also still send a question carrying the key, and the parser should keep tolerating it rather than change parse shape as a side effect of this retirement. Same reasoning as the six `qf.visual*` i18n keys in `specs/current/chat-panel-decisions-sheet.md`. The field now documents when and why it went dormant, and how to bring the rendering half back (the pre-#7863 revision of both files). Also corrects `DesignDirection.mood` in both prompt mirrors: its docblock said the text is "shown to the user as `help`", which was already inaccurate — it is the `mood` blurb on a `direction-cards` option, and that renderer has had no call site since the visual-style question was retired. * fix(web): keep the disconnect failure card when no reconnect row is there A turn that failed with `DAEMON_STREAM_DISCONNECTED` left nothing at all on screen once the user stepped out of the project and came back: no failure card, no reconnect line, no way to retry. `anotherSurfaceOwnsFailure` in `ChatPane` handed the story to the reconnect line unconditionally, on the premise (stated in the comment right above it) that the reconnect line "is always there". It is not: `ProjectView` has a dedicated unmount effect that clears `reconnectView` on project change / screen exit, so on re-entry the card yields to a surface that has already left. The neighbouring balance rung already encodes the right invariant — `balanceCardCannotTakeTheHandoff` refuses the handoff when the upgrade card cannot be drawn. The same invariant was only written on one of the two rungs. Both rungs now name their handoff target first, then ask one shared question: is that surface actually present. Red spec: `ChatPane.error-card-ladder.test.tsx` — a disconnected run with no reconnect row must render the card, its own `chat.connectionDropped` copy, and a retry action. Reverse anchor (row present -> no card) is green on both sides of the fix. The two older cases in that file asserted "no card" while rendering *without* a reconnect row: they pinned this defect rather than the invariant. Both now put the handoff target on screen, so they pin what they were written for — that either signal (structured code, or a legacy detail-only row) is recognized as the disconnect failure. * fix(web): shorten the Cloud switch CTA to match the delivery spec The error card's primary button read 「切换到 OpenDesign Cloud 并重试」 while the delivery spec (docs/design/chat-panel-next.html, cell 79 "特殊错误 · 运行环境为 CLI / BYOK") writes 「切换到 Cloud」. Product ruled in person on 2026-09-08: "切换到 cloud 就行了,你怎么写那么 长的文案『切换到 Cloud 并重试』" and "具体的报错文案不一定跟设计稿对齐, 按钮文案对齐先". So only `chat.amrCard.switchCta` is aligned here — the card's title and body keep their own product copy and are deliberately left untouched, including the separate `chat.amrCard.switchBody` key. All 19 locales are shortened to the same "Switch to Cloud" register with the trailing "and retry" clause dropped. Comments in ChatPane.tsx and amr-guidance.ts that quoted the old label are updated so they stop describing copy that no longer exists; the reasoning around them is unchanged. Playwright assertions in e2e/ui that pinned the old label follow the new string. * docs(specs): record the three rulings taken on 2026-09-08 * fix(web): keep the failure card when a failed turn has no words for it A turn that ends in a terminal failure loses its entire recovery surface whenever the card cannot compute a description. The whole card — title, message, and all four actions (contact support, export logs, retry, switch to Cloud) — hangs off one `displayError` ternary, so a null description takes the explanation and every way out with it. What is left on screen is the execution shell's bare "run failed" line, written on the assumption that the card below it says why. The three description sources all go empty together more often than the shape suggests: the mapping table's own last-resort row carries no `messageKey`, the pane-level `error` state is reset when ProjectView unmounts on navigation, and the run's raw detail never reached the message for the `AGENT_UNAVAILABLE` / `BYOK_PROVIDER_REQUIRED` / `AGENT_RUNTIME_DEF_INVALID` family (daemon `fail()` emits without going through `persistRunEventToAssistantMessage`) or for the `runStatus:'failed'` rows ProjectView writes itself when self-healing a phantom running turn. `resolveRunErrorCardDescription` now takes `turnEndedInTerminalFailure` and falls back to `RUN_FAILURE_FALLBACK_MESSAGE_KEY` instead of rendering nothing. It is the last branch, so every handoff and every more precise source still wins ahead of it. The signal is `retryableAssistantMessage`, which already gates the recovery entries and already treats a delivery failure (`no_result` / `delivery_failed`) as terminal, minus the empty API response — that one has a surface of its own ("No output") and must not get a second one. Also stops an empty pane-error string from shadowing the run's own detail. That branch carried a comment saying the silence was a separate question; this is the same question, answered the same way. |
||
|
|
00fb311455 |
test(e2e): harden onboarding status mock against reload race (#7699)
* test(e2e): harden onboarding status mock against reload race wireOnboardingMocks wrote window.__amrOnboardingStatusCalls via page.evaluate inside the vela/status route handler. When the reload tests intentionally reloaded the page, an in-flight status poll could evaluate against a destroyed execution context and fail the test before its reload assertions ran. Wrap the instrumentation writes in a narrow safeEvaluate helper that only swallows navigation-destroyed errors and re-throws all other failures, preserving strict reload/cancel assertions. * test(e2e): harden onboarding status mock against reload race wireOnboardingMocks wrote window.__amrOnboardingStatusCalls via page.evaluate inside the vela/status route handler. When the reload tests intentionally reloaded the page, an in-flight status poll could evaluate against a destroyed execution context and fail the test before its reload assertions ran. Wrap the instrumentation writes in a narrow safeEvaluate helper that only swallows navigation-destroyed errors and re-throws all other failures, preserving strict reload/cancel assertions. |
||
|
|
faba302437 |
feat(release): alert through the other Feishu bot when the prerelease card goes silent (#7873)
* feat(release): alert through the other Feishu bot when the prerelease card goes silent The progressive card is now the only prerelease notification, and every way it breaks is silent: the pipeline stays green, the packages ship, and the channel hears nothing. The watcher catches Feishu failures on purpose — one hiccup must not end a two-hour watch — so expired application-bot credentials look exactly like a healthy run. Adds a last-resort notice delivered through the custom-bot webhook, which is a different bot with a different secret, so a credential failure on the card side cannot take the alert down with it. It fires only when the card did not reach the group: the watcher now reports `card_delivered` for a card whose FINAL state is in the chat, and the gate reads that rather than the job result. Two callers, mutually exclusive so a release can never draw both: release-prerelease-card.yml's `fallback_notice` job covers a watcher that died or never delivered; steps at the end of release-prerelease.yml's `dispatch_validation` cover a card that was never dispatched. The latter are steps, not a job, because nothing may appear in a `needs:` on the dispatchers. * fix(release): keep the fallback notice reachable past the dispatch it reports The two steps that actually deliver the notice carried no status function, so GitHub's implicit success() skipped them whenever anything earlier in the job had failed. `dispatch-validation.sh` exits non-zero when it cannot dispatch the card on any ref — which marks the job failed — so on the one path the dispatch-side fallback exists for, it was silent. This PR was written to close the implicit-success() trap at the job level and walked into its step-level twin. Both consumers now carry `always() && steps.notice.outcome == 'success'`: the status function so a failed dispatch still reaches them, and the outcome check so neither a crashed nor a skipped composer can hand feishu-notice.ts an empty body. The composer writes `alert` last, after every field a poster reads, so `alert=true` in an append-only $GITHUB_OUTPUT means the whole notice is on disk. Adds the step-level reachability rule as a spec beside the existing job-level one, and puts the dispatched prerelease lanes into that job-level sweep — release-prerelease-card.yml was outside it, so `fallback_notice`, whose `if` hand-checks `needs.card.result`, was uncovered by the rule it has to obey. |
||
|
|
5a7bbe5b10 |
chore(release): stop blocking stable releases on missing release notes (#7886)
A stable release with no docs/CHANGELOG/v<version>/ directory hard-failed at the very first job. Fixing it mid-release is not "write two markdown files": the stable promotion gate pins github.commit to the promoted prerelease, so the changelog commit invalidates that artifact and forces a fresh ~27-minute prerelease round trip. Missing notes are now a loud warning instead of a failure. reviewReleaseNotePlanPolicy returns warnings for a stable release with no notes, or one missing zh-CN, and prepare-release-note surfaces them as a ::warning:: annotation plus a job summary entry. Notes that exist but cannot be consumed still throw: a channel mismatch, or a plan that omits the default locale every consumer falls back to. publish-metadata carried a second stable gate on the same condition. Removing only the first would have moved the hard failure past the GitHub draft release and asset upload, which is strictly worse, so both are gone. The rest of the chain already treated "absent" as a complete outcome — it is the beta and prerelease path — so publication, verification, and metadata assembly are unchanged. Non-stable channels behave exactly as before. |
||
|
|
6b60b567ac |
fix(mcp): accept workflow id when confirming briefs (#7779)
* fix(mcp): accept workflow id when confirming briefs * fix(mcp): reject mismatched brief workflow ids * fix(mcp): reject attributed context on ordinary briefs --------- Co-authored-by: taljeon <169621860+taljeon@users.noreply.github.com> |
||
|
|
92f27d1fc1 |
fix(daemon): finalize deliverable syntax without agent turns (#7801)
* fix(daemon): add bounded deliverable syntax repair loop * test(daemon): enable OD Next for branch evals * fix(daemon): keep syntax gate narrowly scoped * feat(daemon): report deliverable syntax timing * fix(daemon): remove evaluation rollout override * feat(daemon): measure syntax repair windows * fix(daemon): finalize deliverable syntax without agent turns * fix(daemon): validate committed syntax recovery and quote repairs * docs: clarify syntax gate applicability without touched paths * fix(telemetry): reject partial syntax summaries without version |
||
|
|
cce680d292 |
docs: clarify Linux desktop availability in README (#7698)
README advertised a Linux AppImage on GitHub Releases while v0.16.1 and later stable releases only publish macOS/Windows artifacts. Clarify that Linux users can run from source today and that a prebuilt Linux release is pending, with links to the source-run instructions and tracker #4368. Fixes #6190 |
||
|
|
c4da48a74b |
test(desktop): compare PPTX captures portably instead of per-platform (#7845)
`comparePng` in the layered-background suite reduced every BGRA comparison to a mean and one strict global maximum. The maximum is the whole gate for six of the assertions, and it is not portable: on Debian the same commit, lockfile and Electron produce 255/245/211/205/201/18 against bounds of 224/160/160/160/160/16, while Ubuntu 24.04 — the runner ci.yml uses for `workspace_unit` — stays under every one of them. A contributor on any other Linux sees six red tests on a clean checkout with no way to tell them from a real regression. The divergence is not antialiasing noise: hundreds of fully opaque pixels carry the content in the reference capture and plain white in the isolated export, or the reverse. Isolating an element re-renders it, so a clip, mask, filter or text-clip boundary lands on a neighbouring pixel, and the capture rectangle's own rounding lands on the outer frame — on one probe 608 of the 876 divergent pixels were exactly the bottom row of a 608-wide image. Extract the comparison into `compareBgraBitmaps` in deck-capture.ts, next to the other pure capture helpers, and mirror it into the probe source the way `captureUntilPainted` and `bgraBitmapHasPaint` already are. The mean stays a strict whole-bitmap mean, so a broad fidelity loss still has nowhere to hide. The maximum matches each pixel against the best candidate in the 3x3 neighbourhood of the exported bitmap and skips the one-pixel frame, which is what makes it measure fidelity rather than the host graphics stack. No bound is raised. Measured worst case per assertion, Debian / Ubuntu: 111/0 against 224, 8/0 against 16, 123/0 against 160, 72/76 against 160, 73/51 against 160, 75/0 against 160 — at least 23% headroom everywhere, and the two platforms trade places on one probe rather than one being systematically looser. Seven unit tests cover the helper directly: identical bitmaps, a one-pixel shift absorbed, a three-pixel shift still reported, a colour change and dropped content still reported at full magnitude, an outer-frame-only difference invisible to the maximum but visible in the mean, and bitmaps too small to have an interior compared strictly. |
||
|
|
21fffb8600 | docs(deploy): document Vela CLI setup for Linux Docker (#6590) | ||
|
|
066f9cd937 | fix(i18n): refresh French setup and recovery copy (#7753) | ||
|
|
11943a400f |
fix(amr): follow vela's test Cloud domain cutover (#7880)
vela moved the test Cloud entry off `vela.powerformer.net` onto `open-design.powerformer.net/cloud` (vela #1922 prepare, #1929 finalize). The new host serves the test Landing page at `/` and hands `/cloud*` and `/amr*` to a test-only path proxy; per vela's `specs/current/envs.md` the legacy hostname is no longer a mapped test route and is scheduled for decommissioning, so it must not be relied on for a compatibility redirect. Both surfaces that name the test console were still on the old host, so a `OPEN_DESIGN_AMR_PROFILE=test` build sent every console link (dashboard, plans, workspace settings, top-up) at a host on its way out. Every consumer of the daemon's console origin concatenates a path onto it (`${origin}/dashboard`, `${origin}/settings`) rather than resolving against it as a URL base, so carrying the `/cloud` segment in the origin is safe and mirrors the existing prod entry `https://open-design.ai/cloud`. Left unchanged, deliberately: - prod stays on `/amr/dashboard`. It is already the dashboard, not a wallet page, and prod `/amr*` still answers with a 302 to `/cloud/dashboard` that preserves path and query. Moving prod off the compat prefix touches a much wider surface and is not part of this cutover. - feature-test still has no row in either table. Its hostname is internal and this repository ships publicly, so it arrives through OD_VELA_WEB_URLS / OD_VELA_WEB_URL at packaging time and otherwise falls back to the public console instead of guessing. The daemon's public origin table had no test coverage at all; the added cases pin the shipped test/prod/local values and the feature-test absence. |