From 20bff591023daffc5ee1032cff02b54b75da3567 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sat, 25 Jul 2026 13:13:41 -0700 Subject: [PATCH 001/128] fix(desktop): track concurrent agent turns up to the harness maximum (#2882) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop's active-turn store capped tracked concurrent turns per agent at `4` while the harness runs `DEFAULT_AGENT_PARALLELISM = 24` parallel agent subprocesses and accepts up to `32` (`--agents` / `BUZZ_ACP_AGENTS`, `value_parser range(1..=32)`). Turns above the cap were silently evicted, so a genuinely-running turn lost its working badge in the sidebar and the agents popover. The eviction also caused the badge set to rotate indefinitely. Evicted turns are still alive, so their hosts keep emitting `turn_liveness` every 10s; `recordActivity` can't find the evicted turn, `resurrectTurn` recreates it, and that eviction drops one of the surviving turns. With two live turns above the cap the visible set churned every 10 seconds. `MAX_TURNS_PER_AGENT` exists only to bound map growth, so it now sits at the harness's hard upper bound of `32` — unreachable for any legitimately-configured agent while still keeping the per-agent map bounded. `MAX_TERMINAL_TOMBSTONES` derives from it (`* 4`), so the tombstone cap moves from 16 to 128. Two regressions cover the reported symptom: a default-parallelism agent working in 24 channels keeps all 24 badges, and the tracked channel set stays stable as `turn_liveness` arrives for turns that previously would have been evicted. Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- .../agents/activeAgentTurnsStore.test.mjs | 100 ++++++++++++++---- .../features/agents/activeAgentTurnsStore.ts | 8 +- 2 files changed, 88 insertions(+), 20 deletions(-) diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index f182b7b5f7..48f735d178 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -154,25 +154,88 @@ describe("activeAgentTurnsStore", () => { }); describe("eviction at MAX_TURNS_PER_AGENT", () => { - it("evicts oldest turn when exceeding 4 concurrent turns", () => { + // Mirrors the store's private cap: the harness's hard upper bound on + // parallel agent subprocesses (`--agents` accepts 1..=32). + const CAP = 32; + /** Desktop's default harness parallelism (DEFAULT_AGENT_PARALLELISM). */ + const DEFAULT_PARALLELISM = 24; + const EPOCH = Date.parse("2024-01-01T00:00:00Z"); + const at = (ms) => new Date(EPOCH + ms).toISOString(); + + /** One turn_started per channel, minute apart so start order is unambiguous. */ + function startTurns(count, firstSeq = 1) { const events = []; - for (let i = 1; i <= 5; i++) { + for (let i = 1; i <= count; i++) { events.push( makeEvent({ - seq: i, + seq: firstSeq + i - 1, turnId: `t${i}`, channelId: `c${i}`, - timestamp: `2024-01-01T00:0${i}:00Z`, + timestamp: at(i * 60_000), }), ); } - syncAgentTurnsFromEvents(AGENT, events); + return events; + } + + it("evicts oldest turn when exceeding the cap", () => { + syncAgentTurnsFromEvents(AGENT, startTurns(CAP + 1)); const channels = channelIdsOf(getActiveTurnsForAgent(AGENT)); - // Should have evicted c1 (oldest) to make room for c5 - assert.equal(channels.size, 4); + // c1 (oldest) evicted to make room for the 33rd turn. + assert.equal(channels.size, CAP); assert.ok(!channels.has("c1"), "oldest turn should be evicted"); assert.ok(channels.has("c2")); - assert.ok(channels.has("c5")); + assert.ok(channels.has(`c${CAP + 1}`)); + }); + + it("tracks every turn of a default-parallelism agent working in 24 channels", () => { + // DEFAULT_AGENT_PARALLELISM is 24, so a single agent legitimately runs 24 + // concurrent turns. Every one must keep its working badge. + syncAgentTurnsFromEvents(AGENT, startTurns(DEFAULT_PARALLELISM)); + const channels = channelIdsOf(getActiveTurnsForAgent(AGENT)); + assert.equal( + channels.size, + DEFAULT_PARALLELISM, + "all 24 concurrently-worked channels must be tracked", + ); + for (let i = 1; i <= DEFAULT_PARALLELISM; i++) { + assert.ok(channels.has(`c${i}`), `c${i} must be tracked`); + } + }); + + it("keeps the tracked channel set stable as liveness arrives for every turn", () => { + // The flicker: with the cap below real parallelism, turns above it are + // evicted while still alive, and their 10s turn_liveness frames land on + // resurrectTurn — which evicts one of the survivors to make room. The set + // then rotates forever. Under a cap at the harness maximum, liveness for + // any live turn is a plain refresh and the set never moves. + const TURNS = 6; + syncAgentTurnsFromEvents(AGENT, startTurns(TURNS)); + const expected = channelIdsOf(getActiveTurnsForAgent(AGENT)); + + // Liveness for the two earliest-started turns — the first to be evicted + // under the old cap, hence the first to trigger a resurrection swap. + for (const [i, turnId] of ["t1", "t2"].entries()) { + syncAgentTurnsFromEvents(AGENT, [ + makeEvent({ + seq: TURNS + i + 1, + kind: "turn_liveness", + turnId, + channelId: turnId.replace("t", "c"), + timestamp: at((TURNS + i + 1) * 60_000), + }), + ]); + assert.deepEqual( + [...channelIdsOf(getActiveTurnsForAgent(AGENT))].sort(), + [...expected].sort(), + `liveness for ${turnId} must not change the tracked channel set`, + ); + } + assert.equal( + expected.size, + TURNS, + "all six live turns must be tracked, not a rotating subset", + ); }); }); @@ -1343,16 +1406,17 @@ describe("activeAgentTurnsStore", () => { }); it("evicts the oldest tombstone once past the cap so the map stays bounded", () => { - // The tombstone map is capped at MAX_TERMINAL_TOMBSTONES (16). Complete - // 18 distinct turns so eviction fires twice, dropping the two oldest by - // insertion order (t0, t1). Probe via the ONE behavior a tombstone gates - // that a strictly-newer frame cannot mask: an EQUAL-timestamp liveness - // (frameAt == terminalAt). All completions share timestamp T with rising - // seq, so the probe clears the per-agent watermark on the seq tiebreak - // (compareObserverEvents is timestamp-primary, seq-secondary) yet stays - // equal to the recorded terminal — reaching resurrectTurn's tombstone - // check rather than being shadowed by the watermark. - const CAP = 16; + // The tombstone map is capped at MAX_TERMINAL_TOMBSTONES (MAX_TURNS_PER_AGENT + // * 4 = 128). Complete 130 distinct turns so eviction fires twice, dropping + // the two oldest by insertion order (t0, t1). Probe via the ONE behavior a + // tombstone gates that a strictly-newer frame cannot mask: an + // EQUAL-timestamp liveness (frameAt == terminalAt). All completions share + // timestamp T with rising seq, so the probe clears the per-agent watermark + // on the seq tiebreak (compareObserverEvents is timestamp-primary, + // seq-secondary) yet stays equal to the recorded terminal — reaching + // resurrectTurn's tombstone check rather than being shadowed by the + // watermark. + const CAP = 128; const TOTAL = CAP + 2; const T = at(0); const completions = []; diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index b281929503..3af41ae078 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -23,8 +23,12 @@ const REMOVE_AFTER_MS = LIVENESS_INTERVAL_MS * 2.5; const FRAME_GAP_PAUSE_MS = LIVENESS_INTERVAL_MS * 2; /** A silent agent is treated as dead after this bounded prune pause. */ const PRUNE_PAUSE_MAX_MS = 3 * 60_000; -/** Maximum concurrent active turns tracked per agent (matches pool size). */ -const MAX_TURNS_PER_AGENT = 4; +/** Maximum concurrent active turns tracked per agent. Purely an unbounded-growth + * guard, so it sits at the harness's hard upper bound for parallel agent + * subprocesses (`--agents` / `BUZZ_ACP_AGENTS` accepts `1..=32`) rather than the + * Desktop default of 24 — any lower value silently evicts a live turn, dropping + * its working badge. */ +const MAX_TURNS_PER_AGENT = 32; /** Cap on per-agent terminal tombstones (A's resurrection guard). Only the * most recently completed turns can be raced by a late liveness frame; older * ones are already below the watermark, so a small multiple of the live cap is From c7089d3b52a6758596cb516f7b3e65989428d26b Mon Sep 17 00:00:00 2001 From: Mager Date: Sat, 25 Jul 2026 17:22:18 -0500 Subject: [PATCH 002/128] docs(buzz-acp): correct agent key generation instructions (#2875) --- crates/buzz-acp/README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index d9cd362cb8..d863729d54 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -25,13 +25,22 @@ export PATH="$PWD/target/release:$PATH" ## Generating Keys -Each agent needs a Nostr keypair — this is the agent's identity in Buzz. Use `buzz-admin` to mint one: +Each agent needs a Nostr keypair — this is the agent's identity in Buzz. Use `buzz-admin` to generate one: ```bash -cargo run -p buzz-admin -- mint-token --name "my-agent" --scopes "messages:read,messages:write,channels:read" +cargo run -p buzz-admin -- generate-key ``` -This prints an `nsec1...` private key and an API token. **Save both immediately — they're shown only once.** +This prints a public and secret key pair as hex. **Save the secret key immediately — it is not stored and cannot be recovered.** Set `BUZZ_PRIVATE_KEY` to the secret key to act as this identity. + +Then register the agent's public key as a relay member so it can read and publish: + +```bash +BUZZ_RELAY_PRIVATE_KEY= \ + cargo run -p buzz-admin -- add-member --pubkey +``` + +`add-member` publishes a kind:13534 membership event, so the relay needs a stable signing key: set `BUZZ_RELAY_PRIVATE_KEY` in the relay's environment (uncomment it in `.env`) and restart the relay before running this. > **Running multiple agents?** Mint a separate keypair for each. Every agent needs its own identity. From 25e7864b35f4dfd1c0ff31304a38555230a85f8d Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 25 Jul 2026 16:49:56 -0700 Subject: [PATCH 003/128] fix(desktop): consolidate prepend scroll correction (#2855) ## Summary - make Virtua the sole prepend geometry/correction owner by accumulating active prepend ResizeObserver corrections from the live DOM offset - retire prepend reconciliation on ordinary reader wheel input, while preserving it for Ctrl+wheel browser zoom - remove Buzz's competing three-second semantic-anchor watcher and corrective `scrollBy` loop - keep ESM/CJS Virtua patch behavior equivalent and update the patch lock hash ## Why Buzz admitted prepended rows using seeded estimates, then Virtua received multiple measurement corrections for the same transaction. Each correction was based on the same stale model offset, so a later write replaced an earlier correction instead of accumulating it. In the reproduced first page, that resurrected 452px of anchor drift; the app-level watcher merely corrected the lost virtualizer write afterward. This fixes the correction inside Virtua and deletes the competing app writer, following the single-owner geometry invariant used by Berd rather than copying its spacer implementation. ## Validation - watcher-off desktop virtualization matrix: 11/11 passed, including 15 cascading prepends, continued wheel input, detached rich-row growth, channel switching, bottom follow, and buffered live arrivals - focused cascading prepend/Ctrl+wheel regression passed - desktop typecheck passed - desktop unit suite passed: 3,495 tests - Biome passed on changed desktop files - `git diff --check` clean ## Manual behavior Load older history repeatedly while scrolling upward, then wheel downward during/after a prepend. The visible anchor should stay within the existing 5px contract during reconciliation, and deliberate reader movement should not be pulled back. Ctrl+wheel during the prepend commit must not cancel reconciliation. --------- Signed-off-by: Wes Co-authored-by: Carl Co-authored-by: Carl --- .../features/messages/ui/MessageTimeline.tsx | 1 + .../messages/ui/TimelineMessageList.tsx | 164 ++-------- .../ui/useAnchoredScroll.lifecycle.test.mjs | 115 +++++++ .../features/messages/ui/useAnchoredScroll.ts | 12 + .../ui/useSettleGatedPrependMessages.ts | 14 +- .../messages/ui/useUpwardPaginationWheel.ts | 3 + .../ui/useVirtualizedBottomSettle.test.mjs | 280 ++++++++++++++++++ .../messages/ui/useVirtualizedBottomSettle.ts | 140 ++++++--- .../messages/ui/virtuaWheelModePatch.test.mjs | 27 ++ desktop/tests/e2e/scroll-history.spec.ts | 76 ++++- desktop/tests/e2e/smoke.spec.ts | 3 + desktop/tests/e2e/timeline-no-shift.spec.ts | 1 + desktop/tests/e2e/virtualization.spec.ts | 39 ++- patches/virtua@0.49.3.patch | 77 ++++- pnpm-lock.yaml | 6 +- 15 files changed, 744 insertions(+), 214 deletions(-) create mode 100644 desktop/src/features/messages/ui/useVirtualizedBottomSettle.test.mjs create mode 100644 desktop/src/features/messages/ui/virtuaWheelModePatch.test.mjs diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index b66c97619e..73526da5e8 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -335,6 +335,7 @@ const MessageTimelineBase = React.forwardRef< scrollContainerRef: activeScrollContainerRef, splitPanelOpen: splitThreadPanelOpen, targetMessageId, + virtualCancelBottomIntent: timelineVirtualizerApi?.cancelBottomIntent, virtualScrollToMessage: timelineVirtualizerApi?.scrollToMessage, virtualScrollToBottom: timelineVirtualizerApi?.scrollToBottom, virtualSettleAtBottom: timelineVirtualizerApi?.settleAtBottom, diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 5d3cf8fd5d..3c35cb6d43 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -42,6 +42,7 @@ import { useUpwardPaginationWheel } from "./useUpwardPaginationWheel"; import { useVirtualizedBottomSettle } from "./useVirtualizedBottomSettle"; export type TimelineVirtualizerApi = { + cancelBottomIntent: () => void; scrollToBottom: (behavior?: ScrollBehavior) => void; settleAtBottom: () => void; scrollToMessage: ( @@ -454,145 +455,39 @@ function VirtualizedTimelineRows({ const keys = React.useMemo(() => items.map(virtualizedItemKey), [items]); itemsLengthRef.current = items.length; const previousKeysRef = React.useRef([]); - const prependAnchorRef = React.useRef<{ - itemKey: string; - top: number; - } | null>(null); - const prependWatcherFrameRef = React.useRef(null); const [prependShiftEpoch, clearPrependShift] = React.useReducer( (version: number) => version + 1, 0, ); - // Virtua's `shift` is a one-render instruction, not a persistent mode. If it - // stays true after a prepend, later measurement changes can keep anchoring - // from the end and leave a stale blank range until the next scroll event. + const { cancel: cancelBottomSettle, settle: settleAtBottom } = + useVirtualizedBottomSettle(hostRef, listRef, itemsLengthRef); + const { arm: armUpwardMomentum } = useUpwardPaginationWheel( + hostRef, + cancelBottomSettle, + ); + + React.useEffect( + () => () => { + cancelBottomSettle(); + }, + [cancelBottomSettle], + ); + const isPrepend = React.useMemo(() => { void prependShiftEpoch; return didPrependVirtualizedTimeline(previousKeysRef.current, keys); }, [keys, prependShiftEpoch]); - const retirePrependAnchor = React.useCallback(() => { - if (prependWatcherFrameRef.current !== null) { - cancelAnimationFrame(prependWatcherFrameRef.current); - } - prependWatcherFrameRef.current = null; - prependAnchorRef.current = null; - }, []); - const { cancel: cancelBottomSettle, settle: settleAtBottom } = - useVirtualizedBottomSettle(hostRef, listRef, itemsLengthRef); - const retireTimelineSettle = React.useCallback(() => { - retirePrependAnchor(); - cancelBottomSettle(); - }, [cancelBottomSettle, retirePrependAnchor]); - const { arm: armUpwardMomentum, clear: clearUpwardMomentum } = - useUpwardPaginationWheel(hostRef, retireTimelineSettle); - - const capturePrependAnchor = React.useCallback(() => { - // Keep the pending capture current while the fetch is in flight. Once the - // prepend commits and the watcher starts, its baseline is frozen. - if (prependWatcherFrameRef.current !== null) return; - const scroller = hostRef.current?.firstElementChild; - if (!(scroller instanceof HTMLDivElement)) return; - const scrollerTop = scroller.getBoundingClientRect().top; - const row = Array.from( - scroller.querySelectorAll("[data-timeline-item-key]"), - ).find( - (candidate) => candidate.getBoundingClientRect().top >= scrollerTop - 1, - ); - const itemKey = row?.dataset.timelineItemKey; - if (!row || !itemKey) return; - prependAnchorRef.current = { - itemKey, - top: row.getBoundingClientRect().top - scrollerTop, - }; - }, []); - - React.useLayoutEffect(() => { - if (!isPrepend || !prependAnchorRef.current) return; - // Virtua's shift mode correctly absorbs prepended measurements, but - // estimated offsets can misclassify late row growth deep in history. Keep - // the semantic row identity as a short-lived, deviation-gated backstop. - // Do not correct in this commit: Virtua has shifted its estimate but has not - // applied its ResizeObserver batch yet, so that delta is transient and its - // subsequent absolute correction would overwrite our relative write. - // This watcher deliberately survives a temporary row unmount and waits for - // stable geometry so Virtua remains the primary scroll owner. - if (prependWatcherFrameRef.current !== null) { - cancelAnimationFrame(prependWatcherFrameRef.current); - } - const anchor = prependAnchorRef.current; - const deadline = performance.now() + 3_000; - let previousScrollTop: number | null = null; - let settledFrames = 0; - - const watch = () => { - const scroller = hostRef.current?.firstElementChild; - if (!(scroller instanceof HTMLDivElement)) { - prependWatcherFrameRef.current = null; - prependAnchorRef.current = null; - return; - } - const atBottom = - scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <= - 32; - const row = Array.from( - scroller.querySelectorAll("[data-timeline-item-key]"), - ).find( - (candidate) => candidate.dataset.timelineItemKey === anchor.itemKey, - ); - const top = row - ? row.getBoundingClientRect().top - scroller.getBoundingClientRect().top - : null; - const scrollTop = scroller.scrollTop; - settledFrames = - previousScrollTop !== null && - Math.abs(scrollTop - previousScrollTop) < 0.5 - ? settledFrames + 1 - : 0; - previousScrollTop = scrollTop; - - if (row && top !== null && settledFrames >= 2) { - const delta = top - anchor.top; - if (Math.abs(delta) > 4) { - scroller.scrollBy({ top: delta }); - settledFrames = 0; - previousScrollTop = null; - } - } - - const retired = - performance.now() >= deadline || - atBottom || - (top !== null && top > scroller.clientHeight * 2); - if (retired) { - retirePrependAnchor(); - return; - } - prependWatcherFrameRef.current = requestAnimationFrame(watch); - }; - prependWatcherFrameRef.current = requestAnimationFrame(watch); - clearUpwardMomentum(); - }, [clearUpwardMomentum, isPrepend, retirePrependAnchor]); - - React.useEffect( - () => () => { - retirePrependAnchor(); - cancelBottomSettle(); - }, - [cancelBottomSettle, retirePrependAnchor], - ); - React.useLayoutEffect(() => { previousKeysRef.current = keys; if (isPrepend) { - cancelBottomSettle(); clearPrependShift(); } if (!hasInitialPositionedRef.current && items.length > 0) { hasInitialPositionedRef.current = true; settleAtBottom(); } - }, [cancelBottomSettle, isPrepend, items.length, keys, settleAtBottom]); + }, [isPrepend, items.length, keys, settleAtBottom]); const messageItemIndexById = React.useMemo(() => { const byId = new Map(); @@ -622,16 +517,13 @@ function VirtualizedTimelineRows({ React.useLayoutEffect(() => { if (!onVirtualizerApiChange) return; const api: TimelineVirtualizerApi = { + cancelBottomIntent: cancelBottomSettle, scrollToBottom() { - retireTimelineSettle(); - const lastIndex = itemsLengthRef.current - 1; - if (lastIndex >= 0) { - listRef.current?.scrollToIndex(lastIndex, { align: "end" }); - } + settleAtBottom(); }, settleAtBottom, scrollToMessage(messageId) { - retireTimelineSettle(); + cancelBottomSettle(); const index = messageItemIndexByIdRef.current.get(messageId); if (index === undefined) return false; listRef.current?.scrollToIndex(index, { align: "center" }); @@ -640,7 +532,7 @@ function VirtualizedTimelineRows({ }; onVirtualizerApiChange(api); return () => onVirtualizerApiChange(null); - }, [onVirtualizerApiChange, retireTimelineSettle, settleAtBottom]); + }, [cancelBottomSettle, onVirtualizerApiChange, settleAtBottom]); React.useLayoutEffect(() => { const host = hostRef.current; @@ -670,15 +562,13 @@ function VirtualizedTimelineRows({ if (!list || !(scroller instanceof HTMLDivElement)) return; onVirtualizerRangeChanged?.(); const distanceFromBottom = list.scrollSize - list.viewportSize - offset; - if (distanceFromBottom > 32) cancelBottomSettle(); + // Do not infer reader intent from an intermediate virtualizer offset. + // Initial channel positioning deliberately chases the floor while rows + // are measured; those measurements can briefly report a large gap and + // emit `onScroll` without any user input. Cancelling here strands the + // channel above its newest message. The settle hook's wheel, pointer, + // touch, and key listeners are the authoritative user-interaction gate. onAtBottomStateChange?.(distanceFromBottom <= 32); - if ( - prependAnchorRef.current !== null || - offset <= 200 || - prependWatcherFrameRef.current === null - ) { - capturePrependAnchor(); - } if (offset <= 200) { // Layout scrolls near the top must not poison the reader's next input. armUpwardMomentum(onStartReached?.() ?? false); @@ -686,8 +576,6 @@ function VirtualizedTimelineRows({ }, [ armUpwardMomentum, - cancelBottomSettle, - capturePrependAnchor, onAtBottomStateChange, onStartReached, onVirtualizerRangeChanged, diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs index 44854c0d58..2df5a22efe 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs +++ b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs @@ -48,6 +48,10 @@ function installDOMShim() { return this.children[0] ?? null; } + get firstElementChild() { + return this.children[0] ?? null; + } + get lastChild() { return this.children.at(-1) ?? null; } @@ -122,8 +126,16 @@ function installDOMShim() { } globalThis.document = new DocumentShim(); + const windowEvents = new EventTargetShim(); + globalThis.addEventListener = + windowEvents.addEventListener.bind(windowEvents); + globalThis.removeEventListener = + windowEvents.removeEventListener.bind(windowEvents); + globalThis.dispatchEvent = windowEvents.dispatchEvent.bind(windowEvents); globalThis.HTMLIFrameElement = NodeShim; + globalThis.HTMLDivElement = NodeShim; globalThis.HTMLElement = NodeShim; + globalThis.Node = NodeShim; globalThis.IS_REACT_ACT_ENVIRONMENT = true; process.env.IS_REACT_ACT_ENVIRONMENT = "true"; Object.defineProperty(globalThis, "window", { @@ -142,6 +154,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { useAnchoredScroll } from "./useAnchoredScroll.ts"; +import { useVirtualizedBottomSettle } from "./useVirtualizedBottomSettle.ts"; function makePinnedCenterNodes() { const resizeObservers = []; @@ -232,6 +245,32 @@ function Harness({ channelId, refs }) { return null; } +function VirtualTargetHarness({ refs }) { + const didRun = React.useRef(false); + const bottomApi = useVirtualizedBottomSettle( + refs.host, + refs.list, + refs.itemsLength, + ); + const anchored = useAnchoredScroll({ + channelId: "conversation", + contentRef: refs.content, + isLoading: false, + messages: Array.from({ length: 5 }, (_, index) => ({ id: String(index) })), + scrollContainerRef: refs.scroller, + virtualCancelBottomIntent: bottomApi.cancel, + virtualizerOwnsPrependAnchoring: true, + virtualScrollToMessage: () => true, + }); + React.useLayoutEffect(() => { + if (didRun.current) return; + didRun.current = true; + bottomApi.settle(); + refs.targetResult.current = anchored.scrollToMessage("selected"); + }, [anchored.scrollToMessage, bottomApi.settle, refs.targetResult]); + return null; +} + test("channel change attaches pinned-center observers after refs mount", async () => { const refs = { container: { current: null }, @@ -272,3 +311,79 @@ test("channel change attaches pinned-center observers after refs mount", async ( root.unmount(); }); }); + +test("mounted virtual target retires bottom intent before direct centering", async () => { + const resizeObservers = []; + globalThis.ResizeObserver = class { + constructor(callback) { + this.callback = callback; + this.targets = []; + resizeObservers.push(this); + } + disconnect() {} + observe(target) { + this.targets.push(target); + } + }; + + const content = document.createElement("div"); + const scroller = document.createElement("div"); + const host = document.createElement("div"); + scroller.appendChild(content); + host.appendChild(scroller); + scroller.clientHeight = 400; + scroller.scrollHeight = 1_000; + scroller.scrollTop = 0; + scroller.getBoundingClientRect = () => ({ bottom: 400, top: 0 }); + const targetContentTop = 250; + const row = { + getBoundingClientRect: () => ({ + bottom: targetContentTop - scroller.scrollTop + 40, + height: 40, + top: targetContentTop - scroller.scrollTop, + }), + }; + scroller.querySelector = () => row; + scroller.querySelectorAll = () => []; + scroller.scrollTo = ({ top }) => { + scroller.scrollTop = top; + }; + + const bottomWrites = []; + const refs = { + content: { current: content }, + host: { current: host }, + itemsLength: { current: 5 }, + list: { + current: { + scrollToIndex: (index, options) => + bottomWrites.push({ index, options }), + }, + }, + scroller: { current: scroller }, + targetResult: { current: null }, + }; + const root = createRoot(document.createElement("div")); + await act(async () => { + root.render(React.createElement(VirtualTargetHarness, { refs })); + }); + + assert.deepEqual(bottomWrites, [{ index: 4, options: { align: "end" } }]); + assert.equal(refs.targetResult.current, true); + assert.equal(row.getBoundingClientRect().top, 180); + const bottomGeometryObserver = resizeObservers.find( + (observer) => + observer.targets.includes(content) && observer.targets.includes(scroller), + ); + assert.ok(bottomGeometryObserver); + bottomGeometryObserver.callback(); + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + + assert.equal( + row.getBoundingClientRect().top, + 180, + "target remains centered after later virtual geometry activity", + ); + assert.equal(bottomWrites.length, 1, "geometry cannot re-pin to bottom"); + await act(async () => root.unmount()); +}); diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index b865260de0..34929fc26e 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -109,6 +109,7 @@ type UseAnchoredScrollOptions = { /** Keeps a targeted message centered until the user deliberately scrolls. */ pinTargetCentered?: boolean; onTargetReached?: (messageId: string) => void; + virtualCancelBottomIntent?: () => void; virtualScrollToMessage?: ( messageId: string, options?: { behavior?: ScrollBehavior }, @@ -229,6 +230,7 @@ export function useAnchoredScroll({ highlightTargetMessage = true, pinTargetCentered = false, onTargetReached, + virtualCancelBottomIntent, virtualScrollToMessage, virtualScrollToBottom, virtualSettleAtBottom, @@ -439,6 +441,10 @@ export function useAnchoredScroll({ `[data-message-id="${messageId}"]`, ); if (virtualizerOwnsPrependAnchoring && virtualScrollToMessage) { + // Target navigation owns the viewport before any movement strategy is + // chosen. The already-mounted fast path centers the DOM node directly + // and would otherwise leave durable bottom intent armed. + virtualCancelBottomIntent?.(); if (el) { const rect = el.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); @@ -534,6 +540,7 @@ export function useAnchoredScroll({ highlightMessage, pinTargetCentered, scrollContainerRef, + virtualCancelBottomIntent, virtualizerOwnsPrependAnchoring, writePinnedCenterScroll, virtualScrollToMessage, @@ -607,6 +614,11 @@ export function useAnchoredScroll({ // to the requested target message, or to the bottom by default. if (!hasInitializedRef.current) { if (isLoading) return; + // The virtualized list owns the actual scroll node. Its API registers in + // a child layout effect, after this parent hook's first pass; treating + // that API-less pass as initialized writes to the inert outer wrapper + // and permanently consumes the channel's initial bottom pin. + if (virtualizerOwnsPrependAnchoring && !virtualScrollToBottom) return; // Establish the initial position before the browser paints. The follow-up // frame is a settling pass for content whose measurements land with the // commit (fonts, deferred rows, media), not the first bottom pin. Keeping diff --git a/desktop/src/features/messages/ui/useSettleGatedPrependMessages.ts b/desktop/src/features/messages/ui/useSettleGatedPrependMessages.ts index 84663e5da3..bad8d0f04a 100644 --- a/desktop/src/features/messages/ui/useSettleGatedPrependMessages.ts +++ b/desktop/src/features/messages/ui/useSettleGatedPrependMessages.ts @@ -4,13 +4,13 @@ import * as React from "react"; * Holds an older-history prepend out of the rendered timeline until the * scroller is genuinely at rest, then admits it atomically. * - * Why: every prepend-compensation mechanism in this design — Virtua's shift - * correction, the pre-paint scrollBy, the semantic-anchor watcher — is a - * scrollTop write. On macOS WKWebView those writes can be dropped or - * overridden while trackpad momentum owns the committed offset, so a page - * commit that lands mid-fling displaces the viewport by the full prepended - * height with no reliable way to correct it. Committing only at rest keeps - * all three writers operating in the regime where they are exact. + * Why: Virtua reconciles an active prepend by correcting scrollTop as the + * inserted rows are measured. On macOS WKWebView those corrections can be + * dropped or overridden while trackpad momentum owns the committed offset, so + * a page admitted mid-fling can displace the viewport by the full prepended + * height. Admitting only at rest gives Virtua a stable geometry window in + * which to complete that reconciliation exactly; subsequent reader wheel input + * then retires it. * * The fetched store stays authoritative and fetches still start immediately; * this hook only delays when the fetched page joins the rendered snapshot. diff --git a/desktop/src/features/messages/ui/useUpwardPaginationWheel.ts b/desktop/src/features/messages/ui/useUpwardPaginationWheel.ts index 5c3c4fb22c..910b251ac4 100644 --- a/desktop/src/features/messages/ui/useUpwardPaginationWheel.ts +++ b/desktop/src/features/messages/ui/useUpwardPaginationWheel.ts @@ -15,6 +15,9 @@ export function useUpwardPaginationWheel( if (!(scroller instanceof HTMLDivElement)) return; let releaseTimer: number | null = null; const handleWheel = (event: WheelEvent) => { + // Ctrl+wheel belongs to browser zoom. It must not retire bottom intent or + // arm upward-pagination momentum because it does not move the reader. + if (event.ctrlKey) return; onWheel(); if (event.deltaY >= 0) { clear(); diff --git a/desktop/src/features/messages/ui/useVirtualizedBottomSettle.test.mjs b/desktop/src/features/messages/ui/useVirtualizedBottomSettle.test.mjs new file mode 100644 index 0000000000..bc0aebe917 --- /dev/null +++ b/desktop/src/features/messages/ui/useVirtualizedBottomSettle.test.mjs @@ -0,0 +1,280 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +class EventTargetShim { + listeners = new Map(); + addEventListener(type, listener) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + removeEventListener(type, listener) { + this.listeners.set( + type, + (this.listeners.get(type) ?? []).filter( + (current) => current !== listener, + ), + ); + } + dispatchEvent(event) { + for (const listener of this.listeners.get(event.type) ?? []) + listener(event); + return true; + } +} + +class ElementShim extends EventTargetShim { + constructor(firstElementChild = null) { + super(); + this.firstElementChild = firstElementChild; + this.children = []; + this.childNodes = []; + this.isContentEditable = false; + this.nodeName = "DIV"; + this.tagName = "DIV"; + this.nodeType = 1; + this.namespaceURI = "http://www.w3.org/1999/xhtml"; + } + get ownerDocument() { + return globalThis.document; + } + appendChild(child) { + this.children.push(child); + this.childNodes.push(child); + return child; + } + removeChild(child) { + this.children = this.children.filter((current) => current !== child); + this.childNodes = this.childNodes.filter((current) => current !== child); + return child; + } + insertBefore(child, reference) { + const index = this.children.indexOf(reference); + if (index < 0) return this.appendChild(child); + this.children.splice(index, 0, child); + this.childNodes.splice(index, 0, child); + return child; + } + closest() { + return null; + } + contains(target) { + return this === target || this.firstElementChild?.contains(target) === true; + } +} + +globalThis.document = { + addEventListener() {}, + createElement: () => new ElementShim(), + get defaultView() { + return globalThis.window; + }, + nodeType: 9, + removeEventListener() {}, +}; + +const animationFrames = new Map(); +let nextAnimationFrameId = 1; +globalThis.requestAnimationFrame = (callback) => { + const id = nextAnimationFrameId++; + animationFrames.set(id, callback); + return id; +}; +globalThis.cancelAnimationFrame = (id) => animationFrames.delete(id); +globalThis.HTMLElement = ElementShim; +globalThis.HTMLDivElement = ElementShim; +globalThis.Node = ElementShim; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +process.env.IS_REACT_ACT_ENVIRONMENT = "true"; +Object.defineProperty(globalThis, "window", { + configurable: true, + value: new EventTargetShim(), +}); +globalThis.window.HTMLIFrameElement = ElementShim; + +const resizeObservers = []; +globalThis.ResizeObserver = class { + constructor(callback) { + this.callback = callback; + resizeObservers.push(this); + } + disconnect() {} + observe(target) { + this.targets = [...(this.targets ?? []), target]; + } +}; + +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { useVirtualizedBottomSettle } from "./useVirtualizedBottomSettle.ts"; + +function flushAnimationFrames() { + const callbacks = [...animationFrames.values()]; + animationFrames.clear(); + for (const callback of callbacks) callback(performance.now()); +} + +function Harness({ apiRef, hostRef, itemsLengthRef, listRef }) { + apiRef.current = useVirtualizedBottomSettle(hostRef, listRef, itemsLengthRef); + return null; +} + +async function mountHarness() { + animationFrames.clear(); + resizeObservers.length = 0; + const content = new ElementShim(); + const scroller = new ElementShim(content); + const host = new ElementShim(scroller); + const writes = []; + const refs = { + api: { current: null }, + host: { current: host }, + itemsLength: { current: 5 }, + list: { + current: { + scrollToIndex(index, options) { + writes.push({ index, options }); + }, + }, + }, + }; + const root = createRoot(new ElementShim()); + await act(async () => { + root.render( + React.createElement(Harness, { + apiRef: refs.api, + hostRef: refs.host, + itemsLengthRef: refs.itemsLength, + listRef: refs.list, + }), + ); + }); + return { content, refs, root, scroller, writes }; +} + +test("bottom intent follows arbitrarily late virtual geometry changes", async () => { + const { content, refs, root, scroller, writes } = await mountHarness(); + refs.api.current.settle(); + assert.deepEqual(writes, [{ index: 4, options: { align: "end" } }]); + + const geometryObserver = resizeObservers.find((observer) => + observer.targets?.includes(content), + ); + assert.ok(geometryObserver); + geometryObserver.callback(); + flushAnimationFrames(); + assert.equal(writes.length, 2); + + // There is no deadline: another geometry change much later still re-pins. + geometryObserver.callback(); + flushAnimationFrames(); + assert.equal(writes.length, 3); + + // Viewport changes also move the physical floor without resizing content. + assert.ok(geometryObserver.targets.includes(scroller)); + geometryObserver.callback(); + flushAnimationFrames(); + assert.equal(writes.length, 4); + await act(async () => root.unmount()); +}); + +for (const eventType of ["pointerdown", "touchmove", "wheel", "keydown"]) { + test(`${eventType} transfers ownership away from bottom intent`, async () => { + const { content, refs, root, scroller, writes } = await mountHarness(); + refs.api.current.settle(); + const target = eventType === "keydown" ? window : scroller; + target.dispatchEvent({ + altKey: false, + ctrlKey: false, + key: eventType === "keydown" ? "PageUp" : undefined, + metaKey: false, + target: + eventType === "keydown" + ? scroller + : eventType === "pointerdown" + ? scroller + : undefined, + type: eventType, + }); + + resizeObservers + .find((observer) => observer.targets?.includes(content)) + .callback(); + flushAnimationFrames(); + assert.equal(writes.length, 1); + await act(async () => root.unmount()); + assert.equal( + scroller.listeners.get(eventType)?.length ?? 0, + 0, + `${eventType} listener is removed on unmount`, + ); + }); +} + +test("descendant interaction and outside navigation preserve bottom intent", async () => { + const { content, refs, root, scroller, writes } = await mountHarness(); + refs.api.current.settle(); + const rowControl = new ElementShim(); + scroller.firstElementChild.firstElementChild = rowControl; + + scroller.dispatchEvent({ target: rowControl, type: "pointerdown" }); + window.dispatchEvent({ + altKey: false, + ctrlKey: false, + key: "PageUp", + metaKey: false, + target: new ElementShim(), + type: "keydown", + }); + resizeObservers + .find((observer) => observer.targets?.includes(content)) + .callback(); + flushAnimationFrames(); + + assert.equal(writes.length, 2); + await act(async () => root.unmount()); +}); + +test("Ctrl+wheel zoom preserves bottom intent through geometry reflow", async () => { + const { content, refs, root, scroller, writes } = await mountHarness(); + refs.api.current.settle(); + + scroller.dispatchEvent({ ctrlKey: true, deltaY: -100, type: "wheel" }); + resizeObservers + .find((observer) => observer.targets?.includes(content)) + .callback(); + flushAnimationFrames(); + + assert.equal(writes.length, 2); + await act(async () => root.unmount()); +}); + +test("typing and editable navigation keys preserve bottom intent", async () => { + const { content, refs, root, writes } = await mountHarness(); + refs.api.current.settle(); + + window.dispatchEvent({ + altKey: false, + ctrlKey: false, + key: "a", + metaKey: false, + target: null, + type: "keydown", + }); + const editable = new ElementShim(); + editable.isContentEditable = true; + window.dispatchEvent({ + altKey: false, + ctrlKey: false, + key: "ArrowUp", + metaKey: false, + target: editable, + type: "keydown", + }); + + resizeObservers + .find((observer) => observer.targets?.includes(content)) + .callback(); + flushAnimationFrames(); + assert.equal(writes.length, 2); + await act(async () => root.unmount()); +}); diff --git a/desktop/src/features/messages/ui/useVirtualizedBottomSettle.ts b/desktop/src/features/messages/ui/useVirtualizedBottomSettle.ts index 2ff91de99f..8c0535779f 100644 --- a/desktop/src/features/messages/ui/useVirtualizedBottomSettle.ts +++ b/desktop/src/features/messages/ui/useVirtualizedBottomSettle.ts @@ -1,67 +1,129 @@ import * as React from "react"; import type { VListHandle } from "virtua"; -const BOTTOM_EPSILON_PX = 1; -const SETTLE_DEADLINE_MS = 250; +const SCROLL_INTENT_KEYS = new Set([ + "ArrowDown", + "ArrowUp", + "End", + "Home", + "PageDown", + "PageUp", + " ", +]); + +function isEditableKeyboardTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + return ( + target.closest("input, textarea, select, [contenteditable='true']") !== null + ); +} export function useVirtualizedBottomSettle( hostRef: React.RefObject, listRef: React.RefObject, itemsLengthRef: React.RefObject, ) { + const bottomIntentRef = React.useRef(false); const frameRef = React.useRef(null); - const cancel = React.useCallback(() => { + + const cancelFrame = React.useCallback(() => { if (frameRef.current !== null) { cancelAnimationFrame(frameRef.current); frameRef.current = null; } }, []); + const cancel = React.useCallback(() => { + bottomIntentRef.current = false; + cancelFrame(); + }, [cancelFrame]); + + const pinToBottom = React.useCallback(() => { + if (!bottomIntentRef.current) return; + const scroller = hostRef.current?.firstElementChild; + const lastIndex = itemsLengthRef.current - 1; + if (!(scroller instanceof HTMLDivElement) || lastIndex < 0) return; + listRef.current?.scrollToIndex(lastIndex, { align: "end" }); + }, [hostRef, itemsLengthRef, listRef]); + + const schedulePinToBottom = React.useCallback(() => { + if (!bottomIntentRef.current || frameRef.current !== null) return; + frameRef.current = requestAnimationFrame(() => { + frameRef.current = null; + pinToBottom(); + }); + }, [pinToBottom]); + React.useLayoutEffect(() => { const scroller = hostRef.current?.firstElementChild; if (!(scroller instanceof HTMLDivElement)) return; const retire = () => cancel(); - scroller.addEventListener("pointerdown", retire, { passive: true }); - scroller.addEventListener("touchstart", retire, { passive: true }); - scroller.addEventListener("wheel", retire, { passive: true }); - window.addEventListener("keydown", retire, true); + const retireForPointer = (event: PointerEvent) => { + // A descendant pointerdown is ordinary row interaction (link, reaction, + // thread action), not evidence that the reader took scroll ownership. + // Direct scroller hits cover scrollbar/background drag initiation. + if (event.target === scroller) cancel(); + }; + const retireForWheel = (event: WheelEvent) => { + // Ctrl+wheel is browser zoom, not reader navigation. Keep bottom intent + // armed so the resulting viewport/content reflow can settle at the new + // physical floor. + if (!event.ctrlKey) cancel(); + }; + const retireForScrollKey = (event: KeyboardEvent) => { + if ( + !event.altKey && + !event.ctrlKey && + !event.metaKey && + event.target instanceof Node && + scroller.contains(event.target) && + !isEditableKeyboardTarget(event.target) && + SCROLL_INTENT_KEYS.has(event.key) + ) { + cancel(); + } + }; + scroller.addEventListener("pointerdown", retireForPointer, { + passive: true, + }); + scroller.addEventListener("touchmove", retire, { passive: true }); + scroller.addEventListener("wheel", retireForWheel, { passive: true }); + window.addEventListener("keydown", retireForScrollKey, true); return () => { - scroller.removeEventListener("pointerdown", retire); - scroller.removeEventListener("touchstart", retire); - scroller.removeEventListener("wheel", retire); - window.removeEventListener("keydown", retire, true); + scroller.removeEventListener("pointerdown", retireForPointer); + scroller.removeEventListener("touchmove", retire); + scroller.removeEventListener("wheel", retireForWheel); + window.removeEventListener("keydown", retireForScrollKey, true); }; }, [cancel, hostRef]); + React.useLayoutEffect(() => { + const scroller = hostRef.current?.firstElementChild; + if (!(scroller instanceof HTMLDivElement)) return; + const content = scroller.firstElementChild; + if ( + !(content instanceof HTMLElement) || + typeof ResizeObserver === "undefined" + ) { + return; + } + // Virtua updates this inner element's extent whenever measured row geometry + // changes. Bottom intent therefore follows the physical floor for as long + // as it remains active, rather than guessing that layout is done after an + // arbitrary timeout. Reader input and explicit target/prepend navigation + // retire the intent through `cancel`. + const observer = new ResizeObserver(schedulePinToBottom); + observer.observe(content); + observer.observe(scroller); + return () => observer.disconnect(); + }, [hostRef, schedulePinToBottom]); + const settle = React.useCallback(() => { - cancel(); - const deadline = performance.now() + SETTLE_DEADLINE_MS; - let settledFrames = 0; - let previousHeight = -1; - const next = () => { - const scroller = hostRef.current?.firstElementChild; - const lastIndex = itemsLengthRef.current - 1; - if (!(scroller instanceof HTMLDivElement) || lastIndex < 0) { - cancel(); - return; - } - listRef.current?.scrollToIndex(lastIndex, { align: "end" }); - const atBottom = - scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <= - BOTTOM_EPSILON_PX; - settledFrames = - atBottom && scroller.scrollHeight === previousHeight - ? settledFrames + 1 - : 0; - previousHeight = scroller.scrollHeight; - if (settledFrames >= 2 || performance.now() >= deadline) { - frameRef.current = null; - return; - } - frameRef.current = requestAnimationFrame(next); - }; - next(); - }, [cancel, hostRef, itemsLengthRef, listRef]); + bottomIntentRef.current = true; + cancelFrame(); + pinToBottom(); + }, [cancelFrame, pinToBottom]); React.useEffect(() => cancel, [cancel]); return { cancel, settle }; diff --git a/desktop/src/features/messages/ui/virtuaWheelModePatch.test.mjs b/desktop/src/features/messages/ui/virtuaWheelModePatch.test.mjs new file mode 100644 index 0000000000..cf6c1fe5c3 --- /dev/null +++ b/desktop/src/features/messages/ui/virtuaWheelModePatch.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const patch = await readFile( + new URL("../../../../../patches/virtua@0.49.3.patch", import.meta.url), + "utf8", +); + +test("reader wheel retires Virtua shift mode without publishing scroll end", () => { + // Virtua 0.49.3 has no public transition for leaving SCROLL_BY_SHIFT. + // Keep the CJS and ESM patch paths symmetric and deliberately narrower than + // ACTION_SCROLL_END (2), which also idles direction, clears frozen range, + // flushes pending jumps, and emits UPDATE_SCROLL_END_EVENT. + const addedActionBodies = [ + ...patch.matchAll(/\+\s+case 9:\n((?:\+.*\n)+?)(?=\s*})/g), + ].map(([, body]) => + body + .split("\n") + .map((line) => line.replace(/^\+\s*/, "").trim()) + .filter(Boolean), + ); + assert.deepEqual(addedActionBodies, [["I = 0;"], ["w = 0;"]]); + assert.match(patch, /\+\s+e\.q\(9\);/); + assert.match(patch, /\+\s+e\.B\(9\);/); + assert.doesNotMatch(patch, /\+\s+e\.(?:q|B)\(2\);/); +}); diff --git a/desktop/tests/e2e/scroll-history.spec.ts b/desktop/tests/e2e/scroll-history.spec.ts index 515669407e..5d7bb6e5bc 100644 --- a/desktop/tests/e2e/scroll-history.spec.ts +++ b/desktop/tests/e2e/scroll-history.spec.ts @@ -68,6 +68,61 @@ async function getMessagePosition( }, messageId); } +test("channel switch settles at the newest message after virtualized rows measure", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + + await page.evaluate(() => { + const base = Math.floor(Date.now() / 1000); + for (let index = 0; index < 80; index += 1) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: `switch-bottom ${index} ${"variable-height ".repeat( + index % 7, + )}`, + createdAt: base + index, + }); + } + }); + + // Open another channel first so this exercises a real channel switch, where + // the virtualizer API is temporarily null while the keyed list remounts. + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const timeline = page.getByTestId("message-timeline"); + await expect(timeline).toContainText("switch-bottom 79"); + // Reflow a rendered row well after the removed 250ms settle deadline. The + // geometry-driven bottom intent must still chase the new physical floor. + await timeline.evaluate((element) => { + window.setTimeout(() => { + const row = element.querySelector("[data-message-id]"); + if (row) { + row.style.minHeight = `${row.getBoundingClientRect().height + 240}px`; + } + element.dataset.delayedBottomReflow = "complete"; + }, 600); + }); + await expect(timeline).toHaveAttribute( + "data-delayed-bottom-reflow", + "complete", + ); + await expect + .poll(async () => { + const metrics = await getTimelineMetrics(page); + return metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop; + }) + .toBeLessThanOrEqual(1); + await expect(page.getByTestId("message-scroll-to-latest")).toHaveCount(0); +}); + test("first channel load paints the first window without waiting for the row-floor top-up", async ({ page, }) => { @@ -168,6 +223,7 @@ test("preserves user scroll while older channel history loads", async ({ const scrollToTop = async () => timeline.evaluate((element) => { const container = element as HTMLDivElement; + container.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); container.scrollTop = 0; container.dispatchEvent(new Event("scroll", { bubbles: true })); }); @@ -454,11 +510,20 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a ) .toBe("resolved"); - const afterPrepend = await getTimelineMetrics(page); - // (a) Geometry: timeline still pinned to bottom. - expect( - afterPrepend.scrollTop + afterPrepend.clientHeight, - ).toBeGreaterThanOrEqual(afterPrepend.scrollHeight - 2); + // (a) Geometry: timeline settles back to bottom. The durable bottom owner + // batches ResizeObserver-driven geometry correction into requestAnimationFrame, + // so scrollHeight growth and the physical-floor write need not share a turn. + await expect + .poll( + async () => { + const metrics = await getTimelineMetrics(page); + return ( + metrics.scrollTop + metrics.clientHeight >= metrics.scrollHeight - 2 + ); + }, + { timeout: 2_000 }, + ) + .toBe(true); // (b) DOM: the last rendered [data-message-id] sits within 2px of the // timeline's bottom edge. This catches a class of bugs where the geometry @@ -1641,6 +1706,7 @@ test("channel intro stays hidden while paginating past the timeline cap", async const scrollToTop = async () => timeline.evaluate((element) => { const container = element as HTMLDivElement; + container.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); container.scrollTop = 0; container.dispatchEvent(new Event("scroll", { bubbles: true })); }); diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index d5ae2046db..afe58d3713 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -634,6 +634,9 @@ test("does not shift the timeline when the composer grows", async ({ await page.waitForTimeout(400); await page.getByTestId("message-timeline").evaluate((element) => { const timeline = element as HTMLDivElement; + // The raw position assignment sets up detached history, while wheel is the + // same ownership signal a real reader produces before composer reflow. + timeline.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 })); timeline.scrollTop = 0; timeline.dispatchEvent(new Event("scroll")); }); diff --git a/desktop/tests/e2e/timeline-no-shift.spec.ts b/desktop/tests/e2e/timeline-no-shift.spec.ts index b123a67418..7065d29378 100644 --- a/desktop/tests/e2e/timeline-no-shift.spec.ts +++ b/desktop/tests/e2e/timeline-no-shift.spec.ts @@ -481,6 +481,7 @@ test("timeline prepend plus late row reflow keeps the reading row stable", async await page.waitForTimeout(100); await timeline.evaluate((element) => { const scroller = element as HTMLDivElement; + scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); scroller.scrollTop = 150; scroller.dispatchEvent(new Event("scroll", { bubbles: true })); }); diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts index 31800ae7e5..c8e84c05b8 100644 --- a/desktop/tests/e2e/virtualization.spec.ts +++ b/desktop/tests/e2e/virtualization.spec.ts @@ -297,8 +297,25 @@ test.describe("list virtualization", () => { }); const committedAnchor = await sampleVisibleAnchor(before.id); const motion = await timeline.evaluate( - async (scroller, { anchorId, anchorTop, oldHeight }) => { + async (scroller, { anchorId, anchorTop, oldHeight, testCtrlWheel }) => { const s = scroller as HTMLElement; + let ctrlWheelDispatched = false; + const mutationObserver = testCtrlWheel + ? new MutationObserver(() => { + if (ctrlWheelDispatched) return; + ctrlWheelDispatched = true; + // Ctrl+wheel is browser zoom, not reader scroll intent. Fire it + // synchronously with the prepend DOM commit, before the + // ResizeObserver measurement batch reconciles estimated rows. + s.dispatchEvent( + new WheelEvent("wheel", { ctrlKey: true, deltaY: -100 }), + ); + }) + : null; + mutationObserver?.observe(s.firstElementChild ?? s, { + childList: true, + subtree: true, + }); let maxDrift = 0; let sawPrepend = false; let sawAnchorAfterPrepend = false; @@ -326,15 +343,20 @@ test.describe("list virtualization", () => { if (sawAnchorAfterPrepend && stableFrames >= 8) break; await new Promise((resolve) => requestAnimationFrame(resolve)); } - return { maxDrift, sawPrepend }; + mutationObserver?.disconnect(); + return { ctrlWheelDispatched, maxDrift, sawPrepend }; }, { anchorId: committedAnchor.id, anchorTop: committedAnchor.top, oldHeight: before.scrollHeight, + // Exercise browser-zoom input once while a real prepend transaction + // still owns its measurement reconciliation. + testCtrlWheel: pageIndex === 0, }, ); expect(motion.sawPrepend).toBe(true); + if (pageIndex === 0) expect(motion.ctrlWheelDispatched).toBe(true); expect(motion.maxDrift).toBeLessThan(5); await expect @@ -357,11 +379,10 @@ test.describe("list virtualization", () => { await timeline.evaluate((element) => element.clientHeight), ); - // Leave the boundary with real downward wheel input while this prepend's - // three-second semantic-anchor watcher is still alive. The watcher belongs - // only to the completed prepend: it must not reinterpret this deliberate - // reader movement as row drift and pull the viewport back toward its stale - // baseline before the next upward load. + // Leave the boundary with real downward wheel input after this prepend. + // That reader intent retires Virtua's active prepend reconciliation, so + // later row measurements must not pull the viewport back toward the + // completed prepend before the next upward load. const exitTracePromise = timeline.evaluate(async (scroller) => { const s = scroller as HTMLElement; const startScrollTop = s.scrollTop; @@ -667,6 +688,10 @@ test("offscreen rich-row resize preserves the viewport-center anchor", async ({ const result = await timeline.evaluate(async (element) => { const scroller = element as HTMLDivElement; + // Retire bottom-follow intent the same way real reader input does before + // moving into detached history. A raw scrollTop assignment alone is not + // user intent and would correctly leave bottom following armed. + scroller.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); scroller.scrollTop = scroller.scrollHeight / 2; scroller.dispatchEvent(new Event("scroll", { bubbles: true })); await new Promise((resolve) => setTimeout(resolve, 250)); diff --git a/patches/virtua@0.49.3.patch b/patches/virtua@0.49.3.patch index d7432534b8..bd5424617e 100644 --- a/patches/virtua@0.49.3.patch +++ b/patches/virtua@0.49.3.patch @@ -1,5 +1,5 @@ diff --git a/lib/index.cjs b/lib/index.cjs -index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..c3bbc7f4d8dd396f5329a40a2701f4f752e3c5cc 100644 +index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..b12677a356f748ebccf6e16cb781efe1315bb55b 100644 --- a/lib/index.cjs +++ b/lib/index.cjs @@ -35,10 +35,18 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, @@ -39,11 +39,39 @@ index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..c3bbc7f4d8dd396f5329a40a2701f4f7 case 5: - t[1] ? (L(w(M, t[0], !0)), I = 2, l = 1) : (w(M, t[0]), l = 1); -+ t[1] ? (L(w(M, t[0], !0, t[2])), I = 2, l = 1) : (w(M, t[0], !1, t[2]), l = 1); ++ t[1] ? (L(w(M, t[0], !0, t[2])), I = 2, l = 1) : (w(M, t[0], !1, t[2]), I = 0, l = 1); break; case 6: -@@ -519,11 +527,11 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, +@@ -160,6 +168,10 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, + + case 8: + k = W(t, t + a), l = 1; ++ break; ++ ++ case 9: ++ I = 0; + } + l && (d = 1 + (2147483647 & d), o && z && ($ += z, z = 0), O.forEach(([e, t]) => { + l & e && t(n); +@@ -186,6 +198,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, + c = l(), d && (a = !0), i && e.q(6, i()), e.q(1, n()), h(); + }, p = t => { + if (f || !e.M() || t.ctrlKey) return; ++ e.q(9); + const r = l() - c; + 150 > r && 50 < r && (o ? t.deltaX : t.deltaY) && (f = !0); + }, v = () => { +@@ -266,7 +279,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, + e[l] = t; + }); + } +- f[i] = R(e.C() + t, s), o && u(); ++ f[i] = R((o ? R(f[i], s) : e.C()) + t, s), o && u(); + }), n[1](!0); + }, + v() { +@@ -519,11 +532,11 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, })(e); return [ e => t[e], t.length ]; }, [ e, o ]), j = /*#__PURE__*/ t.forwardRef(({children: r, data: n, bufferSize: s, itemSize: i, shift: l, horizontal: c, keepMounted: d, cache: a, startMargin: h = 0, ssrCount: g, as: p = "div", item: v = "div", scrollRef: _, onScroll: w, onScrollEnd: S}, m) => { @@ -59,7 +87,7 @@ index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..c3bbc7f4d8dd396f5329a40a2701f4f7 const o = $(t); return e.jsx(Y, { diff --git a/lib/index.js b/lib/index.js -index 110ac3858a002a6cdb698da2b56350bc1bf609d2..81d239dad48d4453efd5ce7f8397555c66c99d56 100644 +index 110ac3858a002a6cdb698da2b56350bc1bf609d2..db83efc83fb3b6d7aa07d83b7463d6ed7746287e 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,7 +1,7 @@ @@ -102,25 +130,44 @@ index 110ac3858a002a6cdb698da2b56350bc1bf609d2..81d239dad48d4453efd5ce7f8397555c if (n = d(n, e.l - 1), b(e, n) <= t) { const r = y(e, o, n); return [ y(e, t, n, r), r ]; -@@ -124,7 +132,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, - if (!e.length) break; - N(e.reduce((e, [t, o]) => { - let n; -- if (2 === w) n = !0; else if (I && 1 === w) n = t < I[0]; else { -+ if (2 === w) n = J(t) < E(); else if (I && 1 === w) n = t < I[0]; else { - const e = E(), o = J(t), r = A(t); - n = 1 !== _ && 0 === w ? o + r <= e : o < e && o + r < e + l; - } @@ -151,7 +159,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, break; case 5: - t[1] ? (N(x(M, t[0], !0)), w = 2, d = 1) : (x(M, t[0]), d = 1); -+ t[1] ? (N(x(M, t[0], !0, t[2])), w = 2, d = 1) : (x(M, t[0], !1, t[2]), d = 1); ++ t[1] ? (N(x(M, t[0], !0, t[2])), w = 2, d = 1) : (x(M, t[0], !1, t[2]), w = 0, d = 1); break; case 6: -@@ -523,11 +531,11 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, +@@ -164,6 +172,10 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, + + case 8: + I = W(t, t + l), d = 1; ++ break; ++ ++ case 9: ++ w = 0; + } + d && (i = 1 + (2147483647 & i), o && p && (g += p, p = 0), O.forEach(([e, t]) => { + d & e && t(n); +@@ -190,6 +202,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, + l = i(), d && (a = !0), s && e.B(6, s()), e.B(1, n()), h(); + }, p = t => { + if (c || !e.M() || t.ctrlKey) return; ++ e.B(9); + const n = i() - l; + 150 > n && 50 < n && (o ? t.deltaX : t.deltaY) && (c = !0); + }, v = () => { +@@ -270,7 +283,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, + e[l] = t; + }); + } +- u[i] = W(e.C() + t, s), o && f(); ++ u[i] = W((o ? W(u[i], s) : e.C()) + t, s), o && f(); + }), r[1](!0); + }, + v() { +@@ -523,11 +536,11 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, })(e); return [ e => o[e], o.length ]; }, [ e, t ]), Z = /*#__PURE__*/ i(({children: t, data: o, bufferSize: r, itemSize: s, shift: i, horizontal: u, keepMounted: d, cache: a, startMargin: h = 0, ssrCount: g, as: p = "div", item: _ = "div", scrollRef: w, onScroll: S, onScrollEnd: $}, z) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8ed8ec316..4cedd9bbe2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: patchedDependencies: isomorphic-git: e9b414a60d4cf1d8aa18f7a779483984e821989967c235662566e94ef0238d3f - virtua@0.49.3: acef47b2cfcb8bfd36668c30a16f918fe492eb07a1ddeafc8cc3a22a3efbf71b + virtua@0.49.3: 63923c1f0c73f6fd487c788159fe2f1bd6930e5aeebe815cc511c1c4747294bc importers: @@ -232,7 +232,7 @@ importers: version: 2.1.0 virtua: specifier: 0.49.3 - version: 0.49.3(patch_hash=acef47b2cfcb8bfd36668c30a16f918fe492eb07a1ddeafc8cc3a22a3efbf71b)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 0.49.3(patch_hash=63923c1f0c73f6fd487c788159fe2f1bd6930e5aeebe815cc511c1c4747294bc)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) yaml: specifier: ^2.8.3 version: 2.9.0 @@ -6926,7 +6926,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - virtua@0.49.3(patch_hash=acef47b2cfcb8bfd36668c30a16f918fe492eb07a1ddeafc8cc3a22a3efbf71b)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + virtua@0.49.3(patch_hash=63923c1f0c73f6fd487c788159fe2f1bd6930e5aeebe815cc511c1c4747294bc)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): optionalDependencies: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) From 07d0265cfc212ef02e1c26153bf58ff46ce5ffe6 Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 25 Jul 2026 17:22:15 -0700 Subject: [PATCH 004/128] fix(desktop): retire prepend mode on every reader wheel (#2913) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - retire Virtua prepend reconciliation on every ordinary reader wheel event, including events suppressed from its separate wheel-timing heuristic - keep Ctrl+wheel browser zoom excluded from reader ownership - arm the Ctrl+wheel prepend probe before pagination begins so it cannot miss the commit - preserve ESM/CJS patch parity and update the patch lock hash ## Why main was red PR #2855 added the reader-wheel retirement action after Virtua's existing suppression guard. Once the first event set that guard, later events in the same wheel burst returned before retiring prepend mode. A late ResizeObserver correction could then pull the viewport backward by 20–40px. The same test had failed twice on #2855 but passed its final retry, so the PR job appeared green; the merge commit lost all three retries. The separate Ctrl+wheel failure was a test race: its MutationObserver was registered after the request had already been triggered and could miss the prepend commit. ## Validation - desktop full unit suite: 3,515 passed - pre-push desktop check: passed - `git diff --check`: passed - CI is the E2E verification; no local E2E was run Fixes the main-branch failure in CI run 30180099007. Signed-off-by: Wes Co-authored-by: Carl --- .../messages/ui/virtuaWheelModePatch.test.mjs | 14 +++++- desktop/tests/e2e/virtualization.spec.ts | 50 ++++++++++--------- patches/virtua@0.49.3.patch | 12 +++-- pnpm-lock.yaml | 6 +-- 4 files changed, 49 insertions(+), 33 deletions(-) diff --git a/desktop/src/features/messages/ui/virtuaWheelModePatch.test.mjs b/desktop/src/features/messages/ui/virtuaWheelModePatch.test.mjs index cf6c1fe5c3..9dcb07d121 100644 --- a/desktop/src/features/messages/ui/virtuaWheelModePatch.test.mjs +++ b/desktop/src/features/messages/ui/virtuaWheelModePatch.test.mjs @@ -21,7 +21,17 @@ test("reader wheel retires Virtua shift mode without publishing scroll end", () .filter(Boolean), ); assert.deepEqual(addedActionBodies, [["I = 0;"], ["w = 0;"]]); - assert.match(patch, /\+\s+e\.q\(9\);/); - assert.match(patch, /\+\s+e\.B\(9\);/); + assert.match( + patch, + /if \(!e\.M\(\) \|\| t\.ctrlKey\) return;\n\+\s+e\.q\(9\);\n\+\s+if \(f\) return;/, + ); + assert.match( + patch, + /if \(!e\.M\(\) \|\| t\.ctrlKey\) return;\n\+\s+e\.B\(9\);\n\+\s+if \(c\) return;/, + ); + assert.doesNotMatch( + patch, + /\+\s+if \((?:f|c) \|\| !e\.M\(\) \|\| t\.ctrlKey\) return;/, + ); assert.doesNotMatch(patch, /\+\s+e\.(?:q|B)\(2\);/); }); diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts index c8e84c05b8..61a2753baa 100644 --- a/desktop/tests/e2e/virtualization.spec.ts +++ b/desktop/tests/e2e/virtualization.spec.ts @@ -252,6 +252,29 @@ test.describe("list virtualization", () => { }); await page.waitForTimeout(150); const before = await sampleVisibleAnchor(); + const ctrlWheelPromise = + pageIndex === 0 + ? timeline.evaluate( + (scroller) => + new Promise((resolve) => { + const s = scroller as HTMLElement; + const observer = new MutationObserver(() => { + observer.disconnect(); + // Ctrl+wheel is browser zoom, not reader scroll intent. Fire + // it synchronously with the prepend DOM commit, before the + // ResizeObserver measurement batch reconciles estimated rows. + s.dispatchEvent( + new WheelEvent("wheel", { ctrlKey: true, deltaY: -100 }), + ); + resolve(true); + }); + observer.observe(s.firstElementChild ?? s, { + childList: true, + subtree: true, + }); + }), + ) + : Promise.resolve(false); const wheelTracePromise = timeline.evaluate(async (scroller) => { const s = scroller as HTMLElement; let previousScrollTop = s.scrollTop; @@ -297,25 +320,8 @@ test.describe("list virtualization", () => { }); const committedAnchor = await sampleVisibleAnchor(before.id); const motion = await timeline.evaluate( - async (scroller, { anchorId, anchorTop, oldHeight, testCtrlWheel }) => { + async (scroller, { anchorId, anchorTop, oldHeight }) => { const s = scroller as HTMLElement; - let ctrlWheelDispatched = false; - const mutationObserver = testCtrlWheel - ? new MutationObserver(() => { - if (ctrlWheelDispatched) return; - ctrlWheelDispatched = true; - // Ctrl+wheel is browser zoom, not reader scroll intent. Fire it - // synchronously with the prepend DOM commit, before the - // ResizeObserver measurement batch reconciles estimated rows. - s.dispatchEvent( - new WheelEvent("wheel", { ctrlKey: true, deltaY: -100 }), - ); - }) - : null; - mutationObserver?.observe(s.firstElementChild ?? s, { - childList: true, - subtree: true, - }); let maxDrift = 0; let sawPrepend = false; let sawAnchorAfterPrepend = false; @@ -343,20 +349,16 @@ test.describe("list virtualization", () => { if (sawAnchorAfterPrepend && stableFrames >= 8) break; await new Promise((resolve) => requestAnimationFrame(resolve)); } - mutationObserver?.disconnect(); - return { ctrlWheelDispatched, maxDrift, sawPrepend }; + return { maxDrift, sawPrepend }; }, { anchorId: committedAnchor.id, anchorTop: committedAnchor.top, oldHeight: before.scrollHeight, - // Exercise browser-zoom input once while a real prepend transaction - // still owns its measurement reconciliation. - testCtrlWheel: pageIndex === 0, }, ); expect(motion.sawPrepend).toBe(true); - if (pageIndex === 0) expect(motion.ctrlWheelDispatched).toBe(true); + if (pageIndex === 0) expect(await ctrlWheelPromise).toBe(true); expect(motion.maxDrift).toBeLessThan(5); await expect diff --git a/patches/virtua@0.49.3.patch b/patches/virtua@0.49.3.patch index bd5424617e..5debe418eb 100644 --- a/patches/virtua@0.49.3.patch +++ b/patches/virtua@0.49.3.patch @@ -54,11 +54,13 @@ index e02dfd0b3db60faff2cc705d7ce0aa51cb958f5a..b12677a356f748ebccf6e16cb781efe1 } l && (d = 1 + (2147483647 & d), o && z && ($ += z, z = 0), O.forEach(([e, t]) => { l & e && t(n); -@@ -186,6 +198,7 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, +@@ -186,6 +198,8 @@ const r = null, {min: n, max: s, abs: i, floor: l} = Math, c = (e, t, o) => n(o, c = l(), d && (a = !0), i && e.q(6, i()), e.q(1, n()), h(); }, p = t => { - if (f || !e.M() || t.ctrlKey) return; +- if (f || !e.M() || t.ctrlKey) return; ++ if (!e.M() || t.ctrlKey) return; + e.q(9); ++ if (f) return; const r = l() - c; 150 > r && 50 < r && (o ? t.deltaX : t.deltaY) && (f = !0); }, v = () => { @@ -150,11 +152,13 @@ index 110ac3858a002a6cdb698da2b56350bc1bf609d2..db83efc83fb3b6d7aa07d83b7463d6ed } d && (i = 1 + (2147483647 & i), o && p && (g += p, p = 0), O.forEach(([e, t]) => { d & e && t(n); -@@ -190,6 +202,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, +@@ -190,6 +202,8 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o, l = i(), d && (a = !0), s && e.B(6, s()), e.B(1, n()), h(); }, p = t => { - if (c || !e.M() || t.ctrlKey) return; +- if (c || !e.M() || t.ctrlKey) return; ++ if (!e.M() || t.ctrlKey) return; + e.B(9); ++ if (c) return; const n = i() - l; 150 > n && 50 < n && (o ? t.deltaX : t.deltaY) && (c = !0); }, v = () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cedd9bbe2..e3516e853f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: patchedDependencies: isomorphic-git: e9b414a60d4cf1d8aa18f7a779483984e821989967c235662566e94ef0238d3f - virtua@0.49.3: 63923c1f0c73f6fd487c788159fe2f1bd6930e5aeebe815cc511c1c4747294bc + virtua@0.49.3: 30a7a92b94800ec37be8d36546ea98c04a05dcd0637f9fac27a06f016ba2eff4 importers: @@ -232,7 +232,7 @@ importers: version: 2.1.0 virtua: specifier: 0.49.3 - version: 0.49.3(patch_hash=63923c1f0c73f6fd487c788159fe2f1bd6930e5aeebe815cc511c1c4747294bc)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 0.49.3(patch_hash=30a7a92b94800ec37be8d36546ea98c04a05dcd0637f9fac27a06f016ba2eff4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) yaml: specifier: ^2.8.3 version: 2.9.0 @@ -6926,7 +6926,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - virtua@0.49.3(patch_hash=63923c1f0c73f6fd487c788159fe2f1bd6930e5aeebe815cc511c1c4747294bc)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + virtua@0.49.3(patch_hash=30a7a92b94800ec37be8d36546ea98c04a05dcd0637f9fac27a06f016ba2eff4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): optionalDependencies: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) From 8eb6e3eb601174249642373a6a367262fa476753 Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Sat, 25 Jul 2026 19:56:18 -0500 Subject: [PATCH 005/128] fix(agents): run live Databricks discovery instead of the fallback list (#2890) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The Databricks model dropdown offers a handful of stale models — and there's no way to tell that list apart from the real one. The AI Gateway exposes **66** chat/embedding endpoints on `block-lakehouse-production`, but the picker was showing a short list that includes models the gateway no longer serves and embedding endpoints that can't chat at all. Three independent defects, all on the discovery path: **1. Live discovery never ran for agents with no saved provider.** `get_agent_models` gates every in-process discovery attempt on the provider (`is_openai_compatible_provider` / `is_anthropic_provider` / `is_databricks_provider`), reading it straight from `record.provider`. That field is `null` for every agent record created before provider persistence — and for any agent that inherits its provider from the build. So all three gates saw `None`, no HTTP discovery ran, and the request fell through to the `buzz-acp models` subprocess. On the Databricks path that subprocess returns `discovery_failure_fallback` — the small hardcoded `DATABRICKS_V2_KNOWN_MODELS` catalog — which the frontend renders exactly like a live catalog. An internal DMG that bakes `BUZZ_AGENT_PROVIDER=databricks_v2` and a `DATABRICKS_HOST` still got the fallback. **2. The fallback list couldn't represent the running model.** When discovery genuinely fails, the picker should at minimum be able to show what the agent is actually configured with. For `DatabricksV2` it couldn't: the fallback returned only the hardcoded slate, so a model like `databricks-gpt-5-5` wasn't selectable in its own picker. **3. Embedding endpoints were offered as chat models.** `databricks-bge-large-en` was selectable (visible in the dialog today). The v2 endpoints payload carries no `task` or `state` field, so there is nothing to filter on but the name. ## Changes - **`effective_discovery_provider`** (new, `desktop/src-tauri/src/commands/agent_models_env.rs`) — an explicit provider (saved record value, or the create/edit dialog's current form value) still always wins; when there is none, discovery falls back to the runtime's own provider env var (`GOOSE_PROVIDER`, `BUZZ_AGENT_PROVIDER`, …) read off the merged env, which by that point already carries the baked build floor and the process env. Wired into both `get_agent_models` and `discover_agent_models`. `SavedAgentModelDiscoveryConfig` now carries `provider_env_var` from `known_acp_runtime`, so each runtime reads *its own* key rather than a shared guess. - The relay-mesh branches in `discover_agent_models` deliberately keep using `input.provider`: those key off a deliberate provider selection, never a baked default. - **Asserted vs inferred matters for missing credentials.** The OpenAI and Anthropic gates error on a missing API key, while the Databricks gate falls through; an inferred provider hitting the first two would have replaced a working subprocess catalog with `config: ANTHROPIC_API_KEY required` (`export GOOSE_PROVIDER=anthropic` is goose's documented way to pick a provider, and it keeps the key in its own keyring). So `effective_discovery_provider` returns a `DiscoveryProvider` that remembers how the value was resolved, and `required_env` only reports a missing credential for an asserted provider. A wrong guess declines and lets the subprocess answer. - **`is_chat_capable_endpoint`** (new, `crates/buzz-agent/src/catalog.rs`) — applied in `parse_v2_endpoints_page`. Drops `*embedding*` and segment-matched `bge` / `gte` endpoints; keeps everything unrecognised (fail-open, so a new model family is never hidden). Segment matching is why it's `split('-')` and not `contains`: a substring check would swallow legitimate names. - **`discovery_failure_fallback`** for `Provider::DatabricksV2` now leads with the configured model (deduped against the known slate, blank-tolerant), so a failed discovery still yields a picker that can show the running model. The configured model is trimmed once up front — `resolve_model` doesn't trim, so a padded `DATABRICKS_MODEL` used to slip past the dedupe and appear twice. - **`sort_v2_endpoints_newest_first`** (new, second commit) — the catalog is now ordered newest-first on each endpoint's `created_timestamp`, ties broken by name. Previously Buzz sorted nothing, so the gateway's own order reached the picker: it pages in two phases (Databricks-managed, then workspace-created — the page token decodes to `{"phase":"user"}`), each alphabetical, which buried `databricks-claude-opus-5` 8th behind five older Claude endpoints and `goose-claude-opus-5` — the newest endpoint in the catalog — 55th of 63. Sorting in `fetch_v2_models` means both discovery paths inherit it with no wire or type changes, and the combobox filter preserves incoming order. Endpoints with an absent or unparseable timestamp sort last rather than first, so a wire-shape change degrades to "unordered at the bottom" instead of "shuffled to the top". - The name tiebreak is load-bearing: eleven managed endpoints share one placeholder timestamp (`1699610000000`), so without it their relative order would vary between runs. That placeholder is also not always accurate — a few genuinely recent endpoints (`databricks-kimi-k2-7-code`, `databricks-llama-4-maverick`) land at the bottom with the 2023 batch. The gateway offers nothing better to sort on. - Env/provider lookup helpers moved out of `agent_models.rs` into `agent_models_env.rs`. This keeps the command module under the file-size limit **without ratcheting the override up** — the existing 1079 entry is untouched (file is now 1066 lines). ## Verification Live against `block-lakehouse-production`, release build: ``` BUZZ_ACP_AGENT_COMMAND=$PWD/target/release/buzz-agent \ BUZZ_AGENT_PROVIDER=databricks_v2 \ DATABRICKS_HOST=https://block-lakehouse-production.cloud.databricks.com \ DATABRICKS_MODEL=databricks-gpt-5-5 \ ./target/release/buzz-acp models --json ``` - before: 66 endpoints, including `databricks-bge-large-en`, `databricks-gte-large-en`, `databricks-qwen3-embedding-0-6b` - after: **63** endpoints, `[.models[] | select(.id | test("embedding|-bge-|-gte-"))]` → `[]` Top of the list after the sort commit: ``` goose-claude-opus-5 2026-07-24 databricks-claude-opus-5 2026-07-23 databricks-gemini-3-6-flash 2026-07-20 databricks-gemini-3-5-flash-lite 2026-07-20 databricks-inkling 2026-07-14 ``` Tests: 15 new (8 in `catalog.rs` — including the two-wire-shape timestamp parse, the sort's tiebreak/no-timestamp cases, and the padded-model dedupe — and 7 plus one assertion in `agent_models_tests.rs`, 3 of them covering the asserted/inferred credential split), two existing tests updated. `just check`, `just test-unit`, and `just desktop-tauri-test` all pass (1636 desktop-tauri tests, 274 buzz-agent lib tests). Not run locally: the Docker-backed integration suite (`just test`) — this diff touches neither `buzz-relay`, `buzz-db`, nor `buzz-auth`. ## Follow-ups (deliberately out of scope) Two inference-path defects found while investigating, both reproduced live against the gateway and both independent of discovery: 1. **Gemini thought signatures are dropped.** The gateway returns a bare `thoughtSignature` on tool calls; the external-model serving endpoints return it nested as `extra_content.google.thought_signature`. Neither shape is round-tripped, so multi-turn tool use on `databricks-gemini-*` fails with a 400 on the second turn. 2. **Array-shaped `content` is silently discarded.** Some models return OpenAI `content` as a block array rather than a string; `parse_openai`'s `str_field` returns `None` and the text is dropped. The legacy `serving-endpoints` path does not work around either one, and costs reasoning support on the GPT-5 family. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) --- crates/buzz-agent/src/catalog.rs | 291 ++++++++++++++++-- crates/buzz-agent/src/lib.rs | 12 +- .../src-tauri/src/commands/agent_models.rs | 132 ++++---- .../src/commands/agent_models_env.rs | 114 +++++++ .../src/commands/agent_models_tests.rs | 129 ++++++++ desktop/src-tauri/src/commands/mod.rs | 1 + 6 files changed, 583 insertions(+), 96 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_models_env.rs diff --git a/crates/buzz-agent/src/catalog.rs b/crates/buzz-agent/src/catalog.rs index fa57935717..aa2a121c99 100644 --- a/crates/buzz-agent/src/catalog.rs +++ b/crates/buzz-agent/src/catalog.rs @@ -37,8 +37,11 @@ pub const DATABRICKS_V2_KNOWN_MODELS: &[&str] = /// This is the list of models advertised by `session/new` when /// `discover_databricks_models` returns an error (e.g., no token available). /// -/// - `DatabricksV2` falls back to [`DATABRICKS_V2_KNOWN_MODELS`] so the -/// model-picker is always populated for AI Gateway v2 users. +/// - `DatabricksV2` falls back to the configured model plus +/// [`DATABRICKS_V2_KNOWN_MODELS`] so the model-picker is always populated for +/// AI Gateway v2 users. The configured model leads: without it a fallback +/// catalog can omit the very model the agent is running, leaving the picker +/// unable to represent the current selection. /// - Legacy `Databricks` falls back to only the configured model — the /// `DATABRICKS_V2_KNOWN_MODELS` IDs are AI Gateway v2 endpoints that the /// `/serving-endpoints/{model}/invocations` API may not serve. @@ -46,25 +49,62 @@ pub const DATABRICKS_V2_KNOWN_MODELS: &[&str] = /// Extracting this as a pure function makes the split testable without /// spawning an async runtime or making network calls. pub fn discovery_failure_fallback(provider: Provider, configured_model: &str) -> Vec { + // `resolve_model` does not trim, so a padded `DATABRICKS_MODEL` reaches here: + // normalize once, or the dedupe below misses and the picker lists the model + // twice (once padded, once from the known slate). + let configured_model = configured_model.trim(); + let configured = ModelEntry { + id: configured_model.to_string(), + name: configured_model.to_string(), + }; match provider { - Provider::DatabricksV2 => DATABRICKS_V2_KNOWN_MODELS - .iter() - .map(|id| ModelEntry { - id: id.to_string(), - name: id.to_string(), - }) - .collect(), - Provider::Databricks => vec![ModelEntry { - id: configured_model.to_string(), - name: configured_model.to_string(), - }], - _ => vec![ModelEntry { - id: configured_model.to_string(), - name: configured_model.to_string(), - }], + Provider::DatabricksV2 => { + let mut entries = Vec::with_capacity(DATABRICKS_V2_KNOWN_MODELS.len() + 1); + if !configured_model.is_empty() { + entries.push(configured); + } + entries.extend( + DATABRICKS_V2_KNOWN_MODELS + .iter() + .filter(|id| **id != configured_model) + .map(|id| ModelEntry { + id: id.to_string(), + name: id.to_string(), + }), + ); + entries + } + Provider::Databricks => vec![configured], + _ => vec![configured], } } +/// Heuristic: `true` when a v2 AI Gateway endpoint name looks like it serves +/// chat/completions traffic. +/// +/// The v1 `serving-endpoints` payload carries `task`, so [`parse_v1_endpoints`] +/// can filter on it directly. The v2 `ai-gateway/v2/endpoints` payload carries +/// no task or readiness field at all, so the only signal available here is the +/// endpoint name. Embedding endpoints are the one family that reliably cannot +/// serve a chat request — they reject it with +/// `API type 'mlflow/v1/chat/completions' is not supported by ''` — so +/// they are dropped rather than offered as selectable models. +/// +/// Deliberately narrow: image-capable endpoints (e.g. +/// `databricks-gemini-3-pro-image`) do answer chat requests, so they stay. Any +/// name this heuristic does not recognise is kept — preferring to include over +/// silently dropping, matching [`parse_v1_endpoints`]. +pub(crate) fn is_chat_capable_endpoint(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + if lower.contains("embedding") { + return false; + } + // Segment match so `bge`/`gte` cannot fire on a substring of a longer word. + !lower + .split('-') + .any(|segment| matches!(segment, "bge" | "gte")) +} + /// Discover available models for a Databricks provider. /// /// Returns a non-empty `Vec` on success. Returns @@ -197,7 +237,7 @@ async fn fetch_v2_models( host: &str, bearer: &str, ) -> Result, AgentError> { - let mut all_models: Vec = Vec::new(); + let mut all_endpoints: Vec = Vec::new(); let mut page_token: Option = None; let base_url = format!("{host}/api/ai-gateway/v2/endpoints"); @@ -235,8 +275,8 @@ async fn fetch_v2_models( )) })?; - let (page_models, next) = parse_v2_endpoints_page(&json)?; - all_models.extend(page_models); + let (page_endpoints, next) = parse_v2_endpoints_page(&json)?; + all_endpoints.extend(page_endpoints); match next { Some(tok) if Some(&tok) != page_token.as_ref() => page_token = Some(tok), @@ -245,26 +285,74 @@ async fn fetch_v2_models( } // Fall back to known-model list if the API returned nothing. - if all_models.is_empty() { - all_models = DATABRICKS_V2_KNOWN_MODELS + if all_endpoints.is_empty() { + return Ok(DATABRICKS_V2_KNOWN_MODELS .iter() .map(|id| ModelEntry { id: id.to_string(), name: id.to_string(), }) - .collect(); + .collect()); } - Ok(all_models) + sort_v2_endpoints_newest_first(&mut all_endpoints); + + Ok(all_endpoints + .into_iter() + .map(|endpoint| endpoint.entry) + .collect()) +} + +/// A v2 gateway endpoint plus the key discovery orders the catalog by. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct V2Endpoint { + pub(crate) entry: ModelEntry, + /// `created_timestamp` as epoch milliseconds. `None` when the field is + /// absent or unparseable — those sort last rather than jumping the queue. + pub(crate) created_ms: Option, +} + +/// Read `created_timestamp` from one endpoint object. +/// +/// The gateway sends epoch milliseconds as a JSON *string* +/// (`"created_timestamp": "1699610000000"`); accept a bare number too, so a +/// wire-shape change doesn't silently drop every endpoint to the bottom. +fn endpoint_created_ms(endpoint: &serde_json::Value) -> Option { + let value = endpoint.get("created_timestamp")?; + value + .as_i64() + .or_else(|| value.as_str()?.trim().parse::().ok()) +} + +/// Order the catalog newest-first, breaking ties by name. +/// +/// The gateway returns endpoints in two phases — Databricks-managed first, then +/// workspace-created — each alphabetical by name, which buries a brand-new +/// frontier model deep in the list. Newest-first puts the models people are +/// reaching for at the top of the picker. +/// +/// Endpoints with no usable timestamp sort last, and the name tiebreak keeps the +/// result stable: several managed endpoints share one placeholder timestamp, so +/// without it their relative order would be arbitrary. +pub(crate) fn sort_v2_endpoints_newest_first(endpoints: &mut [V2Endpoint]) { + endpoints.sort_by(|a, b| { + // `None` < `Some(_)`, so reversing puts timestamped endpoints first. + b.created_ms + .cmp(&a.created_ms) + .then_with(|| a.entry.name.cmp(&b.entry.name)) + }); } /// Parse one page of a `GET api/ai-gateway/v2/endpoints` response. /// -/// Returns `(models, next_page_token)`. An empty or absent `next_page_token` -/// signals the last page. +/// Returns `(endpoints, next_page_token)`. An empty or absent `next_page_token` +/// signals the last page. Endpoints that cannot serve chat traffic are dropped +/// (see [`is_chat_capable_endpoint`]) so the model picker only offers models the +/// agent can actually run. Page order is preserved here; the caller sorts once +/// every page is in (see [`sort_v2_endpoints_newest_first`]). pub(crate) fn parse_v2_endpoints_page( json: &serde_json::Value, -) -> Result<(Vec, Option), AgentError> { +) -> Result<(Vec, Option), AgentError> { let endpoints = json .get("endpoints") .and_then(|v| v.as_array()) @@ -279,9 +367,15 @@ pub(crate) fn parse_v2_endpoints_page( .iter() .filter_map(|endpoint| { let name = endpoint.get("name")?.as_str()?.to_string(); - Some(ModelEntry { - id: name.clone(), - name, + if !is_chat_capable_endpoint(&name) { + return None; + } + Some(V2Endpoint { + entry: ModelEntry { + id: name.clone(), + name, + }, + created_ms: endpoint_created_ms(endpoint), }) }) .collect(); @@ -356,7 +450,7 @@ mod tests { }); let (models, next) = parse_v2_endpoints_page(&json).unwrap(); - let ids: Vec<&str> = models.iter().map(|m| m.id.as_str()).collect(); + let ids: Vec<&str> = models.iter().map(|m| m.entry.id.as_str()).collect(); assert_eq!( ids, vec![ @@ -399,4 +493,139 @@ mod tests { "got: {err}" ); } + + #[test] + fn v2_parse_drops_embedding_endpoints() { + // The v2 payload carries no `task`, so embedding endpoints are only + // recognisable by name. They reject chat requests, so offering them in + // the picker can only produce a 400 at send time. + let json = serde_json::json!({ + "endpoints": [ + {"name": "databricks-bge-large-en"}, + {"name": "databricks-gte-large-en"}, + {"name": "databricks-qwen3-embedding-0-6b"}, + {"name": "databricks-claude-opus-5"}, + {"name": "databricks-gemini-3-pro-image"}, + ] + }); + + let (models, _) = parse_v2_endpoints_page(&json).unwrap(); + let ids: Vec<&str> = models.iter().map(|m| m.entry.id.as_str()).collect(); + // Image endpoints DO answer chat requests, so they are retained. + assert_eq!( + ids, + vec!["databricks-claude-opus-5", "databricks-gemini-3-pro-image"] + ); + } + + #[test] + fn v2_parse_reads_created_timestamp_in_either_wire_shape() { + // The gateway sends epoch ms as a string; a bare number must work too. + let json = serde_json::json!({ + "endpoints": [ + {"name": "string-ts", "created_timestamp": "1784932442251"}, + {"name": "number-ts", "created_timestamp": 1784932442251i64}, + {"name": "junk-ts", "created_timestamp": "not-a-number"}, + {"name": "no-ts"}, + ] + }); + + let (models, _) = parse_v2_endpoints_page(&json).unwrap(); + let stamps: Vec> = models.iter().map(|m| m.created_ms).collect(); + assert_eq!( + stamps, + vec![Some(1784932442251), Some(1784932442251), None, None,] + ); + } + + #[test] + fn v2_endpoints_sort_newest_first_then_by_name() { + // Mirrors the real catalog: the gateway pages Databricks-managed + // endpoints first, then workspace-created ones, each alphabetical — so + // the newest model is buried mid-list until this sort runs. + let json = serde_json::json!({ + "endpoints": [ + {"name": "databricks-claude-opus-5", "created_timestamp": "1784851200000"}, + {"name": "databricks-gpt-5-6-sol", "created_timestamp": "1784073600000"}, + {"name": "databricks-gpt-5-6-luna", "created_timestamp": "1784073600000"}, + {"name": "databricks-llama-4-maverick", "created_timestamp": "1699610000000"}, + {"name": "goose-claude-opus-5", "created_timestamp": "1784932442251"}, + {"name": "endpoint-without-timestamp"}, + ] + }); + + let (mut models, _) = parse_v2_endpoints_page(&json).unwrap(); + sort_v2_endpoints_newest_first(&mut models); + + let ids: Vec<&str> = models.iter().map(|m| m.entry.id.as_str()).collect(); + assert_eq!( + ids, + vec![ + // Newest first, across both pagination phases. + "goose-claude-opus-5", + "databricks-claude-opus-5", + // Same timestamp — the name tiebreak keeps this deterministic. + "databricks-gpt-5-6-luna", + "databricks-gpt-5-6-sol", + "databricks-llama-4-maverick", + // No usable timestamp sorts last, never first. + "endpoint-without-timestamp", + ] + ); + } + + #[test] + fn is_chat_capable_endpoint_keeps_unrecognised_names() { + // Prefer including over silently dropping — an unknown family is kept. + assert!(is_chat_capable_endpoint("databricks-glm-5-2")); + assert!(is_chat_capable_endpoint("some-teams-custom-endpoint")); + // `bge`/`gte` match as whole segments only, never as substrings. + assert!(is_chat_capable_endpoint("databricks-budget-gtex-model")); + assert!(!is_chat_capable_endpoint("databricks-bge-large-en")); + assert!(!is_chat_capable_endpoint("databricks-gte-large-en")); + assert!(!is_chat_capable_endpoint("databricks-qwen3-embedding-0-6b")); + } + + #[test] + fn v2_discovery_failure_fallback_leads_with_configured_model() { + let result = discovery_failure_fallback(Provider::DatabricksV2, "databricks-claude-opus-5"); + let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); + + // The running model must be representable in the picker even when + // discovery failed, so it leads the fallback catalog. + assert_eq!(ids.first(), Some(&"databricks-claude-opus-5")); + for known in DATABRICKS_V2_KNOWN_MODELS { + assert!(ids.contains(known), "fallback must retain '{known}'"); + } + } + + #[test] + fn v2_discovery_failure_fallback_does_not_duplicate_configured_model() { + let configured = DATABRICKS_V2_KNOWN_MODELS[0]; + let result = discovery_failure_fallback(Provider::DatabricksV2, configured); + let occurrences = result.iter().filter(|m| m.id == configured).count(); + assert_eq!(occurrences, 1, "got: {result:?}"); + assert_eq!(result.len(), DATABRICKS_V2_KNOWN_MODELS.len()); + } + + #[test] + fn v2_discovery_failure_fallback_tolerates_blank_configured_model() { + for configured in ["", " "] { + let result = discovery_failure_fallback(Provider::DatabricksV2, configured); + let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS.to_vec()); + } + } + + #[test] + fn v2_discovery_failure_fallback_dedupes_a_padded_configured_model() { + // `DATABRICKS_MODEL=" databricks-gpt-5-5 "` reaches here untrimmed, and an + // untrimmed comparison would list the model twice — once padded, once from + // the known slate. + let configured = DATABRICKS_V2_KNOWN_MODELS[0]; + let result = + discovery_failure_fallback(Provider::DatabricksV2, &format!(" {configured} ")); + let ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS.to_vec()); + } } diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index f5faafe853..e141b9860f 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -920,11 +920,13 @@ mod tests { let configured = "my-configured-model"; let result = discovery_failure_fallback(Provider::DatabricksV2, configured); - // DatabricksV2 must return the full DATABRICKS_V2_KNOWN_MODELS list. + // DatabricksV2 must return the full DATABRICKS_V2_KNOWN_MODELS list, + // plus the configured model so the picker can still represent the model + // the agent is actually running. assert_eq!( result.len(), - DATABRICKS_V2_KNOWN_MODELS.len(), - "DatabricksV2 fallback must return all known models" + DATABRICKS_V2_KNOWN_MODELS.len() + 1, + "DatabricksV2 fallback must return all known models plus the configured model" ); let result_ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); for known_id in DATABRICKS_V2_KNOWN_MODELS { @@ -933,6 +935,10 @@ mod tests { "DatabricksV2 fallback must include known model '{known_id}'" ); } + assert!( + result_ids.contains(&configured), + "DatabricksV2 fallback must include the configured model" + ); } #[test] diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 425eadb5f6..112f297fa7 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -5,6 +5,13 @@ use serde::Deserialize; use tauri::{AppHandle, State}; use super::agent_model_process::run_agent_models_command; +// The map-only lookup is reached solely from the base-URL helpers that exist for +// their unit tests; discovery itself always goes through the process-env variant. +#[cfg(test)] +use super::agent_models_env::env_value; +use super::agent_models_env::{ + effective_discovery_provider, env_or_process_value, redaction_env_with_value, DiscoveryProvider, +}; use super::agent_update_rollback::{rollback_failed_agent_update, AgentUpdateRollback}; use crate::{ @@ -31,7 +38,15 @@ pub async fn get_agent_models( app: AppHandle, state: State<'_, AppState>, ) -> Result { - let (resolved_acp, agent_command, agent_args, persisted_model, effective_provider, merged_env) = { + let ( + resolved_acp, + agent_command, + agent_args, + persisted_model, + saved_provider, + provider_env_var, + merged_env, + ) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -82,14 +97,19 @@ pub async fn get_agent_models( args, discovery.model, discovery.provider, + discovery.provider_env_var, discovery.env, ) }; // store lock released — subprocess runs without holding the lock let merged_env = discovery_env_with_baked_floor(merged_env); + // Resolve against the baked/process env when the record saved no provider, + // so a build-provided provider still gets live discovery. + let effective_provider = + effective_discovery_provider(saved_provider.as_deref(), provider_env_var, &merged_env); if let Some(models) = discover_openai_compatible_models( &state.http_client, - effective_provider.as_deref(), + &effective_provider, &merged_env, persisted_model.clone(), ) @@ -100,7 +120,7 @@ pub async fn get_agent_models( if let Some(models) = discover_anthropic_models( &state.http_client, - effective_provider.as_deref(), + &effective_provider, &merged_env, persisted_model.clone(), ) @@ -111,7 +131,7 @@ pub async fn get_agent_models( if let Some(models) = discover_databricks_models( &state.http_client, - effective_provider.as_deref(), + &effective_provider, &merged_env, persisted_model.clone(), ) @@ -134,6 +154,10 @@ pub async fn get_agent_models( struct SavedAgentModelDiscoveryConfig { model: Option, provider: Option, + /// The runtime's provider env var (e.g. `BUZZ_AGENT_PROVIDER`), so discovery + /// can recover the provider from the env when the record has none. `None` + /// for runtimes that do not take a provider, or an unknown command. + provider_env_var: Option<&'static str>, env: BTreeMap, } @@ -141,8 +165,9 @@ fn saved_agent_model_discovery_config( record: &crate::managed_agents::ManagedAgentRecord, agent_command: &str, ) -> SavedAgentModelDiscoveryConfig { + let runtime_meta = known_acp_runtime(agent_command); let mut derived_env = BTreeMap::new(); - if let Some(meta) = known_acp_runtime(agent_command) { + if let Some(meta) = runtime_meta { for (key, value) in crate::managed_agents::runtime_metadata_env_vars( meta.model_env_var, meta.provider_env_var, @@ -157,6 +182,7 @@ fn saved_agent_model_discovery_config( SavedAgentModelDiscoveryConfig { model: record.model.clone(), provider: record.provider.clone(), + provider_env_var: runtime_meta.and_then(|meta| meta.provider_env_var), env: crate::managed_agents::merged_user_env(&derived_env, &record.env_vars), } } @@ -205,8 +231,9 @@ pub async fn discover_agent_models( .map(|p| p.display().to_string()) .unwrap_or_else(|| agent_command.to_string()); + let runtime_meta = known_acp_runtime(agent_command); let mut derived_env = BTreeMap::new(); - if let Some(meta) = known_acp_runtime(agent_command) { + if let Some(meta) = runtime_meta { let provider = input .provider .as_deref() @@ -220,6 +247,13 @@ pub async fn discover_agent_models( } let merged_env = crate::managed_agents::merged_user_env(&derived_env, &input.env_vars); let merged_env = discovery_env_with_baked_floor(merged_env); + // Recover a build-provided provider when the form has none, so the create + // dialog discovers live models instead of falling through to the subprocess. + let effective_provider = effective_discovery_provider( + input.provider.as_deref(), + runtime_meta.and_then(|meta| meta.provider_env_var), + &merged_env, + ); // Buzz shared compute discovery must not depend on the local OpenAI ingress: that // client endpoint is started only after a live target is selected. @@ -268,7 +302,7 @@ pub async fn discover_agent_models( if let Some(models) = discover_openai_compatible_models( &state.http_client, - input.provider.as_deref(), + &effective_provider, &merged_env, None, ) @@ -277,24 +311,16 @@ pub async fn discover_agent_models( return Ok(models); } - if let Some(models) = discover_anthropic_models( - &state.http_client, - input.provider.as_deref(), - &merged_env, - None, - ) - .await? + if let Some(models) = + discover_anthropic_models(&state.http_client, &effective_provider, &merged_env, None) + .await? { return Ok(models); } - if let Some(models) = discover_databricks_models( - &state.http_client, - input.provider.as_deref(), - &merged_env, - None, - ) - .await? + if let Some(models) = + discover_databricks_models(&state.http_client, &effective_provider, &merged_env, None) + .await? { return Ok(models); } @@ -337,33 +363,6 @@ fn openai_compatible_models_url_for_discovery(env: &BTreeMap) -> format!("{}/models", base_url.trim_end_matches('/')) } -fn env_value(env: &BTreeMap, key: &str) -> Option { - env.get(key) - .map(String::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - -fn env_or_process_value(env: &BTreeMap, key: &str) -> Option { - env_value(env, key).or_else(|| { - std::env::var(key) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - }) -} - -fn redaction_env_with_value( - env: &BTreeMap, - key: &str, - value: &str, -) -> BTreeMap { - let mut redaction_env = env.clone(); - redaction_env.insert(key.to_string(), value.to_string()); - redaction_env -} - fn is_agent_text_model_id(id: &str) -> bool { let lower = id.to_ascii_lowercase(); if [ @@ -482,20 +481,23 @@ fn normalize_openai_compatible_models( async fn discover_openai_compatible_models( client: &reqwest::Client, - provider: Option<&str>, + provider: &DiscoveryProvider, env: &BTreeMap, selected_model: Option, ) -> Result, String> { - let relay_mesh = provider.map(str::trim) == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID); - if !relay_mesh && !is_openai_compatible_provider(provider) { + let relay_mesh = + provider.as_deref().map(str::trim) == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID); + if !relay_mesh && !is_openai_compatible_provider(provider.as_deref()) { return Ok(None); } let api_key = if relay_mesh { crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER.to_string() } else { - env_or_process_value(env, "OPENAI_COMPAT_API_KEY") - .ok_or_else(|| "config: OPENAI_COMPAT_API_KEY required".to_string())? + match provider.required_env(env, "OPENAI_COMPAT_API_KEY")? { + Some(api_key) => api_key, + None => return Ok(None), + } }; let redaction_env = redaction_env_with_value(env, "OPENAI_COMPAT_API_KEY", &api_key); let url = if relay_mesh { @@ -520,13 +522,13 @@ async fn discover_openai_compatible_models( .json::() .await .map_err(|error| format!("OpenAI model discovery response parse failed: {error}"))?; - let models = normalize_openai_compatible_models(response, provider); + let models = normalize_openai_compatible_models(response, provider.as_deref()); if models.is_empty() { return Err("OpenAI model discovery returned no compatible text models".to_string()); } Ok(Some(AgentModelsResponse { - agent_name: provider.unwrap_or("openai").trim().to_string(), + agent_name: provider.as_deref().unwrap_or("openai").trim().to_string(), agent_version: "models-api".to_string(), models, agent_default_model: None, @@ -631,16 +633,18 @@ async fn fetch_anthropic_model_page( async fn discover_anthropic_models( client: &reqwest::Client, - provider: Option<&str>, + provider: &DiscoveryProvider, env: &BTreeMap, selected_model: Option, ) -> Result, String> { - if !is_anthropic_provider(provider) { + if !is_anthropic_provider(provider.as_deref()) { return Ok(None); } - let api_key = env_or_process_value(env, "ANTHROPIC_API_KEY") - .ok_or_else(|| "config: ANTHROPIC_API_KEY required".to_string())?; + let api_key = match provider.required_env(env, "ANTHROPIC_API_KEY")? { + Some(api_key) => api_key, + None => return Ok(None), + }; let redaction_env = redaction_env_with_value(env, "ANTHROPIC_API_KEY", &api_key); let url = anthropic_models_url_for_discovery(env); let mut models = Vec::new(); @@ -666,7 +670,11 @@ async fn discover_anthropic_models( } Ok(Some(AgentModelsResponse { - agent_name: provider.unwrap_or("anthropic").trim().to_string(), + agent_name: provider + .as_deref() + .unwrap_or("anthropic") + .trim() + .to_string(), agent_version: "models-api".to_string(), models, agent_default_model: None, @@ -708,11 +716,11 @@ fn databricks_agent_provider(provider: &str) -> buzz_agent_pkg::config::Provider async fn discover_databricks_models( _client: &reqwest::Client, - provider: Option<&str>, + provider: &DiscoveryProvider, env: &BTreeMap, selected_model: Option, ) -> Result, String> { - let provider_str = match provider { + let provider_str = match provider.as_deref() { Some(p) if is_databricks_provider(Some(p)) => p, _ => return Ok(None), }; diff --git a/desktop/src-tauri/src/commands/agent_models_env.rs b/desktop/src-tauri/src/commands/agent_models_env.rs new file mode 100644 index 0000000000..0a40b6bd8f --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_env.rs @@ -0,0 +1,114 @@ +//! Env lookups shared by the in-process model discovery paths. +//! +//! Discovery reads configuration from a merged env map (record/form env over the +//! runtime-derived env, under the baked build floor) and falls back to the +//! process env for values a GUI launch never wrote into the map. + +use std::collections::BTreeMap; + +/// Read a non-blank value from the merged discovery env. +pub(super) fn env_value(env: &BTreeMap, key: &str) -> Option { + env.get(key) + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +/// Read a non-blank value from the merged discovery env, then the process env. +pub(super) fn env_or_process_value(env: &BTreeMap, key: &str) -> Option { + env_value(env, key).or_else(|| { + std::env::var(key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) +} + +/// Clone `env` with `key` set to the value a request actually used, so error +/// redaction masks the inherited process value and not just the mapped one. +pub(super) fn redaction_env_with_value( + env: &BTreeMap, + key: &str, + value: &str, +) -> BTreeMap { + let mut redaction_env = env.clone(); + redaction_env.insert(key.to_string(), value.to_string()); + redaction_env +} + +/// A provider resolved for discovery, plus how it was resolved. +/// +/// The distinction decides what a missing credential means. An explicit provider +/// is an assertion — the record or the dialog says this agent runs on Anthropic, +/// so a missing `ANTHROPIC_API_KEY` is a real misconfiguration and the user +/// should see it. An inferred provider is only a guess read off the environment, +/// and a wrong guess must not replace a working catalog with an error: a +/// `GOOSE_PROVIDER=anthropic` export (goose's documented way to pick a provider, +/// with the key in goose's own keyring rather than Buzz's env) would otherwise +/// turn the subprocess catalog into `config: ANTHROPIC_API_KEY required`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct DiscoveryProvider { + value: Option, + inferred: bool, +} + +impl DiscoveryProvider { + pub(super) fn as_deref(&self) -> Option<&str> { + self.value.as_deref() + } + + /// Read a credential the provider's discovery cannot run without. + /// + /// `Ok(None)` means "not configured, and the provider was only inferred" — + /// the caller must fall through to the `buzz-acp models` subprocess rather + /// than surface an error. + pub(super) fn required_env( + &self, + env: &BTreeMap, + key: &str, + ) -> Result, String> { + match env_or_process_value(env, key) { + Some(value) => Ok(Some(value)), + None if self.inferred => Ok(None), + None => Err(format!("config: {key} required")), + } + } +} + +/// Resolve the provider that live model discovery should run against. +/// +/// An explicit provider — the agent record's saved provider, or the create/edit +/// dialog's current form value — always wins. When there is none, fall back to +/// the provider the agent will actually launch with: the runtime's provider env +/// var read out of `env`, which by this point already carries the baked build +/// floor (see `discovery_env_with_baked_floor`) and the process env. +/// +/// Without this fallback, every provider gate sees `None` for an agent whose +/// record predates provider persistence, so no in-process discovery runs at all +/// — even on an internal build that bakes `BUZZ_AGENT_PROVIDER=databricks_v2` +/// and a `DATABRICKS_HOST`. Discovery then degrades to the `buzz-acp models` +/// subprocess, which on a Databricks failure path surfaces the small +/// known-models fallback catalog instead of the live gateway list — +/// indistinguishable, from the picker's side, from the real thing. +pub(super) fn effective_discovery_provider( + provider: Option<&str>, + provider_env_var: Option<&str>, + env: &BTreeMap, +) -> DiscoveryProvider { + if let Some(explicit) = provider + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + { + return DiscoveryProvider { + value: Some(explicit), + inferred: false, + }; + } + + DiscoveryProvider { + value: provider_env_var.and_then(|key| env_or_process_value(env, key)), + inferred: true, + } +} diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 0f99927c9e..0d6449e781 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -210,6 +210,135 @@ fn saved_agent_model_discovery_uses_record_snapshot() { Some("record-key") ); assert!(!config.env.contains_key("BUZZ_PRIVATE_KEY")); + assert_eq!(config.provider_env_var, Some("GOOSE_PROVIDER")); +} + +// --------------------------------------------------------------------------- +// Provider resolution for discovery +// --------------------------------------------------------------------------- + +#[test] +fn effective_discovery_provider_prefers_the_explicit_provider() { + let env = BTreeMap::from([( + "BUZZ_AGENT_PROVIDER".to_string(), + "databricks_v2".to_string(), + )]); + + // A saved/selected provider is a deliberate choice and must win over the + // build-provided default, so discovery matches what spawn will use. + assert_eq!( + effective_discovery_provider(Some("anthropic"), Some("BUZZ_AGENT_PROVIDER"), &env) + .as_deref(), + Some("anthropic") + ); +} + +#[test] +fn effective_discovery_provider_recovers_baked_provider_when_record_has_none() { + let env = BTreeMap::from([( + "BUZZ_AGENT_PROVIDER".to_string(), + "databricks_v2".to_string(), + )]); + + // The regression this guards: records predating provider persistence carry + // `provider: null`, so every discovery gate saw None and no live Databricks + // catalog was ever fetched on builds that bake the provider in. + for provider in [None, Some(""), Some(" ")] { + assert_eq!( + effective_discovery_provider(provider, Some("BUZZ_AGENT_PROVIDER"), &env).as_deref(), + Some("databricks_v2"), + "provider input {provider:?} must fall back to the env value" + ); + } +} + +#[test] +fn effective_discovery_provider_is_none_without_an_explicit_or_env_provider() { + let env = BTreeMap::new(); + assert_eq!( + effective_discovery_provider(None, Some("BUZZ_AGENT_PROVIDER"), &env).as_deref(), + None + ); + // A runtime that takes no provider env var has nothing to recover from. + assert_eq!( + effective_discovery_provider( + None, + None, + &BTreeMap::from([( + "BUZZ_AGENT_PROVIDER".to_string(), + "databricks_v2".to_string() + )]) + ) + .as_deref(), + None + ); +} + +/// A credential name no environment sets, so `required_env` is exercised without +/// depending on what the developer happens to have exported. +const UNSET_CREDENTIAL: &str = "BUZZ_TEST_UNSET_DISCOVERY_CREDENTIAL"; + +#[test] +fn env_derived_provider_falls_through_when_its_credential_is_missing() { + let env = BTreeMap::from([("GOOSE_PROVIDER".to_string(), "anthropic".to_string())]); + let inferred = effective_discovery_provider(None, Some("GOOSE_PROVIDER"), &env); + assert_eq!(inferred.as_deref(), Some("anthropic")); + + // `export GOOSE_PROVIDER=anthropic` is goose's documented way to pick a + // provider, and it keeps the API key in its own config/keyring rather than in + // Buzz's env — so the provider is visible here and the credential is not. + // Erroring would swap the working subprocess catalog for a hard + // "config: ... required" on exactly the null-provider records this fallback + // exists to serve; the gate has to decline instead. + assert_eq!(inferred.required_env(&env, UNSET_CREDENTIAL), Ok(None)); +} + +#[test] +fn explicit_provider_still_reports_a_missing_credential() { + // An explicit provider is an assertion about this agent, so a missing + // credential is a real misconfiguration and stays user-visible. + let env = BTreeMap::new(); + let explicit = effective_discovery_provider(Some("anthropic"), Some("GOOSE_PROVIDER"), &env); + assert_eq!( + explicit.required_env(&env, UNSET_CREDENTIAL), + Err(format!("config: {UNSET_CREDENTIAL} required")) + ); +} + +#[test] +fn required_env_returns_a_configured_credential_however_the_provider_was_resolved() { + let env = BTreeMap::from([ + ("GOOSE_PROVIDER".to_string(), "anthropic".to_string()), + ( + UNSET_CREDENTIAL.to_string(), + " sk-configured ".to_string(), + ), + ]); + for provider in [Some("anthropic"), None] { + let resolved = effective_discovery_provider(provider, Some("GOOSE_PROVIDER"), &env); + assert_eq!( + resolved.required_env(&env, UNSET_CREDENTIAL), + Ok(Some("sk-configured".to_string())), + "provider input {provider:?} must read the configured credential" + ); + } +} + +#[test] +fn effective_discovery_provider_reads_the_runtimes_own_env_var() { + // goose keys its provider off GOOSE_PROVIDER, so a BUZZ_AGENT_PROVIDER in + // the env must not be mistaken for this runtime's provider. + let env = BTreeMap::from([ + ("GOOSE_PROVIDER".to_string(), "databricks".to_string()), + ( + "BUZZ_AGENT_PROVIDER".to_string(), + "databricks_v2".to_string(), + ), + ]); + assert_eq!( + effective_discovery_provider(None, Some("GOOSE_PROVIDER"), &env).as_deref(), + Some("databricks") + ); } // --------------------------------------------------------------------------- diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 7e9d916be6..a048ad24af 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -5,6 +5,7 @@ mod agent_logs; mod agent_metric_archive; mod agent_model_process; mod agent_models; +mod agent_models_env; mod agent_providers; mod agent_settings; mod agent_update_rollback; From aa51dab9da5fef7054d03cf1a1207986d0000684 Mon Sep 17 00:00:00 2001 From: Mic Neale Date: Sun, 26 Jul 2026 12:13:51 +1000 Subject: [PATCH 006/128] fix(desktop): supervise and re-arm relay-mesh runtime (#2823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #2062 This carries forward the relay-mesh recovery work from #2304 by @Bartok9 (cherry-picked with original authorship/sign-offs) and adds the startup/readiness supervision and packaging fixes found while validating it against a real two-machine Buzz setup. ## From #2304 - Watch the local OpenAI ingress (`:9337`) after launch and re-arm a stale relay-mesh runtime. - Require consecutive failed probes before eviction so a transient inference stall does not cause a cold restart. - Bound stale-runtime shutdown, preserve runtime identity across the asynchronous probe, and never evict a concurrent replacement. - Re-arm only for agents that are actually running; deliberately stopped agents stay stopped. - Persist an actionable sentinel error under the managed-agent store lock and clear only that error after recovery. - Treat serve-to-client fallback as an intentional fail-safe; configured serve restoration remains on its existing path. ## Added here - Use the inference ingress (`:9337`), rather than management port `:3131`, as Buzz's client-readiness boundary. A usable client no longer fails or holds agent-save open merely because management startup is still pending. - Supervise the embedded SDK startup asynchronously, publish a pending status while management is unavailable, and keep its mesh identity alive. - Avoid racing a replacement while SDK startup still owns the embedded runtime. If that pending startup later loses ingress while a running agent still needs it, request a controlled Buzz restart to reclaim the otherwise-unreachable SDK thread. - Defer roster-driven replacement while client management startup is pending. - Keep post-launch recovery in a dedicated module so the mesh entry point remains within the desktop file-size gate. - Explicitly mark generated Unix sidecars executable. On macOS, copying over an existing non-executable destination preserved its old mode, causing packaged `buzz-acp`, `buzz-agent`, and tool sidecars to be reported as missing. ## Validation Automated: - `just ci` — passed, including formatting, Clippy with warnings denied, desktop/web/mobile checks and tests, and builds. - Full Tauri mesh-feature suite — 1,702 passed, 0 failed, 15 ignored. - Mesh-feature Clippy with `-D warnings` — passed. - Release macOS app bundle with `mesh-llm` — built successfully; every bundled sidecar passed executable-mode and deep code-signature verification. Live two-machine E2E: - M5: released Buzz serving `unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M`. - Mac mini: this branch's packaged Buzz running a saved `buzz-agent`/relay-mesh agent. - Confirmed `:9337` accepted inference and the saved ACP harness started with no error while `:3131` was still unavailable. - Exact inference succeeded before restart (`CORRECTED-MINI-E2E-OK`). - Bespoke Buzz shut down cleanly in 2.3s, then restored ingress and the saved harness in 18.8s while `:3131` was still unavailable. - Exact inference succeeded after restart (`AFTER-RESTART-E2E-OK`). - A real Buzz `@C55` message traversed desktop → `buzz-acp` → `buzz-agent` → mini `:9337` → M5 compute and published the requested reply successfully. --------- Signed-off-by: Bartok9 Signed-off-by: Michael Neale Co-authored-by: Bartok9 Co-authored-by: Michael Neale --- desktop/scripts/check-file-sizes.mjs | 5 +- desktop/src-tauri/src/app_state.rs | 4 + desktop/src-tauri/src/commands/mesh_llm.rs | 61 ++- desktop/src-tauri/src/mesh_llm/coordinator.rs | 42 +- desktop/src-tauri/src/mesh_llm/mod.rs | 270 +++++++++- desktop/src-tauri/src/mesh_llm/mod_tests.rs | 65 +++ desktop/src-tauri/src/mesh_llm/recovery.rs | 493 ++++++++++++++++++ scripts/bundle-sidecars.sh | 10 +- 8 files changed, 924 insertions(+), 26 deletions(-) create mode 100644 desktop/src-tauri/src/mesh_llm/recovery.rs diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index f34b6dc332..f2d3883794 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -400,7 +400,10 @@ const overrides = new Map([ // transition lock doc broadened to cover all protected-PID transitions, and // clear_agent_session_caches (per-pubkey retain) added alongside the // per-key clear. Load-bearing identity-contract change; queued to split. - ["src-tauri/src/app_state.rs", 1081], + // +4 (1081 -> 1085): mesh recovery keeps one app-scoped state object beside + // the embedded runtime and coordinator. Probe/re-arm logic lives in + // mesh_llm/recovery.rs rather than growing AppState or command modules. + ["src-tauri/src/app_state.rs", 1085], // multi-slot splitting + no-op suppression (#1309): the ReadStateManager // class grew from ~700 lines to ~1019 with the addition of // splitContextsIntoBudgetedSlots (pure fn + 5 tests), publishSplitSlots, diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 778bd5fb3f..94d162e620 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -108,6 +108,8 @@ pub struct AppState { /// In-process mesh-llm node started by Buzz Desktop. #[cfg(feature = "mesh-llm")] pub mesh_llm_runtime: AsyncMutex>, + #[cfg(feature = "mesh-llm")] + pub mesh_recovery: crate::mesh_llm::MeshRecoveryState, /// Runtime-owned shared-compute coordinator. It publishes member-signed /// discovery status and reconciles MeshLLM's admission roster; MeshLLM /// itself owns direct QUIC/iroh connection establishment. @@ -223,6 +225,8 @@ pub fn build_app_state() -> AppState { #[cfg(feature = "mesh-llm")] mesh_llm_runtime: AsyncMutex::new(None), #[cfg(feature = "mesh-llm")] + mesh_recovery: crate::mesh_llm::MeshRecoveryState::default(), + #[cfg(feature = "mesh-llm")] mesh_coordinator: AsyncMutex::new(None), pending_owned_channels: Mutex::new(std::collections::HashSet::new()), } diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 9a0a3c32eb..adcdeb7ec1 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -157,7 +157,7 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C }; let started = mesh_llm::DesktopMeshRuntime::start(request) .await - .map_err(|error| format!("failed to restore Share Compute: {error}"))?; + .map_err(|error| format!("failed to restore Share Compute: {error:#}"))?; *runtime = Some(started); drop(runtime); mesh_llm::publish_current_status_once(app, "restore").await; @@ -183,11 +183,11 @@ pub async fn mesh_start_node( let saved_request = request.clone(); let started = mesh_llm::DesktopMeshRuntime::start(request) .await - .map_err(|error| error.to_string())?; + .map_err(|error| format!("{error:#}"))?; let status = started .status() .await - .map_err(|error| format!("mesh node started but status probe failed: {error}"))?; + .map_err(|error| format!("mesh node started but status probe failed: {error:#}"))?; *runtime = Some(started); drop(runtime); if saved_request.mode == mesh_llm::MeshNodeMode::Serve { @@ -407,11 +407,11 @@ pub(crate) async fn ensure_client_node_for_model( } let started = mesh_llm::DesktopMeshRuntime::start(start) .await - .map_err(|error| format!("mesh client failed to start: {error}"))?; + .map_err(|error| format!("mesh client failed to start: {error:#}"))?; let status = started .status() .await - .map_err(|error| format!("mesh client started but status probe failed: {error}"))?; + .map_err(|error| format!("mesh client started but status probe failed: {error:#}"))?; *runtime = Some(started); Ok(status) } @@ -490,9 +490,47 @@ pub(crate) async fn ensure_relay_mesh_for_record( }; // A local serve/client runtime already owns the OpenAI ingress and its // router can resolve both `auto` and explicit remote models. Do not require - // a separate relay-advertised target in that case. + // a separate relay-advertised target in that case — BUT only trust it when + // the ingress is actually alive. A runtime that exited/wedged after launch + // leaves `mesh_llm_runtime = Some` pointing at a dead `:9337` ingress, so a + // blind `wait_for_mesh_inference` would just time out and the agent would + // stay silent (#2062). Probe first; if the ingress is dead, drop the stale + // runtime and fall through to re-arm it. The mesh coordinator watchdog also + // calls this path after eviction so recovery is not start-only (Brad #2304). if state.mesh_llm_runtime.lock().await.is_some() { - return wait_for_mesh_inference(&model_id).await; + match mesh_llm::recover_stale_mesh_runtime( + &state, + mesh_llm::MeshRecoveryUrgency::Foreground, + ) + .await + { + mesh_llm::MeshRuntimeRecovery::Live => { + return wait_for_mesh_inference(&model_id).await; + } + mesh_llm::MeshRuntimeRecovery::Evicted | mesh_llm::MeshRuntimeRecovery::Absent => {} + mesh_llm::MeshRuntimeRecovery::Debouncing => { + return Err( + "Buzz shared compute ingress is temporarily unresponsive; recovery is already scheduled. Try again shortly." + .to_string(), + ); + } + mesh_llm::MeshRuntimeRecovery::ReleasePending => { + return Err( + "Buzz shared compute is still shutting down its previous local ingress. Try again shortly." + .to_string(), + ); + } + mesh_llm::MeshRuntimeRecovery::Replaced => { + return wait_for_mesh_inference(&model_id).await; + } + mesh_llm::MeshRuntimeRecovery::RestartRequired => { + app.request_restart(); + return Err( + "Buzz shared compute startup lost its local ingress before shutdown control became available. Buzz is restarting to recover it." + .to_string(), + ); + } + } } let target = match resolve_mesh_bootstrap_target(&state, &model_id).await { Ok(Some(target)) => target, @@ -509,6 +547,15 @@ pub(crate) async fn ensure_relay_mesh_for_record( } }; + // Serve→Client re-arm transition (micspiral review #3, intentional-by-design): + // if the dead ingress belonged to a *serve* node with running consumer + // agents, this re-arms it as a Client (`MeshNodeMode::Client`). That is the + // correct/safe recovery here — config-backed serve restoration is + // `restore_mesh_sharing`'s job (`MeshNodeMode::Serve`), and + // `ensure_client_node_for_model` reuses any live runtime of *either* mode + // (the router resolves per-request), so it only cold-starts a Client when + // there is genuinely no runtime. Falling back to Client if a serve node + // crashed under local pressure is a desirable fail-safe, not a regression. ensure_client_node_for_model(&state, &model_id, Some(target.endpoint_addr)).await?; wait_for_mesh_inference(&model_id).await } diff --git a/desktop/src-tauri/src/mesh_llm/coordinator.rs b/desktop/src-tauri/src/mesh_llm/coordinator.rs index e83bf8b280..930149d0a5 100644 --- a/desktop/src-tauri/src/mesh_llm/coordinator.rs +++ b/desktop/src-tauri/src/mesh_llm/coordinator.rs @@ -22,10 +22,16 @@ const STATUS_D_TAG_PREFIX: &str = "buzz-mesh-member-status"; const ROSTER_POLL_INTERVAL: Duration = Duration::from_secs(60); const STATUS_PUBLISH_INTERVAL: Duration = Duration::from_secs(45); const STATUS_PUBLISH_TIMEOUT: Duration = Duration::from_secs(10); +/// Post-launch ingress liveness / re-arm for #2062. Bounded backoff: base 15s, +/// doubles after consecutive failures up to 120s so a sticky offline peer does +/// not hammer discovery every tick, but a recovered peer is noticed quickly. +const INGRESS_WATCHDOG_BASE: Duration = Duration::from_secs(15); +const INGRESS_WATCHDOG_MAX: Duration = Duration::from_secs(120); pub struct MeshCoordinator { _status_publisher: tokio::task::JoinHandle<()>, _roster_watcher: tokio::task::JoinHandle<()>, + _ingress_watchdog: tokio::task::JoinHandle<()>, } /// Start the runtime-owned status publisher and admission-roster watcher. @@ -63,16 +69,40 @@ pub async fn start_coordinator(app: AppHandle) { } }); + // Brad #2304 / #2062: ensure_relay_mesh_for_record only runs on explicit + // start + launch restore. After launch, local buzz-agent processes talk + // directly to :9337; there is no desktop "turn dispatch" hook. This + // watchdog is the post-launch seam: probe ingress, drop a zombie handle, + // re-arm via ensure_relay_mesh_for_record, surface last_error on failure. + let ingress_app = app.clone(); + let ingress_watchdog = tokio::spawn(async move { + let mut sleep_for = INGRESS_WATCHDOG_BASE; + loop { + tokio::time::sleep(sleep_for).await; + match crate::mesh_llm::rearm_relay_mesh_for_running_agents(&ingress_app).await { + Ok(()) => { + sleep_for = INGRESS_WATCHDOG_BASE; + } + Err(error) => { + eprintln!("buzz-mesh: ingress re-arm watchdog: {error}"); + sleep_for = (sleep_for * 2).min(INGRESS_WATCHDOG_MAX); + } + } + } + }); + let state = app.state::(); let mut guard = state.mesh_coordinator.lock().await; if guard.is_none() { *guard = Some(MeshCoordinator { _status_publisher: status_publisher, _roster_watcher: roster_watcher, + _ingress_watchdog: ingress_watchdog, }); } else { status_publisher.abort(); roster_watcher.abort(); + ingress_watchdog.abort(); } } @@ -182,6 +212,16 @@ async fn reconcile_roster( let mut request = current_request; request.trusted_owner_ids = Some(fresh); let mut guard = state.mesh_llm_runtime.lock().await; + let startup_pending = match guard.as_ref() { + Some(runtime) => runtime.is_starting().await, + None => false, + }; + if startup_pending { + eprintln!( + "buzz-mesh: membership roster changed while client management startup is pending; deferring restart" + ); + return Ok(()); + } let Some(running) = guard.take() else { return Ok(()); }; @@ -191,7 +231,7 @@ async fn reconcile_roster( } let replacement = crate::mesh_llm::DesktopMeshRuntime::start(request) .await - .map_err(|error| format!("mesh node restart after roster change failed: {error}"))?; + .map_err(|error| format!("mesh node restart after roster change failed: {error:#}"))?; *guard = Some(replacement); Ok(()) } diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index acece498c0..c041aa07a9 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -22,6 +22,13 @@ pub use identity::ensure_owner_identity; mod progress; pub use progress::install_progress_sink; +mod recovery; +pub use recovery::MeshRecoveryState; +pub(crate) use recovery::{ + rearm_relay_mesh_for_running_agents, recover_stale_mesh_runtime, MeshRecoveryUrgency, + MeshRuntimeRecovery, +}; + mod transport_policy; #[cfg(test)] use transport_policy::iroh_relay_mode_from; @@ -48,6 +55,17 @@ const MESH_IROH_RELAYS_ENV: &str = "BUZZ_MESH_IROH_RELAYS"; /// First model load can include a multi-GB download plus Metal warmup; the /// SDK default (30s) times out long before that. Matches mesh-console. const MESH_STARTUP_TIMEOUT: Duration = Duration::from_secs(180); +/// The pinned SDK defines startup readiness as the management API on `:3131`. +/// Buzz defines client readiness by the OpenAI ingress agents consume on +/// `:9337`, so it supervises client startup independently and gives the SDK a +/// long management deadline. A live ingress is therefore not torn down merely +/// because the optional management API is delayed; Buzz's ingress watchdog is +/// responsible for aborting and re-arming genuinely dead attempts. +const MESH_CLIENT_MANAGEMENT_TIMEOUT: Duration = Duration::from_secs(365 * 24 * 60 * 60); +/// Bound explicit and watchdog-driven shutdowns. `EmbeddedNodeHandle::stop` +/// sends the shutdown signal before awaiting the runtime thread, so dropping +/// that wait after the deadline still leaves a graceful shutdown in flight. +const MESH_STOP_TIMEOUT: Duration = Duration::from_secs(12); /// Sentinel model id meaning "let the mesh router pick". mesh-llm's OpenAI /// ingress auto-routes `"model": "auto"` to a context-compatible live target /// (`resolve_auto_routed_model`), so agents don't have to name a model and @@ -229,6 +247,7 @@ pub struct MeshServingUsage { impl MeshServingUsage { /// True when at least one request has been served for a non-local consumer. + #[cfg(test)] pub fn has_remote_consumers(&self) -> bool { self.remote_attempts > 0 || self.endpoint_attempts > 0 } @@ -286,9 +305,31 @@ pub fn stopped_status() -> MeshNodeStatus { } } +/// Monotonic id source so callers can compare runtime *identity* across an +/// `.await` point. The re-arm watchdog must not evict a fresh replacement that +/// a concurrent stop/start swapped in while the ingress probe was in flight +/// (Brad #2304 race), so it captures the id before probing and only evicts if +/// the same handle is still installed on lock reacquire. +static MESH_RUNTIME_ID_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +enum DesktopMeshHandle { + /// The pinned SDK does not return its handle until `:3131/api/status` is + /// available. Keep that wait off the agent-save path while Buzz supervises + /// the real `:9337` ingress independently. + Starting { + task: tokio::task::JoinHandle>, + queued_join_tokens: Vec, + }, + Ready(EmbeddedNodeHandle), + Failed(String), +} + pub struct DesktopMeshRuntime { - handle: EmbeddedNodeHandle, + id: u64, + handle: tokio::sync::Mutex, mode: MeshNodeMode, + api_base_url: String, + console_url: String, model_id: Option, model_name: Option, /// The request this node was started with. Kept so the coordinator can @@ -362,6 +403,8 @@ impl DesktopMeshRuntime { ensure_model_downloaded(model).await?; } } + let api_port = mesh_api_port()?; + let console_port = mesh_console_port()?; let handle = match request.mode { MeshNodeMode::Serve => { let model = model_id @@ -369,8 +412,8 @@ impl DesktopMeshRuntime { .ok_or_else(|| anyhow::anyhow!("modelId is required for serve mode"))?; let mut builder = serve::EmbeddedServeConfig::builder() .model(model) - .api_port(mesh_api_port()?) - .console_port(mesh_console_port()?) + .api_port(api_port) + .console_port(console_port) // No-leak invariants: never publish mesh presence, never // auto-discover other meshes, no public Nostr relays. // Iroh relays are transport-only and enabled by default @@ -405,17 +448,17 @@ impl DesktopMeshRuntime { .trust_policy(TrustPolicy::Allowlist) .trust_owners(owners); } - serve::start(builder.build()).await? + DesktopMeshHandle::Ready(serve::start(builder.build()).await?) } MeshNodeMode::Client => { let mut builder = client::EmbeddedClientConfig::builder() - .api_port(mesh_api_port()?) - .console_port(mesh_console_port()?) + .api_port(api_port) + .console_port(console_port) // Same no-leak invariants as serve mode above. .publish(false) .auto_join(false) .discovery_mode(MeshDiscoveryMode::Nostr) - .startup_timeout(MESH_STARTUP_TIMEOUT) + .startup_timeout(MESH_CLIENT_MANAGEMENT_TIMEOUT) .console_ui(true); builder = match iroh_relay_mode()? { IrohRelayMode::Disabled => builder.disable_iroh_relays(true), @@ -437,19 +480,33 @@ impl DesktopMeshRuntime { .trust_policy(TrustPolicy::Allowlist) .trust_owners(owners); } - client::start(builder.build()).await? + let config = builder.build(); + DesktopMeshHandle::Starting { + task: tokio::spawn(async move { client::start(config).await }), + queued_join_tokens: Vec::new(), + } } }; Ok(Self { - handle, + id: MESH_RUNTIME_ID_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + handle: tokio::sync::Mutex::new(handle), mode: request.mode, + api_base_url: format!("http://127.0.0.1:{api_port}/v1"), + console_url: format!("http://127.0.0.1:{console_port}"), model_id, model_name, start_request: request, }) } + /// Process-unique identity for this runtime instance. Used by the re-arm + /// watchdog to detect a concurrent handle swap across the ingress probe + /// `.await` so it never evicts a fresh replacement runtime (Brad #2304). + pub fn id(&self) -> u64 { + self.id + } + /// The request this node was started with (roster drift detection). pub fn start_request(&self) -> &StartMeshNodeRequest { &self.start_request @@ -463,13 +520,117 @@ impl DesktopMeshRuntime { self.mode } + async fn promote_finished_startup(handle: &mut DesktopMeshHandle) { + let startup_finished = matches!( + handle, + DesktopMeshHandle::Starting { task, .. } if task.is_finished() + ); + if !startup_finished { + return; + } + + let previous = std::mem::replace( + handle, + DesktopMeshHandle::Failed("mesh client startup state was unavailable".to_string()), + ); + let (task, queued_join_tokens) = match previous { + DesktopMeshHandle::Starting { + task, + queued_join_tokens, + } => (task, queued_join_tokens), + other => { + *handle = other; + return; + } + }; + *handle = match task.await { + Ok(Ok(ready)) => { + for token in queued_join_tokens { + if let Err(error) = ready.join_token(token).await { + eprintln!( + "buzz-mesh: failed to apply a dial request queued during startup: {error:#}" + ); + } + } + DesktopMeshHandle::Ready(ready) + } + Ok(Err(error)) => { + DesktopMeshHandle::Failed(format!("embedded mesh client startup failed: {error:#}")) + } + Err(error) if error.is_cancelled() => { + DesktopMeshHandle::Failed("embedded mesh client startup was cancelled".to_string()) + } + Err(error) => DesktopMeshHandle::Failed(format!( + "embedded mesh client startup task failed: {error}" + )), + }; + } + + pub async fn is_starting(&self) -> bool { + let mut handle = self.handle.lock().await; + Self::promote_finished_startup(&mut handle).await; + matches!(*handle, DesktopMeshHandle::Starting { .. }) + } + pub async fn status(&self) -> anyhow::Result { - let status = self.handle.status().await?; - self.status_from_sdk(status) + let mut handle = self.handle.lock().await; + Self::promote_finished_startup(&mut handle).await; + match &*handle { + DesktopMeshHandle::Ready(ready) => { + let status = ready.status().await; + drop(handle); + match status { + Ok(status) => self.status_from_sdk(status), + Err(error) + if self.mode == MeshNodeMode::Client + && recovery::mesh_ingress_is_live_at(&self.api_base_url).await => + { + Ok(self.ingress_only_client_status(&format!( + "management status is unavailable: {error:#}" + ))) + } + Err(error) => Err(error), + } + } + DesktopMeshHandle::Starting { .. } => { + drop(handle); + if recovery::mesh_ingress_is_live_at(&self.api_base_url).await { + Ok(self.ingress_only_client_status("management status is still starting")) + } else { + Ok(self.starting_client_status()) + } + } + DesktopMeshHandle::Failed(error) => { + let error = error.clone(); + Err(anyhow::anyhow!(error)) + } + } } pub async fn status_report_payload(&self) -> anyhow::Result { - let status = self.handle.status().await?; + let mut handle = self.handle.lock().await; + Self::promote_finished_startup(&mut handle).await; + let ready = match &*handle { + DesktopMeshHandle::Ready(ready) => ready, + DesktopMeshHandle::Starting { .. } if self.mode == MeshNodeMode::Client => { + // Consumer identities must keep publishing owner bindings while + // the SDK is still waiting for its management API. Serving + // peers derive their admission roster from these fresh member + // notes; withholding heartbeats here would make an otherwise + // working `:9337` client age out of the host's allowlist. + return Ok(serde_json::json!({ + "serveTargets": [], + "models": [], + })); + } + DesktopMeshHandle::Starting { .. } => { + anyhow::bail!("mesh management status is not ready") + } + DesktopMeshHandle::Failed(error) => { + anyhow::bail!("mesh runtime failed: {error}") + } + }; + let status = ready.status().await?; let mut payload = status.payload; enrich_status_payload_identity(&mut payload, status.invite_token.as_deref()); if let Ok(identity) = ensure_owner_identity() { @@ -504,18 +665,43 @@ impl DesktopMeshRuntime { /// Read-only host-side usage snapshot from the node's own runtime metrics. pub async fn serving_usage(&self) -> anyhow::Result { - let status = self.handle.status().await?; + let mut handle = self.handle.lock().await; + Self::promote_finished_startup(&mut handle).await; + let DesktopMeshHandle::Ready(ready) = &*handle else { + anyhow::bail!("mesh management status is not ready"); + }; + let status = ready.status().await?; Ok(serving_usage_from_payload(&status.payload)) } pub async fn dial_endpoint_addr(&self, endpoint_addr: impl Into) -> anyhow::Result<()> { let endpoint_addr = endpoint_addr.into(); let validated = validate_advertised_endpoint(&endpoint_addr)?; - self.handle.join_token(validated.join_token).await + let mut handle = self.handle.lock().await; + Self::promote_finished_startup(&mut handle).await; + match &mut *handle { + DesktopMeshHandle::Ready(ready) => ready.join_token(validated.join_token).await, + DesktopMeshHandle::Starting { + queued_join_tokens, .. + } => { + if !queued_join_tokens.contains(&validated.join_token) { + queued_join_tokens.push(validated.join_token); + } + Ok(()) + } + DesktopMeshHandle::Failed(error) => { + anyhow::bail!("cannot dial from failed mesh client: {error}") + } + } } pub async fn installed_models(&self) -> anyhow::Result> { - let status = self.handle.status().await?; + let mut handle = self.handle.lock().await; + Self::promote_finished_startup(&mut handle).await; + let DesktopMeshHandle::Ready(ready) = &*handle else { + anyhow::bail!("mesh management status is not ready"); + }; + let status = ready.status().await?; Ok(models_from_status_payload(Some(&status.payload))) } @@ -543,8 +729,60 @@ impl DesktopMeshRuntime { }) } + fn ingress_only_client_status(&self, reason: &str) -> MeshNodeStatus { + MeshNodeStatus { + state: MeshNodeState::Running, + mode: Some(MeshNodeMode::Client), + health: MeshHealth::degraded(format!("OpenAI ingress is live; {reason}")), + api_base_url: Some(self.api_base_url.clone()), + console_url: Some(self.console_url.clone()), + model_id: self.model_id.clone(), + model_name: self.model_name.clone(), + invite_token: None, + endpoint_id: None, + device_id: None, + device_name: None, + } + } + + fn starting_client_status(&self) -> MeshNodeStatus { + MeshNodeStatus { + state: MeshNodeState::Starting, + mode: Some(MeshNodeMode::Client), + health: MeshHealth::degraded("OpenAI ingress and management status are still starting"), + api_base_url: Some(self.api_base_url.clone()), + console_url: Some(self.console_url.clone()), + model_id: self.model_id.clone(), + model_name: self.model_name.clone(), + invite_token: None, + endpoint_id: None, + device_id: None, + device_name: None, + } + } + pub async fn stop(self) -> anyhow::Result<()> { - self.handle.stop().await + match self.handle.into_inner() { + DesktopMeshHandle::Ready(ready) => { + match tokio::time::timeout(MESH_STOP_TIMEOUT, ready.stop()).await { + Ok(result) => result, + Err(_) => anyhow::bail!( + "timed out after {}s waiting for embedded mesh runtime to stop", + MESH_STOP_TIMEOUT.as_secs() + ), + } + } + DesktopMeshHandle::Starting { task, .. } => { + // The pinned SDK has not exposed its shutdown handle yet. + // Abort only the Buzz-side waiter; normal callers never replace + // a pending runtime, and app shutdown/restart then terminates + // the process-owned embedded thread. Recovery requests that + // controlled restart instead of racing a second runtime. + task.abort(); + Ok(()) + } + DesktopMeshHandle::Failed(_) => Ok(()), + } } } diff --git a/desktop/src-tauri/src/mesh_llm/mod_tests.rs b/desktop/src-tauri/src/mesh_llm/mod_tests.rs index a8b78bc190..1f2c6abb44 100644 --- a/desktop/src-tauri/src/mesh_llm/mod_tests.rs +++ b/desktop/src-tauri/src/mesh_llm/mod_tests.rs @@ -3,6 +3,71 @@ use super::find_progressish_reason; use serde_json::json; +fn pending_client_runtime( + task: tokio::task::JoinHandle>, +) -> super::DesktopMeshRuntime { + let request = super::StartMeshNodeRequest { + mode: super::MeshNodeMode::Client, + model_id: None, + max_vram_gb: None, + join_token: Some("initial-token".to_string()), + trusted_owner_ids: None, + }; + super::DesktopMeshRuntime { + id: 7, + handle: tokio::sync::Mutex::new(super::DesktopMeshHandle::Starting { + task, + queued_join_tokens: Vec::new(), + }), + mode: super::MeshNodeMode::Client, + api_base_url: "http://127.0.0.1:1/v1".to_string(), + console_url: "http://127.0.0.1:2".to_string(), + model_id: None, + model_name: None, + start_request: request, + } +} + +#[tokio::test] +async fn pending_client_status_does_not_wait_for_management_timeout() { + let task = tokio::spawn(async { + std::future::pending::>().await + }); + let runtime = pending_client_runtime(task); + + let status = tokio::time::timeout(std::time::Duration::from_secs(1), runtime.status()) + .await + .expect("status should not wait for the SDK management probe") + .expect("pending client should have a synthetic status"); + assert_eq!(status.state, super::MeshNodeState::Starting); + assert_eq!(status.mode, Some(super::MeshNodeMode::Client)); + let report = runtime + .status_report_payload() + .await + .expect("pending clients must keep publishing admission identity heartbeats"); + assert_eq!(report["serveTargets"], json!([])); + assert_eq!(report["models"], json!([])); + + tokio::time::timeout(std::time::Duration::from_secs(1), runtime.stop()) + .await + .expect("stopping a pending client should abort its SDK task") + .expect("pending client stop should succeed"); +} + +#[tokio::test] +async fn failed_client_startup_is_promoted_without_losing_the_runtime_slot() { + let task = tokio::spawn(async { anyhow::bail!("controlled startup failure") }); + let runtime = pending_client_runtime(task); + tokio::task::yield_now().await; + + let error = runtime + .status() + .await + .expect_err("finished failed startup should be surfaced"); + assert!(error.to_string().contains("controlled startup failure")); + assert!(!runtime.is_starting().await); +} + #[test] fn progressish_reads_typed_phase_not_whole_tree() { assert_eq!( diff --git a/desktop/src-tauri/src/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs new file mode 100644 index 0000000000..29ed8a13fe --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -0,0 +1,493 @@ +use std::collections::HashSet; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::time::Duration; + +use tauri::{AppHandle, Manager}; + +use crate::app_state::AppState; + +const INGRESS_PROBE_TIMEOUT: Duration = Duration::from_secs(3); +const INGRESS_CONNECT_TIMEOUT: Duration = Duration::from_millis(300); +const INGRESS_RELEASE_TIMEOUT: Duration = Duration::from_secs(3); +const STALE_STOP_TIMEOUT: Duration = Duration::from_secs(12); +const DEAD_PROBE_EVICT_THRESHOLD: u32 = 2; + +/// Sentinel prefix on errors owned by this recovery path. Recovery clears only +/// these errors and never an unrelated agent failure. +pub(crate) const MESH_REARM_ERROR_SENTINEL: &str = "[buzz-mesh-rearm] "; + +/// App-scoped recovery coordination. The runtime id binds a dead-probe streak +/// to one specific handle, while `rearm_lock` prevents overlapping watchdog +/// passes from starting competing replacements. +pub struct MeshRecoveryState { + probe_runtime_id: AtomicU64, + dead_probes: AtomicU32, + rearm_lock: tokio::sync::Mutex<()>, +} + +impl Default for MeshRecoveryState { + fn default() -> Self { + Self { + probe_runtime_id: AtomicU64::new(0), + dead_probes: AtomicU32::new(0), + rearm_lock: tokio::sync::Mutex::new(()), + } + } +} + +impl MeshRecoveryState { + fn reset_probe_streak(&self) { + self.probe_runtime_id.store(0, Ordering::Relaxed); + self.dead_probes.store(0, Ordering::Relaxed); + } + + fn record_dead_probe(&self, runtime_id: u64) -> u32 { + if self.probe_runtime_id.swap(runtime_id, Ordering::Relaxed) != runtime_id { + self.dead_probes.store(1, Ordering::Relaxed); + return 1; + } + self.dead_probes.fetch_add(1, Ordering::Relaxed) + 1 + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum MeshIngressProbe { + Live, + PortClosed, + Unhealthy, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum MeshRecoveryUrgency { + Foreground, + Watchdog, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum MeshRuntimeRecovery { + Absent, + Live, + Debouncing, + Evicted, + ReleasePending, + Replaced, + RestartRequired, +} + +/// Probe the contract agents actually consume. A successful `/v1/models` +/// response proves the local OpenAI ingress is accepting requests; the +/// management console on `:3131` is intentionally not part of this health +/// decision. +pub(crate) async fn probe_mesh_ingress() -> MeshIngressProbe { + probe_mesh_ingress_at(crate::managed_agents::RELAY_MESH_API_BASE_URL).await +} + +pub(crate) async fn mesh_ingress_is_live_at(api_base_url: &str) -> bool { + probe_mesh_ingress_at(api_base_url).await == MeshIngressProbe::Live +} + +pub(crate) async fn probe_mesh_ingress_at(api_base_url: &str) -> MeshIngressProbe { + let base = api_base_url.trim_end_matches('/'); + let client = match reqwest::Client::builder() + .timeout(INGRESS_PROBE_TIMEOUT) + .build() + { + Ok(client) => client, + Err(_) => return MeshIngressProbe::Unhealthy, + }; + if client + .get(format!("{base}/models")) + .bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER) + .send() + .await + .is_ok_and(|response| response.status().is_success()) + { + return MeshIngressProbe::Live; + } + if ingress_port_is_bound(api_base_url).await { + MeshIngressProbe::Unhealthy + } else { + MeshIngressProbe::PortClosed + } +} + +async fn ingress_port_is_bound(api_base_url: &str) -> bool { + let Ok(url) = url::Url::parse(api_base_url) else { + return false; + }; + let Some(host) = url.host_str() else { + return false; + }; + let Some(port) = url.port_or_known_default() else { + return false; + }; + matches!( + tokio::time::timeout( + INGRESS_CONNECT_TIMEOUT, + tokio::net::TcpStream::connect((host, port)), + ) + .await, + Ok(Ok(_)) + ) +} + +async fn wait_for_ingress_release() -> bool { + let deadline = tokio::time::Instant::now() + INGRESS_RELEASE_TIMEOUT; + loop { + if !ingress_port_is_bound(crate::managed_agents::RELAY_MESH_API_BASE_URL).await { + return true; + } + if tokio::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +fn should_evict_after_probe( + urgency: MeshRecoveryUrgency, + probe: MeshIngressProbe, + consecutive: u32, +) -> bool { + urgency == MeshRecoveryUrgency::Foreground && probe == MeshIngressProbe::PortClosed + || consecutive >= DEAD_PROBE_EVICT_THRESHOLD +} + +/// Probe and, when justified, remove one stale runtime. A closed port is +/// decisive for a foreground agent start; watchdog and ambiguous/unhealthy +/// ports require consecutive failures to avoid restarting on a transient load +/// spike. +pub(crate) async fn recover_stale_mesh_runtime( + state: &AppState, + urgency: MeshRecoveryUrgency, +) -> MeshRuntimeRecovery { + let (candidate_id, startup_in_progress) = match state.mesh_llm_runtime.lock().await.as_ref() { + Some(runtime) => (runtime.id(), runtime.is_starting().await), + None => { + state.mesh_recovery.reset_probe_streak(); + // A cancelled SDK startup can outlive its Buzz-side task briefly + // because the embedded runtime runs on its own thread. Never start + // a replacement merely because the tracked handle is gone: first + // prove the old ingress is either still useful or has released the + // port. This closes the port-conflict loop in #2304. + return match probe_mesh_ingress().await { + MeshIngressProbe::Live => MeshRuntimeRecovery::Live, + MeshIngressProbe::PortClosed => MeshRuntimeRecovery::Absent, + MeshIngressProbe::Unhealthy => MeshRuntimeRecovery::ReleasePending, + }; + } + }; + let probe = probe_mesh_ingress().await; + if probe == MeshIngressProbe::Live { + state.mesh_recovery.reset_probe_streak(); + return MeshRuntimeRecovery::Live; + } + let consecutive = state.mesh_recovery.record_dead_probe(candidate_id); + if startup_in_progress && consecutive < DEAD_PROBE_EVICT_THRESHOLD { + eprintln!( + "buzz-mesh: ingress is not live on the first startup probe; allowing the supervised SDK task one watchdog interval" + ); + return MeshRuntimeRecovery::Debouncing; + } + if !should_evict_after_probe(urgency, probe, consecutive) { + eprintln!( + "buzz-mesh: ingress probe {probe:?} ({consecutive}/{DEAD_PROBE_EVICT_THRESHOLD}); debouncing" + ); + return MeshRuntimeRecovery::Debouncing; + } + + // The pinned SDK does not yield its control handle until the management + // API is ready. Dropping its still-pending start future would detach the + // embedded runtime thread without sending a shutdown request, so Buzz must + // not evict it and race a replacement onto the same ports. A controlled + // app relaunch is the only process-owned cleanup boundary in this state. + if startup_in_progress { + state.mesh_recovery.reset_probe_streak(); + return MeshRuntimeRecovery::RestartRequired; + } + + let stale = { + let mut guard = state.mesh_llm_runtime.lock().await; + if guard.as_ref().map(|runtime| runtime.id()) != Some(candidate_id) { + state.mesh_recovery.reset_probe_streak(); + return MeshRuntimeRecovery::Replaced; + } + guard.take() + }; + let Some(stale) = stale else { + return MeshRuntimeRecovery::Replaced; + }; + state.mesh_recovery.reset_probe_streak(); + + match tokio::time::timeout(STALE_STOP_TIMEOUT, stale.stop()).await { + Ok(Ok(())) => {} + Ok(Err(error)) => eprintln!("buzz-mesh: stale runtime stop failed: {error:#}"), + Err(_) => eprintln!( + "buzz-mesh: stale runtime stop exceeded {}s; waiting for port release", + STALE_STOP_TIMEOUT.as_secs() + ), + } + if wait_for_ingress_release().await { + MeshRuntimeRecovery::Evicted + } else { + eprintln!( + "buzz-mesh: old runtime still owns the local ingress; deferring replacement to avoid a port-conflict loop" + ); + MeshRuntimeRecovery::ReleasePending + } +} + +/// Post-launch recovery for actively running relay-mesh agents. +pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Result<(), String> { + let state = app.state::(); + let _rearm_guard = state.mesh_recovery.rearm_lock.lock().await; + let recovery = recover_stale_mesh_runtime(&state, MeshRecoveryUrgency::Watchdog).await; + let active_pubkeys = active_managed_agent_pubkeys(&state); + + match recovery { + MeshRuntimeRecovery::Live + | MeshRuntimeRecovery::Debouncing + | MeshRuntimeRecovery::Replaced => return Ok(()), + MeshRuntimeRecovery::RestartRequired => { + let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default(); + if !records + .iter() + .any(|record| is_running_relay_mesh_agent(record, &active_pubkeys)) + { + // A foreground save may still be bringing up its first ingress. + // Only an already-running consumer justifies an automatic app + // relaunch from the background watchdog. + return Ok(()); + } + eprintln!( + "buzz-mesh: supervised client startup lost its ingress before the SDK exposed a shutdown handle; restarting Buzz" + ); + app.request_restart(); + return Ok(()); + } + MeshRuntimeRecovery::ReleasePending => { + return Err(format!( + "{MESH_REARM_ERROR_SENTINEL}old local mesh ingress is still shutting down" + )); + } + MeshRuntimeRecovery::Absent => { + let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default(); + if !records + .iter() + .any(|record| is_running_relay_mesh_agent(record, &active_pubkeys)) + { + return Ok(()); + } + } + MeshRuntimeRecovery::Evicted => {} + } + + let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default(); + let mesh_records: Vec<_> = records + .into_iter() + .filter(|record| is_running_relay_mesh_agent(record, &active_pubkeys)) + .collect(); + let mut first_error = None; + for record in &mesh_records { + match crate::commands::mesh_llm::ensure_relay_mesh_for_record(app, record, false).await { + Ok(()) => { + if let Err(error) = clear_mesh_last_error_if_set(app, &record.pubkey) { + eprintln!("buzz-mesh: failed to clear recovery error: {error}"); + } + } + Err(error) => { + let message = format!( + "{MESH_REARM_ERROR_SENTINEL}Buzz shared compute offline — failed to re-arm local ingress for this agent: {error}" + ); + if let Err(persist_error) = persist_mesh_last_error(app, &record.pubkey, &message) { + eprintln!("buzz-mesh: failed to persist recovery error: {persist_error}"); + } + first_error.get_or_insert(message); + } + } + } + first_error.map_or(Ok(()), Err) +} + +fn active_managed_agent_pubkeys(state: &AppState) -> HashSet { + state + .managed_agent_processes + .lock() + .map(|guard| { + guard + .keys() + .map(|key| key.pubkey.to_ascii_lowercase()) + .collect() + }) + .unwrap_or_default() +} + +fn is_running_relay_mesh_agent( + record: &crate::managed_agents::ManagedAgentRecord, + active_pubkeys: &HashSet, +) -> bool { + record.backend == crate::managed_agents::BackendKind::Local + && crate::managed_agents::relay_mesh_model_id(record).is_some() + && active_pubkeys.contains(&record.pubkey.to_ascii_lowercase()) + && record + .runtime_pid + .is_none_or(crate::managed_agents::process_is_running) +} + +fn persist_mesh_last_error(app: &AppHandle, pubkey: &str, error: &str) -> Result<(), String> { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| format!("failed to acquire managed agents store lock: {e}"))?; + let mut records = crate::managed_agents::load_managed_agents(app)?; + let record = crate::managed_agents::find_managed_agent_mut(&mut records, pubkey)?; + record.last_error = Some(error.to_string()); + record.updated_at = crate::util::now_iso(); + crate::managed_agents::save_managed_agents(app, &records) +} + +fn clear_mesh_last_error_if_set(app: &AppHandle, pubkey: &str) -> Result<(), String> { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| format!("failed to acquire managed agents store lock: {e}"))?; + let mut records = crate::managed_agents::load_managed_agents(app)?; + let record = crate::managed_agents::find_managed_agent_mut(&mut records, pubkey)?; + if !record + .last_error + .as_deref() + .is_some_and(|error| error.starts_with(MESH_REARM_ERROR_SENTINEL)) + { + return Ok(()); + } + record.last_error = None; + record.updated_at = crate::util::now_iso(); + crate::managed_agents::save_managed_agents(app, &records) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mesh_record( + pubkey: &str, + runtime_pid: Option, + ) -> crate::managed_agents::ManagedAgentRecord { + let mut record = crate::managed_agents::AgentDefinition { + id: pubkey.to_string(), + display_name: pubkey.to_string(), + avatar_url: None, + system_prompt: String::new(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: std::collections::BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1/".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ), + ]), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + } + .into_agent_record(); + record.pubkey = pubkey.to_string(); + record.backend = crate::managed_agents::BackendKind::Local; + record.runtime_pid = runtime_pid; + record + } + + fn active_set(pubkeys: &[&str]) -> HashSet { + pubkeys + .iter() + .map(|pubkey| pubkey.to_ascii_lowercase()) + .collect() + } + + #[tokio::test] + async fn closed_port_is_distinct_from_unhealthy_bound_port() { + assert_eq!( + probe_mesh_ingress_at("http://127.0.0.1:1/v1").await, + MeshIngressProbe::PortClosed + ); + } + + #[test] + fn foreground_closed_port_evicts_without_debounce() { + assert!(should_evict_after_probe( + MeshRecoveryUrgency::Foreground, + MeshIngressProbe::PortClosed, + 1 + )); + assert!(!should_evict_after_probe( + MeshRecoveryUrgency::Watchdog, + MeshIngressProbe::PortClosed, + 1 + )); + } + + #[test] + fn probe_streak_is_scoped_to_runtime_identity() { + let state = MeshRecoveryState::default(); + assert_eq!(state.record_dead_probe(7), 1); + assert_eq!(state.record_dead_probe(7), 2); + assert_eq!(state.record_dead_probe(8), 1); + } + + #[test] + fn explicit_stop_budget_accommodates_sdk_forced_shutdown() { + assert!(STALE_STOP_TIMEOUT >= Duration::from_secs(10)); + assert!(STALE_STOP_TIMEOUT <= Duration::from_secs(15)); + } + + #[test] + fn only_running_relay_mesh_agents_trigger_rearm() { + let empty = active_set(&[]); + assert!(!is_running_relay_mesh_agent( + &mesh_record("stopped", Some(std::process::id())), + &empty + )); + + let active = active_set(&["live"]); + assert!(is_running_relay_mesh_agent( + &mesh_record("live", Some(std::process::id())), + &active + )); + assert!(is_running_relay_mesh_agent( + &mesh_record("live", None), + &active + )); + + let mut non_mesh = mesh_record("plain", Some(std::process::id())); + non_mesh.env_vars.clear(); + non_mesh.relay_mesh = None; + assert!(!is_running_relay_mesh_agent( + &non_mesh, + &active_set(&["plain"]) + )); + } + + #[test] + fn recovery_error_sentinel_does_not_match_unrelated_errors() { + assert!( + format!("{MESH_REARM_ERROR_SENTINEL}offline").starts_with(MESH_REARM_ERROR_SENTINEL) + ); + assert!(!"user note: shared compute config".starts_with(MESH_REARM_ERROR_SENTINEL)); + } +} diff --git a/scripts/bundle-sidecars.sh b/scripts/bundle-sidecars.sh index be37cbce0d..07de477405 100755 --- a/scripts/bundle-sidecars.sh +++ b/scripts/bundle-sidecars.sh @@ -35,6 +35,14 @@ fi mkdir -p "$BINARIES_DIR" for bin in "${SIDECARS[@]}"; do - cp "$SRC_DIR/${bin}${EXE}" "$BINARIES_DIR/${bin}-${TARGET}${EXE}" + destination="$BINARIES_DIR/${bin}-${TARGET}${EXE}" + cp "$SRC_DIR/${bin}${EXE}" "$destination" + + # cp preserves the mode of an existing destination on macOS. Generated + # sidecar placeholders may not be executable, so make the bundled Unix + # binaries executable explicitly. + if [[ -z "$EXE" ]]; then + chmod 755 "$destination" + fi done echo "Sidecars bundled for $TARGET" From ab7aa8b1200710dbc2d7a8661ed5aab95c4199c1 Mon Sep 17 00:00:00 2001 From: Shawn Yeager Date: Sat, 25 Jul 2026 21:29:50 -0500 Subject: [PATCH 007/128] fix(desktop): enable arboard Wayland backend so Linux copies reach the Wayland clipboard (#2904) Fixes #2896 **Root cause:** `arboard = "3"` compiles with default features only (`image-data`), so the Linux build has no Wayland backend. In a Wayland session every copy lands on the XWayland clipboard; the compositor only mirrors X11 selections while an XWayland window holds focus, and arboard's clipboard client is windowless, so Wayland-native apps read nothing. `set_text` still returns `Ok`, which is why the invite dialog shows "Copied" with an empty clipboard. **Fix:** enable arboard's `wayland-data-control` feature. Under Wayland it uses the `zwlr-data-control` protocol; when `WAYLAND_DISPLAY` is absent it falls back to X11 as before. The `wl-clipboard-rs` dependency is target-gated inside arboard, so macOS and Windows builds are unchanged. **Verified:** `cargo check` passes on the desktop crate; wl-clipboard-rs now resolves in the lockfile. Signed-off-by: Shawn Yeager --- desktop/src-tauri/Cargo.lock | 127 ++++++++++++++++++++++++++++++++++- desktop/src-tauri/Cargo.toml | 6 +- 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 303c5eb1fa..080584c17f 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -227,6 +227,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -2406,6 +2407,12 @@ dependencies = [ "tendril", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -5835,6 +5842,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "noq" version = "1.0.1" @@ -6674,6 +6690,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.45.0", +] + [[package]] name = "os_str_bytes" version = "6.6.1" @@ -10157,7 +10183,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", - "nom", + "nom 7.1.3", "phf 0.11.3", "phf_codegen 0.11.3", ] @@ -10849,6 +10875,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -11403,6 +11440,76 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.0", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.103" @@ -12277,6 +12384,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "wmi" version = "0.18.4" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 638f0739da..0ed7417333 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -107,7 +107,11 @@ tauri-plugin-global-shortcut = "2" tauri-plugin-notification = "2.3.3" uuid = { version = "1", features = ["v4", "v5"] } png = "0.18" -arboard = "3" +# wayland-data-control: without it arboard is X11-only on Linux, so copies made +# in a Wayland session land in XWayland's clipboard where Wayland-native apps +# never see them (set_text still returns Ok). The backing wl-clipboard-rs dep is +# target-gated inside arboard; at runtime X11 sessions still fall back to X11. +arboard = { version = "3", features = ["wayland-data-control"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif"] } zip = "8" flate2 = "1" From 5d1233e841b0efa91470bb45467b2c8e4284ebf6 Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 25 Jul 2026 19:46:07 -0700 Subject: [PATCH 008/128] refactor(desktop): remove Agent directory section from Agents page (#2290) --- desktop/src/features/agents/ui/AgentsView.tsx | 12 -- .../agents/ui/RelayDirectorySection.tsx | 159 ------------------ 2 files changed, 171 deletions(-) delete mode 100644 desktop/src/features/agents/ui/RelayDirectorySection.tsx diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 77d9e82060..8e1c47c615 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -16,7 +16,6 @@ import { AgentSnapshotImportDialog } from "./AgentSnapshotImportDialog"; import { TeamSnapshotExportDialog } from "./TeamSnapshotExportDialog"; import { TeamSnapshotImportDialog } from "./TeamSnapshotImportDialog"; import { TeamShareDialog } from "./TeamShareDialog"; -import { RelayDirectorySection } from "./RelayDirectorySection"; import { SecretRevealDialog } from "./SecretRevealDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; @@ -227,17 +226,6 @@ export function AgentsView() { personas={personas.libraryPersonas} teams={teamActions.teams} /> - - diff --git a/desktop/src/features/agents/ui/RelayDirectorySection.tsx b/desktop/src/features/agents/ui/RelayDirectorySection.tsx deleted file mode 100644 index ca4fe58030..0000000000 --- a/desktop/src/features/agents/ui/RelayDirectorySection.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import * as React from "react"; -import { ChevronDown, ChevronRight, Search } from "lucide-react"; - -import type { RelayAgent } from "@/shared/api/types"; -import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; -import { Card } from "@/shared/ui/card"; -import { Input } from "@/shared/ui/input"; -import { truncatePubkey } from "@/shared/lib/pubkey"; - -export function RelayDirectorySection({ - error, - isLoading, - managedPubkeys, - relayAgents, -}: { - error: Error | null; - isLoading: boolean; - managedPubkeys: Set; - relayAgents: RelayAgent[]; -}) { - const [isExpanded, setIsExpanded] = React.useState(false); - const [searchQuery, setSearchQuery] = React.useState(""); - - // Only show agents that are NOT managed locally — those are already in the - // managed agents section above. - const otherAgents = React.useMemo( - () => relayAgents.filter((agent) => !managedPubkeys.has(agent.pubkey)), - [relayAgents, managedPubkeys], - ); - - const filteredAgents = React.useMemo(() => { - if (!searchQuery.trim()) return otherAgents; - const query = searchQuery.toLowerCase(); - return otherAgents.filter( - (agent) => - agent.name.toLowerCase().includes(query) || - agent.agentType.toLowerCase().includes(query) || - agent.channels.some((ch) => ch.toLowerCase().includes(query)), - ); - }, [otherAgents, searchQuery]); - - const sortedAgents = React.useMemo( - () => - [...filteredAgents].sort((left, right) => - left.name.localeCompare(right.name), - ), - [filteredAgents], - ); - - if (isLoading || otherAgents.length === 0) return null; - - return ( -
-
- -

- View agents other members have shared in this community. -

-
- - {isExpanded ? ( - <> -
- - setSearchQuery(event.target.value)} - placeholder="Search by name, type, or channel..." - value={searchQuery} - /> -
- - {sortedAgents.length === 0 ? ( -

- {searchQuery.trim() - ? "No agents match your search." - : "No other agents in this community."} -

- ) : ( - -
- - - - - - - - - - - {sortedAgents.map((agent) => ( - - - - - - - ))} - -
AgentStatusTypeChannels
-
-

- {agent.name} -

-

- {truncatePubkey(agent.pubkey)} -

-
-
- - - {agent.agentType || "Unknown"} - - - {agent.channels.length > 0 - ? agent.channels.join(", ") - : "No visible channel memberships"} - -
-
-
- )} - - ) : null} - - {error ? ( -

- {error.message} -

- ) : null} -
- ); -} From cc6c4d3471629fad018bcf645f9471a01b9ffe2f Mon Sep 17 00:00:00 2001 From: Daniel Cadenas Date: Sat, 25 Jul 2026 23:49:30 -0300 Subject: [PATCH 009/128] fix(desktop): make Linux AppImage GStreamer work on non-Debian distros (#2176) --- .github/workflows/release.yml | 2 +- desktop/scripts/fix-appimage.sh | 103 +++++++++++++----- .../src-tauri/src/managed_agents/runtime.rs | 9 +- .../src/managed_agents/runtime/tests.rs | 7 ++ 4 files changed, 92 insertions(+), 29 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d3e20c393d..c613924e57 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -629,7 +629,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - - name: Fix AppImage (remove infra libs, symlink system GStreamer) + - name: Fix AppImage (remove infra libs, shim host GStreamer) run: | mapfile -t APPIMAGES < <(find desktop/src-tauri/target/release/bundle/appimage -name '*.AppImage' -type f) if [[ ${#APPIMAGES[@]} -eq 0 ]]; then diff --git a/desktop/scripts/fix-appimage.sh b/desktop/scripts/fix-appimage.sh index 45f537bb72..c51b85723a 100755 --- a/desktop/scripts/fix-appimage.sh +++ b/desktop/scripts/fix-appimage.sh @@ -19,12 +19,19 @@ # skew causes eglGetDisplay to return EGL_BAD_PARAMETER under Wayland, which # WebKitWebProcess treats as fatal and aborts before the window ever appears. # -# 2. GStreamer crash: linuxdeploy also bundles libgst*.so* (GStreamer core libs). -# AppRun unconditionally sets GST_PLUGIN_SYSTEM_PATH_1_0 to a dir inside the -# AppImage that the bundler never populates (bundleMediaFramework is false by -# default), so GStreamer's plugin discovery yields an empty registry. The -# "GStreamer element appsink not found" error kills the render process; as a -# side effect the broken run poisons ~/.cache/gstreamer-1.0/registry.x86_64.bin. +# 2. GStreamer crash: linuxdeploy's compiled AppRun.wrapped force-sets +# GST_PLUGIN_SYSTEM_PATH_1_0 to $APPDIR/usr/lib/gstreamer-1.0 -- a dir the +# bundler never populates (bundleMediaFramework is off, and we strip the +# bundled libgst* core below to use the host's). Crucially, once that variable +# is set it *replaces* GStreamer's compiled-in default search path rather than +# adding to it, so the app finds ZERO plugins on every distro: +# "GStreamer element appsink not found" kills the WebKitWebProcess and the +# window never paints. An earlier revision of this script hid the failure on +# Debian only by symlinking usr/lib/gstreamer-1.0 to the Debian multiarch dir +# (/usr/lib/x86_64-linux-gnu/gstreamer-1.0); that symlink dangles on Arch and +# Fedora, and the "safe fallback to default discovery" it assumed does not +# exist -- a set GST_PLUGIN_SYSTEM_PATH_1_0 disables the default. A broken run +# also poisons ~/.cache/gstreamer-1.0/registry.x86_64.bin. # # 3. WebKit helper mismatch (latent): the bundled WebKit helpers # (WebKitNetworkProcess/WebKitWebProcess) have RUNPATH=$ORIGIN only, and @@ -35,11 +42,17 @@ # wrong -- spawning nothing, dying on unresolved bundled libs, or spawning # the system helpers -- and the window never appears. # -# Fix: remove the offending libs so the app uses the system copies (which are -# newer and ABI-compatible on any distro shipping glib >= 2.72 / Ubuntu 22.04+), -# and symlink the system GStreamer plugin directory so discovery works correctly. -# No tauri.conf.json knob can do this — bundle.linux.appimage only exposes -# bundleMediaFramework, files (copy-only, no remove/symlink), and bundleXdgOpen. +# Fix: (a) remove the offending libs so the app uses the system copies (newer and +# ABI-compatible on any distro shipping glib >= 2.72 / Ubuntu 22.04+), and +# (b) install a launcher shim in front of the app binary that strips the +# bundle-pointing GST_PLUGIN_* overrides AppRun.wrapped injects, letting the host +# GStreamer resolve plugins via its own default path (correct on Debian, Arch, and +# Fedora alike). The shim has to run *after* AppRun.wrapped: the wrapper rewrites +# the variable last -- after every apprun-hook -- so any value set before it is +# discarded (verified empirically; a runtime GST_PLUGIN_SYSTEM_PATH_1_0 passed +# into the AppImage does not survive). No tauri.conf.json knob can do this -- +# bundle.linux.appimage only exposes bundleMediaFramework, files (copy-only, no +# remove/symlink), and bundleXdgOpen. set -euo pipefail @@ -61,16 +74,6 @@ APPIMAGE_NAME="$(basename "$APPIMAGE_ABS")" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -# Detect multiarch triplet for GStreamer plugin path. -case "$(uname -m)" in - x86_64) MULTIARCH="x86_64-linux-gnu" ;; - aarch64) MULTIARCH="aarch64-linux-gnu" ;; - *) - echo "Error: unsupported architecture: $(uname -m)" >&2 - exit 1 - ;; -esac - WORKDIR="$(mktemp -d)" trap 'rm -rf "$WORKDIR"' EXIT @@ -107,12 +110,58 @@ rm -f \ "$LIBDIR"/libelf.so* \ "$LIBDIR"/libffi.so* -echo "==> Symlinking system GStreamer plugin directory" -# On distros without the Debian multiarch layout (e.g. Arch), this symlink -# dangles — GStreamer then falls back to its default plugin discovery, which -# is a safe degradation (unlike the original empty in-bundle dir). -rm -rf "$LIBDIR/gstreamer-1.0" -ln -s "/usr/lib/$MULTIARCH/gstreamer-1.0" "$LIBDIR/gstreamer-1.0" +echo "==> Installing GStreamer launcher shim on the app binary" +# AppRun.wrapped force-sets GST_PLUGIN_SYSTEM_PATH_1_0 (and the 0.10-era +# GST_PLUGIN_SYSTEM_PATH) to $APPDIR/usr/lib/gstreamer-1.0 — a dir we bundle no +# plugins into. Because a set path *replaces* GStreamer's default instead of +# extending it, the app finds zero plugins on any distro and WebKit aborts. The +# wrapper rewrites the variable after every apprun-hook, so the only place to undo +# it is a shim between AppRun.wrapped and the real binary. First confirm the +# wrapper still injects the override; if a tauri/linuxdeploy bump drops it, the +# shim becomes a harmless no-op, but we want a human to re-verify rather than +# silently ship — so fail loudly (mirrors the libwayland guard above). +APPRUN_WRAPPED="$WORKDIR/squashfs-root/AppRun.wrapped" +if ! grep -aq "GST_PLUGIN_SYSTEM_PATH_1_0" "$APPRUN_WRAPPED"; then + echo "Error: AppRun.wrapped is missing or no longer references GST_PLUGIN_SYSTEM_PATH_1_0 — GStreamer path injection changed; re-verify fix-appimage.sh" >&2 + exit 1 +fi + +APP_BIN="$WORKDIR/squashfs-root/usr/bin/buzz-desktop" +if [[ ! -f "$APP_BIN" ]]; then + echo "Error: app binary usr/bin/buzz-desktop not found — bundler layout changed; update fix-appimage.sh" >&2 + exit 1 +fi +if [[ -e "$APP_BIN.bin" ]]; then + echo "Error: usr/bin/buzz-desktop.bin already exists — shim already installed?" >&2 + exit 1 +fi + +# The real binary moves aside; buzz-desktop becomes a shim AppRun.wrapped execs. +mv "$APP_BIN" "$APP_BIN.bin" +cat > "$APP_BIN" <<'SHIM' +#!/usr/bin/env bash +# GStreamer shim installed by desktop/scripts/fix-appimage.sh. +# +# linuxdeploy's AppRun.wrapped force-sets GST_PLUGIN_SYSTEM_PATH_1_0 to an empty +# in-bundle dir ($APPDIR/usr/lib/gstreamer-1.0). A set path *replaces* the host's +# default GStreamer search path, so the app finds zero plugins and WebKit aborts +# (blank window). Drop the bundle-pointing GST_PLUGIN_* overrides so the system +# GStreamer — which we use, having removed the bundled core libs — resolves +# plugins via its own default path on any distro. Values that don't point into +# this AppImage are the user's own and are preserved. +here="$(dirname "$(readlink -f "$0")")" +appdir="$(readlink -f "$here/../..")" +for var in GST_PLUGIN_SYSTEM_PATH_1_0 GST_PLUGIN_SYSTEM_PATH \ + GST_PLUGIN_PATH_1_0 GST_PLUGIN_PATH \ + GST_PLUGIN_SCANNER GST_PLUGIN_SCANNER_1_0; do + val="${!var-}" + if [[ -n "$val" && "$val" == *"$appdir/"* ]]; then + unset "$var" + fi +done +exec -a "buzz-desktop" "$here/buzz-desktop.bin" "$@" +SHIM +chmod +x "$APP_BIN" echo "==> Repacking AppImage" # Pass a pinned type2 runtime when provided (CI sets APPIMAGETOOL_RUNTIME_FILE); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 6687cbbcf2..44cee49aeb 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -855,7 +855,14 @@ pub(crate) fn collect_same_instance_orphans( /// Binary names for the Buzz desktop/Tauri process. Used by dead-instance /// detection to confirm the owning desktop is still alive. -const DESKTOP_BINARY_NAMES: &[&str] = &["Buzz", "buzz-desktop", "buzz_desktop"]; +const DESKTOP_BINARY_NAMES: &[&str] = &[ + "Buzz", + "buzz-desktop", + "buzz_desktop", + // Linux limits /proc//comm to 15 visible bytes, truncating the + // AppImage shim's real executable name, `buzz-desktop.bin`. + "buzz-desktop.bi", +]; /// Check if a process name matches a known Buzz desktop binary. fn is_desktop_binary(name: &str) -> bool { diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index fd758047c3..019bfe61d3 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1,5 +1,12 @@ use crate::managed_agents::known_acp_runtime; +// ── desktop binary name tests ─────────────────────────────────────────── + +#[test] +fn appimage_binary_matches_truncated_linux_comm_name() { + assert!(super::is_desktop_binary("buzz-desktop.bi")); +} + // ── buffer_contains_identifier tests ──────────────────────────────────── #[test] From c2a4ee711e481bb427d6cf8cd08b2c7329d1508c Mon Sep 17 00:00:00 2001 From: Matthew Sweeney Date: Sat, 25 Jul 2026 19:51:09 -0700 Subject: [PATCH 010/128] Fix formatting in README.md diagram (#2284) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 89a131e73e..72af92ce13 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ If you'd rather point buzz at a different bash-compatible shell, set `BUZZ_SHELL ┌─────────────────────────────────────────────────────────────────────────┐ │ buzz-relay │ │ NIP-01 · NIP-42 auth · channel/DM/media/workflow/git REST · audit log │ -└───┬──────────────────────────┬──────────────────────────┬──────────────┘ +└───┬──────────────────────────┬──────────────────────────┬───────────────┘ │ │ │ ┌──▼───────────┐ ┌──────▼──────┐ ┌───────▼─────┐ │ Postgres │ │ Redis │ │ S3/MinIO │ From dd222a509b156ba52ed3219e895d7bf1cf322c92 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 26 Jul 2026 08:57:31 +0100 Subject: [PATCH 011/128] Refine mobile settings and themes (#2844) ## Summary - reorganize mobile settings around profile, appearance, and connection cards - add System/Light/Dark theme pairing, accent selection, and the Buzz gradient theme - align avatar badges, status editing, and supporting mobile chrome ## Test plan - `just mobile-check` - `just mobile-test` --- mobile/lib/app.dart | 35 +- .../channels/channel_management_provider.dart | 4 +- .../lib/features/channels/channels_page.dart | 36 +- .../channels/channels_page/channel_tile.dart | 41 +- mobile/lib/features/channels/compose_bar.dart | 4 +- .../lib/features/channels/emoji_picker.dart | 6 +- .../features/channels/message_actions.dart | 4 +- .../features/channels/message_content.dart | 6 +- .../lib/features/channels/reaction_row.dart | 2 +- .../features/channels/timeline_message.dart | 2 +- mobile/lib/features/forum/forum_provider.dart | 4 +- mobile/lib/features/home/home_page.dart | 10 +- .../lib/features/profile/profile_avatar.dart | 71 ++-- .../features/profile/set_status_sheet.dart | 281 +++++--------- .../profile/settings_profile_header.dart | 157 ++++++++ mobile/lib/features/pulse/note_card.dart | 2 +- mobile/lib/features/pulse/pulse_actions.dart | 4 +- .../features/settings/accent_picker_page.dart | 74 ++++ .../lib/features/settings/settings_page.dart | 309 ++------------- .../settings_page/appearance_section.dart | 138 +++++++ .../settings_page/connection_section.dart | 103 +++++ .../features/settings/theme_picker_page.dart | 280 +++++++------- .../custom_emoji/custom_emoji.dart | 2 +- .../custom_emoji/custom_emoji_provider.dart | 2 +- .../custom_emoji/custom_emoji_render.dart | 2 +- mobile/lib/shared/theme/app_colors.dart | 29 +- mobile/lib/shared/theme/app_theme.dart | 13 +- mobile/lib/shared/theme/buzz_theme.dart | 49 +++ mobile/lib/shared/theme/theme.dart | 2 + mobile/lib/shared/theme/theme_catalog.dart | 20 + mobile/lib/shared/theme/theme_pairs.dart | 115 ++++++ mobile/lib/shared/theme/theme_provider.dart | 203 ++++++++-- mobile/lib/shared/widgets/app_list.dart | 112 +++--- mobile/lib/shared/widgets/app_list_card.dart | 85 +++++ mobile/lib/shared/widgets/app_list_inset.dart | 23 ++ .../lib/shared/widgets/frosted_app_bar.dart | 13 +- .../shared/widgets/masked_avatar_badge.dart | 356 ++++++++++++++++++ .../features/channels/channels_page_test.dart | 24 +- .../features/channels/compose_bar_test.dart | 4 +- mobile/test/features/home/home_page_test.dart | 17 +- .../features/profile/profile_avatar_test.dart | 104 +++++ .../profile/set_status_sheet_test.dart | 72 ++++ .../profile/settings_profile_header_test.dart | 63 ++++ .../settings/theme_picker_page_test.dart | 203 ++++++++++ .../custom_emoji/custom_emoji_test.dart | 2 +- .../test/shared/theme/accent_colors_test.dart | 4 +- mobile/test/shared/theme/buzz_theme_test.dart | 196 ++++++++++ .../test/shared/theme/theme_pairs_test.dart | 254 +++++++++++++ mobile/test/shared/widgets/app_list_test.dart | 124 ++++++ .../widgets/masked_avatar_badge_test.dart | 147 ++++++++ 50 files changed, 3037 insertions(+), 776 deletions(-) create mode 100644 mobile/lib/features/profile/settings_profile_header.dart create mode 100644 mobile/lib/features/settings/accent_picker_page.dart create mode 100644 mobile/lib/features/settings/settings_page/appearance_section.dart create mode 100644 mobile/lib/features/settings/settings_page/connection_section.dart rename mobile/lib/{features => shared}/custom_emoji/custom_emoji.dart (99%) rename mobile/lib/{features => shared}/custom_emoji/custom_emoji_provider.dart (98%) rename mobile/lib/{features => shared}/custom_emoji/custom_emoji_render.dart (98%) create mode 100644 mobile/lib/shared/theme/buzz_theme.dart create mode 100644 mobile/lib/shared/theme/theme_pairs.dart create mode 100644 mobile/lib/shared/widgets/app_list_card.dart create mode 100644 mobile/lib/shared/widgets/app_list_inset.dart create mode 100644 mobile/lib/shared/widgets/masked_avatar_badge.dart create mode 100644 mobile/test/features/profile/profile_avatar_test.dart create mode 100644 mobile/test/features/profile/set_status_sheet_test.dart create mode 100644 mobile/test/features/profile/settings_profile_header_test.dart create mode 100644 mobile/test/features/settings/theme_picker_page_test.dart rename mobile/test/{features => shared}/custom_emoji/custom_emoji_test.dart (98%) create mode 100644 mobile/test/shared/theme/buzz_theme_test.dart create mode 100644 mobile/test/shared/theme/theme_pairs_test.dart create mode 100644 mobile/test/shared/widgets/app_list_test.dart create mode 100644 mobile/test/shared/widgets/masked_avatar_badge_test.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 38a7e60aee..3d6a562a92 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -10,6 +10,8 @@ import 'features/pairing/pairing_page.dart'; import 'features/channels/agent_activity/observer_subscription.dart'; import 'features/channels/deep_link_dispatcher.dart'; import 'features/profile/user_status_cache_provider.dart'; +import 'features/profile/settings_profile_header.dart'; +import 'features/settings/settings_page.dart'; import 'shared/auth/auth.dart'; import 'shared/deeplink/pending_deep_link_provider.dart'; import 'shared/relay/relay.dart'; @@ -25,13 +27,25 @@ class App extends HookConsumerWidget { final schemeName = ref.watch(schemeProvider); final authState = ref.watch(authProvider); - final resolved = resolveSchemes(schemeName); + final resolved = resolveSchemes(schemeName, themeMode); final lightScheme = applyAccent(resolved.light, accentIndex); final darkScheme = applyAccent(resolved.dark, accentIndex); - // Default and named schemes can force light or dark mode; otherwise - // respect the user's ThemeMode preference. + // Light/Dark modes pin the brightness; System leaves it null so Flutter + // follows the OS across the selected theme and its pair. final effectiveMode = resolved.forcedMode ?? themeMode; + // Derive the gradient from the themes that produced each color scheme. + // This keeps fallbacks and pinned brightness changes aligned with the + // rendered palette rather than the raw persisted selection. + final buzzLightGradient = buzzTopSectionGradient( + resolved.lightTheme?.name ?? '', + lightScheme.brightness, + ); + final buzzDarkGradient = buzzTopSectionGradient( + resolved.darkTheme?.name ?? '', + darkScheme.brightness, + ); + // Eagerly initialize websocket session and lifecycle observer when // authenticated. These providers connect and manage the websocket. if (authState.value?.status == AuthStatus.authenticated) { @@ -65,15 +79,21 @@ class App extends HookConsumerWidget { return MaterialApp( title: 'Buzz', - theme: AppTheme.light(colorScheme: lightScheme), - darkTheme: AppTheme.dark(colorScheme: darkScheme), + theme: AppTheme.light( + colorScheme: lightScheme, + topSectionGradient: buzzLightGradient, + ), + darkTheme: AppTheme.dark( + colorScheme: darkScheme, + topSectionGradient: buzzDarkGradient, + ), themeMode: effectiveMode, home: authState.when( loading: () => const _SplashScreen(), error: (_, _) => const PairingPage(), data: (state) => switch (state.status) { AuthStatus.authenticated => const DeepLinkDispatcher( - child: HomePage(), + child: HomePage(settingsPageBuilder: _buildSettingsPage), ), _ => const DeepLinkDispatcher( dispatchMessageLinks: false, @@ -85,6 +105,9 @@ class App extends HookConsumerWidget { } } +Widget _buildSettingsPage(BuildContext context) => + const SettingsPage(profileHeader: SettingsProfileHeader()); + class _SplashScreen extends StatelessWidget { const _SplashScreen(); diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index d88de261c3..9a72054a2b 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -5,10 +5,10 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/auth/auth.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/relay/relay.dart'; import '../profile/profile_provider.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; import 'channel.dart'; import 'channels_provider.dart'; diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 674a498af8..df92a51193 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -15,12 +15,11 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; -import '../custom_emoji/custom_emoji_render.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../profile/profile_avatar.dart'; import '../profile/profile_provider.dart'; -import '../settings/settings_page.dart'; import '../profile/presence_cache_provider.dart'; import '../profile/user_cache_provider.dart'; import '../pairing/pairing_page.dart'; @@ -62,6 +61,19 @@ const double _kChannelLabelGap = Grid.xxs; const double _kChannelRowVerticalPadding = Grid.xxs + Grid.quarter; const double _kChannelLabelInset = _kChannelSectionInset + _kChannelLeadingWidth + _kChannelLabelGap; + +/// DM avatars are circles, so they fill their box edge to edge where a channel +/// glyph leaves 4dp of slack inside the same 22dp leading column. Sizing them to +/// the glyph's ink width keeps the icon-to-label distance identical across both +/// sections while the labels stay on [_kChannelLabelInset]. +const double _kDmAvatarSize = _kChannelIconSize; + +/// The top section's avatars are 32dp circles, which fill their box edge to +/// edge; the channel rows below lead with an 18dp glyph left-aligned in a 22dp +/// box at [_kChannelSectionInset]. Edge-aligning the two leaves the circles +/// looking pushed outward, so the bar is pulled in to sit the avatar's centre +/// on the channel-icon column (12 + 16 = 28dp against the glyph's ~29dp). +const double _kTopSectionInset = Grid.twelve; const Duration _kSectionExpandDuration = Duration(milliseconds: 220); const Duration _kSectionCollapseDuration = Duration(milliseconds: 170); const Curve _kSectionExpandCurve = Cubic(0.23, 1, 0.32, 1); @@ -129,7 +141,9 @@ _UnreadChannelState _computeUnreadChannelState({ } class ChannelsPage extends HookConsumerWidget { - const ChannelsPage({super.key}); + const ChannelsPage({required this.settingsPageBuilder, super.key}); + + final WidgetBuilder settingsPageBuilder; @override Widget build(BuildContext context, WidgetRef ref) { @@ -208,7 +222,11 @@ class ChannelsPage extends HookConsumerWidget { return FrostedScaffold( appBar: FrostedAppBar( - horizontalInset: _kChannelSectionInset, + horizontalInset: _kTopSectionInset, + // Under a Buzz theme the community + account avatar strip carries the + // branded gradient, the way desktop paints it across the sidebar. Null + // under every other theme, leaving the default frosted fill. + gradient: context.appColors.topSectionGradient, leading: _CommunityIndicator( onTap: () => showModalBottomSheet( context: context, @@ -219,9 +237,9 @@ class ChannelsPage extends HookConsumerWidget { title: const SizedBox.shrink(), actions: [ ProfileAvatar( - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SettingsPage()), - ), + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: settingsPageBuilder)), ), ], ), diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 2d730a34d2..fc3f340b9d 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -41,20 +41,19 @@ class _ChannelTile extends ConsumerWidget { ), child: Row( children: [ - if (channel.isDm) - _DmAvatar(channel: channel, currentPubkey: currentPubkey) - else - SizedBox( - width: _kChannelLeadingWidth, - child: Align( - alignment: Alignment.centerLeft, - child: Icon( - channelIcon(channel), - size: _kChannelIconSize, - color: context.colors.onSurface, - ), - ), + SizedBox( + width: _kChannelLeadingWidth, + child: Align( + alignment: Alignment.centerLeft, + child: channel.isDm + ? _DmAvatar(channel: channel, currentPubkey: currentPubkey) + : Icon( + channelIcon(channel), + size: _kChannelIconSize, + color: context.colors.onSurface, + ), ), + ), const SizedBox(width: _kChannelLabelGap), Expanded( child: Column( @@ -343,8 +342,8 @@ class _DmAvatar extends ConsumerWidget { if (visiblePubkeys.length > 1) { return Container( - width: 22, - height: 22, + width: _kDmAvatarSize, + height: _kDmAvatarSize, alignment: Alignment.center, decoration: BoxDecoration( color: context.colors.surfaceContainerHighest, @@ -354,7 +353,7 @@ class _DmAvatar extends ConsumerWidget { child: Text( '${visiblePubkeys.length}', style: context.textTheme.labelSmall?.copyWith( - fontSize: 10, + fontSize: 9, color: context.colors.onSurface, fontWeight: FontWeight.w600, height: 1, @@ -385,14 +384,14 @@ class _DmAvatar extends ConsumerWidget { : 'offline'; return SizedBox( - width: 22, - height: 22, + width: _kDmAvatarSize, + height: _kDmAvatarSize, child: Stack( clipBehavior: Clip.none, children: [ AvatarImage( imageUrl: avatarUrl, - radius: 10, + radius: _kDmAvatarSize / 2, backgroundColor: context.colors.primaryContainer, fallback: Text( initial, @@ -407,8 +406,8 @@ class _DmAvatar extends ConsumerWidget { right: -1, bottom: -1, child: Container( - width: 9, - height: 9, + width: 8, + height: 8, decoration: BoxDecoration( color: _presenceColor(context, presence), shape: BoxShape.circle, diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 8711ff7b3a..d0190f513a 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -17,8 +17,8 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; import 'camera_capture_cleanup.dart'; import 'channel.dart'; import 'channel_management_provider.dart'; diff --git a/mobile/lib/features/channels/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart index b43a57b410..c961966531 100644 --- a/mobile/lib/features/channels/emoji_picker.dart +++ b/mobile/lib/features/channels/emoji_picker.dart @@ -3,9 +3,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; -import '../custom_emoji/custom_emoji_render.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji_render.dart'; /// Opens the full emoji picker as a modal bottom sheet. void showEmojiPicker({ diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index fe03bcb65e..c1b52203e6 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -4,8 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; import 'channel_management_provider.dart'; import 'emoji_picker.dart'; import 'thread_detail_page.dart'; diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 1dc892acfd..cb1af75c68 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -15,9 +15,9 @@ import '../../shared/clipboard_utils.dart'; import '../../shared/relay/relay.dart'; import '../../shared/syntax_highlight.dart'; import '../../shared/theme/theme.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; -import '../custom_emoji/custom_emoji_render.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji_render.dart'; import 'media_viewer_page.dart'; import 'message_media.dart'; diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index a721ef1988..8814d698fb 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; -import '../custom_emoji/custom_emoji_render.dart'; +import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import 'channel_management_provider.dart'; diff --git a/mobile/lib/features/channels/timeline_message.dart b/mobile/lib/features/channels/timeline_message.dart index 496060e50b..203be2dbbd 100644 --- a/mobile/lib/features/channels/timeline_message.dart +++ b/mobile/lib/features/channels/timeline_message.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import '../../shared/relay/relay.dart'; -import '../custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; import 'channel_window.dart'; enum SystemEventType { diff --git a/mobile/lib/features/forum/forum_provider.dart b/mobile/lib/features/forum/forum_provider.dart index 8ff2c335c4..710e16aa34 100644 --- a/mobile/lib/features/forum/forum_provider.dart +++ b/mobile/lib/features/forum/forum_provider.dart @@ -2,8 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; import '../channels/channel_management_provider.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; import 'forum_models.dart'; /// Fetches forum posts (kind:45001) for a channel from the relay. diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index e76d739ab2..b4fa08649d 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -13,7 +13,9 @@ import '../channels/channels_page.dart'; import '../search/search_page.dart'; class HomePage extends HookConsumerWidget { - const HomePage({super.key}); + const HomePage({required this.settingsPageBuilder, super.key}); + + final WidgetBuilder settingsPageBuilder; static const double _tabBarHeight = 56; static const double _tabBarRadius = _tabBarHeight / 2; @@ -54,7 +56,11 @@ class HomePage extends HookConsumerWidget { _destinations.length, ); - const pages = [ChannelsPage(), ActivityPage(), SearchPage()]; + final pages = [ + ChannelsPage(settingsPageBuilder: settingsPageBuilder), + const ActivityPage(), + const SearchPage(), + ]; return Scaffold( extendBody: true, diff --git a/mobile/lib/features/profile/profile_avatar.dart b/mobile/lib/features/profile/profile_avatar.dart index 38cc3e6138..fb60fae125 100644 --- a/mobile/lib/features/profile/profile_avatar.dart +++ b/mobile/lib/features/profile/profile_avatar.dart @@ -3,10 +3,23 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/masked_avatar_badge.dart'; import 'profile_provider.dart'; import 'user_profile.dart'; +/// Matches desktop's sidebar profile card, whose avatar is 32px. +const _avatarSize = 32.0; + +/// The visible dot is smaller than the notch it sits in, so a ring of +/// background separates it from the avatar. Desktop's `h-2 w-2` dot inside a +/// `h-3.5 w-3.5` badge frame. +const _presenceDotRatio = 8 / 14; + /// User avatar with a presence dot indicator, for use in the app bar. +/// +/// The dot sits in a notch masked out of the avatar rather than overlapping it, +/// the same treatment desktop's `SidebarProfileCard` uses — so it needs no +/// background-coloured ring, and reads correctly over the themed top section. class ProfileAvatar extends ConsumerWidget { final VoidCallback? onTap; final bool showPresence; @@ -32,7 +45,7 @@ class ProfileAvatar extends ConsumerWidget { Widget _buildPlaceholder(BuildContext context) { return CircleAvatar( - radius: 16, + radius: _avatarSize / 2, backgroundColor: context.colors.primaryContainer, ); } @@ -44,37 +57,37 @@ class ProfileAvatar extends ConsumerWidget { ) { return GestureDetector( onTap: onTap, - child: Stack( - children: [ - AvatarImage( - imageUrl: profile?.avatarUrl, - radius: 16, - backgroundColor: context.colors.primaryContainer, - fallback: Text( - profile?.initial ?? '?', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - ), - ), - ), - if (showPresence) - Positioned( - right: 0, - bottom: 0, - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: _presenceColor(context, presence), - shape: BoxShape.circle, - border: Border.all( - color: context.theme.scaffoldBackgroundColor, - width: 1.5, - ), + child: MaskedAvatarBadge( + size: _avatarSize, + geometry: AvatarBadgeMaskGeometry.presenceDot, + avatar: ClipOval( + child: ColoredBox( + color: context.colors.primaryContainer, + child: AvatarImageContent( + imageUrl: profile?.avatarUrl, + fallback: Text( + profile?.initial ?? '?', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onPrimaryContainer, ), ), ), - ], + ), + ), + badge: showPresence + ? Center( + child: FractionallySizedBox( + widthFactor: _presenceDotRatio, + heightFactor: _presenceDotRatio, + child: DecoratedBox( + decoration: BoxDecoration( + color: _presenceColor(context, presence), + shape: BoxShape.circle, + ), + ), + ), + ) + : null, ), ); } diff --git a/mobile/lib/features/profile/set_status_sheet.dart b/mobile/lib/features/profile/set_status_sheet.dart index 2249a0d6f8..2e03e4c110 100644 --- a/mobile/lib/features/profile/set_status_sheet.dart +++ b/mobile/lib/features/profile/set_status_sheet.dart @@ -1,33 +1,24 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/theme/theme.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; -import '../custom_emoji/custom_emoji_render.dart'; import '../channels/emoji_picker.dart'; import 'user_status.dart'; import 'user_status_provider.dart'; -const _emojiOptions = [ - (emoji: '\u{1F5E3}\u{FE0F}', label: 'In a meeting'), - (emoji: '\u{1F68C}', label: 'Commuting'), - (emoji: '\u{1F912}', label: 'Out sick'), - (emoji: '\u{1F3D6}\u{FE0F}', label: 'Vacationing'), - (emoji: '\u{1F3E0}', label: 'Working remotely'), - (emoji: '\u{1F354}', label: 'Lunch'), - (emoji: '\u{1F3AF}', label: 'Focus'), - (emoji: '\u{1F4AA}', label: 'Exercising'), -]; - -const _presets = [ - (text: 'In a meeting', emoji: '\u{1F5E3}\u{FE0F}'), - (text: 'Commuting', emoji: '\u{1F68C}'), - (text: 'Out sick', emoji: '\u{1F912}'), - (text: 'Vacationing', emoji: '\u{1F3D6}\u{FE0F}'), - (text: 'Working remotely', emoji: '\u{1F3E0}'), -]; +/// The emoji well and the text field are sized to be the two things you reach +/// for, so they carry no borders — the sheet has no other controls to compete +/// with them. +const _emojiWellSize = 56.0; +const _emojiGlyphSize = 32.0; +const _saveButtonHeight = 52.0; void showSetStatusSheet(BuildContext context, {UserStatus? currentStatus}) { showModalBottomSheet( @@ -51,7 +42,6 @@ class _SetStatusSheet extends HookConsumerWidget { final emoji = useState(currentStatus?.emoji ?? ''); final text = useState(currentStatus?.text ?? ''); final isSaving = useState(false); - final customEmoji = ref.watch(customEmojiListProvider); useEffect(() { void listener() => text.value = textController.text; @@ -91,7 +81,13 @@ class _SetStatusSheet extends HookConsumerWidget { Grid.gutter, 0, Grid.gutter, - MediaQuery.viewInsetsOf(context).bottom, + // The sheet ends in the Save button, so it owns its own breathing room + // above whichever is taller: the keyboard, or the home indicator. + Grid.gutter + + math.max( + MediaQuery.viewInsetsOf(context).bottom, + MediaQuery.viewPaddingOf(context).bottom, + ), ), child: Column( mainAxisSize: MainAxisSize.min, @@ -107,31 +103,77 @@ class _SetStatusSheet extends HookConsumerWidget { ), const SizedBox(height: Grid.twelve), - // Text input with emoji preview + // The emoji well doubles as the picker's entry point, which is why + // there is no separate row of emoji suggestions below. Row( children: [ - Container( - width: 40, - height: 40, - alignment: Alignment.center, - decoration: BoxDecoration( - border: Border.all(color: context.colors.outlineVariant), - borderRadius: BorderRadius.circular(Radii.md), - ), - child: _StatusEmojiPreview(emoji: emoji.value), + Stack( + clipBehavior: Clip.none, + children: [ + Semantics( + button: true, + label: 'Choose a status emoji', + child: InkWell( + borderRadius: BorderRadius.circular(Radii.lg), + onTap: () => showEmojiPicker( + context: context, + onSelect: (value) => emoji.value = value, + ), + child: SizedBox.square( + dimension: _emojiWellSize, + child: Center( + child: _StatusEmojiPreview(emoji: emoji.value), + ), + ), + ), + ), + if (emoji.value.isNotEmpty) + Positioned( + top: -Grid.quarter, + right: -Grid.quarter, + child: SizedBox.square( + dimension: Grid.sm, + child: IconButton( + onPressed: isSaving.value + ? null + : () => emoji.value = '', + tooltip: 'Remove status emoji', + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + backgroundColor: context.colors.surface, + minimumSize: const Size.square(Grid.sm), + maximumSize: const Size.square(Grid.sm), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: Icon( + LucideIcons.x, + size: 14, + color: context.colors.onSurface, + ), + ), + ), + ), + ], ), - const SizedBox(width: Grid.xxs), + const SizedBox(width: Grid.half), Expanded( child: TextField( controller: textController, autofocus: true, - decoration: const InputDecoration( + style: context.textTheme.titleMedium, + decoration: InputDecoration( hintText: 'What\u2019s your status?', - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric( - horizontal: Grid.twelve, - vertical: Grid.xxs, + hintStyle: context.textTheme.titleMedium?.copyWith( + color: context.colors.onSurfaceVariant, ), + // The theme outlines inputs; this one is the sheet's whole + // content, so every border state is cleared explicitly. + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: false, + contentPadding: EdgeInsets.zero, ), textInputAction: TextInputAction.done, onSubmitted: (_) { @@ -141,74 +183,28 @@ class _SetStatusSheet extends HookConsumerWidget { ), ], ), - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.gutter), - Wrap( - spacing: Grid.half, - runSpacing: Grid.half, - children: [ - for (final option in _emojiOptions) - _EmojiButton( - emoji: option.emoji, - label: option.label, - selected: emoji.value == option.emoji, - onTap: () { - emoji.value = emoji.value == option.emoji - ? '' - : option.emoji; - }, - ), - if (customEmoji.isNotEmpty) - _PickCustomEmojiButton( - selected: emoji.value.startsWith(':'), - onTap: () => showEmojiPicker( - context: context, - onSelect: (value) => emoji.value = value, - ), - ), - ], + SizedBox( + width: double.infinity, + height: _saveButtonHeight, + child: FilledButton( + onPressed: hasContent && !isSaving.value ? handleSave : null, + child: const Text('Save'), + ), ), - const SizedBox(height: Grid.twelve), - - // Presets - Wrap( - spacing: Grid.half, - runSpacing: Grid.half, - children: [ - for (final preset in _presets) - ActionChip( - label: Text('${preset.emoji} ${preset.text}'), - labelStyle: context.textTheme.labelSmall, - onPressed: () { - textController.text = preset.text; - emoji.value = preset.emoji; - }, - ), - ], - ), - const SizedBox(height: Grid.xs), - - Row( - children: [ - if (hasExistingStatus) - TextButton( + // No Cancel \u2014 the sheet dismisses by swiping down. + if (hasExistingStatus) + Padding( + padding: const EdgeInsets.only(top: Grid.half), + child: SizedBox( + width: double.infinity, + child: TextButton( onPressed: isSaving.value ? null : handleClear, child: const Text('Clear status'), ), - const Spacer(), - TextButton( - onPressed: isSaving.value - ? null - : () => Navigator.of(context).pop(), - child: const Text('Cancel'), ), - const SizedBox(width: Grid.xxs), - FilledButton( - onPressed: hasContent && !isSaving.value ? handleSave : null, - child: const Text('Save'), - ), - ], - ), + ), ], ), ); @@ -223,7 +219,11 @@ class _StatusEmojiPreview extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { if (emoji.isEmpty) { - return const Text('\u{1F4AC}', style: TextStyle(fontSize: 18)); + return Icon( + LucideIcons.smilePlus, + size: _emojiGlyphSize, + color: context.colors.onSurfaceVariant, + ); } final palette = ref.watch(customEmojiListProvider); final shortcode = normalizeShortcode(emoji); @@ -233,84 +233,11 @@ class _StatusEmojiPreview extends ConsumerWidget { return CustomEmojiImage( shortcode: shortcode, url: entry.url, - size: 22, + size: _emojiGlyphSize, ); } } } - return Text(emoji, style: const TextStyle(fontSize: 18)); - } -} - -class _PickCustomEmojiButton extends StatelessWidget { - final bool selected; - final VoidCallback onTap; - - const _PickCustomEmojiButton({required this.selected, required this.onTap}); - - @override - Widget build(BuildContext context) { - return Tooltip( - message: 'Custom emoji', - child: InkWell( - borderRadius: BorderRadius.circular(Radii.md), - onTap: onTap, - child: Container( - width: 36, - height: 36, - alignment: Alignment.center, - decoration: BoxDecoration( - color: selected - ? context.colors.secondaryContainer - : Colors.transparent, - borderRadius: BorderRadius.circular(Radii.md), - border: selected ? Border.all(color: context.colors.outline) : null, - ), - child: Icon( - Icons.add_reaction_outlined, - size: 18, - color: context.colors.onSurfaceVariant, - ), - ), - ), - ); - } -} - -class _EmojiButton extends StatelessWidget { - final String emoji; - final String label; - final bool selected; - final VoidCallback onTap; - - const _EmojiButton({ - required this.emoji, - required this.label, - required this.selected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Tooltip( - message: label, - child: InkWell( - borderRadius: BorderRadius.circular(Radii.md), - onTap: onTap, - child: Container( - width: 36, - height: 36, - alignment: Alignment.center, - decoration: BoxDecoration( - color: selected - ? context.colors.secondaryContainer - : Colors.transparent, - borderRadius: BorderRadius.circular(Radii.md), - border: selected ? Border.all(color: context.colors.outline) : null, - ), - child: Text(emoji, style: const TextStyle(fontSize: 18)), - ), - ), - ); + return Text(emoji, style: const TextStyle(fontSize: _emojiGlyphSize)); } } diff --git a/mobile/lib/features/profile/settings_profile_header.dart b/mobile/lib/features/profile/settings_profile_header.dart new file mode 100644 index 0000000000..23816ccaa9 --- /dev/null +++ b/mobile/lib/features/profile/settings_profile_header.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji_render.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/masked_avatar_badge.dart'; +import 'profile_provider.dart'; +import 'set_status_sheet.dart'; +import 'user_status_provider.dart'; + +/// Desktop's settings-avatar treatment (`ProfileSettingsCard`): a large centred +/// avatar with a circular badge notched out of its bottom-right corner. Desktop +/// puts an edit-photo pencil in that badge; here it carries the status glyph and +/// opens the status sheet instead. The notch shape — including the fillets where +/// it meets the avatar's edge — comes from [AvatarBadgeMaskGeometry.badge]. +class SettingsProfileHeader extends ConsumerWidget { + const SettingsProfileHeader({super.key}); + + static const _avatarSize = 128.0; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final profile = ref.watch(profileProvider).asData?.value; + final status = ref.watch(userStatusProvider).asData?.value; + final hasStatus = status != null && !status.isEmpty; + + void openStatusSheet() => + showSetStatusSheet(context, currentStatus: status); + + return Padding( + padding: const EdgeInsets.only(top: Grid.xxs, bottom: Grid.sm), + child: Column( + children: [ + MaskedAvatarBadge( + size: _avatarSize, + avatar: ColoredBox( + color: context.colors.primaryContainer, + child: AvatarImageContent( + imageUrl: profile?.avatarUrl, + fallback: Text( + profile?.initial ?? '?', + style: context.textTheme.displaySmall?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + ), + ), + badge: _StatusBadge( + emoji: status?.emoji ?? '', + onTap: openStatusSheet, + ), + ), + const SizedBox(height: Grid.twelve), + Text( + profile?.label ?? 'Your profile', + style: context.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + // No placeholder copy — the badge is the affordance, so this line + // appears only once there is an actual status to show. + if (hasStatus) + GestureDetector( + onTap: openStatusSheet, + child: Padding( + padding: const EdgeInsets.only( + top: Grid.quarter, + left: Grid.gutter, + right: Grid.gutter, + bottom: Grid.half, + ), + child: Text( + status.text.isNotEmpty ? status.text : status.emoji, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + ); + } +} + +/// Fills the notch left by [MaskedAvatarBadge], so its size comes from the mask +/// geometry rather than being set here. +class _StatusBadge extends StatelessWidget { + const _StatusBadge({required this.emoji, required this.onTap}); + + final String emoji; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: 'Set a status', + child: GestureDetector( + onTap: onTap, + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Center(child: _StatusGlyph(emoji: emoji)), + ), + ), + ); + } +} + +/// The status emoji, resolving `:shortcode:` values against the community's +/// custom emoji. Falls back to the add-status icon when no status is set. +class _StatusGlyph extends ConsumerWidget { + const _StatusGlyph({required this.emoji}); + + final String emoji; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (emoji.isEmpty) { + return Icon( + LucideIcons.smilePlus, + size: 20, + color: context.colors.onSurfaceVariant, + ); + } + + final shortcode = emoji.startsWith(':') && emoji.endsWith(':') + ? emoji.substring(1, emoji.length - 1).toLowerCase() + : null; + if (shortcode != null) { + for (final entry in ref.watch(customEmojiListProvider)) { + if (entry.shortcode == shortcode) { + return CustomEmojiImage( + shortcode: shortcode, + url: entry.url, + size: 22, + ); + } + } + return Icon( + LucideIcons.smile, + size: 20, + color: context.colors.onSurfaceVariant, + ); + } + + return Text(emoji, style: const TextStyle(fontSize: 20)); + } +} diff --git a/mobile/lib/features/pulse/note_card.dart b/mobile/lib/features/pulse/note_card.dart index c843de7010..6caf1dad6a 100644 --- a/mobile/lib/features/pulse/note_card.dart +++ b/mobile/lib/features/pulse/note_card.dart @@ -149,7 +149,7 @@ class NoteCard extends HookConsumerWidget { _ActionButton( icon: effectiveUpvoted ? Icons.favorite - : Icons.favorite_border, + : LucideIcons.heart, label: effectiveCount > 0 ? '$effectiveCount' : null, color: effectiveUpvoted ? Colors.redAccent : null, onTap: () async { diff --git a/mobile/lib/features/pulse/pulse_actions.dart b/mobile/lib/features/pulse/pulse_actions.dart index f1657abd13..13b5dee667 100644 --- a/mobile/lib/features/pulse/pulse_actions.dart +++ b/mobile/lib/features/pulse/pulse_actions.dart @@ -2,8 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; import '../channels/channel_management_provider.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; import 'pulse_models.dart'; import 'pulse_provider.dart'; diff --git a/mobile/lib/features/settings/accent_picker_page.dart b/mobile/lib/features/settings/accent_picker_page.dart new file mode 100644 index 0000000000..6f174df741 --- /dev/null +++ b/mobile/lib/features/settings/accent_picker_page.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; + +/// Accent colors as a list of rows, matching the theme picker's shape. The +/// swatches used to live inline on the settings page as a wrap of tappable +/// circles; a row per color keeps the settings page to plain rows and gives each +/// accent a readable name. +class AccentPickerPage extends ConsumerWidget { + const AccentPickerPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selected = ref.watch(accentProvider); + final colorScheme = context.colors; + + return FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Accent Color')), + body: ListView( + padding: EdgeInsets.only( + top: frostedAppBarHeight(context), + bottom: Grid.xs, + ), + children: [ + for (var i = 0; i < accentColors.length; i++) + _AccentRow( + color: accentColorForScheme(colorScheme, i), + label: accentColors[i].name, + selected: selected == i, + onTap: () => ref.read(accentProvider.notifier).setAccent(i), + ), + ], + ), + ); + } +} + +class _AccentRow extends StatelessWidget { + const _AccentRow({ + required this.color, + required this.label, + required this.selected, + required this.onTap, + }); + + final Color color; + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: context.colors.outlineVariant), + ), + ), + title: Text(label), + trailing: selected + ? Icon(LucideIcons.check, size: 18, color: context.colors.primary) + : null, + onTap: onTap, + ); + } +} diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 214360f9eb..f089f63915 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -10,23 +10,22 @@ import '../../shared/clipboard_utils.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/app_list.dart'; +import '../../shared/widgets/app_list_card.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; -import '../profile/set_status_sheet.dart'; -import '../profile/user_status_provider.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; -import '../custom_emoji/custom_emoji_render.dart'; +import 'accent_picker_page.dart'; import 'theme_picker_page.dart'; +part 'settings_page/appearance_section.dart'; +part 'settings_page/connection_section.dart'; + class SettingsPage extends HookConsumerWidget { - const SettingsPage({super.key}); + const SettingsPage({super.key, required this.profileHeader}); + + final Widget profileHeader; @override Widget build(BuildContext context, WidgetRef ref) { - final config = ref.watch(relayConfigProvider); - final selectedAccent = ref.watch(accentProvider); - final selectedScheme = ref.watch(schemeProvider); - final colorScheme = context.colors; final packageInfoFuture = useMemoized(() => PackageInfo.fromPlatform()); final packageInfo = useFuture(packageInfoFuture); @@ -41,283 +40,55 @@ class SettingsPage extends HookConsumerWidget { bottom: Grid.xs, ), children: [ - const SizedBox(height: Grid.xxs), - - // Status — flush header row, like Slack's profile/status block. - _StatusRow(), - - // Appearance - AppListSection( - label: 'Appearance', - children: [ - AppListRow( - icon: LucideIcons.palette, - title: 'Color Scheme', - subtitle: selectedScheme == null - ? 'Default ($defaultSchemeDisplayName)' - : findTheme(selectedScheme)?.displayName ?? - selectedScheme, - trailing: Icon( - LucideIcons.chevronRight, - size: 18, - color: context.colors.onSurfaceVariant, - ), - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ThemePickerPage(), - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - Grid.xxs, - Grid.gutter, - Grid.twelve, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Accent Color', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - const SizedBox(height: Grid.twelve), - Wrap( - spacing: Grid.xxs, - runSpacing: Grid.xxs, - children: [ - for (var i = 0; i < accentColors.length; i++) - _AccentSwatch( - color: accentColorForScheme(colorScheme, i), - label: accentColors[i].name, - selected: selectedAccent == i, - onTap: () => ref - .read(accentProvider.notifier) - .setAccent(i), - ), - ], - ), - ], - ), - ), - ], - ), - - // Connection - AppListSection( - label: 'Connection', - children: [ - AppListRow( - icon: LucideIcons.server, - title: 'Connected to', - subtitle: config.baseUrl, - ), - if (config.nsec != null && config.nsec!.isNotEmpty) - Builder( - builder: (context) { - final privHex = nostr.Nip19.decode( - payload: config.nsec!, - ).data; - final pubkey = privHex.isNotEmpty - ? nostr.Keys(privHex).public - : 'unknown'; - return AppListRow( - icon: LucideIcons.key, - title: 'Identity (pubkey)', - subtitle: pubkey, - subtitleStyle: context.textTheme.bodySmall - ?.copyWith( - color: context.colors.onSurfaceVariant, - fontFamily: 'GeistMono', - fontSize: 11, - ), - subtitleMaxLines: 2, - trailing: IconButton( - icon: const Icon(LucideIcons.copy, size: 16), - onPressed: () async { - await copyToClipboard( - context, - pubkey, - message: 'Pubkey copied', - ); - }, - ), - ); - }, - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - child: Center( - child: TextButton.icon( - onPressed: () => _confirmSignOut(context, ref), - icon: const Icon(LucideIcons.logOut, size: 18), - label: const Text('Remove Community'), - style: TextButton.styleFrom( - foregroundColor: context.colors.error, - ), - ), - ), - ), - ], - ), + profileHeader, + const _AppearanceSection(), + const _ConnectionSection(), + const _RemoveCommunitySection(), ], ), ), if (packageInfo.hasData) - SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.only(bottom: Grid.xs, top: Grid.xxs), - child: Center( - child: Text( - 'v${packageInfo.data!.version}', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant.withValues( - alpha: 0.6, - ), - ), - ), - ), - ), - ), - ], - ), - ); - } - - void _confirmSignOut(BuildContext context, WidgetRef ref) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Remove Community'), - content: const Text( - 'This will disconnect this community. You will need ' - 'to scan a new pairing code to reconnect.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - Navigator.of(ctx).pop(); // close dialog - // Pop all pushed routes back to root so MaterialApp.home - // rebuilds to PairingPage when auth state changes. - Navigator.of(context).popUntil((route) => route.isFirst); - ref.read(authProvider.notifier).signOut(); - }, - style: FilledButton.styleFrom(backgroundColor: ctx.colors.error), - child: const Text('Remove'), - ), + _VersionFooter(version: packageInfo.data!.version), ], ), ); } } -class _StatusRow extends ConsumerWidget { - @override - Widget build(BuildContext context, WidgetRef ref) { - final statusAsync = ref.watch(userStatusProvider); - final status = statusAsync.asData?.value; - final hasStatus = status != null && !status.isEmpty; +class _VersionFooter extends StatelessWidget { + const _VersionFooter({required this.version}); - return AppListRowRaw( - leading: _StatusEmojiIcon(emoji: status?.emoji ?? ''), - title: Text( - hasStatus - ? (status.text.isNotEmpty ? status.text : status.emoji) - : 'Set a status', - style: hasStatus - ? context.textTheme.bodyLarge - : context.textTheme.bodyLarge?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - subtitle: hasStatus - ? Text( - 'Tap to update', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ) - : null, - onTap: () => showSetStatusSheet(context, currentStatus: status), - ); - } -} - -class _StatusEmojiIcon extends ConsumerWidget { - final String emoji; - - const _StatusEmojiIcon({required this.emoji}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - if (emoji.isEmpty) { - return const Text('\u{1F4AC}', style: TextStyle(fontSize: 20)); - } - final shortcode = emoji.startsWith(':') && emoji.endsWith(':') - ? emoji.substring(1, emoji.length - 1).toLowerCase() - : null; - if (shortcode != null) { - for (final entry in ref.watch(customEmojiListProvider)) { - if (entry.shortcode == shortcode) { - return CustomEmojiImage( - shortcode: shortcode, - url: entry.url, - size: 24, - ); - } - } - } - return Text(emoji, style: const TextStyle(fontSize: 20)); - } -} - -class _AccentSwatch extends StatelessWidget { - const _AccentSwatch({ - required this.color, - required this.label, - required this.selected, - required this.onTap, - }); - - final Color color; - final String label; - final bool selected; - final VoidCallback onTap; + final String version; @override Widget build(BuildContext context) { - return Tooltip( - message: label, - child: GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - width: 36, - height: 36, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(Radii.md), - border: selected - ? Border.all(color: context.colors.onSurface, width: 2.5) - : Border.all(color: color.withValues(alpha: 0.4), width: 1), + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.only(bottom: Grid.xs, top: Grid.xxs), + child: Center( + child: Text( + 'v$version', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant.withValues(alpha: 0.6), + ), ), - child: selected - ? Icon( - LucideIcons.check, - size: 16, - color: contrastForeground(color), - ) - : null, ), ), ); } } + +/// Trailing affordance shared by the rows that push a picker page. +class _RowChevron extends StatelessWidget { + const _RowChevron(); + + @override + Widget build(BuildContext context) { + return Icon( + LucideIcons.chevronRight, + size: 18, + color: context.colors.onSurfaceVariant, + ); + } +} diff --git a/mobile/lib/features/settings/settings_page/appearance_section.dart b/mobile/lib/features/settings/settings_page/appearance_section.dart new file mode 100644 index 0000000000..26c9d20fe1 --- /dev/null +++ b/mobile/lib/features/settings/settings_page/appearance_section.dart @@ -0,0 +1,138 @@ +part of '../settings_page.dart'; + +/// System / Light / Dark, mirroring desktop's appearance-mode selector. +const _modeOptions = <({ThemeMode mode, String label, IconData icon})>[ + (mode: ThemeMode.system, label: 'System', icon: LucideIcons.sunMoon), + (mode: ThemeMode.light, label: 'Light', icon: LucideIcons.sun), + (mode: ThemeMode.dark, label: 'Dark', icon: LucideIcons.moon), +]; + +String _modeLabel(ThemeMode mode) => + _modeOptions.firstWhere((option) => option.mode == mode).label; + +class _AppearanceSection extends ConsumerWidget { + const _AppearanceSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mode = ref.watch(themeProvider); + final schemeName = ref.watch(schemeProvider); + final accentIndex = ref.watch(accentProvider); + + return AppListCard( + label: 'Style', + children: [ + AppListRow( + icon: LucideIcons.sunMoon, + title: 'Appearance', + value: _modeLabel(mode), + trailing: const _RowChevron(), + onTap: () => _showAppearanceModeSheet(context), + ), + AppListRow( + icon: LucideIcons.palette, + title: 'Theme', + value: themeSelectionLabel(schemeName, mode), + trailing: const _RowChevron(), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ThemePickerPage()), + ), + ), + AppListRow( + icon: LucideIcons.droplet, + title: 'Accent color', + // The swatch *is* the value — naming the color as well would say the + // same thing twice, so it takes the chevron's place. + trailing: _AccentSwatch(accentIndex: accentIndex), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AccentPickerPage()), + ), + ), + ], + ); + } +} + +void _showAppearanceModeSheet(BuildContext context) { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (_) => const _AppearanceModeSheet(), + ); +} + +/// Three choices is too few to warrant a page, so the appearance row opens a +/// sheet rather than pushing a route. +class _AppearanceModeSheet extends ConsumerWidget { + const _AppearanceModeSheet(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mode = ref.watch(themeProvider); + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xxs, + ), + child: Text('Appearance', style: context.textTheme.titleMedium), + ), + for (final option in _modeOptions) + AppListRow( + icon: option.icon, + title: option.label, + trailing: option.mode == mode + ? Icon( + LucideIcons.check, + size: 18, + color: context.colors.primary, + ) + : null, + onTap: () { + final schemeName = ref.read(schemeProvider); + final compatibleScheme = schemeForAppearanceMode( + schemeName, + option.mode, + ); + if (compatibleScheme != schemeName) { + ref.read(schemeProvider.notifier).setScheme(compatibleScheme); + } + ref.read(themeProvider.notifier).setMode(option.mode); + Navigator.of(context).pop(); + }, + ), + const SizedBox(height: Grid.xxs), + ], + ), + ); + } +} + +/// The selected accent, shown where a row would otherwise carry a chevron. +class _AccentSwatch extends StatelessWidget { + const _AccentSwatch({required this.accentIndex}); + + static const _size = 22.0; + + final int accentIndex; + + @override + Widget build(BuildContext context) { + return Container( + width: _size, + height: _size, + decoration: BoxDecoration( + color: accentColorForScheme(context.colors, accentIndex), + shape: BoxShape.circle, + border: Border.all(color: context.colors.outlineVariant), + ), + ); + } +} diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart new file mode 100644 index 0000000000..f34e88f26e --- /dev/null +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -0,0 +1,103 @@ +part of '../settings_page.dart'; + +class _ConnectionSection extends ConsumerWidget { + const _ConnectionSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final config = ref.watch(relayConfigProvider); + final nsec = config.nsec; + + return AppListCard( + label: 'Connection', + children: [ + AppListRow( + icon: LucideIcons.server, + title: 'Connected to', + subtitle: config.baseUrl, + ), + if (nsec != null && nsec.isNotEmpty) _IdentityRow(nsec: nsec), + ], + ); + } +} + +/// Destructive, so it gets a container of its own rather than sitting at the +/// bottom of the connection group. +class _RemoveCommunitySection extends ConsumerWidget { + const _RemoveCommunitySection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return AppListCard( + children: [ + AppListRow( + icon: LucideIcons.logOut, + title: 'Remove community', + titleColor: context.colors.error, + onTap: () => _confirmRemoveCommunity(context, ref), + ), + ], + ); + } +} + +class _IdentityRow extends StatelessWidget { + const _IdentityRow({required this.nsec}); + + final String nsec; + + @override + Widget build(BuildContext context) { + final privHex = nostr.Nip19.decode(payload: nsec).data; + final pubkey = privHex.isNotEmpty ? nostr.Keys(privHex).public : 'unknown'; + + return AppListRow( + icon: LucideIcons.key, + title: 'Identity (pubkey)', + subtitle: pubkey, + subtitleStyle: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontFamily: 'GeistMono', + fontSize: 11, + ), + subtitleMaxLines: 2, + trailing: IconButton( + icon: const Icon(LucideIcons.copy, size: 16), + onPressed: () async { + await copyToClipboard(context, pubkey, message: 'Pubkey copied'); + }, + ), + ); + } +} + +void _confirmRemoveCommunity(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Remove Community'), + content: const Text( + 'This will disconnect this community. You will need ' + 'to scan a new pairing code to reconnect.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(ctx).pop(); // close dialog + // Pop all pushed routes back to root so MaterialApp.home rebuilds + // to PairingPage when auth state changes. + Navigator.of(context).popUntil((route) => route.isFirst); + ref.read(authProvider.notifier).signOut(); + }, + style: FilledButton.styleFrom(backgroundColor: ctx.colors.error), + child: const Text('Remove'), + ), + ], + ), + ); +} diff --git a/mobile/lib/features/settings/theme_picker_page.dart b/mobile/lib/features/settings/theme_picker_page.dart index f9bbc8ea1d..aba6579148 100644 --- a/mobile/lib/features/settings/theme_picker_page.dart +++ b/mobile/lib/features/settings/theme_picker_page.dart @@ -7,6 +7,9 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; +/// Themes for the current appearance mode, organised the way desktop does it: +/// System offers the light/dark pairs as single entries, while Light and Dark +/// each list every theme of that brightness. class ThemePickerPage extends HookConsumerWidget { const ThemePickerPage({super.key}); @@ -15,41 +18,47 @@ class ThemePickerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final mode = ref.watch(themeProvider); final selectedScheme = ref.watch(schemeProvider); final searchQuery = useState(''); final searchController = useTextEditingController(); final scrollController = useScrollController(); - // Flat alphabetical list by display name - final sorted = List.from(themeCatalog) - ..sort((a, b) => a.displayName.compareTo(b.displayName)); + final groups = themeGroups(); + final entries = switch (mode) { + ThemeMode.system => groups.paired, + ThemeMode.light => groups.light, + ThemeMode.dark => groups.dark, + }; + final isSystem = mode == ThemeMode.system; + + String labelFor(ThemeColors theme) => + isSystem ? pairedThemeLabel(theme.name) : theme.displayName; final query = searchQuery.value.toLowerCase(); - final defaultLabel = 'Default ($defaultSchemeDisplayName)'; final filtered = query.isEmpty - ? sorted - : sorted - .where((t) => t.displayName.toLowerCase().contains(query)) + ? entries + : entries + .where((t) => labelFor(t).toLowerCase().contains(query)) .toList(); - final showDefault = - query.isEmpty || - 'default'.contains(query) || - defaultLabel.toLowerCase().contains(query); - // Default entry colors. - final defaultTheme = findTheme(defaultSchemeName); - final defaultBg = defaultTheme?.bg ?? lightColorScheme.surface; - final defaultFg = defaultTheme?.fg ?? lightColorScheme.onSurface; - final defaultComment = - defaultTheme?.comment ?? lightColorScheme.onSurfaceVariant; + // In System mode either half of a pair counts as the selection; in the + // pinned modes the effective theme already accounts for coercion, so the + // checkmark always names what is actually rendered. + final active = effectiveTheme(selectedScheme, mode); + bool isSelected(ThemeColors theme) { + if (active == null) return false; + if (!isSystem) return active.name == theme.name; + return active.name == theme.name || + themePairFor(theme.name) == active.name; + } - // Auto-scroll to the selected theme on first build (no search active) + // Auto-scroll to the selected theme on first build (no search active). useEffect(() { - if (selectedScheme == null || query.isNotEmpty) return null; - final idx = sorted.indexWhere((t) => t.name == selectedScheme); + if (query.isNotEmpty) return null; + final idx = filtered.indexWhere(isSelected); if (idx < 0) return null; - // +1 to account for the Default entry at the top - final offset = (idx + 1) * _itemHeight; + final offset = idx * _itemHeight; WidgetsBinding.instance.addPostFrameCallback((_) { if (scrollController.hasClients) { scrollController.animateTo( @@ -63,98 +72,22 @@ class ThemePickerPage extends HookConsumerWidget { }, const []); return FrostedScaffold( - appBar: const FrostedAppBar(title: Text('Color Scheme')), + appBar: const FrostedAppBar(title: Text('Theme')), body: Column( children: [ SizedBox(height: frostedAppBarHeight(context)), - // Always-visible search bar - Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.xxs, - ), - child: Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.lg), - border: Border.all(color: context.colors.outlineVariant), - ), - child: Row( - children: [ - Icon( - LucideIcons.search, - size: 16, - color: context.colors.onSurfaceVariant, - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: TextField( - controller: searchController, - decoration: InputDecoration( - hintText: 'Search themes...', - hintStyle: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), - style: context.textTheme.bodyMedium, - onChanged: (v) => searchQuery.value = v, - ), - ), - if (searchQuery.value.isNotEmpty) - GestureDetector( - onTap: () { - searchController.clear(); - searchQuery.value = ''; - }, - child: Icon( - LucideIcons.x, - size: 16, - color: context.colors.onSurfaceVariant, - ), - ), - ], - ), - ), + _SearchField( + controller: searchController, + query: searchQuery.value, + onChanged: (v) => searchQuery.value = v, + onClear: () { + searchController.clear(); + searchQuery.value = ''; + }, ), - - // Theme list Expanded( - child: ListView( - controller: scrollController, - children: [ - // Default entry - if (showDefault) - _ThemeRow( - bg: defaultBg, - fg: defaultFg, - comment: defaultComment, - label: defaultLabel, - selected: selectedScheme == null, - onTap: () => - ref.read(schemeProvider.notifier).setScheme(null), - ), - - // All themes, flat alphabetical - for (final theme in filtered) - _ThemeRow( - bg: theme.bg, - fg: theme.fg, - comment: theme.comment, - label: theme.displayName, - selected: selectedScheme == theme.name, - onTap: () => - ref.read(schemeProvider.notifier).setScheme(theme.name), - ), - - if (!showDefault && filtered.isEmpty) - Padding( + child: filtered.isEmpty + ? Padding( padding: const EdgeInsets.all(Grid.sm), child: Center( child: Text( @@ -164,9 +97,26 @@ class ThemePickerPage extends HookConsumerWidget { ), ), ), + ) + : ListView.builder( + controller: scrollController, + itemCount: filtered.length, + itemBuilder: (_, i) { + final theme = filtered[i]; + final pairName = isSystem + ? themePairFor(theme.name) + : null; + return _ThemeRow( + theme: theme, + pair: pairName == null ? null : findTheme(pairName), + label: labelFor(theme), + selected: isSelected(theme), + onTap: () => ref + .read(schemeProvider.notifier) + .setScheme(theme.name), + ); + }, ), - ], - ), ), ], ), @@ -174,50 +124,118 @@ class ThemePickerPage extends HookConsumerWidget { } } -/// A row showing a 3-stripe color bar (bg/fg/comment) + theme name + checkmark. +class _SearchField extends StatelessWidget { + const _SearchField({ + required this.controller, + required this.query, + required this.onChanged, + required this.onClear, + }); + + final TextEditingController controller; + final String query; + final ValueChanged onChanged; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.gutter, + vertical: Grid.xxs, + ), + child: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.lg), + border: Border.all(color: context.colors.outlineVariant), + ), + child: Row( + children: [ + Icon( + LucideIcons.search, + size: 16, + color: context.colors.onSurfaceVariant, + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: TextField( + controller: controller, + decoration: InputDecoration( + hintText: 'Search themes...', + hintStyle: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + style: context.textTheme.bodyMedium, + onChanged: onChanged, + ), + ), + if (query.isNotEmpty) + GestureDetector( + onTap: onClear, + child: Icon( + LucideIcons.x, + size: 16, + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} + +/// A theme row with a colour bar + name + checkmark. When [pair] is supplied the +/// bar is split down the middle — light stripes on the left, dark on the right — +/// standing in for desktop's split light/dark preview tile. class _ThemeRow extends StatelessWidget { const _ThemeRow({ - required this.bg, - required this.fg, - required this.comment, + required this.theme, + required this.pair, required this.label, required this.selected, required this.onTap, }); - final Color bg; - final Color fg; - final Color comment; + final ThemeColors theme; + final ThemeColors? pair; final String label; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { + final swatch = pair == null ? [theme] : [theme, pair!]; + return ListTile( leading: Container( width: 56, height: 28, decoration: BoxDecoration( borderRadius: BorderRadius.circular(Radii.sm), - border: Border.all(color: context.colors.outlineVariant, width: 1), + border: Border.all(color: context.colors.outlineVariant), ), child: ClipRRect( borderRadius: BorderRadius.circular(Radii.sm - 1), child: Row( children: [ - Expanded( - child: ColoredBox(color: bg, child: const SizedBox.expand()), - ), - Expanded( - child: ColoredBox(color: fg, child: const SizedBox.expand()), - ), - Expanded( - child: ColoredBox( - color: comment, - child: const SizedBox.expand(), - ), - ), + for (final t in swatch) + for (final color in [t.bg, t.fg, t.comment]) + Expanded( + child: ColoredBox( + color: color, + child: const SizedBox.expand(), + ), + ), ], ), ), diff --git a/mobile/lib/features/custom_emoji/custom_emoji.dart b/mobile/lib/shared/custom_emoji/custom_emoji.dart similarity index 99% rename from mobile/lib/features/custom_emoji/custom_emoji.dart rename to mobile/lib/shared/custom_emoji/custom_emoji.dart index 5fe51151af..da2e2d2909 100644 --- a/mobile/lib/features/custom_emoji/custom_emoji.dart +++ b/mobile/lib/shared/custom_emoji/custom_emoji.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -import '../../shared/relay/nostr_models.dart'; +import '../relay/nostr_models.dart'; /// A community custom emoji: a `:shortcode:` mapped to an image URL. /// diff --git a/mobile/lib/features/custom_emoji/custom_emoji_provider.dart b/mobile/lib/shared/custom_emoji/custom_emoji_provider.dart similarity index 98% rename from mobile/lib/features/custom_emoji/custom_emoji_provider.dart rename to mobile/lib/shared/custom_emoji/custom_emoji_provider.dart index be866e17c4..1779678a0d 100644 --- a/mobile/lib/features/custom_emoji/custom_emoji_provider.dart +++ b/mobile/lib/shared/custom_emoji/custom_emoji_provider.dart @@ -1,6 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../shared/relay/relay.dart'; +import '../relay/relay.dart'; import 'custom_emoji.dart'; /// Community custom-emoji palette (NIP-30, per-user kind:30030 sets unioned). diff --git a/mobile/lib/features/custom_emoji/custom_emoji_render.dart b/mobile/lib/shared/custom_emoji/custom_emoji_render.dart similarity index 98% rename from mobile/lib/features/custom_emoji/custom_emoji_render.dart rename to mobile/lib/shared/custom_emoji/custom_emoji_render.dart index 549babe608..e4af0cc43f 100644 --- a/mobile/lib/features/custom_emoji/custom_emoji_render.dart +++ b/mobile/lib/shared/custom_emoji/custom_emoji_render.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:gpt_markdown/custom_widgets/markdown_config.dart'; -import '../../shared/relay/relay.dart'; +import '../relay/relay.dart'; import 'custom_emoji.dart'; diff --git a/mobile/lib/shared/theme/app_colors.dart b/mobile/lib/shared/theme/app_colors.dart index 21c47eb0d1..2a937ec6b6 100644 --- a/mobile/lib/shared/theme/app_colors.dart +++ b/mobile/lib/shared/theme/app_colors.dart @@ -6,19 +6,31 @@ class AppColors extends ThemeExtension { final Color warning; final Color accent; + /// Gradient for the app's top section, non-null only under the Buzz themes. + /// Carried on the theme rather than read from a provider so any surface can + /// opt in via `context.appColors.topSectionGradient` — see + /// `buzzTopSectionGradient`. + final Gradient? topSectionGradient; + const AppColors({ required this.success, required this.warning, required this.accent, + this.topSectionGradient, }); @override - AppColors copyWith({Color? success, Color? warning, Color? accent}) => - AppColors( - success: success ?? this.success, - warning: warning ?? this.warning, - accent: accent ?? this.accent, - ); + AppColors copyWith({ + Color? success, + Color? warning, + Color? accent, + Gradient? topSectionGradient, + }) => AppColors( + success: success ?? this.success, + warning: warning ?? this.warning, + accent: accent ?? this.accent, + topSectionGradient: topSectionGradient ?? this.topSectionGradient, + ); @override AppColors lerp(ThemeExtension? other, double t) { @@ -27,6 +39,11 @@ class AppColors extends ThemeExtension { success: Color.lerp(success, other.success, t)!, warning: Color.lerp(warning, other.warning, t)!, accent: Color.lerp(accent, other.accent, t)!, + topSectionGradient: Gradient.lerp( + topSectionGradient, + other.topSectionGradient, + t, + ), ); } } diff --git a/mobile/lib/shared/theme/app_theme.dart b/mobile/lib/shared/theme/app_theme.dart index f05c579a32..c9ace49eee 100644 --- a/mobile/lib/shared/theme/app_theme.dart +++ b/mobile/lib/shared/theme/app_theme.dart @@ -13,16 +13,21 @@ class Radii { static const double lg = 10.0; static const double md = 8.0; static const double sm = 6.0; + static const double card = 12.0; // grouped settings cards static const double dialog = 24.0; // desktop uses rounded-3xl for dialogs } class AppTheme { - static ThemeData light({ColorScheme? colorScheme}) { + static ThemeData light({ + ColorScheme? colorScheme, + Gradient? topSectionGradient, + }) { final scheme = colorScheme ?? lightColorScheme; final appColors = AppColors( success: const Color(0xFF40A02B), // Catppuccin Latte Green — universal warning: const Color(0xFFDF8E1D), // Latte Yellow accent: scheme.tertiary, + topSectionGradient: topSectionGradient, ); return _buildTheme( @@ -34,7 +39,10 @@ class AppTheme { ); } - static ThemeData dark({ColorScheme? colorScheme}) { + static ThemeData dark({ + ColorScheme? colorScheme, + Gradient? topSectionGradient, + }) { final scheme = colorScheme ?? darkColorScheme; final appColors = AppColors( success: const Color( @@ -42,6 +50,7 @@ class AppTheme { ), // Catppuccin Macchiato Green — universal warning: const Color(0xFFEED49F), // Macchiato Yellow accent: scheme.tertiary, + topSectionGradient: topSectionGradient, ); return _buildTheme( diff --git a/mobile/lib/shared/theme/buzz_theme.dart b/mobile/lib/shared/theme/buzz_theme.dart new file mode 100644 index 0000000000..1214b97790 --- /dev/null +++ b/mobile/lib/shared/theme/buzz_theme.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +/// Name of the first-party Buzz theme. Buzz reuses the GitHub Light palette for +/// every base color; the one thing that sets it apart is a branded gradient +/// painted across the app's top section. Mirrors desktop, where the same +/// gradient fills the sidebar canvas — see `data-buzz-sidebar` in +/// `desktop/src/shared/styles/globals/theme.css`. +const buzzThemeName = 'buzz'; + +/// Name of the dark counterpart, which reuses the GitHub Dark palette and the +/// dark-tuned gradient stops. Paired with [buzzThemeName] in `themePairs`, so +/// the two behave as a single "Buzz" choice under System mode. +const buzzDarkThemeName = 'buzz-dark'; + +/// Whether [themeName] is either half of the Buzz pair. Both halves enable the +/// gradient so System mode keeps it on across an OS light/dark switch. +bool isBuzzTheme(String themeName) => + themeName == buzzThemeName || themeName == buzzDarkThemeName; + +/// Gradient stops, matching desktop's `--buzz-gradient-*` custom properties. +const _lightTop = Color(0xFFE6E6B6); +const _lightBottom = Color(0xFFC4D0DA); +const _darkTop = Color(0xFF4A4616); +const _darkBottom = Color(0xFF0A1423); + +/// The Buzz gradient for the app's top section, or null when [themeName] is not +/// a Buzz theme — in which case the section keeps its default frosted fill. +/// +/// The stops are fully opaque: under Buzz the color replaces the frosted +/// treatment rather than tinting it, matching desktop's solid sidebar canvas. +/// +/// [brightness] comes from the applied color scheme rather than the theme name, +/// so System mode picks the right stops as the OS switches. +LinearGradient? buzzTopSectionGradient( + String themeName, + Brightness brightness, +) { + if (!isBuzzTheme(themeName)) return null; + + final isDark = brightness == Brightness.dark; + return LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + isDark ? _darkTop : _lightTop, + isDark ? _darkBottom : _lightBottom, + ], + ); +} diff --git a/mobile/lib/shared/theme/theme.dart b/mobile/lib/shared/theme/theme.dart index e8a956661c..f1aae3609b 100644 --- a/mobile/lib/shared/theme/theme.dart +++ b/mobile/lib/shared/theme/theme.dart @@ -2,8 +2,10 @@ export 'accent_colors.dart'; export 'adaptive_theme.dart'; export 'app_colors.dart'; export 'app_theme.dart'; +export 'buzz_theme.dart'; export 'color_scheme.dart'; export 'grid.dart'; export 'theme_catalog.dart'; export 'theme_extensions.dart'; +export 'theme_pairs.dart'; export 'theme_provider.dart'; diff --git a/mobile/lib/shared/theme/theme_catalog.dart b/mobile/lib/shared/theme/theme_catalog.dart index 3a9780f201..9e00cfc811 100644 --- a/mobile/lib/shared/theme/theme_catalog.dart +++ b/mobile/lib/shared/theme/theme_catalog.dart @@ -33,6 +33,7 @@ class ThemeColors { /// Known light theme names — used to show sun/moon icons before loading. const lightThemeNames = { + 'buzz', 'catppuccin-latte', 'everforest-light', 'github-light', @@ -79,6 +80,25 @@ const themeCatalog = [ added: Color(0xFF70BF56), deleted: Color(0xFFF26D78), ), + // Buzz and Buzz Dark are first-party: they borrow the GitHub Light / GitHub + // Dark palettes wholesale and are distinguished only by the branded gradient + // painted across the app's top section (see buzz_theme.dart). + ThemeColors( + name: 'buzz', + bg: Color(0xFFFFFFFF), + fg: Color(0xFF24292E), + comment: Color(0xFF6A737D), + added: Color(0xFF28A745), + deleted: Color(0xFFD73A49), + ), + ThemeColors( + name: 'buzz-dark', + bg: Color(0xFF24292E), + fg: Color(0xFFE1E4E8), + comment: Color(0xFF6A737D), + added: Color(0xFF34D058), + deleted: Color(0xFFEA4A5A), + ), ThemeColors( name: 'catppuccin-frappe', bg: Color(0xFF303446), diff --git a/mobile/lib/shared/theme/theme_pairs.dart b/mobile/lib/shared/theme/theme_pairs.dart new file mode 100644 index 0000000000..cfbf6a53a8 --- /dev/null +++ b/mobile/lib/shared/theme/theme_pairs.dart @@ -0,0 +1,115 @@ +import 'theme_catalog.dart'; + +/// Light → dark theme counterparts, ported from the desktop app's `THEME_PAIRS` +/// (`desktop/src/shared/theme/theme-loader.ts`) so both clients offer the same +/// System-mode pairings. +/// +/// Buzz leads the map the way it leads desktop's, so the first-party pair sorts +/// ahead of the borrowed syntax themes wherever insertion order is preserved. +const themePairs = { + 'buzz': 'buzz-dark', + 'catppuccin-latte': 'catppuccin-mocha', + 'everforest-light': 'everforest-dark', + 'github-light': 'github-dark', + 'github-light-default': 'github-dark-default', + 'github-light-high-contrast': 'github-dark-high-contrast', + 'gruvbox-light-hard': 'gruvbox-dark-hard', + 'gruvbox-light-medium': 'gruvbox-dark-medium', + 'gruvbox-light-soft': 'gruvbox-dark-soft', + 'kanagawa-lotus': 'kanagawa-wave', + 'light-plus': 'dark-plus', + 'material-theme-lighter': 'material-theme', + 'min-light': 'min-dark', + 'one-light': 'one-dark-pro', + 'rose-pine-dawn': 'rose-pine', + 'slack-ochin': 'slack-dark', + 'solarized-light': 'solarized-dark', + 'vitesse-light': 'vitesse-dark', +}; + +final Map _darkToLight = { + for (final entry in themePairs.entries) entry.value: entry.key, +}; + +/// The counterpart of [name] in the opposite brightness, or null when the theme +/// has no pair. Resolves in both directions. +String? themePairFor(String name) => themePairs[name] ?? _darkToLight[name]; + +/// Whether [name] participates in a light/dark pair, and so can follow the OS. +bool isPairedTheme(String name) => themePairFor(name) != null; + +/// [themeCatalog] partitioned the way the appearance picker offers it. +class ThemeGroups { + /// Light members of light/dark pairs — the System-mode options. Each stands + /// in for both halves of its pair. + final List paired; + + /// Every light theme, paired or not — the Light-mode options. + final List light; + + /// Every dark theme, paired or not — the Dark-mode options. + final List dark; + + const ThemeGroups({ + required this.paired, + required this.light, + required this.dark, + }); +} + +ThemeGroups? _groups; + +/// [themeCatalog] grouped for the appearance picker, each list sorted by +/// display name. Computed once — the catalog is a compile-time constant. +ThemeGroups themeGroups() { + final cached = _groups; + if (cached != null) return cached; + + final paired = []; + final light = []; + final dark = []; + + for (final theme in themeCatalog) { + if (theme.isDark) { + dark.add(theme); + continue; + } + light.add(theme); + if (themePairs.containsKey(theme.name)) paired.add(theme); + } + + int byDisplayName(ThemeColors a, ThemeColors b) => + a.displayName.compareTo(b.displayName); + paired.sort(byDisplayName); + light.sort(byDisplayName); + dark.sort(byDisplayName); + + return _groups = ThemeGroups(paired: paired, light: light, dark: dark); +} + +/// Tokens that only describe a theme's brightness, stripped from paired labels. +const _modeTokens = { + 'light', + 'latte', + 'dawn', + 'lotus', + 'ochin', + 'lighter', + 'plus', +}; + +/// Display label for a pair, derived from its light member: strips the +/// mode-specific tokens so `github-light` reads as "Github" and stands for both +/// halves. Mirrors desktop's `pairedThemeLabel`. +String pairedThemeLabel(String lightName) { + final stripped = lightName + .split('-') + .where((token) => !_modeTokens.contains(token)) + .toList(); + // Stripping can remove everything (e.g. "light-plus") — fall back to the raw + // name so the row is never blank. + final parts = stripped.isEmpty ? lightName.split('-') : stripped; + return parts + .map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}') + .join(' '); +} diff --git a/mobile/lib/shared/theme/theme_provider.dart b/mobile/lib/shared/theme/theme_provider.dart index 79f47dcab4..908134ebf7 100644 --- a/mobile/lib/shared/theme/theme_provider.dart +++ b/mobile/lib/shared/theme/theme_provider.dart @@ -4,21 +4,26 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'accent_colors.dart'; import 'adaptive_theme.dart'; +import 'buzz_theme.dart'; import 'color_scheme.dart'; import 'theme_catalog.dart'; +import 'theme_pairs.dart'; const _themeModeKey = 'buzz_theme_mode'; const _accentKey = 'buzz_accent_color'; const _schemeKey = 'buzz_color_scheme'; -const defaultSchemeName = 'github-light'; -const defaultSchemeDisplayName = 'GitHub Light'; +/// Buzz ships as the default: the first-party pair, so a fresh install gets the +/// branded top-section gradient without picking a theme first. +const defaultSchemeName = buzzThemeName; +const defaultSchemeDisplayName = 'Buzz'; /// Pre-loaded SharedPreferences instance, overridden in main(). final savedPrefsProvider = Provider( (_) => throw UnimplementedError('Must be overridden'), ); +/// Tracks the appearance mode: follow the OS, or pin light/dark. class ThemeNotifier extends Notifier { @override ThemeMode build() { @@ -30,6 +35,11 @@ class ThemeNotifier extends Notifier { } return ThemeMode.system; } + + void setMode(ThemeMode mode) { + state = mode; + ref.read(savedPrefsProvider).setString(_themeModeKey, mode.name); + } } final themeProvider = NotifierProvider( @@ -68,19 +78,21 @@ class SchemeNotifier extends Notifier { @override String? build() { final prefs = ref.read(savedPrefsProvider); - final scheme = prefs.getString(_schemeKey); + final stored = prefs.getString(_schemeKey); + final compatible = schemeForAppearanceMode( + stored, + ref.watch(themeProvider), + ); - // One-time migration: if no scheme has been chosen and the user had a - // non-system themeMode persisted (from the old Light/System/Dark toggle), - // reset it because default schemes now control their own brightness. - if (scheme == null) { - final storedMode = prefs.getString(_themeModeKey); - if (storedMode != null && storedMode != ThemeMode.system.name) { - prefs.setString(_themeModeKey, ThemeMode.system.name); + if (compatible != stored) { + if (compatible == null) { + prefs.remove(_schemeKey); + } else { + prefs.setString(_schemeKey, compatible); } } - return scheme; + return compatible; } void setScheme(String? name) { @@ -98,25 +110,160 @@ final schemeProvider = NotifierProvider( SchemeNotifier.new, ); -/// Resolves the current scheme selection into light and dark [ColorScheme]s. -/// When a named scheme is selected, generates it via the adaptive engine. -/// When null (default), resolves to [defaultSchemeName]. -({ColorScheme light, ColorScheme dark, ThemeMode? forcedMode}) resolveSchemes( - String? schemeName, -) { - final theme = +/// Returns a scheme that can honor [mode] without silently pinning brightness. +/// +/// System mode only offers paired themes because they have both light and dark +/// variants. Switching to it from an unpaired or unknown selection therefore +/// falls back to the first paired theme, matching the picker ordering. +String? schemeForAppearanceMode(String? schemeName, ThemeMode mode) { + if (mode != ThemeMode.system) return schemeName; + + final selected = findTheme(schemeName ?? defaultSchemeName); + if (selected != null && isPairedTheme(selected.name)) return schemeName; + + return themeGroups().paired.firstOrNull?.name ?? schemeName; +} + +/// Resolves the selected scheme and appearance [mode] into light and dark +/// [ColorScheme]s, mirroring desktop's System / Light / Dark behaviour. +/// +/// In [ThemeMode.system] a paired theme is rendered with its pair +/// ([themePairFor]) so Flutter swaps them with the OS. An unpaired theme is +/// pinned to its own brightness. In [ThemeMode.light] and [ThemeMode.dark] the +/// theme is coerced to that brightness and the mode is forced, so the OS setting +/// is ignored. +({ + ColorScheme light, + ColorScheme dark, + ThemeColors? lightTheme, + ThemeColors? darkTheme, + ThemeMode? forcedMode, +}) +resolveSchemes(String? schemeName, ThemeMode mode) { + final selected = findTheme(schemeName ?? defaultSchemeName) ?? findTheme(defaultSchemeName); - if (theme == null) { - return (light: lightColorScheme, dark: darkColorScheme, forcedMode: null); + if (selected == null) { + return ( + light: lightColorScheme, + dark: darkColorScheme, + lightTheme: null, + darkTheme: null, + forcedMode: null, + ); } - final scheme = generateColorScheme(theme); - - // A named scheme is inherently light or dark — force the mode. - return ( - light: scheme, - dark: scheme, - forcedMode: theme.isDark ? ThemeMode.dark : ThemeMode.light, - ); + switch (mode) { + case ThemeMode.system: + final pairName = themePairFor(selected.name); + if (pairName == null) { + final scheme = generateColorScheme(selected); + return ( + light: scheme, + dark: scheme, + lightTheme: selected, + darkTheme: selected, + forcedMode: selected.isDark ? ThemeMode.dark : ThemeMode.light, + ); + } + final counterpart = findTheme(pairName) ?? selected; + final lightTheme = selected.isDark ? counterpart : selected; + final darkTheme = selected.isDark ? selected : counterpart; + return ( + light: generateColorScheme(lightTheme), + dark: generateColorScheme(darkTheme), + lightTheme: lightTheme, + darkTheme: darkTheme, + forcedMode: null, + ); + case ThemeMode.light: + final theme = _themeForBrightness(selected, wantDark: false); + final scheme = generateColorScheme(theme); + return ( + light: scheme, + dark: scheme, + lightTheme: theme, + darkTheme: theme, + forcedMode: ThemeMode.light, + ); + case ThemeMode.dark: + final theme = _themeForBrightness(selected, wantDark: true); + final scheme = generateColorScheme(theme); + return ( + light: scheme, + dark: scheme, + lightTheme: theme, + darkTheme: theme, + forcedMode: ThemeMode.dark, + ); + } +} + +/// The theme whose colors are actually applied for [schemeName] under [mode]. +/// +/// In [ThemeMode.system] that is the selected theme itself — its pair covers the +/// other brightness. In the pinned modes it is the theme coerced to the pinned +/// brightness, which is what the picker highlights so the checkmark always +/// tracks what is on screen. +ThemeColors? effectiveTheme(String? schemeName, ThemeMode mode) { + final selected = + findTheme(schemeName ?? defaultSchemeName) ?? + findTheme(defaultSchemeName); + if (selected == null) return null; + + return switch (mode) { + ThemeMode.system => selected, + ThemeMode.light => _themeForBrightness(selected, wantDark: false), + ThemeMode.dark => _themeForBrightness(selected, wantDark: true), + }; +} + +/// Label for the current theme selection. System mode names the pair as a whole +/// ("Github" rather than "Github Light"), since both halves are in play. +String themeSelectionLabel(String? schemeName, ThemeMode mode) { + final theme = effectiveTheme(schemeName, mode); + if (theme == null) return defaultSchemeDisplayName; + if (mode != ThemeMode.system) return theme.displayName; + + final lightMember = themePairs.containsKey(theme.name) + ? theme.name + : themePairFor(theme.name); + return lightMember == null + ? theme.displayName + : pairedThemeLabel(lightMember); +} + +/// Coerces [selected] to the requested brightness: itself when it already +/// matches, otherwise its pair, otherwise the first catalog theme of that +/// brightness. Keeps a stored light theme usable after switching to Dark mode +/// without rewriting the user's saved selection. +ThemeColors _themeForBrightness( + ThemeColors selected, { + required bool wantDark, +}) { + if (selected.isDark == wantDark) return selected; + + final pairName = themePairFor(selected.name); + final paired = pairName == null ? null : findTheme(pairName); + if (paired != null && paired.isDark == wantDark) return paired; + + // Match desktop's paired-first picker ordering: an unpaired selection falls + // back through the first-party default pair, not whichever borrowed theme + // sorts first in the full brightness group. + final defaultTheme = findTheme(defaultSchemeName); + if (defaultTheme != null) { + if (defaultTheme.isDark == wantDark) return defaultTheme; + + final defaultPairName = themePairFor(defaultTheme.name); + final defaultPair = defaultPairName == null + ? null + : findTheme(defaultPairName); + if (defaultPair != null && defaultPair.isDark == wantDark) { + return defaultPair; + } + } + + final groups = themeGroups(); + final fallback = wantDark ? groups.dark : groups.light; + return fallback.isEmpty ? selected : fallback.first; } diff --git a/mobile/lib/shared/widgets/app_list.dart b/mobile/lib/shared/widgets/app_list.dart index 53557925a0..393b1d70ba 100644 --- a/mobile/lib/shared/widgets/app_list.dart +++ b/mobile/lib/shared/widgets/app_list.dart @@ -1,10 +1,19 @@ import 'package:flutter/material.dart'; import '../theme/theme.dart'; +import 'app_list_inset.dart'; + +/// Widest an inline [AppListRow.value] may grow before it ellipsises, leaving +/// room for a reasonable title beside it. +const double _maxValueWidth = 180.0; + +/// Row height comes entirely from this padding — rows carry a single line of +/// text most of the time, so it sets how airy a card reads. +const double _rowVerticalPadding = Grid.xs; /// A flush, borderless settings/list row: leading icon, title, optional -/// subtitle and trailing widget. No card, no background — groups are -/// separated by [AppListSection] dividers instead. +/// subtitle and trailing widget. Its own background comes from whatever +/// contains it — a grouped card, or the page itself. class AppListRow extends StatelessWidget { const AppListRow({ super.key, @@ -13,6 +22,7 @@ class AppListRow extends StatelessWidget { this.subtitle, this.subtitleStyle, this.subtitleMaxLines, + this.value, this.trailing, this.titleColor, this.onTap, @@ -23,6 +33,11 @@ class AppListRow extends StatelessWidget { final String? subtitle; final TextStyle? subtitleStyle; final int? subtitleMaxLines; + + /// The row's current setting, shown muted on the trailing side next to + /// [trailing] — for rows whose value is short enough to sit inline. + final String? value; + final Widget? trailing; final Color? titleColor; final VoidCallback? onTap; @@ -30,21 +45,20 @@ class AppListRow extends StatelessWidget { @override Widget build(BuildContext context) { final row = Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.twelve, + padding: EdgeInsets.symmetric( + horizontal: AppListInset.of(context), + vertical: _rowVerticalPadding, ), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + // Centred rather than baseline-aligned to the title: on a two-line row + // the icon and trailing control read as belonging to the row, not to + // its first line. children: [ if (icon != null) ...[ - Padding( - padding: const EdgeInsets.only(top: 1), - child: Icon( - icon, - size: 22, - color: titleColor ?? context.colors.onSurfaceVariant, - ), + Icon( + icon, + size: 22, + color: titleColor ?? context.colors.onSurfaceVariant, ), const SizedBox(width: Grid.xs), ], @@ -77,6 +91,24 @@ class AppListRow extends StatelessWidget { ], ), ), + if (value != null) ...[ + const SizedBox(width: Grid.xxs), + // Inflexible, so the title's Expanded absorbs the slack and the + // value stays flush against the trailing edge; capped instead of + // flexed so a long value ellipsises rather than overflowing. + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _maxValueWidth), + child: Text( + value!, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + ), + ), + ], if (trailing != null) ...[const SizedBox(width: Grid.xxs), trailing!], ], ), @@ -108,9 +140,9 @@ class AppListRowRaw extends StatelessWidget { @override Widget build(BuildContext context) { final row = Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.twelve, + padding: EdgeInsets.symmetric( + horizontal: AppListInset.of(context), + vertical: _rowVerticalPadding, ), child: Row( children: [ @@ -138,51 +170,3 @@ class AppListRowRaw extends StatelessWidget { return InkWell(onTap: onTap, child: row); } } - -/// A group of list rows separated from the previous group by a hairline -/// divider with breathing room, Slack-style. An optional [label] renders a -/// small muted header above the rows. -class AppListSection extends StatelessWidget { - const AppListSection({ - super.key, - this.label, - required this.children, - this.showDivider = true, - }); - - final String? label; - final List children; - final bool showDivider; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showDivider) - Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - child: Divider(height: 1, color: context.colors.outlineVariant), - ), - if (label != null) - Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - Grid.xxs, - Grid.gutter, - Grid.quarter, - ), - child: Text( - label!.toUpperCase(), - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - letterSpacing: 0.6, - ), - ), - ), - ...children, - ], - ); - } -} diff --git a/mobile/lib/shared/widgets/app_list_card.dart b/mobile/lib/shared/widgets/app_list_card.dart new file mode 100644 index 0000000000..20a569e2e3 --- /dev/null +++ b/mobile/lib/shared/widgets/app_list_card.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +import '../theme/theme.dart'; +import 'app_list_inset.dart'; + +/// A group of list rows in a rounded container, with an optional [label] above +/// it. Rows inside are hairline-separated and inset to the card rather than the +/// page, via [AppListInset]. +class AppListCard extends StatelessWidget { + const AppListCard({super.key, this.label, required this.children}); + + /// Rendered above the card in sentence case, as written — no uppercasing. + final String? label; + + final List children; + + static const _inset = Grid.xs; + + /// Separators start at the label column, clearing the leading icon. + static const _dividerIndent = _inset + _iconColumnWidth; + static const _iconColumnWidth = 22.0 + Grid.xs; + + @override + Widget build(BuildContext context) { + final separated = []; + for (var index = 0; index < children.length; index++) { + if (index > 0) { + separated.add( + Divider( + height: 1, + thickness: 1, + indent: _dividerIndent, + endIndent: _inset, + // The scheme's own border tokens are derived from the page surface, + // which lands them within a few levels of the card fill — invisible. + // Tinting with the text color instead keeps the hairline readable on + // the card in both brightnesses. + color: context.colors.onSurface.withValues(alpha: 0.12), + ), + ); + } + separated.add(children[index]); + } + + return Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xxs, + Grid.gutter, + Grid.xxs, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) + Padding( + padding: const EdgeInsets.only(left: Grid.half, bottom: Grid.xxs), + child: Text( + label!, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + Material( + // The softest step on the elevation ramp — a rung below the home tab + // bar's active pill (primaryContainer, see PR #2810), since a + // full-width card at the pill's contrast reads heavier than the pill + // does. The dividers carry the group structure, so the fill only has + // to separate the card from the page. + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.card), + // Keeps row ripples inside the rounded corners. + clipBehavior: Clip.antiAlias, + child: AppListInset( + horizontal: _inset, + child: Column(children: separated), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/app_list_inset.dart b/mobile/lib/shared/widgets/app_list_inset.dart new file mode 100644 index 0000000000..122894e4f6 --- /dev/null +++ b/mobile/lib/shared/widgets/app_list_inset.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +import '../theme/theme.dart'; + +/// The horizontal padding list rows use, allowing grouped containers to replace +/// the page-level inset without changing each row. +class AppListInset extends InheritedWidget { + const AppListInset({ + super.key, + required this.horizontal, + required super.child, + }); + + final double horizontal; + + static double of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.horizontal ?? + Grid.gutter; + + @override + bool updateShouldNotify(AppListInset oldWidget) => + oldWidget.horizontal != horizontal; +} diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index 34a2da8970..9fa76781bd 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -36,6 +36,11 @@ class FrostedAppBar extends StatelessWidget { /// Color applied to icons in the app bar. final Color? iconColor; + /// Paints over the frosted fill instead of the default translucent surface. + /// Used by the Buzz themes to carry their branded gradient across the app's + /// top section — see [buzzTopSectionGradient]. + final Gradient? gradient; + const FrostedAppBar({ super.key, this.leading, @@ -43,6 +48,7 @@ class FrostedAppBar extends StatelessWidget { this.actions = const [], this.horizontalInset = Grid.quarter, this.iconColor, + this.gradient, }); @override @@ -75,7 +81,12 @@ class FrostedAppBar extends StatelessWidget { child: Container( padding: EdgeInsets.only(top: topPadding), decoration: BoxDecoration( - color: context.colors.surface.withValues(alpha: 0.5), + // A gradient and a color cannot both paint, so the gradient + // replaces the frosted surface fill when one is supplied. + color: gradient == null + ? context.colors.surface.withValues(alpha: 0.5) + : null, + gradient: gradient, border: Border( bottom: BorderSide( color: context.colors.outlineVariant.withValues(alpha: 0.3), diff --git a/mobile/lib/shared/widgets/masked_avatar_badge.dart b/mobile/lib/shared/widgets/masked_avatar_badge.dart new file mode 100644 index 0000000000..4d476abfd4 --- /dev/null +++ b/mobile/lib/shared/widgets/masked_avatar_badge.dart @@ -0,0 +1,356 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +/// Avatar-with-badge geometry ported from desktop's `MaskedAvatarBadgeFrame` +/// (`desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx`). +/// +/// A badge (presence dot, status glyph) sits in a notch cut out of the avatar +/// rather than on top of it. The notch is not a plain circular subtraction: the +/// two edges are joined by cubic fillets so the avatar's outline flows into the +/// cutout instead of meeting it at a cusp. That fillet is the whole visual +/// signature, which is why the curve constants are ported verbatim. +@immutable +class AvatarBadgeCurve { + const AvatarBadgeCurve({ + this.avatarRoundingAngle = 0.075, + this.cutoutRoundingLength = 5.5, + this.cutoutRoundingMinAngle = 0.22, + this.cutoutRoundingMaxAngle = 0.38, + this.handleDistanceRatio = 0.42, + this.handleLengthRatio = 0.14, + }); + + /// Radians the avatar's edge pulls back from the intersection before the + /// fillet starts. + final double avatarRoundingAngle; + + /// Fillet length along the cutout, converted to an angle by dividing by the + /// cutout radius and clamped between the two angle bounds below. + final double cutoutRoundingLength; + final double cutoutRoundingMinAngle; + final double cutoutRoundingMaxAngle; + + /// Bezier handle length, as a fraction of the gap between the two fillet + /// endpoints and of the cutout radius — whichever is shorter wins. + final double handleDistanceRatio; + final double handleLengthRatio; + + /// Desktop's `DEFAULT_AVATAR_BADGE_CURVE`, used for large badges. + static const standard = AvatarBadgeCurve(); + + /// Desktop's `STATUS_DOT_MASK_CURVE` — a rounder, softer notch that reads + /// correctly at presence-dot sizes. + static const statusDot = AvatarBadgeCurve( + avatarRoundingAngle: 0.11, + cutoutRoundingLength: 6.5, + cutoutRoundingMinAngle: 0.28, + cutoutRoundingMaxAngle: 0.44, + handleDistanceRatio: 0.5, + handleLengthRatio: 0.2, + ); + + @override + bool operator ==(Object other) => + other is AvatarBadgeCurve && + other.avatarRoundingAngle == avatarRoundingAngle && + other.cutoutRoundingLength == cutoutRoundingLength && + other.cutoutRoundingMinAngle == cutoutRoundingMinAngle && + other.cutoutRoundingMaxAngle == cutoutRoundingMaxAngle && + other.handleDistanceRatio == handleDistanceRatio && + other.handleLengthRatio == handleLengthRatio; + + @override + int get hashCode => Object.hash( + avatarRoundingAngle, + cutoutRoundingLength, + cutoutRoundingMinAngle, + cutoutRoundingMaxAngle, + handleDistanceRatio, + handleLengthRatio, + ); +} + +/// Where the notch sits and how big the badge in it is, as fractions of the +/// avatar box so one constant covers every avatar size. +@immutable +class AvatarBadgeMaskGeometry { + const AvatarBadgeMaskGeometry({ + required this.cutoutCenterRatio, + required this.cutoutRadiusRatio, + required this.badgeSizeRatio, + required this.curve, + }); + + /// Distance of the notch centre from the top-left, on both axes. + final double cutoutCenterRatio; + final double cutoutRadiusRatio; + + /// The badge is centred on the notch and is deliberately smaller than it, so + /// a ring of background shows between badge and avatar. + final double badgeSizeRatio; + + final AvatarBadgeCurve curve; + + /// Desktop's settings-card treatment: a 192px avatar with a 54px badge notched + /// in at (165, 165) with radius 30. + static const badge = AvatarBadgeMaskGeometry( + cutoutCenterRatio: 165 / 192, + cutoutRadiusRatio: 30 / 192, + badgeSizeRatio: 54 / 192, + curve: AvatarBadgeCurve.standard, + ); + + /// Desktop's sidebar presence treatment: a 32px avatar with a 14px dot frame + /// notched in at (28, 28) with radius 7.5. + static const presenceDot = AvatarBadgeMaskGeometry( + cutoutCenterRatio: 28 / 32, + cutoutRadiusRatio: 7.5 / 32, + badgeSizeRatio: 14 / 32, + curve: AvatarBadgeCurve.statusDot, + ); + + Offset cutoutCenter(double size) => + Offset(size * cutoutCenterRatio, size * cutoutCenterRatio); + + double cutoutRadius(double size) => size * cutoutRadiusRatio; + + double badgeSize(double size) => size * badgeSizeRatio; + + @override + bool operator ==(Object other) => + other is AvatarBadgeMaskGeometry && + other.cutoutCenterRatio == cutoutCenterRatio && + other.cutoutRadiusRatio == cutoutRadiusRatio && + other.badgeSizeRatio == badgeSizeRatio && + other.curve == curve; + + @override + int get hashCode => + Object.hash(cutoutCenterRatio, cutoutRadiusRatio, badgeSizeRatio, curve); +} + +double _angleTo(Offset center, Offset point) => + math.atan2(point.dy - center.dy, point.dx - center.dx); + +Offset _pointOn(Offset center, double radius, double angle) => + center + Offset(math.cos(angle), math.sin(angle)) * radius; + +/// Unit tangent at [angle]; [clockwise] picks which way around the circle. +Offset _tangent(double angle, {required bool clockwise}) => clockwise + ? Offset(-math.sin(angle), math.cos(angle)) + : Offset(math.sin(angle), -math.cos(angle)); + +/// Signed sweep from [startAngle] to [endAngle], ported from desktop's +/// `getArcSweep`. Flutter shares SVG's angle convention (y grows downward, so a +/// positive sweep runs clockwise on screen), which is why the port is direct. +double _arcSweep( + double startAngle, + double endAngle, { + required bool clockwise, + required bool largeArc, +}) { + const fullTurn = math.pi * 2; + var sweep = clockwise ? endAngle - startAngle : startAngle - endAngle; + while (sweep < 0) { + sweep += fullTurn; + } + if (largeArc && sweep < math.pi) sweep += fullTurn; + if (!largeArc && sweep > math.pi) sweep -= fullTurn; + return clockwise ? sweep : -sweep; +} + +/// The avatar outline with [geometry]'s notch cut out of its bottom-right. +/// +/// Falls back to a plain circle when the notch does not actually cross the +/// avatar's edge — there is nothing to fillet in that case. +Path avatarBadgeMaskPath({ + required double size, + required AvatarBadgeMaskGeometry geometry, +}) { + final avatarRadius = size / 2; + final avatarCenter = Offset(avatarRadius, avatarRadius); + final avatarRect = Rect.fromCircle( + center: avatarCenter, + radius: avatarRadius, + ); + final cutoutCenter = geometry.cutoutCenter(size); + final cutoutRadius = geometry.cutoutRadius(size); + final curve = geometry.curve; + + final span = cutoutCenter - avatarCenter; + final distance = span.distance; + if (distance >= avatarRadius + cutoutRadius || + distance <= (avatarRadius - cutoutRadius).abs()) { + return Path()..addOval(avatarRect); + } + + // Chord where the two circles cross, split into the upper and lower crossing. + final distanceToMidpoint = + (avatarRadius * avatarRadius - + cutoutRadius * cutoutRadius + + distance * distance) / + (2 * distance); + final halfChord = math.sqrt( + math.max( + 0, + avatarRadius * avatarRadius - distanceToMidpoint * distanceToMidpoint, + ), + ); + final unit = span / distance; + final midpoint = avatarCenter + unit * distanceToMidpoint; + final perpendicular = Offset(-unit.dy, unit.dx); + final first = midpoint + perpendicular * halfChord; + final second = midpoint - perpendicular * halfChord; + final upperCrossing = first.dy < second.dy ? first : second; + final lowerCrossing = first.dy < second.dy ? second : first; + + final cutoutRoundingAngle = math.min( + curve.cutoutRoundingMaxAngle, + math.max( + curve.cutoutRoundingMinAngle, + curve.cutoutRoundingLength / cutoutRadius, + ), + ); + + // Pull each edge back from its crossing; the gap left behind is the fillet. + final avatarUpperAngle = + _angleTo(avatarCenter, upperCrossing) - curve.avatarRoundingAngle; + final avatarLowerAngle = + _angleTo(avatarCenter, lowerCrossing) + curve.avatarRoundingAngle; + final cutoutUpperAngle = + _angleTo(cutoutCenter, upperCrossing) - cutoutRoundingAngle; + final cutoutLowerAngle = + _angleTo(cutoutCenter, lowerCrossing) + cutoutRoundingAngle; + + final avatarUpper = _pointOn(avatarCenter, avatarRadius, avatarUpperAngle); + final avatarLower = _pointOn(avatarCenter, avatarRadius, avatarLowerAngle); + final cutoutUpper = _pointOn(cutoutCenter, cutoutRadius, cutoutUpperAngle); + final cutoutLower = _pointOn(cutoutCenter, cutoutRadius, cutoutLowerAngle); + + final upperHandle = math.min( + cutoutRadius * curve.handleLengthRatio, + (cutoutUpper - avatarUpper).distance * curve.handleDistanceRatio, + ); + final lowerHandle = math.min( + cutoutRadius * curve.handleLengthRatio, + (avatarLower - cutoutLower).distance * curve.handleDistanceRatio, + ); + + final upperFromCutout = + cutoutUpper + _tangent(cutoutUpperAngle, clockwise: true) * upperHandle; + final upperIntoAvatar = + avatarUpper - _tangent(avatarUpperAngle, clockwise: false) * upperHandle; + final lowerFromAvatar = + avatarLower + _tangent(avatarLowerAngle, clockwise: false) * lowerHandle; + final lowerIntoCutout = + cutoutLower - _tangent(cutoutLowerAngle, clockwise: true) * lowerHandle; + + return Path() + ..moveTo(cutoutUpper.dx, cutoutUpper.dy) + ..cubicTo( + upperFromCutout.dx, + upperFromCutout.dy, + upperIntoAvatar.dx, + upperIntoAvatar.dy, + avatarUpper.dx, + avatarUpper.dy, + ) + // The long way round the avatar, back to the other side of the notch. + ..arcTo( + avatarRect, + avatarUpperAngle, + _arcSweep( + avatarUpperAngle, + avatarLowerAngle, + clockwise: false, + largeArc: true, + ), + false, + ) + ..cubicTo( + lowerFromAvatar.dx, + lowerFromAvatar.dy, + lowerIntoCutout.dx, + lowerIntoCutout.dy, + cutoutLower.dx, + cutoutLower.dy, + ) + // Back across the notch itself, which bites into the avatar. + ..arcTo( + Rect.fromCircle(center: cutoutCenter, radius: cutoutRadius), + cutoutLowerAngle, + _arcSweep( + cutoutLowerAngle, + cutoutUpperAngle, + clockwise: true, + largeArc: false, + ), + false, + ) + ..close(); +} + +/// Clips an avatar to the rounded badge-notch path described by [geometry]. +class AvatarBadgeMaskClipper extends CustomClipper { + const AvatarBadgeMaskClipper({required this.geometry}); + + final AvatarBadgeMaskGeometry geometry; + + @override + Path getClip(Size size) => + avatarBadgeMaskPath(size: size.shortestSide, geometry: geometry); + + @override + bool shouldReclip(covariant AvatarBadgeMaskClipper oldClipper) => + oldClipper.geometry != geometry; +} + +/// A square [avatar] with [badge] seated in a notch cut out of its bottom-right +/// corner. With no [badge] the avatar is left whole — there is nothing to make +/// room for. +class MaskedAvatarBadge extends StatelessWidget { + const MaskedAvatarBadge({ + super.key, + required this.size, + required this.avatar, + this.geometry = AvatarBadgeMaskGeometry.badge, + this.badge, + }); + + final double size; + final Widget avatar; + final AvatarBadgeMaskGeometry geometry; + final Widget? badge; + + @override + Widget build(BuildContext context) { + if (badge == null) { + return SizedBox.square(dimension: size, child: avatar); + } + + final badgeSize = geometry.badgeSize(size); + final center = geometry.cutoutCenter(size); + + return SizedBox.square( + dimension: size, + child: Stack( + // The notch reaches past the avatar box, so the badge does too. + clipBehavior: Clip.none, + children: [ + ClipPath( + clipper: AvatarBadgeMaskClipper(geometry: geometry), + child: SizedBox.square(dimension: size, child: avatar), + ), + Positioned( + left: center.dx - badgeSize / 2, + top: center.dy - badgeSize / 2, + width: badgeSize, + height: badgeSize, + child: badge!, + ), + ], + ), + ); + } +} diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 0cb3383f3a..b51133ede2 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -11,6 +11,7 @@ import 'package:buzz/features/channels/channels_page.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/read_state/read_state_provider.dart'; import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart'; +import 'package:buzz/features/profile/profile_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/user_profile.dart'; import 'package:buzz/shared/theme/theme.dart'; @@ -42,7 +43,7 @@ void main() { ), home: const Stack( children: [ - ChannelsPage(), + ChannelsPage(settingsPageBuilder: _buildSettingsPage), Positioned.fill( child: ChannelQuickActionsLauncher( visible: true, @@ -117,6 +118,24 @@ void main() { expect(find.byTooltip('Create or start conversation'), findsOneWidget); }); + testWidgets('opens the settings page supplied by the app layer', ( + tester, + ) async { + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(ProfileAvatar)); + await tester.pumpAndSettle(); + + expect(find.text('Injected settings'), findsOneWidget); + }); + testWidgets('quick actions slide behind navigation when leaving home', ( tester, ) async { @@ -885,6 +904,9 @@ void main() { }); } +Widget _buildSettingsPage(BuildContext context) => + const Scaffold(body: Text('Injected settings')); + class _FakeNotifier extends ChannelsNotifier { final List _channels; final Map> _observedEventsByChannel; diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index a4953401ae..4464749382 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -18,8 +18,8 @@ import 'package:buzz/features/channels/compose_bar.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/mentions/mention_candidates.dart'; import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; -import 'package:buzz/features/custom_emoji/custom_emoji.dart'; -import 'package:buzz/features/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index a738c3dce4..15e13b5822 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -12,7 +12,10 @@ void main() { ) async { await tester.pumpWidget( ProviderScope( - child: MaterialApp(theme: AppTheme.light(), home: const HomePage()), + child: MaterialApp( + theme: AppTheme.light(), + home: const HomePage(settingsPageBuilder: _buildSettingsPage), + ), ), ); await tester.pump(); @@ -67,7 +70,10 @@ void main() { await tester.pumpWidget( ProviderScope( - child: MaterialApp(theme: AppTheme.light(), home: const HomePage()), + child: MaterialApp( + theme: AppTheme.light(), + home: const HomePage(settingsPageBuilder: _buildSettingsPage), + ), ), ); await tester.pump(); @@ -95,7 +101,10 @@ void main() { ) async { await tester.pumpWidget( ProviderScope( - child: MaterialApp(theme: AppTheme.light(), home: const HomePage()), + child: MaterialApp( + theme: AppTheme.light(), + home: const HomePage(settingsPageBuilder: _buildSettingsPage), + ), ), ); await tester.pump(); @@ -135,3 +144,5 @@ void main() { expect(opacity(), closeTo(1, 0.001)); }); } + +Widget _buildSettingsPage(BuildContext context) => const SizedBox.shrink(); diff --git a/mobile/test/features/profile/profile_avatar_test.dart b/mobile/test/features/profile/profile_avatar_test.dart new file mode 100644 index 0000000000..4562e4166c --- /dev/null +++ b/mobile/test/features/profile/profile_avatar_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/features/profile/profile_avatar.dart'; +import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; + +void main() { + Widget harness({bool showPresence = true, String presence = 'online'}) { + return ProviderScope( + overrides: [ + profileProvider.overrideWith(_FakeProfileNotifier.new), + presenceProvider.overrideWith(() => _FakePresenceNotifier(presence)), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Center(child: ProfileAvatar(showPresence: showPresence)), + ), + ); + } + + testWidgets('seats the presence dot in a notch instead of over the avatar', ( + tester, + ) async { + await tester.pumpWidget(harness()); + await tester.pumpAndSettle(); + + final badge = tester.widget( + find.byType(MaskedAvatarBadge), + ); + expect(badge.geometry, AvatarBadgeMaskGeometry.presenceDot); + expect( + tester.widget(find.byType(ClipPath).last).clipper, + isA(), + ); + + // The notch supplies the separation, so the dot carries no border ring. + final dot = tester + .widgetList(find.byType(DecoratedBox)) + .map((box) => box.decoration) + .whereType() + .firstWhere((decoration) => decoration.shape == BoxShape.circle); + expect(dot.border, isNull); + }); + + Color dotColor(WidgetTester tester) => tester + .widgetList(find.byType(DecoratedBox)) + .map((box) => box.decoration) + .whereType() + .firstWhere((decoration) => decoration.shape == BoxShape.circle) + .color!; + + final theme = AppTheme.light(); + + testWidgets('paints the dot with the presence color', (tester) async { + await tester.pumpWidget(harness()); + await tester.pumpAndSettle(); + + expect(dotColor(tester), theme.extension()!.success); + }); + + testWidgets('mutes the dot when offline', (tester) async { + await tester.pumpWidget(harness(presence: 'offline')); + await tester.pumpAndSettle(); + + expect(dotColor(tester), theme.colorScheme.outline); + }); + + testWidgets('leaves the avatar unmasked when presence is hidden', ( + tester, + ) async { + await tester.pumpWidget(harness(showPresence: false)); + await tester.pumpAndSettle(); + + final badge = tester.widget( + find.byType(MaskedAvatarBadge), + ); + expect(badge.badge, isNull); + expect( + find.descendant( + of: find.byType(MaskedAvatarBadge), + matching: find.byType(ClipPath), + ), + findsNothing, + ); + }); +} + +class _FakeProfileNotifier extends ProfileNotifier { + @override + Future build() async => + const UserProfile(pubkey: 'aabb', displayName: 'Test'); +} + +class _FakePresenceNotifier extends PresenceNotifier { + _FakePresenceNotifier(this._presence); + + final String _presence; + + @override + Future build() async => _presence; +} diff --git a/mobile/test/features/profile/set_status_sheet_test.dart b/mobile/test/features/profile/set_status_sheet_test.dart new file mode 100644 index 0000000000..53563758d9 --- /dev/null +++ b/mobile/test/features/profile/set_status_sheet_test.dart @@ -0,0 +1,72 @@ +import 'package:buzz/features/profile/set_status_sheet.dart'; +import 'package:buzz/features/profile/user_status.dart'; +import 'package:buzz/features/profile/user_status_provider.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets('removes only the emoji from an existing status', (tester) async { + final statusNotifier = _RecordingUserStatusNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + customEmojiListProvider.overrideWithValue(const []), + userStatusProvider.overrideWith(() => statusNotifier), + ], + child: Builder( + builder: (context) => FilledButton( + onPressed: () => showSetStatusSheet( + context, + currentStatus: const UserStatus( + text: 'Focusing', + emoji: '\u{1F3AF}', + updatedAt: 1, + ), + ), + child: const Text('Open status editor'), + ), + ), + ), + ); + + await tester.tap(find.text('Open status editor')); + await tester.pumpAndSettle(); + + expect(find.text('\u{1F3AF}'), findsOneWidget); + expect(find.byTooltip('Remove status emoji'), findsOneWidget); + + await tester.tap(find.byTooltip('Remove status emoji')); + await tester.pump(); + + expect(find.text('\u{1F3AF}'), findsNothing); + expect(find.byIcon(LucideIcons.smilePlus), findsOneWidget); + expect( + tester.widget(find.byType(TextField)).controller?.text, + 'Focusing', + ); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(statusNotifier.savedText, 'Focusing'); + expect(statusNotifier.savedEmoji, isEmpty); + }); +} + +class _RecordingUserStatusNotifier extends UserStatusNotifier { + String? savedText; + String? savedEmoji; + + @override + Future build() async => null; + + @override + Future setStatus(String text, String emoji) async { + savedText = text; + savedEmoji = emoji; + } +} diff --git a/mobile/test/features/profile/settings_profile_header_test.dart b/mobile/test/features/profile/settings_profile_header_test.dart new file mode 100644 index 0000000000..dbf27b5c81 --- /dev/null +++ b/mobile/test/features/profile/settings_profile_header_test.dart @@ -0,0 +1,63 @@ +import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/features/profile/settings_profile_header.dart'; +import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/features/profile/user_status.dart'; +import 'package:buzz/features/profile/user_status_provider.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets('uses a bounded icon for an unresolved status shortcode', ( + tester, + ) async { + const missingShortcode = ':very_long_missing_custom_emoji:'; + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(_FakeProfileNotifier.new), + userStatusProvider.overrideWith( + () => _FakeUserStatusNotifier( + const UserStatus( + text: 'Focusing', + emoji: missingShortcode, + updatedAt: 1, + ), + ), + ), + customEmojiListProvider.overrideWithValue(const []), + ], + child: const SettingsProfileHeader(), + ), + ); + await tester.pumpAndSettle(); + + final badge = find.byType(MaskedAvatarBadge); + expect( + find.descendant(of: badge, matching: find.text(missingShortcode)), + findsNothing, + ); + expect( + find.descendant(of: badge, matching: find.byIcon(LucideIcons.smile)), + findsOneWidget, + ); + }); +} + +class _FakeProfileNotifier extends ProfileNotifier { + @override + Future build() async => + const UserProfile(pubkey: 'aabb', displayName: 'Test'); +} + +class _FakeUserStatusNotifier extends UserStatusNotifier { + _FakeUserStatusNotifier(this._status); + + final UserStatus _status; + + @override + Future build() async => _status; +} diff --git a/mobile/test/features/settings/theme_picker_page_test.dart b/mobile/test/features/settings/theme_picker_page_test.dart new file mode 100644 index 0000000000..6f8591f285 --- /dev/null +++ b/mobile/test/features/settings/theme_picker_page_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:buzz/features/settings/accent_picker_page.dart'; +import 'package:buzz/features/settings/theme_picker_page.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../helpers/widget_helpers.dart'; + +Future _prefs(Map initial) async { + SharedPreferences.setMockInitialValues(initial); + return SharedPreferences.getInstance(); +} + +Future _pumpPicker( + WidgetTester tester, + Widget page, { + Map prefs = const {}, +}) async { + final instance = await _prefs(prefs); + await tester.pumpWidget( + WidgetHelpers.testable( + child: page, + overrides: [savedPrefsProvider.overrideWithValue(instance)], + ), + ); + await tester.pumpAndSettle(); +} + +Future _search(WidgetTester tester, String query) async { + await tester.enterText(find.byType(TextField), query); + await tester.pumpAndSettle(); +} + +void main() { + group('ThemePickerPage', () { + testWidgets('system mode offers pairs under their stripped label', ( + tester, + ) async { + await _pumpPicker(tester, const ThemePickerPage()); + + await _search(tester, 'github'); + + // Paired labels drop the brightness token — one row stands for both halves. + expect(find.text('Github'), findsOneWidget); + expect(find.text('Github Default'), findsOneWidget); + expect(find.text('Github Light'), findsNothing); + expect(find.text('Github Dark'), findsNothing); + }); + + testWidgets('system mode hides themes with no counterpart', (tester) async { + await _pumpPicker(tester, const ThemePickerPage()); + + // 'snazzy-light' is light but unpaired, so it cannot follow the OS. + await _search(tester, 'snazzy'); + + expect(find.text('No themes found'), findsOneWidget); + }); + + testWidgets('system mode normalizes a stored unpaired theme', ( + tester, + ) async { + final instance = await _prefs({'buzz_color_scheme': 'snazzy-light'}); + await tester.pumpWidget( + WidgetHelpers.testable( + child: const ThemePickerPage(), + overrides: [savedPrefsProvider.overrideWithValue(instance)], + ), + ); + await tester.pumpAndSettle(); + + expect( + instance.getString('buzz_color_scheme'), + themeGroups().paired.first.name, + ); + }); + + testWidgets('light mode lists light themes by their full name', ( + tester, + ) async { + await _pumpPicker( + tester, + const ThemePickerPage(), + prefs: {'buzz_theme_mode': 'light'}, + ); + + await _search(tester, 'github'); + + expect(find.text('Github Light'), findsOneWidget); + expect(find.text('Github Light Default'), findsOneWidget); + expect(find.text('Github Dark'), findsNothing); + }); + + testWidgets('light mode includes unpaired light themes', (tester) async { + await _pumpPicker( + tester, + const ThemePickerPage(), + prefs: {'buzz_theme_mode': 'light'}, + ); + + await _search(tester, 'snazzy'); + + expect(find.text('Snazzy Light'), findsOneWidget); + }); + + testWidgets('dark mode lists dark themes only', (tester) async { + await _pumpPicker( + tester, + const ThemePickerPage(), + prefs: {'buzz_theme_mode': 'dark'}, + ); + + await _search(tester, 'github'); + + expect(find.text('Github Dark'), findsOneWidget); + expect(find.text('Github Light'), findsNothing); + }); + + testWidgets('checks the row matching the stored selection', (tester) async { + await _pumpPicker( + tester, + const ThemePickerPage(), + prefs: {'buzz_theme_mode': 'light', 'buzz_color_scheme': 'nord'}, + ); + + await _search(tester, 'nord'); + + // 'nord' is dark, so Light mode renders its light fallback instead — the + // checkmark must follow what is applied, not the raw stored value. + expect(find.byIcon(LucideIcons.check), findsNothing); + }); + + testWidgets('either half of a pair checks the same system row', ( + tester, + ) async { + await _pumpPicker( + tester, + const ThemePickerPage(), + prefs: {'buzz_color_scheme': 'github-dark'}, + ); + + await _search(tester, 'github'); + + final checked = tester + .widgetList(find.byType(ListTile)) + .where((tile) => tile.trailing != null); + expect(checked, hasLength(1)); + expect(find.text('Github'), findsOneWidget); + }); + + testWidgets('tapping a theme persists the selection', (tester) async { + final instance = await _prefs({'buzz_theme_mode': 'dark'}); + await tester.pumpWidget( + WidgetHelpers.testable( + child: const ThemePickerPage(), + overrides: [savedPrefsProvider.overrideWithValue(instance)], + ), + ); + await tester.pumpAndSettle(); + + await _search(tester, 'nord'); + await tester.tap(find.text('Nord')); + await tester.pumpAndSettle(); + + expect(instance.getString('buzz_color_scheme'), 'nord'); + }); + }); + + group('AccentPickerPage', () { + testWidgets('lists every accent and checks the stored one', (tester) async { + await _pumpPicker( + tester, + const AccentPickerPage(), + prefs: {'buzz_accent_color': 2}, + ); + + for (final accent in accentColors) { + expect(find.text(accent.name), findsOneWidget); + } + expect(find.byIcon(LucideIcons.check), findsOneWidget); + }); + + testWidgets('tapping an accent persists the index', (tester) async { + final instance = await _prefs(const {}); + await tester.pumpWidget( + WidgetHelpers.testable( + child: const AccentPickerPage(), + overrides: [savedPrefsProvider.overrideWithValue(instance)], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Green')); + await tester.pumpAndSettle(); + + expect( + instance.getInt('buzz_accent_color'), + accentColors.indexWhere((a) => a.name == 'Green'), + ); + }); + }); +} diff --git a/mobile/test/features/custom_emoji/custom_emoji_test.dart b/mobile/test/shared/custom_emoji/custom_emoji_test.dart similarity index 98% rename from mobile/test/features/custom_emoji/custom_emoji_test.dart rename to mobile/test/shared/custom_emoji/custom_emoji_test.dart index 5b20f8276d..145bf63ea8 100644 --- a/mobile/test/features/custom_emoji/custom_emoji_test.dart +++ b/mobile/test/shared/custom_emoji/custom_emoji_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:buzz/features/custom_emoji/custom_emoji.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; import 'package:buzz/shared/relay/nostr_models.dart'; NostrEvent _event( diff --git a/mobile/test/shared/theme/accent_colors_test.dart b/mobile/test/shared/theme/accent_colors_test.dart index d1f777c598..8d31ae6cee 100644 --- a/mobile/test/shared/theme/accent_colors_test.dart +++ b/mobile/test/shared/theme/accent_colors_test.dart @@ -5,7 +5,7 @@ import 'package:buzz/shared/theme/theme.dart'; void main() { group('default accent', () { test('uses black on the default light scheme', () { - final resolved = resolveSchemes(null); + final resolved = resolveSchemes(null, ThemeMode.light); final accented = applyAccent(resolved.light, defaultAccentIndex); @@ -13,7 +13,7 @@ void main() { }); test('uses the theme foreground on forced dark schemes', () { - final resolved = resolveSchemes('github-dark'); + final resolved = resolveSchemes('github-dark', ThemeMode.dark); final base = resolved.dark; final accented = applyAccent(base, defaultAccentIndex); diff --git a/mobile/test/shared/theme/buzz_theme_test.dart b/mobile/test/shared/theme/buzz_theme_test.dart new file mode 100644 index 0000000000..613fec1467 --- /dev/null +++ b/mobile/test/shared/theme/buzz_theme_test.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/frosted_app_bar.dart'; + +void main() { + group('Buzz theme catalog entries', () { + test('both halves are in the catalog', () { + expect(findTheme(buzzThemeName), isNotNull); + expect(findTheme(buzzDarkThemeName), isNotNull); + }); + + test('borrow the GitHub palettes', () { + final buzz = findTheme(buzzThemeName)!; + final github = findTheme('github-light')!; + expect(buzz.bg, github.bg); + expect(buzz.fg, github.fg); + expect(buzz.comment, github.comment); + + final buzzDark = findTheme(buzzDarkThemeName)!; + final githubDark = findTheme('github-dark')!; + expect(buzzDark.bg, githubDark.bg); + expect(buzzDark.fg, githubDark.fg); + expect(buzzDark.comment, githubDark.comment); + }); + + test('are a light/dark pair', () { + expect(findTheme(buzzThemeName)!.isDark, isFalse); + expect(findTheme(buzzDarkThemeName)!.isDark, isTrue); + expect(themePairFor(buzzThemeName), buzzDarkThemeName); + expect(themePairFor(buzzDarkThemeName), buzzThemeName); + }); + + test('appear as a single System-mode option labelled "Buzz"', () { + final paired = themeGroups().paired.map((t) => t.name); + expect(paired, contains(buzzThemeName)); + expect(paired, isNot(contains(buzzDarkThemeName))); + expect(pairedThemeLabel(buzzThemeName), 'Buzz'); + expect(themeSelectionLabel(buzzThemeName, ThemeMode.system), 'Buzz'); + expect(themeSelectionLabel(buzzDarkThemeName, ThemeMode.system), 'Buzz'); + }); + + test('resolve across brightnesses like any other pair', () { + final resolved = resolveSchemes(buzzThemeName, ThemeMode.system); + expect(resolved.forcedMode, isNull); + expect(resolved.light.brightness, Brightness.light); + expect(resolved.dark.brightness, Brightness.dark); + expect(resolved.lightTheme?.name, buzzThemeName); + expect(resolved.darkTheme?.name, buzzDarkThemeName); + + expect( + effectiveTheme(buzzThemeName, ThemeMode.dark)?.name, + buzzDarkThemeName, + ); + expect( + effectiveTheme(buzzDarkThemeName, ThemeMode.light)?.name, + buzzThemeName, + ); + }); + + test( + 'fallbacks expose the effective Buzz theme for gradient selection', + () { + final coerced = resolveSchemes('nord', ThemeMode.light); + expect(coerced.lightTheme?.name, buzzThemeName); + expect( + buzzTopSectionGradient( + coerced.lightTheme!.name, + coerced.light.brightness, + ), + isNotNull, + ); + + final unknown = resolveSchemes('not-a-theme', ThemeMode.light); + expect(unknown.lightTheme?.name, buzzThemeName); + expect( + buzzTopSectionGradient( + unknown.lightTheme!.name, + unknown.light.brightness, + ), + isNotNull, + ); + }, + ); + }); + + group('buzzTopSectionGradient', () { + test('is null for non-Buzz themes', () { + expect(buzzTopSectionGradient('github-light', Brightness.light), isNull); + expect(buzzTopSectionGradient('nord', Brightness.dark), isNull); + }); + + test('paints top to bottom for both halves of the pair', () { + for (final name in [buzzThemeName, buzzDarkThemeName]) { + final gradient = buzzTopSectionGradient(name, Brightness.light); + expect(gradient, isNotNull, reason: '$name should be gradient-backed'); + expect(gradient!.begin, Alignment.topCenter); + expect(gradient.end, Alignment.bottomCenter); + expect(gradient.colors, hasLength(2)); + } + }); + + test('brightness selects the stops, not the theme name', () { + // Both halves enable the gradient, so System mode keeps it on across an + // OS switch — the applied brightness alone decides which stops are used. + final light = buzzTopSectionGradient(buzzThemeName, Brightness.light)!; + final dark = buzzTopSectionGradient(buzzThemeName, Brightness.dark)!; + + expect(light.colors, isNot(dark.colors)); + expect( + buzzTopSectionGradient(buzzDarkThemeName, Brightness.dark)!.colors, + dark.colors, + ); + expect( + buzzTopSectionGradient(buzzDarkThemeName, Brightness.light)!.colors, + light.colors, + ); + }); + + test('is opaque so the color replaces the frosted fill', () { + for (final brightness in Brightness.values) { + final gradient = buzzTopSectionGradient(buzzThemeName, brightness)!; + for (final color in gradient.colors) { + expect(color.a, 1.0); + } + } + }); + }); + + group('theme threading', () { + BoxDecoration barDecoration(WidgetTester tester) { + final container = tester + .widgetList( + find.descendant( + of: find.byType(FrostedAppBar), + matching: find.byType(Container), + ), + ) + .first; + return container.decoration! as BoxDecoration; + } + + Widget harness(ThemeData theme) => MaterialApp( + theme: theme, + home: Builder( + builder: (context) => Stack( + children: [ + FrostedAppBar( + gradient: context.appColors.topSectionGradient, + title: const Text('Home'), + ), + ], + ), + ), + ); + + testWidgets('AppTheme carries the gradient to the top section', ( + tester, + ) async { + await tester.pumpWidget( + harness( + AppTheme.light( + topSectionGradient: buzzTopSectionGradient( + buzzThemeName, + Brightness.light, + ), + ), + ), + ); + + final decoration = barDecoration(tester); + expect(decoration.gradient, isNotNull); + // A BoxDecoration cannot paint a color and a gradient at once. + expect(decoration.color, isNull); + }); + + testWidgets('non-Buzz themes keep the frosted surface fill', ( + tester, + ) async { + await tester.pumpWidget(harness(AppTheme.light())); + + final decoration = barDecoration(tester); + expect(decoration.gradient, isNull); + expect(decoration.color, isNotNull); + }); + }); + + group('isBuzzTheme', () { + test('matches only the Buzz pair', () { + expect(isBuzzTheme(buzzThemeName), isTrue); + expect(isBuzzTheme(buzzDarkThemeName), isTrue); + expect(isBuzzTheme('github-light'), isFalse); + expect(isBuzzTheme(''), isFalse); + }); + }); +} diff --git a/mobile/test/shared/theme/theme_pairs_test.dart b/mobile/test/shared/theme/theme_pairs_test.dart new file mode 100644 index 0000000000..428ad5bc40 --- /dev/null +++ b/mobile/test/shared/theme/theme_pairs_test.dart @@ -0,0 +1,254 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/theme/theme.dart'; + +void main() { + group('themePairs', () { + test('every pair member exists in the catalog', () { + for (final entry in themePairs.entries) { + expect( + findTheme(entry.key), + isNotNull, + reason: '${entry.key} is not in themeCatalog', + ); + expect( + findTheme(entry.value), + isNotNull, + reason: '${entry.value} is not in themeCatalog', + ); + } + }); + + test('pairs map a light theme to a dark one', () { + for (final entry in themePairs.entries) { + expect( + findTheme(entry.key)!.isDark, + isFalse, + reason: '${entry.key} should be light', + ); + expect( + findTheme(entry.value)!.isDark, + isTrue, + reason: '${entry.value} should be dark', + ); + } + }); + + test('resolves in both directions', () { + expect(themePairFor('github-light'), 'github-dark'); + expect(themePairFor('github-dark'), 'github-light'); + expect(themePairFor('andromeeda'), isNull); + expect(isPairedTheme('catppuccin-latte'), isTrue); + expect(isPairedTheme('andromeeda'), isFalse); + }); + }); + + group('themeGroups', () { + test('light and dark partition the whole catalog', () { + final groups = themeGroups(); + + expect(groups.light.length + groups.dark.length, themeCatalog.length); + expect(groups.light.every((t) => !t.isDark), isTrue); + expect(groups.dark.every((t) => t.isDark), isTrue); + }); + + test('paired holds only light members of pairs', () { + final groups = themeGroups(); + + expect(groups.paired.length, themePairs.length); + for (final theme in groups.paired) { + expect(theme.isDark, isFalse); + expect(themePairs.containsKey(theme.name), isTrue); + } + }); + + test('groups are sorted by display name', () { + final groups = themeGroups(); + + for (final list in [groups.paired, groups.light, groups.dark]) { + final names = list.map((t) => t.displayName).toList(); + expect(names, orderedEquals(List.of(names)..sort())); + } + }); + }); + + group('pairedThemeLabel', () { + test('strips brightness tokens', () { + expect(pairedThemeLabel('github-light'), 'Github'); + expect(pairedThemeLabel('github-light-default'), 'Github Default'); + expect(pairedThemeLabel('gruvbox-light-soft'), 'Gruvbox Soft'); + expect(pairedThemeLabel('material-theme-lighter'), 'Material Theme'); + expect(pairedThemeLabel('catppuccin-latte'), 'Catppuccin'); + }); + + test('falls back to the raw name when stripping empties it', () { + expect(pairedThemeLabel('light-plus'), 'Light Plus'); + }); + + test('never produces a blank label for a real pair', () { + for (final lightName in themePairs.keys) { + expect(pairedThemeLabel(lightName), isNotEmpty); + } + }); + }); + + group('resolveSchemes', () { + test('system mode pairs the selection across brightnesses', () { + final resolved = resolveSchemes('github-light', ThemeMode.system); + + // Null forcedMode leaves MaterialApp following the OS. + expect(resolved.forcedMode, isNull); + expect(resolved.light.brightness, Brightness.light); + expect(resolved.dark.brightness, Brightness.dark); + }); + + test('system mode works from the dark half of a pair too', () { + final resolved = resolveSchemes('github-dark', ThemeMode.system); + + expect(resolved.forcedMode, isNull); + expect(resolved.light.brightness, Brightness.light); + expect(resolved.dark.brightness, Brightness.dark); + }); + + test('system mode pins an unpaired theme to its own brightness', () { + final resolved = resolveSchemes('andromeeda', ThemeMode.system); + + // No counterpart exists, so both sides render the selected theme rather + // than jumping to an unrelated one. + expect(resolved.forcedMode, ThemeMode.dark); + expect(resolved.light, resolved.dark); + expect(resolved.dark.brightness, Brightness.dark); + }); + + test('system mode pins an unpaired light theme to light mode', () { + final resolved = resolveSchemes('snazzy-light', ThemeMode.system); + + expect(resolved.forcedMode, ThemeMode.light); + expect(resolved.light, resolved.dark); + expect(resolved.light.brightness, Brightness.light); + }); + + test('light mode forces light and swaps a dark selection for its pair', () { + final resolved = resolveSchemes('github-dark', ThemeMode.light); + + expect(resolved.forcedMode, ThemeMode.light); + expect(resolved.light, resolved.dark); + expect(resolved.light.brightness, Brightness.light); + expect(resolved.light, generateColorScheme(findTheme('github-light')!)); + }); + + test('dark mode forces dark and swaps a light selection for its pair', () { + final resolved = resolveSchemes('github-light', ThemeMode.dark); + + expect(resolved.forcedMode, ThemeMode.dark); + expect(resolved.dark.brightness, Brightness.dark); + expect(resolved.dark, generateColorScheme(findTheme('github-dark')!)); + }); + + test('dark mode falls back to the default pair when pick is unpaired', () { + // 'snazzy-light' is light and has no dark counterpart. + final resolved = resolveSchemes('snazzy-light', ThemeMode.dark); + + expect(resolved.forcedMode, ThemeMode.dark); + expect(resolved.dark.brightness, Brightness.dark); + expect(resolved.darkTheme?.name, buzzDarkThemeName); + expect(resolved.dark, generateColorScheme(findTheme(buzzDarkThemeName)!)); + }); + + test('light mode falls back to the default pair when pick is unpaired', () { + final resolved = resolveSchemes('andromeeda', ThemeMode.light); + + expect(resolved.forcedMode, ThemeMode.light); + expect(resolved.light.brightness, Brightness.light); + expect(resolved.lightTheme?.name, buzzThemeName); + expect(resolved.light, generateColorScheme(findTheme(buzzThemeName)!)); + }); + + test('an unknown scheme name falls back to the default theme', () { + final resolved = resolveSchemes('not-a-theme', ThemeMode.light); + + expect( + resolved.light, + generateColorScheme(findTheme(defaultSchemeName)!), + ); + }); + }); + + group('schemeForAppearanceMode', () { + test('system mode replaces an unpaired selection with a paired theme', () { + expect( + schemeForAppearanceMode('snazzy-light', ThemeMode.system), + themeGroups().paired.first.name, + ); + expect( + schemeForAppearanceMode('nord', ThemeMode.system), + themeGroups().paired.first.name, + ); + }); + + test('system mode preserves either half of a paired selection', () { + expect( + schemeForAppearanceMode('github-light', ThemeMode.system), + 'github-light', + ); + expect( + schemeForAppearanceMode('github-dark', ThemeMode.system), + 'github-dark', + ); + }); + + test('pinned modes preserve an unpaired selection', () { + expect( + schemeForAppearanceMode('snazzy-light', ThemeMode.light), + 'snazzy-light', + ); + expect(schemeForAppearanceMode('nord', ThemeMode.dark), 'nord'); + }); + }); + + group('effectiveTheme', () { + test('system mode keeps the stored selection', () { + expect( + effectiveTheme('github-dark', ThemeMode.system)?.name, + 'github-dark', + ); + }); + + test('pinned modes report the coerced theme', () { + expect( + effectiveTheme('github-dark', ThemeMode.light)?.name, + 'github-light', + ); + expect( + effectiveTheme('github-light', ThemeMode.dark)?.name, + 'github-dark', + ); + }); + + test('a null selection resolves to the default theme', () { + expect(effectiveTheme(null, ThemeMode.light)?.name, defaultSchemeName); + }); + }); + + group('themeSelectionLabel', () { + test('names the pair as a whole in system mode', () { + expect(themeSelectionLabel('github-light', ThemeMode.system), 'Github'); + expect(themeSelectionLabel('github-dark', ThemeMode.system), 'Github'); + }); + + test('names the concrete theme in pinned modes', () { + expect( + themeSelectionLabel('github-light', ThemeMode.light), + 'Github Light', + ); + expect( + themeSelectionLabel('github-light', ThemeMode.dark), + 'Github Dark', + ); + }); + + test('falls back to the theme display name when unpaired', () { + expect(themeSelectionLabel('andromeeda', ThemeMode.system), 'Andromeeda'); + }); + }); +} diff --git a/mobile/test/shared/widgets/app_list_test.dart b/mobile/test/shared/widgets/app_list_test.dart new file mode 100644 index 0000000000..db493a3f9b --- /dev/null +++ b/mobile/test/shared/widgets/app_list_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/app_list.dart'; +import 'package:buzz/shared/widgets/app_list_card.dart'; + +Widget _host(Widget child) => MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: child), +); + +void main() { + group('AppListRow', () { + testWidgets('centres the leading icon against a two-line row', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const AppListRow( + icon: Icons.dns, + title: 'Connected to', + subtitle: 'https://relay.example.com', + ), + ), + ); + + final row = tester.getRect(find.byType(AppListRow)); + final icon = tester.getRect(find.byType(Icon)); + expect(icon.center.dy, closeTo(row.center.dy, 0.5)); + }); + + testWidgets('centres the trailing control against a two-line row', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const AppListRow( + icon: Icons.key, + title: 'Identity', + subtitle: 'deadbeef', + trailing: Icon(Icons.copy, key: Key('trailing')), + ), + ), + ); + + final row = tester.getRect(find.byType(AppListRow)); + final trailing = tester.getRect(find.byKey(const Key('trailing'))); + expect(trailing.center.dy, closeTo(row.center.dy, 0.5)); + }); + + testWidgets('keeps the value flush against the trailing edge', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const AppListRow( + icon: Icons.palette, + title: 'Theme', + value: 'Buzz', + trailing: Icon(Icons.chevron_right, key: Key('chevron')), + ), + ), + ); + + final value = tester.getRect(find.text('Buzz')); + final chevron = tester.getRect(find.byKey(const Key('chevron'))); + // Only the row's own inter-widget gap sits between them — no flex slack. + expect(chevron.left - value.right, closeTo(Grid.xxs, 0.5)); + }); + }); + + group('AppListCard', () { + testWidgets('separates rows with dividers that clear the icon column', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const AppListCard( + label: 'Style', + children: [ + AppListRow(icon: Icons.sunny, title: 'Appearance'), + AppListRow(icon: Icons.palette, title: 'Theme'), + AppListRow(icon: Icons.water_drop, title: 'Accent color'), + ], + ), + ), + ); + + final dividers = find.byType(Divider); + expect(dividers, findsNWidgets(2)); + + final divider = tester.widget(dividers.first); + final scheme = AppTheme.light().colorScheme; + // Derived from the text color, not the scheme's border tokens — those sit + // within a few levels of the card fill and vanish. + expect(divider.color, scheme.onSurface.withValues(alpha: 0.12)); + + // The indent is painted inside the divider's box, so the line starts at + // the box's left edge plus the indent — level with the row titles. + final titleLeft = tester.getRect(find.text('Appearance')).left; + final lineLeft = tester.getRect(dividers.first).left + divider.indent!; + expect(lineLeft, closeTo(titleLeft, 0.5)); + }); + + testWidgets('fills with the softest container step', (tester) async { + await tester.pumpWidget( + _host( + const AppListCard(children: [AppListRow(title: 'Remove community')]), + ), + ); + + final material = tester.widget( + find.descendant( + of: find.byType(AppListCard), + matching: find.byType(Material), + ), + ); + expect( + material.color, + AppTheme.light().colorScheme.surfaceContainerHighest, + ); + }); + }); +} diff --git a/mobile/test/shared/widgets/masked_avatar_badge_test.dart b/mobile/test/shared/widgets/masked_avatar_badge_test.dart new file mode 100644 index 0000000000..f2794d7132 --- /dev/null +++ b/mobile/test/shared/widgets/masked_avatar_badge_test.dart @@ -0,0 +1,147 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; + +void main() { + group('avatarBadgeMaskPath', () { + const size = 128.0; + const geometry = AvatarBadgeMaskGeometry.badge; + + test('keeps the avatar but cuts the notch out of it', () { + final path = avatarBadgeMaskPath(size: size, geometry: geometry); + + expect(path.contains(const Offset(size / 2, size / 2)), isTrue); + expect(path.contains(geometry.cutoutCenter(size)), isFalse); + // The far edge is untouched — the notch only bites the bottom-right. + expect(path.contains(const Offset(4, size / 2)), isTrue); + }); + + test('stays inside the avatar circle on the unnotched sides', () { + // Checked with contains() rather than getBounds(), which reports + // control-point bounds and so overshoots the near-full-circle arc. + final path = avatarBadgeMaskPath(size: size, geometry: geometry); + + expect(path.contains(const Offset(-1, size / 2)), isFalse); + expect(path.contains(const Offset(size / 2, -1)), isFalse); + expect(path.contains(const Offset(2, 2)), isFalse); + }); + + test('fillets the notch rather than subtracting a bare circle', () { + // A plain difference meets the cutout at two cusps. The fillets round + // those off, which necessarily removes extra material just outside the + // cutout circle — so the masked path must be a strict subset there. + final masked = avatarBadgeMaskPath(size: size, geometry: geometry); + final cutoutCenter = geometry.cutoutCenter(size); + final cutoutRadius = geometry.cutoutRadius(size); + final bare = Path.combine( + PathOperation.difference, + Path()..addOval( + Rect.fromCircle( + center: const Offset(size / 2, size / 2), + radius: size / 2, + ), + ), + Path()..addOval( + Rect.fromCircle(center: cutoutCenter, radius: cutoutRadius), + ), + ); + + // Sample the ring just outside the cutout, where the fillets live. + var filleted = 0; + for (var step = 0; step < 360; step++) { + final angle = step * math.pi / 180; + final point = + cutoutCenter + + Offset(math.cos(angle), math.sin(angle)) * (cutoutRadius + 2); + if (bare.contains(point) && !masked.contains(point)) filleted++; + // The fillet only adds material to the cut, never puts it back. + expect( + masked.contains(point) && !bare.contains(point), + isFalse, + reason: 'mask should not extend beyond the plain difference', + ); + } + expect(filleted, greaterThan(0)); + }); + + test('falls back to a plain circle when the notch misses the avatar', () { + // A notch tucked at the centre never crosses the avatar's edge, so there + // is no crossing to fillet and nothing should be cut. + const detached = AvatarBadgeMaskGeometry( + cutoutCenterRatio: 0.5, + cutoutRadiusRatio: 0.05, + badgeSizeRatio: 0.08, + curve: AvatarBadgeCurve.standard, + ); + final path = avatarBadgeMaskPath(size: size, geometry: detached); + + expect(path.contains(detached.cutoutCenter(size)), isTrue); + expect(path.getBounds().size, const Size(size, size)); + }); + + test( + 'scales geometry with the avatar, matching desktop at its own size', + () { + // Desktop's sidebar avatar is 32px with the cutout at (28, 28) r 7.5. + const presence = AvatarBadgeMaskGeometry.presenceDot; + + expect(presence.cutoutCenter(32), const Offset(28, 28)); + expect(presence.cutoutRadius(32), 7.5); + expect(presence.badgeSize(32), 14); + // Twice the avatar, twice the notch. + expect(presence.cutoutCenter(64), const Offset(56, 56)); + expect(presence.cutoutRadius(64), 15); + }, + ); + }); + + group('MaskedAvatarBadge', () { + Widget harness({Widget? badge}) => MaterialApp( + theme: AppTheme.light(), + home: Center( + child: MaskedAvatarBadge( + size: 128, + geometry: AvatarBadgeMaskGeometry.badge, + avatar: const ColoredBox(color: Color(0xFF123456)), + badge: badge, + ), + ), + ); + + testWidgets('masks the avatar and centres the badge in the notch', ( + tester, + ) async { + await tester.pumpWidget( + harness(badge: const ColoredBox(color: Color(0xFFABCDEF))), + ); + + final clip = tester.widget(find.byType(ClipPath)); + expect(clip.clipper, isA()); + + const geometry = AvatarBadgeMaskGeometry.badge; + final frame = tester.getRect(find.byType(MaskedAvatarBadge)); + final badgeRect = tester.getRect(find.byType(ColoredBox).last); + + expect(badgeRect.width, closeTo(geometry.badgeSize(128), 0.01)); + expect( + badgeRect.center - frame.topLeft, + within(distance: 0.01, from: geometry.cutoutCenter(128)), + ); + }); + + testWidgets('leaves the avatar whole when there is no badge', ( + tester, + ) async { + await tester.pumpWidget(harness()); + + expect(find.byType(ClipPath), findsNothing); + expect( + tester.getSize(find.byType(MaskedAvatarBadge)), + const Size(128, 128), + ); + }); + }); +} From 74b63e1846212af6e6751a62cfc631f74b1dfe07 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 26 Jul 2026 16:34:00 +0100 Subject: [PATCH 012/128] Refactor managed-agent runtime into cohesive modules (#2974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - split the managed-agent runtime warehouse into cohesive modules for process ownership/termination, orphan sweeping, dead-instance reaping, lifecycle synchronization, and runtime metadata - preserve the existing `runtime` API through narrow re-exports; helper bodies and platform `cfg` branches are unchanged apart from module-qualified visibility - reduce `runtime.rs` from 2,220 lines on `main` to 908 lines and remove its temporary file-size override, restoring the standard 1,000-line ceiling ## Why `main` failed after stale successful PR checks allowed independent growth to combine above `runtime.rs`'s 2,216-line override. The earlier fix in #2974 extracted only 55 lines and left the monolith on a special ratchet. This replacement includes that extraction but establishes responsibility boundaries and removes the exception entirely. ## Module boundaries - `process.rs` — process identity, ownership markers, receipt validation, and termination primitives - `orphan_sweep.rs` — same-instance orphan discovery and cleanup - `instance_reaper.rs` — foreign/dead desktop instance detection and agent reaping - `lifecycle.rs` — tracked runtime synchronization and stale record cleanup - `metadata.rs` — model/provider metadata resolution - `runtime.rs` — summary/config/spawn orchestration and composition ## Validation At `a824fda31eff6ecc0d39ca1b8ea5602a108897e6`: - pre-push `desktop-check` - pre-push `desktop-test` - pre-push full `desktop-tauri-test`: 1,637 passed, 0 failed, 14 ignored; integration + doc tests passed - `cargo check --manifest-path desktop/src-tauri/Cargo.toml --lib` - `cargo fmt --manifest-path desktop/src-tauri/Cargo.toml --all -- --check` - `node desktop/scripts/check-file-sizes.mjs` Supersedes #2974 and #2930. Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Princess Donut <68157ebd23b3897c1991015c3038658ea916200c67d3a54620b0754d1b92f6e0@buzz.block.builderlab.xyz> --- desktop/scripts/check-file-sizes.mjs | 13 - .../src-tauri/src/managed_agents/runtime.rs | 1367 +---------------- .../managed_agents/runtime/instance_reaper.rs | 359 +++++ .../src/managed_agents/runtime/lifecycle.rs | 105 ++ .../src/managed_agents/runtime/metadata.rs | 55 + .../managed_agents/runtime/orphan_sweep.rs | 371 +++++ .../src/managed_agents/runtime/process.rs | 447 ++++++ 7 files changed, 1366 insertions(+), 1351 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/runtime/instance_reaper.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime/lifecycle.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime/metadata.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime/orphan_sweep.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime/process.rs diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index f2d3883794..65928a29db 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -119,19 +119,6 @@ const overrides = new Map([ // helpers) replace the pubkey-keyed PID file, plus the hashed pair-scoped // runtime log path. Load-bearing crash-recovery surface; queued to split. ["src-tauri/src/managed_agents/storage.rs", 1383], - // harness-persona-sync: persona-runtime resolution threaded into the spawn - // path here. Load-bearing feature growth; queued to split in the resolver - // unify refactor followup. +26 for resolve_effective_prompt_model_provider - // re-introduced after 826d735fe removal (config-bridge caller still needs it). - // PGID resolution helper + PID-recycling safety guard added for orphan sweep. - // activity-feed threads avatar_url into build_managed_agent_summary for the - // assistant-bubble pinned snapshot. - // +1 for agent_pubkey field in setup payload (config-nudge card wire). - // persona-blank-fallback: resolve_effective_prompt_model_provider gains a - // record_provider param + applies persona_field_with_record_fallback. +5 lines. - // global-agent-config: spawn_agent_child loads global config and merges as - // lowest env layer (+8 lines). Queued to split. - ["src-tauri/src/managed_agents/runtime.rs", 2216], // config-bridge setup-payload env-boundary fix adds readiness wiring in // spawn_agent_child; load-bearing security fix, queued to split. ["src-tauri/src/managed_agents/config_bridge/reader.rs", 1016], diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 44cee49aeb..b9c7ec65ea 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -20,6 +20,9 @@ pub(crate) use path::compose_path_entries; pub(crate) use path::should_skip_claude_executable; pub(crate) use path::should_use_inherited; +mod metadata; +pub(crate) use metadata::{resolve_effective_prompt_model_provider, runtime_metadata_env_vars}; + mod stop; pub(crate) use stop::managed_agent_runtime_keys; pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; @@ -29,1294 +32,38 @@ pub(crate) use sweep::sweep_untracked_bundle_harnesses; type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); -/// Binary name fragments for all known agent/harness processes that Buzz -/// may spawn. Used by `process_belongs_to_us()` and the orphan sweep to -/// identify processes we should clean up. Both hyphenated and underscored -/// variants are listed because macOS `proc_name()` and Linux `/proc/comm` -/// may report either form depending on how the binary was built. -pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[ - "buzz-acp", - "buzz_acp", - "buzz-agent", - "buzz_agent", - "claude-agent-acp", - "claude_agent_acp", - "claude-code-acp", - "claude_code_acp", - "codex-acp", - "codex_acp", - "goose", - // buzz-dev-mcp's multicall personalities (rg, tree, buzz, - // git-credential-nostr, git-sign-nostr) are short-lived per-tool-call - // invocations — not listed here. - "buzz-dev-mcp", - "buzz_dev_mcp", -]; - -/// Script interpreters that may host managed agent wrappers (e.g. npm shims). -/// A process whose name matches here is NOT immediately claimed — it must also -/// carry `BUZZ_MANAGED_AGENT` in its environment (checked by the caller via -/// `process_has_buzz_marker()`). This avoids sweeping unrelated node processes. -pub(crate) const KNOWN_SCRIPT_INTERPRETERS: &[&str] = &["node"]; - -/// Check if a process name matches any of our known agent binaries. -/// Uses exact match or prefix-with-separator to avoid false positives -/// (e.g. `"goose"` must not match `"mongoose"`). -fn name_matches_known_binary(name: &str) -> bool { - KNOWN_AGENT_BINARIES.iter().any(|&binary| { - name == binary || { - name.starts_with(binary) && { - let rest = &name[binary.len()..]; - rest.starts_with('-') || rest.starts_with('_') || rest.starts_with('.') - } - } - }) -} - -/// Check if a process name is a known script interpreter that may be hosting -/// a managed agent wrapper (e.g. `node` running an npm shim for `codex-acp`). -/// Callers must additionally verify `BUZZ_MANAGED_AGENT` ownership. -fn name_matches_interpreter(name: &str) -> bool { - KNOWN_SCRIPT_INTERPRETERS.contains(&name) -} +mod process; +#[cfg(test)] +use process::{ + buzz_marker_entry, name_matches_interpreter, name_matches_known_binary, + terminate_runtime_receipt_with, +}; +pub(crate) use process::{ + current_instance_id, process_belongs_to_us, process_has_buzz_marker, process_is_running, + terminate_process, terminate_untracked_pair_runtime, valid_agent_runtime_receipt, +}; +mod orphan_sweep; +#[cfg(target_os = "macos")] +use orphan_sweep::proc_pidinfo; +pub(crate) use orphan_sweep::{ + sweep_orphaned_agent_processes, sweep_system_agent_processes, + sweep_system_agent_processes_with_grace, +}; +#[cfg(target_os = "macos")] +use orphan_sweep::{BSDInfo, PROC_PIDTBSDINFO}; #[cfg(unix)] -pub(crate) fn process_is_running(pid: u32) -> bool { - // Use libc::kill with signal 0 instead of forking a subprocess. - // Returns true only if the process exists AND we can signal it. - // Returns false for non-existent PIDs (ESRCH) and PIDs owned by - // other users (EPERM) — callers should not interact with those. - unsafe { libc::kill(pid as i32, 0) == 0 } -} +use process::resolve_pgids_and_kill; -#[cfg(not(unix))] -pub(crate) fn process_is_running(_pid: u32) -> bool { - false -} - -/// Check if a PID belongs to a known agent process we spawned. -/// Returns false for recycled PIDs that now belong to other processes. -#[cfg(target_os = "macos")] -pub(crate) fn process_belongs_to_us(pid: u32) -> bool { - // Use proc_name() from libproc to get the process name without spawning - // a subprocess. - extern "C" { - fn proc_name(pid: libc::c_int, buffer: *mut libc::c_void, buffersize: u32) -> libc::c_int; - } - let mut buf = [0u8; 1024]; - let len = unsafe { - proc_name( - pid as i32, - buf.as_mut_ptr() as *mut libc::c_void, - buf.len() as u32, - ) - }; - if len <= 0 { - return false; - } - let name = String::from_utf8_lossy(&buf[..len as usize]); - // Fall through for script interpreters (e.g. `node` hosting an npm shim): - // the caller's `process_has_buzz_marker()` check decides true ownership. - name_matches_known_binary(&name) || name_matches_interpreter(&name) -} - -#[cfg(all(unix, not(target_os = "macos")))] -pub(crate) fn process_belongs_to_us(pid: u32) -> bool { - // First try /proc//comm. Note: comm is truncated to 15 bytes on Linux, - // so binaries with names longer than 15 chars (e.g. "claude-agent-acp") - // will never match here. - if let Ok(name) = std::fs::read_to_string(format!("/proc/{pid}/comm")) { - if name_matches_known_binary(name.trim()) { - return true; - } - // Interpreter check: `node` is 4 bytes, never truncated. - if name_matches_interpreter(name.trim()) { - return true; - } - } - - // Fallback: read /proc//exe which is a symlink to the full binary path. - // This is not subject to the 15-byte truncation limit. - if let Ok(exe_path) = std::fs::read_link(format!("/proc/{pid}/exe")) { - if let Some(basename) = exe_path.file_name().and_then(|n| n.to_str()) { - // Fall through for script interpreters — caller checks the marker. - return name_matches_known_binary(basename) || name_matches_interpreter(basename); - } - } - - false -} - -#[cfg(not(unix))] -pub(crate) fn process_belongs_to_us(_pid: u32) -> bool { - false -} - -/// The value stamped into the `BUZZ_MANAGED_AGENT` env var of every agent we -/// spawn, identifying *which* desktop instance owns it. We use the app's bundle -/// identifier (`xyz.block.buzz.app` for release, `xyz.block.buzz.app.dev` -/// for `just dev`) because it is stable across restarts — a relaunched dev -/// instance still recognizes its own previously-spawned agents as reclaimable, -/// while never matching another instance's (e.g. a dev build never reaps a DMG -/// build's agents, and vice versa). This is what lets two Buzzs coexist on -/// one machine without one's cleanup nuking the other's agents. -pub(crate) fn current_instance_id(app: &AppHandle) -> String { - app.config().identifier.clone() -} - -/// Build the full `BUZZ_MANAGED_AGENT=` env entry we match -/// against when scanning processes. Kept here so the spawn stamp and the sweep -/// matcher can never drift apart. -fn buzz_marker_entry(instance_id: &str) -> Vec { - format!("BUZZ_MANAGED_AGENT={instance_id}").into_bytes() -} - -/// Check if a running process is one of *our* managed agents: it must carry -/// `BUZZ_MANAGED_AGENT=` in its environment, where `instance_id` -/// is this desktop instance's id. A process stamped with a *different* instance -/// id belongs to another live Buzz app and must never be reaped here. -#[cfg(target_os = "macos")] -pub(crate) fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { - let marker = buzz_marker_entry(instance_id); - let Some(buf) = sweep::procargs2_buffer(pid) else { - return false; - }; - - // Buffer layout: [i32 argc][exec_path\0][null padding][argv\0...][env\0...] - if buf.len() < std::mem::size_of::() { - return false; - } - let mut n_args: libc::c_int = 0; - unsafe { - std::ptr::copy_nonoverlapping( - buf.as_ptr(), - &mut n_args as *mut libc::c_int as *mut u8, - std::mem::size_of::(), - ); - } - let mut pos = std::mem::size_of::(); - - // Skip exec path (scan to first null). - while pos < buf.len() && buf[pos] != 0 { - pos += 1; - } - // Skip null padding between exec path and argv[0]. - while pos < buf.len() && buf[pos] == 0 { - pos += 1; - } - // Skip argc argument strings. - let mut args_remaining = n_args; - while args_remaining > 0 && pos < buf.len() { - while pos < buf.len() && buf[pos] != 0 { - pos += 1; - } - while pos < buf.len() && buf[pos] == 0 { - pos += 1; - } - args_remaining -= 1; - } - // Remaining bytes are null-delimited environment strings. - buf[pos..].split(|&b| b == 0).any(|entry| entry == marker) -} - -#[cfg(all(unix, not(target_os = "macos")))] -pub(crate) fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { - let marker = buzz_marker_entry(instance_id); - let Ok(data) = std::fs::read(format!("/proc/{pid}/environ")) else { - return false; - }; - data.split(|&b| b == 0).any(|entry| entry == marker) -} - -#[cfg(not(unix))] -pub(crate) fn process_has_buzz_marker(_pid: u32, _instance_id: &str) -> bool { - false -} - -#[cfg(unix)] -fn signal_process_group_or_leader(pid: u32, signal: i32, action: &str) -> Result<(), String> { - let pgid = -(pid as i32); - - if unsafe { libc::kill(pgid, signal) } == 0 { - return Ok(()); - } - - let group_err = std::io::Error::last_os_error(); - if !process_is_running(pid) { - return Ok(()); - } - - // Some local agent trees can no longer be signalled as a process group - // (for example if the leader changed groups, or macOS returns EPERM for one - // descendant). Fall back to the leader PID so stop/delete can still recover. - if matches!( - group_err.raw_os_error(), - Some(libc::EPERM) | Some(libc::ESRCH) - ) { - if unsafe { libc::kill(pid as i32, signal) } == 0 { - return Ok(()); - } - - let leader_err = std::io::Error::last_os_error(); - if leader_err.raw_os_error() == Some(libc::ESRCH) || !process_is_running(pid) { - return Ok(()); - } - - return Err(format!("failed to {action} process {pid}: {leader_err}")); - } - - Err(format!( - "failed to {action} process group {pid}: {group_err}" - )) -} - -#[cfg(unix)] -pub(crate) fn terminate_process(pid: u32) -> Result<(), String> { - // Try graceful shutdown first (SIGTERM to the group). - signal_process_group_or_leader(pid, libc::SIGTERM, "terminate")?; - - // Wait up to 1s for graceful exit. - for _ in 0..10 { - if !process_is_running(pid) { - return Ok(()); - } - std::thread::sleep(std::time::Duration::from_millis(100)); - } - - // Escalate to SIGKILL on the entire group. - signal_process_group_or_leader(pid, libc::SIGKILL, "kill")?; - - Ok(()) -} - -#[cfg(windows)] -pub(crate) fn terminate_process(pid: u32) -> Result<(), String> { - // No job handle is available on this path (e.g. after an app restart, when - // we only recovered the PID from the record), so fall back to taskkill on - // the whole tree. - super::process_lifecycle::taskkill_tree(pid) -} - -#[cfg(not(any(unix, windows)))] -pub(crate) fn terminate_process(_pid: u32) -> Result<(), String> { - Err("managed agent shutdown after app restart is not supported on this platform".to_string()) -} - -/// Send SIGTERM to all given PIDs (as process groups), wait, then SIGKILL -/// any survivors. Uses `-pid` to kill the entire process group — if an -/// orphaned agent called `setsid()`, it IS the group leader, so this -/// reaches its children too. -#[cfg(unix)] -fn sigterm_then_sigkill(pids: &[i32]) { - // Send SIGTERM to each process group. Track whether any signal was - // actually delivered so we can skip the sleep when everything is - // already gone. - let mut any_signalled = false; - for &pid in pids { - if unsafe { libc::kill(-pid, libc::SIGTERM) } == 0 { - any_signalled = true; - } - } - - if !any_signalled { - return; - } - - std::thread::sleep(std::time::Duration::from_millis(200)); - - for &pid in pids { - // Check if the group has any living members, not just the leader. - // kill(-pid, 0) returns 0 if ANY member of the group is signalable. - if unsafe { libc::kill(-pid, 0) } == 0 { - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } - } -} - -/// Resolve orphan candidate PIDs to their actual process group IDs, dedupe, -/// and signal the groups. An orphaned grandchild (e.g. `goose` or `buzz-dev-mcp`) -/// whose harness has exited retains the harness's PGID — signaling that PGID -/// kills the entire orphaned subtree. Falls back to the candidate PID itself -/// when PGID resolution fails (process may have exited between detection and -/// kill). -#[cfg(target_os = "macos")] -fn resolve_pgids_and_kill(candidate_pids: &[i32]) { - let candidate_set: std::collections::HashSet = candidate_pids.iter().copied().collect(); - let mut pgids = std::collections::HashSet::new(); - for &pid in candidate_pids { - let pgid = unsafe { libc::getpgid(pid) }; - if pgid > 0 { - pgids.insert(pgid); - } else { - // Process may have exited; try signaling it directly as a group. - pgids.insert(pid); - } - } - // PID-recycling guard: if a resolved PGID is alive but isn't one of our - // orphan candidates, the old harness PID was recycled by a new process - // that called setsid() — skip it to avoid killing an unrelated group. - let candidate_groups = pgids.len(); - pgids.retain(|&pgid| { - if candidate_set.contains(&pgid) { - return true; - } - let alive = unsafe { libc::kill(pgid, 0) } == 0; - !alive - }); - if pgids.is_empty() && candidate_groups > 0 { - eprintln!( - "buzz-desktop: orphan sweep: skipped all {candidate_groups} candidate group(s) (live foreign group leader or candidate already exited); nothing signalled" - ); - } - let unique: Vec = pgids.into_iter().collect(); - sigterm_then_sigkill(&unique); -} - -/// Resolve orphan candidate PIDs to their actual process group IDs, dedupe, -/// and signal the groups. Linux variant reads PGID from /proc//stat. -#[cfg(all(unix, not(target_os = "macos")))] -fn resolve_pgids_and_kill(candidate_pids: &[i32]) { - let candidate_set: std::collections::HashSet = candidate_pids.iter().copied().collect(); - let mut pgids = std::collections::HashSet::new(); - for &pid in candidate_pids { - if let Some(pgid) = read_pgid_linux(pid as u32) { - pgids.insert(pgid as i32); - } else { - // Process may have exited; try signaling it directly as a group. - pgids.insert(pid); - } - } - // PID-recycling guard: if a resolved PGID is alive but isn't one of our - // orphan candidates, the old harness PID was recycled by a new process - // that called setsid() — skip it to avoid killing an unrelated group. - let candidate_groups = pgids.len(); - pgids.retain(|&pgid| { - if candidate_set.contains(&pgid) { - return true; - } - let alive = unsafe { libc::kill(pgid, 0) } == 0; - !alive - }); - if pgids.is_empty() && candidate_groups > 0 { - eprintln!( - "buzz-desktop: orphan sweep: skipped all {candidate_groups} candidate group(s) (live foreign group leader or candidate already exited); nothing signalled" - ); - } - let unique: Vec = pgids.into_iter().collect(); - sigterm_then_sigkill(&unique); -} - -pub(crate) fn valid_agent_runtime_receipt( - path: &std::path::Path, - receipt: &super::ManagedAgentRuntimeReceipt, - instance_id: &str, -) -> bool { - let Ok(canonical) = - ManagedAgentRuntimeKey::new(receipt.key.pubkey.clone(), &receipt.key.relay_url) - else { - return false; - }; - canonical == receipt.key - && path.file_name().and_then(|name| name.to_str()) - == Some(&format!("{}.json", receipt.key.runtime_id())) - && receipt.desktop_instance_id == instance_id - && process_is_running(receipt.pid) - && process_belongs_to_us(receipt.pid) - && process_has_buzz_marker(receipt.pid, &receipt.desktop_instance_id) -} - -fn terminate_runtime_receipt_with( - path: &std::path::Path, - receipt: &super::ManagedAgentRuntimeReceipt, - terminate: impl FnOnce(u32) -> Result<(), String>, - mut is_running: impl FnMut(u32) -> bool, - remove: impl FnOnce(&std::path::Path), -) -> Result<(), String> { - terminate(receipt.pid)?; - for _ in 0..20 { - if !is_running(receipt.pid) { - remove(path); - return Ok(()); - } - std::thread::sleep(std::time::Duration::from_millis(100)); - } - Err(format!( - "prior runtime {} for pair {} on {} did not exit", - receipt.pid, receipt.key.pubkey, receipt.key.relay_url - )) -} - -/// Replace a valid prior-session process before registering a new child for -/// the same pair. The caller must hold the runtime transition lock so receipt -/// inspection, termination, spawn, and registration cannot race shutdown or -/// another start. -pub(crate) fn terminate_untracked_pair_runtime( - app: &AppHandle, - key: &ManagedAgentRuntimeKey, -) -> Result<(), String> { - let instance_id = current_instance_id(app); - let Some((path, receipt)) = super::read_all_agent_runtime_receipts(app) - .into_iter() - .find(|(path, receipt)| { - receipt.key == *key && valid_agent_runtime_receipt(path, receipt, &instance_id) - }) - else { - return Ok(()); - }; - - terminate_runtime_receipt_with( - &path, - &receipt, - terminate_process, - process_is_running, - super::remove_agent_runtime_receipt_path, - ) -} - -/// Kill orphaned agent processes using PID file receipts. Reads all files from -/// `agent-pids/`, verifies each PID still belongs to a known agent binary, -/// then resolves each candidate's actual PGID and signals the process group. -/// Deletes the PID file after killing. -/// -/// `skip_pids` are PIDs already handled by the tracked-agent path. -#[cfg(unix)] -pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, skip_pids: &[u32]) { - let legacy_entries = super::read_all_agent_pid_files(app); - let instance_id = current_instance_id(app); - let receipt_entries: Vec<_> = super::read_all_agent_runtime_receipts(app) - .into_iter() - .filter_map(|(path, receipt)| { - if valid_agent_runtime_receipt(&path, &receipt, &instance_id) { - Some((path, receipt)) - } else { - super::remove_agent_runtime_receipt_path(&path); - None - } - }) - .collect(); - // Collect live orphans AND dead-leader groups into a single kill batch. - // Dead leaders: PGID may have been recycled, but the window is narrow - // (PID files are from this session) and the cost of missing surviving - // group members outweighs the recycling risk. - let targets: Vec = legacy_entries - .iter() - .map(|(_, pid)| *pid) - .chain(receipt_entries.iter().map(|(_, receipt)| receipt.pid)) - .filter(|pid| { - if skip_pids.contains(pid) { - return false; - } - (process_is_running(*pid) && process_belongs_to_us(*pid)) || !process_is_running(*pid) - }) - .map(|pid| pid as i32) - .collect(); - - if !targets.is_empty() { - resolve_pgids_and_kill(&targets); - } - - // Clean up PID files for processes we just killed or that are already gone. - for (pubkey, pid) in &legacy_entries { - if skip_pids.contains(pid) { - continue; - } - if !process_is_running(*pid) || !process_belongs_to_us(*pid) { - super::remove_agent_pid_file(app, pubkey); - } - } - for (_, receipt) in &receipt_entries { - if skip_pids.contains(&receipt.pid) { - continue; - } - if !process_is_running(receipt.pid) || !process_belongs_to_us(receipt.pid) { - super::remove_agent_runtime_receipt(app, &receipt.key); - } - } -} - -#[cfg(not(unix))] -pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, _skip_pids: &[u32]) { - let _ = app; -} - -// ── macOS process-info FFI (shared by all sweep/reap functions) ────────── -// -// `proc_listallpids` lives in `sweep.rs` (which owns `collect_all_pids`). -// All callers in this file reach it through `sweep::collect_all_pids()`. -// `proc_pidinfo` and `BSDInfo` are declared here as `pub(super)` so that -// `sweep.rs` can call `super::proc_pidinfo` / use `super::BSDInfo` without -// redefining the struct layout in two places. - -#[cfg(target_os = "macos")] -extern "C" { - pub(super) fn proc_pidinfo( - pid: libc::c_int, - flavor: libc::c_int, - arg: u64, - buffer: *mut libc::c_void, - buffersize: libc::c_int, - ) -> libc::c_int; -} - -/// Subset of `struct proc_bsdinfo` from ``. Layout verified -/// against the macOS SDK — total size 136 bytes. -#[cfg(target_os = "macos")] -#[repr(C)] -pub(super) struct BSDInfo { - _flags_status_xstatus: [u8; 12], // pbi_flags + pbi_status + pbi_xstatus - pub(super) pbi_pid: u32, // offset 12 - pub(super) pbi_ppid: u32, // offset 16 - pub(super) pbi_uid: u32, // offset 20 - _rest: [u8; 112], -} - -#[cfg(target_os = "macos")] -const _: () = assert!(std::mem::size_of::() == 136); - -#[cfg(target_os = "macos")] -pub(super) const PROC_PIDTBSDINFO: libc::c_int = 3; - -/// Enumerate all processes on the system owned by the current user and kill any -/// agent binary stamped with *this* instance's `BUZZ_MANAGED_AGENT` marker -/// (`instance_id`) that isn't in `skip_pids`. This catches orphans that escaped -/// PID-file-based cleanup (e.g. agent workers spawned with their own process -/// group whose parent harness already exited and had its PID file removed), -/// while leaving another live Buzz instance's agents untouched. -#[cfg(target_os = "macos")] -pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) { - let my_uid = unsafe { libc::getuid() }; - let pids = sweep::collect_all_pids(); - if pids.is_empty() { - return; - } - let my_pid = std::process::id() as i32; - let mut orphans: Vec = Vec::new(); - - for &pid in &pids { - if pid <= 0 { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) || pid == my_pid { - continue; - } - // Check binary name first (cheap proc_name call) before UID lookup. - if !process_belongs_to_us(upid) { - continue; - } - // Verify UID and PPID via proc_pidinfo. - let mut info = std::mem::MaybeUninit::::zeroed(); - let ret = unsafe { - proc_pidinfo( - pid, - PROC_PIDTBSDINFO, - 0, - info.as_mut_ptr() as *mut libc::c_void, - std::mem::size_of::() as libc::c_int, - ) - }; - if ret <= 0 { - continue; - } - let info = unsafe { info.assume_init() }; - if info.pbi_uid != my_uid { - continue; - } - if !process_has_buzz_marker(upid, instance_id) { - continue; - } - // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. - if sweep::is_live_descendant_macos(upid, info.pbi_ppid, skip_pids) { - continue; - } - orphans.push(pid); - } - - if !orphans.is_empty() { - eprintln!( - "buzz-desktop: system sweep found {} orphaned agent process(es), cleaning up", - orphans.len() - ); - resolve_pgids_and_kill(&orphans); - } -} - -/// Read the process group ID from /proc//stat by delegating to the shared -/// stat parser in `sweep`. Keeps a single parse site for the `/proc//stat` -/// field layout. -#[cfg(all(unix, not(target_os = "macos")))] -fn read_pgid_linux(pid: u32) -> Option { - sweep::proc_stat_ppid_pgid_linux(pid).map(|(_, pgid)| pgid) -} - -#[cfg(all(unix, not(target_os = "macos")))] -pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) { - let my_uid = unsafe { libc::getuid() }; - let mut orphans: Vec = Vec::new(); - let my_pid = std::process::id() as i32; - - let Ok(entries) = std::fs::read_dir("/proc") else { - return; - }; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name_str) = name.to_str() else { - continue; - }; - let Ok(pid) = name_str.parse::() else { - continue; - }; - if pid <= 0 || pid == my_pid { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) { - continue; - } - // Check ownership via /proc/ metadata. - let Ok(meta) = entry.metadata() else { - continue; - }; - use std::os::unix::fs::MetadataExt; - if meta.uid() != my_uid { - continue; - } - if !process_belongs_to_us(upid) || !process_has_buzz_marker(upid, instance_id) { - continue; - } - // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. - if sweep::is_live_descendant_linux(upid, skip_pids) { - continue; - } - orphans.push(pid); - } - - if !orphans.is_empty() { - eprintln!( - "buzz-desktop: system sweep found {} orphaned agent process(es), cleaning up", - orphans.len() - ); - resolve_pgids_and_kill(&orphans); - } -} - -#[cfg(not(unix))] -pub(crate) fn sweep_system_agent_processes(_instance_id: &str, _skip_pids: &[u32]) {} - -/// Periodic-sweep variant with two-tick grace: only reaps same-instance orphans -/// that were also seen orphaned on the previous tick. This prevents killing a -/// legitimately-starting agent that spawned between the skip-list snapshot and -/// the process scan. Returns the current orphan set for use as `prev_orphans` -/// on the next tick. -#[cfg(unix)] -pub(crate) fn sweep_system_agent_processes_with_grace( - instance_id: &str, - skip_pids: &[u32], - prev_orphans: &std::collections::HashSet, -) -> std::collections::HashSet { - let current = collect_same_instance_orphans(instance_id, skip_pids); - // Only reap PIDs seen orphaned on two consecutive ticks. - let confirmed: Vec = current - .iter() - .filter(|pid| prev_orphans.contains(pid)) - .map(|&pid| pid as i32) - .collect(); - if !confirmed.is_empty() { - eprintln!( - "buzz-desktop: periodic sweep confirmed {} orphaned agent process(es), cleaning up", - confirmed.len() - ); - resolve_pgids_and_kill(&confirmed); - } - current -} - -#[cfg(not(unix))] -pub(crate) fn sweep_system_agent_processes_with_grace( - _instance_id: &str, - _skip_pids: &[u32], - _prev_orphans: &std::collections::HashSet, -) -> std::collections::HashSet { - std::collections::HashSet::new() -} - -/// Collect PIDs of same-instance agent processes that appear orphaned (not in -/// `skip_pids`). Returns the set for use in two-tick grace logic — does NOT -/// kill anything. -#[cfg(target_os = "macos")] -pub(crate) fn collect_same_instance_orphans( - instance_id: &str, - skip_pids: &[u32], -) -> std::collections::HashSet { - let my_uid = unsafe { libc::getuid() }; - let my_pid = std::process::id() as i32; - let mut orphans = std::collections::HashSet::new(); - - let pids = sweep::collect_all_pids(); - if pids.is_empty() { - return orphans; - } - - for &pid in &pids { - if pid <= 0 || pid == my_pid { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) { - continue; - } - if !process_belongs_to_us(upid) { - continue; - } - let mut info = std::mem::MaybeUninit::::zeroed(); - let ret = unsafe { - proc_pidinfo( - pid, - PROC_PIDTBSDINFO, - 0, - info.as_mut_ptr() as *mut libc::c_void, - std::mem::size_of::() as libc::c_int, - ) - }; - if ret <= 0 { - continue; - } - let info = unsafe { info.assume_init() }; - if info.pbi_uid != my_uid { - continue; - } - if !process_has_buzz_marker(upid, instance_id) { - continue; - } - // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. - if sweep::is_live_descendant_macos(upid, info.pbi_ppid, skip_pids) { - continue; - } - orphans.insert(upid); - } - orphans -} - -#[cfg(all(unix, not(target_os = "macos")))] -pub(crate) fn collect_same_instance_orphans( - instance_id: &str, - skip_pids: &[u32], -) -> std::collections::HashSet { - let my_uid = unsafe { libc::getuid() }; - let my_pid = std::process::id() as i32; - let mut orphans = std::collections::HashSet::new(); - - let Ok(entries) = std::fs::read_dir("/proc") else { - return orphans; - }; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name_str) = name.to_str() else { - continue; - }; - let Ok(pid) = name_str.parse::() else { - continue; - }; - if pid <= 0 || pid == my_pid { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) { - continue; - } - let Ok(meta) = entry.metadata() else { - continue; - }; - use std::os::unix::fs::MetadataExt; - if meta.uid() != my_uid { - continue; - } - if !process_belongs_to_us(upid) || !process_has_buzz_marker(upid, instance_id) { - continue; - } - // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. - if sweep::is_live_descendant_linux(upid, skip_pids) { - continue; - } - orphans.insert(upid); - } - orphans -} - -#[cfg(not(unix))] -pub(crate) fn collect_same_instance_orphans( - _instance_id: &str, - _skip_pids: &[u32], -) -> std::collections::HashSet { - std::collections::HashSet::new() -} - -/// Binary names for the Buzz desktop/Tauri process. Used by dead-instance -/// detection to confirm the owning desktop is still alive. -const DESKTOP_BINARY_NAMES: &[&str] = &[ - "Buzz", - "buzz-desktop", - "buzz_desktop", - // Linux limits /proc//comm to 15 visible bytes, truncating the - // AppImage shim's real executable name, `buzz-desktop.bin`. - "buzz-desktop.bi", -]; - -/// Check if a process name matches a known Buzz desktop binary. -fn is_desktop_binary(name: &str) -> bool { - DESKTOP_BINARY_NAMES.contains(&name) -} - -/// Check whether `buf` contains `id` as a complete identifier — not as a -/// prefix of a longer dotted name. The identifier appears in the Tauri config -/// JSON as `"identifier":"xyz.block.buzz.app.dev"` and in environment entries -/// as `KEY=...app.dev\0`, so a valid match is followed by a non-identifier byte -/// (not `[A-Za-z0-9._-]`) or sits at the end of the buffer. This prevents -/// `xyz.block.buzz.app` from matching inside `xyz.block.buzz.app.dev`. -fn buffer_contains_identifier(buf: &[u8], id: &[u8]) -> bool { - if id.is_empty() { - return false; - } - buf.windows(id.len()).enumerate().any(|(i, w)| { - if w != id { - return false; - } - // Boundary check on the byte immediately after the match: end-of-buffer - // or any byte that can't continue a dotted reverse-DNS identifier. - match buf.get(i + id.len()) { - None => true, - Some(&next) => { - !next.is_ascii_alphanumeric() && next != b'.' && next != b'_' && next != b'-' - } - } - }) -} - -/// Extract the `BUZZ_MANAGED_AGENT` value from a process's environment. -/// Returns `None` if the process doesn't have the marker or can't be read. -#[cfg(target_os = "macos")] -fn extract_buzz_marker_value(pid: u32) -> Option { - let prefix = b"BUZZ_MANAGED_AGENT="; - let buf = sweep::procargs2_buffer(pid)?; - - if buf.len() < std::mem::size_of::() { - return None; - } - let mut n_args: libc::c_int = 0; - unsafe { - std::ptr::copy_nonoverlapping( - buf.as_ptr(), - &mut n_args as *mut libc::c_int as *mut u8, - std::mem::size_of::(), - ); - } - let mut pos = std::mem::size_of::(); - - // Skip exec path. - while pos < buf.len() && buf[pos] != 0 { - pos += 1; - } - while pos < buf.len() && buf[pos] == 0 { - pos += 1; - } - // Skip argc argument strings. - let mut args_remaining = n_args; - while args_remaining > 0 && pos < buf.len() { - while pos < buf.len() && buf[pos] != 0 { - pos += 1; - } - while pos < buf.len() && buf[pos] == 0 { - pos += 1; - } - args_remaining -= 1; - } - // Search environment entries for our marker. - for entry in buf[pos..].split(|&b| b == 0) { - if entry.starts_with(prefix) { - return String::from_utf8(entry[prefix.len()..].to_vec()).ok(); - } - } - None -} - -#[cfg(all(unix, not(target_os = "macos")))] -fn extract_buzz_marker_value(pid: u32) -> Option { - let prefix = b"BUZZ_MANAGED_AGENT="; - let data = std::fs::read(format!("/proc/{pid}/environ")).ok()?; - for entry in data.split(|&b| b == 0) { - if entry.starts_with(prefix) { - return String::from_utf8(entry[prefix.len()..].to_vec()).ok(); - } - } - None -} - -#[cfg(not(unix))] -fn extract_buzz_marker_value(_pid: u32) -> Option { - None -} - -/// Check if a Buzz desktop process is still alive for the given instance ID. -/// Scans all user-owned processes named "Buzz" or "buzz-desktop" and checks -/// whether any has the identifier in its command-line args (KERN_PROCARGS2 buffer -/// includes both argv and environ — the `--config` JSON from `tauri dev` contains -/// the identifier string). -#[cfg(target_os = "macos")] -fn desktop_is_alive_for_instance(instance_id: &str) -> bool { - extern "C" { - fn proc_name(pid: libc::c_int, buffer: *mut libc::c_void, buffersize: u32) -> libc::c_int; - } - - let my_uid = unsafe { libc::getuid() }; - let identifier_bytes = instance_id.as_bytes(); - - let pids = sweep::collect_all_pids(); - if pids.is_empty() { - return false; - } - - for &pid in &pids { - if pid <= 0 { - continue; - } - // Check binary name — only look at desktop binaries. - let mut name_buf = [0u8; 1024]; - let len = unsafe { - proc_name( - pid, - name_buf.as_mut_ptr() as *mut libc::c_void, - name_buf.len() as u32, - ) - }; - if len <= 0 { - continue; - } - let name = String::from_utf8_lossy(&name_buf[..len as usize]); - if !is_desktop_binary(&name) { - continue; - } - // Verify UID. - let mut info = std::mem::MaybeUninit::::zeroed(); - let ret = unsafe { - proc_pidinfo( - pid, - PROC_PIDTBSDINFO, - 0, - info.as_mut_ptr() as *mut libc::c_void, - std::mem::size_of::() as libc::c_int, - ) - }; - if ret <= 0 { - continue; - } - let info = unsafe { info.assume_init() }; - if info.pbi_uid != my_uid { - continue; - } - // Check if this desktop process's args/env contain the identifier. - // The KERN_PROCARGS2 buffer holds argv + environ as null-delimited strings. - let Some(args_buf) = sweep::procargs2_buffer(pid as u32) else { - continue; - }; - // Boundary-anchored search: the identifier in the config JSON is - // followed by a non-identifier char (typically `"`). A raw substring - // match would let `...app` match inside `...app.dev`. - if buffer_contains_identifier(&args_buf, identifier_bytes) { - return true; - } - } - false -} - -#[cfg(all(unix, not(target_os = "macos")))] -fn desktop_is_alive_for_instance(instance_id: &str) -> bool { - let my_uid = unsafe { libc::getuid() }; - let Ok(entries) = std::fs::read_dir("/proc") else { - return false; - }; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name_str) = name.to_str() else { - continue; - }; - let Ok(pid) = name_str.parse::() else { - continue; - }; - // Check ownership. - let Ok(meta) = entry.metadata() else { - continue; - }; - use std::os::unix::fs::MetadataExt; - if meta.uid() != my_uid { - continue; - } - // Check binary name via /proc//comm. - let Ok(comm) = std::fs::read_to_string(format!("/proc/{pid}/comm")) else { - continue; - }; - if !is_desktop_binary(comm.trim()) { - continue; - } - // Check cmdline for the identifier with boundary anchoring. - let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { - continue; - }; - if buffer_contains_identifier(&cmdline, instance_id.as_bytes()) { - return true; - } - } - false -} - -#[cfg(not(unix))] -fn desktop_is_alive_for_instance(_instance_id: &str) -> bool { - false -} - -/// Reap agent processes belonging to dead Buzz desktop instances. -/// -/// Scans all user processes for `BUZZ_MANAGED_AGENT=*`, groups them by -/// instance ID, and for each foreign instance (≠ `our_instance_id`) checks -/// whether a Buzz desktop binary is still alive for that instance. If not, -/// all agents from that dead instance are reaped. -#[cfg(target_os = "macos")] -pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32]) { - let my_uid = unsafe { libc::getuid() }; - let my_pid = std::process::id() as i32; - - let pids = sweep::collect_all_pids(); - if pids.is_empty() { - return; - } - - // Collect (pid, instance_id) for all foreign agent processes. - let mut foreign_agents: HashMap> = HashMap::new(); - - for &pid in &pids { - if pid <= 0 || pid == my_pid { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) { - continue; - } - if !process_belongs_to_us(upid) { - continue; - } - // Verify UID. - let mut info = std::mem::MaybeUninit::::zeroed(); - let ret = unsafe { - proc_pidinfo( - pid, - PROC_PIDTBSDINFO, - 0, - info.as_mut_ptr() as *mut libc::c_void, - std::mem::size_of::() as libc::c_int, - ) - }; - if ret <= 0 { - continue; - } - let info = unsafe { info.assume_init() }; - if info.pbi_uid != my_uid { - continue; - } - // Extract the instance ID from this agent's env. - let Some(agent_instance_id) = extract_buzz_marker_value(upid) else { - continue; - }; - // Skip agents belonging to our own instance (handled by sweep_system_agent_processes). - if agent_instance_id == our_instance_id { - continue; - } - foreign_agents - .entry(agent_instance_id) - .or_default() - .push(pid); - } - - // For each foreign instance, check if its desktop is still alive. - for (instance_id, agent_pids) in &foreign_agents { - if desktop_is_alive_for_instance(instance_id) { - continue; - } - eprintln!( - "buzz-desktop: reaping {} orphaned agent(s) from dead instance '{instance_id}'", - agent_pids.len() - ); - resolve_pgids_and_kill(agent_pids); - } -} - -#[cfg(all(unix, not(target_os = "macos")))] -pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32]) { - let my_uid = unsafe { libc::getuid() }; - let my_pid = std::process::id() as i32; - let mut foreign_agents: HashMap> = HashMap::new(); - - let Ok(entries) = std::fs::read_dir("/proc") else { - return; - }; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name_str) = name.to_str() else { - continue; - }; - let Ok(pid) = name_str.parse::() else { - continue; - }; - if pid <= 0 || pid == my_pid { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) { - continue; - } - let Ok(meta) = entry.metadata() else { - continue; - }; - use std::os::unix::fs::MetadataExt; - if meta.uid() != my_uid { - continue; - } - if !process_belongs_to_us(upid) { - continue; - } - let Some(agent_instance_id) = extract_buzz_marker_value(upid) else { - continue; - }; - if agent_instance_id == our_instance_id { - continue; - } - foreign_agents - .entry(agent_instance_id) - .or_default() - .push(pid); - } - - for (instance_id, agent_pids) in &foreign_agents { - if desktop_is_alive_for_instance(instance_id) { - continue; - } - eprintln!( - "buzz-desktop: reaping {} orphaned agent(s) from dead instance '{instance_id}'", - agent_pids.len() - ); - resolve_pgids_and_kill(agent_pids); - } -} - -#[cfg(not(unix))] -pub(crate) fn reap_dead_instance_agents(_our_instance_id: &str, _skip_pids: &[u32]) {} +mod instance_reaper; +pub(crate) use instance_reaper::reap_dead_instance_agents; +#[cfg(test)] +use instance_reaper::{buffer_contains_identifier, is_desktop_binary}; // Exact-path harness sweep lives in runtime/sweep.rs (re-exported above). -/// Kill stale agent processes from a previous session whose PID is still alive -/// but not tracked in the current `runtimes` map. Updates the record fields and -/// returns `true` if any records were modified. -pub fn kill_stale_tracked_processes( - records: &mut [ManagedAgentRecord], - runtimes: &HashMap, - instance_id: &str, -) -> bool { - use crate::managed_agents::BackendKind; - - let mut changed = false; - for record in records.iter_mut() { - if record.backend != BackendKind::Local { - continue; - } - let Some(pid) = record.runtime_pid else { - continue; - }; - if !runtimes.keys().any(|key| key.pubkey == record.pubkey) { - if process_belongs_to_us(pid) && process_has_buzz_marker(pid, instance_id) { - let _ = terminate_process(pid); - } - record.runtime_pid = None; - record.last_stopped_at = Some(crate::util::now_iso()); - record.updated_at = crate::util::now_iso(); - changed = true; - } - } - changed -} - -pub fn sync_managed_agent_processes( - records: &mut [ManagedAgentRecord], - runtimes: &mut HashMap, - _instance_id: &str, -) -> (bool, Vec) { - let mut changed = false; - let mut exited = Vec::new(); - - for (key, runtime) in runtimes.iter_mut() { - let status = match runtime.child.try_wait() { - Ok(status) => status, - Err(error) => { - if let Some(record) = records - .iter_mut() - .find(|record| record.pubkey == key.pubkey) - { - record.updated_at = now_iso(); - record.last_error = Some(format!("failed to inspect process state: {error}")); - record.last_error_code = None; - } - changed = true; - exited.push(key.clone()); - continue; - } - }; - - let Some(status) = status else { - continue; - }; - - if let Some(record) = records - .iter_mut() - .find(|record| record.pubkey == key.pubkey) - { - record.updated_at = now_iso(); - record.last_stopped_at = Some(now_iso()); - record.last_exit_code = status.code(); - let log_err = if status.success() { - None - } else { - Some( - super::meaningful_agent_error_from_log(&runtime.log_path).unwrap_or_else( - || super::storage::AgentLogError { - message: format!("harness exited with status {status}"), - code: None, - }, - ), - ) - }; - record.last_error = log_err.as_ref().map(|e| e.message.clone()); - record.last_error_code = log_err.as_ref().and_then(|e| e.code); - } - - changed = true; - exited.push(key.clone()); - } - - let exited_pubkeys: Vec = exited.iter().map(|key| key.pubkey.clone()).collect(); - for key in exited { - runtimes.remove(&key); - } - - // `runtime_pid` is legacy bookkeeping. Pair runtimes and receipts are the - // authoritative lifecycle source; migration cleanup is handled separately. - for record in records.iter_mut() { - if record.runtime_pid.take().is_some() { - record.updated_at = now_iso(); - changed = true; - } - } - - (changed, exited_pubkeys) -} +mod lifecycle; +pub use lifecycle::{kill_stale_tracked_processes, sync_managed_agent_processes}; /// Classify an agent's persona against the live catalog for the Agents-menu /// drift indicator. Returns `(out_of_date, orphaned)`. @@ -2159,61 +906,5 @@ pub fn start_managed_agent_process( Ok(()) } -/// Returns the (key, value) env var pairs that should be forwarded to the -/// agent process for model and provider selection. -/// -/// Model injection is unconditional — even agents that support ACP model -/// switching need the initial bootstrap value. Provider injection is skipped -/// when `provider_locked` is true (e.g. Claude runtimes that only work with -/// Anthropic). -pub(crate) fn runtime_metadata_env_vars<'a>( - model_env_var: Option<&'a str>, - provider_env_var: Option<&'a str>, - provider_locked: bool, - effective_model: Option<&'a str>, - effective_provider: Option<&'a str>, -) -> Vec<(&'a str, &'a str)> { - let mut vars = Vec::new(); - if let (Some(env_key), Some(model)) = (model_env_var, effective_model) { - vars.push((env_key, model)); - } - if !provider_locked { - if let (Some(env_key), Some(provider)) = (provider_env_var, effective_provider) { - vars.push((env_key, provider)); - } - } - vars -} - -/// Resolve the effective (prompt, model, provider) triple for a persona-linked agent. -/// -/// Given a persona_id, finds the persona in the list and returns its system_prompt, -/// model, and provider as the authoritative values. When the persona leaves `model` -/// or `provider` blank (None or whitespace-only), falls back to the record's own -/// field using the same precedence rule as `persona_snapshot_with_agent_config_fallback` -/// so the display surface matches spawn behavior. Falls back to the record's own -/// prompt/model/provider when no persona is linked or found. -/// -/// Used by `agent_config.rs` to inject persona defaults into the config surface -/// before running the reader, so BuzzExplicit-tagged fields can be re-tagged to -/// PersonaDefault for fields the record did not independently set. -pub(crate) fn resolve_effective_prompt_model_provider( - persona_id: Option<&str>, - personas: &[crate::managed_agents::types::AgentDefinition], - record_prompt: Option, - record_model: Option, - record_provider: Option, -) -> (Option, Option, Option) { - let fallback = crate::managed_agents::persona_events::persona_field_with_record_fallback; - match persona_id.and_then(|pid| personas.iter().find(|p| p.id == pid)) { - Some(p) => ( - Some(p.system_prompt.clone()), - fallback(p.model.as_deref(), record_model.as_deref()), // fallback: record.model - fallback(p.provider.as_deref(), record_provider.as_deref()), // fallback: record.provider - ), - None => (record_prompt, record_model, record_provider), - } -} - #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime/instance_reaper.rs b/desktop/src-tauri/src/managed_agents/runtime/instance_reaper.rs new file mode 100644 index 0000000000..27fefd0303 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/instance_reaper.rs @@ -0,0 +1,359 @@ +use super::*; + +/// Binary names for the Buzz desktop/Tauri process. Used by dead-instance +/// detection to confirm the owning desktop is still alive. +const DESKTOP_BINARY_NAMES: &[&str] = &[ + "Buzz", + "buzz-desktop", + "buzz_desktop", + // Linux limits /proc//comm to 15 visible bytes, truncating the + // AppImage shim's real executable name, `buzz-desktop.bin`. + "buzz-desktop.bi", +]; + +/// Check if a process name matches a known Buzz desktop binary. +pub(super) fn is_desktop_binary(name: &str) -> bool { + DESKTOP_BINARY_NAMES.contains(&name) +} + +/// Check whether `buf` contains `id` as a complete identifier — not as a +/// prefix of a longer dotted name. The identifier appears in the Tauri config +/// JSON as `"identifier":"xyz.block.buzz.app.dev"` and in environment entries +/// as `KEY=...app.dev\0`, so a valid match is followed by a non-identifier byte +/// (not `[A-Za-z0-9._-]`) or sits at the end of the buffer. This prevents +/// `xyz.block.buzz.app` from matching inside `xyz.block.buzz.app.dev`. +pub(super) fn buffer_contains_identifier(buf: &[u8], id: &[u8]) -> bool { + if id.is_empty() { + return false; + } + buf.windows(id.len()).enumerate().any(|(i, w)| { + if w != id { + return false; + } + // Boundary check on the byte immediately after the match: end-of-buffer + // or any byte that can't continue a dotted reverse-DNS identifier. + match buf.get(i + id.len()) { + None => true, + Some(&next) => { + !next.is_ascii_alphanumeric() && next != b'.' && next != b'_' && next != b'-' + } + } + }) +} + +/// Extract the `BUZZ_MANAGED_AGENT` value from a process's environment. +/// Returns `None` if the process doesn't have the marker or can't be read. +#[cfg(target_os = "macos")] +fn extract_buzz_marker_value(pid: u32) -> Option { + let prefix = b"BUZZ_MANAGED_AGENT="; + let buf = sweep::procargs2_buffer(pid)?; + + if buf.len() < std::mem::size_of::() { + return None; + } + let mut n_args: libc::c_int = 0; + unsafe { + std::ptr::copy_nonoverlapping( + buf.as_ptr(), + &mut n_args as *mut libc::c_int as *mut u8, + std::mem::size_of::(), + ); + } + let mut pos = std::mem::size_of::(); + + // Skip exec path. + while pos < buf.len() && buf[pos] != 0 { + pos += 1; + } + while pos < buf.len() && buf[pos] == 0 { + pos += 1; + } + // Skip argc argument strings. + let mut args_remaining = n_args; + while args_remaining > 0 && pos < buf.len() { + while pos < buf.len() && buf[pos] != 0 { + pos += 1; + } + while pos < buf.len() && buf[pos] == 0 { + pos += 1; + } + args_remaining -= 1; + } + // Search environment entries for our marker. + for entry in buf[pos..].split(|&b| b == 0) { + if entry.starts_with(prefix) { + return String::from_utf8(entry[prefix.len()..].to_vec()).ok(); + } + } + None +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn extract_buzz_marker_value(pid: u32) -> Option { + let prefix = b"BUZZ_MANAGED_AGENT="; + let data = std::fs::read(format!("/proc/{pid}/environ")).ok()?; + for entry in data.split(|&b| b == 0) { + if entry.starts_with(prefix) { + return String::from_utf8(entry[prefix.len()..].to_vec()).ok(); + } + } + None +} + +#[cfg(not(unix))] +fn extract_buzz_marker_value(_pid: u32) -> Option { + None +} + +/// Check if a Buzz desktop process is still alive for the given instance ID. +/// Scans all user-owned processes named "Buzz" or "buzz-desktop" and checks +/// whether any has the identifier in its command-line args (KERN_PROCARGS2 buffer +/// includes both argv and environ — the `--config` JSON from `tauri dev` contains +/// the identifier string). +#[cfg(target_os = "macos")] +fn desktop_is_alive_for_instance(instance_id: &str) -> bool { + extern "C" { + fn proc_name(pid: libc::c_int, buffer: *mut libc::c_void, buffersize: u32) -> libc::c_int; + } + + let my_uid = unsafe { libc::getuid() }; + let identifier_bytes = instance_id.as_bytes(); + + let pids = sweep::collect_all_pids(); + if pids.is_empty() { + return false; + } + + for &pid in &pids { + if pid <= 0 { + continue; + } + // Check binary name — only look at desktop binaries. + let mut name_buf = [0u8; 1024]; + let len = unsafe { + proc_name( + pid, + name_buf.as_mut_ptr() as *mut libc::c_void, + name_buf.len() as u32, + ) + }; + if len <= 0 { + continue; + } + let name = String::from_utf8_lossy(&name_buf[..len as usize]); + if !is_desktop_binary(&name) { + continue; + } + // Verify UID. + let mut info = std::mem::MaybeUninit::::zeroed(); + let ret = unsafe { + proc_pidinfo( + pid, + PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr() as *mut libc::c_void, + std::mem::size_of::() as libc::c_int, + ) + }; + if ret <= 0 { + continue; + } + let info = unsafe { info.assume_init() }; + if info.pbi_uid != my_uid { + continue; + } + // Check if this desktop process's args/env contain the identifier. + // The KERN_PROCARGS2 buffer holds argv + environ as null-delimited strings. + let Some(args_buf) = sweep::procargs2_buffer(pid as u32) else { + continue; + }; + // Boundary-anchored search: the identifier in the config JSON is + // followed by a non-identifier char (typically `"`). A raw substring + // match would let `...app` match inside `...app.dev`. + if buffer_contains_identifier(&args_buf, identifier_bytes) { + return true; + } + } + false +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn desktop_is_alive_for_instance(instance_id: &str) -> bool { + let my_uid = unsafe { libc::getuid() }; + let Ok(entries) = std::fs::read_dir("/proc") else { + return false; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + let Ok(pid) = name_str.parse::() else { + continue; + }; + // Check ownership. + let Ok(meta) = entry.metadata() else { + continue; + }; + use std::os::unix::fs::MetadataExt; + if meta.uid() != my_uid { + continue; + } + // Check binary name via /proc//comm. + let Ok(comm) = std::fs::read_to_string(format!("/proc/{pid}/comm")) else { + continue; + }; + if !is_desktop_binary(comm.trim()) { + continue; + } + // Check cmdline for the identifier with boundary anchoring. + let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { + continue; + }; + if buffer_contains_identifier(&cmdline, instance_id.as_bytes()) { + return true; + } + } + false +} + +#[cfg(not(unix))] +fn desktop_is_alive_for_instance(_instance_id: &str) -> bool { + false +} + +/// Reap agent processes belonging to dead Buzz desktop instances. +/// +/// Scans all user processes for `BUZZ_MANAGED_AGENT=*`, groups them by +/// instance ID, and for each foreign instance (≠ `our_instance_id`) checks +/// whether a Buzz desktop binary is still alive for that instance. If not, +/// all agents from that dead instance are reaped. +#[cfg(target_os = "macos")] +pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32]) { + let my_uid = unsafe { libc::getuid() }; + let my_pid = std::process::id() as i32; + + let pids = sweep::collect_all_pids(); + if pids.is_empty() { + return; + } + + // Collect (pid, instance_id) for all foreign agent processes. + let mut foreign_agents: HashMap> = HashMap::new(); + + for &pid in &pids { + if pid <= 0 || pid == my_pid { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) { + continue; + } + if !process_belongs_to_us(upid) { + continue; + } + // Verify UID. + let mut info = std::mem::MaybeUninit::::zeroed(); + let ret = unsafe { + proc_pidinfo( + pid, + PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr() as *mut libc::c_void, + std::mem::size_of::() as libc::c_int, + ) + }; + if ret <= 0 { + continue; + } + let info = unsafe { info.assume_init() }; + if info.pbi_uid != my_uid { + continue; + } + // Extract the instance ID from this agent's env. + let Some(agent_instance_id) = extract_buzz_marker_value(upid) else { + continue; + }; + // Skip agents belonging to our own instance (handled by sweep_system_agent_processes). + if agent_instance_id == our_instance_id { + continue; + } + foreign_agents + .entry(agent_instance_id) + .or_default() + .push(pid); + } + + // For each foreign instance, check if its desktop is still alive. + for (instance_id, agent_pids) in &foreign_agents { + if desktop_is_alive_for_instance(instance_id) { + continue; + } + eprintln!( + "buzz-desktop: reaping {} orphaned agent(s) from dead instance '{instance_id}'", + agent_pids.len() + ); + resolve_pgids_and_kill(agent_pids); + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32]) { + let my_uid = unsafe { libc::getuid() }; + let my_pid = std::process::id() as i32; + let mut foreign_agents: HashMap> = HashMap::new(); + + let Ok(entries) = std::fs::read_dir("/proc") else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + let Ok(pid) = name_str.parse::() else { + continue; + }; + if pid <= 0 || pid == my_pid { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) { + continue; + } + let Ok(meta) = entry.metadata() else { + continue; + }; + use std::os::unix::fs::MetadataExt; + if meta.uid() != my_uid { + continue; + } + if !process_belongs_to_us(upid) { + continue; + } + let Some(agent_instance_id) = extract_buzz_marker_value(upid) else { + continue; + }; + if agent_instance_id == our_instance_id { + continue; + } + foreign_agents + .entry(agent_instance_id) + .or_default() + .push(pid); + } + + for (instance_id, agent_pids) in &foreign_agents { + if desktop_is_alive_for_instance(instance_id) { + continue; + } + eprintln!( + "buzz-desktop: reaping {} orphaned agent(s) from dead instance '{instance_id}'", + agent_pids.len() + ); + resolve_pgids_and_kill(agent_pids); + } +} + +#[cfg(not(unix))] +pub(crate) fn reap_dead_instance_agents(_our_instance_id: &str, _skip_pids: &[u32]) {} diff --git a/desktop/src-tauri/src/managed_agents/runtime/lifecycle.rs b/desktop/src-tauri/src/managed_agents/runtime/lifecycle.rs new file mode 100644 index 0000000000..ec64433af0 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/lifecycle.rs @@ -0,0 +1,105 @@ +use super::*; + +/// Kill stale agent processes from a previous session whose PID is still alive +/// but not tracked in the current `runtimes` map. Updates the record fields and +/// returns `true` if any records were modified. +pub fn kill_stale_tracked_processes( + records: &mut [ManagedAgentRecord], + runtimes: &HashMap, + instance_id: &str, +) -> bool { + use crate::managed_agents::BackendKind; + + let mut changed = false; + for record in records.iter_mut() { + if record.backend != BackendKind::Local { + continue; + } + let Some(pid) = record.runtime_pid else { + continue; + }; + if !runtimes.keys().any(|key| key.pubkey == record.pubkey) { + if process_belongs_to_us(pid) && process_has_buzz_marker(pid, instance_id) { + let _ = terminate_process(pid); + } + record.runtime_pid = None; + record.last_stopped_at = Some(crate::util::now_iso()); + record.updated_at = crate::util::now_iso(); + changed = true; + } + } + changed +} + +pub fn sync_managed_agent_processes( + records: &mut [ManagedAgentRecord], + runtimes: &mut HashMap, + _instance_id: &str, +) -> (bool, Vec) { + let mut changed = false; + let mut exited = Vec::new(); + + for (key, runtime) in runtimes.iter_mut() { + let status = match runtime.child.try_wait() { + Ok(status) => status, + Err(error) => { + if let Some(record) = records + .iter_mut() + .find(|record| record.pubkey == key.pubkey) + { + record.updated_at = now_iso(); + record.last_error = Some(format!("failed to inspect process state: {error}")); + record.last_error_code = None; + } + changed = true; + exited.push(key.clone()); + continue; + } + }; + + let Some(status) = status else { + continue; + }; + + if let Some(record) = records + .iter_mut() + .find(|record| record.pubkey == key.pubkey) + { + record.updated_at = now_iso(); + record.last_stopped_at = Some(now_iso()); + record.last_exit_code = status.code(); + let log_err = if status.success() { + None + } else { + Some( + super::super::meaningful_agent_error_from_log(&runtime.log_path) + .unwrap_or_else(|| super::super::storage::AgentLogError { + message: format!("harness exited with status {status}"), + code: None, + }), + ) + }; + record.last_error = log_err.as_ref().map(|e| e.message.clone()); + record.last_error_code = log_err.as_ref().and_then(|e| e.code); + } + + changed = true; + exited.push(key.clone()); + } + + let exited_pubkeys: Vec = exited.iter().map(|key| key.pubkey.clone()).collect(); + for key in exited { + runtimes.remove(&key); + } + + // `runtime_pid` is legacy bookkeeping. Pair runtimes and receipts are the + // authoritative lifecycle source; migration cleanup is handled separately. + for record in records.iter_mut() { + if record.runtime_pid.take().is_some() { + record.updated_at = now_iso(); + changed = true; + } + } + + (changed, exited_pubkeys) +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs new file mode 100644 index 0000000000..56d2e68353 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -0,0 +1,55 @@ +/// Returns the (key, value) env var pairs that should be forwarded to the +/// agent process for model and provider selection. +/// +/// Model injection is unconditional — even agents that support ACP model +/// switching need the initial bootstrap value. Provider injection is skipped +/// when `provider_locked` is true (e.g. Claude runtimes that only work with +/// Anthropic). +pub(crate) fn runtime_metadata_env_vars<'a>( + model_env_var: Option<&'a str>, + provider_env_var: Option<&'a str>, + provider_locked: bool, + effective_model: Option<&'a str>, + effective_provider: Option<&'a str>, +) -> Vec<(&'a str, &'a str)> { + let mut vars = Vec::new(); + if let (Some(env_key), Some(model)) = (model_env_var, effective_model) { + vars.push((env_key, model)); + } + if !provider_locked { + if let (Some(env_key), Some(provider)) = (provider_env_var, effective_provider) { + vars.push((env_key, provider)); + } + } + vars +} + +/// Resolve the effective (prompt, model, provider) triple for a persona-linked agent. +/// +/// Given a persona_id, finds the persona in the list and returns its system_prompt, +/// model, and provider as the authoritative values. When the persona leaves `model` +/// or `provider` blank (None or whitespace-only), falls back to the record's own +/// field using the same precedence rule as `persona_snapshot_with_agent_config_fallback` +/// so the display surface matches spawn behavior. Falls back to the record's own +/// prompt/model/provider when no persona is linked or found. +/// +/// Used by `agent_config.rs` to inject persona defaults into the config surface +/// before running the reader, so BuzzExplicit-tagged fields can be re-tagged to +/// PersonaDefault for fields the record did not independently set. +pub(crate) fn resolve_effective_prompt_model_provider( + persona_id: Option<&str>, + personas: &[crate::managed_agents::types::AgentDefinition], + record_prompt: Option, + record_model: Option, + record_provider: Option, +) -> (Option, Option, Option) { + let fallback = crate::managed_agents::persona_events::persona_field_with_record_fallback; + match persona_id.and_then(|pid| personas.iter().find(|p| p.id == pid)) { + Some(p) => ( + Some(p.system_prompt.clone()), + fallback(p.model.as_deref(), record_model.as_deref()), // fallback: record.model + fallback(p.provider.as_deref(), record_provider.as_deref()), // fallback: record.provider + ), + None => (record_prompt, record_model, record_provider), + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/orphan_sweep.rs b/desktop/src-tauri/src/managed_agents/runtime/orphan_sweep.rs new file mode 100644 index 0000000000..899f47b2c6 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/orphan_sweep.rs @@ -0,0 +1,371 @@ +use super::*; + +/// Kill orphaned agent processes using PID file receipts. Reads all files from +/// `agent-pids/`, verifies each PID still belongs to a known agent binary, +/// then resolves each candidate's actual PGID and signals the process group. +/// Deletes the PID file after killing. +/// +/// `skip_pids` are PIDs already handled by the tracked-agent path. +#[cfg(unix)] +pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, skip_pids: &[u32]) { + let legacy_entries = super::super::read_all_agent_pid_files(app); + let instance_id = current_instance_id(app); + let receipt_entries: Vec<_> = super::super::read_all_agent_runtime_receipts(app) + .into_iter() + .filter_map(|(path, receipt)| { + if valid_agent_runtime_receipt(&path, &receipt, &instance_id) { + Some((path, receipt)) + } else { + super::super::remove_agent_runtime_receipt_path(&path); + None + } + }) + .collect(); + // Collect live orphans AND dead-leader groups into a single kill batch. + // Dead leaders: PGID may have been recycled, but the window is narrow + // (PID files are from this session) and the cost of missing surviving + // group members outweighs the recycling risk. + let targets: Vec = legacy_entries + .iter() + .map(|(_, pid)| *pid) + .chain(receipt_entries.iter().map(|(_, receipt)| receipt.pid)) + .filter(|pid| { + if skip_pids.contains(pid) { + return false; + } + (process_is_running(*pid) && process_belongs_to_us(*pid)) || !process_is_running(*pid) + }) + .map(|pid| pid as i32) + .collect(); + + if !targets.is_empty() { + resolve_pgids_and_kill(&targets); + } + + // Clean up PID files for processes we just killed or that are already gone. + for (pubkey, pid) in &legacy_entries { + if skip_pids.contains(pid) { + continue; + } + if !process_is_running(*pid) || !process_belongs_to_us(*pid) { + super::super::remove_agent_pid_file(app, pubkey); + } + } + for (_, receipt) in &receipt_entries { + if skip_pids.contains(&receipt.pid) { + continue; + } + if !process_is_running(receipt.pid) || !process_belongs_to_us(receipt.pid) { + super::super::remove_agent_runtime_receipt(app, &receipt.key); + } + } +} + +#[cfg(not(unix))] +pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, _skip_pids: &[u32]) { + let _ = app; +} + +// ── macOS process-info FFI (shared by all sweep/reap functions) ────────── +// +// `proc_listallpids` lives in `sweep.rs` (which owns `collect_all_pids`). +// All callers in this file reach it through `sweep::collect_all_pids()`. +// `proc_pidinfo` and `BSDInfo` are declared here as `pub(super)` so that +// `sweep.rs` can call `super::proc_pidinfo` / use `super::BSDInfo` without +// redefining the struct layout in two places. + +#[cfg(target_os = "macos")] +extern "C" { + pub(super) fn proc_pidinfo( + pid: libc::c_int, + flavor: libc::c_int, + arg: u64, + buffer: *mut libc::c_void, + buffersize: libc::c_int, + ) -> libc::c_int; +} + +/// Subset of `struct proc_bsdinfo` from ``. Layout verified +/// against the macOS SDK — total size 136 bytes. +#[cfg(target_os = "macos")] +#[repr(C)] +pub(super) struct BSDInfo { + _flags_status_xstatus: [u8; 12], // pbi_flags + pbi_status + pbi_xstatus + pub(super) pbi_pid: u32, // offset 12 + pub(super) pbi_ppid: u32, // offset 16 + pub(super) pbi_uid: u32, // offset 20 + _rest: [u8; 112], +} + +#[cfg(target_os = "macos")] +const _: () = assert!(std::mem::size_of::() == 136); + +#[cfg(target_os = "macos")] +pub(super) const PROC_PIDTBSDINFO: libc::c_int = 3; + +/// Enumerate all processes on the system owned by the current user and kill any +/// agent binary stamped with *this* instance's `BUZZ_MANAGED_AGENT` marker +/// (`instance_id`) that isn't in `skip_pids`. This catches orphans that escaped +/// PID-file-based cleanup (e.g. agent workers spawned with their own process +/// group whose parent harness already exited and had its PID file removed), +/// while leaving another live Buzz instance's agents untouched. +#[cfg(target_os = "macos")] +pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) { + let my_uid = unsafe { libc::getuid() }; + let pids = sweep::collect_all_pids(); + if pids.is_empty() { + return; + } + let my_pid = std::process::id() as i32; + let mut orphans: Vec = Vec::new(); + + for &pid in &pids { + if pid <= 0 { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) || pid == my_pid { + continue; + } + // Check binary name first (cheap proc_name call) before UID lookup. + if !process_belongs_to_us(upid) { + continue; + } + // Verify UID and PPID via proc_pidinfo. + let mut info = std::mem::MaybeUninit::::zeroed(); + let ret = unsafe { + proc_pidinfo( + pid, + PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr() as *mut libc::c_void, + std::mem::size_of::() as libc::c_int, + ) + }; + if ret <= 0 { + continue; + } + let info = unsafe { info.assume_init() }; + if info.pbi_uid != my_uid { + continue; + } + if !process_has_buzz_marker(upid, instance_id) { + continue; + } + // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. + if sweep::is_live_descendant_macos(upid, info.pbi_ppid, skip_pids) { + continue; + } + orphans.push(pid); + } + + if !orphans.is_empty() { + eprintln!( + "buzz-desktop: system sweep found {} orphaned agent process(es), cleaning up", + orphans.len() + ); + resolve_pgids_and_kill(&orphans); + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) { + let my_uid = unsafe { libc::getuid() }; + let mut orphans: Vec = Vec::new(); + let my_pid = std::process::id() as i32; + + let Ok(entries) = std::fs::read_dir("/proc") else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + let Ok(pid) = name_str.parse::() else { + continue; + }; + if pid <= 0 || pid == my_pid { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) { + continue; + } + // Check ownership via /proc/ metadata. + let Ok(meta) = entry.metadata() else { + continue; + }; + use std::os::unix::fs::MetadataExt; + if meta.uid() != my_uid { + continue; + } + if !process_belongs_to_us(upid) || !process_has_buzz_marker(upid, instance_id) { + continue; + } + // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. + if sweep::is_live_descendant_linux(upid, skip_pids) { + continue; + } + orphans.push(pid); + } + + if !orphans.is_empty() { + eprintln!( + "buzz-desktop: system sweep found {} orphaned agent process(es), cleaning up", + orphans.len() + ); + resolve_pgids_and_kill(&orphans); + } +} + +#[cfg(not(unix))] +pub(crate) fn sweep_system_agent_processes(_instance_id: &str, _skip_pids: &[u32]) {} + +/// Periodic-sweep variant with two-tick grace: only reaps same-instance orphans +/// that were also seen orphaned on the previous tick. This prevents killing a +/// legitimately-starting agent that spawned between the skip-list snapshot and +/// the process scan. Returns the current orphan set for use as `prev_orphans` +/// on the next tick. +#[cfg(unix)] +pub(crate) fn sweep_system_agent_processes_with_grace( + instance_id: &str, + skip_pids: &[u32], + prev_orphans: &std::collections::HashSet, +) -> std::collections::HashSet { + let current = collect_same_instance_orphans(instance_id, skip_pids); + // Only reap PIDs seen orphaned on two consecutive ticks. + let confirmed: Vec = current + .iter() + .filter(|pid| prev_orphans.contains(pid)) + .map(|&pid| pid as i32) + .collect(); + if !confirmed.is_empty() { + eprintln!( + "buzz-desktop: periodic sweep confirmed {} orphaned agent process(es), cleaning up", + confirmed.len() + ); + resolve_pgids_and_kill(&confirmed); + } + current +} + +#[cfg(not(unix))] +pub(crate) fn sweep_system_agent_processes_with_grace( + _instance_id: &str, + _skip_pids: &[u32], + _prev_orphans: &std::collections::HashSet, +) -> std::collections::HashSet { + std::collections::HashSet::new() +} + +/// Collect PIDs of same-instance agent processes that appear orphaned (not in +/// `skip_pids`). Returns the set for use in two-tick grace logic — does NOT +/// kill anything. +#[cfg(target_os = "macos")] +pub(crate) fn collect_same_instance_orphans( + instance_id: &str, + skip_pids: &[u32], +) -> std::collections::HashSet { + let my_uid = unsafe { libc::getuid() }; + let my_pid = std::process::id() as i32; + let mut orphans = std::collections::HashSet::new(); + + let pids = sweep::collect_all_pids(); + if pids.is_empty() { + return orphans; + } + + for &pid in &pids { + if pid <= 0 || pid == my_pid { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) { + continue; + } + if !process_belongs_to_us(upid) { + continue; + } + let mut info = std::mem::MaybeUninit::::zeroed(); + let ret = unsafe { + proc_pidinfo( + pid, + PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr() as *mut libc::c_void, + std::mem::size_of::() as libc::c_int, + ) + }; + if ret <= 0 { + continue; + } + let info = unsafe { info.assume_init() }; + if info.pbi_uid != my_uid { + continue; + } + if !process_has_buzz_marker(upid, instance_id) { + continue; + } + // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. + if sweep::is_live_descendant_macos(upid, info.pbi_ppid, skip_pids) { + continue; + } + orphans.insert(upid); + } + orphans +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn collect_same_instance_orphans( + instance_id: &str, + skip_pids: &[u32], +) -> std::collections::HashSet { + let my_uid = unsafe { libc::getuid() }; + let my_pid = std::process::id() as i32; + let mut orphans = std::collections::HashSet::new(); + + let Ok(entries) = std::fs::read_dir("/proc") else { + return orphans; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + let Ok(pid) = name_str.parse::() else { + continue; + }; + if pid <= 0 || pid == my_pid { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) { + continue; + } + let Ok(meta) = entry.metadata() else { + continue; + }; + use std::os::unix::fs::MetadataExt; + if meta.uid() != my_uid { + continue; + } + if !process_belongs_to_us(upid) || !process_has_buzz_marker(upid, instance_id) { + continue; + } + // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. + if sweep::is_live_descendant_linux(upid, skip_pids) { + continue; + } + orphans.insert(upid); + } + orphans +} + +#[cfg(not(unix))] +pub(crate) fn collect_same_instance_orphans( + _instance_id: &str, + _skip_pids: &[u32], +) -> std::collections::HashSet { + std::collections::HashSet::new() +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/process.rs b/desktop/src-tauri/src/managed_agents/runtime/process.rs new file mode 100644 index 0000000000..335080a3cb --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/process.rs @@ -0,0 +1,447 @@ +use super::*; + +/// Binary name fragments for all known agent/harness processes that Buzz +/// may spawn. Used by `process_belongs_to_us()` and the orphan sweep to +/// identify processes we should clean up. Both hyphenated and underscored +/// variants are listed because macOS `proc_name()` and Linux `/proc/comm` +/// may report either form depending on how the binary was built. +pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[ + "buzz-acp", + "buzz_acp", + "buzz-agent", + "buzz_agent", + "claude-agent-acp", + "claude_agent_acp", + "claude-code-acp", + "claude_code_acp", + "codex-acp", + "codex_acp", + "goose", + // buzz-dev-mcp's multicall personalities (rg, tree, buzz, + // git-credential-nostr, git-sign-nostr) are short-lived per-tool-call + // invocations — not listed here. + "buzz-dev-mcp", + "buzz_dev_mcp", +]; + +/// Script interpreters that may host managed agent wrappers (e.g. npm shims). +/// A process whose name matches here is NOT immediately claimed — it must also +/// carry `BUZZ_MANAGED_AGENT` in its environment (checked by the caller via +/// `process_has_buzz_marker()`). This avoids sweeping unrelated node processes. +pub(crate) const KNOWN_SCRIPT_INTERPRETERS: &[&str] = &["node"]; + +/// Check if a process name matches any of our known agent binaries. +/// Uses exact match or prefix-with-separator to avoid false positives +/// (e.g. `"goose"` must not match `"mongoose"`). +pub(super) fn name_matches_known_binary(name: &str) -> bool { + KNOWN_AGENT_BINARIES.iter().any(|&binary| { + name == binary || { + name.starts_with(binary) && { + let rest = &name[binary.len()..]; + rest.starts_with('-') || rest.starts_with('_') || rest.starts_with('.') + } + } + }) +} + +/// Check if a process name is a known script interpreter that may be hosting +/// a managed agent wrapper (e.g. `node` running an npm shim for `codex-acp`). +/// Callers must additionally verify `BUZZ_MANAGED_AGENT` ownership. +pub(super) fn name_matches_interpreter(name: &str) -> bool { + KNOWN_SCRIPT_INTERPRETERS.contains(&name) +} + +#[cfg(unix)] +pub(crate) fn process_is_running(pid: u32) -> bool { + // Use libc::kill with signal 0 instead of forking a subprocess. + // Returns true only if the process exists AND we can signal it. + // Returns false for non-existent PIDs (ESRCH) and PIDs owned by + // other users (EPERM) — callers should not interact with those. + unsafe { libc::kill(pid as i32, 0) == 0 } +} + +#[cfg(not(unix))] +pub(crate) fn process_is_running(_pid: u32) -> bool { + false +} + +/// Check if a PID belongs to a known agent process we spawned. +/// Returns false for recycled PIDs that now belong to other processes. +#[cfg(target_os = "macos")] +pub(crate) fn process_belongs_to_us(pid: u32) -> bool { + // Use proc_name() from libproc to get the process name without spawning + // a subprocess. + extern "C" { + fn proc_name(pid: libc::c_int, buffer: *mut libc::c_void, buffersize: u32) -> libc::c_int; + } + let mut buf = [0u8; 1024]; + let len = unsafe { + proc_name( + pid as i32, + buf.as_mut_ptr() as *mut libc::c_void, + buf.len() as u32, + ) + }; + if len <= 0 { + return false; + } + let name = String::from_utf8_lossy(&buf[..len as usize]); + // Fall through for script interpreters (e.g. `node` hosting an npm shim): + // the caller's `process_has_buzz_marker()` check decides true ownership. + name_matches_known_binary(&name) || name_matches_interpreter(&name) +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn process_belongs_to_us(pid: u32) -> bool { + // First try /proc//comm. Note: comm is truncated to 15 bytes on Linux, + // so binaries with names longer than 15 chars (e.g. "claude-agent-acp") + // will never match here. + if let Ok(name) = std::fs::read_to_string(format!("/proc/{pid}/comm")) { + if name_matches_known_binary(name.trim()) { + return true; + } + // Interpreter check: `node` is 4 bytes, never truncated. + if name_matches_interpreter(name.trim()) { + return true; + } + } + + // Fallback: read /proc//exe which is a symlink to the full binary path. + // This is not subject to the 15-byte truncation limit. + if let Ok(exe_path) = std::fs::read_link(format!("/proc/{pid}/exe")) { + if let Some(basename) = exe_path.file_name().and_then(|n| n.to_str()) { + // Fall through for script interpreters — caller checks the marker. + return name_matches_known_binary(basename) || name_matches_interpreter(basename); + } + } + + false +} + +#[cfg(not(unix))] +pub(crate) fn process_belongs_to_us(_pid: u32) -> bool { + false +} + +/// The value stamped into the `BUZZ_MANAGED_AGENT` env var of every agent we +/// spawn, identifying *which* desktop instance owns it. We use the app's bundle +/// identifier (`xyz.block.buzz.app` for release, `xyz.block.buzz.app.dev` +/// for `just dev`) because it is stable across restarts — a relaunched dev +/// instance still recognizes its own previously-spawned agents as reclaimable, +/// while never matching another instance's (e.g. a dev build never reaps a DMG +/// build's agents, and vice versa). This is what lets two Buzzs coexist on +/// one machine without one's cleanup nuking the other's agents. +pub(crate) fn current_instance_id(app: &AppHandle) -> String { + app.config().identifier.clone() +} + +/// Build the full `BUZZ_MANAGED_AGENT=` env entry we match +/// against when scanning processes. Kept here so the spawn stamp and the sweep +/// matcher can never drift apart. +pub(super) fn buzz_marker_entry(instance_id: &str) -> Vec { + format!("BUZZ_MANAGED_AGENT={instance_id}").into_bytes() +} + +/// Check if a running process is one of *our* managed agents: it must carry +/// `BUZZ_MANAGED_AGENT=` in its environment, where `instance_id` +/// is this desktop instance's id. A process stamped with a *different* instance +/// id belongs to another live Buzz app and must never be reaped here. +#[cfg(target_os = "macos")] +pub(crate) fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { + let marker = buzz_marker_entry(instance_id); + let Some(buf) = sweep::procargs2_buffer(pid) else { + return false; + }; + + // Buffer layout: [i32 argc][exec_path\0][null padding][argv\0...][env\0...] + if buf.len() < std::mem::size_of::() { + return false; + } + let mut n_args: libc::c_int = 0; + unsafe { + std::ptr::copy_nonoverlapping( + buf.as_ptr(), + &mut n_args as *mut libc::c_int as *mut u8, + std::mem::size_of::(), + ); + } + let mut pos = std::mem::size_of::(); + + // Skip exec path (scan to first null). + while pos < buf.len() && buf[pos] != 0 { + pos += 1; + } + // Skip null padding between exec path and argv[0]. + while pos < buf.len() && buf[pos] == 0 { + pos += 1; + } + // Skip argc argument strings. + let mut args_remaining = n_args; + while args_remaining > 0 && pos < buf.len() { + while pos < buf.len() && buf[pos] != 0 { + pos += 1; + } + while pos < buf.len() && buf[pos] == 0 { + pos += 1; + } + args_remaining -= 1; + } + // Remaining bytes are null-delimited environment strings. + buf[pos..].split(|&b| b == 0).any(|entry| entry == marker) +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { + let marker = buzz_marker_entry(instance_id); + let Ok(data) = std::fs::read(format!("/proc/{pid}/environ")) else { + return false; + }; + data.split(|&b| b == 0).any(|entry| entry == marker) +} + +#[cfg(not(unix))] +pub(crate) fn process_has_buzz_marker(_pid: u32, _instance_id: &str) -> bool { + false +} + +#[cfg(unix)] +fn signal_process_group_or_leader(pid: u32, signal: i32, action: &str) -> Result<(), String> { + let pgid = -(pid as i32); + + if unsafe { libc::kill(pgid, signal) } == 0 { + return Ok(()); + } + + let group_err = std::io::Error::last_os_error(); + if !process_is_running(pid) { + return Ok(()); + } + + // Some local agent trees can no longer be signalled as a process group + // (for example if the leader changed groups, or macOS returns EPERM for one + // descendant). Fall back to the leader PID so stop/delete can still recover. + if matches!( + group_err.raw_os_error(), + Some(libc::EPERM) | Some(libc::ESRCH) + ) { + if unsafe { libc::kill(pid as i32, signal) } == 0 { + return Ok(()); + } + + let leader_err = std::io::Error::last_os_error(); + if leader_err.raw_os_error() == Some(libc::ESRCH) || !process_is_running(pid) { + return Ok(()); + } + + return Err(format!("failed to {action} process {pid}: {leader_err}")); + } + + Err(format!( + "failed to {action} process group {pid}: {group_err}" + )) +} + +#[cfg(unix)] +pub(crate) fn terminate_process(pid: u32) -> Result<(), String> { + // Try graceful shutdown first (SIGTERM to the group). + signal_process_group_or_leader(pid, libc::SIGTERM, "terminate")?; + + // Wait up to 1s for graceful exit. + for _ in 0..10 { + if !process_is_running(pid) { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + // Escalate to SIGKILL on the entire group. + signal_process_group_or_leader(pid, libc::SIGKILL, "kill")?; + + Ok(()) +} + +#[cfg(windows)] +pub(crate) fn terminate_process(pid: u32) -> Result<(), String> { + // No job handle is available on this path (e.g. after an app restart, when + // we only recovered the PID from the record), so fall back to taskkill on + // the whole tree. + super::super::process_lifecycle::taskkill_tree(pid) +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn terminate_process(_pid: u32) -> Result<(), String> { + Err("managed agent shutdown after app restart is not supported on this platform".to_string()) +} + +/// Send SIGTERM to all given PIDs (as process groups), wait, then SIGKILL +/// any survivors. Uses `-pid` to kill the entire process group — if an +/// orphaned agent called `setsid()`, it IS the group leader, so this +/// reaches its children too. +#[cfg(unix)] +fn sigterm_then_sigkill(pids: &[i32]) { + // Send SIGTERM to each process group. Track whether any signal was + // actually delivered so we can skip the sleep when everything is + // already gone. + let mut any_signalled = false; + for &pid in pids { + if unsafe { libc::kill(-pid, libc::SIGTERM) } == 0 { + any_signalled = true; + } + } + + if !any_signalled { + return; + } + + std::thread::sleep(std::time::Duration::from_millis(200)); + + for &pid in pids { + // Check if the group has any living members, not just the leader. + // kill(-pid, 0) returns 0 if ANY member of the group is signalable. + if unsafe { libc::kill(-pid, 0) } == 0 { + unsafe { + libc::kill(-pid, libc::SIGKILL); + } + } + } +} + +/// Resolve orphan candidate PIDs to their actual process group IDs, dedupe, +/// and signal the groups. An orphaned grandchild (e.g. `goose` or `buzz-dev-mcp`) +/// whose harness has exited retains the harness's PGID — signaling that PGID +/// kills the entire orphaned subtree. Falls back to the candidate PID itself +/// when PGID resolution fails (process may have exited between detection and +/// kill). +#[cfg(target_os = "macos")] +pub(super) fn resolve_pgids_and_kill(candidate_pids: &[i32]) { + let candidate_set: std::collections::HashSet = candidate_pids.iter().copied().collect(); + let mut pgids = std::collections::HashSet::new(); + for &pid in candidate_pids { + let pgid = unsafe { libc::getpgid(pid) }; + if pgid > 0 { + pgids.insert(pgid); + } else { + // Process may have exited; try signaling it directly as a group. + pgids.insert(pid); + } + } + // PID-recycling guard: if a resolved PGID is alive but isn't one of our + // orphan candidates, the old harness PID was recycled by a new process + // that called setsid() — skip it to avoid killing an unrelated group. + let candidate_groups = pgids.len(); + pgids.retain(|&pgid| { + if candidate_set.contains(&pgid) { + return true; + } + let alive = unsafe { libc::kill(pgid, 0) } == 0; + !alive + }); + if pgids.is_empty() && candidate_groups > 0 { + eprintln!( + "buzz-desktop: orphan sweep: skipped all {candidate_groups} candidate group(s) (live foreign group leader or candidate already exited); nothing signalled" + ); + } + let unique: Vec = pgids.into_iter().collect(); + sigterm_then_sigkill(&unique); +} + +/// Resolve orphan candidate PIDs to their actual process group IDs, dedupe, +/// and signal the groups. Linux variant reads PGID from /proc//stat. +#[cfg(all(unix, not(target_os = "macos")))] +pub(super) fn resolve_pgids_and_kill(candidate_pids: &[i32]) { + let candidate_set: std::collections::HashSet = candidate_pids.iter().copied().collect(); + let mut pgids = std::collections::HashSet::new(); + for &pid in candidate_pids { + if let Some((_, pgid)) = sweep::proc_stat_ppid_pgid_linux(pid as u32) { + pgids.insert(pgid as i32); + } else { + // Process may have exited; try signaling it directly as a group. + pgids.insert(pid); + } + } + // PID-recycling guard: if a resolved PGID is alive but isn't one of our + // orphan candidates, the old harness PID was recycled by a new process + // that called setsid() — skip it to avoid killing an unrelated group. + let candidate_groups = pgids.len(); + pgids.retain(|&pgid| { + if candidate_set.contains(&pgid) { + return true; + } + let alive = unsafe { libc::kill(pgid, 0) } == 0; + !alive + }); + if pgids.is_empty() && candidate_groups > 0 { + eprintln!( + "buzz-desktop: orphan sweep: skipped all {candidate_groups} candidate group(s) (live foreign group leader or candidate already exited); nothing signalled" + ); + } + let unique: Vec = pgids.into_iter().collect(); + sigterm_then_sigkill(&unique); +} + +pub(crate) fn valid_agent_runtime_receipt( + path: &std::path::Path, + receipt: &super::super::ManagedAgentRuntimeReceipt, + instance_id: &str, +) -> bool { + let Ok(canonical) = + ManagedAgentRuntimeKey::new(receipt.key.pubkey.clone(), &receipt.key.relay_url) + else { + return false; + }; + canonical == receipt.key + && path.file_name().and_then(|name| name.to_str()) + == Some(&format!("{}.json", receipt.key.runtime_id())) + && receipt.desktop_instance_id == instance_id + && process_is_running(receipt.pid) + && process_belongs_to_us(receipt.pid) + && process_has_buzz_marker(receipt.pid, &receipt.desktop_instance_id) +} + +pub(super) fn terminate_runtime_receipt_with( + path: &std::path::Path, + receipt: &super::super::ManagedAgentRuntimeReceipt, + terminate: impl FnOnce(u32) -> Result<(), String>, + mut is_running: impl FnMut(u32) -> bool, + remove: impl FnOnce(&std::path::Path), +) -> Result<(), String> { + terminate(receipt.pid)?; + for _ in 0..20 { + if !is_running(receipt.pid) { + remove(path); + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + Err(format!( + "prior runtime {} for pair {} on {} did not exit", + receipt.pid, receipt.key.pubkey, receipt.key.relay_url + )) +} + +/// Replace a valid prior-session process before registering a new child for +/// the same pair. The caller must hold the runtime transition lock so receipt +/// inspection, termination, spawn, and registration cannot race shutdown or +/// another start. +pub(crate) fn terminate_untracked_pair_runtime( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, +) -> Result<(), String> { + let instance_id = current_instance_id(app); + let Some((path, receipt)) = super::super::read_all_agent_runtime_receipts(app) + .into_iter() + .find(|(path, receipt)| { + receipt.key == *key && valid_agent_runtime_receipt(path, receipt, &instance_id) + }) + else { + return Ok(()); + }; + + terminate_runtime_receipt_with( + &path, + &receipt, + terminate_process, + process_is_running, + super::super::remove_agent_runtime_receipt_path, + ) +} From c9a73726be6e6edd4e5d0c9c44abdf07d550ecd0 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 26 Jul 2026 09:16:36 -0700 Subject: [PATCH 013/128] fix(mobile): validate invite relay destinations (#2986) ## Summary - require invite relay destinations to be secure public origins in production - reject non-public and ambiguous IP literals before confirmation and again before the claim request - disable redirects for invite claims so a validated relay cannot redirect the request elsewhere - preserve explicit debug-only localhost support ## Validation - pre-commit `dart format` and `flutter analyze` - pre-push full mobile test suite: 666 passed, 1 skipped - independent source reviews from Princess Donut and Mongo found no remaining blockers ## Scope and residual risk This fixes the mobile invite trust boundary without changing NIP-98 or NIP-42. Hostnames are not resolved and pinned by this patch, so DNS rebinding remains a networking-layer residual risk requiring connect-time resolution/pinning. Signed-off-by: Wes Co-authored-by: Carl --- .../invites/invite_join_provider.dart | 33 ++-- mobile/lib/shared/deeplink/deep_link.dart | 22 ++- mobile/lib/shared/relay/relay_validation.dart | 164 ++++++++++++++++++ .../invites/invite_join_provider_test.dart | 37 +++- .../test/shared/deeplink/deep_link_test.dart | 29 ++++ .../shared/relay/relay_validation_test.dart | 161 +++++++++++++++++ 6 files changed, 429 insertions(+), 17 deletions(-) create mode 100644 mobile/lib/shared/relay/relay_validation.dart create mode 100644 mobile/test/shared/relay/relay_validation_test.dart diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart index 0cd58d47a7..0e5547aca5 100644 --- a/mobile/lib/features/invites/invite_join_provider.dart +++ b/mobile/lib/features/invites/invite_join_provider.dart @@ -7,6 +7,7 @@ import 'package:nostr/nostr.dart' as nostr; import '../../shared/auth/auth.dart'; import '../../shared/deeplink/deep_link.dart'; import '../../shared/relay/relay_session.dart'; +import '../../shared/relay/relay_validation.dart'; final inviteJoinHttpClientProvider = Provider((ref) { final client = http.Client(); @@ -68,6 +69,7 @@ class InviteJoinNotifier extends Notifier { InviteJoinState build() => const InviteJoinState(); Future prepare(InviteDeepLink invite) async { + validateInviteRelayUri(Uri.parse(invite.relayUrl)); final communities = await ref.read(communityListProvider.future); final existing = _existingCommunity(communities, invite.relayUrl); if (existing != null) { @@ -121,22 +123,25 @@ class InviteJoinNotifier extends Notifier { if (invite.policyReceipt != null) 'policy_receipt': invite.policyReceipt, }); + final relayUri = Uri.parse(invite.relayUrl); + validateInviteRelayUri(relayUri); final url = _claimUrlFromRelay(invite.relayUrl); - final response = await ref + final request = http.Request('POST', Uri.parse(url)) + ..followRedirects = false + ..headers.addAll({ + 'Authorization': buildNip98AuthHeader( + method: 'POST', + url: url, + bodyBytes: utf8.encode(body), + nsec: keys.nsec, + ), + 'Content-Type': 'application/json', + }) + ..body = body; + final streamedResponse = await ref .read(inviteJoinHttpClientProvider) - .post( - Uri.parse(url), - headers: { - 'Authorization': buildNip98AuthHeader( - method: 'POST', - url: url, - bodyBytes: utf8.encode(body), - nsec: keys.nsec, - ), - 'Content-Type': 'application/json', - }, - body: body, - ); + .send(request); + final response = await http.Response.fromStream(streamedResponse); final decoded = jsonDecode(response.body.isEmpty ? '{}' : response.body); if (response.statusCode < 200 || response.statusCode >= 300) { final message = decoded is Map && decoded['error'] is String diff --git a/mobile/lib/shared/deeplink/deep_link.dart b/mobile/lib/shared/deeplink/deep_link.dart index b2e57b8b00..a208048dbd 100644 --- a/mobile/lib/shared/deeplink/deep_link.dart +++ b/mobile/lib/shared/deeplink/deep_link.dart @@ -7,6 +7,8 @@ /// half-formed target. library; +import '../relay/relay_validation.dart'; + /// A parsed deep link supported by the app. sealed class BuzzDeepLink { const BuzzDeepLink(); @@ -107,8 +109,9 @@ MessageDeepLink? parseMessageDeepLink(Uri uri) { /// /// Accepted forms: /// - `https:///invite/` -> `wss://` + code -/// - `http:///invite/` -> `ws://` + code -/// - `buzz://join?relay=&code=` -> relay + code +/// - `http://localhost/invite/` -> `ws://localhost` + code in debug builds +/// - `buzz://join?relay=&code=` -> relay + code +/// - `buzz://join?relay=&code=` -> local relay in debug /// /// Rejects credentials, fragments, missing params, nested relay credentials, and /// non-invite paths so scanners do not accidentally treat arbitrary URLs as @@ -131,6 +134,11 @@ InviteDeepLink? parseInviteDeepLink(Uri uri) { relayUri.hasFragment) { return null; } + try { + validateInviteRelayUri(relayUri); + } on FormatException { + return null; + } final normalizedRelay = Uri( scheme: relayUri.scheme, host: relayUri.host, @@ -155,6 +163,16 @@ InviteDeepLink? parseInviteDeepLink(Uri uri) { return null; } final relayScheme = uri.scheme == 'https' ? 'wss' : 'ws'; + final relayUri = Uri( + scheme: relayScheme, + host: uri.host, + port: uri.hasPort ? uri.port : null, + ); + try { + validateInviteRelayUri(relayUri); + } on FormatException { + return null; + } final relay = Uri( scheme: relayScheme, host: uri.host, diff --git a/mobile/lib/shared/relay/relay_validation.dart b/mobile/lib/shared/relay/relay_validation.dart new file mode 100644 index 0000000000..eccbadf974 --- /dev/null +++ b/mobile/lib/shared/relay/relay_validation.dart @@ -0,0 +1,164 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +/// Validates an untrusted relay origin before the mobile app connects to it. +/// +/// Production relay URLs must use TLS and may not contain local or otherwise +/// non-public IP literals. Debug builds retain plaintext localhost support for +/// local development, but other non-public destinations remain blocked. +void validateInviteRelayUri( + Uri uri, { + bool allowInsecureLocalhost = kDebugMode, +}) { + if (uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.hasFragment || + (uri.path.isNotEmpty && uri.path != '/') || + uri.hasQuery) { + throw const FormatException('Relay URL must be an origin'); + } + + if (uri.scheme != 'ws' && uri.scheme != 'wss') { + throw FormatException('Invalid relay URL scheme: ${uri.scheme}'); + } + + final host = uri.host.toLowerCase(); + final isLocalhost = host == 'localhost' || host.endsWith('.localhost'); + if (uri.scheme != 'wss' && !(allowInsecureLocalhost && isLocalhost)) { + throw const FormatException('Relay URL must use WSS'); + } + if (isLocalhost) { + if (!allowInsecureLocalhost) { + throw const FormatException('Relay URL cannot target localhost'); + } + return; + } + + final address = InternetAddress.tryParse(host); + if (_looksLikeNumericAddress(host)) { + // Only canonical dotted-decimal IPv4 is accepted. Ambiguous legacy forms + // such as a single integer, hexadecimal, shortened components, or leading + // zeroes have varied interpretation across URI/network stacks. + final ipv4Parts = host.split('.'); + final hasLeadingZero = ipv4Parts.any( + (part) => part.length > 1 && part.startsWith('0'), + ); + if (address == null || + address.type != InternetAddressType.IPv4 || + address.address != host || + ipv4Parts.length != 4 || + hasLeadingZero) { + throw const FormatException('Relay URL contains an invalid IP address'); + } + } + if (address != null) { + if (!_isPublicAddress(address)) { + throw const FormatException( + 'Relay URL cannot target non-public network addresses', + ); + } + return; + } + + // DNS hostnames are allowed here. Preventing a hostname from resolving or + // rebinding to a non-public address requires connect-time resolution and + // address pinning in the networking layer. +} + +bool _looksLikeNumericAddress(String host) { + if (RegExp(r'^\d+$').hasMatch(host) || + RegExp(r'^0x[0-9a-f]+$', caseSensitive: false).hasMatch(host)) { + return true; + } + final parts = host.split('.'); + return parts.length > 1 && + parts.every( + (part) => + RegExp(r'^\d+$').hasMatch(part) || + RegExp(r'^0x[0-9a-f]+$', caseSensitive: false).hasMatch(part), + ); +} + +bool _isPublicAddress(InternetAddress address) { + final bytes = address.rawAddress; + if (address.type == InternetAddressType.IPv4) { + return !_isNonPublicIpv4(bytes); + } + if (address.type != InternetAddressType.IPv6 || bytes.length != 16) { + return false; + } + + // IPv4-compatible and IPv4-mapped IPv6 addresses inherit the embedded + // IPv4 address's classification. + final firstTenZero = bytes.take(10).every((byte) => byte == 0); + if (firstTenZero && + ((bytes[10] == 0 && bytes[11] == 0) || + (bytes[10] == 0xff && bytes[11] == 0xff))) { + return !_isNonPublicIpv4(bytes.sublist(12)); + } + + // Accept only globally reachable IPv6 literals. Global unicast space is + // 2000::/3, with special-purpose exclusions and narrow globally reachable + // exceptions tracked by IANA (reconciled 2026-07-26): + // https://www.iana.org/assignments/iana-ipv6-special-registry/ + if (!_hasPrefix(bytes, [0x20], 3)) return false; + + // 2001::/23 is reserved for IETF protocol assignments. Only these entries + // are currently classified by IANA as globally reachable. + if (_hasPrefix(bytes, [0x20, 0x01, 0x00], 23) && + !_isGloballyReachableIetfAssignment(bytes)) { + return false; + } + + // Documentation and transition prefixes are not public relay destinations. + if (_hasPrefix(bytes, [0x20, 0x01, 0x0d, 0xb8], 32)) return false; + if (_hasPrefix(bytes, [0x20, 0x02], 16)) return false; + if (_hasPrefix(bytes, [0x3f, 0xff, 0x00], 20)) return false; + return true; +} + +bool _isGloballyReachableIetfAssignment(List bytes) { + final isGlobalAnycast = + _hasPrefix(bytes, [0x20, 0x01, 0x00, 0x01, 0, 0, 0, 0], 64) && + bytes.sublist(8, 15).every((byte) => byte == 0) && + bytes[15] >= 1 && + bytes[15] <= 3; + return isGlobalAnycast || + _hasPrefix(bytes, [0x20, 0x01, 0x00, 0x03], 32) || + _hasPrefix(bytes, [0x20, 0x01, 0x00, 0x04, 0x01, 0x12], 48) || + _hasPrefix(bytes, [0x20, 0x01, 0x00, 0x20], 28) || + _hasPrefix(bytes, [0x20, 0x01, 0x00, 0x30], 28); +} + +bool _hasPrefix(List address, List prefix, int prefixLength) { + final wholeBytes = prefixLength ~/ 8; + for (var i = 0; i < wholeBytes; i++) { + if (address[i] != prefix[i]) return false; + } + final remainingBits = prefixLength % 8; + if (remainingBits == 0) return true; + final mask = (0xff << (8 - remainingBits)) & 0xff; + return (address[wholeBytes] & mask) == (prefix[wholeBytes] & mask); +} + +bool _isNonPublicIpv4(List bytes) { + if (bytes.length != 4) return true; + final a = bytes[0]; + final b = bytes[1]; + final c = bytes[2]; + + return a == 0 || + a == 10 || + a == 127 || + (a == 100 && b >= 64 && b <= 127) || + (a == 169 && b == 254) || + (a == 172 && b >= 16 && b <= 31) || + (a == 192 && b == 0 && c == 0) || + (a == 192 && b == 0 && c == 2) || + (a == 192 && b == 168) || + (a == 198 && (b == 18 || b == 19)) || + (a == 198 && b == 51 && c == 100) || + (a == 203 && b == 0 && c == 113) || + a >= 224; +} diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index 29158f7910..e02134bb5f 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; import 'package:nostr/nostr.dart' as nostr; +import 'package:pointycastle/digests/sha256.dart'; import 'package:buzz/features/invites/invite_join_provider.dart'; import 'package:buzz/shared/auth/auth.dart'; @@ -128,7 +129,26 @@ void main() { 'https://relay.example.com/api/invites/claim', ); expect(capturedRequest!.body, jsonEncode({'code': 'code'})); - expect(capturedRequest!.headers['Authorization'], startsWith('Nostr ')); + final authHeader = capturedRequest!.headers['Authorization']; + expect(authHeader, startsWith('Nostr ')); + expect(capturedRequest!.followRedirects, isFalse); + final encoded = authHeader!.substring('Nostr '.length); + final authEvent = + jsonDecode( + utf8.decode(base64Url.decode(base64Url.normalize(encoded))), + ) + as Map; + final tags = (authEvent['tags'] as List) + .map((tag) => (tag as List).cast()) + .toList(); + final payloadHash = SHA256Digest() + .process(capturedRequest!.bodyBytes) + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + expect(authEvent['kind'], 27235); + expect(tags, contains(equals(['u', capturedRequest!.url.toString()]))); + expect(tags, contains(equals(['method', 'POST']))); + expect(tags, contains(equals(['payload', payloadHash]))); expect(auth.authenticatedCommunities, hasLength(1)); expect( auth.authenticatedCommunities.single.relayUrl, @@ -139,6 +159,21 @@ void main() { }, ); + test('prepare rejects an unsafe relay before showing confirmation', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + await expectLater( + container + .read(inviteJoinProvider.notifier) + .prepare( + const InviteDeepLink(relayUrl: 'wss://127.0.0.1', code: 'code'), + ), + throwsFormatException, + ); + expect(container.read(inviteJoinProvider).status, InviteJoinStatus.idle); + }); + test('join_policy_required requires a fresh link and cannot retry', () async { final keys = nostr.Keys.generate(); var attempts = 0; diff --git a/mobile/test/shared/deeplink/deep_link_test.dart b/mobile/test/shared/deeplink/deep_link_test.dart index a60056c3bc..64e15f0513 100644 --- a/mobile/test/shared/deeplink/deep_link_test.dart +++ b/mobile/test/shared/deeplink/deep_link_test.dart @@ -104,6 +104,23 @@ void _inviteTests() { ); }); + test('normalizes trailing slash in buzz join handoff', () { + final link = parseInviteDeepLink( + Uri.parse( + 'buzz://join?relay=wss%3A%2F%2Frelay.example.com%2F&code=abc123', + ), + ); + expect(link?.relayUrl, 'wss://relay.example.com'); + }); + + test('rejects plaintext public buzz join handoff', () { + final relay = Uri.encodeQueryComponent('ws://relay.example.com'); + expect( + parseInviteDeepLink(Uri.parse('buzz://join?relay=$relay&code=abc')), + isNull, + ); + }); + test('preserves policy receipt in buzz join handoff', () { final link = parseInviteDeepLink( Uri.parse( @@ -177,6 +194,18 @@ void _inviteTests() { ); }); + test('rejects non-public invite relay destinations', () { + for (final url in [ + 'https://127.0.0.1/invite/abc', + 'https://169.254.169.254/invite/abc', + 'https://192.168.1.1/invite/abc', + 'https://[::1]/invite/abc', + 'https://[::ffff:127.0.0.1]/invite/abc', + ]) { + expect(parseInviteDeepLink(Uri.parse(url)), isNull, reason: url); + } + }); + test('rejects buzz join with dangerous relay schemes', () { // The `relay=` param is an allowlist — only `ws` / `wss` are safe to // hand to a Nostr relay session. Anything else must be dropped by the diff --git a/mobile/test/shared/relay/relay_validation_test.dart b/mobile/test/shared/relay/relay_validation_test.dart new file mode 100644 index 0000000000..e564767ea4 --- /dev/null +++ b/mobile/test/shared/relay/relay_validation_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:buzz/shared/relay/relay_validation.dart'; + +void main() { + group('validateInviteRelayUri', () { + test('accepts public secure relay origins', () { + for (final url in [ + 'wss://relay.example.com', + 'wss://relay.example.com/', + 'wss://relay.example.com:8443', + 'wss://8.8.8.8', + 'wss://[2001:4860:4860::8888]', + 'wss://[2001:1::1]', + 'wss://[2001:1::2]', + 'wss://[2001:1::3]', + 'wss://[2001:3::1]', + 'wss://[2001:4:112::1]', + 'wss://[2001:20::1]', + 'wss://[2001:30::1]', + 'wss://[2001:200::1]', + 'wss://[2620:4f:8000::1]', + 'wss://[3fff:1000::1]', + 'wss://[2606:4700:4700::1111]', + ]) { + expect( + () => validateInviteRelayUri( + Uri.parse(url), + allowInsecureLocalhost: false, + ), + returnsNormally, + reason: url, + ); + } + }); + + test('allows plaintext localhost only when explicitly enabled', () { + for (final url in ['ws://localhost:3000', 'ws://relay.localhost:3000']) { + expect( + () => validateInviteRelayUri( + Uri.parse(url), + allowInsecureLocalhost: true, + ), + returnsNormally, + reason: url, + ); + expect( + () => validateInviteRelayUri( + Uri.parse(url), + allowInsecureLocalhost: false, + ), + throwsFormatException, + reason: url, + ); + } + }); + + test('rejects plaintext public relays', () { + expect( + () => validateInviteRelayUri( + Uri.parse('ws://relay.example.com'), + allowInsecureLocalhost: false, + ), + throwsFormatException, + ); + }); + + test('rejects non-public IPv4 literals', () { + for (final host in [ + '0.0.0.0', + '10.0.0.1', + '100.64.0.1', + '127.0.0.1', + '169.254.169.254', + '172.16.0.1', + '192.168.0.1', + '198.18.0.1', + '224.0.0.1', + ]) { + expect( + () => validateInviteRelayUri( + Uri.parse('wss://$host'), + allowInsecureLocalhost: false, + ), + throwsFormatException, + reason: host, + ); + } + }); + + test('rejects non-public IPv6 literals and mapped IPv4', () { + for (final host in [ + '[::]', + '[::1]', + '[::ffff:127.0.0.1]', + '[::ffff:169.254.169.254]', + '[::ffff:a9fe:a9fe]', + '[64:ff9b::a9fe:a9fe]', + '[100::1]', + '[100:0:0:1::1]', + '[2001::1]', + '[2001:1::4]', + '[2001:2::1]', + '[2001:4:111::1]', + '[2001:10::1]', + '[2001:40::1]', + '[2001:db8::1]', + '[2002:a9fe:a9fe::1]', + '[3fff::1]', + '[3fff:fff::1]', + '[5f00::1]', + '[fc00::1]', + '[fd00::1]', + '[fe80::1]', + '[fec0::1]', + '[ff02::1]', + ]) { + expect( + () => validateInviteRelayUri( + Uri.parse('wss://$host'), + allowInsecureLocalhost: false, + ), + throwsFormatException, + reason: host, + ); + } + }); + + test('rejects legacy numeric IPv4 spellings', () { + for (final host in ['2130706433', '0x7f000001', '0177.0.0.1', '127.1']) { + expect( + () => validateInviteRelayUri( + Uri.parse('wss://$host'), + allowInsecureLocalhost: false, + ), + throwsFormatException, + reason: host, + ); + } + }); + + test('rejects non-origin URLs', () { + for (final url in [ + 'https://relay.example.com', + 'wss://user@relay.example.com', + 'wss://relay.example.com/path', + 'wss://relay.example.com?query=x', + 'wss://relay.example.com#fragment', + ]) { + expect( + () => validateInviteRelayUri( + Uri.parse(url), + allowInsecureLocalhost: false, + ), + throwsFormatException, + reason: url, + ); + } + }); + }); +} From 166c6655e8bca87d83ad60c087fb70a32a026baf Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sun, 26 Jul 2026 09:28:30 -0700 Subject: [PATCH 014/128] fix(desktop): surface install failures hidden by curl-pipe exit codes (#2892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ubuntu Doctor reports `Install failed at verify: The installer finished, but Buzz still could not use claude-code (observed: CLI missing)` while the `cli` step shows success. The `cli` step is lying. ## The masking Every CLI install command is a pipe — `curl -fsSL https://claude.ai/install.sh | bash` (`managed_agents/discovery.rs:109`), `… | sh` for Codex (`:141`), `… | CONFIGURE=false bash` for Goose (`:75`). `install_shell_command` ran them through `bash -l -c` with no `pipefail`, so the pipeline's exit status was the **right-hand** side's. A `curl` that fails — or that isn't on the child's PATH at all — feeds `bash` an empty stdin, and `bash` with nothing to run exits 0: ``` $ /bin/bash -l -c 'curl -fsSL https://nonexistent.invalid/x.sh | bash'; echo $? curl: (6) Could not resolve host: nonexistent.invalid 0 $ PATH=/tmp/empty /bin/bash -l -c 'curl -fsSL https://claude.ai/install.sh | bash'; echo $? bash: line 1: curl: command not found 0 ``` `run_install_command` records exit 0 as `success: true`, the adapter step then installs fine (it uses Buzz's own bundled Node, no system PATH needed), and `post_install_verification` correctly reports the CLI is absent. The user is handed a `verify` riddle instead of curl's error, which is why diagnosing this required three rounds of guessing. Install commands now run under `set -o pipefail`, so the left-hand side's failure is the step's failure and `InstallStepResult.stderr` carries the vendor's own message. `SHELLOPTS` is not exported by either shell, so the piped-to vendor script still runs with its default options. The Windows PowerShell install path (`install_powershell_command`) bypasses this shell and is untouched. ## The PATH collapse it was hiding `install_shell_command` composes the child's PATH and calls `cmd.env("PATH", …)`, which **replaces** rather than extends. `should_use_inherited` was `is_windows && !had_shell_path && has_local_context`, so on Unix the inherited process PATH was never appended. When `login_shell_path()` returns `None` — a login shell that exits non-zero or prints nothing, which a GUI-launched process can easily hit via `~/.profile` — the child's entire PATH becomes Buzz's two managed Node dirs. There is no `curl`, `sh`, `sha256sum`, or `tar` in either, so every curl-pipe install fails, and before this PR it failed invisibly. The `is_windows` requirement is dropped: the inherited PATH is the floor whenever no login-shell PATH was obtained, on every OS. Both existing suppressions are kept — a login-shell PATH present still suppresses it (no doubling), and no home/exe context still suppresses it (never manufacture a PATH from ambient state alone). Inherited entries stay **last**, so managed dirs keep precedence. The other caller, `build_augmented_path` (`runtime/path.rs:148`, feeding agent spawns and CLI probes), reads correctly under the new rule for the same reason: it only gains the inherited PATH in the case where it would otherwise hand a child a PATH with no native entries. When a login-shell PATH exists — the normal case on macOS and Linux — its output is unchanged, which `unix_shell_path_suppresses_inherited_fallback` pins. ## Scope This fixes the reporting defect and the PATH floor. The specific environment failure on the affected Ubuntu box is still being diagnosed and is deliberately not addressed here; the point of this change is that the next attempt produces the real error instead of a `verify` riddle. One interaction worth noting: `install_failure_is_retryable` retries any failure that carries an exit code, so a pipefail-surfaced curl failure now gets 3 attempts with backoff — correct for transient network blips, and harmless for hard failures. `desktop/scripts/check-file-sizes.mjs` ratchets the `agent_discovery.rs` ceiling 1836 → 1895 for the added tests. --------- Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- desktop/scripts/check-file-sizes.mjs | 10 +- .../src-tauri/src/commands/agent_discovery.rs | 206 +++++++++++++++++- .../src/managed_agents/runtime/path.rs | 176 +++++++++------ 3 files changed, 319 insertions(+), 73 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 65928a29db..e593a8aa4e 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -492,7 +492,15 @@ const overrides = new Map([ // fix for the Goose Windows installer (PR #2680 interaction with #2750). // +10: pass an explicit PATH through Codex adapter install planning so unit // tests avoid the process-global login-shell PATH cache. - ["src-tauri/src/commands/agent_discovery.rs", 1836], + // +59: run install commands under `pipefail` so a failing `curl` in a + // `curl … | bash` install fails the `cli` step instead of being masked by + // `bash`'s exit 0, plus tests for the arg shape and the real pipeline status. + // +81: install_shell_args re-exports the composed PATH inside the command + // body so login startup files can't clear or reorder it, plus an isolated + // hostile-profile regression the pure composition tests structurally miss. + // +42: gate that re-export off Windows, where join_paths is `;`-separated and + // bash would collapse it into one entry, plus a platform-shape test. + ["src-tauri/src/commands/agent_discovery.rs", 2022], // draft-persistence predicate: submit-time `loadDraft` check + inline comment // + deps-array entry in submitMessage closes the never-persisted-boundary // defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index e3d9b22189..d97e26001d 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -547,6 +547,62 @@ fn persist_last_error_on_install( save_managed_agents(app, &records) } +/// Build the `-l -c` argument list for the install shell. +/// +/// The body runs under `pipefail`: every CLI install command is a `curl … | +/// bash` / `| sh` pipe, and without it the pipeline's status is the right-hand +/// side's — `bash`/`sh` fed an empty stdin exits 0 — so a `curl` that fails (or +/// isn't on PATH at all) was recorded as a successful `cli` step, leaving the +/// user an unactionable `verify` error instead of curl's own stderr. Every +/// install shell supports it; the Windows PowerShell path bypasses this shell. +/// `SHELLOPTS` is not exported, so the piped-to vendor script keeps its own +/// defaults. +/// +/// Off Windows, `composed_path` is passed as a positional and re-exported +/// *inside* the body, because `-l` sources the user's login startup files after +/// the process environment is installed: a profile assigning PATH overwrites +/// `cmd.env("PATH", …)` before the vendor command runs. `export PATH=` empties +/// it outright; macOS `/etc/zprofile` runs `path_helper`, which reorders it and +/// costs Buzz's managed Node/npm dirs their precedence. A positional rather than +/// an interpolated body keeps entries containing spaces or quotes intact. +/// +/// The prelude is omitted where it would do harm: +/// - `composed_path` is `None` — `export PATH="$1"` with `$1` unset sets an +/// *empty* PATH, worse than the ambient one. +/// - `is_windows` — `join_paths` uses the platform separator, so the positional +/// would be `;`-joined while bash splits PATH on `:`, collapsing every entry +/// into one nonsense path; and Windows is where the inherited fallback always +/// fires (`login_shell_path()` is unconditionally `None` there), so this would +/// be the steady state. `cmd.env("PATH", …)` already delivers the native form +/// Git Bash translates on entry, and Windows has no login startup files doing +/// the clobbering this prelude defends against. +/// +/// `is_windows` is a parameter rather than a `#[cfg]` so the Windows shape stays +/// asserted on Unix CI — the same reason `should_skip_claude_executable` takes +/// one. Extracted from `install_shell_command` for that testability, not because +/// it has more than one caller. +fn install_shell_args( + command: &str, + composed_path: Option<&std::ffi::OsStr>, + is_windows: bool, +) -> Vec { + let Some(path) = composed_path.filter(|_| !is_windows) else { + return vec![ + "-l".into(), + "-c".into(), + format!("set -o pipefail; {command}").into(), + ]; + }; + vec![ + "-l".into(), + "-c".into(), + format!("export PATH=\"$1\"; set -o pipefail; {command}").into(), + // `$0` is the shell-name slot, so the PATH must be the second positional. + "buzz-install".into(), + path.to_os_string(), + ] +} + /// Build a login-shell `Command` for `command` with hermit env vars stripped, /// Buzz-managed npm locations set, and the user's PATH set. This is the /// single source of truth for @@ -561,7 +617,6 @@ fn install_shell_command(command: &str) -> Result let shell: std::path::PathBuf = resolve_install_shell()?; let mut cmd = std::process::Command::new(&shell); - cmd.args(["-l", "-c", command]); // Strip hermit vars and set managed npm paths (see apply_npm_env). apply_npm_env(&mut cmd); @@ -569,10 +624,18 @@ fn install_shell_command(command: &str) -> Result // Compose the PATH for the install shell using the same kernel as the // runtime/probe path so the two can never drift. managed entries first // (Node/npm bins keep precedence); login-shell entries next; inherited - // process PATH appended last on Windows when no login-shell PATH exists - // (login_shell_path() always returns None on Windows — Git Bash paths are - // POSIX-shaped and poison native children; cmd.env("PATH", …) replaces - // rather than extends, so without inherited the install shell loses npm). + // process PATH appended last when no login-shell PATH exists — the case + // where the composed PATH would otherwise be Buzz's managed Node dirs + // alone, with no `curl`/`sh`/`tar` for the vendor install pipes + // (cmd.env("PATH", …) replaces rather than extends). On Windows that case + // is the steady state: login_shell_path() always returns None there + // because Git Bash paths are POSIX-shaped and poison native children. + // + // The composed PATH is set twice on purpose off Windows: `cmd.env` so the + // login startup files themselves run with a usable PATH, and the `$1` + // export in `install_shell_args` so their own PATH assignments cannot undo + // it. Neither is redundant — see `install_shell_args`, which also explains + // why the export is suppressed on Windows. let login_path = crate::managed_agents::login_shell_path(); let had_login = login_path.is_some(); let managed: Vec = [ @@ -589,14 +652,20 @@ fn install_shell_command(command: &str) -> Result let inherited: Vec = std::env::var_os("PATH") .map(|p| std::env::split_paths(&p).collect()) .unwrap_or_default(); - let use_inherited = crate::managed_agents::should_use_inherited(had_login, true, cfg!(windows)); + let use_inherited = crate::managed_agents::should_use_inherited(had_login, true); let path_parts = crate::managed_agents::compose_path_entries(managed, login, inherited, use_inherited); - if !path_parts.is_empty() { - if let Ok(path) = std::env::join_paths(path_parts) { - cmd.env("PATH", path); - } + let composed_path = (!path_parts.is_empty()) + .then(|| std::env::join_paths(path_parts).ok()) + .flatten(); + if let Some(path) = composed_path.as_deref() { + cmd.env("PATH", path); } + cmd.args(install_shell_args( + command, + composed_path.as_deref(), + cfg!(windows), + )); // Detach from the controlling terminal so install scripts that read from // /dev/tty (e.g. Codex's "Start Codex now? [y/N]") fall back to stdin @@ -1408,6 +1477,123 @@ mod tests { assert!(result.is_ok(), "install_shell_command must succeed on Unix"); } + // ── pipefail: install pipes must not mask a failing left-hand side ──────── + + /// The command handed to the install shell must run under `set -o pipefail;` + /// with the vendor command preserved verbatim, so `curl … | bash` fails when + /// `curl` does. Platform-agnostic: only the PATH prelude differs by OS, and + /// `test_install_shell_args_shape_per_platform` pins that. + #[test] + fn test_install_shell_command_enables_pipefail() { + let cmd = super::install_shell_command("curl -fsSL https://example.test/i.sh | bash") + .expect("install shell must resolve on a test host"); + let args: Vec = cmd + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + let body = &args[2]; + assert!( + body.contains("set -o pipefail; "), + "the install body must set pipefail; got: {body}" + ); + assert!( + body.ends_with("curl -fsSL https://example.test/i.sh | bash"), + "the vendor command must be preserved verbatim; got: {body}" + ); + } + + /// The PATH prelude is emitted only where it helps, and the exact argument + /// vector is the contract: a stray trailing positional with no `$1` reader, + /// or an export whose `$1` the shell cannot split, both corrupt PATH. + /// Windows is excluded because `join_paths` is `;`-separated there while bash + /// splits PATH on `:` — and it is the platform where the inherited fallback + /// always fires. See `install_shell_args` for the full reasoning. + #[test] + fn test_install_shell_args_shape_per_platform() { + let composed = std::ffi::OsString::from("/buzz/node/bin:/usr/bin"); + let windows_composed = std::ffi::OsString::from(r"C:\buzz\node;C:\Windows\system32"); + let bare = ["-l", "-c", "set -o pipefail; echo hi"].map(std::ffi::OsString::from); + + assert_eq!( + super::install_shell_args("echo hi", Some(&composed), false), + [ + "-l", + "-c", + "export PATH=\"$1\"; set -o pipefail; echo hi", + "buzz-install", + "/buzz/node/bin:/usr/bin", + ] + .map(std::ffi::OsString::from), + "Unix must re-export the composed PATH after login init" + ); + assert_eq!( + super::install_shell_args("echo hi", Some(&windows_composed), true), + bare, + "Windows must not re-export a `;`-joined PATH inside bash" + ); + assert_eq!( + super::install_shell_args("echo hi", None, false), + bare, + "no composed PATH must yield the bare pipefail body and no positionals" + ); + } + + /// Regression for the login-startup-file overwrite: `cmd.env("PATH", …)` is + /// installed *before* `-l` sources the user's profile, so a profile that + /// assigns PATH silently discards the composed one. Uses `/bin/bash` + /// explicitly — the planted profile is bash-specific, so resolving the host + /// shell (which prefers zsh) would make this vacuous. + #[cfg(unix)] + #[test] + fn test_composed_path_survives_a_profile_that_clears_it() { + let home = tempfile::tempdir().expect("temp HOME"); + std::fs::write(home.path().join(".bash_profile"), "export PATH=\n") + .expect("plant a hostile login profile"); + let composed = std::ffi::OsString::from("/buzz/sentinel/bin:/usr/bin:/bin"); + + // `echo` is a shell builtin, so the child needs no PATH to report one. + let out = std::process::Command::new("/bin/bash") + .args(super::install_shell_args( + "echo \"$PATH\"", + Some(&composed), + false, + )) + .env("HOME", home.path()) + .env("PATH", &composed) + .stdin(std::process::Stdio::null()) + .output() + .expect("bash must spawn"); + + let path = String::from_utf8_lossy(&out.stdout); + assert!( + path.contains("/buzz/sentinel/bin"), + "the composed PATH must survive login init; got: {path:?}" + ); + } + + /// End-to-end on the real resolved install shell (no network): a pipeline + /// whose left-hand side fails must exit non-zero, while a fully successful + /// pipeline must still succeed. Without `pipefail` the status is the + /// right-hand side's and the left-hand failure is invisible. + #[cfg(unix)] + #[test] + fn test_install_shell_pipeline_status_follows_left_side() { + for (command, expect_success) in [("false | true", false), ("echo ok | cat", true)] { + let status = super::install_shell_command(command) + .expect("Unix must always resolve an install shell") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .expect("install shell must spawn"); + assert_eq!( + status.success(), + expect_success, + "`{command}` must report success={expect_success}; got {status:?}" + ); + } + } + // ── Phase A: Windows install shell selection ─────────────────────────────── /// On Windows (CI runner has Git pre-installed), resolve_install_shell succeeds. diff --git a/desktop/src-tauri/src/managed_agents/runtime/path.rs b/desktop/src-tauri/src/managed_agents/runtime/path.rs index cf6950f577..efec0c903e 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/path.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/path.rs @@ -30,30 +30,25 @@ pub(crate) fn should_skip_claude_executable(path: &std::path::Path, is_windows: /// Decide whether the inherited process PATH should be appended to the /// composed PATH. /// -/// On Windows, `login_shell_path()` always returns `None` because Git Bash -/// returns POSIX colon-delimited paths that poison native children. -/// `Command::env("PATH", …)` replaces rather than extends, so without the -/// inherited PATH every child loses node/npm/git. -/// -/// This pure function takes an explicit `is_windows` flag so it can be -/// unit-tested cross-host (macOS CI can pass `true` to exercise the Windows -/// policy without needing the `cfg!(windows)` target). +/// `Command::env("PATH", …)` replaces rather than extends, so a child whose +/// composed PATH carries no native entries loses every system binary. On +/// Windows that is the steady state — `login_shell_path()` always returns +/// `None` because Git Bash returns POSIX colon-delimited paths that poison +/// native children. On Unix it is the failure mode: a login shell that exits +/// non-zero or prints nothing also yields `None`, and the child is then left +/// with only Buzz's managed Node dirs — no `curl`, `sh`, or `tar`, which +/// silently breaks every `curl … | bash` install. The inherited PATH is the +/// floor under both cases, appended last so managed dirs keep precedence. /// /// Rules: -/// - Only append when `is_windows` — on Unix the login-shell PATH always covers -/// the needed runtimes. /// - Suppress when `had_shell_path` is `true` — if a login-shell PATH was /// supplied it already carries the user's native entries; appending the /// process PATH would double them. /// - Suppress when `has_local_context` is `false` — callers that pass no home /// or exe-parent context must not receive a PATH manufactured from ambient /// process state alone. -pub(crate) fn should_use_inherited( - had_shell_path: bool, - has_local_context: bool, - is_windows: bool, -) -> bool { - is_windows && !had_shell_path && has_local_context +pub(crate) fn should_use_inherited(had_shell_path: bool, has_local_context: bool) -> bool { + !had_shell_path && has_local_context } /// Pure PATH composition kernel shared by the install shell and the runtime/probe paths. @@ -91,10 +86,14 @@ pub(crate) fn compose_path_entries( /// 4. `nvm_bin` — nvm's default Node.js bin dir (if the user uses nvm) /// 5. exe parent dir — DMG sidecars under `Contents/MacOS/` /// 6. user's login-shell `PATH` — runtimes like node/python from other managers -/// 7. Windows only: the current process `PATH` (appended when no login-shell -/// PATH exists, because callers use `Command::env("PATH", …)` which -/// *replaces* the child's PATH — without this, the child loses node/npm/git -/// and every npm `.cmd` shim fails with `'node' is not recognized`) +/// 7. the current process `PATH` — appended on every platform when no +/// login-shell PATH exists, because callers use `Command::env("PATH", …)` +/// which *replaces* the child's PATH. This is the steady state on Windows, +/// where `login_shell_path()` always returns `None` and without it the +/// child loses node/npm/git and every npm `.cmd` shim fails with +/// `'node' is not recognized`; on Unix it is the login-shell-probe failure +/// fallback, which keeps `curl`/`sh`/`tar` reachable. See +/// [`should_use_inherited`] for the suppression rules. /// /// `shell_path` is the raw colon-delimited string from a login shell, so it is /// split into individual entries before joining. Pushing it as a single segment @@ -145,7 +144,7 @@ pub(in crate::managed_agents) fn build_augmented_path( let inherited: Vec = std::env::var_os("PATH") .map(|p| std::env::split_paths(&p).collect()) .unwrap_or_default(); - let use_inherited = should_use_inherited(had_shell_path, has_local_context, cfg!(windows)); + let use_inherited = should_use_inherited(had_shell_path, has_local_context); let parts = compose_path_entries(managed, login, inherited, use_inherited); if parts.is_empty() { @@ -223,29 +222,86 @@ mod tests { #[cfg(unix)] #[test] fn nvm_bin_none_does_not_add_segment() { + let _guard = crate::managed_agents::lock_path_mutex(); + let previous = std::env::var_os("PATH"); + // With no shell_path the inherited process PATH is appended last, so + // pin it to a sentinel to keep the assertion deterministic. + std::env::set_var("PATH", "/sentinel/inherited"); + let result = build_augmented_path( Some(PathBuf::from("/home/user")), Some(PathBuf::from("/usr/local/bin")), None, None, ); + + match previous { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + let result = result.expect("path"); assert!(result.starts_with("/home/user/.local/bin:"), "{result}"); - assert!(result.ends_with(":/usr/local/bin"), "{result}"); + assert!(!result.contains(".nvm"), "no nvm segment: {result}"); + assert!( + result.contains(":/usr/local/bin:"), + "exe parent must precede the inherited PATH: {result}" + ); + assert!( + result.ends_with(":/sentinel/inherited"), + "inherited PATH must be appended last when no shell_path: {result}" + ); } - /// On Unix, supplying a `shell_path` must NOT trigger the Windows process-PATH - /// fallback — the output must be byte-identical to what it was before this - /// fix. + /// On Unix with no login-shell PATH, `build_augmented_path` must fall back to + /// the inherited process PATH — otherwise the child gets only Buzz-managed + /// dirs and loses every system binary (`curl`, `sh`, `tar`). #[cfg(unix)] #[test] - fn unix_shell_path_output_unchanged_by_windows_fallback_logic() { + fn unix_appends_process_path_when_no_shell_path() { + let _guard = crate::managed_agents::lock_path_mutex(); + let previous = std::env::var_os("PATH"); + std::env::set_var("PATH", "/usr/bin:/bin"); + + let result = build_augmented_path(Some(PathBuf::from("/home/user")), None, None, None); + + match previous { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + + let result = result.expect("path must not be None with a home dir"); + assert!( + result.starts_with("/home/user/.local/bin:"), + "home/.local/bin must be first: {result}" + ); + assert!( + result.ends_with(":/usr/bin:/bin"), + "process PATH must be last: {result}" + ); + } + + /// On Unix, supplying a `shell_path` must NOT also append the inherited + /// process PATH — the login-shell PATH already carries the native entries. + #[cfg(unix)] + #[test] + fn unix_shell_path_suppresses_inherited_fallback() { + let _guard = crate::managed_agents::lock_path_mutex(); + let previous = std::env::var_os("PATH"); + std::env::set_var("PATH", "/should/not/appear"); + let result = build_augmented_path( Some(PathBuf::from("/home/user")), None, Some("/usr/local/bin:/usr/bin:/bin".to_string()), None, ); + + match previous { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + let result = result.expect("path"); assert!( result.ends_with(":/usr/local/bin:/usr/bin:/bin"), @@ -334,8 +390,8 @@ mod tests { // ── Pure policy and composition tests — run on every host ──────────────────── // // These test `should_use_inherited` and `compose_path_entries` with explicit -// inputs, so they run on macOS/Linux CI and validate the Windows policy -// behavior without touching process state or requiring a Windows target. +// inputs, so they run on macOS/Linux CI and validate the cross-platform +// fallback policy without touching process state or requiring a Windows target. #[cfg(test)] mod compose_tests { use super::{compose_path_entries, is_batch_shim, should_use_inherited}; @@ -347,43 +403,37 @@ mod compose_tests { // ── should_use_inherited policy matrix ──────────────────────────────────── - /// Windows + no shell path + has local context → must use inherited. + /// No shell path + has local context → must use inherited, on every OS. + /// This is the steady state on Windows (login_shell_path() is always None) + /// and the failure mode on Unix (login shell exited non-zero or printed + /// nothing); in both the child would otherwise get no native PATH entries. #[test] - fn policy_windows_no_shell_with_context_uses_inherited() { + fn policy_no_shell_with_context_uses_inherited() { assert!( - should_use_inherited(false, true, true), - "Windows, no shell path, has context → must append inherited" + should_use_inherited(false, true), + "no shell path, has context → must append inherited" ); } - /// Windows + shell path present → must NOT use inherited (login path covers it). + /// Shell path present → must NOT use inherited (login path covers it). #[test] - fn policy_windows_shell_path_present_suppresses_inherited() { + fn policy_shell_path_present_suppresses_inherited() { assert!( - !should_use_inherited(true, true, true), - "Windows, shell path present → must not append inherited" + !should_use_inherited(true, true), + "shell path present → must not append inherited" ); } - /// Windows + no local context → must NOT use inherited (no ambient state). + /// No local context → must NOT use inherited (no ambient state). #[test] - fn policy_windows_no_local_context_suppresses_inherited() { + fn policy_no_local_context_suppresses_inherited() { assert!( - !should_use_inherited(false, false, true), - "Windows, no local context → must not append inherited" - ); - } - - /// Non-Windows → never use inherited, regardless of other flags. - #[test] - fn policy_non_windows_never_uses_inherited() { - assert!( - !should_use_inherited(false, true, false), - "non-Windows must never append inherited PATH" + !should_use_inherited(false, false), + "no local context → must not append inherited" ); assert!( - !should_use_inherited(false, false, false), - "non-Windows + no context must never append inherited PATH" + !should_use_inherited(true, false), + "no local context → must not append inherited even with a shell path" ); } @@ -498,23 +548,25 @@ mod compose_tests { // compute the same `should_use_inherited` decision for equivalent inputs. // Tests the policy function directly to confirm the wrappers can't drift. - /// Exhaustive truth-table for `should_use_inherited` — all four input - /// combinations that affect real callers. Confirms the policy is correct - /// before either wrapper binds to it. + /// Exhaustive truth-table for `should_use_inherited` — every input + /// combination. Confirms the policy is correct before either wrapper binds + /// to it. The rule is OS-independent: the inherited PATH is the floor + /// whenever no login-shell PATH was obtained, because the alternative is a + /// child with no native binaries at all. #[test] fn should_use_inherited_policy_truth_table() { - // (had_shell, has_context, is_windows) → expected + // (had_shell, has_context) → expected let cases = [ - (false, true, true, true), // Windows, no shell, context → USE - (true, true, true, false), // Windows, shell present → NO - (false, false, true, false), // Windows, no context → NO - (false, true, false, false), // non-Windows → NO + (false, true, true), // no shell PATH, context → USE (the floor) + (true, true, false), // shell PATH present → NO (already covered) + (false, false, false), // no context → NO (no ambient-only PATH) + (true, false, false), // no context → NO, shell PATH irrelevant ]; - for (had_shell, has_ctx, is_win, expected) in cases { - let result = should_use_inherited(had_shell, has_ctx, is_win); + for (had_shell, has_ctx, expected) in cases { + let result = should_use_inherited(had_shell, has_ctx); assert_eq!( result, expected, - "policy mismatch: had_shell={had_shell} has_ctx={has_ctx} is_win={is_win}" + "policy mismatch: had_shell={had_shell} has_ctx={has_ctx}" ); } } From 8e67cf399d0291bcdbc69cd0402983ca030f05bb Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sun, 26 Jul 2026 09:30:13 -0700 Subject: [PATCH 015/128] chore(desktop): delete dead persona catalog UI cluster (#2886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Deletes 6 orphaned files in `desktop/src/features/agents/ui/` (556 lines) that formed a closed cluster with zero imports from the reachable module graph — orphaned by the 1B dialog consolidation (`PersonaCatalogSurface`, `PersonaCatalogSection`, `PersonaCatalogDetailsSheet`, `PersonaCatalogSelectionBadge`, `PersonaIdentity`, `PersonaLibraryEntryPoints`). - Removes a stale `check-file-sizes.mjs` override entry for the no-longer-existent `PersonaDialog.tsx`. Verified dead two ways: import-graph reachability walk from all entry points puts all six outside the reachable set, and `tsc --noEmit` passes clean after deletion. Their `data-testid`s have zero references outside the cluster. `PersonaCatalogDialog.tsx` is alive (`AgentsView` imports it) and stays. Independent of #1968 — pure dead code removal, no behavioral change. --- desktop/scripts/check-file-sizes.mjs | 14 -- .../agents/ui/PersonaCatalogDetailsSheet.tsx | 163 ----------------- .../agents/ui/PersonaCatalogSection.tsx | 166 ------------------ .../ui/PersonaCatalogSelectionBadge.tsx | 29 --- .../agents/ui/PersonaCatalogSurface.tsx | 79 --------- .../features/agents/ui/PersonaIdentity.tsx | 62 ------- .../agents/ui/PersonaLibraryEntryPoints.tsx | 57 ------ 7 files changed, 570 deletions(-) delete mode 100644 desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx delete mode 100644 desktop/src/features/agents/ui/PersonaCatalogSection.tsx delete mode 100644 desktop/src/features/agents/ui/PersonaCatalogSelectionBadge.tsx delete mode 100644 desktop/src/features/agents/ui/PersonaCatalogSurface.tsx delete mode 100644 desktop/src/features/agents/ui/PersonaIdentity.tsx delete mode 100644 desktop/src/features/agents/ui/PersonaLibraryEntryPoints.tsx diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index e593a8aa4e..b74e05ae7b 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -219,20 +219,6 @@ const overrides = new Map([ // (+3) — the new team-id spawn link and the runtime-layered instructions // field. ["src/shared/api/types.ts", 1047], - // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + - // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog - // shows required markers and credential amber rows (parity with - // CreateAgentDialog). +23 lines of gate wiring. Queued to split. - // config-bridge-aware requirements: useRuntimeFileConfigQuery wiring adds - // ~16 lines. Queued to split. - // baked-env-required-badge: useBakedBuildEnvKeysQuery + bakedEnvKeys wiring - // + correct exclusion-semantics for requiredEnvKeys adds ~14 lines. - // +2 lines: filter managed provider key from requiredEnvKeys (suppress dead-input locked row). - // global-agent-config parity: wire useGlobalAgentConfig into PersonaDialog - // (Gap A: global-aware computeLocalModeGate + drop bare requiredCredentialEnvKeys; - // Gap B: hasAutoOpenedAdvancedRef auto-expand effect) + effective-provider - // save gate + Inherit/Select-a-provider label. Queued to split. - ["src/features/agents/ui/PersonaDialog.tsx", 1080], // harness-persona-sync feature growth, queued to split in the resolver-unify // refactor followup. discovery.rs is dominated by the new test module // (the effective_agent_command / divergent / create-time override matrix); diff --git a/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx b/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx deleted file mode 100644 index 6aab3096cb..0000000000 --- a/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; -import type { AgentPersona } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; -import { promptPreview } from "@/shared/lib/promptPreview"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/shared/ui/sheet"; - -import { PersonaCatalogSelectionBadge } from "./PersonaCatalogSelectionBadge"; -import { - getPersonaCatalogDetailSelectionCopy, - getPersonaCatalogSelectionAriaLabel, -} from "./personaLibraryCopy"; - -type PersonaCatalogDetailsSheetProps = { - feedbackErrorMessage: string | null; - feedbackNoticeMessage: string | null; - isPending: boolean; - onOpenChange: (open: boolean) => void; - onTogglePersona: (persona: AgentPersona) => void; - open: boolean; - persona: AgentPersona | null; -}; - -export function PersonaCatalogDetailsSheet({ - feedbackErrorMessage, - feedbackNoticeMessage, - isPending, - onOpenChange, - onTogglePersona, - open, - persona, -}: PersonaCatalogDetailsSheetProps) { - const preview = persona ? promptPreview(persona.systemPrompt) : ""; - const isSelected = persona ? isCatalogPersonaSelected(persona) : false; - const selectionCopy = getPersonaCatalogDetailSelectionCopy(isSelected); - - return ( - - - {persona ? ( - <> - -
- -
-
- - {persona.displayName} - - -
- - {preview || "No summary available."} - -
-
-
-
- - - {feedbackNoticeMessage ? ( -

- {feedbackNoticeMessage} -

- ) : null} - - {feedbackErrorMessage ? ( -

- {feedbackErrorMessage} -

- ) : null} - -
-
-

- Type -

-

Built-in agent

-
-
-

- Preferred model -

-

- {persona.model ?? "Use app default"} -

-
-
-

- Preferred runtime -

-

- {persona.runtime ?? "Use app default"} -

-
-
- -
-

- System prompt -

-
-                  {persona.systemPrompt}
-                
-
-
- - ) : null} -
-
- ); -} diff --git a/desktop/src/features/agents/ui/PersonaCatalogSection.tsx b/desktop/src/features/agents/ui/PersonaCatalogSection.tsx deleted file mode 100644 index 25cc9e4751..0000000000 --- a/desktop/src/features/agents/ui/PersonaCatalogSection.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; -import type { AgentPersona } from "@/shared/api/types"; -import { promptPreview } from "@/shared/lib/promptPreview"; -import { cn } from "@/shared/lib/cn"; -import { Button } from "@/shared/ui/button"; -import { Card } from "@/shared/ui/card"; -import { Skeleton } from "@/shared/ui/skeleton"; -import { PersonaCatalogSelectionBadge } from "./PersonaCatalogSelectionBadge"; -import { PersonaIdentity } from "./PersonaIdentity"; -import { - getPersonaCatalogSelectionActionCopy, - getPersonaCatalogSelectionAriaLabel, - personaCatalogCopy, -} from "./personaLibraryCopy"; - -type PersonaCatalogSectionProps = { - emptyDescription?: string; - emptyTitle?: string; - error: Error | null; - isLoading: boolean; - isPending: boolean; - onTogglePersona: (persona: AgentPersona) => void; - onViewDetails: (persona: AgentPersona) => void; - personas: AgentPersona[]; - showHeader?: boolean; -}; - -export function PersonaCatalogSection({ - emptyDescription = personaCatalogCopy.emptyCatalogDescription, - emptyTitle = personaCatalogCopy.emptyCatalogTitle, - error, - isLoading, - isPending, - onTogglePersona, - onViewDetails, - personas, - showHeader = true, -}: PersonaCatalogSectionProps) { - return ( -
- {showHeader ? ( -
-

- {personaCatalogCopy.title} -

-

- {personaCatalogCopy.description} -

-
- ) : null} - - {isLoading ? ( -
- {["first", "second", "third", "fourth"].map((key) => ( - -
- -
- - -
-
-
- ))} -
- ) : null} - - {!isLoading && personas.length > 0 ? ( -
- {personas.map((persona) => { - const preview = promptPreview(persona.systemPrompt); - const isSelected = isCatalogPersonaSelected(persona); - - return ( -
- - - - {getPersonaCatalogSelectionActionCopy(isSelected)} - -
-
- - ); - })} - - ) : null} - - {!isLoading && personas.length === 0 ? ( -
-

{emptyTitle}

-

- {emptyDescription} -

-
- ) : null} - - {error ? ( -

- {error.message} -

- ) : null} -
- ); -} diff --git a/desktop/src/features/agents/ui/PersonaCatalogSelectionBadge.tsx b/desktop/src/features/agents/ui/PersonaCatalogSelectionBadge.tsx deleted file mode 100644 index 75837d0f94..0000000000 --- a/desktop/src/features/agents/ui/PersonaCatalogSelectionBadge.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Check } from "lucide-react"; - -import { cn } from "@/shared/lib/cn"; - -import { personaCatalogCopy } from "./personaLibraryCopy"; - -type PersonaCatalogSelectionBadgeProps = { - isActive: boolean; -}; - -export function PersonaCatalogSelectionBadge({ - isActive, -}: PersonaCatalogSelectionBadgeProps) { - return ( - - {isActive ? : null} - {isActive - ? personaCatalogCopy.selectedState - : personaCatalogCopy.availableState} - - ); -} diff --git a/desktop/src/features/agents/ui/PersonaCatalogSurface.tsx b/desktop/src/features/agents/ui/PersonaCatalogSurface.tsx deleted file mode 100644 index 159025f7b9..0000000000 --- a/desktop/src/features/agents/ui/PersonaCatalogSurface.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import * as React from "react"; - -import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; -import type { AgentPersona } from "@/shared/api/types"; -import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; - -import { PersonaCatalogDetailsSheet } from "./PersonaCatalogDetailsSheet"; -import { PersonaCatalogSection } from "./PersonaCatalogSection"; - -type PersonaCatalogSurfaceProps = { - error: Error | null; - feedbackErrorMessage: string | null; - feedbackNoticeMessage: string | null; - isLoading: boolean; - isPending: boolean; - onClearFeedback: () => void; - onSelectPersona: (persona: AgentPersona, active: boolean) => void; - personas: AgentPersona[]; - showHeader?: boolean; -}; - -export function PersonaCatalogSurface({ - error, - feedbackErrorMessage, - feedbackNoticeMessage, - isLoading, - isPending, - onClearFeedback, - onSelectPersona, - personas, - showHeader = true, -}: PersonaCatalogSurfaceProps) { - const [detailPersonaId, setDetailPersonaId] = React.useState( - null, - ); - const detailPersona = React.useMemo( - () => - detailPersonaId - ? (personas.find((persona) => persona.id === detailPersonaId) ?? null) - : null, - [detailPersonaId, personas], - ); - const handleTogglePersona = (persona: AgentPersona) => { - onSelectPersona(persona, !isCatalogPersonaSelected(persona)); - }; - - useFeedbackToasts(feedbackNoticeMessage, feedbackErrorMessage); - - return ( - <> - { - onClearFeedback(); - setDetailPersonaId(persona.id); - }} - personas={personas} - showHeader={showHeader} - /> - - { - if (!open) { - setDetailPersonaId(null); - } - }} - onTogglePersona={handleTogglePersona} - open={detailPersonaId !== null} - persona={detailPersona} - /> - - ); -} diff --git a/desktop/src/features/agents/ui/PersonaIdentity.tsx b/desktop/src/features/agents/ui/PersonaIdentity.tsx deleted file mode 100644 index eb2c5c0253..0000000000 --- a/desktop/src/features/agents/ui/PersonaIdentity.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Info } from "lucide-react"; - -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; -import type { AgentPersona } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; -import { promptPreview } from "@/shared/lib/promptPreview"; -import { Badge } from "@/shared/ui/badge"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; - -type PersonaIdentityProps = { - className?: string; - persona: AgentPersona; - showBuiltInBadge?: boolean; - showPromptTooltip?: boolean; -}; - -export function PersonaIdentity({ - className, - persona, - showBuiltInBadge = persona.isBuiltIn, - showPromptTooltip = true, -}: PersonaIdentityProps) { - const preview = promptPreview(persona.systemPrompt); - - return ( -
-
- -
-
-

- {persona.displayName} -

- {showBuiltInBadge ? ( - Built-in - ) : null} - {showPromptTooltip && preview ? ( - - - - - -

{preview}

-
-
- ) : null} -
-
-
-
- ); -} diff --git a/desktop/src/features/agents/ui/PersonaLibraryEntryPoints.tsx b/desktop/src/features/agents/ui/PersonaLibraryEntryPoints.tsx deleted file mode 100644 index 113d38b898..0000000000 --- a/desktop/src/features/agents/ui/PersonaLibraryEntryPoints.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Upload } from "lucide-react"; - -import { Button } from "@/shared/ui/button"; - -import { CreateNewButton } from "./CreateNewButton"; -import { personaLibraryCopy } from "./personaLibraryCopy"; - -type PersonaLibraryEntryPointsProps = { - canChooseCatalog?: boolean; - isPending: boolean; - layout: "header" | "empty"; - onCreate: () => void; - onChooseCatalog?: () => void; - onImport?: () => void; -}; - -export function PersonaLibraryEntryPoints({ - canChooseCatalog = false, - isPending, - layout, - onCreate, - onChooseCatalog, - onImport, -}: PersonaLibraryEntryPointsProps) { - const isHeader = layout === "header"; - const chooseVariant = isHeader ? "outline" : "default"; - const createVariant = isHeader ? "default" : "outline"; - - return ( - <> - {canChooseCatalog && onChooseCatalog ? ( - - ) : null} - - {!isHeader && onImport ? ( - - ) : null} - - ); -} From 8c0e8cb1656b04ad269bce3c2deeda2a943ae78a Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sun, 26 Jul 2026 09:30:33 -0700 Subject: [PATCH 016/128] fix(desktop): make agent definition authoritative for model/provider/prompt (#1968) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Introduces a single effective-config resolver that makes agent definitions authoritative for model, provider, system prompt, and relay-mesh routing on linked instances. Stale materialized record bytes are never consulted at spawn, deploy, readiness, hash, card summary, or mesh preflight. **Resolution semantics (field-specific):** - **model/provider (linked):** definition → global. `None` = inherit global default. Record value never consulted. - **model/provider (definition-less):** instance → global. - **system_prompt (linked):** strictly from definition; blank = no prompt. No global tier for prompt. - **system_prompt (definition-less):** from the instance. - **relay-mesh preflight (both):** driven by the same `resolve_effective_config` resolution spawn's mesh env consults. For a linked instance the record's own `provider`/`model`/`relay_mesh` bytes are never consulted; for a definition-less instance with no provider of its own, the legacy marker and env preset are the last fallback (see below). - **Orphaned (linked record, definition missing):** spawn, deploy, and mesh preflight blocked with actionable user-facing error. **Changes:** - New `effective_config` resolver module with `ConfigSource` metadata (`definition`, `global`, `instance_legacy`) - All consumers routed through the single resolver: local spawn, deploy, readiness, spawn hash, card summary, relay-mesh preflight (interactive start and restore-on-launch) - `apply_persona_snapshot` no longer preserves stale record model/provider when definition is blank - Backend `update_managed_agent` blocks model/provider/prompt writes for linked records - Frontend omits model/provider/systemPrompt submission for linked instances; system prompt override hidden - `model_source` field added to `ManagedAgentSummary` so card labels distinguish inherited (`Default model (X)`) from explicit - Spawn hash now digests resolved model/provider (not raw record fields) so global default changes trip the restart badge even for runtimes without `model_env_var` - Orphan spawn/deploy/mesh-preflight blocked with jargon-free error ("This agent's configuration is missing — it may still be syncing or was deleted on another device") - `EffectiveAgentConfig::relay_mesh_model_id()` and `resolve_effective_relay_mesh_model_id()` added; both mesh preflights (`start_local_agent_with_preflight`, `restore_managed_agents_on_launch`) call this instead of the deleted `relay_mesh_config`/`relay_mesh_model_id` record-byte sniffs - Legacy relay-mesh records keep their mesh routing. Two shipped generations predate `provider: "relay-mesh"` and are never rewritten on load: the typed `relay_mesh` marker (added when `ManagedAgentRecord` had no `provider` field), and before it the mesh preset written directly into `env_vars`. `resolve_definition_less` falls back to the marker, then to the env preset, so these records still resolve to mesh instead of silently misrouting to an unrelated provider while their stale env bytes reach the child. The fallback is skipped when the record carries an explicit `provider` — that states current intent, including a switch away from mesh — and `resolve_linked` has no legacy fallback at all - The env discriminator accepts both spellings of the two sentinels renamed in the Jun-11 window without a record migration: the provider env key (`BUZZ_AGENT_PROVIDER`, previously `SPROUT_AGENT_PROVIDER`) and the api-key value (`buzz-mesh-local`, previously `sprout-mesh-local`). Each is independent, since a record can straddle the window; the current provider-key spelling wins when both are present - Dead `persona_field_with_record_fallback` and wrapper `persona_snapshot_with_agent_config_fallback` deleted; callers use `persona_snapshot` directly - New resolver/deploy/hash/write-guard/mesh-preflight tests, including switch-away and global-inheritance regressions for both mesh preflight call sites, per-class legacy-mesh resolution (typed marker and env preset, in every rename-window spelling combination), and the paired assertions that a linked instance's legacy mesh bytes stay inert --------- Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- desktop/scripts/check-file-sizes.mjs | 38 +- .../src-tauri/src/commands/agent_config.rs | 98 +- .../src-tauri/src/commands/agent_models.rs | 73 +- .../src/commands/agent_models_tests.rs | 175 +++- desktop/src-tauri/src/commands/agents.rs | 47 +- .../src-tauri/src/commands/agents_deploy.rs | 79 +- .../src-tauri/src/commands/agents_tests.rs | 87 +- desktop/src-tauri/src/commands/mesh_llm.rs | 14 +- .../managed_agents/effective_config/mod.rs | 318 +++++++ .../managed_agents/effective_config/tests.rs | 872 ++++++++++++++++++ .../src-tauri/src/managed_agents/env_vars.rs | 8 +- .../src/managed_agents/global_config/mod.rs | 69 +- .../src/managed_agents/global_config/tests.rs | 56 +- desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../src/managed_agents/persona_events.rs | 115 +-- .../managed_agents/persona_events/tests.rs | 302 +++--- .../src-tauri/src/managed_agents/readiness.rs | 15 +- .../src/managed_agents/relay_mesh.rs | 229 ----- .../src-tauri/src/managed_agents/restore.rs | 28 +- .../src-tauri/src/managed_agents/runtime.rs | 142 ++- .../src/managed_agents/runtime/metadata.rs | 28 +- .../src/managed_agents/runtime/tests.rs | 74 +- .../src/managed_agents/spawn_hash.rs | 42 +- .../src/managed_agents/spawn_hash/tests.rs | 129 ++- desktop/src-tauri/src/managed_agents/types.rs | 45 +- .../agents/lib/agentCardModelLabel.test.mjs | 58 ++ .../agents/lib/agentCardModelLabel.ts | 44 + .../agents/ui/AgentInstanceEditDialog.tsx | 36 +- .../agents/ui/EditAgentAdvancedFields.tsx | 47 +- .../features/agents/ui/ManagedAgentRow.tsx | 14 +- .../agents/ui/UnifiedAgentsSection.tsx | 47 +- .../features/agents/ui/personaRuntimeModel.ts | 43 +- desktop/src/shared/api/tauri.ts | 3 + desktop/src/shared/api/types.ts | 1 + 34 files changed, 2548 insertions(+), 829 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/effective_config/mod.rs create mode 100644 desktop/src-tauri/src/managed_agents/effective_config/tests.rs create mode 100644 desktop/src/features/agents/lib/agentCardModelLabel.test.mjs create mode 100644 desktop/src/features/agents/lib/agentCardModelLabel.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index b74e05ae7b..9649e9bbfb 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -80,7 +80,15 @@ const overrides = new Map([ // ratcheting 1443 -> 1295. Queued to split further in the A2 fold. // global-agent-config: resolve_deploy_model_provider + visibility exports // add ~40 lines on top of the 1A.1 ratchet. Queued to split. - ["src-tauri/src/commands/agents.rs", 1340], + // +22 (1340 -> 1360): agent-config-resolver — start_local_agent_with_preflight + // uses resolve_effective_relay_mesh_model_id at both preflight call sites; + // preview_prospective_persona_snapshot helper extracted; orphan guard threaded + // through restore path. Load-bearing feature changes; queued to split. + // +9 (1360 -> 1369): start_local_agent_pairs_with_preflight — added + // personas/global load + resolve_effective_relay_mesh_model_id call to + // replace stale record-byte preflight. Same resolver pattern as + // start_local_agent_with_preflight. Load-bearing; queued to split. + ["src-tauri/src/commands/agents.rs", 1369], // agent-lifecycle-fixes: cascade-delete in delete_persona restructured into // 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for // retry-safety. Load-bearing reviewer-required change; queued to split. @@ -168,7 +176,9 @@ const overrides = new Map([ // Windows PATH-correctness fix: 3 #[cfg(windows)] test functions covering // .cmd shim rejection, .bat shim rejection, and .exe acceptance for // configure_runtime_cli (fix #2397). Test-only growth; queued to split. - ["src-tauri/src/managed_agents/runtime/tests.rs", 1041], + // +7 (1041 -> 1048): rebase onto main — this PR's resolver tests land on top + // of main's #2397 Windows shim tests. Test-only; queued to split. + ["src-tauri/src/managed_agents/runtime/tests.rs", 1055], // applyWorkspace reposDir parameter plus the validateReposDir binding, // threaded through Tauri invokes for configurable repos_dir, plus the // harness-persona-sync `harnessOverride` create-input bit — load-bearing @@ -203,7 +213,8 @@ const overrides = new Map([ // mapper. This is the existing API boundary; split remains queued. // team-instructions-first-class: createManagedAgent Tauri bridge threads the // new teamId input through to the backend (+1 line). - ["src/shared/api/tauri.ts", 1305], + // +2 for model_source field in RawManagedAgent + fromRawManagedAgent mapping. + ["src/shared/api/tauri.ts", 1307], // doctor-npm-eacces-preflight: hint field added to InstallStepResult (+1 line). // codex-acp-package-swap: "adapter_outdated" variant added to AcpAvailabilityStatus (+1 line). // doctor-install-reliability: AuthStatus tagged union + nodeRequired/authStatus/ @@ -438,7 +449,15 @@ const overrides = new Map([ // (if let Some(provider_update) = input.provider { record.provider = provider_update; }). // +8: harness_override thread-through in update_managed_agent so a deliberate // Custom pin routes to update_time_agent_command_override (comment + call). - ["src-tauri/src/commands/agent_models.rs", 1079], + // +22 (1079 -> 1101): Finding 2 — model discovery now resolves through + // resolve_effective_model_provider instead of raw record bytes + // (saved_agent_model_discovery_config takes personas/global and the + // get_agent_models call site loads global config), plus + // apply_model_provider_prompt_update's linked-instance write-guard + // extraction and its regression tests. + // +4 (1101 -> 1105): rebase onto agents-everywhere — agents.rs function + // signatures updated for ManagedAgentRuntimeKey-keyed runtimes map. + ["src-tauri/src/commands/agent_models.rs", 1113], // global-agent-config: get_agent_config_surface / write_agent_config_field / // put_agent_session_config commands + GlobalAgentConfig serde types. New file // in this PR; queued to split with the command module refactor. @@ -454,7 +473,12 @@ const overrides = new Map([ // relay from the harness-attached payload relayUrl (with effective-relay // fallback for older harnesses) instead of a required arg the frontend // wrapper never passed, which silently broke the session-config cache. - ["src-tauri/src/commands/agent_config.rs", 1050], + // +60 (1050 -> 1110): agent-config-resolver — resolve_config_surface now + // clears a linked instance's own system_prompt/model/provider before + // computing had_* so stale materialized snapshot bytes can never be tagged + // BuzzExplicit and shadow the definition/global fallthrough; the dead + // persona-model re-tag branch replaced; two new regression tests added. + ["src-tauri/src/commands/agent_config.rs", 1110], // codex-install-auto-restart review-fixes: should_restart_after_install // takes pid_alive:bool (pure predicate, no OS-dependent call); 3 racy // cache tests replaced with 6 pure availability_drift predicate tests; @@ -532,7 +556,9 @@ const overrides = new Map([ // for Databricks v1 gate; prospectiveRuntimeId default fallback for builtins. // PR-B moves default/API-key derivation into shared hooks; the explicit // hidden-key projection keeps the top-level secret out of Advanced rows. - ["src/features/agents/ui/AgentInstanceEditDialog.tsx", 1195], + // +6 (1195 -> 1201): rebase onto main — this PR's model-source label wiring + // lands on top of main's dialog growth. Queued to split. + ["src/features/agents/ui/AgentInstanceEditDialog.tsx", 1201], // AgentDefinitionDialog grew past 1000 with the following load-bearing fixes: // isRuntimeAutoSeededRef tracking for edit-mode seeding (Fizz shows models); // runtimeSupportsLlmProviderSelection guard on discovery provider (codex fix); diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index f6094ed529..461fed5dbd 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -39,6 +39,13 @@ pub struct RuntimeFileConfigSubset { /// Resolve the config surface with persona and global default values applied. /// +/// Linked instances are definition-authoritative: the record's own +/// system_prompt/model/provider are cleared before applying, so a stale +/// materialized snapshot can never shadow the persona's current values or a +/// blank-definition fallthrough to global defaults (mirrors +/// `effective_config::resolve_linked`). Definition-less instances keep their +/// own explicit values. +/// /// The pipeline: resolve the linked persona's prompt/model/provider, inject /// each into the record only where the record lacks its own value, let /// `read_config_surface` tag those injected fields `BuzzExplicit`, then re-tag @@ -59,6 +66,22 @@ fn resolve_config_surface( session_cache: Option<&SessionConfigCache>, global: &GlobalAgentConfig, ) -> RuntimeConfigSurface { + // Linked instances are definition-authoritative (mirrors + // `effective_config::resolve_linked`): the record's own + // system_prompt/model/provider fields are, at best, a stale materialized + // snapshot from the last `apply_persona_snapshot` — never a legitimate + // live override, since `update_managed_agent` blocks writing these three + // fields for linked instances. Clear them before computing `had_*` below + // so a stale byte can never masquerade as BuzzExplicit and suppress + // definition/global injection. Env var overrides (set via the advanced + // env-vars editor) are untouched — those remain a legitimate + // per-instance override regardless of link status. + if record.persona_id.is_some() { + record.system_prompt = None; + record.model = None; + record.provider = None; + } + let had_prompt = record.system_prompt.is_some() || record.env_vars.contains_key("BUZZ_ACP_SYSTEM_PROMPT"); let had_model = record.model.is_some(); @@ -172,22 +195,6 @@ fn resolve_config_surface( retag_global_default(&mut surface.normalized.provider); } - // Re-tag persona-snapshotted model from BuzzExplicit to PersonaDefault. - // Persona-created agents have record.model set at create time from the - // persona snapshot — had_model is true, but the model came from the persona, - // not an explicit user choice. Re-tag when the record model matches the - // persona model and no live override is active. Only applies when a persona - // is actually linked — non-persona agents with an explicit model keep BuzzExplicit. - if had_model && !model_overridden && record.persona_id.is_some() { - if let (Some(ref record_model), Some(ref persona_model_val)) = - (&record.model, &persona_model) - { - if record_model == persona_model_val { - retag_persona_default(&mut surface.normalized.model); - } - } - } - surface } @@ -728,11 +735,64 @@ mod tests { } } - /// A model the user set explicitly in Buzz must never be re-tagged to - /// `PersonaDefault`, even when the linked persona also has a model. + /// Definition-authoritative: a stale materialized `record.model` on a + /// linked instance must never outrank (or even be consulted against) the + /// linked persona's model. `update_managed_agent` already blocks writing + /// model/provider/prompt for linked instances, so a non-`None` value here + /// can only be leftover snapshot bytes from before a persona edit — the + /// panel must report the persona's current model, tagged `PersonaDefault`, + /// not the stale byte as `BuzzExplicit`. #[test] - fn explicit_record_model_outranks_persona_and_keeps_buzz_explicit_origin() { + fn linked_stale_record_model_never_outranks_persona_model() { let mut record = agent_record(); + record.model = Some("stale-explicit-model".to_string()); + let personas = vec![persona_with_model("persona-model")]; + + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + None, + &Default::default(), + ); + + let model = surface.normalized.model.as_ref().expect("model resolved"); + assert_eq!(model.value.as_deref(), Some("persona-model")); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); + } + + /// Definition-authoritative, blank-definition case: a linked instance + /// whose persona has no model of its own must fall through to the global + /// default, tagged `GlobalDefault` — mirroring + /// `effective_config::resolve_linked`'s `None => global` arm. A stale + /// materialized record model must not shadow this fallthrough either. + #[test] + fn linked_blank_definition_model_falls_through_to_global_default() { + let mut record = agent_record(); + record.model = Some("stale-explicit-model".to_string()); + let mut persona = persona_with_model("unused"); + persona.model = None; + let personas = vec![persona]; + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + ..Default::default() + }; + + let surface = + resolve_config_surface(record, &personas, Some(goose_runtime()), None, &global); + + let model = surface.normalized.model.as_ref().expect("model resolved"); + assert_eq!(model.value.as_deref(), Some("global-model")); + assert_eq!(model.origin, ConfigOrigin::GlobalDefault); + } + + /// A definition-less (no `persona_id`) instance's own explicit model IS + /// authoritative — the stale-record clearing above is scoped to linked + /// instances only. + #[test] + fn definition_less_explicit_record_model_keeps_buzz_explicit_origin() { + let mut record = agent_record(); + record.persona_id = None; record.model = Some("explicit-model".to_string()); let personas = vec![persona_with_model("persona-model")]; diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 112f297fa7..2e423ed6dd 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -87,9 +87,13 @@ pub async fn get_agent_models( // ModelPicker can persist a selected model but not rewrite the saved // provider/env snapshot, and runtime spawn reads that same snapshot. - // Discover models against the record snapshot so an out-of-date persona - // cannot offer models for a provider this agent will not launch with. - let discovery = saved_agent_model_discovery_config(record, &effective_command); + // Discover models against the resolver's effective model/provider — + // definition-authoritative for linked instances — so discovery can + // never query a stale provider this agent will not actually launch + // with. + let global = crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(); + let discovery = + saved_agent_model_discovery_config(record, &effective_command, &personas, &global); ( resolved, @@ -161,27 +165,38 @@ struct SavedAgentModelDiscoveryConfig { env: BTreeMap, } +/// Resolve the model/provider discovery config from the same authoritative +/// source spawn uses (`resolve_effective_model_provider`) — linked instances +/// read their definition, never a stale materialized `record.model`/ +/// `record.provider`, so model discovery cannot query a provider this agent +/// will not actually launch with. Definition-less instances keep their own +/// record values, matching spawn's `resolve_definition_less` arm. fn saved_agent_model_discovery_config( record: &crate::managed_agents::ManagedAgentRecord, agent_command: &str, + personas: &[crate::managed_agents::AgentDefinition], + global: &crate::managed_agents::GlobalAgentConfig, ) -> SavedAgentModelDiscoveryConfig { let runtime_meta = known_acp_runtime(agent_command); + let (effective_model, effective_provider) = + crate::managed_agents::resolve_effective_model_provider(record, personas, global); + let mut derived_env = BTreeMap::new(); if let Some(meta) = runtime_meta { for (key, value) in crate::managed_agents::runtime_metadata_env_vars( meta.model_env_var, meta.provider_env_var, meta.provider_locked, - record.model.as_deref(), - record.provider.as_deref(), + effective_model.as_deref(), + effective_provider.as_deref(), ) { derived_env.insert(key.to_string(), value.to_string()); } } SavedAgentModelDiscoveryConfig { - model: record.model.clone(), - provider: record.provider.clone(), + model: effective_model, + provider: effective_provider, provider_env_var: runtime_meta.and_then(|meta| meta.provider_env_var), env: crate::managed_agents::merged_user_env(&derived_env, &record.env_vars), } @@ -775,6 +790,35 @@ async fn discover_databricks_models( })) } +/// Apply an `UpdateManagedAgentRequest`'s model/provider/system_prompt patch +/// to `record`, enforcing the linked-instance write guard: a definition-linked +/// record's model/provider/prompt are definition-authoritative (see +/// `effective_config::resolve_linked`), so writes to these three fields are +/// silently dropped for a linked instance rather than persisting a byte the +/// resolver will never read. Definition-less instances accept the patch +/// as-is. Extracted so the guard is exercised by both `update_managed_agent` +/// and its regression tests — a test that reimplements this check instead of +/// calling it can go green after the real guard is deleted. +fn apply_model_provider_prompt_update( + record: &mut crate::managed_agents::ManagedAgentRecord, + model: Option>, + provider: Option>, + system_prompt: Option>, +) { + if record.persona_id.is_some() { + return; + } + if let Some(model_update) = model { + record.model = model_update; + } + if let Some(provider_update) = provider { + record.provider = provider_update; + } + if let Some(prompt_update) = system_prompt { + record.system_prompt = prompt_update; + } +} + /// Update mutable fields on an existing managed agent record. /// /// Does NOT auto-restart the agent. Runtime config changes (system prompt, @@ -814,15 +858,12 @@ pub async fn update_managed_agent( name_changed = true; } } - if let Some(model_update) = input.model { - record.model = model_update; - } - if let Some(provider_update) = input.provider { - record.provider = provider_update; - } - if let Some(prompt_update) = input.system_prompt { - record.system_prompt = prompt_update; - } + apply_model_provider_prompt_update( + record, + input.model, + input.provider, + input.system_prompt, + ); if let Some(parallelism) = input.parallelism { record.parallelism = parallelism; } diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 0d6449e781..d962545bdc 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -164,7 +164,7 @@ fn redaction_env_records_value_used_for_request() { } #[test] -fn saved_agent_model_discovery_uses_record_snapshot() { +fn saved_agent_model_discovery_uses_record_snapshot_for_definition_less_agent() { let record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str( r#"{ "pubkey": "abcd1234", @@ -193,7 +193,12 @@ fn saved_agent_model_discovery_uses_record_snapshot() { ) .expect("sample managed agent record"); - let config = saved_agent_model_discovery_config(&record, "goose"); + let config = saved_agent_model_discovery_config( + &record, + "goose", + &[], + &crate::managed_agents::GlobalAgentConfig::default(), + ); assert_eq!(config.model.as_deref(), Some("record-model")); assert_eq!(config.provider.as_deref(), Some("databricks")); @@ -341,6 +346,74 @@ fn effective_discovery_provider_reads_the_runtimes_own_env_var() { ); } +/// Definition-authoritative: a linked agent's stale materialized +/// `record.model`/`record.provider` must never drive model discovery — the +/// linked definition's current model/provider wins, mirroring spawn's +/// `resolve_effective_model_provider`. +#[test] +fn saved_agent_model_discovery_ignores_stale_record_for_linked_agent() { + let record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "abcd1234", + "name": "test-agent", + "persona_id": "persona-1", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "model": "stale-record-model", + "provider": "stale-record-provider", + "env_vars": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("sample managed agent record"); + + let persona = crate::managed_agents::AgentDefinition { + id: "persona-1".to_string(), + display_name: "Persona".to_string(), + avatar_url: None, + system_prompt: "You are a persona.".to_string(), + runtime: None, + model: Some("persona-model".to_string()), + provider: Some("anthropic".to_string()), + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "".to_string(), + updated_at: "".to_string(), + }; + + let config = saved_agent_model_discovery_config( + &record, + "goose", + &[persona], + &crate::managed_agents::GlobalAgentConfig::default(), + ); + + assert_eq!(config.model.as_deref(), Some("persona-model")); + assert_eq!(config.provider.as_deref(), Some("anthropic")); + assert_eq!( + config.env.get("GOOSE_MODEL").map(String::as_str), + Some("persona-model") + ); +} + // --------------------------------------------------------------------------- // Databricks provider detection // --------------------------------------------------------------------------- @@ -381,6 +454,104 @@ fn update_request_turn_timeout_parses_for_wire_compat() { assert_eq!(req.turn_timeout_seconds, Some(9999)); } +// --------------------------------------------------------------------------- +// Linked-instance write guard (model/provider/prompt) +// --------------------------------------------------------------------------- + +#[test] +fn linked_instance_ignores_model_provider_prompt_writes() { + let mut record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "linked1", + "name": "linked-agent", + "persona_id": "p1", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "model": null, + "provider": null, + "env_vars": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("linked agent record"); + + let is_linked = record.persona_id.is_some(); + assert!(is_linked, "test setup: record must be linked"); + + crate::commands::agent_models::apply_model_provider_prompt_update( + &mut record, + Some(Some("explicit-model".to_string())), + Some(Some("explicit-prov".to_string())), + Some(Some("explicit-prompt".to_string())), + ); + + assert!( + record.model.is_none(), + "linked record model must not be updated" + ); + assert!( + record.provider.is_none(), + "linked record provider must not be updated" + ); + assert!( + record.system_prompt.is_none(), + "linked record system_prompt must not be updated" + ); +} + +#[test] +fn definition_less_instance_accepts_model_provider_prompt_writes() { + let mut record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "standalone1", + "name": "standalone-agent", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "model": null, + "provider": null, + "env_vars": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("standalone agent record"); + + let is_linked = record.persona_id.is_some(); + assert!(!is_linked, "test setup: record must not be linked"); + + crate::commands::agent_models::apply_model_provider_prompt_update( + &mut record, + Some(Some("new-model".to_string())), + Some(Some("new-prov".to_string())), + Some(Some("new-prompt".to_string())), + ); + + assert_eq!(record.model.as_deref(), Some("new-model")); + assert_eq!(record.provider.as_deref(), Some("new-prov")); + assert_eq!(record.system_prompt.as_deref(), Some("new-prompt")); +} + #[test] fn is_databricks_provider_matches_both_variants() { assert!(is_databricks_provider(Some("databricks"))); diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index c65900b5de..61e4034b9f 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -293,16 +293,16 @@ fn resolve_created_avatar_url( #[cfg(feature = "mesh-llm")] async fn ensure_relay_mesh_for_record( app: &AppHandle, - record: &ManagedAgentRecord, + model_id: Option<&str>, allow_fresh_create_start: bool, ) -> Result<(), String> { - crate::commands::ensure_relay_mesh_for_record(app, record, allow_fresh_create_start).await + crate::commands::ensure_relay_mesh_for_record(app, model_id, allow_fresh_create_start).await } #[cfg(not(feature = "mesh-llm"))] async fn ensure_relay_mesh_for_record( _app: &AppHandle, - _record: &ManagedAgentRecord, + _model_id: Option<&str>, _allow_fresh_create_start: bool, ) -> Result<(), String> { Ok(()) @@ -327,7 +327,16 @@ pub(super) async fn start_local_agent_pairs_with_preflight( if record_snapshot.backend != BackendKind::Local { return Err(format!("agent {pubkey} is not a local agent")); } - ensure_relay_mesh_for_record(app, &record_snapshot, false).await?; + let personas_for_preflight = load_personas(app).unwrap_or_default(); + let global_for_preflight = + crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let mesh_model_id = + crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( + &record_snapshot, + &personas_for_preflight, + &global_for_preflight, + ); + ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), false).await?; { let _store_guard = state @@ -407,7 +416,22 @@ pub(super) async fn start_local_agent_with_preflight( return Err(format!("agent {pubkey} is not a local agent")); } - ensure_relay_mesh_for_record(app, &record_snapshot, allow_fresh_create_start).await?; + // Preflight against the same resolution spawn uses — `resolve_effective_config` + // (definition → global fallback). A linked instance's own `provider`/`model`/ + // `relay_mesh` bytes never contribute: this reads the CURRENT definition + // directly, so a definition edit that flips `provider` to/from relay-mesh + // between saves is reflected here without needing a prospective re-snapshot; + // for a global-inherited blank definition, it also folds in the global + // default, which record-byte sniffing could never see. + let personas = load_personas(app).unwrap_or_default(); + let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let mesh_model_id = + crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( + &record_snapshot, + &personas, + &global, + ); + ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?; let _store_guard = state .managed_agents_store_lock @@ -431,9 +455,16 @@ pub(super) async fn start_local_agent_with_preflight( // at the end — avoids a second disk read for the same file in the same call. let personas = load_personas(app).unwrap_or_default(); if let Some(persona_id) = record.persona_id.clone() { - if let Some(persona) = personas.iter().find(|p| p.id == persona_id) { - crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); - record.updated_at = crate::util::now_iso(); + match personas.iter().find(|p| p.id == persona_id) { + Some(persona) => { + crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); + record.updated_at = crate::util::now_iso(); + } + None => { + return Err( + crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(), + ); + } } } start_managed_agent_process(app, record, &mut runtimes, Some(owner_hex))?; diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index be132225ea..af785711d5 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -4,42 +4,36 @@ use tauri::AppHandle; +#[cfg(test)] +use crate::managed_agents::AgentDefinition; use crate::{ app_state::AppState, - managed_agents::{load_personas, AgentDefinition, ManagedAgentRecord}, + managed_agents::{load_personas, ManagedAgentRecord}, relay::relay_ws_url_with_override, }; /// Resolve the deploy-specific structured model/provider for a managed agent. /// -/// Deploy uses **live-persona-first** precedence so remote agents receive -/// current config after a persona update, without requiring delete+recreate. -/// Unlike local spawn (which re-snapshots the persona onto `record` at the -/// start of every spawn), provider start does not re-snapshot — so the -/// record may hold a stale snapshot while the linked persona has moved on. +/// Delegates to the single effective-config resolver which enforces +/// definition-authoritative semantics for linked instances: +/// - **Linked:** definition → global. Stale record bytes are never consulted. +/// - **Definition-less:** instance → global. +/// - **Orphaned:** returns `(None, None)` — spawn is blocked elsewhere. /// -/// Precedence: live-persona → record (snapshot fallback) → global. -/// Symmetric for both model and provider. +/// Both local spawn and deploy now use the same resolver, so they can never +/// disagree on what model/provider an agent runs with. /// /// Exported `pub(crate)` for unit testing. -pub(crate) fn resolve_deploy_model_provider<'a>( - record: &'a ManagedAgentRecord, - personas: &'a [AgentDefinition], - global: &'a crate::managed_agents::GlobalAgentConfig, -) -> (Option<&'a str>, Option<&'a str>) { - let live_persona = record - .persona_id - .as_deref() - .and_then(|pid| personas.iter().find(|p| p.id == pid)); - let model = live_persona - .and_then(|p| p.model.as_deref()) - .or(record.model.as_deref()) - .or(global.model.as_deref()); - let provider = live_persona - .and_then(|p| p.provider.as_deref()) - .or(record.provider.as_deref()) - .or(global.provider.as_deref()); - (model, provider) +#[cfg(test)] +pub(crate) fn resolve_deploy_model_provider( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + global: &crate::managed_agents::GlobalAgentConfig, +) -> (Option, Option) { + crate::managed_agents::effective_config::resolve_effective_model_provider_pair( + record, personas, global, + ) + .unwrap_or((None, None)) } /// Build the standard agent JSON payload for provider deploy calls. @@ -84,15 +78,16 @@ pub(super) fn build_deploy_payload( let merged_env = crate::managed_agents::merged_user_env(&global_persona_merged, &record.env_vars); - // Resolve the deploy-specific structured provider/model. Uses the deploy - // resolver with live-persona → record → global precedence. let personas = load_personas(app).unwrap_or_default(); - let (effective_model, effective_provider) = - resolve_deploy_model_provider(record, &personas, &global_config); - let (effective_model, effective_provider) = ( - effective_model.map(str::to_string), - effective_provider.map(str::to_string), - ); + let cfg = crate::managed_agents::effective_config::resolve_effective_config( + record, + &personas, + &global_config, + ) + .require_resolved()?; + let effective_model = cfg.model.value; + let effective_provider = cfg.provider.value; + let effective_prompt = cfg.system_prompt.value; Ok(deploy_payload_json( record, @@ -102,6 +97,7 @@ pub(super) fn build_deploy_payload( ), effective_model, effective_provider, + effective_prompt, merged_env, )) } @@ -114,36 +110,25 @@ pub(super) fn deploy_payload_json( relay_url: String, effective_model: Option, effective_provider: Option, + effective_prompt: Option, merged_env: std::collections::BTreeMap, ) -> serde_json::Value { serde_json::json!({ "name": &record.name, - // Resolve the per-agent pin against the active workspace relay here: - // this payload crosses the host boundary to a remote provider harness - // that has no notion of the desktop's workspace, so the blank→workspace - // fallback (otherwise applied at read-time in `effective_agent_relay_url`) - // must be materialized into a concrete URL before serializing. "relay_url": relay_url, "private_key_nsec": &record.private_key_nsec, "auth_tag": &record.auth_tag, "agent_command": &record.agent_command, "agent_args": &record.agent_args, - "system_prompt": &record.system_prompt, + "system_prompt": effective_prompt, "model": effective_model, - // Structured provider from the persona record. Providers that don't - // yet read this field will fall back to env_vars or their own default - // — no protocol break. "provider": effective_provider, "turn_timeout_seconds": record.turn_timeout_seconds, "idle_timeout_seconds": record.idle_timeout_seconds, "max_turn_duration_seconds": record.max_turn_duration_seconds, "parallelism": record.parallelism, - // Inbound author gate. Providers that don't yet read these fall back - // to the harness default (`owner-only`) — no protocol break. "respond_to": record.respond_to, "respond_to_allowlist": &record.respond_to_allowlist, - // Merged persona + agent env vars. Providers that don't read this - // field will simply ignore it — no protocol break. "env_vars": merged_env, }) } diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 1b2e0ed179..e32fc1cfe4 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -126,57 +126,56 @@ fn build_agent_archive_request_attaches_owner_auth_and_retired_reason() { })); } -/// Deploy-path regression for Fix 1 of Thufir pass-2: a persona-linked -/// provider agent with a stale record snapshot must use the live persona -/// model/provider in the deploy payload, not the stale record values. -/// -/// Scenario: agent was created with persona at model="old-model"/provider="old-prov". -/// The persona was subsequently updated to "new-model"/"new-prov" but the record -/// was NOT re-snapshotted (provider start skips re-snapshot; local spawn does it). -/// The deploy resolver must use the current persona values. -/// -/// Fails against `resolve_effective_model_provider` (record-first precedence), -/// which would return "old-model"/"old-prov" from the stale record. +/// Deploy resolver uses definition model/provider, ignoring stale record. #[test] -fn deploy_resolver_uses_live_persona_over_stale_record_snapshot() { - // Record holds the stale snapshot (created when persona had old values). +fn deploy_resolver_uses_definition_over_stale_record() { let record = bare_agent_record(Some("p1"), Some("old-model"), Some("old-prov")); - // Live persona has been updated since the record was snapshotted. let personas = vec![persona_record("p1", Some("new-model"), Some("new-prov"))]; let global = crate::managed_agents::GlobalAgentConfig::default(); let (model, provider) = resolve_deploy_model_provider(&record, &personas, &global); assert_eq!( - model, + model.as_deref(), Some("new-model"), - "deploy must use live persona model, not stale record snapshot" + "deploy must use definition model, not stale record snapshot" ); assert_eq!( - provider, + provider.as_deref(), Some("new-prov"), - "deploy must use live persona provider, not stale record snapshot" + "deploy must use definition provider, not stale record snapshot" ); } -/// Deploy resolver falls back to record when persona has no model/provider -/// (persona without structured model — fallback to record snapshot). +/// When a linked definition has blank model/provider (inherit), the deploy +/// resolver must fall through to global — stale record bytes are inert. #[test] -fn deploy_resolver_falls_back_to_record_when_persona_has_none() { - let record = bare_agent_record(Some("p1"), Some("record-model"), Some("record-prov")); - // Persona exists but has no model/provider. +fn deploy_resolver_inherits_global_when_definition_blank() { + let record = bare_agent_record(Some("p1"), Some("stale-model"), Some("stale-prov")); let personas = vec![persona_record("p1", None, None)]; - let global = crate::managed_agents::GlobalAgentConfig::default(); + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-prov".to_string()), + ..Default::default() + }; let (model, provider) = resolve_deploy_model_provider(&record, &personas, &global); - assert_eq!(model, Some("record-model")); - assert_eq!(provider, Some("record-prov")); + assert_eq!( + model.as_deref(), + Some("global-model"), + "definition blank → global; stale record ignored" + ); + assert_eq!( + provider.as_deref(), + Some("global-prov"), + "definition blank → global; stale record ignored" + ); } -/// Deploy resolver falls back to global when both persona and record have none. +/// Deploy resolver falls back to global when both definition and record have none. #[test] -fn deploy_resolver_falls_back_to_global_when_persona_and_record_have_none() { +fn deploy_resolver_falls_back_to_global_when_definition_and_record_have_none() { let record = bare_agent_record(Some("p1"), None, None); let personas = vec![persona_record("p1", None, None)]; let global = crate::managed_agents::GlobalAgentConfig { @@ -187,8 +186,35 @@ fn deploy_resolver_falls_back_to_global_when_persona_and_record_have_none() { let (model, provider) = resolve_deploy_model_provider(&record, &personas, &global); - assert_eq!(model, Some("global-model")); - assert_eq!(provider, Some("global-prov")); + assert_eq!(model.as_deref(), Some("global-model")); + assert_eq!(provider.as_deref(), Some("global-prov")); +} + +/// Orphan: linked record with missing definition → the pure model/provider +/// pair resolver returns `(None, None)`. This is NOT the deploy refusal +/// boundary — `build_deploy_payload` refuses an orphan outright via +/// `.require_resolved()?` before this pair is ever computed. This test pins +/// the resolver's own orphan behavior, which readiness/hash also depend on. +#[test] +fn deploy_resolver_returns_none_for_orphaned_instance() { + let record = bare_agent_record(Some("missing-def"), Some("stale-model"), Some("stale-prov")); + let personas: Vec = vec![]; + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-prov".to_string()), + ..Default::default() + }; + + let (model, provider) = resolve_deploy_model_provider(&record, &personas, &global); + + assert!( + model.is_none(), + "orphaned instance must not resolve to any model" + ); + assert!( + provider.is_none(), + "orphaned instance must not resolve to any provider" + ); } #[test] @@ -404,6 +430,7 @@ fn deploy_payload_carries_the_full_behavioral_quad() { "wss://relay.example".to_string(), Some("gpt-x".to_string()), Some("openai".to_string()), + None, std::collections::BTreeMap::new(), ); diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index adcdeb7ec1..87af9fa08f 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -481,11 +481,11 @@ fn pick_serve_target_for_model( /// Non relay-mesh records are a no-op. pub(crate) async fn ensure_relay_mesh_for_record( app: &AppHandle, - record: &crate::managed_agents::ManagedAgentRecord, + model_id: Option<&str>, _allow_fresh_create_start: bool, ) -> Result<(), String> { let state = app.state::(); - let Some(model_id) = crate::managed_agents::relay_mesh_model_id(record) else { + let Some(model_id) = model_id else { return Ok(()); }; // A local serve/client runtime already owns the OpenAI ingress and its @@ -505,7 +505,7 @@ pub(crate) async fn ensure_relay_mesh_for_record( .await { mesh_llm::MeshRuntimeRecovery::Live => { - return wait_for_mesh_inference(&model_id).await; + return wait_for_mesh_inference(model_id).await; } mesh_llm::MeshRuntimeRecovery::Evicted | mesh_llm::MeshRuntimeRecovery::Absent => {} mesh_llm::MeshRuntimeRecovery::Debouncing => { @@ -521,7 +521,7 @@ pub(crate) async fn ensure_relay_mesh_for_record( ); } mesh_llm::MeshRuntimeRecovery::Replaced => { - return wait_for_mesh_inference(&model_id).await; + return wait_for_mesh_inference(model_id).await; } mesh_llm::MeshRuntimeRecovery::RestartRequired => { app.request_restart(); @@ -532,7 +532,7 @@ pub(crate) async fn ensure_relay_mesh_for_record( } } } - let target = match resolve_mesh_bootstrap_target(&state, &model_id).await { + let target = match resolve_mesh_bootstrap_target(&state, model_id).await { Ok(Some(target)) => target, Ok(None) => { return Err( @@ -556,8 +556,8 @@ pub(crate) async fn ensure_relay_mesh_for_record( // (the router resolves per-request), so it only cold-starts a Client when // there is genuinely no runtime. Falling back to Client if a serve node // crashed under local pressure is a desirable fail-safe, not a regression. - ensure_client_node_for_model(&state, &model_id, Some(target.endpoint_addr)).await?; - wait_for_mesh_inference(&model_id).await + ensure_client_node_for_model(&state, model_id, Some(target.endpoint_addr)).await?; + wait_for_mesh_inference(model_id).await } #[tauri::command] diff --git a/desktop/src-tauri/src/managed_agents/effective_config/mod.rs b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs new file mode 100644 index 0000000000..e079c76a1d --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs @@ -0,0 +1,318 @@ +use std::collections::BTreeMap; + +use serde::Serialize; + +use super::global_config::GlobalAgentConfig; +use super::relay_mesh::{ + RELAY_MESH_API_BASE_URL, RELAY_MESH_API_KEY_PLACEHOLDER, RELAY_MESH_AUTO_MODEL_ID, + RELAY_MESH_PROVIDER_ID, +}; +use super::types::{AgentDefinition, ManagedAgentRecord}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConfigSource { + Definition, + Global, + InstanceLegacy, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedField { + pub value: Option, + pub source: ConfigSource, +} + +#[derive(Debug, Clone)] +pub struct EffectiveAgentConfig { + pub model: ResolvedField, + pub provider: ResolvedField, + pub system_prompt: ResolvedField, +} + +impl EffectiveAgentConfig { + /// The relay-mesh model id this config resolves to, or `None` when the + /// effective provider isn't relay-mesh. + /// + /// This is the single authoritative mesh decision for this config. Both + /// the mesh preflight (interactive start, restore-on-launch) AND spawn's + /// `apply_relay_mesh_env` block MUST derive their mesh gate from this + /// method — never from a separate provider comparison — so the two paths + /// are guaranteed to agree even when the stored provider string has leading + /// or trailing whitespace. The provider is trimmed before matching; + /// a blank effective model falls back to "auto", mirroring + /// `apply_relay_mesh_env`'s own rule. + pub fn relay_mesh_model_id(&self) -> Option { + if self.provider.value.as_deref().map(str::trim) != Some(RELAY_MESH_PROVIDER_ID) { + return None; + } + Some( + self.model + .value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) + .to_string(), + ) + } +} + +#[derive(Debug, Clone)] +pub enum EffectiveConfigResult { + Resolved(EffectiveAgentConfig), + OrphanedInstance { + record_pubkey: String, + missing_persona_id: String, + }, +} + +fn non_blank(v: Option<&str>) -> Option<&str> { + v.filter(|s| !s.trim().is_empty()) +} + +fn resolve_linked( + definition: &AgentDefinition, + global: &GlobalAgentConfig, +) -> EffectiveAgentConfig { + let model = match non_blank(definition.model.as_deref()) { + Some(m) => ResolvedField { + value: Some(m.to_owned()), + source: ConfigSource::Definition, + }, + None => ResolvedField { + value: global.model.clone(), + source: ConfigSource::Global, + }, + }; + + let provider = match non_blank(definition.provider.as_deref()) { + Some(p) => ResolvedField { + value: Some(p.to_owned()), + source: ConfigSource::Definition, + }, + None => ResolvedField { + value: global.provider.clone(), + source: ConfigSource::Global, + }, + }; + + let system_prompt = ResolvedField { + value: non_blank(Some(definition.system_prompt.as_str())).map(str::to_owned), + source: ConfigSource::Definition, + }; + + EffectiveAgentConfig { + model, + provider, + system_prompt, + } +} + +/// The API key value the relay-mesh preset wrote before the Jun-11 rename +/// window (#960, `8f580f308`) changed `RELAY_MESH_API_KEY_PLACEHOLDER` from +/// `"sprout-mesh-local"` to its current value. The old string is persisted as a +/// *value* in `env_vars` on records created before that, and no migration ever +/// rewrote it. +const LEGACY_MESH_API_KEY_PLACEHOLDER: &str = "sprout-mesh-local"; + +/// The provider key the preset wrote before #971 (`8c8312932`) renamed it to +/// `BUZZ_AGENT_PROVIDER`. That commit changed source literals only — persisted +/// `env_vars` keys were never migrated. +const LEGACY_MESH_PROVIDER_ENV_KEY: &str = "SPROUT_AGENT_PROVIDER"; + +/// The legacy env discriminator: recognizes the relay-mesh preset purely from +/// the env vars a pre-typed-field record carries, returning its served model id. +/// +/// All three sentinels must match — the local base URL alone is not enough, +/// since a user may point their own OpenAI-compatible provider at the same +/// port. The placeholder API key is what makes this Buzz's own preset. +/// +/// Two of those sentinels were renamed in the same Jun-11 window, in separate +/// commits, with neither migrating persisted records: the provider env *key* +/// (#971) and the API key *value* (#960). Each is therefore accepted under +/// either spelling, independently — a record straddling the window carries one +/// old and one new. In both cases the current spelling is authoritative when +/// present, so a record that has since been rewritten with a non-mesh value is +/// not resurrected by the stale leftover beside it. +/// +/// `OPENAI_COMPAT_BASE_URL` and `OPENAI_COMPAT_MODEL` were never renamed. +/// Nothing beyond these two either/ors is loosened: every sentinel dropped +/// widens the false-positive surface for a user's own openai-compatible agent. +fn mesh_preset_env_model_id(env_vars: &BTreeMap) -> Option { + let base_url = env_vars.get("OPENAI_COMPAT_BASE_URL")?.trim(); + if base_url.trim_end_matches('/') != RELAY_MESH_API_BASE_URL { + return None; + } + let provider = env_vars + .get("BUZZ_AGENT_PROVIDER") + .or_else(|| env_vars.get(LEGACY_MESH_PROVIDER_ENV_KEY))? + .trim(); + if provider != "openai" { + return None; + } + let api_key = env_vars.get("OPENAI_COMPAT_API_KEY")?.trim(); + if api_key != RELAY_MESH_API_KEY_PLACEHOLDER && api_key != LEGACY_MESH_API_KEY_PLACEHOLDER { + return None; + } + non_blank(env_vars.get("OPENAI_COMPAT_MODEL").map(String::as_str)).map(str::to_owned) +} + +/// The mesh model id a definition-less record carries in legacy form, or `None` +/// when it is not a legacy mesh record. +/// +/// Two shipped record generations predate `provider: "relay-mesh"` and are +/// never rewritten on load, so they are still on disk: +/// +/// - the typed `relay_mesh` marker, added before the record had a `provider` +/// field at all; +/// - before that, the mesh preset written straight into `env_vars`. +/// +/// Consulted only by [`resolve_definition_less`]: a record with no definition +/// has nothing authoritative to be overridden by, so its own bytes are the +/// highest-precedence signal it has. A linked instance never reaches here. +fn legacy_record_mesh_model_id(record: &ManagedAgentRecord) -> Option { + match &record.relay_mesh { + // The marker itself is the mesh signal; a blank `model_ref` still means + // mesh, resolved to the auto model exactly as `apply_relay_mesh_env` + // and `relay_mesh_model_id` treat a blank model. + Some(config) => Some( + non_blank(Some(config.model_ref.as_str())) + .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) + .to_owned(), + ), + None => mesh_preset_env_model_id(&record.env_vars), + } +} + +fn resolve_definition_less( + record: &ManagedAgentRecord, + global: &GlobalAgentConfig, +) -> EffectiveAgentConfig { + let model = match non_blank(record.model.as_deref()) { + Some(m) => ResolvedField { + value: Some(m.to_owned()), + source: ConfigSource::InstanceLegacy, + }, + None => ResolvedField { + value: global.model.clone(), + source: ConfigSource::Global, + }, + }; + + let provider = match non_blank(record.provider.as_deref()) { + Some(p) => ResolvedField { + value: Some(p.to_owned()), + source: ConfigSource::InstanceLegacy, + }, + None => ResolvedField { + value: global.provider.clone(), + source: ConfigSource::Global, + }, + }; + + let system_prompt = ResolvedField { + value: non_blank(record.system_prompt.as_deref()).map(str::to_owned), + source: ConfigSource::InstanceLegacy, + }; + + let mut config = EffectiveAgentConfig { + model, + provider, + system_prompt, + }; + + // Legacy mesh compatibility. A record with an explicit `provider` has + // already stated its intent — including switching AWAY from mesh, which + // leaves the old marker and env bytes behind — so its legacy bytes are + // never consulted. Only a record that never carried a provider at all + // falls back, and both fields move together so the single mesh gate + // (`relay_mesh_model_id`) and the spawned model agree. + if non_blank(record.provider.as_deref()).is_none() { + if let Some(model_ref) = legacy_record_mesh_model_id(record) { + config.provider = ResolvedField { + value: Some(RELAY_MESH_PROVIDER_ID.to_owned()), + source: ConfigSource::InstanceLegacy, + }; + config.model = ResolvedField { + value: Some(model_ref), + source: ConfigSource::InstanceLegacy, + }; + } + } + + config +} + +pub fn resolve_effective_config( + record: &ManagedAgentRecord, + definitions: &[AgentDefinition], + global: &GlobalAgentConfig, +) -> EffectiveConfigResult { + match &record.persona_id { + Some(pid) => match definitions.iter().find(|d| d.id == *pid) { + Some(def) => EffectiveConfigResult::Resolved(resolve_linked(def, global)), + None => EffectiveConfigResult::OrphanedInstance { + record_pubkey: record.pubkey.clone(), + missing_persona_id: pid.clone(), + }, + }, + None => EffectiveConfigResult::Resolved(resolve_definition_less(record, global)), + } +} + +pub fn resolve_effective_model_provider_pair( + record: &ManagedAgentRecord, + definitions: &[AgentDefinition], + global: &GlobalAgentConfig, +) -> Option<(Option, Option)> { + match resolve_effective_config(record, definitions, global) { + EffectiveConfigResult::Resolved(cfg) => Some((cfg.model.value, cfg.provider.value)), + EffectiveConfigResult::OrphanedInstance { .. } => None, + } +} + +/// The relay-mesh preflight decision for `record`, resolved the same way +/// spawn resolves its mesh env: through `resolve_effective_config` (which +/// folds in the definition → global fallback). A linked instance's own +/// `provider`/`model`/`relay_mesh` bytes never contribute; a definition-less +/// legacy record may fall back to them via `legacy_record_mesh_model_id`, +/// which is confined to `resolve_definition_less`. +/// +/// `None` covers both "not a mesh agent" and "orphaned instance" — an orphan +/// never spawns (see `require_resolved`), so it never needs a mesh preflight +/// either; the caller's own orphan handling downstream is unaffected, this +/// just avoids tripping mesh bootstrap for a start that will be refused. +pub fn resolve_effective_relay_mesh_model_id( + record: &ManagedAgentRecord, + definitions: &[AgentDefinition], + global: &GlobalAgentConfig, +) -> Option { + match resolve_effective_config(record, definitions, global) { + EffectiveConfigResult::Resolved(cfg) => cfg.relay_mesh_model_id(), + EffectiveConfigResult::OrphanedInstance { .. } => None, + } +} + +/// The single user-facing message for a linked instance whose definition no +/// longer exists. Shared by every path that must refuse to act on an orphan: +/// the spawn boundary (`spawn_agent_child`), the interactive start command, +/// and provider deploy. +pub const ORPHANED_INSTANCE_ERROR: &str = + "This agent's configuration is missing — it may still be \ + syncing or was deleted on another device."; + +impl EffectiveConfigResult { + /// Unwrap into the resolved config, or the shared orphan-refusal error. + pub fn require_resolved(self) -> Result { + match self { + EffectiveConfigResult::Resolved(cfg) => Ok(cfg), + EffectiveConfigResult::OrphanedInstance { .. } => { + Err(ORPHANED_INSTANCE_ERROR.to_string()) + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs new file mode 100644 index 0000000000..81c2611d5c --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -0,0 +1,872 @@ +use super::*; +use std::collections::BTreeMap; + +fn definition( + id: &str, + model: Option<&str>, + provider: Option<&str>, + prompt: &str, +) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: "Test Definition".to_string(), + avatar_url: None, + system_prompt: prompt.to_string(), + runtime: None, + model: model.map(str::to_string), + provider: provider.map(str::to_string), + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: "".to_string(), + updated_at: "".to_string(), + } +} + +fn record( + persona_id: Option<&str>, + model: Option<&str>, + provider: Option<&str>, + prompt: Option<&str>, +) -> ManagedAgentRecord { + use crate::managed_agents::{BackendKind, RespondTo}; + ManagedAgentRecord { + pubkey: "agent-pk".to_string(), + name: "Agent".to_string(), + persona_id: persona_id.map(str::to_string), + private_key_nsec: "".to_string(), + auth_tag: None, + relay_url: "ws://localhost:3000".to_string(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_command_override: None, + agent_args: vec![], + mcp_command: "".to_string(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: prompt.map(str::to_string), + model: model.map(str::to_string), + provider: provider.map(str::to_string), + persona_source_version: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "".to_string(), + updated_at: "".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + relay_mesh: None, + auto_restart_on_config_change: false, + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + } +} + +fn global(model: Option<&str>, provider: Option<&str>) -> GlobalAgentConfig { + GlobalAgentConfig { + model: model.map(str::to_string), + provider: provider.map(str::to_string), + ..Default::default() + } +} + +// ── Linked instance: definition → global, record ignored ── + +#[test] +fn linked_definition_model_wins_over_stale_record() { + let rec = record( + Some("d1"), + Some("stale-model"), + Some("stale-prov"), + Some("stale prompt"), + ); + let defs = vec![definition( + "d1", + Some("def-model"), + Some("def-prov"), + "def prompt", + )]; + let g = global(Some("global-model"), Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("def-model")); + assert_eq!(cfg.model.source, ConfigSource::Definition); + assert_eq!(cfg.provider.value.as_deref(), Some("def-prov")); + assert_eq!(cfg.provider.source, ConfigSource::Definition); + assert_eq!(cfg.system_prompt.value.as_deref(), Some("def prompt")); + assert_eq!(cfg.system_prompt.source, ConfigSource::Definition); +} + +#[test] +fn linked_inherit_global_when_definition_blank() { + let rec = record( + Some("d1"), + Some("stale-model"), + Some("stale-prov"), + Some("stale prompt"), + ); + let defs = vec![definition("d1", None, None, "")]; + let g = global(Some("global-model"), Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("global-model")); + assert_eq!(cfg.model.source, ConfigSource::Global); + assert_eq!(cfg.provider.value.as_deref(), Some("global-prov")); + assert_eq!(cfg.provider.source, ConfigSource::Global); + assert_eq!(cfg.system_prompt.value, None); + assert_eq!(cfg.system_prompt.source, ConfigSource::Definition); +} + +#[test] +fn linked_stale_record_model_is_inert() { + let rec = record(Some("d1"), Some("stale-model"), Some("stale-prov"), None); + let defs = vec![definition("d1", None, None, "")]; + let g = global(None, None); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value, None); + assert_eq!(cfg.model.source, ConfigSource::Global); + assert_eq!(cfg.provider.value, None); + assert_eq!(cfg.provider.source, ConfigSource::Global); +} + +#[test] +fn linked_definition_model_set_provider_inherits() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("def-model"), None, "prompt")]; + let g = global(None, Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("def-model")); + assert_eq!(cfg.model.source, ConfigSource::Definition); + assert_eq!(cfg.provider.value.as_deref(), Some("global-prov")); + assert_eq!(cfg.provider.source, ConfigSource::Global); +} + +#[test] +fn linked_blank_prompt_means_no_prompt() { + let rec = record(Some("d1"), None, None, Some("stale prompt on record")); + let defs = vec![definition("d1", None, None, "")]; + let g = global(None, None); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.system_prompt.value, None); + assert_eq!(cfg.system_prompt.source, ConfigSource::Definition); +} + +#[test] +fn linked_whitespace_only_definition_model_inherits_global() { + let rec = record(Some("d1"), Some("stale"), None, None); + let defs = vec![definition("d1", Some(" "), Some(" \t"), "")]; + let g = global(Some("global-model"), Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("global-model")); + assert_eq!(cfg.model.source, ConfigSource::Global); + assert_eq!(cfg.provider.value.as_deref(), Some("global-prov")); + assert_eq!(cfg.provider.source, ConfigSource::Global); +} + +// ── Orphaned instance ── + +#[test] +fn orphaned_linked_instance_returns_error() { + let rec = record(Some("missing-def"), None, None, None); + let defs = vec![]; + let g = global(Some("global-model"), None); + + let result = resolve_effective_config(&rec, &defs, &g); + match result { + EffectiveConfigResult::OrphanedInstance { + record_pubkey, + missing_persona_id, + } => { + assert_eq!(record_pubkey, "agent-pk"); + assert_eq!(missing_persona_id, "missing-def"); + } + other => panic!("expected OrphanedInstance, got {:?}", other), + } +} + +// ── Definition-less instance: instance → global ── + +#[test] +fn definition_less_uses_own_fields() { + let rec = record(None, Some("my-model"), Some("my-prov"), Some("my prompt")); + let defs = vec![]; + let g = global(Some("global-model"), Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("my-model")); + assert_eq!(cfg.model.source, ConfigSource::InstanceLegacy); + assert_eq!(cfg.provider.value.as_deref(), Some("my-prov")); + assert_eq!(cfg.provider.source, ConfigSource::InstanceLegacy); + assert_eq!(cfg.system_prompt.value.as_deref(), Some("my prompt")); + assert_eq!(cfg.system_prompt.source, ConfigSource::InstanceLegacy); +} + +#[test] +fn definition_less_falls_back_to_global() { + let rec = record(None, None, None, None); + let defs = vec![]; + let g = global(Some("global-model"), Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("global-model")); + assert_eq!(cfg.model.source, ConfigSource::Global); + assert_eq!(cfg.provider.value.as_deref(), Some("global-prov")); + assert_eq!(cfg.provider.source, ConfigSource::Global); +} + +#[test] +fn definition_less_blank_record_fields_fall_through() { + let rec = record(None, Some(" "), Some(""), Some(" ")); + let defs = vec![]; + let g = global(Some("g-model"), None); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("g-model")); + assert_eq!(cfg.model.source, ConfigSource::Global); + assert_eq!(cfg.provider.value, None); + assert_eq!(cfg.provider.source, ConfigSource::Global); + assert_eq!(cfg.system_prompt.value, None); +} + +// ── Convenience helper ── + +#[test] +fn model_provider_pair_returns_none_for_orphan() { + let rec = record(Some("missing"), None, None, None); + assert_eq!( + resolve_effective_model_provider_pair(&rec, &[], &global(None, None)), + None + ); +} + +#[test] +fn model_provider_pair_returns_resolved_values() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("m"), Some("p"), "")]; + let g = global(None, None); + + let pair = resolve_effective_model_provider_pair(&rec, &defs, &g); + assert_eq!(pair, Some((Some("m".to_string()), Some("p".to_string())))); +} + +// ── require_resolved: the shared orphan-refusal contract used by +// build_deploy_payload and spawn_agent_child ── + +#[test] +fn require_resolved_returns_shared_error_for_orphan() { + let rec = record(Some("missing"), None, None, None); + let error = resolve_effective_config(&rec, &[], &global(None, None)) + .require_resolved() + .expect_err("orphan must not resolve"); + assert_eq!(error, ORPHANED_INSTANCE_ERROR); +} + +#[test] +fn require_resolved_returns_config_for_resolved() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("m"), Some("p"), "prompt")]; + let cfg = resolve_effective_config(&rec, &defs, &global(None, None)) + .require_resolved() + .expect("linked instance with a live definition must resolve"); + assert_eq!(cfg.model.value.as_deref(), Some("m")); +} + +#[test] +fn require_resolved_refuses_orphan_only() { + let orphan = record(Some("missing"), None, None, None); + assert_eq!( + resolve_effective_config(&orphan, &[], &global(None, None)) + .require_resolved() + .unwrap_err(), + ORPHANED_INSTANCE_ERROR, + ); + + let linked = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("m"), None, "")]; + assert!( + resolve_effective_config(&linked, &defs, &global(None, None)) + .require_resolved() + .is_ok() + ); + + // Definition-less instances are never orphaned regardless of how bare + // their own fields are — orphan status only applies to a dangling link. + let bare = record(None, None, None, None); + assert!(resolve_effective_config(&bare, &[], &global(None, None)) + .require_resolved() + .is_ok()); +} + +// ── Morgan's exact regression sequence ── + +#[test] +fn morgans_sequence_inherit_explicit_inherit() { + let g = global(Some("claude-opus-4-6"), Some("anthropic")); + + // Step 1: fresh agent with inherited model → resolves global + let rec_step1 = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", None, None, "agent prompt")]; + let cfg1 = match resolve_effective_config(&rec_step1, &defs, &g) { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("step 1: {:?}", other), + }; + assert_eq!(cfg1.model.value.as_deref(), Some("claude-opus-4-6")); + assert_eq!(cfg1.model.source, ConfigSource::Global); + + // Step 2: set explicit model on definition + let defs_explicit = vec![definition( + "d1", + Some("goose-gpt-5-6-sol"), + Some("databricks"), + "agent prompt", + )]; + let cfg2 = match resolve_effective_config(&rec_step1, &defs_explicit, &g) { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("step 2: {:?}", other), + }; + assert_eq!(cfg2.model.value.as_deref(), Some("goose-gpt-5-6-sol")); + assert_eq!(cfg2.model.source, ConfigSource::Definition); + + // Step 3: switch back to inherit — even with stale record bytes + let rec_stale = record( + Some("d1"), + Some("goose-gpt-5-6-sol"), + Some("databricks"), + None, + ); + let defs_inherit = vec![definition("d1", None, None, "agent prompt")]; + let cfg3 = match resolve_effective_config(&rec_stale, &defs_inherit, &g) { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("step 3: {:?}", other), + }; + assert_eq!(cfg3.model.value.as_deref(), Some("claude-opus-4-6")); + assert_eq!(cfg3.model.source, ConfigSource::Global); + assert_eq!(cfg3.provider.value.as_deref(), Some("anthropic")); + assert_eq!(cfg3.provider.source, ConfigSource::Global); +} + +// ── relay-mesh preflight resolution (Wes review 2 on #1968) ── +// +// Both regressions below reproduce the exact defects: the preflight must +// key off `resolve_effective_config`'s resolution — the same one spawn's +// mesh env consults — not the record's own `provider`/`model` bytes. + +#[test] +fn relay_mesh_model_id_none_for_non_mesh_config() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("m"), Some("anthropic"), "")]; + let g = global(None, None); + + assert_eq!(resolve_effective_relay_mesh_model_id(&rec, &defs, &g), None); +} + +#[test] +fn relay_mesh_model_id_defaults_to_auto_when_model_blank() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", None, Some(RELAY_MESH_PROVIDER_ID), "")]; + let g = global(None, None); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &defs, &g).as_deref(), + Some(RELAY_MESH_AUTO_MODEL_ID) + ); +} + +/// Switch-away regression (Wes finding 1): a linked definition that used to +/// be relay-mesh but was edited to another provider must NOT trigger the mesh +/// preflight — even though the record's own stale bytes still say +/// `provider: relay-mesh`. The old `relay_mesh_config(record)` sniff read +/// those stale record bytes directly and returned Some; the resolver-driven +/// decision must read the definition's CURRENT provider and return None. +#[test] +fn switch_away_from_relay_mesh_clears_preflight_despite_stale_record_bytes() { + let rec = record(Some("d1"), Some("auto"), Some(RELAY_MESH_PROVIDER_ID), None); + let defs = vec![definition( + "d1", + Some("claude-opus-4-6"), + Some("anthropic"), + "", + )]; + let g = global(None, None); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &defs, &g), + None, + "definition switched away from relay-mesh — no mesh preflight should fire" + ); +} + +/// Global-inheritance regression (Wes finding 2): a blank linked definition +/// falling through to a relay-mesh GLOBAL default must trigger the mesh +/// preflight, even though the record carries `provider: None` and no legacy +/// env — all three of the old `relay_mesh_config` branches would miss here. +#[test] +fn global_relay_mesh_default_triggers_preflight_for_blank_definition() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", None, None, "")]; + let g = global(Some("Qwen3"), Some(RELAY_MESH_PROVIDER_ID)); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &defs, &g).as_deref(), + Some("Qwen3"), + "global relay-mesh default must trigger the mesh preflight for a blank definition" + ); +} + +/// Symmetric case: a definition-less (legacy) instance inheriting the global +/// relay-mesh default must also trigger the preflight. +#[test] +fn global_relay_mesh_default_triggers_preflight_for_definition_less_instance() { + let rec = record(None, None, None, None); + let defs = vec![]; + let g = global(None, Some(RELAY_MESH_PROVIDER_ID)); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &defs, &g).as_deref(), + Some(RELAY_MESH_AUTO_MODEL_ID) + ); +} + +/// Orphaned instance: no mesh preflight regardless of stale record bytes — +/// the caller's own orphan refusal is what matters, not a mesh bootstrap for +/// a start that will never happen. +#[test] +fn orphaned_instance_never_triggers_mesh_preflight() { + let rec = record( + Some("missing-def"), + Some("auto"), + Some(RELAY_MESH_PROVIDER_ID), + None, + ); + let g = global(None, None); + + assert_eq!(resolve_effective_relay_mesh_model_id(&rec, &[], &g), None); +} + +// ── Whitespace-provider parity (trim-mismatch regression, T1) ── +// +// The old spawn gate was `effective_provider.as_deref() == Some(RELAY_MESH_PROVIDER_ID)` +// — an exact compare — while `relay_mesh_model_id()` trims before matching. +// A stored provider of " relay-mesh " would make preflight fire while spawn +// skipped the mesh-env block. After the fix spawn derives its gate from +// `relay_mesh_model_id()` too, so the trim semantics are identical. +// +// These tests verify `relay_mesh_model_id()` returns Some for padded providers +// on every resolution path, confirming the helper that both callers now use +// handles whitespace correctly. + +/// Definition provider with leading/trailing whitespace — resolver preserves +/// the raw string, but `relay_mesh_model_id()` trims before matching, so the +/// mesh preflight fires correctly. +#[test] +fn whitespace_provider_in_definition_triggers_mesh_decision() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("qwen3"), Some(" relay-mesh "), "")]; + let g = global(None, None); + + let mesh_id = resolve_effective_relay_mesh_model_id(&rec, &defs, &g); + assert_eq!( + mesh_id.as_deref(), + Some("qwen3"), + "padded definition provider must be treated as relay-mesh by the shared helper" + ); +} + +/// Global provider with leading/trailing whitespace — same assertion for the +/// global-inherit path (blank definition falls through to padded global). +#[test] +fn whitespace_provider_in_global_triggers_mesh_decision() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", None, None, "")]; + let g = global(Some("qwen3"), Some("\trelay-mesh\t")); + + let mesh_id = resolve_effective_relay_mesh_model_id(&rec, &defs, &g); + assert_eq!( + mesh_id.as_deref(), + Some("qwen3"), + "padded global provider must be treated as relay-mesh by the shared helper" + ); +} + +// ── Legacy relay-mesh record compatibility (Wes review 3 on #1968) ── +// +// Two record generations shipped before `provider: "relay-mesh"` existed and +// are never rewritten on load, so they are still on disk: +// +// Class A (#798) — the four mesh preset env vars in `env_vars`, no typed +// field, no `provider` (the record had no `provider` field yet). +// Class B (#879) — the typed `relay_mesh` marker, still no `provider` field. +// +// Without the fallback both classes resolve to a non-mesh provider while their +// own stale env bytes still reach the child: a silent misroute, not a clean +// failure. The fallback lives in `resolve_definition_less` only — a linked +// instance's definition stays authoritative. + +/// Class A: env-only legacy record resolves mesh via the four-env preset. +#[test] +fn legacy_record_falls_back_to_env_sniff() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1/".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)).as_deref(), + Some("Qwen3"), + "env-only legacy mesh record must still resolve to its served model" + ); +} + +/// Class A, oldest shipped record: both renamed sentinels in their original +/// spelling. The preset first wrote `SPROUT_AGENT_PROVIDER` (#798) and the +/// api-key value `sprout-mesh-local`; #971 and #960 renamed each in the same +/// Jun-11 window without migrating persisted `env_vars`, so this exact shape is +/// still on disk and must resolve mesh. +#[test] +fn legacy_record_falls_back_to_env_sniff_with_pre_rename_sentinels() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + "sprout-mesh-local".to_string(), + ), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)).as_deref(), + Some("Qwen3"), + "the oldest shipped mesh record carries both pre-rename sentinels and was never migrated" + ); +} + +/// Class A straddling the rename window: old provider key with the new api-key +/// value. Proves the two either/ors are independent rather than one either/or on +/// a bundled old/new pair. +#[test] +fn legacy_record_falls_back_to_env_sniff_with_mixed_rename_sentinels() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)).as_deref(), + Some("Qwen3"), + "old provider key with new api key must resolve — the sentinels renamed independently" + ); +} + +/// The mirrored straddle: new provider key with the old api-key value. +#[test] +fn legacy_record_falls_back_to_env_sniff_with_new_provider_and_old_api_key() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + "sprout-mesh-local".to_string(), + ), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)).as_deref(), + Some("Qwen3"), + "new provider key with old api key must resolve too" + ); +} + +/// Both spellings present and disagreeing: the renamed key is authoritative, +/// so a record whose current provider env is non-mesh is not resurrected as +/// mesh by the stale `SPROUT_` leftover. +#[test] +fn legacy_env_sniff_prefers_renamed_provider_key_over_stale_old_key() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "anthropic".to_string()), + ("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)), + None, + "the renamed key states current intent — a stale SPROUT_ key must not override it" + ); +} + +/// Class A negative: a user's own OpenAI-compatible provider on the same local +/// port is not Buzz's preset — the placeholder API key is the discriminator. +#[test] +fn legacy_env_sniff_ignores_user_openai_on_same_local_port() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ("OPENAI_COMPAT_API_KEY".to_string(), "real-key".to_string()), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)), + None, + "a real user key on the mesh port must not be mistaken for Buzz's preset" + ); +} + +/// Class B: typed marker with no `provider` resolves mesh, and its `model_ref` +/// wins over an unrelated global model so preflight and spawn agree. +#[test] +fn legacy_record_falls_back_to_typed_marker_without_provider() { + let mut rec = record(None, None, None, None); + rec.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { + model_ref: "Qwen3".to_string(), + }); + + let cfg = match resolve_effective_config(&rec, &[], &global(Some("gpt-5"), Some("openai"))) { + EffectiveConfigResult::Resolved(cfg) => cfg, + EffectiveConfigResult::OrphanedInstance { .. } => { + panic!("definition-less is never orphaned") + } + }; + + assert_eq!(cfg.provider.value.as_deref(), Some(RELAY_MESH_PROVIDER_ID)); + assert_eq!( + cfg.relay_mesh_model_id().as_deref(), + Some("Qwen3"), + "typed marker must resolve mesh even with no provider on the record" + ); +} + +/// Class B with a blank `model_ref`: the marker is still the mesh signal, and +/// the model resolves to auto — same rule `apply_relay_mesh_env` applies. +#[test] +fn legacy_typed_marker_with_blank_model_resolves_auto() { + let mut rec = record(None, None, None, None); + rec.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { + model_ref: " ".to_string(), + }); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)).as_deref(), + Some(RELAY_MESH_AUTO_MODEL_ID) + ); +} + +/// A definition-less record that was switched AWAY from mesh keeps its stale +/// marker and env bytes on disk. Its explicit `provider` states current intent, +/// so the legacy fallback must not resurrect mesh. +#[test] +fn definition_less_explicit_provider_wins_over_stale_legacy_mesh_bytes() { + let mut rec = record(None, Some("claude-opus-4-6"), Some("anthropic"), None); + rec.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { + model_ref: "Qwen3".to_string(), + }); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ), + ]); + + let cfg = match resolve_effective_config(&rec, &[], &global(None, None)) { + EffectiveConfigResult::Resolved(cfg) => cfg, + EffectiveConfigResult::OrphanedInstance { .. } => { + panic!("definition-less is never orphaned") + } + }; + + assert_eq!(cfg.provider.value.as_deref(), Some("anthropic")); + assert_eq!(cfg.model.value.as_deref(), Some("claude-opus-4-6")); + assert_eq!( + cfg.relay_mesh_model_id(), + None, + "a switched-away legacy record must not be dragged back onto mesh" + ); +} + +/// The test that proves the fallback did not reintroduce the bug this PR +/// deletes: a LINKED record carrying both legacy mesh signals resolves purely +/// from its definition and never trips mesh. +#[test] +fn linked_record_ignores_legacy_mesh_marker_and_env() { + let mut rec = record(Some("d1"), Some("auto"), None, None); + rec.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { + model_ref: "Qwen3".to_string(), + }); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ), + ]); + let defs = vec![definition( + "d1", + Some("claude-opus-4-6"), + Some("anthropic"), + "", + )]; + + let cfg = match resolve_effective_config(&rec, &defs, &global(None, None)) { + EffectiveConfigResult::Resolved(cfg) => cfg, + EffectiveConfigResult::OrphanedInstance { .. } => panic!("definition exists"), + }; + + assert_eq!(cfg.provider.value.as_deref(), Some("anthropic")); + assert_eq!(cfg.model.value.as_deref(), Some("claude-opus-4-6")); + assert_eq!( + cfg.relay_mesh_model_id(), + None, + "legacy record bytes must never influence a linked instance" + ); +} + +/// A linked instance whose definition inherits a blank provider from a blank +/// global must NOT pick up the record's legacy mesh bytes either — the linked +/// path has no legacy fallback at all, by construction. +#[test] +fn linked_record_with_legacy_bytes_inherits_global_not_mesh() { + let mut rec = record(Some("d1"), None, None, None); + rec.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { + model_ref: "Qwen3".to_string(), + }); + let defs = vec![definition("d1", None, None, "")]; + + let cfg = match resolve_effective_config(&rec, &defs, &global(Some("gpt-5"), Some("openai"))) { + EffectiveConfigResult::Resolved(cfg) => cfg, + EffectiveConfigResult::OrphanedInstance { .. } => panic!("definition exists"), + }; + + assert_eq!(cfg.provider.value.as_deref(), Some("openai")); + assert_eq!(cfg.model.value.as_deref(), Some("gpt-5")); + assert_eq!(cfg.relay_mesh_model_id(), None); +} diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 6318a5c838..c652dae427 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -285,8 +285,12 @@ pub(crate) fn merged_user_env( /// Look up the live env map of `persona_id` within an already-loaded persona /// slice. Returns an empty map for standalone agents (`None`) and for links -/// to personas that no longer exist (an orphaned agent spawns from its own -/// overrides alone — same fallback the prompt/model resolution uses). +/// to personas that no longer exist. The latter is an orphaned instance, +/// which `spawn_agent_child` refuses before this is ever read at spawn time +/// (see `effective_config::resolve_effective_config`'s `OrphanedInstance` +/// arm via `require_resolved`) — an empty map here only matters for +/// non-spawn callers like readiness/hash that must still resolve +/// a slice for a record whose orphan status hasn't been checked yet. pub(crate) fn live_persona_env( personas: &[super::types::AgentDefinition], persona_id: Option<&str>, diff --git a/desktop/src-tauri/src/managed_agents/global_config/mod.rs b/desktop/src-tauri/src/managed_agents/global_config/mod.rs index 57993fc040..162f447981 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/mod.rs @@ -1,13 +1,14 @@ //! Global agent configuration defaults. //! //! A single `global-agent-config.json` record that applies to ALL managed -//! agents. Per-agent config always wins; global provides the lowest -//! user-settable layer below persona. +//! agents. For a linked instance, the definition wins over global; for a +//! definition-less (legacy) instance, the record's own fields win over +//! global. Global is always the lowest user-settable layer. //! //! # Precedence (low → high) //! //! ```text -//! baked build env < GLOBAL < persona < per-agent < Buzz-identity +//! baked build env < GLOBAL < definition (linked) / instance (legacy) < Buzz-identity //! ``` //! //! # Semantics @@ -38,8 +39,10 @@ use crate::managed_agents::types::{AgentDefinition, ManagedAgentRecord}; /// so the config vocabulary is consistent across all three tiers. /// /// `env_vars` is the lowest user-settable env layer — global < persona < agent. -/// `provider` / `model` are fallback defaults: effective provider/model = -/// `agent → persona → global → None`. +/// `provider` / `model` are fallback defaults, resolved via +/// `effective_config::resolve_effective_config`: for a linked instance, +/// definition → global (the record's own `provider`/`model` bytes are never +/// consulted); for a definition-less instance, instance → global. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct GlobalAgentConfig { /// Global env vars injected into ALL agents unconditionally. @@ -204,44 +207,28 @@ pub fn save_global_agent_config(app: &AppHandle, config: &GlobalAgentConfig) -> atomic_write_json_restricted(&path, &payload) } -/// Resolve the effective model and provider for an agent using the -/// precedence chain: `agent record → linked persona → global defaults → None`. +/// Resolve the effective model and provider for an agent. /// -/// This is the single source of truth used by readiness evaluation, spawn, -/// and deploy-payload construction. All three paths must use this function so -/// they agree on what model/provider the agent will actually run with. +/// Delegates to `effective_config::resolve_effective_config` which enforces +/// definition-authoritative semantics for linked instances: +/// - **Linked:** definition → global. Record bytes are never consulted. +/// - **Definition-less:** instance → global. +/// - **Orphaned:** returns `(None, None)`. This function is a display/ +/// readiness/hash convenience, not the spawn gate — an orphan must never +/// reach a real spawn regardless of what this returns, because +/// `spawn_agent_child` refuses orphans itself via +/// `effective_config::resolve_effective_config(...).require_resolved()` +/// before resolving model/provider/prompt for the process env. /// -/// # Arguments -/// * `record` — the `ManagedAgentRecord` (may have `None` for model/provider) -/// * `personas` — all current persona records (looked up by `record.persona_id`) -/// * `global` — global agent config defaults -/// -/// # Returns -/// `(effective_model, effective_provider)` — both `Option<&str>`. -pub(crate) fn resolve_effective_model_provider<'a>( - record: &'a ManagedAgentRecord, - personas: &'a [AgentDefinition], - global: &'a GlobalAgentConfig, -) -> (Option<&'a str>, Option<&'a str>) { - let (persona_model, persona_provider) = record - .persona_id - .as_deref() - .and_then(|pid| personas.iter().find(|p| p.id == pid)) - .map(|p| (p.model.as_deref(), p.provider.as_deref())) - .unwrap_or((None, None)); - - let effective_model = record - .model - .as_deref() - .or(persona_model) - .or(global.model.as_deref()); - let effective_provider = record - .provider - .as_deref() - .or(persona_provider) - .or(global.provider.as_deref()); - - (effective_model, effective_provider) +/// Returns owned `String`s because the effective value may come from a +/// tier that outlives none of the input borrows (e.g. global config). +pub(crate) fn resolve_effective_model_provider( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + global: &GlobalAgentConfig, +) -> (Option, Option) { + super::effective_config::resolve_effective_model_provider_pair(record, personas, global) + .unwrap_or((None, None)) } #[cfg(test)] diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 6eb4338034..33b93d8a52 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -376,15 +376,14 @@ fn persona(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefini } } -/// Tier 1 — agent record wins: record has explicit model/provider; they must -/// outrank both the linked persona and the global defaults. Fails against any -/// implementation that prefers global or persona over the record. +/// Linked instance: definition (persona) wins — stale record bytes are +/// ignored. This is the core model-inheritance fix. #[test] -fn resolve_agent_record_wins_over_persona_and_global() { +fn resolve_definition_wins_over_stale_record_for_linked_instance() { let mut record = bare_record(); record.persona_id = Some("p1".to_string()); - record.model = Some("record-model".to_string()); - record.provider = Some("record-provider".to_string()); + record.model = Some("stale-record-model".to_string()); + record.provider = Some("stale-record-provider".to_string()); let personas = vec![persona( "p1", Some("persona-model"), @@ -398,11 +397,15 @@ fn resolve_agent_record_wins_over_persona_and_global() { let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); - assert_eq!(model, Some("record-model"), "record model must win"); assert_eq!( - provider, - Some("record-provider"), - "record provider must win" + model.as_deref(), + Some("persona-model"), + "definition model must win over stale record" + ); + assert_eq!( + provider.as_deref(), + Some("persona-provider"), + "definition provider must win over stale record" ); } @@ -428,12 +431,12 @@ fn resolve_persona_fallback_when_record_has_none() { let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); assert_eq!( - model, + model.as_deref(), Some("persona-model"), "persona model must be used when record has none" ); assert_eq!( - provider, + provider.as_deref(), Some("persona-provider"), "persona provider must be used when record has none" ); @@ -458,12 +461,12 @@ fn resolve_global_fallback_when_record_and_persona_have_none() { let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); assert_eq!( - model, + model.as_deref(), Some("global-model"), "global model must be used when record and persona have none" ); assert_eq!( - provider, + provider.as_deref(), Some("global-provider"), "global provider must be used when record and persona have none" ); @@ -519,8 +522,8 @@ fn resolve_global_fallback_when_no_persona_linked() { let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); - assert_eq!(model, Some("global-model")); - assert_eq!(provider, Some("global-provider")); + assert_eq!(model.as_deref(), Some("global-model")); + assert_eq!(provider.as_deref(), Some("global-provider")); } /// All-None: no source provides model/provider → both must be None. @@ -543,18 +546,15 @@ fn resolve_all_none_when_no_source_provides_values() { ); } -/// Partial tier — record has model but not provider; persona has provider but -/// not model; global has both. Each field resolves independently through the -/// three-tier chain. +/// Each field resolves independently: definition model=None → global fills +/// model; definition has provider → definition wins for provider. Stale +/// record bytes are ignored for linked instances. #[test] fn resolve_each_field_resolves_independently_through_tiers() { let mut record = bare_record(); record.persona_id = Some("p1".to_string()); - record.model = Some("record-model".to_string()); - // record.provider = None → falls through to persona + record.model = Some("stale-record-model".to_string()); let personas = vec![persona("p1", None, Some("persona-provider"))]; - // persona.model = None → global fills model if record also had none, but - // record has model here so global is not needed for model. let global = GlobalAgentConfig { model: Some("global-model".to_string()), provider: Some("global-provider".to_string()), @@ -563,11 +563,15 @@ fn resolve_each_field_resolves_independently_through_tiers() { let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); - assert_eq!(model, Some("record-model"), "record wins for model"); assert_eq!( - provider, + model.as_deref(), + Some("global-model"), + "definition model=None → global fills model; stale record ignored" + ); + assert_eq!( + provider.as_deref(), Some("persona-provider"), - "persona wins for provider when record has none" + "definition provider wins" ); } diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 66f3d882b9..d213142352 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -8,6 +8,7 @@ pub(crate) use agent_env::{ mod backend; pub(crate) mod config_bridge; mod discovery; +pub(crate) mod effective_config; mod env_vars; pub(crate) mod git_bash; pub(crate) mod global_config; diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 2033003bc8..5b62615a8c 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -359,10 +359,14 @@ pub fn persona_event_content(record: &AgentDefinition) -> PersonaEventContent { } /// A persona's spawn-relevant config, pinned onto a `ManagedAgentRecord` at -/// create time. After the snapshot, spawn and deploy read these fields off the -/// record and never the live persona, so an agent stays pinned to the config -/// it was created with — restart reuses the snapshot, delete+respawn rewrites -/// it. +/// create time for display/backward-compat purposes. For a linked instance, +/// spawn and deploy do NOT read these snapshotted fields — they resolve +/// model/provider/prompt live from the definition on every spawn (see +/// `effective_config::resolve_effective_config`), so a definition edit +/// propagates on the next restart without delete+respawn. The snapshot still +/// matters for `runtime` (materialized per B5, no live-read path yet) and for +/// `persona_source_version`, the drift basis the Agents menu compares against +/// the definition's current content hash. pub struct PersonaSnapshot { pub system_prompt: Option, pub model: Option, @@ -377,24 +381,6 @@ pub struct PersonaSnapshot { pub source_version: String, } -/// Apply persona-wins-when-set precedence for a single optional string field. -/// -/// Returns the persona's value when it is non-`None` and non-whitespace-only; -/// otherwise falls back to the record's value with the same blank filter applied. -/// Returns `None` only when both are blank — a genuinely unconfigured field stays -/// unconfigured. -/// -/// This is the single source of truth for the precedence rule used by -/// `persona_snapshot_with_agent_config_fallback`, `build_deploy_payload`, and -/// `resolve_effective_prompt_model_provider` so the three paths cannot drift. -pub fn persona_field_with_record_fallback( - persona_value: Option<&str>, - record_value: Option<&str>, -) -> Option { - let non_blank = |v: Option<&str>| v.filter(|s| !s.trim().is_empty()).map(str::to_owned); - non_blank(persona_value).or_else(|| non_blank(record_value)) -} - /// Build the pinned snapshot for an agent created from `persona`. /// /// The persona's `system_prompt` is always present, so it is wrapped in @@ -411,51 +397,14 @@ pub fn persona_snapshot(persona: &AgentDefinition) -> PersonaSnapshot { } } -/// Build the pinned snapshot for an **existing** agent record being re-snapshotted -/// from its linked persona (on spawn or app-launch restore). +/// Re-pin `record` to `persona`: build a snapshot via [`persona_snapshot`] +/// and mirror it onto the record — the definition quad +/// (`system_prompt`/`model`/`provider`/`runtime`), the env-override +/// self-heal, and the `persona_source_version` drift basis. /// -/// Precedence rule: when the persona sets `model` or `provider` (non-`None`, non-empty), -/// the persona wins — this is the expected inheritance. When the persona leaves -/// these fields blank (`None` or empty string), the agent record's own values are -/// preserved instead. This prevents a persona with no configured model/provider from -/// clobbering a value the user already set on the agent, which would trap the agent -/// in a permanent "needs configuration" loop that users cannot escape. -/// -/// `source_version` is always updated to the current persona content hash so the -/// drift badge clears correctly even when model/provider are not touched. -/// -/// Env vars are not part of the snapshot: `record.env_vars` (agent overrides) -/// is left untouched and the live persona env is merged underneath at read time. -/// -/// The two fields (`model`, `provider`) are independent: a persona that sets only -/// `model` wins on `model` while the agent's `provider` is preserved, and vice versa. -pub fn persona_snapshot_with_agent_config_fallback( - persona: &AgentDefinition, - current_agent_model: Option<&str>, - current_agent_provider: Option<&str>, -) -> PersonaSnapshot { - // Delegate system_prompt and source_version to persona_snapshot so future - // PersonaSnapshot field additions stay automatically consistent. - let base = persona_snapshot(persona); - - // Apply the shared precedence rule: persona wins when non-blank, else - // the agent record's value is preserved so a configured agent stays configured. - let model = persona_field_with_record_fallback(base.model.as_deref(), current_agent_model); - let provider = - persona_field_with_record_fallback(base.provider.as_deref(), current_agent_provider); - - PersonaSnapshot { - model, - provider, - ..base - } -} - -/// Re-pin `record` to `persona`: build the snapshot (via -/// [`persona_snapshot_with_agent_config_fallback`], so blank persona -/// `model`/`provider` preserve the record's own values) and mirror it onto the -/// record — the definition quad (`system_prompt`/`model`/`provider`/`runtime`), -/// the env-override self-heal, and the `persona_source_version` drift basis. +/// Definition-authoritative: blank definition model/provider produce `None` +/// on the record. The effective-config resolver falls through to the global +/// default at read time; stale materialized record bytes are never preserved. /// /// This is the single apply used by every snapshot-apply site: the spawn /// re-pin (`start_local_agent_with_preflight`), the launch backfill and @@ -467,11 +416,7 @@ pub fn persona_snapshot_with_agent_config_fallback( /// caller's concern, and `spawn_config_hash` (which applies this to a clone) /// must stay pure. pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDefinition) { - let snapshot = persona_snapshot_with_agent_config_fallback( - persona, - record.model.as_deref(), // fallback: record.model - record.provider.as_deref(), // fallback: record.provider - ); + let snapshot = persona_snapshot(persona); if let Some(prompt) = snapshot.system_prompt { record.system_prompt = Some(prompt); } @@ -507,5 +452,33 @@ pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDe .retain(|k, v| persona.env_vars.get(k) != Some(v)); record.persona_source_version = Some(snapshot.source_version); } + +/// Preview what `record` would look like immediately after the start/restore +/// paths re-pin it to its linked persona, without mutating `record` itself. +/// +/// Every decision made ahead of the real re-pin — the relay-mesh preflight in +/// `start_local_agent_with_preflight`, the restart-badge hash in +/// `spawn_config_hash` — needs to reason about spawn-time state, not +/// pre-snapshot bytes, so a persona edit that flips a field (e.g. `provider` +/// to/from relay-mesh) between saves is reflected in the decision instead of +/// the stale value the real [`apply_persona_snapshot`] is about to overwrite +/// anyway. Idempotent: applying it to an already-current record is a no-op, +/// so the spawn-time stamp and later recomputes agree when nothing changed. +/// +/// Orphaned records (persona deleted) pass through unchanged: the caller's +/// own orphan handling — refusing to spawn, hashing as `(None, None, None)` +/// — runs on the real record downstream, not on this preview. +pub fn preview_prospective_persona_snapshot( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], +) -> ManagedAgentRecord { + let mut preview = record.clone(); + if let Some(persona_id) = preview.persona_id.clone() { + if let Some(persona) = personas.iter().find(|p| p.id == persona_id) { + apply_persona_snapshot(&mut preview, persona); + } + } + preview +} #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index d62cc5b4bb..27d3b0ce06 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -1,4 +1,141 @@ use super::*; +use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; + +/// A linked instance record with no persona-derived fields set yet — the +/// state right after creation, before any snapshot apply. +fn sample_record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "p".repeat(64), + name: "agent".into(), + persona_id: Some("test-persona".into()), + private_key_nsec: "nsec1fake".into(), + auth_tag: None, + relay_url: "ws://localhost:3000".into(), + avatar_url: None, + acp_command: "buzz-acp".into(), + agent_command: "goose".into(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "now".into(), + updated_at: "now".into(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + } +} + +// ── preview_prospective_persona_snapshot (Finding 4: relay-mesh preflight +// ordering) ─────────────────────────────────────────────────────────────── + +/// Regression for the relay-mesh preflight-ordering defect: the preflight +/// used to check `ensure_relay_mesh_for_record` against the record's stale +/// pre-snapshot bytes, so a persona edit that flips `provider` to +/// `relay-mesh` between saves would not trigger the mesh preflight on the +/// very next start — only on the restart after that, once the real +/// `apply_persona_snapshot` had already landed. The preview must reflect the +/// same prospective re-snapshot the real spawn path applies. +#[test] +fn preview_reflects_persona_edit_flipping_provider_into_relay_mesh() { + let record = sample_record(); // provider: None (not relay-mesh) + let mut persona = sample_persona(); + persona.id = "test-persona".into(); + persona.provider = Some("relay-mesh".into()); + persona.model = Some("auto".into()); + + let preview = preview_prospective_persona_snapshot(&record, &[persona]); + + assert_eq!( + preview.provider.as_deref(), + Some("relay-mesh"), + "preview must carry the persona's current provider, not the record's stale None" + ); + assert_eq!(preview.model.as_deref(), Some("auto")); +} + +/// Preview reflects a persona edit flipping AWAY from relay-mesh too — +/// symmetric with the into-mesh case above. +#[test] +fn preview_reflects_persona_edit_flipping_provider_out_of_relay_mesh() { + let mut record = sample_record(); + record.provider = Some("relay-mesh".into()); + record.model = Some("auto".into()); + let mut persona = sample_persona(); + persona.id = "test-persona".into(); + persona.provider = Some("anthropic".into()); + persona.model = Some("claude-opus-4".into()); + + let preview = preview_prospective_persona_snapshot(&record, &[persona]); + + assert_eq!(preview.provider.as_deref(), Some("anthropic")); + assert_eq!(preview.model.as_deref(), Some("claude-opus-4")); +} + +/// The preview must not mutate the record passed in — callers (the mesh +/// preflight) need the original bytes intact for the real snapshot apply +/// that follows. +#[test] +fn preview_does_not_mutate_input_record() { + let record = sample_record(); + let original = record.clone(); + let mut persona = sample_persona(); + persona.id = "test-persona".into(); + persona.provider = Some("relay-mesh".into()); + + let _ = preview_prospective_persona_snapshot(&record, &[persona]); + + assert_eq!(record.provider, original.provider); + assert_eq!(record.model, original.model); +} + +/// Orphaned instance (persona deleted): the preview must pass the record +/// through unchanged so downstream orphan handling (refuse to spawn) sees +/// the same bytes it always would. +#[test] +fn preview_passes_through_unchanged_when_persona_missing() { + let mut record = sample_record(); + record.provider = Some("anthropic".into()); + record.persona_id = Some("deleted-persona".into()); + + let preview = preview_prospective_persona_snapshot(&record, &[]); + + assert_eq!(preview.provider.as_deref(), Some("anthropic")); + assert_eq!(preview.persona_id.as_deref(), Some("deleted-persona")); +} fn sample_persona() -> AgentDefinition { AgentDefinition { @@ -437,43 +574,6 @@ fn persona_content_hash_changes_on_edit() { ); } -// ── persona_field_with_record_fallback ──────────────────────────────────── - -#[test] -fn field_fallback_persona_present_wins() { - assert_eq!( - persona_field_with_record_fallback(Some("persona-model"), Some("record-model")), - Some("persona-model".to_owned()), - ); -} - -#[test] -fn field_fallback_persona_blank_uses_record() { - assert_eq!( - persona_field_with_record_fallback(None, Some("record-model")), - Some("record-model".to_owned()), - ); - assert_eq!( - persona_field_with_record_fallback(Some(" "), Some("record-model")), - Some("record-model".to_owned()), - ); -} - -#[test] -fn field_fallback_both_blank_is_none() { - assert_eq!(persona_field_with_record_fallback(None, None), None); - assert_eq!(persona_field_with_record_fallback(Some(""), Some("")), None); -} - -#[test] -fn field_fallback_record_blank_is_none() { - assert_eq!( - persona_field_with_record_fallback(None, Some(" ")), - None, - "whitespace-only record value must also be treated as blank" - ); -} - // ── PersonaSnapshot.runtime ─────────────────────────────────────────────── /// (b) The snapshot carries the persona's runtime VERBATIM — including None, @@ -484,7 +584,7 @@ fn field_fallback_record_blank_is_none() { #[test] fn snapshot_runtime_verbatim_from_persona() { let persona = sample_persona(); // runtime = Some("goose") - let snap = persona_snapshot_with_agent_config_fallback(&persona, Some("gpt-4"), Some("openai")); + let snap = persona_snapshot(&persona); assert_eq!( snap.runtime.as_deref(), Some("goose"), @@ -493,15 +593,14 @@ fn snapshot_runtime_verbatim_from_persona() { let mut no_runtime = sample_persona(); no_runtime.runtime = None; - let snap = - persona_snapshot_with_agent_config_fallback(&no_runtime, Some("gpt-4"), Some("openai")); + let snap = persona_snapshot(&no_runtime); assert_eq!( snap.runtime, None, "persona runtime None must produce None snapshot (clears stale materialized value)" ); } -// ── persona_snapshot_with_agent_config_fallback ──────────────────────────── +// ── persona_snapshot (definition-authoritative) ────────────────────────── /// Helper: a persona with no model/provider configured. fn blank_model_persona() -> AgentDefinition { @@ -512,25 +611,22 @@ fn blank_model_persona() -> AgentDefinition { } } -/// (a) Persona leaves model/provider blank, agent record has values → -/// record values preserved AND source_version still updated to current hash. +/// Definition-authoritative: blank definition model/provider → snapshot is +/// None. The effective-config resolver handles global fallback at read time. #[test] -fn fallback_preserves_record_values_when_persona_blank() { +fn blank_definition_clears_model_provider() { let persona = blank_model_persona(); let expected_version = persona_content_hash(&persona_event_content(&persona)); - let snapshot = - persona_snapshot_with_agent_config_fallback(&persona, Some("gpt-4o"), Some("openai")); + let snapshot = persona_snapshot(&persona); - assert_eq!( - snapshot.model.as_deref(), - Some("gpt-4o"), - "blank persona model must fall back to agent record value" + assert!( + snapshot.model.is_none(), + "blank definition model must produce None" ); - assert_eq!( - snapshot.provider.as_deref(), - Some("openai"), - "blank persona provider must fall back to agent record value" + assert!( + snapshot.provider.is_none(), + "blank definition provider must produce None" ); assert_eq!( snapshot.source_version, expected_version, @@ -538,124 +634,102 @@ fn fallback_preserves_record_values_when_persona_blank() { ); } -/// (b) Persona has model/provider set → persona wins over agent record. +/// Persona has model/provider set → snapshot carries them verbatim. #[test] -fn fallback_persona_wins_when_set() { +fn snapshot_carries_persona_model_provider() { let persona = sample_persona(); // has model=Some("claude-opus-4"), provider=Some("anthropic") - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, - Some("gpt-4o"), // agent had a different model - Some("openai"), // agent had a different provider - ); + let snapshot = persona_snapshot(&persona); assert_eq!( snapshot.model.as_deref(), Some("claude-opus-4"), - "persona model must win when persona has a value" + "persona model must be carried verbatim" ); assert_eq!( snapshot.provider.as_deref(), Some("anthropic"), - "persona provider must win when persona has a value" + "persona provider must be carried verbatim" ); } -/// (c) Both blank → snapshot keeps None; a genuinely unconfigured agent +/// Both blank → snapshot keeps None; a genuinely unconfigured agent /// stays unconfigured (no fabricated values). #[test] -fn fallback_both_blank_stays_none() { +fn both_blank_stays_none() { let persona = blank_model_persona(); - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, None, // agent also has no model - None, // agent also has no provider - ); + let snapshot = persona_snapshot(&persona); assert!( snapshot.model.is_none(), - "neither persona nor agent has model — snapshot must be None" + "blank persona model → snapshot None" ); assert!( snapshot.provider.is_none(), - "neither persona nor agent has provider — snapshot must be None" + "blank persona provider → snapshot None" ); } -/// Whitespace-only values on the persona are treated as blank; agent -/// fallback applies. +/// Whitespace-only values on the definition are preserved verbatim in the +/// snapshot. The effective-config resolver treats whitespace as blank. #[test] -fn fallback_treats_whitespace_only_persona_value_as_blank() { +fn whitespace_only_definition_preserved_verbatim() { let mut persona = sample_persona(); persona.model = Some(" ".to_string()); persona.provider = Some("\t".to_string()); - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, - Some("claude-opus-4"), - Some("anthropic"), - ); + let snapshot = persona_snapshot(&persona); assert_eq!( snapshot.model.as_deref(), - Some("claude-opus-4"), - "whitespace-only persona model must be treated as blank" + Some(" "), + "whitespace-only model is preserved verbatim in snapshot" ); assert_eq!( snapshot.provider.as_deref(), - Some("anthropic"), - "whitespace-only persona provider must be treated as blank" + Some("\t"), + "whitespace-only provider is preserved verbatim in snapshot" ); } -/// Cross-field independence: persona sets model but not provider → model -/// comes from persona, provider falls back to the record. This is the -/// practically common case (model-only personas). +/// Cross-field independence: definition sets model but not provider → model +/// comes from definition, provider is None (global fallback at read time). #[test] -fn fallback_persona_model_set_provider_blank_uses_record_provider() { - let mut persona = sample_persona(); // model=Some("claude-opus-4"), provider=Some("anthropic") - persona.provider = None; // blank provider on persona +fn definition_model_set_provider_blank_produces_none_provider() { + let mut persona = sample_persona(); + persona.provider = None; - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, - Some("gpt-4o"), // record model (should be overridden by persona) - Some("openai"), // record provider (should be preserved) - ); + let snapshot = persona_snapshot(&persona); assert_eq!( snapshot.model.as_deref(), Some("claude-opus-4"), - "persona model must win when persona has a value" + "definition model must be used" ); - assert_eq!( - snapshot.provider.as_deref(), - Some("openai"), - "record provider must be used when persona provider is blank" + assert!( + snapshot.provider.is_none(), + "definition provider=None → snapshot None" ); } -/// Inverse: persona sets provider but not model → provider comes from -/// persona, model falls back to the record. +/// Definition provider set, model blank → model=None (global fallback at +/// read time), provider from definition. #[test] -fn fallback_persona_provider_set_model_blank_uses_record_model() { - let mut persona = sample_persona(); // model=Some("claude-opus-4"), provider=Some("anthropic") - persona.model = None; // blank model on persona +fn definition_provider_set_model_blank_produces_none_model() { + let mut persona = sample_persona(); + persona.model = None; - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, - Some("gpt-4o"), // record model (should be preserved) - Some("openai"), // record provider (should be overridden by persona) - ); + let snapshot = persona_snapshot(&persona); - assert_eq!( - snapshot.model.as_deref(), - Some("gpt-4o"), - "record model must be used when persona model is blank" + assert!( + snapshot.model.is_none(), + "definition model=None → snapshot None" ); assert_eq!( snapshot.provider.as_deref(), Some("anthropic"), - "persona provider must win when persona has a value" + "definition provider must be used" ); } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c04dfa88a4..fb45cc9a4e 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -98,11 +98,6 @@ pub(crate) fn resolve_effective_agent_env( // Layer 1: baked build defaults (floor — internal builds only; OSS = empty). let mut env = baked_build_env(); - // Layer 2: runtime metadata env vars (model / provider keys derived from - // the record's structured fields, with global as fallback). - // - // Uses the shared resolver to guarantee readiness and spawn agree on the - // effective model/provider: agent → persona → global → None. let (effective_model, effective_provider) = super::global_config::resolve_effective_model_provider(record, personas, global); @@ -111,8 +106,8 @@ pub(crate) fn resolve_effective_agent_env( rt.model_env_var, rt.provider_env_var, rt.provider_locked, - effective_model, - effective_provider, + effective_model.as_deref(), + effective_provider.as_deref(), ) { env.insert(key.to_string(), value.to_string()); } @@ -138,7 +133,11 @@ pub(crate) fn resolve_effective_agent_env( // Buzz shared compute is a native Buzz provider. Translate it to buzz-agent's // OpenAI-compatible transport only in the effective runtime environment. #[cfg(feature = "mesh-llm")] - super::apply_relay_mesh_env(&mut env, effective_provider, effective_model); + super::apply_relay_mesh_env( + &mut env, + effective_provider.as_deref(), + effective_model.as_deref(), + ); EffectiveAgentEnv { env, diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index a47122df80..934155e881 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -1,6 +1,3 @@ -#[cfg(feature = "mesh-llm")] -use super::{ManagedAgentRecord, RelayMeshConfig}; - pub const RELAY_MESH_API_BASE_URL: &str = "http://127.0.0.1:9337/v1"; pub const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "buzz-mesh-local"; pub const RELAY_MESH_PROVIDER_ID: &str = "relay-mesh"; @@ -46,146 +43,11 @@ pub fn apply_relay_mesh_env( env.insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "none".to_string()); } -/// Resolve a record's relay-mesh config, typed field first. -/// -/// Source of truth is the typed `record.relay_mesh` field. For records saved -/// before that field existed, fall back to detecting the relay-mesh preset -/// from `env_vars` (the legacy discriminator). New records carry the typed -/// field and need no env-var sniffing at all. -#[cfg(feature = "mesh-llm")] -pub fn relay_mesh_config(record: &ManagedAgentRecord) -> Option { - if record.provider.as_deref() == Some(RELAY_MESH_PROVIDER_ID) { - let model_ref = record - .model - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) - .to_string(); - return Some(RelayMeshConfig { model_ref }); - } - if let Some(config) = &record.relay_mesh { - return Some(config.clone()); - } - relay_mesh_model_id_from_env(record).map(|model_ref| RelayMeshConfig { model_ref }) -} - -/// Returns the relay-mesh model id for agents whose provider env points at the -/// local mesh client endpoint created by Buzz's relay-mesh preset. -/// -/// Prefer [`relay_mesh_config`]; this remains as a convenience for call sites -/// that only need the model id. -#[cfg(feature = "mesh-llm")] -pub fn relay_mesh_model_id(record: &ManagedAgentRecord) -> Option { - relay_mesh_config(record).map(|config| config.model_ref) -} - -/// Legacy env-var discriminator: detects the relay-mesh preset purely from the -/// four preset env vars. Used as a fallback for records saved before the typed -/// `relay_mesh` field existed. -#[cfg(feature = "mesh-llm")] -fn relay_mesh_model_id_from_env(record: &ManagedAgentRecord) -> Option { - let base_url = record.env_vars.get("OPENAI_COMPAT_BASE_URL")?.trim(); - if base_url.trim_end_matches('/') != RELAY_MESH_API_BASE_URL { - return None; - } - let provider = record.env_vars.get("BUZZ_AGENT_PROVIDER")?.trim(); - if provider != "openai" { - return None; - } - let api_key = record.env_vars.get("OPENAI_COMPAT_API_KEY")?.trim(); - if api_key != RELAY_MESH_API_KEY_PLACEHOLDER { - return None; - } - record - .env_vars - .get("OPENAI_COMPAT_MODEL") - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - #[cfg(all(test, feature = "mesh-llm"))] mod tests { use std::collections::BTreeMap; use super::*; - use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; - - fn fixture() -> ManagedAgentRecord { - ManagedAgentRecord { - pubkey: "p".into(), - name: "n".into(), - persona_id: None, - private_key_nsec: "nsec1fake".into(), - auth_tag: Some("tag".into()), - relay_url: "ws://localhost:3000".into(), - avatar_url: None, - acp_command: "buzz-acp".into(), - agent_command: "goose".into(), - agent_command_override: None, - agent_args: vec![], - mcp_command: String::new(), - turn_timeout_seconds: 320, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - env_vars: BTreeMap::new(), - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: BackendKind::Local, - backend_agent_id: None, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - persona_source_version: None, - provider: None, - created_at: "now".into(), - updated_at: "now".into(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to: RespondTo::OwnerOnly, - respond_to_allowlist: vec![], - display_name: None, - slug: None, - runtime: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - source_team: None, - source_team_persona_slug: None, - definition_respond_to: None, - definition_respond_to_allowlist: Vec::new(), - definition_parallelism: None, - relay_mesh: None, - } - } - - #[test] - fn relay_mesh_model_id_detects_mesh_preset_env() { - let mut rec = fixture(); - rec.env_vars = BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - "http://127.0.0.1:9337/v1/".to_string(), - ), - ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), - ( - "OPENAI_COMPAT_API_KEY".to_string(), - RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), - ), - ]); - - assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("Qwen3")); - } #[test] fn native_provider_uses_context_safe_non_reasoning_budget() { @@ -205,95 +67,4 @@ mod tests { Some("none") ); } - - #[test] - fn relay_mesh_model_id_ignores_non_mesh_openai_env() { - let mut rec = fixture(); - rec.env_vars = BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - "https://api.openai.com/v1".to_string(), - ), - ("OPENAI_COMPAT_MODEL".to_string(), "gpt-5".to_string()), - ]); - - assert_eq!(relay_mesh_model_id(&rec), None); - } - - #[test] - fn relay_mesh_model_id_ignores_user_openai_on_same_local_port() { - let mut rec = fixture(); - rec.env_vars = BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - "http://127.0.0.1:9337/v1".to_string(), - ), - ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), - ("OPENAI_COMPAT_API_KEY".to_string(), "real-key".to_string()), - ]); - - assert_eq!(relay_mesh_model_id(&rec), None); - } - - #[test] - fn native_provider_fields_are_authoritative() { - // The whole point: a typed record needs no env-var sniffing to be - // recognized as a relay-mesh agent. - let mut rec = fixture(); - rec.provider = Some(RELAY_MESH_PROVIDER_ID.to_string()); - rec.model = Some("Qwen3".to_string()); - assert!(rec.env_vars.is_empty()); - assert_eq!( - relay_mesh_config(&rec), - Some(RelayMeshConfig { - model_ref: "Qwen3".to_string() - }) - ); - assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("Qwen3")); - } - - #[test] - fn native_provider_fields_win_over_legacy_config() { - let mut rec = fixture(); - rec.provider = Some(RELAY_MESH_PROVIDER_ID.to_string()); - rec.model = Some("native-model".to_string()); - rec.relay_mesh = Some(RelayMeshConfig { - model_ref: "typed-model".to_string(), - }); - rec.env_vars = BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - "http://127.0.0.1:9337/v1".to_string(), - ), - ("OPENAI_COMPAT_MODEL".to_string(), "env-model".to_string()), - ( - "OPENAI_COMPAT_API_KEY".to_string(), - RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), - ), - ]); - assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("native-model")); - } - - #[test] - fn legacy_record_falls_back_to_env_sniff() { - // Records saved before the typed field still resolve via env vars. - let mut rec = fixture(); - rec.relay_mesh = None; - rec.env_vars = BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - "http://127.0.0.1:9337/v1".to_string(), - ), - ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), - ( - "OPENAI_COMPAT_API_KEY".to_string(), - RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), - ), - ]); - assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("Qwen3")); - } } diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index fab481e12b..1910620159 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -1,5 +1,3 @@ -#[cfg(feature = "mesh-llm")] -use super::relay_mesh_model_id; use super::{ find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, load_personas, save_managed_agents, spawn_agent_child, sync_managed_agent_processes, BackendKind, @@ -36,10 +34,9 @@ type AgentSpawnResult = (String, SpawnOutcome); /// `model`/`provider` were clobbered by the old unconditional snapshot code before /// this fix — are skipped here; they self-heal on the next manual start via the /// start-path re-snapshot in `start_local_agent_with_preflight`. -/// If the linked persona is gone, we log loudly and leave the snapshot empty — -/// the record's own `system_prompt`/`model` (possibly empty for persona-created -/// agents) is then all the config that remains, which is the same fallback an -/// orphaned agent already gets. +/// If the linked persona is gone, we log loudly and leave the record untouched — +/// it stays orphaned and `spawn_agent_child` refuses to start it (see +/// `effective_config::resolve_effective_config`'s `OrphanedInstance` arm). pub fn backfill_persona_snapshots(app: &tauri::AppHandle) -> Result<(), String> { let state = app.state::(); let _store_guard = state @@ -66,7 +63,7 @@ pub fn backfill_persona_snapshots(app: &tauri::AppHandle) -> Result<(), String> } let Some(persona) = personas.iter().find(|p| p.id == persona_id) else { eprintln!( - "buzz-desktop: persona-snapshot backfill: agent {} links persona {persona_id} which no longer exists; leaving snapshot empty — it will spawn from its record fields", + "buzz-desktop: persona-snapshot backfill: agent {} links persona {persona_id} which no longer exists; leaving it orphaned — spawn will refuse it", record.pubkey ); continue; @@ -206,6 +203,9 @@ pub async fn restore_managed_agents_on_launch( continue; }; let Some(persona) = personas_for_snapshot.iter().find(|p| p.id == persona_id) else { + // Orphaned: no current persona to re-snapshot from. Leave the + // record as-is — `spawn_agent_child` (Phase B below) refuses to + // spawn it and Phase C persists the refusal to `last_error`. continue; }; super::persona_events::apply_persona_snapshot(record, persona); @@ -240,16 +240,26 @@ pub async fn restore_managed_agents_on_launch( #[cfg(feature = "mesh-llm")] let agents_to_start = { + // Preflight against the same resolution spawn uses — `resolve_effective_config` + // (definition → global fallback). A linked instance's own `provider`/`model`/ + // `relay_mesh` bytes never contribute. See `start_local_agent_with_preflight` + // in `commands/agents.rs` for the identical rationale on the interactive path. + let personas = load_personas(app).unwrap_or_default(); + let global = super::load_global_agent_config(app).unwrap_or_default(); let mut mesh_preflight_failures = std::collections::HashSet::new(); for record in &agents_to_start { - if relay_mesh_model_id(record).is_none() { + let mesh_model_id = super::effective_config::resolve_effective_relay_mesh_model_id( + record, &personas, &global, + ); + if mesh_model_id.is_none() { continue; } // Auto-start after relaunch: re-resolve a live bootstrap target and // dial it. Skip (with an actionable error) only when no live target // serves this model right now. if let Err(error) = - crate::commands::ensure_relay_mesh_for_record(app, record, false).await + crate::commands::ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), false) + .await { persist_restore_error(app, &state, &record.pubkey, error)?; mesh_preflight_failures.insert(record.pubkey.clone()); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b9c7ec65ea..79ee8df1ac 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -189,12 +189,37 @@ pub fn build_managed_agent_summary( } }; - // Display contract: show the pinned record snapshot — what the agent - // actually runs — not the live persona. The drift flags below signal when - // the snapshot has fallen behind an edited persona; showing live values - // next to an "out of date" badge would contradict it. let (persona_out_of_date, persona_orphaned) = persona_drift_state(record, personas); + let global_for_summary = + crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let effective_cfg = crate::managed_agents::effective_config::resolve_effective_config( + record, + personas, + &global_for_summary, + ); + let (effective_model, effective_provider, effective_prompt, model_source) = match effective_cfg + { + crate::managed_agents::effective_config::EffectiveConfigResult::Resolved(cfg) => { + let source = cfg.model.source.clone(); + ( + cfg.model.value, + cfg.provider.value, + cfg.system_prompt.value, + Some(source), + ) + } + crate::managed_agents::effective_config::EffectiveConfigResult::OrphanedInstance { + record_pubkey, + missing_persona_id, + } => { + eprintln!( + "orphaned agent instance: pubkey={record_pubkey}, missing_persona_id={missing_persona_id}" + ); + (None, None, None, None) + } + }; + // Restart badge: the running process stamped its effective spawn config // at launch; recompute from current disk state and flag drift. Only the // tracked live pair for THIS workspace can drift — stopped agents spawn @@ -227,7 +252,13 @@ pub fn build_managed_agent_summary( runtime.adapter_availability.as_ref(), super::adapter_availability_cached(), ); - hash_drift || availability_drift + // An orphan can never be restarted successfully — + // `spawn_agent_child` refuses it before any process side effect — + // so `needs_restart` must never fire for one regardless of hash or + // availability drift. Surfacing "Restart required" here would offer + // an action guaranteed to fail; the UI shows `persona_orphaned` + // instead (see `ManagedAgentSummary::persona_orphaned`). + restart_eligible(persona_orphaned, hash_drift, availability_drift) }); // Resolve the effective harness the same way, then derive args/mcp from it, @@ -254,10 +285,11 @@ pub fn build_managed_agent_summary( idle_timeout_seconds: record.idle_timeout_seconds, max_turn_duration_seconds: record.max_turn_duration_seconds, parallelism: record.parallelism, - system_prompt: record.system_prompt.clone(), + system_prompt: effective_prompt, avatar_url: record.avatar_url.clone(), - model: record.model.clone(), - provider: record.provider.clone(), + model: effective_model, + model_source, + provider: effective_provider, persona_out_of_date, persona_orphaned, needs_restart, @@ -281,6 +313,19 @@ pub fn build_managed_agent_summary( }) } +/// Pure predicate: should the "Restart required" badge fire? +/// +/// An orphaned linked instance (its persona/definition no longer exists) +/// can never be restarted successfully — `spawn_agent_child` refuses to +/// spawn it before any process side effect. Surfacing "Restart required" +/// for one would offer an action guaranteed to fail, so this always +/// returns `false` for an orphan regardless of drift. Extracted for unit +/// testing without `AppHandle`/global state, following the +/// `availability_drift` pattern in `discovery.rs`. +fn restart_eligible(persona_orphaned: bool, hash_drift: bool, availability_drift: bool) -> bool { + !persona_orphaned && (hash_drift || availability_drift) +} + pub fn find_managed_agent_mut<'a>( records: &'a mut [ManagedAgentRecord], pubkey: &str, @@ -389,6 +434,33 @@ pub fn spawn_agent_child( return Err(error); } let runtime_key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?; + // Resolve the effective harness (agent command) from the linked persona, so + // persona harness edits propagate on the next spawn; an explicit per-agent + // override wins. `agent_args` and `mcp_command` are pure derivations of the + // command, so we recompute them from the effective value rather than the + // frozen record snapshot. Mirrors the model resolution below. + let personas = super::load_personas(app).unwrap_or_default(); + let teams = super::load_teams(app).unwrap_or_default(); + // Load global config once; used for runtime_metadata_env_vars (model/provider fallback) + // and for the env-var merge at spawn time. + let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + + // Resolve model/provider/prompt ONCE, here, at the shared spawn boundary — + // the single source both the env writes below and `spawn_config_hash` + // read from. Previously prompt was read from the record's own (possibly + // stale, Phase-A-snapshot) bytes while model/provider were resolved live + // from `personas`; a definition edit landing between a caller's snapshot + // apply and this spawn could hand a fresh model/provider to a stale + // prompt. This also folds in orphan refusal via `require_resolved`: every + // caller (interactive start, launch restore, `start_managed_agent_process`) + // inherits it — no caller can bypass this by reaching `spawn_agent_child` + // directly. Checked before any side effect (log marker, log file, process + // spawn) so a refused spawn leaves no trace. + let effective_cfg = crate::managed_agents::effective_config::resolve_effective_config( + record, &personas, &global, + ) + .require_resolved()?; + let log_path = super::managed_agent_runtime_log_path(app, &runtime_key)?; append_log_marker( &log_path, @@ -404,16 +476,6 @@ pub fn spawn_agent_child( let stderr = stdout .try_clone() .map_err(|error| format!("failed to clone log handle: {error}"))?; - // Resolve the effective harness (agent command) from the linked persona, so - // persona harness edits propagate on the next spawn; an explicit per-agent - // override wins. `agent_args` and `mcp_command` are pure derivations of the - // command, so we recompute them from the effective value rather than the - // frozen record snapshot. Mirrors the model resolution below. - let personas = super::load_personas(app).unwrap_or_default(); - let teams = super::load_teams(app).unwrap_or_default(); - // Load global config once; used for runtime_metadata_env_vars (model/provider fallback) - // and for the env-var merge at spawn time. - let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); let effective_command = super::record_agent_command(record, &personas); let agent_args = normalize_agent_args(&effective_command, record.agent_args.clone()); let resolved_acp_command = resolve_command(&record.acp_command) @@ -628,37 +690,40 @@ pub fn spawn_agent_child( command.env_remove("BUZZ_ACP_TEAM_INSTRUCTIONS"); } - // System prompt via the shared spawn-effective filter — the SAME function the - // config hash digests, so env write and badge cannot disagree (see - // `effective_spawn_prompt` for the Some("")/None collapse). Model and provider - // use the shared - // resolver: agent → persona → global → None, so a global-default-only agent - // spawns with the correct provider/model env. - let effective_prompt = super::spawn_hash::effective_spawn_prompt(record); - let (effective_model, effective_provider) = - crate::managed_agents::resolve_effective_model_provider(record, &personas, &global); + // Prompt, model, and provider all come from the single `effective_cfg` + // resolved at the top of this function — the SAME resolve `spawn_config_hash` + // performs below, so env write and restart badge cannot disagree. Linked + // instances never consult the record's own model/provider/prompt bytes; + // definition-less instances fall back to their own fields, then global. + // + // Derive the mesh decision BEFORE moving fields out — `relay_mesh_model_id` + // is the single authoritative gate; the mesh-llm block below MUST use it + // rather than re-deriving from `effective_provider` to keep preflight and + // spawn semantics in lock-step (see `EffectiveAgentConfig::relay_mesh_model_id`). + #[cfg(feature = "mesh-llm")] + let mesh_model_id = effective_cfg.relay_mesh_model_id(); + let effective_prompt = effective_cfg.system_prompt.value; + let effective_model = effective_cfg.model.value; + let effective_provider = effective_cfg.provider.value; if let Some(prompt) = &effective_prompt { command.env("BUZZ_ACP_SYSTEM_PROMPT", prompt); } else { command.env_remove("BUZZ_ACP_SYSTEM_PROMPT"); } - if let Some(model) = effective_model { + if let Some(model) = effective_model.as_deref() { command.env("BUZZ_ACP_MODEL", model); } else { command.env_remove("BUZZ_ACP_MODEL"); } - // Baked-in provider defaults for internal builds (buzz-releases sets - // BUZZ_BUILD_BUZZ_AGENT_* at compile time; OSS builds bake nothing). - // Written FIRST so that record/persona metadata env vars below override them. build_buzz_agent_provider_defaults(&mut command); if let Some(meta) = runtime_meta { for (key, value) in runtime_metadata_env_vars( meta.model_env_var, meta.provider_env_var, meta.provider_locked, - effective_model, - effective_provider, + effective_model.as_deref(), + effective_provider.as_deref(), ) { command.env(key, value); } @@ -749,10 +814,17 @@ pub fn spawn_agent_child( // Buzz shared compute is stored as a native provider; derive the OpenAI-compatible // transport at spawn time and scrub any unrelated ambient OpenAI key. + // Gate on `mesh_model_id` (derived from `effective_cfg.relay_mesh_model_id()` + // above) — not on `effective_provider` directly — so the mesh gate here + // uses the same trim semantics as the preflight callers. #[cfg(feature = "mesh-llm")] - if effective_provider == Some(super::RELAY_MESH_PROVIDER_ID) { + if let Some(ref mesh_model_id) = mesh_model_id { let mut mesh_env = std::collections::BTreeMap::new(); - super::apply_relay_mesh_env(&mut mesh_env, effective_provider, effective_model); + super::apply_relay_mesh_env( + &mut mesh_env, + Some(super::RELAY_MESH_PROVIDER_ID), + Some(mesh_model_id.as_str()), + ); command.env_remove("OPENAI_API_KEY"); for (key, value) in mesh_env { command.env(key, value); diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs index 56d2e68353..ac9e3a0a3f 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -24,18 +24,11 @@ pub(crate) fn runtime_metadata_env_vars<'a>( vars } -/// Resolve the effective (prompt, model, provider) triple for a persona-linked agent. -/// -/// Given a persona_id, finds the persona in the list and returns its system_prompt, -/// model, and provider as the authoritative values. When the persona leaves `model` -/// or `provider` blank (None or whitespace-only), falls back to the record's own -/// field using the same precedence rule as `persona_snapshot_with_agent_config_fallback` -/// so the display surface matches spawn behavior. Falls back to the record's own -/// prompt/model/provider when no persona is linked or found. +/// Resolve effective prompt/model/provider using definition-authoritative +/// semantics for linked instances. /// /// Used by `agent_config.rs` to inject persona defaults into the config surface -/// before running the reader, so BuzzExplicit-tagged fields can be re-tagged to -/// PersonaDefault for fields the record did not independently set. +/// before running the reader. pub(crate) fn resolve_effective_prompt_model_provider( persona_id: Option<&str>, personas: &[crate::managed_agents::types::AgentDefinition], @@ -43,13 +36,16 @@ pub(crate) fn resolve_effective_prompt_model_provider( record_model: Option, record_provider: Option, ) -> (Option, Option, Option) { - let fallback = crate::managed_agents::persona_events::persona_field_with_record_fallback; match persona_id.and_then(|pid| personas.iter().find(|p| p.id == pid)) { - Some(p) => ( - Some(p.system_prompt.clone()), - fallback(p.model.as_deref(), record_model.as_deref()), // fallback: record.model - fallback(p.provider.as_deref(), record_provider.as_deref()), // fallback: record.provider - ), + Some(p) => { + fn non_blank(v: Option<&str>) -> Option { + v.filter(|s| !s.trim().is_empty()).map(str::to_owned) + } + let prompt = non_blank(Some(&p.system_prompt)); + let model = non_blank(p.model.as_deref()); + let provider = non_blank(p.provider.as_deref()); + (prompt, model, provider) + } None => (record_prompt, record_model, record_provider), } } diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 019bfe61d3..55fa8efba7 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -315,20 +315,16 @@ fn persona_with_provider( // that the old create-time env baking silently blocked. use crate::managed_agents::env_vars::{live_persona_env, merged_user_env}; -use crate::managed_agents::persona_events::persona_snapshot; use std::collections::BTreeMap; /// Apply a persona snapshot onto a record, mirroring `create_managed_agent`: -/// snapshotted prompt/model/provider/source_version are pinned, with the -/// system_prompt unwrapped (the persona always carries one). `env_vars` is -/// deliberately NOT touched — it stays agent overrides only. +/// links the record to `persona.id`, then delegates the actual snapshot +/// (prompt/model/provider/runtime/source_version) to the real production +/// `apply_persona_snapshot` — so a change to that function's behavior is +/// exercised by these tests instead of silently diverging from it. fn pin_persona(record: &mut ManagedAgentRecord, persona: &crate::managed_agents::AgentDefinition) { - let snapshot = persona_snapshot(persona); record.persona_id = Some(persona.id.clone()); - record.system_prompt = snapshot.system_prompt; - record.model = snapshot.model; - record.provider = snapshot.provider; - record.persona_source_version = Some(snapshot.source_version); + crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); } fn persona_v( @@ -428,19 +424,31 @@ fn agent_env_overrides_win_over_persona_env_at_spawn() { } #[test] -fn orphaned_agent_spawns_from_its_own_overrides() { - // Persona deleted: the live merge degrades to the record's own overrides. +fn orphaned_agent_refused_at_spawn_boundary() { + // Persona deleted: `spawn_agent_child` must refuse before any process + // side effect, not silently degrade to the record's stale overrides. + // `require_resolved` on the shared resolver is the pure predicate + // `spawn_agent_child` checks first — this pins the contract without + // needing a real `AppHandle`. let persona = persona_v("p", "prompt", &[("ANTHROPIC_API_KEY", "persona-key")]); let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); record.env_vars = BTreeMap::from([("EXTRA".to_string(), "agent-value".to_string())]); pin_persona(&mut record, &persona); - let env = spawn_user_env(&record, &[]); - assert_eq!(env.get("EXTRA").map(String::as_str), Some("agent-value")); + // The persona is absent from the live catalog — same shape restore/start + // see when a persona was deleted on another device. + let no_personas: &[crate::managed_agents::AgentDefinition] = &[]; + let error = crate::managed_agents::effective_config::resolve_effective_config( + &record, + no_personas, + &Default::default(), + ) + .require_resolved() + .unwrap_err(); assert_eq!( - env.get("ANTHROPIC_API_KEY"), - None, - "a deleted persona's env must not linger" + error, + crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(), + "an orphaned linked instance must be refused, not spawned from its own overrides" ); } @@ -1010,3 +1018,37 @@ fn invalid_pubkey_resolves_no_pair_key() { // the summary must fall back to the stopped/legacy-pid path, not panic. assert!(super::resolve_workspace_pair_key("not-a-key", "", "wss://one.example").is_none()); } + +// ── restart_eligible tests ────────────────────────────────────────────── + +#[test] +fn restart_eligible_true_when_non_orphan_has_hash_drift() { + assert!(super::restart_eligible(false, true, false)); +} + +#[test] +fn restart_eligible_true_when_non_orphan_has_availability_drift() { + assert!(super::restart_eligible(false, false, true)); +} + +#[test] +fn restart_eligible_false_when_orphan_has_hash_drift() { + // An orphan can never be restarted successfully — spawn refuses it — + // so hash drift alone must not surface "Restart required". + assert!(!super::restart_eligible(true, true, false)); +} + +#[test] +fn restart_eligible_false_when_orphan_has_availability_drift() { + assert!(!super::restart_eligible(true, false, true)); +} + +#[test] +fn restart_eligible_false_when_orphan_has_no_drift() { + assert!(!super::restart_eligible(true, false, false)); +} + +#[test] +fn restart_eligible_false_when_non_orphan_has_no_drift() { + assert!(!super::restart_eligible(false, false, false)); +} diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs index 4d80549288..8cf0014e36 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs @@ -28,24 +28,14 @@ use std::hash::{DefaultHasher, Hash, Hasher}; use super::{ + effective_config::{resolve_effective_config, EffectiveConfigResult}, known_acp_runtime, normalize_agent_args, - persona_events::apply_persona_snapshot, + persona_events::preview_prospective_persona_snapshot, resolve_effective_agent_env, types::{AgentDefinition, ManagedAgentRecord, TeamRecord}, GlobalAgentConfig, }; -/// The prompt a spawn would actually deliver: `Some("")` collapses to `None` -/// because an empty `BUZZ_ACP_SYSTEM_PROMPT` is no prompt. -/// -/// The single source of truth for the spawn env write AND the config hash. -pub(crate) fn effective_spawn_prompt(record: &ManagedAgentRecord) -> Option { - record - .system_prompt - .clone() - .filter(|prompt| !prompt.is_empty()) -} - /// Resolve the current instructions for this instance's deployment-time team binding. /// A deleted team deliberately degrades to no team section. pub(crate) fn effective_team_instructions( @@ -78,12 +68,7 @@ pub(crate) fn spawn_config_hash( // when nothing changed. The persona env itself reaches the hash through // `resolve_effective_agent_env` below; `persona_source_version` is set on // the clone but is not a hash input. - let mut record = record.clone(); - if let Some(persona_id) = record.persona_id.clone() { - if let Some(persona) = personas.iter().find(|p| p.id == persona_id) { - apply_persona_snapshot(&mut record, persona); - } - } + let record = preview_prospective_persona_snapshot(record, personas); let record = &record; let effective_command = crate::managed_agents::record_agent_command(record, personas); @@ -109,11 +94,24 @@ pub(crate) fn spawn_config_hash( // resolved: every record spawns on the workspace relay (legacy pins // ignored), so a workspace relay change must trip the badge. crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay).hash(&mut hasher); - // Prompt and runtime-layered team instructions use the same resolver as spawn. - effective_spawn_prompt(record).hash(&mut hasher); + // Team instructions use the same resolver as spawn. effective_team_instructions(record, teams).hash(&mut hasher); - record.model.hash(&mut hasher); - record.provider.hash(&mut hasher); + // Prompt, model, and provider all come from ONE `resolve_effective_config` + // call — the SAME resolve `spawn_agent_child` performs for the env write, + // so env write and this badge cannot disagree. An orphaned link (missing + // definition) hashes as if all three were absent: `spawn_agent_child` + // refuses to spawn an orphan regardless, so this is a display-only + // convenience, not the spawn gate. + let (resolved_prompt, resolved_model, resolved_provider) = + match resolve_effective_config(record, personas, global) { + EffectiveConfigResult::Resolved(cfg) => { + (cfg.system_prompt.value, cfg.model.value, cfg.provider.value) + } + EffectiveConfigResult::OrphanedInstance { .. } => (None, None, None), + }; + resolved_prompt.hash(&mut hasher); + resolved_model.hash(&mut hasher); + resolved_provider.hash(&mut hasher); record.auth_tag.hash(&mut hasher); record.respond_to.as_str().hash(&mut hasher); // The allowlist is hashed as the env receives it: spawn sets diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs index 6438b3b37d..44ad7f4bbc 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -441,18 +441,123 @@ fn missing_definition_leaves_materialized_runtime_in_hash() { ); } +// ── Global default trips hash for linked inherited agents ───────────────── + #[test] -fn effective_spawn_prompt_matches_hash_semantics() { - // The env write and the hash share effective_spawn_prompt — this row - // pins the helper's own semantics so a refactor of either caller cannot - // silently diverge from the contract. - let mut r = record(); - r.system_prompt = Some(String::new()); - assert_eq!( - effective_spawn_prompt(&r), - None, - "empty collapses to absent" +fn global_model_change_trips_hash_for_linked_inherited_agent() { + let mut rec = record(); + rec.persona_id = Some("p1".into()); + rec.model = Some("stale-record-model".into()); + + let personas = vec![persona("p1", Some("goose"), "prompt")]; + + let global_a = GlobalAgentConfig { + model: Some("model-a".to_string()), + provider: Some("prov-a".to_string()), + ..Default::default() + }; + let global_b = GlobalAgentConfig { + model: Some("model-b".to_string()), + provider: Some("prov-b".to_string()), + ..Default::default() + }; + + let hash_a = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_a); + let hash_b = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_b); + + assert_ne!( + hash_a, hash_b, + "changing the global default must trip the hash for a linked inherited agent" + ); +} + +#[test] +fn global_model_change_trips_hash_without_model_env_var() { + let mut rec = record(); + rec.persona_id = Some("p1".into()); + rec.agent_command = "some-harness-without-model-env".into(); + + let personas = vec![{ + let mut p = persona("p1", None, "prompt"); + p.model = None; + p.provider = None; + p + }]; + + let global_a = GlobalAgentConfig { + model: Some("model-a".to_string()), + ..Default::default() + }; + let global_b = GlobalAgentConfig { + model: Some("model-b".to_string()), + ..Default::default() + }; + + let hash_a = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_a); + let hash_b = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_b); + + assert_ne!( + hash_a, hash_b, + "global model change must trip hash even without a model_env_var runtime" + ); +} + +#[test] +fn linked_instance_stale_prompt_bytes_are_inert_at_hash_time() { + // Regression for the split-resolve defect: prompt used to be read from + // the record's own (possibly Phase-A-snapshot-stale) bytes while + // model/provider were resolved live from the definition. A definition + // edit landing between a caller's snapshot apply and spawn could hand a + // fresh model/provider to a stale prompt, and the hash (which already + // resolved model/provider live) would silently agree with a spawn that + // wrote the stale prompt. Now both come from one `resolve_effective_config` + // call, so a record whose own `system_prompt` bytes disagree with the + // live definition must hash exactly as if the record carried the + // definition's prompt verbatim — the record's prompt bytes are inert for + // a linked instance. + let mut rec = record(); + rec.persona_id = Some("p1".into()); + rec.system_prompt = Some("stale prompt on record".into()); + + let mut matching_bytes = rec.clone(); + matching_bytes.system_prompt = Some("live prompt".into()); + + let personas = [persona("p1", Some("goose"), "live prompt")]; + + assert_eq!( + spawn_config_hash( + &rec, + &personas, + &[], + "wss://ws.example", + &Default::default() + ), + spawn_config_hash( + &matching_bytes, + &personas, + &[], + "wss://ws.example", + &Default::default() + ), + "record's own system_prompt bytes must not affect the hash of a linked instance" + ); +} + +#[test] +fn linked_instance_prompt_model_provider_resolve_from_one_call() { + // The prompt for a linked instance must track the definition, exactly + // like model/provider — a definition prompt edit trips the hash even + // though the record's own (stale) system_prompt bytes are unchanged. + let mut rec = record(); + rec.persona_id = Some("p1".into()); + rec.system_prompt = Some("stale".into()); + + let before = [persona("p1", Some("goose"), "old definition prompt")]; + let after = [persona("p1", Some("goose"), "new definition prompt")]; + + assert_ne!( + spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), + "linked instance prompt must resolve from the live definition, not stale record bytes" ); - r.system_prompt = Some("real".into()); - assert_eq!(effective_spawn_prompt(&r).as_deref(), Some("real")); } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index af4908911b..cce2cd4039 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -259,13 +259,19 @@ pub struct ManagedAgentRecord { /// Desired LLM model ID. Matches AgentModelInfo.id from discovery. /// The harness re-discovers the correct ACP switching metadata at session /// creation by matching this ID against the fresh session/new response. + /// For a linked instance this is a legacy/display snapshot only — spawn + /// and deploy resolve the effective model from the definition, never + /// from this field (see `effective_config::resolve_effective_config`). + /// For a definition-less instance this field is authoritative. #[serde(default)] pub model: Option, - /// LLM inference provider snapshotted from the persona at create time - /// (e.g. 'databricks', 'anthropic'). Spawn and deploy read this, never the - /// live persona — so the agent stays pinned to the provider it was created - /// with across restarts. `#[serde(default)]` so pre-existing records - /// deserialize as `None` and get backfilled on first load. + /// LLM inference provider. For a linked instance this is a legacy/display + /// snapshot only — spawn and deploy resolve the effective provider from + /// the definition, never from this field (see + /// `effective_config::resolve_effective_config`). For a definition-less + /// instance this field is authoritative. `#[serde(default)]` so + /// pre-existing records deserialize as `None` and get backfilled on + /// first load. #[serde(default)] pub provider: Option, /// Content hash of the persona at the time this agent was created — the @@ -390,12 +396,15 @@ pub struct ManagedAgentRecord { /// inference through Buzz's relay-mesh local endpoint; the `model_ref` is /// the served model id to route to. `None` is a normal agent. /// - /// This is the source of truth for "is this a mesh agent + which model" — - /// replacing the old practice of sniffing it back out of `env_vars` - /// (`relay_mesh_config`). Spawn-time env vars are *derived from* this, not - /// the other way around. `#[serde(default)]` so pre-existing saved records - /// deserialize as `None` and are resolved via the env-var fallback until - /// they are rewritten with this field. + /// Not the source of truth. `provider == "relay-mesh"` is, resolved through + /// `effective_config::resolve_effective_config`; spawn-time env vars are + /// derived from that resolution. This field is retained solely as a + /// backward-compatibility signal for records written before the record had + /// a `provider` field, and is consulted only for definition-less records + /// that carry no provider — after which the env-var preset is the last + /// fallback. A linked instance's marker is never read: its definition is + /// authoritative. `#[serde(default)]` so records predating the field + /// deserialize as `None`. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_mesh: Option, } @@ -478,7 +487,11 @@ pub struct ManagedAgentSummary { pub system_prompt: Option, pub avatar_url: Option, pub model: Option, - /// LLM inference provider, from the agent's pinned record snapshot. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_source: Option, + /// LLM inference provider, resolved the same way as `model`/`model_source` + /// (definition → global for linked instances; instance → global for + /// definition-less instances). `None` for an orphaned instance. pub provider: Option, /// `true` when the linked persona has been edited since this agent was /// created — the running agent uses the older pinned snapshot. The UI @@ -487,9 +500,11 @@ pub struct ManagedAgentSummary { /// persona is gone, so there is nothing newer to drift toward). pub persona_out_of_date: bool, /// `true` when the agent was created from a persona that no longer exists. - /// Distinct from out-of-date: there is no current persona to respawn into, - /// so the UI should not prompt a respawn — the pinned snapshot is all the - /// config that remains. + /// Distinct from out-of-date: there is no current persona to respawn into. + /// An orphaned agent also cannot be (re)started — `spawn_agent_child` + /// refuses it (see `effective_config::resolve_effective_config`'s + /// `OrphanedInstance` arm via `require_resolved`) — so the UI + /// should surface that it's stuck, not merely stale. pub persona_orphaned: bool, /// `true` when the running process was spawned with a config that no /// longer matches what a spawn would use today — a plain restart would diff --git a/desktop/src/features/agents/lib/agentCardModelLabel.test.mjs b/desktop/src/features/agents/lib/agentCardModelLabel.test.mjs new file mode 100644 index 0000000000..696a055d20 --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardModelLabel.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveAgentCardModelLabel } from "./agentCardModelLabel.ts"; + +test("resolveAgentCardModelLabel — unspawned definition with explicit model renders the model, not inherited", () => { + const label = resolveAgentCardModelLabel({ + agent: undefined, + personaModel: "gpt-5", + defaultModel: "claude-sonnet", + }); + assert.equal(label, "gpt-5"); +}); + +test("resolveAgentCardModelLabel — unspawned definition with no model renders the default", () => { + const label = resolveAgentCardModelLabel({ + agent: undefined, + personaModel: null, + defaultModel: "claude-sonnet", + }); + assert.equal(label, "Default model (claude-sonnet)"); +}); + +test("resolveAgentCardModelLabel — linked instance inheriting the global default ignores stale persona.model", () => { + const label = resolveAgentCardModelLabel({ + agent: { modelSource: "global", model: "stale-model" }, + personaModel: "gpt-5", + defaultModel: "claude-sonnet", + }); + assert.equal(label, "Default model (claude-sonnet)"); +}); + +test("resolveAgentCardModelLabel — linked instance with no modelSource (legacy/unset) is treated as inherited", () => { + const label = resolveAgentCardModelLabel({ + agent: { modelSource: null, model: "stale-model" }, + personaModel: "gpt-5", + defaultModel: "claude-sonnet", + }); + assert.equal(label, "Default model (claude-sonnet)"); +}); + +test("resolveAgentCardModelLabel — linked instance with an explicit resolved model renders that model", () => { + const label = resolveAgentCardModelLabel({ + agent: { modelSource: "definition", model: "gpt-5" }, + personaModel: "should-not-be-used", + defaultModel: "claude-sonnet", + }); + assert.equal(label, "gpt-5"); +}); + +test("resolveAgentCardModelLabel — non-inherited agent with a blank resolved model falls back to the default", () => { + const label = resolveAgentCardModelLabel({ + agent: { modelSource: "instance_legacy", model: " " }, + personaModel: null, + defaultModel: "claude-sonnet", + }); + assert.equal(label, "Default model (claude-sonnet)"); +}); diff --git a/desktop/src/features/agents/lib/agentCardModelLabel.ts b/desktop/src/features/agents/lib/agentCardModelLabel.ts new file mode 100644 index 0000000000..4ec06f10c5 --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardModelLabel.ts @@ -0,0 +1,44 @@ +import { formatAgentModelLabel } from "./formatAgentModelLabel"; +import type { ManagedAgent } from "@/shared/api/types"; + +/** + * Model label for the Agents-page card, shared by the definition-grouped + * card (`AgentPersonaCard`) and the standalone/legacy card + * (`StandaloneAgentCard`). + * + * A materialized `agent` is authoritative once it exists: its `modelSource` + * says whether the *effective* config (from `resolve_effective_config` on + * the backend) came from the global default or from an explicit + * definition/instance value, so the card never has to re-derive that from + * raw model bytes. Absent or `"global"` renders the default-model label; + * anything else renders the agent's own resolved model. + * + * With NO materialized instance yet (a definition that has never been + * started), there is no `modelSource` to read — the definition itself is + * authoritative, so an explicit `personaModel` must render directly rather + * than falling through to "inherited" for lack of an instance. + */ +export function resolveAgentCardModelLabel(input: { + agent: Pick | undefined; + personaModel: string | null | undefined; + defaultModel: string; +}): string { + if (input.agent) { + const isInherited = + !input.agent.modelSource || input.agent.modelSource === "global"; + if (isInherited) { + return formatDefaultModelLabel(input.defaultModel); + } + return input.agent.model?.trim() + ? formatAgentModelLabel(input.agent.model) + : formatDefaultModelLabel(input.defaultModel); + } + return input.personaModel?.trim() + ? formatAgentModelLabel(input.personaModel) + : formatDefaultModelLabel(input.defaultModel); +} + +export function formatDefaultModelLabel(defaultModel: string) { + const model = defaultModel.trim(); + return model ? `Default model (${model})` : "Default model"; +} diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 86a70c3db6..601d57f95d 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -655,30 +655,36 @@ export function AgentInstanceEditDialog({ parsedParallelism > 0 && parsedParallelism !== agent.parallelism ? parsedParallelism : undefined, - // Use tri-state: send null to clear, value to set, omit if unchanged. + // Linked instances defer model/provider/systemPrompt to the definition. systemPrompt: - (systemPrompt.trim() || null) !== agent.systemPrompt - ? systemPrompt.trim() || null - : undefined, + linkedPersona != null + ? undefined + : (systemPrompt.trim() || null) !== agent.systemPrompt + ? systemPrompt.trim() || null + : undefined, model: - normalizedModel !== (agent.model ?? null) - ? normalizedModel - : undefined, + linkedPersona != null + ? undefined + : normalizedModel !== (agent.model ?? null) + ? normalizedModel + : undefined, // Tri-state provider persistence keyed on providerRuntimeCapability: // "capable" → persist: value if changed, omit if unchanged. // "locked" → clear: send null if provider was set, else omit. // "unknown" → omit always (never send null for a transient state). // llmProviderFieldVisible is for UX visibility only; not used here. provider: - providerRuntimeCapability === "capable" - ? normalizedSubmitProvider !== (agent.provider ?? null) - ? normalizedSubmitProvider - : undefined - : providerRuntimeCapability === "locked" - ? (agent.provider ?? null) !== null - ? null + linkedPersona != null + ? undefined + : providerRuntimeCapability === "capable" + ? normalizedSubmitProvider !== (agent.provider ?? null) + ? normalizedSubmitProvider : undefined - : undefined, // "unknown" → omit always + : providerRuntimeCapability === "locked" + ? (agent.provider ?? null) !== null + ? null + : undefined + : undefined, // "unknown" → omit always envVars: envVarsEqual(submitEnvVars, agent.envVars) ? undefined : submitEnvVars, diff --git a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx index 35de0d983d..8412792a2c 100644 --- a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx @@ -211,29 +211,32 @@ export function EditAgentAdvancedFields({ - {/* System prompt override */} -
- -
-